如何验证用于创建多条记录的动态生成多输入项表单?
这确实是IHP批量表单处理里的一个常见痛点——官方示例大多聚焦单条记录,批量验证并收集所有错误需要换个思路。我来给你梳理下最优的实现方式:
核心思路
我们不能再依赖单条记录的ifValid逻辑(它会在第一个错误触发时直接返回Left分支),而是要:
- 从请求中提取所有批量输入项的数据
- 遍历每个输入项单独做验证
- 收集所有验证错误(而不是遇到第一个就中断)
- 根据错误情况,要么带着错误和原始数据回显表单,要么批量创建所有合法记录
具体代码实现
第一步:处理批量表单的Action逻辑
假设你的前端表单用item.title[]、item.body[]这种数组式命名(方便IHP批量获取参数),对应的Action代码如下:
{-# LANGUAGE OverloadedStrings #-} module Web.View.Posts.CreatePosts where import Web.View.Prelude import IHP.Validation.Validation (toEither) import Data.List (intercalate) action CreatePostsAction = do -- 从请求中批量提取所有标题和内容 let titles = paramList @Text "item.title" bodies = paramList @Text "item.body" -- 配对成完整的条目列表,先检查长度是否一致 items = if length titles == length bodies then zip titles bodies else [] -- 长度不一致时视为无效输入 -- 先处理长度不匹配的情况 when (length titles /= length bodies) $ do setErrorMessage "所有条目必须同时填写标题和内容" render NewPostsView { titles, bodies } -- 定义单个条目的验证逻辑 let validateItem :: (Text, Text) -> Either [ValidationError] Post validateItem (title, body) = newRecord @Post |> set #title title |> set #body body |> validateField #title nonEmpty |> validateField #body nonEmpty |> toEither -- 把Validation类型转成Either,方便收集错误 -- 给每个条目加上索引,方便后续定位错误 indexedResults = map (\(idx, item) -> (idx, validateItem item)) (zip [0..] items) -- 分离错误和合法记录 itemErrors = [(idx, errs) | (idx, Left errs) <- indexedResults] validPosts = [post | (_, Right post) <- indexedResults] -- 根据错误情况分支处理 if not (null itemErrors) then do -- 把错误信息传递给视图,同时保留用户的原始输入 render NewPostsView { titles, bodies, itemErrors } else do -- 批量创建记录,用runDB包裹实现事务回滚 runDB $ mapM_ createRecord validPosts setSuccessMessage $ "成功创建" <> show (length validPosts) <> "条记录" redirectTo PostsAction
第二步:视图层回显错误和原始数据
视图需要支持动态生成输入项,并且在验证失败时,给对应的输入项显示错误提示:
module Web.View.Posts.NewPosts where import Web.View.Prelude data NewPostsView = NewPostsView { titles :: [Text] , bodies :: [Text] , itemErrors :: [(Int, [ValidationError])] -- 存储索引对应的错误 } -- 默认视图(首次加载时没有错误) instance View NewPostsView where html view@NewPostsView { titles, bodies, itemErrors } = [hsx| <div class="container"> <h1>批量创建帖子</h1> {renderFlashMessages} <form method="POST" action={CreatePostsAction}> {forEach (zipWith3 (\idx title body -> renderItem idx title body itemErrors) [0..] titles bodies)} <button type="button" onclick="addNewItem()" class="btn btn-secondary mt-2">添加新条目</button> <button type="submit" class="btn btn-primary mt-3">提交所有</button> </form> </div> <script>{addItemScript}</script> |] -- 渲染单个输入条目,包含错误提示 renderItem :: Int -> Text -> Text -> [(Int, [ValidationError])] -> Html renderItem idx title body errors = [hsx| <div class="row mb-3"> <div class="col"> <input type="text" name="item.title[]" value={title} placeholder="标题" class="form-control {isInvalid idx}"> {renderFieldErrors idx "title"} </div> <div class="col"> <textarea name="item.body[]" placeholder="内容" class="form-control {isInvalid idx}">{body}</textarea> {renderFieldErrors idx "body"} </div> </div> |] where -- 判断当前条目是否有错误 isInvalid idx = if any (\(i, _) -> i == idx) errors then "is-invalid" else "" -- 渲染对应字段的错误信息 renderFieldErrors idx fieldName = case lookup idx errors of Just errs -> [hsx| <div class="invalid-feedback"> {forEach errs (\err -> if err.fieldName == Just fieldName then [hsx|<p>{err.message}</p>|] else mempty)} </div> |] Nothing -> mempty -- 前端动态添加新输入项的脚本 addItemScript :: Text addItemScript = [js| function addNewItem() { const form = document.querySelector('form'); const idx = document.querySelectorAll('.row.mb-3').length; const newItem = ` <div class="row mb-3"> <div class="col"> <input type="text" name="item.title[]" placeholder="标题" class="form-control"> </div> <div class="col"> <textarea name="item.body[]" placeholder="内容" class="form-control"></textarea> </div> </div> `; form.insertAdjacentHTML('beforeend', newItem); } |] -- 首次加载视图的默认值 instance Default NewPostsView where def = NewPostsView { titles = ["", ""], bodies = ["", ""], itemErrors = [] }
关键细节说明
- 参数命名规范:前端必须用
item.title[]这种数组式命名,IHP的paramList才能正确批量提取所有输入值 - 事务支持:用
runDB $ mapM_ createRecord validPosts包裹批量创建逻辑,确保如果某条记录创建失败,所有操作都会回滚 - 错误定位:给每个条目加上索引,能精准地在对应的输入项下方显示错误,提升用户体验
- 原始数据保留:验证失败时把用户输入的
titles和bodies传递回视图,避免用户重新输入
内容的提问来源于stack exchange,提问作者stephenbenedict
相关产品推荐
相关产品推荐

