seanhess / hyperbole

Haskell interactive serverside web framework inspired by HTMX
Other
93 stars 6 forks source link

Feedback Requested: Records-based forms #21

Closed seanhess closed 1 month ago

seanhess commented 4 months ago

This PR changes hyperbole to use higher-kinded records for forms. Is this easier to use / better?

In the current (old) form example, we use newtypes to differentiate form fields:

data User = User Text deriving (Generic, FormField)
data Age = Age Int deriving (Generic, FormField)
data Pass1 = Pass1 Text deriving (Generic, FormField)
data Pass2 = Pass2 Text deriving (Generic, FormField)

We replace these with the following

data UserForm f = UserForm
  { user :: Field f Text
  , age :: Field f Int
  , pass1 :: Field f Text
  , pass2 :: Field f Text
  }
  deriving (Generic, Form Validated)

We can now use the same shape for different data. UserForm Identity refers to the form data, while a UserForm Validated constains a bunch of Validated a.

Forms Interface

Changes from the old version, where the validated object is threaded through the form's context and type indexed:

-- current hyperbole
formView :: UserForm Validated -> View FormView ()
formView v = do
  form Submit v (gap 10 . pad 10) $ do
    el Style.h1 "Sign Up"

    field @User valStyle $ do
      label "Username"
      input Username (inp . placeholder "username")
      ...

Or with this PR we address the field with dot-syntax. Note that we have to create a UserForm (FormField Validated) first. (An empty one can be generated with genFields)

-- proposed change
formView :: UserForm Validated -> View FormView ()
formView v = do
  let f = genFieldsFrom v
  form Submit (gap 10 . pad 10) $ do
    el Style.h1 "Sign Up"

    field f.user valStyle $ do
      label "Username"
      input Username (inp . placeholder "username")
      ...

Advantages of this new approach

  1. The example was already using a higher-kinded type, to make parsing the form all at once easier, so this reduces it to one paradigm
  2. Records are a simpler pattern than type-addressing fields
  3. Simpler form: you pass what you need directly to fields
  4. Makes it easy to pass around forms of various shapes

Disadvantages of this approach

  1. Getting it to work required more complex Generics classes
  2. People might not be comfortable with higher kinded record types

Any thoughts?

cgeorgii commented 4 months ago

Interesting! I wonder if it can be made extra ergonomic with something like higgledy:

data User
  = User
      { name :: String
      , age  :: Int
      , ...
      }
  deriving Generic

-- HKD for free!
type UserF f = HKD User f

Could this enable skipping the HKD altogether and just having a form for the data structure directly?

seanhess commented 4 months ago

Neat, I didn't know about higgledy! I like the idea

Could this enable skipping the HKD altogether and just having a form for the data structure directly?

Take a look at Example.Forms. With the current approach, the user has to deal with the higher kinded type directly to do validation: (UserForm Validated in the example). Would it be more or less confusing for them to create a type with Higgledy, but then need to use UserF Validated? Maybe with clever conventions it becomes more clear:

data User
  = User
      { name :: String
      , age  :: Int
      , ...
      }
  deriving Generic

-- name this "XForm" instead of XF
type UserForm f = HKD User f

-- they still have to create one manually
validateForm :: User -> UserForm Validated
validateForm u =
  UserForm
    { user = validateUser u.user
    , age = validateAge u.age
    , pass1 = validatePass u.pass1 u.pass2
    , pass2 = NotInvalid
    }

-- user needs to generate a "FormField" version of their form data structure to power the field funciton
formView :: UserForm Validated -> View FormView ()
formView v = do
  -- 
  let f = genFieldsFrom v :: UserForm FormField
  ...
seanhess commented 3 months ago

Update: I don't think Higgledy can work. There's no direct UserForm constructor, they use fancier composable constructors, at the cost of approachability and clarity. In the above example the user constructs a UserForm Validated using record-syntax. I think getting people more familiar with the pattern by asking them to create the type directly is easier and more clear.

If we're getting into the weeds of composable types, I'd rather drop this PR and go back to type-addressed fields like in the main branch.

seanhess commented 1 month ago

Merged, made some simplifications, but this approach is definitely going to be more scalable and intuitive than the type-per-field approach