DirectSurface UIDirectSurface UI
开始使用
文档/业务指南

表单校验与提交

import {  ErrorProvider,  FormBindingBag,  FormSession,  RenderFieldPresenter,  RenderNumberInput,  RenderTextBox,  ValidationProvider,} from 'ds-ui'

FormSession 把草稿、错误、校验和提交状态组合在一个页面级会话中:

FormSession
  EditSession       baseline、draft、dirty、touched
  ErrorProvider     本地、服务端和业务错误
  ValidationProvider 同步、异步和跨字段规则

它不会根据组件树自动绑定输入组件。页面可以用 FormBindingBag.bindField() 显式声明字段、编辑器和字段壳的关系;需要完全自定义接线时,也可以直接调用下面的底层注册和校验 API。

LIVE CANVAS可运行组件示例
ON-DEMAND RUNTIME交互式示例将在进入视区时启动避免文档首屏同时初始化多个 Canvas Runtime

示例中的字段绑定同时处理值同步、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:指定字段、消息、级别和错误代码。
  • 数组:一次返回多条问题。
  • nullundefined:校验通过。

默认触发时机包含 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.isValidatingform.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()。外部错误使用独立且稳定的名称,例如 serverbusiness

相关文档