module Route.TailwindForm exposing (Data, Model, Msg, page) import Browser.Dom import Css exposing (Color) import Css.Global import DataSource exposing (DataSource) import Date exposing (Date) import Dict exposing (Dict) import Form exposing (Form) import Form.Value import Head import Head.Seo as Seo import Html as CoreHtml import Html.Events import Html.Styled as Html exposing (Html) import Html.Styled.Attributes as Attr exposing (css) import Http import Icon import Pages.PageUrl exposing (PageUrl) import Pages.Url import RouteBuilder exposing (StatefulRoute, StatelessRoute, StaticPayload) import Server.Request as Request exposing (Request) import Server.Response as Response exposing (Response) import Shared import Tailwind.Breakpoints as Bp import Tailwind.Utilities as Tw import Task import Time import View exposing (View) type alias Model = { form : Form.Model , flashMessage : Maybe (Result String String) } type Msg = FormMsg Form.Msg | GotFormResponse (Result Http.Error Form.ServerUpdate) | MovedToTop type alias RouteParams = {} type alias User = { first : String , last : String , username : String , email : String , birthDay : Date , checkIn : Date , checkOut : Date , rating : Int , password : ( String, String ) , notificationPreferences : NotificationPreferences } type alias NotificationPreferences = { comments : Bool , candidates : Bool , offers : Bool , pushNotificationsSetting : PushNotificationsSetting } defaultUser : User defaultUser = { first = "jane" , last = "Doe" , username = "janedoe" , email = "janedoe@example.com" , birthDay = Date.fromCalendarDate 1969 Time.Jul 20 , checkIn = Date.fromCalendarDate 2022 Time.Jan 11 , checkOut = Date.fromCalendarDate 2022 Time.Jan 12 , rating = 5 , password = ( "", "" ) , notificationPreferences = { comments = False , candidates = False , offers = False , pushNotificationsSetting = PushNone } } styleAttrs attrs = List.map Attr.fromUnstyled attrs usernameInput ({ toInput, toLabel, errors, submitStatus } as info) = Html.div [] [ Html.div [ css [ Bp.sm [ Tw.grid , Tw.grid_cols_3 , Tw.gap_4 , Tw.items_start , Tw.border_t , Tw.border_gray_200 , Tw.pt_5 ] ] ] [ Html.label (styleAttrs toInput ++ [ Attr.for "username" , css [ Tw.block , Tw.text_sm , Tw.font_medium , Tw.text_gray_700 , Bp.sm [ Tw.mt_px , Tw.pt_2 ] ] ] ) [ Html.text "Username" ] , Html.div [ css [ Tw.mt_1 , Bp.sm [ Tw.mt_0 , Tw.col_span_2 ] ] ] [ Html.div [ css [ Tw.max_w_lg , Tw.flex , Tw.rounded_md , Tw.shadow_sm , Tw.relative ] ] [ Html.span [ css [ Tw.inline_flex , Tw.items_center , Tw.px_3 , Tw.rounded_l_md , Tw.border , Tw.border_r_0 , Tw.border_gray_300 , Tw.bg_gray_50 , Tw.text_gray_500 , Bp.sm [ Tw.text_sm ] ] ] [ Html.text "workcation.com/" ] , Html.input (styleAttrs toInput ++ [ Attr.type_ "text" , Attr.name "username" , Attr.id "username" , Attr.attribute "autocomplete" "username" , css [ Tw.flex_1 , Tw.block , Tw.w_full , Tw.min_w_0 , Tw.rounded_none , Tw.rounded_r_md , Tw.border_gray_300 , Css.focus [ Tw.ring_indigo_500 , Tw.border_indigo_500 ] , Bp.sm [ Tw.text_sm ] ] ] ) [] , Html.div [ css [ Tw.absolute , Tw.inset_y_0 , Tw.right_0 , Tw.pr_3 , Tw.flex , Tw.items_center , Tw.pointer_events_none ] ] [ if errors |> List.isEmpty then Html.text "" else Icon.error ] ] ] ] , errorsView info ] validateCapitalized : String -> Result String String validateCapitalized string = if string |> String.toList |> List.head |> Maybe.withDefault 'a' |> Char.isUpper then Ok string else Err "Needs to be capitalized" form : User -> Form String User (Html Form.Msg) form user = Form.succeed User |> Form.with (Form.text "first" (textInput "First name") |> Form.required "Required" |> Form.withInitialValue (user.first |> Form.Value.string) |> Form.withClientValidation validateCapitalized ) |> Form.with (Form.text "last" (textInput "Last name") |> Form.required "Required" |> Form.withInitialValue (user.last |> Form.Value.string) |> Form.withClientValidation validateCapitalized ) |> Form.with (Form.text "username" usernameInput |> Form.withInitialValue (user.username |> Form.Value.string) |> Form.required "Required" |> Form.withServerValidation (\username -> if username == "asdf" then DataSource.succeed [ "username is taken" ] else DataSource.succeed [] ) |> Form.withClientValidation2 (\username -> Ok ( username , if username |> String.contains "@" then [ "Cannot contain @ symbol" ] else [] ) ) |> Form.withClientValidation2 (\username -> Ok ( username , if username |> String.contains "#" then [ "Cannot contain # symbol" ] else [] ) ) |> Form.withClientValidation2 (\username -> Ok ( username , if (username |> String.length) < 3 then [ "Must be at least 3 characters long" ] else [] ) ) ) |> Form.with (Form.text "email" (textInput "Email address") |> Form.withInitialValue (user.email |> Form.Value.string) |> Form.email |> Form.required "Required" ) |> Form.with (Form.date "dob" { invalid = \_ -> "Invalid date" } (textInput "Date of Birth") |> Form.required "Required" |> Form.withInitialValue (user.birthDay |> Form.Value.date) |> Form.withMin (Date.fromCalendarDate 1900 Time.Jan 1 |> Form.Value.date) |> Form.withMax (Date.fromCalendarDate 2022 Time.Jan 1 |> Form.Value.date) |> Form.withServerValidation (\birthDate -> let _ = birthDate |> Date.toIsoString in if birthDate == Date.fromCalendarDate 1969 Time.Jul 20 then DataSource.succeed [ "No way, that's when the moon landing happened!" ] else DataSource.succeed [] ) ) |> Form.with (Form.date "checkin" { invalid = \_ -> "Invalid date" } (textInput "Check-in") |> Form.required "Required" |> Form.withInitialValue (user.checkIn |> Form.Value.date) |> Form.withMin (Date.fromCalendarDate 1900 Time.Jan 1 |> Form.Value.date) |> Form.withMax (Date.fromCalendarDate 2022 Time.Jan 1 |> Form.Value.date) ) |> Form.with (Form.date "checkout" { invalid = \_ -> "Invalid date" } (textInput "Check-out") |> Form.required "Required" |> Form.withInitialValue (user.checkOut |> Form.Value.date) |> Form.withMin (Date.fromCalendarDate 1900 Time.Jan 1 |> Form.Value.date) |> Form.withMax (Date.fromCalendarDate 2022 Time.Jan 1 |> Form.Value.date) ) |> Form.with (Form.range "rating" { missing = "Required" , invalid = \_ -> "Invalid number" } { initial = 3 , min = 1 , max = 5 } (textInput "Rating") ) |> Form.wrap wrapSection |> Form.appendForm (|>) (Form.succeed Tuple.pair |> Form.with (Form.text "password" (textInput "Password") |> Form.password |> Form.required "Required" ) |> Form.with (Form.text "password-confirmation" (textInput "Password Confirmation") |> Form.password |> Form.required "Required" ) |> Form.validate (\( password, passwordConfirmation ) -> if password == passwordConfirmation then [] else [ ( "password-confirmation" , [ "Must match password" ] ) ] ) ) |> Form.appendForm (|>) ((Form.succeed NotificationPreferences |> Form.with (Form.checkbox "comments" --user.checkbox False (checkboxInput { name = "Comments", description = "Get notified when someones posts a comment on a posting." }) ) |> Form.with (Form.checkbox "candidates" --user.checkbox False (checkboxInput { name = "Candidates", description = "Get notified when a candidate applies for a job." }) ) |> Form.with (Form.checkbox "offers" --user.checkbox False (checkboxInput { name = "Offers", description = "Get notified when a candidate accepts or rejects an offer." }) ) |> Form.wrap wrapEmailSection |> Form.appendForm (|>) (Form.succeed identity |> Form.with (Form.radio "push-notifications" "Invalid option" ( ( "PushAll", PushAll ) , [ ( "PushEmail", PushEmail ) , ( "PushNone", PushNone ) ] ) radioInput wrapPushNotificationsSection |> Form.required "Please select your notification preference." ) ) ) |> Form.wrap wrapNotificationsSections ) |> Form.appendForm (\() rest -> rest) (Form.succeed identity |> Form.with (Form.checkbox "acceptTerms" False (checkboxInput { name = "Accept terms", description = "Please read the terms before proceeding." }) |> Form.withClientValidation2 (\checked -> if checked then Ok ( (), [] ) else Ok ( (), [ "Please agree to terms to proceed." ] ) ) ) ) |> Form.append (Form.submit (\{ attrs, formHasErrors } -> Html.div [ css [ Tw.pt_5 ] ] [ Html.div [ css [ Tw.flex , Tw.justify_end ] ] [ cancelButton , saveButton formHasErrors attrs ] ] ) ) |> Form.validate (\user_ -> [ ( "checkin" , if Date.compare user_.checkIn user_.checkOut == GT then [ "Must be before checkout." ] else [] ) ] ) type PushNotificationsSetting = PushAll | PushEmail | PushNone saveButton formHasErrors formAttrs = Html.button (styleAttrs formAttrs ++ [ css [ Tw.ml_3 , Tw.inline_flex , Tw.justify_center , Tw.py_2 , Tw.px_4 , Tw.border , Tw.border_transparent , Tw.shadow_sm , Tw.text_sm , Tw.font_medium , Tw.rounded_md , Tw.text_white , Tw.bg_indigo_600 , Css.focus [ Tw.outline_none , Tw.ring_2 , Tw.ring_offset_2 , Tw.ring_indigo_500 ] , --if formHasErrors then -- Css.batch -- [ Tw.text_gray_200 -- , Tw.bg_indigo_500 -- , Tw.cursor_default -- ] -- -- else Css.hover [ Tw.bg_indigo_700 ] ] ] ) [ Html.text "Save" ] cancelButton : Html msg cancelButton = Html.button [ Attr.type_ "button" , css [ Tw.bg_white , Tw.py_2 , Tw.px_4 , Tw.border , Tw.border_gray_300 , Tw.rounded_md , Tw.shadow_sm , Tw.text_sm , Tw.font_medium , Tw.text_gray_700 , Css.focus [ Tw.outline_none , Tw.ring_2 , Tw.ring_offset_2 , Tw.ring_indigo_500 ] , Css.hover [ Tw.bg_gray_50 ] ] ] [ Html.text "Cancel" ] page : StatefulRoute RouteParams Data Model Msg page = RouteBuilder.serverRender { head = head , data = data } |> RouteBuilder.buildWithLocalState { view = view , update = update , init = init , subscriptions = \_ _ _ _ _ -> Sub.none } update _ _ _ _ msg model = case msg of FormMsg formMsg -> model.form |> Form.update FormMsg GotFormResponse (form defaultUser) formMsg |> Tuple.mapFirst (\newFormModel -> { model | form = newFormModel }) GotFormResponse result -> case result of Ok updatedFormModel -> if Form.hasErrors2 model.form then ( model, Cmd.none ) |> withFlash (Err "Failed to submit or had errors") else ( model, Browser.Dom.setViewport 0 0 |> Task.perform (\() -> MovedToTop) ) |> withFlash (Ok "Success! Submitted form from Elm") Err _ -> ( model, Cmd.none ) MovedToTop -> ( model, Cmd.none ) withFlash : Result String String -> ( Model, Cmd Msg ) -> ( Model, Cmd Msg ) withFlash flashMessage ( model, cmd ) = ( { model | flashMessage = Just flashMessage }, cmd ) init _ _ static = ( { form = static.data.initialForm , flashMessage = static.data.user |> Maybe.map (\user_ -> Ok ("Successfully received user " ++ user_.first ++ " " ++ user_.last) ) } , Cmd.none ) type alias Data = { user : Maybe User , initialForm : Form.Model } data : RouteParams -> Request (DataSource (Response Data)) data routeParams = Request.oneOf [ Form.submitHandlers (form defaultUser) (\model decoded -> DataSource.succeed { user = Result.toMaybe decoded , initialForm = model } ) , { user = Nothing , initialForm = Form.init (form defaultUser) } |> Response.render |> DataSource.succeed |> Request.succeed ] head : StaticPayload Data RouteParams -> List Head.Tag head static = Seo.summary { canonicalUrlOverride = Nothing , siteName = "elm-pages" , image = { url = Pages.Url.external "TODO" , alt = "elm-pages logo" , dimensions = Nothing , mimeType = Nothing } , description = "TODO" , locale = Nothing , title = "TODO title" -- metadata.title -- TODO } |> Seo.website wrapSection : List (Html msg) -> Html msg wrapSection children = Html.div [] [ Html.div [] [ Html.h3 [ css [ Tw.text_lg , Tw.leading_6 , Tw.font_medium , Tw.text_gray_900 ] ] [ Html.text "Profile" ] , Html.p [ css [ Tw.mt_1 , Tw.max_w_2xl , Tw.text_sm , Tw.text_gray_500 ] ] [ Html.text "This information will be displayed publicly so be careful what you share." ] ] , Html.div [ css [ Tw.mt_6 , Tw.space_y_6 , Bp.sm [ Tw.mt_5 , Tw.space_y_5 ] ] ] children ] formModelView formModel = formModel |> Debug.toString |> Html.text |> List.singleton |> Html.pre [ Attr.style "white-space" "break-spaces" ] view : Maybe PageUrl -> Shared.Model -> Model -> StaticPayload Data RouteParams -> View Msg view maybeUrl sharedModel model static = let user : User user = static.data.user |> Maybe.withDefault defaultUser in { title = "Form Example" , body = [ Html.div [] [ Css.Global.global Tw.globalStyles , model.flashMessage |> Maybe.map flashView |> Maybe.withDefault (Html.p [] []) , Html.p [] [ if Form.isSubmitting model.form then Html.text "Submitting..." else Html.text "" ] , Html.div [ css [ Tw.flex , Tw.flex_col , Tw.items_center , Tw.mt_8 , Tw.border_gray_700 , Tw.rounded_lg ] ] [ form user |> Form.toHtml { pageReloadSubmit = False } (\attrs children -> Html.form (List.map Attr.fromUnstyled attrs) children) model.form |> Html.map FormMsg ] ] |> Html.toUnstyled ] } successColor : Color successColor = Css.rgb 163 251 163 errorColor : Color errorColor = Css.rgb 251 163 163 flashView : Result String String -> Html msg flashView message = Html.p [ css [ Css.backgroundColor (case message of Ok _ -> successColor Err _ -> errorColor ) , Tw.p_4 ] ] [ Html.text <| case message of Ok okMessage -> okMessage Err error -> "Something went wrong: " ++ error ] textInput labelText ({ toInput, toLabel, errors, submitStatus } as info) = Html.div [ css [ Bp.sm [ Tw.grid , Tw.grid_cols_3 , Tw.gap_4 , Tw.items_start , Tw.border_t , Tw.border_gray_200 , Tw.pt_5 ] ] ] [ --Html.text (Debug.toString submitStatus), Html.span [ css [ Tw.font_bold ] ] [ Html.text (Debug.toString info.status) ] , Html.label ([ css [ Tw.block , Tw.text_sm , Tw.font_medium , Tw.text_gray_700 , Bp.sm [ Tw.mt_px , Tw.pt_2 ] ] ] ++ styleAttrs toLabel ) [ Html.text labelText ] , Html.div [ css [ Tw.mt_1 , Bp.sm [ Tw.mt_0 , Tw.col_span_2 ] ] ] [ Html.input (styleAttrs toInput ++ [ --Attr.attribute "autocomplete" "given-name", css [ Tw.max_w_lg , Tw.block , Tw.w_full , Tw.shadow_sm , Tw.border_gray_300 , Tw.rounded_md , Css.focus [ Tw.ring_indigo_500 , Tw.border_indigo_500 ] , Bp.sm [ Tw.max_w_xs , Tw.text_sm ] ] ] ) [] ] , errorsView info ] errorsView : { a | errors : List String, submitStatus : Form.SubmitStatus, status : Form.FieldStatus } -> Html msg errorsView { errors, submitStatus, status } = let showErrors : Bool showErrors = --(status |> Form.isAtLeast Form.Focused) || submitStatus == Form.Submitting || submitStatus == Form.Submitted True in Html.ul [ css [ Tw.mt_2 , Tw.text_sm , Tw.text_red_600 ] ] (if showErrors then errors |> List.map (\error -> Html.li [ css [ Tw.list_disc ] ] [ Html.text error ] ) else [] ) checkboxInput { name, description } ({ toLabel, toInput, errors } as info) = Html.div [ css [ Tw.max_w_lg , Tw.space_y_4 ] ] [ Html.div [ css [ Tw.relative , Tw.flex , Tw.items_start ] ] [ Html.div [ css [ Tw.flex , Tw.items_center , Tw.h_5 ] ] [ Html.input (styleAttrs toInput ++ [ css [ Tw.h_4 , Tw.w_4 , Tw.text_indigo_600 , Tw.border_gray_300 , Tw.rounded , Css.focus [ Tw.ring_indigo_500 ] ] ] ) [] ] , Html.div [ css [ Tw.ml_3 , Tw.text_sm ] ] [ Html.label (styleAttrs toLabel ++ [ css [ Tw.font_medium , Tw.text_gray_700 ] ] ) [ Html.text name ] , Html.p [ css [ Tw.text_gray_500 ] ] [ Html.text description ] ] ] , errorsView info ] wrapNotificationsSections children = Html.div [ css [ Tw.divide_y , Tw.divide_gray_200 , Tw.pt_8 , Tw.space_y_6 , Bp.sm [ Tw.pt_10 , Tw.space_y_5 ] ] ] [ Html.div [] [ Html.h3 [ css [ Tw.text_lg , Tw.leading_6 , Tw.font_medium , Tw.text_gray_900 ] ] [ Html.text "Notifications" ] , Html.p [ css [ Tw.mt_1 , Tw.max_w_2xl , Tw.text_sm , Tw.text_gray_500 ] ] [ Html.text "We'll always let you know about important changes, but you pick what else you want to hear about." ] ] , Html.div [ css [ Tw.space_y_6 , Tw.divide_y , Tw.divide_gray_200 , Bp.sm [ Tw.space_y_5 ] ] ] children ] wrapEmailSection children = Html.div [ css [ Tw.pt_6 , Bp.sm [ Tw.pt_5 ] ] ] [ Html.div [ Attr.attribute "role" "group" , Attr.attribute "aria-labelledby" "label-email" ] [ Html.div [ css [ Bp.sm [ Tw.grid , Tw.grid_cols_3 , Tw.gap_4 , Tw.items_baseline ] ] ] [ Html.div [] [ Html.div [ css [ Tw.text_base , Tw.font_medium , Tw.text_gray_900 , Bp.sm [ Tw.text_sm , Tw.text_gray_700 ] ] , Attr.id "label-email" ] [ Html.text "By Email" ] ] , Html.div [ css [ Tw.mt_4 , Bp.sm [ Tw.mt_0 , Tw.col_span_2 ] ] ] [ Html.div [ css [ Tw.max_w_lg , Tw.space_y_4 ] ] children ] ] ] ] radioInput item { toLabel, toInput, errors, status } = Html.div [ css [ Tw.flex , Tw.items_center ] ] [ Html.input (styleAttrs toInput ++ [ css [ Tw.h_4 , Tw.w_4 , Tw.text_indigo_600 , Tw.border_gray_300 , Css.focus [ Tw.ring_indigo_500 ] ] ] ) [] , Html.label (styleAttrs toLabel ++ [ css [ Tw.ml_3 , Tw.block , Tw.text_sm , Tw.font_medium , Tw.text_gray_700 ] ] ) [ (case item of PushAll -> "Everything" PushEmail -> "Same as email" PushNone -> "No push notifications" ) |> Html.text ] ] wrapPushNotificationsSection ({ errors, submitStatus } as info) children = Html.div [ css [ Tw.pt_6 , Bp.sm [ Tw.pt_5 ] ] ] [ Html.div [ Attr.attribute "role" "group" , Attr.attribute "aria-labelledby" "label-notifications" ] [ Html.span [ css [ Tw.font_bold ] ] [ Html.text (Debug.toString info.status) ] , Html.div [ css [ Bp.sm [ Tw.grid , Tw.grid_cols_3 , Tw.gap_4 , Tw.items_baseline ] ] ] [ Html.div [] [ Html.div [ css [ Tw.text_base , Tw.font_medium , Tw.text_gray_900 , Bp.sm [ Tw.text_sm , Tw.text_gray_700 ] ] , Attr.id "label-notifications" ] [ Html.text "Push Notifications" ] ] , Html.div [ css [ Bp.sm [ Tw.col_span_2 ] ] ] [ Html.div [ css [ Tw.max_w_lg ] ] [ Html.p [ css [ Tw.text_sm , Tw.text_gray_500 ] ] [ Html.text "These are delivered via SMS to your mobile phone." ] , Html.div [ css [ Tw.mt_4 , Tw.space_y_4 ] ] children ] ] ] ] , errorsView info ]