edkelly303/elm-yafl/2.0.0

Yafl: Yet another form library

DISCLAIMER

This package is experimental, and the API may change. It's also pretty weird. I'm fairly sure there are still some bugs and oddities. I would not recommend using it in production.

What does it do?

This package allows you to create very sophisticated forms by designing Widgets, (which are essentially miniature Elm applications), and converting them into Fields which can then be composed to create HTML forms.

Widgets can use any Elm types as their Msg and Model types, and they can send Cmds and receive Subs, just like a full-scale Elm Program. This gives you a lot of flexibility to design rich user interface components, because you have the full power of the Elm architecture at your disposal.

Fields can be transformed and composed using standard functional combinators such as map, andMap and andThen. These combinators make it easy to create forms for complex data structures by composing simpler Fields, without too much wiring or boilerplate.

What's nice about it?

  • Custom Widgets that can draw on the full power of the Elm architecture
  • Composable API built on familiar functional combinators
  • Minimal wiring and boilerplate
  • Built-in validation, including multi-field validation
  • Support for both traditional forms and multi-step wizards
  • Inter-Field communication through message-passing

What's... less nice about it?

  • You'll have to build your own Widgets.
    • But there are some simple examples in the repo that you can copy as a starting-point.
  • The Msg and Model types for your forms can look a bit unusual, as they are built on nested tuples like ( Maybe String, ( Maybe Int, () ) ).
    • They are a lot less weird than the types used in certain other form packages written by me. More on this later!
  • The type signatures of some of the functions for converting Widgets to Fields are quite terrifying
    • But you don't need to understand the types (I certainly don't), and in practice they are quite easy to use.

Getting started

Until I have time to write a proper tutorial, here's a taste of what it looks like to use this package:

Let's say we want to create a form for this very boring User type:

module Examples exposing (..)

type alias User = 
    { firstName : String
    , lastName : String
    , numberOfPets : Int
    }

Step 1: Define your Widgets

First, we'll need some Widgets that can generate the primitive types for our User's record fields (String and Int). Widgets are just like little Elm apps! But in addition to init, update, view and subscriptions, they need a submit function and a label. (They also take a parameter that allows you to pass in some configuration, but we don't need that here, so we just pass in ()):

import Html as H
import Html.Attributes as HA
import Html.Events as HE
import Yafl

stringWidget () =
    { init = ( "", Cmd.none )
    , update = \msg model -> ( msg, Cmd.none )
    , view =
        \{ label, id, feedback } model ->
            [ H.label [ HA.for id ] [ H.text label ]
            , H.input
                [ HA.id id
                , HA.type_ "text"
                , HA.value model
                , HE.onInput identity
                ]
                []
            , H.ul [] (List.map (\f -> H.li [] [ H.text f.message ]) feedback)
            ]
    , subscriptions = \model -> Sub.none
    , submit = \model -> Ok model
    , label = "String"
    }

type CounterMsg 
    = Increment 
    | Decrement

counterWidget () =
    { init = ( 0, Cmd.none )
    , update =
        \msg model ->
            case msg of
                Increment ->
                    ( model + 1, Cmd.none )

                Decrement ->
                    ( model - 1, Cmd.none )
    , view =
        \{ label, id } model ->
            [ H.label [ HA.for id ] [ H.text label ]
            , H.fieldset
                [ HA.id id ]
                [ H.button 
                    [ HA.type_ "button"
                    , HE.onClick Decrement 
                    ] 
                    [ H.text "-" ]
                , H.output [] 
                    [ H.text (String.fromInt model) ]
                , H.button 
                    [ HA.type_ "button"
                    , HE.onClick Increment 
                    ] 
                    [ H.text "+" ]
                ]
            ]
    , subscriptions = \_ -> Sub.none
    , submit = \model -> Ok model
    , label = "Counter"
    }


-- DOC TESTS
stringWidget --: Yafl.Widget () String String String
counterWidget --: Yafl.Widget () Int CounterMsg Int

Step 2: Convert Widgets into Fields

Next, we define the Fields we're going to use in our form. (You might have a big library of Widgets, but if you only need to use a handful of them in your form, there's no need to include them all).

import Examples exposing (..)
import Yafl

fields =
    Yafl.defineFields
        (\string counter -> 
            { string = string
            , counter = counter 
            }
        )
        |> Yafl.addWidget stringWidget
        |> Yafl.addWidget counterWidget
        |> Yafl.endFields

-- Defining the fields will also define the `Model` and `Msg` 
-- types for our form:

type alias FormModel = 
    ( Maybe String, (Maybe Int, () ) )

type alias FormMsg =
    ( Maybe String, (Maybe CounterMsg, () ) )


-- DOC TESTS
fields --: { string : Yafl.Field FormModel FormMsg Yafl.NoId String String String, counter : Yafl.Field FormModel FormMsg Yafl.NoId CounterMsg CounterMsg Int }

Now whenever we need a field that produces a String, we can use fields.string, and if we need a field that produces an Int, it's just fields.counter.

Step 3: Customize your Fields

Each field needs a label, and we might also want to add some validation, so:

import Examples exposing (..)
import Yafl

nonEmptyString =
    fields.string
        |> Yafl.error
            (\string -> 
                if String.isEmpty string then 
                    Just "This field must not be blank"
                else    
                    Nothing
            )

firstName =
    nonEmptyString
        |> Yafl.label "What is the user's first name?"

lastName =
    nonEmptyString
        |> Yafl.label "What is the user's last name?"

numberOfPets =
    fields.counter
        |> Yafl.label "How many pets do they have?"

-- DOC TESTS
nonEmptyString --: Yafl.Field FormModel FormMsg Yafl.NoId String String  String
firstName --: Yafl.Field FormModel FormMsg Yafl.NoId String String  String
lastName --: Yafl.Field FormModel FormMsg Yafl.NoId String String  String
numberOfPets --: Yafl.Field FormModel FormMsg Yafl.NoId CounterMsg CounterMsg Int

Step 4: Compose the Fields to create a form

We can use a combination of succeed and andMap to compose our Fields into a form that produces our User type as its output:

import Examples exposing (..)
import Yafl

user = 
    Yafl.succeed User
        |> Yafl.andMap .firstName firstName
        |> Yafl.andMap .lastName lastName
        |> Yafl.andMap .numberOfPets numberOfPets

-- DOC TESTS
user --: Yafl.Field FormModel FormMsg Never Never { firstName : Maybe String, lastName : Maybe String, numberOfPets : Maybe CounterMsg } User

Step 5: Try out your form in Yafl.studio

If you create a main function with Yafl.studio, you can use elm reactor to take your form for a quick test-drive in the browser.

import Examples exposing (..)
import Yafl

main = 
    Yafl.studio Debug.toString user

-- DOC TESTS
main --: Platform.Program () (Yafl.Model FormModel User) (Yafl.Msg FormMsg)

Step 6: Integrate your form into your Elm application

Once you are happy with how your form looks and behaves, you'll probably want to integrate it into a larger Elm app. This isn't a very realistic example, but it should get you up and running:

import Examples exposing (..)
import Yafl
import Browser
import Html as H

main = 
    Browser.element
        { init = \flags -> Yafl.init user
        , update = \msg model -> Yafl.update user msg model
        , view = \model -> H.form [] (Yafl.view user model)
        , subscriptions = \model -> Yafl.subscriptions user model
        }


-- DOC TESTS
main --: Program () (Yafl.Model FormModel User) (Yafl.Msg FormMsg )

For a slightly larger-scale example, take a look at the examples folder. You can run the examples and tests and view this documentation by doing npx run-pty run-pty.json

Step 7: That's all, folks!

That's the end of the Getting Started section of this README. Hopefully it is enough to get you up and running. The API docs for the Yafl module should help you explore the package in a bit more detail.

The rest of this document is about the rationale behind creating this package, design decisions and tradeoffs.

Yafl: an apologia

"Not forms again?! Why are you doing this?"

I guess you could say that I have... form for creating form packages in Elm. My previous attempt was edkelly303/elm-any-type-forms, which similarly allowed you to create widgets with arbitrary model and msg types and compose them into forms. However, the approach I took in that package had some serious shortcomings:

1. Userland type complexity

With elm-any-type-forms, the types of the forms quickly became very complex. For a type as simple as:

type Foo
    = Foo String
    | Bar Int

The resulting form's model type was:

type alias FormModel =
    ( Control.CustomType
        ( Control.Variant (Control.Arg String Control.EndVariant)
            ( Control.Variant (Control.Arg String Control.EndVariant)
                Control.EndCustomType
            )
        )
    )

And this got worse every time you added a variant to a custom type or field to a record.

The key insight for elm-yafl was that the complexity of the types doesn't need to increase with the number of fields in the form - it only needs to increase when you add a new type of widget to the form.

For example, once you've added a String widget type to your fields definition, you can include as many String fields in your form as you like without blowing up your msg and model types.

So, with elm-yafl, our Foo form's model could be as simple as:

type alias FormModel =
    ( Maybe String, ( Maybe Int, () ) )

And even if the definition of Foo changed to:

type Foo
    = Foo String
    | Bar Int
    | Baz String String String String
    | Qux Int Int Int Int

The FormModel type would remain exactly the same.

2. API type complexity and hideous error messages

In elm-any-type-forms, the type signatures of many of the functions are completely insane - in some cases, they are hundreds of lines of code.

In fact, the type annotations got so long that the Elm 0.19.1 compiler refused to allow me to publish the package, so I had to use an earlier compiler version to get it published (thank you Dillon Kearns for teaching me this trick!)

If you misused any of these functions, the compiler would often spend several seconds printing out thousands of lines of error messages, which was... somewhat offputting for most users?

In elm-yafl, there are only four functions that have crazy type signatures: defineFields, addWidget, addWidgetWithConfig and endFields, and the longest is only 70 lines of code.

These functions only need to be used once per project, and they are designed to be difficult to misuse, so it's less likely that users will be confronted with bizarre error messages.

3. Lack of inter-field communication

Wolfgang Schuster once asked me whether it was possible for the state of a field in elm-any-type-forms to depend on the state of another field. I didn't have a good answer. If you wanted widgets that were interdependent, your only real option was to create a custom field from scratch.

With elm-yafl, we now have a mechanism for fields to communicate with each other through message passing. At the top level of your app, you can intercept messages that a field is sending to itself, and then send messages to another field via the Elm runtime.

4. Implementation complexity

While writing elm-any-type-forms, I blundered into the discovery that both product and sum types could be represented using nested tuples. But I didn't come up with a comfortable way to work with nested tuples until much later, when I distilled the core ideas into my elm-nested-tuples package.

As a result, the codebase for elm-any-type-forms is extremely difficult to work with. It's also 9,500 lines of code in one main file, with literally thousands of lines of type annotations.

By contrast, elm-yafl builds on elm-nested-tuples, which reduces the complexity significantly. It also comes in at just over 3,000 lines of code including type annotations and documentation.

Trade-offs

There are some cool features of elm-any-type-forms that haven't made it into elm-yafl.

  • I haven't included integrated debouncing, because I'm currently thinking this might be better left in userland. For many fields, debouncing doesn't make sense, or needs to be handled in a field-specific way, so I don't think there's a single abstraction that will fit all use-cases.

There are many other possible features, but I'm dogfooding the current version first to see what my real-world requirements are. (If I'd done this with elm-any-type-forms, I could have saved myself a lot of work!)