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 Msg
s 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.
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