DEV Community

Dwayne Crooks
Dwayne Crooks

Posted on

Frontend Mentor's Contact form challenge built with Elm

My motivation for completing Frontend Mentor's Contact form challenge was to test-drive my field and form packages. I also recently started using and enjoying Astro so I wanted to explore what it would be like to use it as my frontend workshop environment. I even ended up experimenting with Makefiles, Nushell, and Nix flakes within this project. Overall, I learned a lot and gained some new skills. In this post I'm sharing the highlights of my experience.

How I built the form

I followed the typical process I've used for all my Elm web applications. In general, I build the UI first using just HTML/CSS, then I translate the UI to elm/html, then I implement the business logic, and finally I combine the UI and business logic.

For this project, here's the specific steps I followed:

  1. I implemented the UI, using HTML and Sass, in a custom Astro frontend workshop environment.
  2. I translated the UI into elm/html.
  3. I used my field and form packages to implement the form's logic.
  4. And finally, I wrote trivial glue code to connect the UI and form logic.

The UI

The UI was implemented in the workshop directory using Astro. I structured the HTML and Sass using a combination of BEM, data attributes, and utility classes. Each block was implemented using an Astro component. Pages were assembled to test out colors, typography, each individual block, combinations of blocks, and entire page states.

The workshop acted as the single source of truth for the HTML and CSS. For this project, the artifacts were a CSS file and a font file (.ttf).

elm/html

Based on the HTML structure and CSS styles developed in the frontend workshop environment I built the corresponding Elm views.

As an example, let's take a look at the path to get to the LabelledRadio component.

Astro component

---
import Radio from "@/components/Radio.astro";

const { labelProps, text = "Text", ...rest } = Astro.props;
---
<label class:list={["labelled-radio", labelProps?.class]}>
  <Radio {...rest} />
  {text}
</label>
Enter fullscreen mode Exit fullscreen mode

Sass

@use "colors";
@use "spaces";
@use "typography";

.labelled-radio {
  display: flex;
  align-items: center;
  gap: spaces.get-space(150);

  min-width: fit-content;
  width: 100%;

  border: 1px solid colors.get-color("grey-500");
  border-radius: spaces.get-space(100);

  padding: spaces.get-space(150) spaces.get-space(300);

  cursor: pointer;

  background-color: colors.get-color("white");
  color: colors.get-color("grey-900");

  @include typography.body-m;

  &:hover {
    border-color: colors.get-color("green-600");
  }

  &:has(.input[type="radio"]:checked) {
    border-color: colors.get-color("green-600");
    background-color: colors.get-color("green-200");

    &:hover {
      background-color: colors.get-color("white");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Elm view

module View.LabelledRadio exposing (ViewOptions, view)

import Html as H
import Html.Attributes as HA
import Lib.View.Generic as Generic
import View.Radio as Radio


type alias ViewOptions msg =
    { text : String
    , radioAttrs : List (H.Attribute msg)
    }


view : ViewOptions msg -> List (H.Attribute msg) -> H.Html msg
view { text, radioAttrs } attrs =
    Generic.view
        { element = H.label
        , preAttrs =
            [ HA.class "labelled-radio"
            ]
        , postAttrs = []
        }
        attrs
        [ Radio.view radioAttrs
        , H.text text
        ]
Enter fullscreen mode Exit fullscreen mode

I even wrote unit tests for all the Elm views. Here's the test for the LabelledRadio:

module Test.View.LabelledRadio exposing (suite)

import Html.Attributes as HA
import Test exposing (Test, describe, test)
import Test.Html.Query as Query
import Test.Html.Selector as Sel
import View.LabelledRadio as LabelledRadio


suite : Test
suite =
    describe "View.LabelledRadio"
        [ describe "general enquiry" <|
            let
                view =
                    LabelledRadio.view
                        { text = "General Enquiry"
                        , radioAttrs =
                            [ HA.name "query-type"
                            , HA.checked True
                            ]
                        }
                        []
            in
            [ test "label" <|
                \_ ->
                    view
                        |> Query.fromHtml
                        |> Query.has
                            [ Sel.tag "label"
                            , Sel.exactClassName "labelled-radio"
                            , Sel.exactText "General Enquiry"
                            ]
            , test "radio" <|
                \_ ->
                    view
                        |> Query.fromHtml
                        |> Query.find
                            [ Sel.attribute (HA.type_ "radio")
                            ]
                        |> Query.has
                            [ Sel.attribute (HA.name "query-type")
                            , Sel.checked True
                            ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

The two most interesting views to test were actually View.Field and View.QueryType.

Form logic

The form's logic is independent of the form's view. It was implemented in app/src/elm/Data/Contact/Form.elm.

module Data.Contact.Form exposing
    ( Accessors
    , Error(..)
    , Form
    , Output
    , State
    , form
    )

import Data.Contact.Bool as Bool
import Data.Contact.Email as Email exposing (Email)
import Data.Contact.QueryType as QueryType exposing (QueryType)
import Data.Contact.Text as Text exposing (Text)
import Field.Advanced as Field exposing (Field, Validation)
import Form exposing (Accessor)



-- FORM


type alias Form =
    Form.Form State Accessors Error Output


type alias State =
    { firstName : Field Text.Error Text
    , lastName : Field Text.Error Text
    , email : Field Email.Error Email
    , queryType : Field QueryType.Error QueryType
    , message : Field Text.Error Text
    , consent : Field Bool.Error Bool
    }


type alias Accessors =
    { firstName : Accessor State (Field Text.Error Text)
    , lastName : Accessor State (Field Text.Error Text)
    , email : Accessor State (Field Email.Error Email)
    , queryType : Accessor State (Field QueryType.Error QueryType)
    , message : Accessor State (Field Text.Error Text)
    , consent : Accessor State (Field Bool.Error Bool)
    }


type Error
    = FirstNameError Text.Error
    | LastNameError Text.Error
    | EmailError Email.Error
    | QueryTypeError QueryType.Error
    | MessageError Text.Error
    | ConsentError Bool.Error


type alias Output =
    { firstName : Text
    , lastName : Text
    , email : Email
    , queryType : QueryType
    , message : Text
    }


form : Form
form =
    Form.new
        { init = init
        , accessors = accessors
        , validate = validate
        }



-- INIT


init : State
init =
    { firstName = Field.empty (Text.fieldType { min = 1, max = 25 })
    , lastName = Field.empty (Text.fieldType { min = 1, max = 25 })
    , email = Field.empty Email.fieldType
    , queryType = Field.empty QueryType.fieldType
    , message = Field.empty (Text.fieldType { min = 1, max = 300 })
    , consent = Field.empty Field.true
    }



-- ACCESSSORS


accessors : Accessors
accessors =
    { firstName =
        { get = .firstName
        , modify = \f state -> { state | firstName = f state.firstName }
        }
    , lastName =
        { get = .lastName
        , modify = \f state -> { state | lastName = f state.lastName }
        }
    , email =
        { get = .email
        , modify = \f state -> { state | email = f state.email }
        }
    , queryType =
        { get = .queryType
        , modify = \f state -> { state | queryType = f state.queryType }
        }
    , message =
        { get = .message
        , modify = \f state -> { state | message = f state.message }
        }
    , consent =
        { get = .consent
        , modify = \f state -> { state | consent = f state.consent }
        }
    }



-- VALIDATE


validate : State -> Validation Error Output
validate state =
    always Output
        |> Field.succeed
        |> Field.applyValidation (state.consent |> Field.mapError ConsentError)
        |> Field.applyValidation (state.firstName |> Field.mapError FirstNameError)
        |> Field.applyValidation (state.lastName |> Field.mapError LastNameError)
        |> Field.applyValidation (state.email |> Field.mapError EmailError)
        |> Field.applyValidation (state.queryType |> Field.mapError QueryTypeError)
        |> Field.applyValidation (state.message |> Field.mapError MessageError)
Enter fullscreen mode Exit fullscreen mode

And, extensively tested in app/tests/Test/Data/Contact/Form.elm.

module Test.Data.Contact.Form exposing (suite)

import Data.Contact.Form as Contact
import Data.Contact.Text as Text
import Expect
import Field.Advanced as Field
import Form
import Fuzz
import Test exposing (Test, describe, fuzz, test)


suite : Test
suite =
    describe "Data.Contact.Form"
        [ test "all fields are clean and empty" <|
            \_ ->
                let
                    apply2 f g a =
                        Expect.equal True (f a && g a)
                in
                Contact.form
                    |> Form.toState
                    |> Expect.all
                        [ .firstName >> apply2 Field.isClean Field.isEmpty
                        , .lastName >> apply2 Field.isClean Field.isEmpty
                        , .email >> apply2 Field.isClean Field.isEmpty
                        , .queryType >> apply2 Field.isClean Field.isEmpty
                        , .message >> apply2 Field.isClean Field.isEmpty
                        , .consent >> apply2 Field.isClean Field.isEmpty
                        ]
        , describe "firstName"
            [ fuzz (Fuzz.intRange 0 30) "it is required and must be less than 25 characters long" <|
                \n ->
                    let
                        form =
                            Contact.form
                                |> Form.modify .firstName (Field.setFromString (String.repeat n "f"))
                    in
                    if n == 0 then
                        form
                            |> Form.get .firstName
                            |> Field.toResult
                            |> Expect.equal (Err [ Field.blankError ])

                    else if n > 25 then
                        form
                            |> Form.get .firstName
                            |> Field.toResult
                            |> Expect.equal (Err [ Field.customError (Text.TooLong { actual = n, max = 25 }) ])

                    else
                        form
                            |> Form.get .firstName
                            |> Field.isValid
                            |> Expect.equal True
            ]
        , describe "lastName"
            [ fuzz (Fuzz.intRange 0 30) "it is required and must be less than 25 characters long" <|
                \n ->
                    let
                        form =
                            Contact.form
                                |> Form.modify .lastName (Field.setFromString (String.repeat n "l"))
                    in
                    if n == 0 then
                        form
                            |> Form.get .lastName
                            |> Field.toResult
                            |> Expect.equal (Err [ Field.blankError ])

                    else if n > 25 then
                        form
                            |> Form.get .lastName
                            |> Field.toResult
                            |> Expect.equal (Err [ Field.customError (Text.TooLong { actual = n, max = 25 }) ])

                    else
                        form
                            |> Form.get .lastName
                            |> Field.isValid
                            |> Expect.equal True
            ]
        , describe "email"
            [ test "it is required" <|
                \_ ->
                    Contact.form
                        |> Form.modify .email (Field.setFromString "")
                        |> Form.get .email
                        |> Field.toResult
                        |> Expect.equal (Err [ Field.blankError ])
            , test "it must contain @" <|
                \_ ->
                    Contact.form
                        |> Form.modify .email (Field.setFromString "ab.c")
                        |> Form.get .email
                        |> Field.toResult
                        |> Expect.equal (Err [ Field.syntaxError "ab.c" ])
            ]
        , describe "queryType"
            [ test "it is required" <|
                \_ ->
                    Contact.form
                        |> Form.modify .queryType (Field.setFromString "")
                        |> Form.get .queryType
                        |> Field.toResult
                        |> Expect.equal (Err [ Field.blankError ])
            ]
        , describe "message"
            [ fuzz (Fuzz.intRange 0 350) "it is required and must be less than 300 characters long" <|
                \n ->
                    let
                        form =
                            Contact.form
                                |> Form.modify .message (Field.setFromString (String.repeat n "m"))
                    in
                    if n == 0 then
                        form
                            |> Form.get .message
                            |> Field.toResult
                            |> Expect.equal (Err [ Field.blankError ])

                    else if n > 300 then
                        form
                            |> Form.get .message
                            |> Field.toResult
                            |> Expect.equal (Err [ Field.customError (Text.TooLong { actual = n, max = 300 }) ])

                    else
                        form
                            |> Form.get .message
                            |> Field.isValid
                            |> Expect.equal True
            ]
        , describe "consent"
            [ test "it is required" <|
                \_ ->
                    Contact.form
                        |> Form.modify .consent (Field.setFromString "")
                        |> Form.get .consent
                        |> Field.toResult
                        |> Expect.equal (Err [ Field.blankError ])
            , test "it cannot be false" <|
                \_ ->
                    Contact.form
                        |> Form.modify .consent (Field.setFromValue False)
                        |> Form.get .consent
                        |> Field.toResult
                        |> Expect.equal (Err [ Field.validationError "false" ])
            , test "it must be true" <|
                \_ ->
                    Contact.form
                        |> Form.modify .consent (Field.setFromValue True)
                        |> Form.get .consent
                        |> Field.isValid
                        |> Expect.equal True
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

Glue code

The code that connects the UI to the form's logic lives in app/src/elm/Main.elm and is implemented like any other Elm web application that uses TEA. There's no use of nested TEA. There's nothing special about it. For me, that's the point. The glue code loosely couples the UI and form logic and that's it.

Astro as a frontend workshop environment

I was never able to take a web design and directly implement it in a web application in one go. I always needed somewhere where I could experiment with ideas for how I wanted to write the HTML and CSS before I settled on a final solution. In this regard, I would always have a folder in my project where these experiements lived. For e.g. when I was building 2048 I had both an experiments and a prototype folder that served the purpose.

I called my process prototyping and the idea behind the experiments and prototype folders for UI development has helped my work tremendously. More examples include:

It's only recently that I learned about Brad Frost's frontend workshop environment idea and how Storybook is a specific instance of that idea. These other ideas and tools have only reinforced my belief that there's enormous value in having this extra prototyping step in my process.

Astro components are relatively easy to make and it doesn't enforce any particular workflow on you. Since I'm still experimenting with my workflow I value the freedom Astro gives me over Storybook. I can use Astro as a foundation to tailor the features of my frontend workshop environment to my needs and the needs of the particular project.

Project structure

For this project I also experimented with a new project structure.

I used two top-level directories called app and workshop. app contained the main Elm web application and workshop contained the frontend workshop environment.

I worked within workshop to build the UI and its artifacts (a CSS file and a font file) became inputs into app. The connection can be seen from the app prepare script.

To build the Elm web application you build the workshop, copy the artifacts over to the app's public directory and then build the app. All of this is handled by make (or equivalently make build).

Miscellaneous

I used Nushell for the bin/app and bin/workshop scripts. I have to use it some more to say whether or not it's worth it. I did like how easy it was to write a script with subcommands, document the subcommands, and wrap pre-exisiting scripts.

I packaged my deployment script with Nix and Nix flakes then added it as a dependency in my devbox.json. When you enter the developer environment you have access to the deploy Bash script which I then wrapped up into app deploy. Previously, I would copy and paste all the Bash scripts I needed from past projects into my current project but this approach was much nicer.

Subscribe to my newsletter

If you're interested in improving your skills with Elm then I invite you to subscribe to my newsletter, Elm with Dwayne. To learn more about it, please read this announcement.

Top comments (0)