From 4b63c89c6562c84e74c8f879a9eb52d89115a650 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 23:51:27 +0000 Subject: [PATCH] =?UTF-8?q?docs(plugin-form):=20README=20=E7=9A=84=20Schem?= =?UTF-8?q?a=20API=20=E4=B8=8E=20Examples=20=E6=8C=89=20form=20=E7=9C=9F?= =?UTF-8?q?=E8=AF=BB=E7=9A=84=E9=94=AE=E9=9D=A2=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三组连体键面漂移,一次改对(#5075 的三组读数逐条复测通过,另修正两处计数)。 判据一律取构建产物 `dist/index.d.ts` 在 strict 下的真编译。 1. `validation` 写成 `[{ type, value, message }, …]` 三处 —— 真身是 `FormField.validation?: FieldValidationRules`,按规则名开键的**对象** (`types/src/form.ts:744`)。类型侧 TS2559 ×3;运行时侧是**静默**的,这是本 组最要紧的一半:唯一读点 `components/src/renderers/form/form.tsx:1652` 写的是 `const rules: any = { ...validation }`,把数组展开进对象字面量得到 `{ '0': …, '1': … }`,而 react-hook-form 的字段校验器只解构 required/maxLength/minLength/min/max/pattern/validate/valueAsNumber —— README 教出来的规则一条不跑且零报错。顺带写进两条读者猜不到的事实: `validation.required` 只提供**消息**(是否必填由字段自身的 required / requiredWhen 决定),手写的 `pattern.value` 必须给 RegExp(RHF 只应用 `value instanceof RegExp` 的 pattern;把声明的 string 编译成正则的是元数据 路线的 `buildValidationRules`)。 2. `type: 'multi-step-form'` 全仓未注册,`steps` 也不是任何 form schema 的键 (`types/src` 零声明,`.steps` 的读点全是 flow 执行日志)—— 整个示例渲染出 未知组件占位,`steps` 里的字段从不被读。改教两个真实入口:`object-form` + `formType: 'wizard'`(步骤即 `sections`,`ObjectForm.tsx:255` 的路由),以及 导出的 `WizardForm` 配 section 内联字段、无 dataSource(`onSuccess` 而非 `onSubmit`)。⛔ 未注册任何新 type 去把旧拼法变真。 3. `FormField` 参考块现场声明了一个本地 `interface FormField` —— 裸声明永远编译 通过。五行是错的(`type`/`label` 是**可选**;`validation` 见上; `defaultValue`/`className` 不是声明键),`ValidationRule` 这个类型名全仓不 存在,且 23 个真键只列了 7 个。改为对真身声明的键表 + `FormSchema` 键表,示例 逐块标注真类型 —— 标注本身就是要点:`FormField` 与 `BaseSchema` 都带 `[key: string]: any`,不带标注的 `const schema = { … }` 写什么都能过编译, 发明的键也永远不是编译错误。 卡面两处计数如实修正:缺席的真键是 **16** 个(卡面列了 15,漏了 `inputType` —— README 自己的示例在用它);「五处漂移」里的 `defaultValue`/`className` 两条 无法由探针证明,只能由声明缺席 + 读点 grep 证明(见 PR 正文的分层)。 纯文档改动:不改渲染器行为,不动 form.tsx 的读点,未新增任何能力、导出或类型。 Fixes #5075 Co-authored-by: Claude --- .../plugin-form-readme-key-face-5075.md | 51 ++++ packages/plugin-form/README.md | 256 ++++++++++++++---- 2 files changed, 255 insertions(+), 52 deletions(-) create mode 100644 .changeset/plugin-form-readme-key-face-5075.md diff --git a/.changeset/plugin-form-readme-key-face-5075.md b/.changeset/plugin-form-readme-key-face-5075.md new file mode 100644 index 000000000..b4bd11b51 --- /dev/null +++ b/.changeset/plugin-form-readme-key-face-5075.md @@ -0,0 +1,51 @@ +--- +'@object-ui/plugin-form': patch +--- + +Docs only: `packages/plugin-form/README.md`'s Schema API and Examples now spell +the keys the form renderers actually read (objectui#5075). Three connected +drifts, judged against the build product's `dist/index.d.ts` under `strict`: + +- **`validation` was written as an ARRAY** of `{ type, value, message }` entries + in three places. The real key is `FormField.validation?: FieldValidationRules` + — an OBJECT keyed by rule name (`required`, `minLength`, `maxLength`, `min`, + `max`, `pattern`, `validate`). The array form is worse than a type error, + because its runtime failure is SILENT: the only reader spreads the value into + the rule object handed to react-hook-form (`const rules: any = { ...validation }`, + `packages/components/src/renderers/form/form.tsx:1652`), and spreading an array + into an object literal yields numeric keys (`{ '0': …, '1': … }`). + react-hook-form's field validator reads exactly `required`, `maxLength`, + `minLength`, `min`, `max`, `pattern`, `validate`, `valueAsNumber` off its + descriptor, so every documented rule was dropped with nothing thrown — a form + copied from this README looked validated while validating nothing. The rewrite + also records two facts a reader could not have guessed: `validation.required` + supplies the required MESSAGE only (presence is decided by the field's own + `required` / `requiredWhen`), and a hand-authored `pattern.value` must be a + RegExp, since react-hook-form only applies a pattern whose value + `instanceof RegExp` and it is the object-metadata path (`buildValidationRules`) + that compiles a declared string into one. + +- **`type: 'multi-step-form'` is registered nowhere**, and `steps` is not a key + on any form schema — so the whole "Multi-Step Form" example rendered the + unknown-component placeholder, with the fields inside `steps` never read. The + example is replaced by the two real entry points: an `object-form` with + `formType: 'wizard'`, whose steps are its `sections` (this is what + `ObjectForm` routes to `WizardForm`), and the exported `WizardForm` itself + with inline section fields and no data source — the shape closest to what the + old snippet was reaching for. No new schema type was registered to make the + old spelling true. + +- **The `FormField` reference block declared a local `interface FormField`**, + which type-checks whatever it says because it is unrelated to the real type. + Five of its rows were wrong (`type` and `label` are OPTIONAL; `validation` is + the object above; `defaultValue` and `className` are not declared keys — the + form-level `defaultValues` and `span` / `colSpan` / `fieldContainerClass` are), + it named a `ValidationRule` type that exists nowhere in the repo, and it listed + 7 of the real 23 keys. The block is now a key table over the real declaration, + with `FormSchema`'s own keys beside it, and both examples are annotated with + their real types — the annotation is the point: `FormField` and `BaseSchema` + both declare `[key: string]: any`, so an un-annotated `const schema = { … }` + accepts any invented key and a nonexistent key is never a compile error. + +No renderer behaviour changes, and no capability, export or type was added to +make an example true. diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 9fa3a446d..060cbc055 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -143,39 +143,114 @@ component the same way. ## Schema API -### Form +Two form schemas reach a renderer, and **both are declared in +`@object-ui/types`** — this package imports them and declares neither. The +tables below name the keys and point at the declaration; the declaration is the +contract. (Restating an interface inside this README is what let this section +drift away from the code in the first place — objectui#5075.) -Complete form with fields and validation: +| Schema | `type` | Declared in | Rendered by | +|---|---|---|---| +| `FormSchema` | `'form'` | `packages/types/src/form.ts` | the basic form in `@object-ui/components` — bare `form` is deliberately **not** claimed by this plugin (`skipFallback: true`, see the table above) | +| `ObjectFormSchema` | `'object-form'` | `packages/types/src/objectql.ts` | `ObjectForm` here, through `plugin-form:object-form` / `object-form` | -```typescript -{ - type: 'form', - fields: FormField[], - submitLabel?: string, - cancelLabel?: string, - onSubmit?: (data) => void, - onCancel?: () => void, - className?: string -} -``` +Both `type` slots are string **literals**. A schema whose `type` names something +no registration claims does not fall back to a form — it renders the +unknown-component placeholder. + +### Form (`type: 'form'`) + +| Key | Type | Notes | +|---|---|---| +| `type` | `'form'` | literal, not a free string | +| `fields` | `FormField[]` | **optional** — a form may render `children` instead | +| `defaultValues` | `Record` | form-level initial values. There is no field-level `defaultValue` | +| `submitLabel` / `cancelLabel` | `string` | button text | +| `showCancel` / `showActions` | `boolean` | action-row composition | +| `mobileStickyActions` | `boolean` | pin the action row on small viewports | +| `layout` | `'vertical' \| 'horizontal'` | label placement | +| `columns` | `number` | grid width (1–4) | +| `validationMode` | `'onSubmit' \| 'onBlur' \| 'onChange' \| 'onTouched' \| 'all'` | when the rules run | +| `resetOnSubmit` / `disabled` | `boolean` | | +| `mode` | `'edit' \| 'read' \| 'disabled'` | whole-form mode | +| `objectName` | `string` | enables metadata field locators `data-testid="field:{objectName}.{field}"` (ADR-0054 C4) | +| `previousValues` | `Record` | edit-mode hosts only — the persisted record, as evaluation context for `previous` / `readonlyWhen`. Never sent anywhere | +| `fieldContainerClass` | `string` | class for the field grid inside the `
` | +| `fieldTabs` / `defaultFieldTab` / `fieldTabsPosition` | see [Tabbed field layout](#tabbed-field-layout-fieldtabs) | | +| `fieldPanes` / `fieldPanesOrientation` / `fieldPanesResizable` | see [Split field layout](#split-field-layout-fieldpanes) | | +| `actions` | `SchemaNode[]` | extra nodes in the action row | +| `children` | `SchemaNode \| SchemaNode[]` | custom body instead of `fields` | +| `onSubmit` / `onChange` / `onDirtyChange` / `onCancel` | callbacks | **TypeScript-authored schemas only** — a JSON metadata document cannot carry a function. Metadata pages go through the object-form route instead | +| `className`, `id`, `hidden`, … | — | inherited from `BaseSchema` | + +⚠️ `FormSchema` extends `BaseSchema`, which declares `[key: string]: any` +(`packages/types/src/base.ts`), so an invented or misspelled key on a form schema +is **not** a compile error — it is simply never read. That is why every example +below is annotated with its real type *and* checked against these key tables: +an un-annotated `const schema = { … }` type-checks whatever is written in it. ### Form Field -Individual form field configuration: +`FormField` (`packages/types/src/form.ts`) declares 23 keys, and `name` is the +only **required** one: -```typescript -interface FormField { - name: string; - type: string; // 'input', 'select', 'checkbox', etc. - label: string; - placeholder?: string; - required?: boolean; - validation?: ValidationRule[]; - defaultValue?: any; - disabled?: boolean; - className?: string; -} -``` +| Key | Type | What it does | +|---|---|---| +| `name` | `string` | **required** — the submit key | +| `id` | `string` | stable render key; falls back to `name` | +| `label` | `string` | optional — with none, no label element is rendered at all (validation messages fall back to `name`). The object-bound paths always fill it from the object field | +| `description` | `string` | help text under the control | +| `type` | `string` | optional, defaults to `'input'`. Built-ins: `input`, `textarea`, `checkbox`, `switch`, `select`; any other value resolves through the registry (`field:` first, then the bare name) | +| `inputType` | `string` | HTML input type for `type: 'input'` — `'email'`, `'password'`, `'tel'`, … | +| `widget` | `string` | widget override; wins over `type` (spec `FormField.widget`) | +| `placeholder` | `string` | | +| `required` | `boolean` | the presence rule. `validation.required` does **not** make a field required — see below | +| `disabled` | `boolean` | not interactive, muted | +| `readonly` | `boolean` | shown plainly, not editable — deliberately distinct from `disabled` | +| `hidden` | `boolean` | field is not rendered at all | +| `options` | `SelectOption[] \| RadioOption[]` | for `select` / radio fields | +| `validation` | `FieldValidationRules` | **an object keyed by rule name** — see below | +| `condition` | `FieldCondition` | legacy `{ field, equals, notEquals, in, custom }` matcher | +| `visibleWhen` / `readonlyWhen` / `requiredWhen` | `string \| { dialect?, source }` | CEL predicates over the live record, evaluated by `@objectstack/formula` — the same engine and dialect the server uses. Fail open | +| `visibleOn` | `string \| { dialect?, source }` | view-level visibility predicate (spec `FormField.visibleOn`) | +| `dependsOn` | `DependsOnInput` | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries | +| `span` | `'auto' \| 'full'` | relative width, independent of the column count (preferred) | +| `colSpan` | `number` | legacy column span (1–4), clamped to the current column count | +| `field` | `Record` | the resolved object-field **metadata object**, stashed by the object-bound paths so widgets can read `precision`, `currency`, `reference_to`, … In the *spec* form-view vocabulary `field` is a string (the referenced field name); that shape ends at `normalizeSectionField` and never reaches a runtime `FormField` | + +`FormField` also declares `[key: string]: any`, so an invented key type-checks +here too. Two that a reader might expect, and that are **not** declared: + +| Not a `FormField` key | Write this instead | +|---|---| +| `defaultValue` | `FormSchema.defaultValues` at form level. An object-bound form seeds from the object field's own declared `defaultValue` — see [What a create form opens with](#what-a-create-form-opens-with) | +| `className` | `span` / `colSpan` for width, `FormSchema.fieldContainerClass` for the grid. (A field-level `className` is read on exactly one pseudo-field, `type: 'section-divider'`, where it styles the inline section header.) | + +There is no `ValidationRule` type in this repo, under any spelling. + +#### `validation` is an object keyed by rule name + +`FieldValidationRules` (`packages/types/src/form.ts`) is **not** an array of +`{ type, value, message }` entries: + +| Rule | Type | Notes | +|---|---|---| +| `required` | `string \| boolean` | supplies the required **message** only. Whether the field is required is decided by `required` / `requiredWhen` **on the field** | +| `minLength` / `maxLength` | `{ value: number; message: string }` | `message` is not optional when you author the rule by hand | +| `min` / `max` | `{ value: number; message: string }` | numeric range | +| `pattern` | `{ value: string \| RegExp; message: string }` | pass a **RegExp** in a hand-authored schema: react-hook-form only applies a pattern whose value `instanceof RegExp`, and it is the object-metadata path (`buildValidationRules` in `@object-ui/fields`) that compiles a declared string into one | +| `validate` | `(value) => boolean \| string \| Promise` | custom check; TypeScript-authored schemas only | + +There is no `email` rule name — an email check is a `pattern`, which is exactly +what `buildValidationRules` emits for an object field of type `email`. + +**Why the array spelling fails silently.** The only reader of this key is the +basic form renderer, which spreads it into the rule object handed to +react-hook-form — `const rules: any = { ...validation }` +(`packages/components/src/renderers/form/form.tsx:1652`). Spreading an **array** +into an object literal produces numeric keys (`{ '0': …, '1': … }`), which +react-hook-form does not recognise: every rule is dropped, nothing throws, and +the form looks validated while validating nothing. ### What a create form opens with @@ -368,7 +443,9 @@ primary pane, the rest stack in the secondary one behind inline section headers. ### Basic Form ```typescript -const schema = { +import type { FormSchema } from '@object-ui/types'; + +const schema: FormSchema = { type: 'form', fields: [ { @@ -384,9 +461,15 @@ const schema = { inputType: 'email', label: 'Email Address', required: true, - validation: [ - { type: 'email', message: 'Invalid email format' } - ] + // Rule name → rule. Not an array (see Schema API above), and there is no + // 'email' rule: the email check is the pattern the metadata path builds + // for a field of type `email`. + validation: { + pattern: { + value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: 'Invalid email format' + } + } }, { name: 'country', @@ -413,51 +496,111 @@ const schema = { ### Multi-Step Form +A multi-step form is an **`object-form` with `formType: 'wizard'`**, and its +steps are its `sections` — one step per section. There is no +`multi-step-form` schema type (no registration anywhere claims that name) and no +`steps` key on any form schema, so a schema written that way renders the +unknown-component placeholder and the fields inside `steps` are never read. + ```typescript -const schema = { - type: 'multi-step-form', - steps: [ +import type { ObjectFormSchema } from '@object-ui/types'; + +const schema: ObjectFormSchema = { + type: 'object-form', + objectName: 'contacts', // required + mode: 'create', // required + formType: 'wizard', // routes to WizardForm — needs at least one section + sections: [ + { + name: 'personal', + label: 'Personal Info', + fields: ['first_name', 'last_name'] // field NAMES, resolved from the object schema + }, { - title: 'Personal Info', + name: 'contact', + label: 'Contact Info', + fields: ['email', 'phone'] + } + ], + allowSkip: false, // see "Wizard steps and allowSkip" above + showStepIndicator: true +}; +``` + +A section's `fields` accepts three shapes — a field **name**, a spec +`FormFieldSchema` object (whose identity key is `field`), or an inline runtime +`FormField` object. The inline shape is what lets a wizard run with no data +source at all, which is the closest equivalent of the old snippet; note that +`WizardForm` reports a data-source-less submit through `onSuccess` (there is no +`onSubmit` on this schema): + +```tsx +import { WizardForm } from '@object-ui/plugin-form'; +import type { WizardFormSchema } from '@object-ui/plugin-form'; + +const wizard: WizardFormSchema = { + type: 'object-form', + formType: 'wizard', + objectName: 'contacts', + mode: 'create', + sections: [ + { + name: 'personal', + label: 'Personal Info', fields: [ { name: 'firstName', type: 'input', label: 'First Name', required: true }, { name: 'lastName', type: 'input', label: 'Last Name', required: true } ] }, { - title: 'Contact Info', + name: 'contact', + label: 'Contact Info', fields: [ { name: 'email', type: 'input', inputType: 'email', label: 'Email', required: true }, { name: 'phone', type: 'input', inputType: 'tel', label: 'Phone' } ] - }, - { - title: 'Review', - fields: [] } ], - onSubmit: (data) => { + onSuccess: (data) => { console.log('Multi-step form completed:', data); } }; + + // dataSource omitted: every step lists inline fields ``` +`WizardFormSchema` declares no index signature, so an invented key on *this* +type is a real compile error — unlike `ObjectFormSchema`, which inherits +`BaseSchema`'s `[key: string]: any`. + +One more route exists and is worth knowing about rather than reinventing: a flat +`object-form` can be turned into a stepper on small viewports with +`mobile: { stepper: true | 'auto', stepperFieldsPerStep, stepperMinFields }`, +which feeds the same `WizardForm`. + ### Form with Validation ```typescript -const schema = { +import type { FormSchema } from '@object-ui/types'; + +const schema: FormSchema = { type: 'form', + validationMode: 'onBlur', fields: [ { name: 'username', type: 'input', label: 'Username', - required: true, - validation: [ - { type: 'minLength', value: 3, message: 'Username must be at least 3 characters' }, - { type: 'maxLength', value: 20, message: 'Username must be less than 20 characters' }, - { type: 'pattern', value: '^[a-zA-Z0-9_]+$', message: 'Only letters, numbers, and underscores' } - ] + required: true, // presence: this is the key that decides it + validation: { + required: 'Pick a username', // the MESSAGE for the rule above, nothing more + minLength: { value: 3, message: 'Username must be at least 3 characters' }, + maxLength: { value: 20, message: 'Username must be less than 20 characters' }, + pattern: { + value: /^[a-zA-Z0-9_]+$/, // a RegExp, not a string + message: 'Only letters, numbers, and underscores' + } + } }, { name: 'password', @@ -465,15 +608,24 @@ const schema = { inputType: 'password', label: 'Password', required: true, - validation: [ - { type: 'minLength', value: 8, message: 'Password must be at least 8 characters' }, - { type: 'pattern', value: '(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])', message: 'Must contain uppercase, lowercase, and number' } - ] + validation: { + minLength: { value: 8, message: 'Password must be at least 8 characters' }, + pattern: { + value: /(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])/, + message: 'Must contain uppercase, lowercase, and number' + }, + validate: (value) => + String(value).toLowerCase() !== 'password' || 'Pick something less guessable' + } } ] }; ``` +Each rule appears **once**, under its own name — an object, not a list. A second +`minLength` cannot exist, which is the point: the shape the renderer hands +react-hook-form is one rule per kind. + ## Integration with Data Sources Connect forms to backend APIs: