module Page.Login exposing (Model, Msg, init, subscriptions, toSession, update, view) {-| The login page. -} import Api exposing (optionalError) import Browser.Navigation as Nav import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) import Http import Json.Decode as Decode exposing (Decoder, decodeString, field, string) import Json.Decode.Pipeline exposing (optional) import Json.Encode as Encode import Route exposing (Route) import Session exposing (Session) import Validate exposing (Valid, Validator, fromValid, ifBlank, validate) import Viewer exposing (Viewer) import Viewer.Cred as Cred exposing (Cred) -- MODEL type alias Model = { session : Session , errors : List Error , form : Form } type alias Form = { email : String , password : String } init : Session -> ( Model, Cmd msg ) init session = ( { session = session , errors = [] , form = { email = "" , password = "" } } , Cmd.none ) -- VIEW view : Model -> { title : String, content : Html Msg } view model = { title = "Login" , content = div [ class "cred-page" ] [ div [ class "container page" ] [ div [ class "row" ] [ div [ class "col-md-6 offset-md-3 col-xs-12" ] [ h1 [ class "text-xs-center" ] [ text "Sign in" ] , p [ class "text-xs-center" ] [ a [ Route.href Route.Register ] [ text "Need an account?" ] ] , ul [ class "error-messages" ] <| List.map (\( _, error ) -> li [] [ text error ]) model.errors , viewForm model.form ] ] ] ] } viewForm : Form -> Html Msg viewForm form = Html.form [ onSubmit SubmittedForm ] [ fieldset [ class "form-group" ] [ input [ class "form-control form-control-lg" , placeholder "Email" , onInput EnteredEmail , value form.email ] [] ] , fieldset [ class "form-group" ] [ input [ class "form-control form-control-lg" , type_ "password" , placeholder "Password" , onInput EnteredPassword , value form.password ] [] ] , button [ class "btn btn-lg btn-primary pull-xs-right" ] [ text "Sign in" ] ] -- UPDATE type Msg = SubmittedForm | EnteredEmail String | EnteredPassword String | CompletedLogin (Result Http.Error Viewer) | GotSession Session update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of SubmittedForm -> case validate formValidator model.form of Ok validForm -> ( { model | errors = [] } , Http.send CompletedLogin (login validForm) ) Err errors -> ( { model | errors = errors } , Cmd.none ) EnteredEmail email -> updateForm (\form -> { form | email = email }) model EnteredPassword password -> updateForm (\form -> { form | password = password }) model CompletedLogin (Err error) -> let serverErrors = error |> Api.listErrors errorsDecoder |> List.map (\errorMessage -> ( Server, errorMessage )) |> List.append model.errors in ( { model | errors = List.append model.errors serverErrors } , Cmd.none ) CompletedLogin (Ok cred) -> ( model , Session.login cred ) GotSession session -> ( { model | session = session }, Cmd.none ) {-| Helper function for `update`. Updates the form and returns Cmd.none and Ignored. Useful for recording form fields! -} updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg ) updateForm transform model = ( { model | form = transform model.form }, Cmd.none ) -- SUBSCRIPTIONS subscriptions : Model -> Sub Msg subscriptions model = Session.changes GotSession (Session.navKey model.session) -- VALIDATION type ErrorSource = Server | Email | Password {-| Recording validation errors on a per-field basis facilitates displaying them inline next to the field where the error occurred. I implemented it this way out of habit, then realized the spec called for displaying all the errors at the top. I thought about simplifying it, but then figured it'd be useful to show how I would normally model this data - assuming the intended UX was to render errors per field. (The other part of this is having a view function like this: viewFormErrors : Field -> List Error -> Html msg ...and it filters the list of errors to render only the ones for the given Field. This way you can call this: viewFormErrors Email model.errors ...next to the `email` field, and call `viewFormErrors Password model.errors` next to the `password` field, and so on. -} type alias Error = ( ErrorSource, String ) formValidator : Validator Error Form formValidator = Validate.all [ ifBlank .email ( Email, "email can't be blank." ) , ifBlank .password ( Password, "password can't be blank." ) ] errorsDecoder : Decoder (List String) errorsDecoder = Decode.succeed (\emailOrPassword email username password -> List.concat [ emailOrPassword, email, username, password ]) |> optionalError "email or password" |> optionalError "email" |> optionalError "username" |> optionalError "password" -- HTTP login : Valid Form -> Http.Request Viewer login validForm = let form = fromValid validForm user = Encode.object [ ( "email", Encode.string form.email ) , ( "password", Encode.string form.password ) ] body = Encode.object [ ( "user", user ) ] |> Http.jsonBody in Decode.field "user" Viewer.decoder |> Http.post (Api.url [ "users", "login" ]) body -- EXPORT toSession : Model -> Session toSession model = model.session