2

Context

I have a view with a form with an arbitrary number of inputs that are dynamically generated by a controller. When the form is submitted, each input should create its own record, so that if there are 60 inputs then 60 records should be made.

Question

How should one validate each of the inputs/fields? In the examples in the IHP documentation, only 1 record is created by a single form so I am not sure what is the best or idiomatic way to do this.

Possibly I could map a function like the following to every submitted input, but the Left case would be triggered by the first validation failure rather than all validation failures so I would need to save each failure in a list (?) before redirecting to the previous form view.

 action CreatePostAction = do
    let post = newRecord @Post
    post
        |> fill @'["title", "body"]
        |> validateField #title nonEmpty
        |> validateField #body nonEmpty
        |> ifValid \case
            Left post -> render NewView { post }
            Right post -> do
                post <- post |> createRecord
                setSuccessMessage "Post created"
                redirectTo PostsAction

1 Answers1

5

Try something like this:

    action CreatePostAction = do
        let titles :: [Text] = paramList "title"
        let bodys :: [Text] = paramList "body"

        let posts = zip titles bodys
                |> map (\(title, body) -> newRecord @Post
                        |> set #title title
                        |> set #body body
                        |> validateField #title nonEmpty
                        |> validateField #body nonEmpty
                    )

        validatedPosts :: [Either Post Post] <- forM posts (ifValid (\post -> pure post))

        case Either.partitionEithers validatedPosts of
            ([], posts) -> do
                createMany posts
                setSuccessMessage "Post created"
                redirectTo PostsAction

            (invalidPosts, validPosts) -> render NewView { posts }

For that to work you need a view like this:

module Web.View.Posts.New where
import Web.View.Prelude
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Html5.Attributes as A

data NewView = NewView { posts :: [Post] }

instance View NewView where
    html NewView { .. } = [hsx|
        <nav>
            <ol class="breadcrumb">
                <li class="breadcrumb-item"><a href={PostsAction}>Posts</a></li>
                <li class="breadcrumb-item active">New Post</li>
            </ol>
        </nav>
        <h1>New Post</h1>

        <form id="main-form" method="POST" action={CreatePostAction}>
            <input type="submit" class="btn btn-primary"/>
            {forEach posts renderForm}
        </form>
    |]

renderForm :: Post -> Html
renderForm post = [hsx|
    <div class="form-group">
        <label>
            Title
        </label>
        <input type="text" name="title" value={get #title post} class={classes ["form-control", ("is-invalid", isInvalidTitle)]}/>
        {titleFeedback}
    </div>

    <div class="form-group">
        <label>
            Body
        </label>
        <input type="text" name="body" value={get #body post} class={classes ["form-control", ("is-invalid", isInvalidBody)]}/>
        {bodyFeedback}
    </div>
|]
    where
        isInvalidTitle = isJust (getValidationFailure #title post)
        isInvalidBody = isJust (getValidationFailure #body post)

        titleFeedback = case getValidationFailure #title post of
            Just result -> [hsx|<div class="invalid-feedback">{result}</div>|]
            Nothing -> mempty

        bodyFeedback = case getValidationFailure #body post of
            Just result -> [hsx|<div class="invalid-feedback">{result}</div>|]
            Nothing -> mempty

My NewPostAction looks like this:

    action NewPostAction = do
        let post = newRecord
        let posts = take (paramOrDefault 2 "forms") $ repeat post
        render NewView { .. }
Marc Scholten
  • 1,351
  • 3
  • 5
  • What does `[Either Post Post]` do? Also,`createMany posts`? (I couldn't find anything for `createMany` in IHP API docs.) My record type has 3 fields so I am trying to adapt your solution for that. I can use `zip3` in CreatePostAction, and there are no errors, but the records are not created. So I am trying to understand how your solution works. – stephenbenedict Sep 11 '21 at 13:55
  • 1
    Regarding the `[Either Post Post]`, the `ifValid` returns a `Left post` when it's an invalid post (e.g. no title) or a `Right post` when the post is valid. Therefore we have a list of Either (Left and Right values) and when there's a `Left post` we know that some validation has failed and we need to re-render the form. The `createMany` does a multi-insert. It's a more efficient way of doing `forEach posts (\post -> createRecord post)` https://ihp.digitallyinduced.com/Guide/database.html#creating-many-records – Marc Scholten Sep 11 '21 at 14:32
  • Thanks for the explanation! I understand now. And I completely missed the `createMany` reference in the guide. However, why would adding a 3rd field prevent records from being created? After using zip3, I'm pattern matching like `|> map (\(title, body, score)` and no errors are shown. – stephenbenedict Sep 11 '21 at 14:45
  • Really silly mistake on my part... In my CreatePostAction, `let scores :: [Text] = paramList "scores"` had "scores" in the plural. After changing that to "score" everything works as expected. – stephenbenedict Sep 12 '21 at 01:01
  • I wasn't able to edit the answer, however `value={get #title post}` should be changed to `value="{get #title post}"` to prevent a compile error. – amitaibu Oct 02 '21 at 18:53