Search code examples
recursionfunctional-programmingelm

How to handle messages from recursive HTML UI in Elm?


I'm trying to build a UI that allows a user to manipulate a recursive data structure. For example, imagine a visual schema editor or database table editor in which you have plain old types (strings and integers) and compound types made up of those plain types (arrays, structs). In the example below, a Struct_ is like a JavaScript object, where the keys are strings and the values are any type, including nested Array_s and Struct_s.

-- underscores appended to prevent confusion about native Elm types. These are custom to my application.
type ValueType
    = String_
    | Int_
    | Float_
    | Array_ ValueType
    | Struct_ (List (String, ValueType))

type alias Field =
    { id : Int
    , label : String
    , hint : String
    , hidden : Bool
    , valueType : ValueType
    }

type alias Schema = List Field

Now to go about building a UI for this I can make a simple recursive function:

viewField : Field -> Html Msg
viewField field =
    div []
    [ input [ type_ "text", value field.label ] []
    , viewValueType field.valueType
    ]

viewValueType : ValueType -> Html Msg
viewValueType valueType =
    let
        structField : (String, ValueType) -> Html Msg
        structField (key, subtype) =
            div []
                [ input [type_ "text", placeholder "Key", value key, onInput EditStructSubfieldKey] []
                , viewValueType subtype
                ]

        options : List(Html Msg)
        options = case valueType of
            String_ -> -- string ui
            Int_ -> -- int ui
            Float_ -> -- float ui
            Array_ subtype ->
                [ label [] [ text "subtype" ]
                , viewValueType subtype
                ]
            Struct_ fields ->
                [ label [] [ text "subfields" ]
                , List.map structField fields
                , button [ onClick AddStructSubfield ] [ text "Add subfield" ]
                ]
    in
    div [] options

My issue arises when trying to manipulate my state with this recursive structure. What data structure in a Msgs would accommodate user edits to this structure, adding new fields, subfields, and editing their properties? How would I properly decode that in my update loop?

For example...

type alias Model =
    { fields : List Field }

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
    case msg of
        AddStructSubfield _???_ ->
            ({model | fields = ???}, Cmd.none)
        EditStructSubfieldKey _???_ ->
            ({model | fields = ???}, Cmd.none)

What kind of data would you attach to that AddStructSubfield or EditStructSubfieldKey message (that's passed with the onClick handler to the button above) to properly update your state, specifically when the Struct_ is say, nested inside of another Struct_, nested inside of an Array_? EditStructSubfieldKey, for example, will only contain the new string that the user has entered, but not enough information to address a deeply-nested item.


Solution

  • I ended up solving this by passing an updater function down the recursive chain. I've simplified this example as much as possible while showing the recursive nature of the updating. This allows for updating infinitely nested structures and lists without worrying about encoding/decoding a path. The downside, I believe, is that my single update Msg will always replace the entire model. I'm not sure about the semantics of how this will affect Elm's equality checking, and if that will produce performance issues in certain applications.

    This example can be copy/pasted into https://elm-lang.org/try as-is to see it in action.

    import Html exposing (Html, div, input, ul, li, text, select, button, option)
    import Html.Attributes exposing (value, type_, selected)
    import Html.Events exposing (onInput, onClick)
    import Browser
    
    type ValueType
        = String_
        | Int_
        | Array_ ValueType
        | Struct_ (List Field)
    
    type alias Field =
        { label : String
        , valueType : ValueType
        }
    
    type alias Model = Field
    
    main = Browser.sandbox { init = init, update = update, view = view }
    
    init : Model
    init =
        { label = "Root Field", valueType = String_ }
    
    type Msg
        = UpdateField Field
    
    update : Msg -> Model -> Model
    update msg model =
        case msg of
            UpdateField field ->
                field
    
    view : Model -> Html Msg
    view model =
        let
            updater : Field -> Msg
            updater field =
                UpdateField field
        in
        div [] [ viewField updater model ]
    
    viewField : (Field -> Msg) -> Field -> Html Msg
    viewField updater field =
        let
            updateLabel : String -> Msg
            updateLabel newLabel =
                updater {field | label = newLabel}
    
            updateValueType : ValueType -> Msg
            updateValueType newValueType =
                updater {field | valueType = newValueType}
        in
        li []
        [ input [ type_ "text", value field.label, onInput updateLabel ] [ ]
        , viewTypeOptions updateValueType field.valueType
        ]
    
    viewTypeOptions : (ValueType -> Msg) -> ValueType -> Html Msg
    viewTypeOptions updater valueType =
        let
            typeOptions = case valueType of
                String_ ->
                    div [] []
                Int_ ->
                    div [] []
                Array_ subtype ->
                    let
                        subUpdater : ValueType -> Msg
                        subUpdater newType =
                            updater <| Array_ newType
                    in
                    div [] [ div [] [ text "Subtype" ], viewTypeOptions subUpdater subtype ]
                Struct_ fields ->
                    let
                        fieldAdder : Msg
                        fieldAdder =
                            updater <| Struct_ ({label = "", valueType = String_} :: fields)
    
                        fieldUpdater : Int -> Field -> Msg
                        fieldUpdater index newField =
                             updater <| Struct_ <| replaceInList index newField fields
                    in
                    div []
                      [ ul [] (List.indexedMap (\i -> (viewField <| fieldUpdater i)) fields)
                      , button [ onClick fieldAdder ] [ text "+ Add Field" ]
                      ]
    
            isArray t = case t of
                Array_ _ -> True
                _ -> False
    
            isStruct t = case t of
                Struct_ _ -> True
                _ -> False
    
            stringToType str = case str of
                "string" -> String_
                "int" -> Int_
                "array" -> Array_ String_
                "struct" -> Struct_ []
                _ -> String_
    
            changeType str =
                updater <| stringToType str
    
        in
        div []
        [ select [ onInput changeType ]
            [ option [ value "string", selected <| valueType == String_ ] [ text "String" ]
            , option [ value "int", selected <| valueType == Int_ ] [ text "Integer" ]
            , option [ value "array", selected <| isArray valueType ] [ text "Array" ]
            , option [ value "struct", selected <| isStruct valueType ] [ text "Struct" ]
            ]
        , typeOptions
        ]
    
    replaceInList : Int -> a -> List a -> List a
    replaceInList index item list =
        let
            head = List.take index list
            tail = List.drop (index+1) list
        in
        head ++ [ item ] ++ tail