表单校验与提交
import { ErrorProvider, FormBindingBag, FormSession, RenderFieldPresenter, RenderNumberInput, RenderTextBox, ValidationProvider,} from 'ds-ui'
FormSession 把草稿、错误、校验和提交状态组合在一个页面级会话中:
FormSession
EditSession baseline、draft、dirty、touched
ErrorProvider 本地、服务端和业务错误
ValidationProvider 同步、异步和跨字段规则
它不会根据组件树自动绑定输入组件。页面可以用 FormBindingBag.bindField() 显式声明字段、编辑器和字段壳的关系;需要完全自定义接线时,也可以直接调用下面的底层注册和校验 API。
示例中的字段绑定同时处理值同步、change/blur 校验、错误投影、首错焦点,以及提交前尚未完成的数字或日期输入。完整契约见编辑器契约与表单字段绑定。
注册字段规则
interface OrderDraft { drugCode: string dose: number} const form = new FormSession<OrderDraft>({ drugCode: '', dose: 0,}) form.registerField('drugCode', { triggers: ['change', 'blur', 'submit'], rules: [ value => value ? undefined : '请选择药品', ],})form.registerField('dose', { rules: [ value => value > 0 ? undefined : '单次剂量必须大于 0', ],})
规则可以返回:
- 字符串:当前字段的一条 error。
ValidationIssueInput:指定字段、消息、级别和错误代码。- 数组:一次返回多条问题。
null或undefined:校验通过。
默认触发时机包含 change、blur 和 submit。页面根据真实交互调用:
interface Draft { name: string} const form = new FormSession<Draft>({ name: '' })form.registerField('name', { rules: [value => value.trim() ? undefined : '名称不能为空'],}) form.setValue('name', '新名称')void form.validateField('name', 'change')void form.touchAndValidate('name')
touchAndValidate(field) 会先标记 touched,再按 blur 规则校验。
异步规则
异步规则会收到当前模型、触发时机和 AbortSignal:
interface Draft { drugCode: string} const form = new FormSession<Draft>({ drugCode: '' })const stoppedCodes = new Set(['MED003']) form.registerField('drugCode', { rules: [ async (value, context) => { await Promise.resolve() if (context.signal.aborted) return undefined return stoppedCodes.has(value) ? '该药品已经停用' : undefined }, ],})
同一字段再次校验时,前一次运行会被取消。即使旧请求稍后才返回,也不能覆盖新值的结果。form.isValidating 和 form.validation.isFieldPending(field) 可用于显示校验中状态。
表单值在异步校验期间发生变化时,当前表单级校验也会失效。runSubmit() 不会拿旧模型的校验结果去提交后来修改的新模型。再次调用 validate() 会取代尚未完成的前一次表单级校验。
业务自己的请求函数也应接收并转交 AbortSignal,这样页面关闭时可以停止网络工作,而不只是忽略结果。
跨字段规则
interface DoseDraft { dose: number maxSingleDose: number drugCode: string} const form = new FormSession<DoseDraft>({ dose: 0.5, maxSingleDose: 1, drugCode: 'MED001',}) form.registerFormRule( 'single-dose-limit', draft => draft.dose <= draft.maxSingleDose ? undefined : { field: 'dose', message: `单次剂量不能超过 ${draft.maxSingleDose}g`, severity: 'error', }, { triggers: ['change', 'blur', 'submit'], dependsOn: ['dose', 'maxSingleDose'], },)
dependsOn 决定调用 validateField() 时哪些跨字段规则需要重跑。执行 validate('submit') 时,所有包含 submit 触发器的规则都会运行。
错误来源
ErrorProvider 按 source 保存问题。本地规则、服务端返回和业务警告可以同时存在,不会互相清除:
interface Draft { drugCode: string} const form = new FormSession<Draft>({ drugCode: 'MED001' }) form.setExternalIssues('server', [{ field: 'drugCode', code: 'insurance-restriction', message: '当前患者不符合该药品的医保限制条件', severity: 'error',}]) const serverIssues = form.errors.getIssues({ field: 'drugCode', source: 'server',})void serverIssuesform.clearExternalIssues('server', 'drugCode')
常用查询:
| API | 用途 |
|---|---|
errors.issues |
按字段注册顺序返回全部问题。 |
errors.hasErrors |
是否存在 error。 |
errors.getFieldIssues(field) |
读取一个字段的全部来源。 |
errors.getFormIssues() |
读取没有 field 的整体问题。 |
errors.firstIssue(filter) |
读取匹配条件的第一条问题。 |
errors.firstError(field?) |
读取第一个 error。 |
errors.clearSource(source) |
只清除指定来源。 |
reset() 和 acceptChanges() 会清空当前会话的全部错误。单独重新运行本地规则不会清掉 setExternalIssues() 注入的服务端错误。
投影到字段组件
校验层不依赖 RenderObject。常规表单直接在绑定时传入 presenter:
interface Draft { dose: number} const form = new FormSession<Draft>({ dose: 0 })const doseInput = new RenderNumberInput({ value: 0 })const presenter = new RenderFieldPresenter({ label: '单次剂量', child: doseInput,})const bindings = new FormBindingBag(form)bindings.bindField('dose', doseInput, { presenter, rules: [value => value > 0 ? undefined : '单次剂量必须大于 0'],})
需要自定义错误优先级或展示位置时,页面也可以订阅会话,把字段第一条问题写到 RenderFieldPresenter:
interface Draft { dose: number} const form = new FormSession<Draft>({ dose: 0 })const presenter = new RenderFieldPresenter({ label: '单次剂量', child: new RenderNumberInput({ value: 0 }),}) const syncError = (): void => { const issue = form.errors.firstIssue({ field: 'dose', severity: ['error', 'warning'], }) presenter.status = issue?.severity === 'error' ? 'error' : issue?.severity === 'warning' ? 'warning' : 'default' presenter.message = issue?.message ?? ''} form.subscribe(syncError)syncError()
无论使用绑定还是手动投影,业务模型都不认识组件,组件也不持有整份业务对象。后续接入其他字段壳时,可以复用同一份错误数据。
首错定位
bindField() 默认使用编辑器的 requestFocus()。手动注册字段时也可以提供 focus 回调:
interface Draft { name: string} const form = new FormSession<Draft>({ name: '' })const input = new RenderTextBox({ value: '' }) form.registerField('name', { rules: [value => value ? undefined : '名称不能为空'], focus: () => { input.requestFocus() return input.isFocused },}) await form.validate('submit')form.focusFirstError()
字段按注册顺序定位。runSubmit() 校验失败时会自动调用 focusFirstError()。
接入复合组件
DataGrid 等拥有自己编辑和校验模型的复合组件,不需要把内部错误复制进表单。GridView 可直接使用正式桥接:
import { DataGridEditSession, FormSession, RenderDataGrid, bindDataGridToForm,} from 'ds-ui' const orderForm = new FormSession({ title: '' })const orderGrid = new RenderDataGrid({ columns: [{ key: 'quantity', title: '数量', type: 'number' }], rows: [{ quantity: 1 }],})const orderGridEdits = new DataGridEditSession(orderGrid)const unbindOrderGrid = bindDataGridToForm(orderForm, orderGrid, { editSession: orderGridEdits,})
提交时,字段规则、跨字段规则和 Grid 一起决定结果。逐单元格错误仍由 Grid 展示和定位;FormSession 只保存一条汇总问题。默认只在 submit 阶段运行 Grid 全表校验,可通过 triggers 调整。
桥接还会把 Grid 的 dirty 和 touched 汇总到 FormSession,并让 reset()、acceptChanges() 同时处理表单字段和表格变更。runSubmit() 会先提交活动单元格,再取得表单快照和表格变更集:
const submitForm = new FormSession({ title: '' })const submitGrid = new RenderDataGrid({ columns: [{ key: 'quantity', title: '数量', type: 'number' }], rows: [{ quantity: 1 }],})const submitGridEdits = new DataGridEditSession(submitGrid)bindDataGridToForm(submitForm, submitGrid, { editSession: submitGridEdits,}) const submitResult = await submitForm.runSubmit(async draft => { const rowChanges = submitGridEdits.getChanges() return { form: { ...draft }, rowChanges, }}) if (submitResult.status === 'submitted') { submitForm.acceptChanges(submitResult.value.form)}
复合组件还有两种不同的接入职责:
FormEditParticipant在取校验快照前同步提交内部暂存值,也可以提供 dirty、touched、reset 和 accept 生命周期。FormValidationParticipant执行允许异步的独立校验、首错定位和错误清理。
bindDataGridToForm() 已同时注册这两类参与者。其他复合组件也可以手工注册,但不能等 validation participant 开始校验后再补交编辑值,否则字段规则和跨字段规则已经读取了旧快照。绑定不拥有 form、grid 或 grid edit session;页面释放时应取消绑定并释放各自资源。
提交
interface Draft { name: string} const form = new FormSession<Draft>({ name: '新项目' })form.registerField('name', { rules: [value => value.trim() ? undefined : '名称不能为空'],}) const save = async ( draft: Readonly<Draft>, _signal: AbortSignal,): Promise<Draft> => ({ ...draft }) const result = await form.runSubmit(save)if (result.status === 'submitted') { form.acceptChanges(result.value)}
返回状态:
| 状态 | 含义 |
|---|---|
submitted |
校验通过,提交函数已返回结果。 |
invalid |
字段规则、跨字段规则或参与者未通过。 |
busy |
当前会话已有提交正在进行。 |
cancelled |
调用了 cancelSubmit()、提交期间草稿发生变化、校验被更新运行取代,或提交进行中会话被释放。 |
runSubmit() 会在 submit 校验取得快照前同步收尾编辑器内的暂存值。通过 FormBindingBag 接入时,编辑器的 commitEdit() 返回 false 会生成字段错误并阻止保存;底层 FormEditParticipant 本身没有失败返回值。runSubmit() 也会把已注册字段标记为 touched,并维护 isSubmitting。它不会自动调用 acceptChanges(),因为有些业务保存后仍要等待审核、刷新或服务端回写。
存在未完成的 EditSessionTransaction 时,表单不会执行校验或提交。先 commit 或 rollback,避免把局部编辑器里的暂存值和正式草稿混在一次保存中。
恢复与释放
resetField(field):使当前提交和校验失效,先取消该字段编辑器的暂存输入,清除该字段规则及依赖规则产生的本地问题并恢复原值;外部 server/business 来源保持不变。reset():取消提交和校验,先取消全部编辑器暂存输入,清除参与者错误并恢复整份 baseline。acceptChanges(value?):取消提交和校验及全部编辑器暂存输入,清除错误,以当前或指定值建立新 baseline。cancelSubmit():通过 signal 取消当前提交。dispose():取消校验和提交,释放字段注册、参与者和全部订阅。
页面、弹窗或工作区文档必须持有并释放自己的 FormSession。使用显式字段绑定时,页面也持有 FormBindingBag,并在释放 FormSession 前先释放 binding bag。不要在释放后继续使用会话。
直接使用底层 Provider
已有自己的草稿模型时,可以只使用:
ErrorProvider<FieldKey>:多来源错误存储和排序。ValidationProvider<T>:通过getModel()读取外部草稿并执行规则。
新表单通常直接使用 FormSession<T>,避免页面重复组合和释放这些对象。
自定义 validation source 由 ValidationProvider 独占,不能再传给 setExternalIssues()。外部错误使用独立且稳定的名称,例如 server、business。