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:
- I implemented the UI, using HTML and Sass, in a custom Astro frontend workshop environment.
- I translated the UI into
elm/html
. - I used my field and form packages to implement the form's logic.
- 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
).
- Building a Scalable CSS Architecture with BEM and Utility Classes
- The wasted potential of CSS attribute selectors
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>
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");
}
}
}
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
]
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
]
]
]
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)
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
]
]
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)