![](/style/images/good.png)
![](/style/images/bad.png)
Elixir API and Elm SPA - Part 4
source link: https://www.tuicool.com/articles/hit/nEnuyiJ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Part 4: Adding Login and Register pages
We are going to add a Login and Register pages to the app.
Series
- Part 1 - Elixir App creation
- Part 2 - Adds Guardian Authentication
- Part 3 - Elm App creation and Routing setup
- Part 4 - Adding Login and Register pages
Add CORS to the backend api
Before we can start sending requests to our backend api, we need to enable CORS in order to our backend to allow the request going through. So go to the toltec-api web and add a new dependency to the mix.exs file:
# mix.exs defp deps do [ {:phoenix, "~> 1.3.0"}, {:phoenix_pubsub, "~> 1.0"}, {:phoenix_ecto, "~> 3.2"}, {:postgrex, ">= 0.0.0"}, {:gettext, "~> 0.11"}, {:cowboy, "~> 1.0"}, {:comeonin, "~> 4.0"}, {:argon2_elixir, "~> 1.2"}, {:guardian, "~> 1.0"}, {:cors_plug, "~> 1.4"} ] end
Then add this to the endpoint.ex, just before the router plug
# lib/toltec_web/endpoint.ex plug(CORSPlug) plug(ToltecWeb.Router)
Get the dependencies with mix deps.get and restart the toltec-api app. We should be ready to consume the API.
Add Elm dependencies
We are going to need some external packages to build our app. Add them:
elm-app install NoRedInk/elm-decode-pipeline elm-app install elm-community/json-extra elm-app install elm-lang/http elm-app install rtfeldman/elm-validate
Create the User model
We also need a User model. Add a new User/ directory and create a Model.elm
-- src/User/Model.elm module User.Model exposing (User, decoder, encode) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (decode, required, optional) import Json.Encode as Encode exposing (Value) import Json.Encode.Extra as Extra exposing (maybe) import Util exposing ((=>)) type alias User = { email : String , name : Maybe String } decoder : Decoder User decoder = decode User |> required "email" Decode.string |> required "name" (Decode.nullable Decode.string) encode : User -> Value encode user = Encode.object [ "email" => Encode.string user.email , "name" => (Extra.maybe Encode.string) user.name ]
Nothing very strange here. Just a model a pair encode/decoder functions to serialize/deserialize this model.
Create the Session model
Create a Model.elm file inside the Session/ directory
-- src/Session/Model.elm module Session.Model exposing (Session, decoder, encode) import Session.AuthToken as AuthToken exposing (AuthToken, decoder) import User.Model as User exposing (User, decoder) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (decode, required, optional) import Json.Encode as Encode exposing (Value) import Util exposing ((=>)) type alias Session = { user : User , token : AuthToken } decoder : Decoder Session decoder = decode Session |> required "user" User.decoder |> required "token" AuthToken.decoder encode : Session -> Value encode session = Encode.object [ "user" => User.encode session.user , "token" => AuthToken.encode session.token ]
The session is a type with a user and a token, the token we get from our backend API and that we need to send each time we do a request to the REST API.
The AuthToken.elm is this:
-- src/Session/AuthToken.elm module Session.AuthToken exposing (AuthToken, decoder, encode) import Json.Decode as Decode exposing (Decoder) import Json.Encode as Encode exposing (Value) type AuthToken = AuthToken String encode : AuthToken -> Value encode (AuthToken token) = Encode.string token decoder : Decoder AuthToken decoder = Decode.string |> Decode.map AuthToken
The AuthToken is very simple and it is a tagged type to hold the token we get on successful login.
Create the Login page
The Login is a direct copy of Richard's Login page. The idea is this: you have a self-contained module that does it own internal model-update-view cycle. This will be driven from the outer update function we already have. This internal cycle will communicate with the outer one by returning a tuple with a message indicating what happened. This message is ExternalMsg. Other than this, the inner cycle knows nothing about how is used.
Let's start by adding the model to the Login.elm file:
-- src/Session/Login.elm -- MODEL -- type alias Model = { errors : List Error , email : String , password : String } initialModel : Model initialModel = { errors = [] , email = "" , password = "" }
It is very simple, we have an email and a password and a list of validation errors to show to the user.
Now replace our current view in the same file with this:
-- src/Session/Login.elm -- VIEW -- view : Model -> Html Msg view model = div [ class "mt4 mt6-l pa4" ] [ h1 [] [ text "Sign in" ] , div [ class "measure center" ] [ Form.viewErrors model.errors , viewForm ] ] viewForm : Html Msg viewForm = Html.form [ onSubmit SubmitForm ] [ Form.input "Email" [ onInput SetEmail ] [] , Form.password "Password" [ onInput SetPassword ] [] , button [ class "b ph3 pv2 input-reset ba b--black bg-transparent grow pointer f6" ] [ text "Sign in" ] ]
Add the messages:
-- src/Session/Login.elm -- MESSAGES -- type Msg = SubmitForm | SetEmail String | SetPassword String | LoginCompleted (Result Http.Error Session) type ExternalMsg = NoOp | SetSession Session
The main thing here is the ExternalMsg that will be used to communicate with the outer update function.
Add the Login's internal update function. Pay attention to the returning type that includes the ExternalMsg to signalling important things to the outer world.
-- src/Session/Login.elm -- UPDATE -- update : Msg -> Model -> ( ( Model, Cmd Msg ), ExternalMsg ) update msg model = case msg of SubmitForm -> case validate modelValidator model of [] -> { model | errors = [] } => Http.send LoginCompleted (login model) => NoOp errors -> { model | errors = errors } => Cmd.none => NoOp SetEmail email -> { model | email = email } => Cmd.none => NoOp SetPassword password -> { model | password = password } => Cmd.none => NoOp LoginCompleted (Err error) -> let errorMessages = case error of Http.BadStatus response -> response.body |> decodeString (field "errors" errorsDecoder) |> Result.withDefault [] _ -> [ "unable to perform login" ] in { model | errors = List.map (\errorMessage -> Form => errorMessage) errorMessages } => Cmd.none => NoOp LoginCompleted (Ok session) -> model => Route.modifyUrl Route.Home => SetSession session
We need to validate that the form is correct. Add it:
-- src/Session/Login.elm -- VALIDATION -- type Field = Form | Email | Password type alias Error = ( Field, String ) modelValidator : Validator Error Model modelValidator = Validate.all [ ifBlank .email (Email => "email can't be blank.") , ifBlank .password (Password => "password can't be blank.") ]
We are using a decoder to extract the errors from the JSON response that the backend sends to us.
-- src/Session/Login.elm -- DECODERS -- errorsDecoder : Decoder (List String) errorsDecoder = decode (\email password error -> error :: List.concat [ email, password ]) |> optionalFieldError "email" |> optionalFieldError "password" |> optionalError "error"
As you can see, it looks for errors under the email, password or a generic error attributes.
Your imports should be like this:
-- src/Session/Login.elm module Session.Login exposing (ExternalMsg(..), Model, Msg, initialModel, update, view) import Helpers.Decode exposing (optionalError, optionalFieldError) import Helpers.Form as Form 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 (decode) import Route exposing (Route) import Session.Model exposing (Session) import Session.Request exposing (login) import Util exposing ((=>)) import Validate exposing (Validator, ifBlank, validate)
We are using some helper functions here. Create a new Helpers/ directory and add these files:
-- src/Helpers/Decode.elm module Helpers.Decode exposing (optionalError, optionalFieldError) import Json.Decode as Decode exposing (Decoder, decodeString, field, string) import Json.Decode.Pipeline exposing (decode, optional) optionalError : String -> Decoder (String -> a) -> Decoder a optionalError fieldName = optional fieldName string "" optionalFieldError : String -> Decoder (List String -> a) -> Decoder a optionalFieldError fieldName = let errorToString errorMessage = String.join " " [ fieldName, errorMessage ] in optional fieldName (Decode.list (Decode.map errorToString string)) []
These couple of functions simplifies the handling of missing or optional attributes in a JSON response.
-- src/Helpers/Form.elm module Helpers.Form exposing (input, password, textarea, viewErrors) import Html exposing (Attribute, Html, fieldset, li, text, ul, label) import Html.Attributes as Attr exposing (class, type_, name) password : String -> List (Attribute msg) -> List (Html msg) -> Html msg password name attrs = control Html.input name ([ type_ "password" ] ++ attrs) input : String -> List (Attribute msg) -> List (Html msg) -> Html msg input name attrs = control Html.input name ([ type_ "text" ] ++ attrs) textarea : String -> List (Attribute msg) -> List (Html msg) -> Html msg textarea name = control Html.textarea name viewErrors : List ( a, String ) -> Html msg viewErrors errors = errors |> List.map (\( _, error ) -> li [ class "dib" ] [ text error ]) |> ul [ class "ph2 tl f6 red measure" ] control : (List (Attribute msg) -> List (Html msg) -> Html msg) -> String -> List (Attribute msg) -> List (Html msg) -> Html msg control element name attributes children = fieldset [ class "ba b--transparent ph0 mh0 f6" ] [ label [ class "db fw6 lh-copy tl" ] [ text name ] , element ([ Attr.name name, class "pa2 input-reset ba bg-transparent w-100" ] ++ attributes) children ]
This has some handy functions to create input fields for forms in our app.
-- src/Helpers/Request.elm module Helpers.Request exposing (apiUrl) apiUrl : String -> String apiUrl str = "http://localhost:4000/api" ++ str
Let's continue. There is something else missing here: the part where the request is done. On the SubmitForm branch inside the update function we're using Http.send to send a request to the backend and instruct it to tag the response with the LoginCompleted message. This is the definition of the request:
-- src/Session/Request.elm module Session.Request exposing (login) import Session.AuthToken as AuthToken exposing (AuthToken) import User.Model as User exposing (User) import Session.Model as Session exposing (Session) import Http import Json.Decode as Decode exposing (Decoder) import Json.Encode as Encode import Helpers.Request exposing (apiUrl) import Util exposing ((=>)) login : { r | email : String, password : String } -> Http.Request Session login { email, password } = let user = Encode.object [ "email" => Encode.string email , "password" => Encode.string password ] body = user |> Http.jsonBody in decodeSessionResponse |> Http.post (apiUrl "/sessions") body decodeSessionResponse : Decoder Session decodeSessionResponse = Decode.map2 Session (Decode.field "data" User.decoder) (Decode.at [ "meta", "token" ] AuthToken.decoder)
The login message receives an extensible record with email and password and builds a JSON body with them. This body is then POSTed to the "/sessions" path on the apiUrl.
We are also instructing the Http.post function to use the decodeSessionResponse to decode the response we get from the API and map it to a Session type, using the appropriate decoders for each subpart of the response. As we saw in the previous parts, the user data comes in the "data" property and the JWT in the "token" attribute below the "meta" attribute.
Summarizing, the Http.post will give us a Session type if the response of the API is successful. The Http.send will tag with LoginCompleted the result of the request. If it is ok, it will be the Session type, if it is not, it will be a error string.
We match for those two cases in the update function as you can see.
Modify the Model.elm to use the session we just created:
-- src/Model.elm import Session.Login as Login import Session.Model as Session exposing (Session) -- ... type Page = Blank | NotFound | Home | Login Login.Model | Register -- ... type alias Model = { session : Maybe Session , pageState : PageState } initialModel : Value -> Model initialModel val = { session = Nothing , pageState = Loaded Blank }
Add the LoginMsg message to Messages.elm
-- src/Messages.elm import Session.Login as Login -- ... type Msg = SetRoute (Maybe Route) | LoginMsg Login.Msg
Change the updateRoute function in Update.elm:
-- src/Update.elm -- ... import Session.Login as Login -- ... -- From this Just Route.Login -> { model | pageState = Loaded Login } => Cmd.none -- To this Just Route.Login -> { model | pageState = Loaded (Login Login.initialModel) } => Cmd.none
Add the new branches to the updatePage function:
-- src/Update.elm updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg ) updatePage page msg model = case ( msg, page ) of ( SetRoute route, _ ) -> updateRoute route model ( LoginMsg subMsg, Login subModel ) -> let ( ( pageModel, cmd ), msgFromPage ) = Login.update subMsg subModel newModel = case msgFromPage of Login.NoOp -> model Login.SetSession session -> { model | session = Just session } in { newModel | pageState = Loaded (Login pageModel) } => Cmd.map LoginMsg cmd ( _, NotFound ) -> -- Disregard incoming messages when we're on the -- NotFound page. model => Cmd.none ( _, _ ) -> -- Disregard incoming messages that arrived for the wrong page model => Cmd.none
Now change the viewPage function in View.elm
-- src/View.elm -- ... import Session.Login as Login -- ... -- From this Login -> Login.view |> frame Page.Login -- To this Login subModel -> Login.view subModel |> frame Page.Login |> Html.map LoginMsg
And the pageSubscriptions function in Subscriptions.elm
-- src/Subscriptions.elm -- From this Login -> Sub.none -- To this Login _ -> Sub.none
That's it, the Login page should work now. elm-app start your app and you should see no errors. If you go to the browser you should see something like this:
And after logging in with our seed user (email: user@toltec, password: user@toltec) you should see the home page:
Create the Register page
Let's create the register page. This is almost identical to the login page so we're not going to enter into a lot of detail here.
Start by modifying the model
-- src/Model.elm import Session.Register as Register -- ... type Page = Blank | NotFound | Home | Login Login.Model | Register Register.Model
The messages
-- src/Messages.elm -- ... import Session.Register as Register type Msg = SetRoute (Maybe Route) | LoginMsg Login.Msg | RegisterMsg Register.Msg
And subscriptions
-- src/Subscriptions.elm -- From this Register -> Sub.none -- To this Register _ -> Sub.none
The updateRoute function
-- src/Update.elm -- ... import Session.Register as Register -- ... -- From this Just Route.Register -> { model | pageState = Loaded Register } => Cmd.none -- To this Just Route.Register -> { model | pageState = Loaded (Register Register.initialModel) } => Cmd.none
And the updatePage function
-- src/Update.elm updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg ) updatePage page msg model = case ( msg, page ) of -- .. ( RegisterMsg subMsg, Register subModel ) -> let ( ( pageModel, cmd ), msgFromPage ) = Register.update subMsg subModel newModel = case msgFromPage of Register.NoOp -> model Register.SetSession session -> { model | session = Just session } in { newModel | pageState = Loaded (Register pageModel) } => Cmd.map RegisterMsg cmd -- ..
The view
-- src/View.elm -- From this Register -> Register.view |> frame Page.Register -- To this Register subModel -> Register.view subModel |> frame Page.Register |> Html.map RegisterMsg
And we need a new request to point to the create user API endpoint
-- src/Session/Request.elm module Session.Request exposing (login, register) -- .. register : { r | name : String, email : String, password : String } -> Http.Request Session register { name, email, password } = let user = Encode.object [ "name" => Encode.string name , "email" => Encode.string email , "password" => Encode.string password ] body = user |> Http.jsonBody in decodeSessionResponse |> Http.post (apiUrl "/users") body
Finally the whole Register.elm is this
-- src/Session/Register.elm module Session.Register exposing (ExternalMsg(..), Model, Msg, initialModel, update, view) import Helpers.Decode exposing (optionalError, optionalFieldError) import Helpers.Form as Form 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 (decode) import Route exposing (Route) import Session.Model exposing (Session) import Session.Request exposing (register) import Util exposing ((=>)) import Validate exposing (Validator, ifBlank, validate) -- MESSAGES -- type Msg = SubmitForm | SetName String | SetEmail String | SetPassword String | RegisterCompleted (Result Http.Error Session) type ExternalMsg = NoOp | SetSession Session -- MODEL -- type alias Model = { errors : List Error , name : String , email : String , password : String } initialModel : Model initialModel = { errors = [] , name = "" , email = "" , password = "" } -- VIEW -- view : Model -> Html Msg view model = div [ class "mt4 mt6-l pa4" ] [ h1 [] [ text "Sign up" ] , p [ class "f7" ] [ a [ Route.href Route.Login ] [ text "Have an account?" ] ] , div [ class "measure center" ] [ Form.viewErrors model.errors , viewForm ] ] viewForm : Html Msg viewForm = Html.form [ onSubmit SubmitForm ] [ Form.input "Name" [ onInput SetName ] [] , Form.input "Email" [ onInput SetEmail ] [] , Form.password "Password" [ onInput SetPassword ] [] , button [ class "b ph3 pv2 input-reset ba b--black bg-transparent grow pointer f6" ] [ text "Sign up" ] ] -- UPDATE -- update : Msg -> Model -> ( ( Model, Cmd Msg ), ExternalMsg ) update msg model = case msg of SubmitForm -> case validate modelValidator model of [] -> { model | errors = [] } => Http.send RegisterCompleted (register model) => NoOp errors -> { model | errors = errors } => Cmd.none => NoOp SetName name -> { model | name = name } => Cmd.none => NoOp SetEmail email -> { model | email = email } => Cmd.none => NoOp SetPassword password -> { model | password = password } => Cmd.none => NoOp RegisterCompleted (Err error) -> let errorMessages = case error of Http.BadStatus response -> response.body |> decodeString (field "errors" errorsDecoder) |> Result.withDefault [] _ -> [ "Unable to process registration" ] in { model | errors = List.map (\errorMessage -> Form => errorMessage) errorMessages } => Cmd.none => NoOp RegisterCompleted (Ok session) -> model => Route.modifyUrl Route.Home => SetSession session -- VALIDATION -- type Field = Form | Name | Email | Password type alias Error = ( Field, String ) modelValidator : Validator Error Model modelValidator = Validate.all [ ifBlank .name (Name => "name can't be blank.") , ifBlank .email (Email => "email can't be blank.") , ifBlank .password (Password => "password can't be blank.") ] -- DECODERS -- errorsDecoder : Decoder (List String) errorsDecoder = decode (\name email password error -> error :: List.concat [ name, email, password ]) |> optionalFieldError "name" |> optionalFieldError "email" |> optionalFieldError "password" |> optionalError "error"
We are done with the register page. Go to the browser and navigate to the Register menu and you'll see something like this:
And if you enter the info for a new user, it will be created in the backend app and you should be logged in too
You can find the source code for the backend changes here . The changes for the frontend are here . In both cases, look for the part-04 branch.
Let's wrap it here for now. We have added the Login and Register pages to the Elm app.
In part 5 we're going to store the session in local storage and add some visual improvements.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK