Browse Source
* build(@vben-core/form-ui): update form validation dependencies * refactor(@vben-core/form-ui): replace vee-validate with TanStack Form * test(@vben-core/form-ui): cover TanStack Form migration * docs(@vben/docs): document Zod 4 form migration * refactor(project): update form adapters * refactor(project): migrate application form consumers * refactor(@vben/playground): migrate form examples * docs(@vben/docs): fix validate return type and required rule empty check in form docs * fix(@vben-core/form-ui): resolve oxlint and eslint errors - rewrite nested ternary expressions as if/else in form-field-array.vue and form-runtime.ts - sort @tanstack/vue-form before @vben-core/composables in package.json * chore: fix merge artifacts and formatting - move dependencies before devDependencies in root package.json - update preferences snapshot for widget positioning fields - format form-array demo README * refactor(@vben-core/form-ui): optimize form runtime and value flow - add fine-grained field selectors and atomic dependency resolution - expose raw and formatted value snapshots with focused regression coverage * fix(@vben/layouts): dispose sortable instance on unmount * docs(@vben/docs): document form runtime API changes - list added, changed, removed, and deprecated form APIs - document atomic dependencies and raw versus formatted valuespull/8182/head
committed by
GitHub
81 changed files with 4660 additions and 1412 deletions
@ -0,0 +1,242 @@ |
|||||
|
--- |
||||
|
outline: deep |
||||
|
--- |
||||
|
|
||||
|
# Zod 4 and TanStack Form Migration |
||||
|
|
||||
|
This migration upgrades form schemas from Zod 3 to Zod 4 and replaces vee-validate with TanStack Form internally. The Vben business API remains stable while implementation-specific form APIs are removed from the public boundary. |
||||
|
|
||||
|
## Dependency Changes |
||||
|
|
||||
|
| Area | Before | After | |
||||
|
| --- | --- | --- | |
||||
|
| Schema | `zod@^3.25.76` | `zod@^4.4.3` | |
||||
|
| Defaults | `zod-defaults@0.1.3` | `zod-defaults@^0.2.3` | |
||||
|
| Form engine | `vee-validate@^4.15.1` | `@tanstack/vue-form@^1.33.2` | |
||||
|
| Zod adapter | `@vee-validate/zod@^4.15.1` | Removed; TanStack Form supports Standard Schema | |
||||
|
|
||||
|
Source files, package manifests, and the lockfile must no longer depend on `vee-validate` or `@vee-validate/zod`. |
||||
|
|
||||
|
## Compatibility Boundary |
||||
|
|
||||
|
The following Vben APIs remain supported: |
||||
|
|
||||
|
- `useVbenForm(options)` returning `[Form, formApi]` |
||||
|
- existing `FormApi` methods for values, reset, validation, submission, schema updates, and component refs |
||||
|
- existing `FormSchema` fields, dependencies, `valueFormat`, and array schema structure |
||||
|
- application adapters and the re-exported `z` namespace |
||||
|
- the existing `componentField` slot and binding shape |
||||
|
|
||||
|
`formApi.form` is now the library-independent `FormContextApi`. It exposes values, errors, meta, set/reset/validate/submit methods, and array operations without leaking vee or raw TanStack generics. |
||||
|
|
||||
|
New code uses `reset`, `submit`, `validateAndSubmit`, and `clearValidation`. The former `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` names remain deprecated forwarding aliases. They emit one warning per name in development and stay silent in production. |
||||
|
|
||||
|
## Form UI API Changes in This Refactor |
||||
|
|
||||
|
### Added APIs |
||||
|
|
||||
|
| API | Type/Location | Description | |
||||
|
| --- | --- | --- | |
||||
|
| `dependencies.resolve(context)` | `FormItemDependenciesResolve` | Evaluates one complete dynamic patch from declared `triggerFields` and commits it atomically. Context contains readonly `values`, `actions`, `controller`, and row-aware `schema`. | |
||||
|
| `useValues()` | `FormContextApi` | Subscribes to all form values. Use only when full-form reactivity is required. | |
||||
|
| `useFieldValue(fieldName)` | `FormContextApi` | Subscribes to one field value without reacting to unrelated fields. | |
||||
|
| `useFieldValues(fieldNames)` | `FormContextApi` | Subscribes to a declared group of field values. | |
||||
|
| `useFieldError(fieldName)` | `FormContextApi` | Subscribes to one field error without consuming the full error object. | |
||||
|
| `getRawValues()` | `FormApi` | Returns an independent raw snapshot before field mapping and `valueFormat`. | |
||||
|
| `formatValues(rawValues)` | `FormApi` | Runs the unified formatting pipeline on a supplied raw snapshot. | |
||||
|
| `getValueSnapshot()` | `FormApi` | Returns `{ rawValues, values }`, where `values` is the formatted payload. | |
||||
|
| `asyncDebounceMs` | `FormFieldOptions` | Configures TanStack Field async validation debounce. | |
||||
|
| `changeEventFallback` | `FormCommonConfig` / adapter config | Enables fallback for legacy components that emit `change` without `update:*`; defaults to `false`. | |
||||
|
|
||||
|
`dependencies.resolve` may return `if`, `show`, `disabled`, `required`, `rules`, `componentProps`, `help`, and `renderComponentContent`. Omitting `rules` keeps the static rule; returning `rules: null` disables it. |
||||
|
|
||||
|
### Changed APIs |
||||
|
|
||||
|
| API | Before | After | |
||||
|
| --- | --- | --- | |
||||
|
| Submit callback | `handleSubmit(values)` | `handleSubmit(values, rawValues)`; the first argument is formatted and the second is the matching readonly raw snapshot. Existing single-argument functions remain valid. | |
||||
|
| Values change callback | `handleValuesChange(values, fieldsChanged)` | `handleValuesChange(rawValues, fieldsChanged, getFormattedValues)`; formatting is lazy and incurs no clone/transform cost unless requested. | |
||||
|
| Field validation triggers | Four `validateOn*` booleans | `validateOn?: readonly ('blur' \| 'change')[]`; submit always validates. | |
||||
|
| Change-event compatibility | `disabledOnChangeListener: false` enabled fallback | `changeEventFallback: true` enables fallback with positive semantics. | |
||||
|
| Top-level render callbacks | `componentProps(values, actions, ctx)`, `help(values, actions, ctx)`, `renderComponentContent(values, actions, ctx)` | Receive only lightweight `FormSchemaContext`; value-dependent behavior moves to `dependencies.resolve`. | |
||||
|
| `validateAndSubmit()` | Repeated low-level validation/scroll handling and could validate again during submit | Delegates to canonical `validate()` and shared submission logic; invalid forms do not submit. | |
||||
|
| `getValues()` | Implicitly returned transformed values | Still returns the formatted payload; use `getRawValues()` for raw state. | |
||||
|
|
||||
|
### Removed APIs |
||||
|
|
||||
|
| Removed API | Replacement | |
||||
|
| --- | --- | |
||||
|
| `FormValidationOptions` | `validate()` and `validateField(fieldName)` no longer accept options. | |
||||
|
| `force` / `silent` / `validated-only` validation modes | Removed because these vee modes have no TanStack runtime semantics. | |
||||
|
| `validateOnBlur` / `validateOnChange` / `validateOnInput` / `validateOnModelUpdate` | Use `formFieldProps.validateOn`; input and model updates are represented by `change`. | |
||||
|
| `disabledOnChangeListener` | Use positive `changeEventFallback`. | |
||||
|
| `disabledOnInputListener` | Input listeners are no longer bound automatically; provide `componentProps.onInput` explicitly when required. | |
||||
|
| `values/actions` parameters from top-level schema render functions | Use `FormSchemaContext`; move value-dependent behavior to `dependencies.resolve`. | |
||||
|
|
||||
|
### Deprecated but Supported |
||||
|
|
||||
|
- `dependencies.if/show/disabled/required/rules/componentProps/trigger` remain compatible for this release, but every callback is marked `@deprecated` and emits one development warning. If both syntaxes bypass the type union, `resolve` wins. |
||||
|
- `resetForm`, `submitForm`, `resetValidate`, and `validateAndSubmitForm` continue forwarding to canonical methods. |
||||
|
- `FormActions` remains as a deprecated alias of `FormContextApi`. |
||||
|
- `setupVbenForm({ defineRules })` remains supported; `rules` wins for duplicate names. |
||||
|
- The re-exported `z`, `componentField` slots, and `emptyStateValue` remain unchanged. |
||||
|
|
||||
|
### Internal Behavior Changes |
||||
|
|
||||
|
- Field components use fine-grained value/error selectors; full error aggregation is no longer on the normal input path. |
||||
|
- Async validators discard stale Promises through a Vben generation without reading private TanStack AbortController or meta fields. |
||||
|
- New and legacy dependencies share one atomic executor, so stale async results cannot overwrite newer state. |
||||
|
- Formatting runs in a fixed array-to-string, range mapping, schema `valueFormat` order and performs one deep clone per formatted snapshot. |
||||
|
|
||||
|
## Typed Values and Slots |
||||
|
|
||||
|
Application adapters keep the UI component mapping fixed and expose the business value shape as the only generic: |
||||
|
|
||||
|
```ts |
||||
|
interface AccountFormValues { |
||||
|
email: string; |
||||
|
nickname: string; |
||||
|
} |
||||
|
|
||||
|
const [Form, formApi] = useVbenForm<AccountFormValues>({ |
||||
|
handleSubmit(values) { |
||||
|
return addAccount(values); |
||||
|
}, |
||||
|
schema: [ |
||||
|
{ component: 'Input', fieldName: 'email' }, |
||||
|
{ component: 'Input', fieldName: 'nickname' }, |
||||
|
], |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
`TValues` flows through `VbenFormProps`, `FormSchema`, `FormApi`, `FormContextApi`, value APIs, submit/change callbacks, selectors, and dynamic schema callbacks. The returned `Form` component also exposes typed slots: known field slots use the matching value type for `field.state.value` and `componentField.modelValue`, while all field/default/action slots receive the complete `values` and matching `formApi`. Legacy forms without `TValues` retain arbitrary slot names and broad props. |
||||
|
|
||||
|
## New and Legacy Rule Registration |
||||
|
|
||||
|
Use `rules` in new code: |
||||
|
|
||||
|
```ts |
||||
|
setupVbenForm({ |
||||
|
rules: { |
||||
|
required(value, _params, context) { |
||||
|
const isEmpty = |
||||
|
value === undefined || |
||||
|
value === null || |
||||
|
value === '' || |
||||
|
(Array.isArray(value) && value.length === 0); |
||||
|
return isEmpty ? `${context.label} is required` : true; |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
The legacy `defineRules` option forwards to the same registry: |
||||
|
|
||||
|
```ts |
||||
|
setupVbenForm({ |
||||
|
defineRules: { |
||||
|
required: legacyRequiredRule, |
||||
|
}, |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
Legacy runtime usage emits one warning per deprecation key in development and no warnings in production. If both options define the same rule, `rules` wins. The `FormActions` type remains as a deprecated alias of `FormContextApi`; editors report the type deprecation because type-only usage cannot emit runtime warnings. |
||||
|
|
||||
|
## Running the Codemod |
||||
|
|
||||
|
Run the pinned tool against each affected tsconfig from a clean Git worktree: |
||||
|
|
||||
|
```bash |
||||
|
npx --yes zod-v3-to-v4@1.21.3 path/to/tsconfig.json |
||||
|
``` |
||||
|
|
||||
|
The tool edits `.ts`, `.tsx`, and `.vue` files in place and has no dry-run mode. Always review `git diff` afterward. |
||||
|
|
||||
|
The codemod primarily recognizes direct `zod` imports. Schemas that obtain `z` through `@vben/common-ui` or an application adapter need manual review, especially constructor errors, string formats, and dynamic refinement messages. |
||||
|
|
||||
|
## Zod 4 Changes |
||||
|
|
||||
|
### Unified Error Parameters |
||||
|
|
||||
|
Replace `required_error` and `invalid_type_error` with `error`: |
||||
|
|
||||
|
```ts |
||||
|
const count = z.number({ |
||||
|
error: (issue) => |
||||
|
issue.input === undefined ? 'Count is required' : 'Count must be a number', |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
Use an `error(issue)` callback for dynamic refinement messages instead of passing a function that returns params as the second argument to `.refine()`. |
||||
|
|
||||
|
### String Formats and Errors |
||||
|
|
||||
|
Prefer top-level format schemas: |
||||
|
|
||||
|
```ts |
||||
|
z.email('Invalid email'); |
||||
|
z.url('Invalid URL'); |
||||
|
z.uuid('Invalid UUID'); |
||||
|
``` |
||||
|
|
||||
|
Read validation details from `ZodError.issues`; the old `.errors` property is removed. |
||||
|
|
||||
|
### Defaults and Optionality |
||||
|
|
||||
|
Zod 4 defaults may return immediately when the input is `undefined`. Review `.default().optional()` using actual parse behavior instead of internal type names. |
||||
|
|
||||
|
Vben initial values use this precedence: |
||||
|
|
||||
|
1. explicit schema `defaultValue` |
||||
|
2. Zod `.default()` |
||||
|
3. Zod 4-compatible `zod-defaults` |
||||
|
4. component empty-state conventions |
||||
|
|
||||
|
Required markers are derived from whether the schema accepts `undefined`. |
||||
|
|
||||
|
### Wrappers, Refine, Transform, and Coerce |
||||
|
|
||||
|
Do not read `_def`, `_zod.def`, or `typeName`. Use public `.unwrap()` APIs and public pipe inputs. Delegate intersection defaults to the Zod 4-compatible `zod-defaults` package. |
||||
|
|
||||
|
Standard Schema validation does not write transform/coerce output back into TanStack Form state. Keep using `valueFormat` for submission payload conversion, or explicitly call `parseAsync` at the submission boundary when transformed schema output is required. |
||||
|
|
||||
|
Also review these changes: |
||||
|
|
||||
|
- `z.record()` should specify key and value schemas |
||||
|
- `z.enum()` replaces former `nativeEnum` use cases |
||||
|
- number integer, Infinity, and finite behavior |
||||
|
- object strictness, merge, and unknown keys |
||||
|
- intersection merge conflicts |
||||
|
- coerce input types defaulting to `unknown` |
||||
|
- removal of Zod 3 types such as `ZodEffects`, `ZodTypeAny`, and `AnyZodObject` |
||||
|
|
||||
|
## Form Engine Behavior |
||||
|
|
||||
|
`formFieldProps.validateOn` accepts `blur` and `change`, with both enabled by default; submit always validates fields. `asyncDebounceMs` configures TanStack Field async debounce. The four vee-style `validateOn*` booleans and `force/silent/validated-only` modes have been removed. |
||||
|
|
||||
|
The shadcn form primitives now use a Vben-owned field context. Labels, controls, descriptions, and messages continue to provide ids, `aria-invalid`, `aria-describedby`, touched, dirty, valid, and error states. |
||||
|
|
||||
|
`clearValidation(fieldNames?)` advances Vben's validator generation and clears public error state without relying on a private TanStack AbortController. A Promise that finishes later is discarded as stale. Omitting `fieldNames` covers every registered field and every field with an existing error. |
||||
|
|
||||
|
`dependencies.resolve(context)` is the recommended API: it evaluates once and atomically commits one dynamic-state patch, while stale async results are discarded as a unit. Legacy `if/show/disabled/required/rules/componentProps/trigger` callbacks remain supported through the same normalized executor, but are marked `@deprecated` and emit one development warning. Both APIs react only to declared `triggerFields`. |
||||
|
|
||||
|
`handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` receives readonly raw values and formats only when its third argument is called. `getRawValues()` and `getValues()` each create only the requested snapshot; use `getValueSnapshot()` when both are required. `handleSubmit(values, rawValues)` receives both forms at submission. The formatter performs one deep clone, then applies array-to-string, range mapping, and schema `valueFormat` in order. Array fields keep using TanStack push/remove operations and stable row identity. |
||||
|
|
||||
|
## Test and Acceptance Matrix |
||||
|
|
||||
|
Required coverage includes: |
||||
|
|
||||
|
- Zod defaults, optional, nullable, intersection, pipe, transform, coerce, and errors |
||||
|
- runtime values, selectors, reset, manual errors, validation, and async validation |
||||
|
- field binding, blur/change triggers, error messages, ARIA, dependencies, and arrays |
||||
|
- new/legacy API equivalence, warning deduplication, production silence, and type aliases |
||||
|
- complete `useVbenForm` lifecycle, submission, `handleValuesChange`, submit-on-change, and async race handling |
||||
|
|
||||
|
Acceptance requires zero TypeScript errors, zero build errors, all tests passing, no unhandled browser errors, modified-file formatting and lint passing, and no source dependency on vee or Zod private structures. |
||||
|
|
||||
|
## References |
||||
|
|
||||
|
- [Zod 4 release notes](https://zod.dev/v4) |
||||
|
- [Zod migration guide](https://zod.dev/v4/changelog) |
||||
|
- [TanStack Form Vue overview](https://tanstack.com/form/latest/docs/framework/vue/overview) |
||||
|
- [TanStack Form validation](https://tanstack.com/form/latest/docs/framework/vue/guides/validation) |
||||
@ -0,0 +1,274 @@ |
|||||
|
--- |
||||
|
outline: deep |
||||
|
--- |
||||
|
|
||||
|
# Zod 4 与 TanStack Form 迁移指南 |
||||
|
|
||||
|
本次迁移将表单校验 schema 从 Zod 3 升级到 Zod 4,并将内部表单引擎从 vee-validate 替换为 TanStack Form。迁移目标是保持 Vben 业务 API 稳定,同时移除业务代码对具体表单引擎的耦合。 |
||||
|
|
||||
|
## 依赖变化 |
||||
|
|
||||
|
| 类型 | 迁移前 | 迁移后 | |
||||
|
| --- | --- | --- | |
||||
|
| Schema | `zod@^3.25.76` | `zod@^4.4.3` | |
||||
|
| 默认值 | `zod-defaults@0.1.3` | `zod-defaults@^0.2.3` | |
||||
|
| 表单引擎 | `vee-validate@^4.15.1` | `@tanstack/vue-form@^1.33.2` | |
||||
|
| Zod 适配器 | `@vee-validate/zod@^4.15.1` | 不再需要,TanStack Form 支持 Standard Schema | |
||||
|
|
||||
|
迁移后,源码、package manifest 和锁文件中都不应再依赖 `vee-validate` 或 `@vee-validate/zod`。 |
||||
|
|
||||
|
## 上层兼容范围 |
||||
|
|
||||
|
以下 Vben API 保持兼容: |
||||
|
|
||||
|
- `useVbenForm(options)` 仍返回 `[Form, formApi]` |
||||
|
- `FormApi` 的值、校验、提交、重置、schema 更新和组件引用能力 |
||||
|
- `FormSchema` 的 `fieldName`、`component`、`componentProps`、`rules`、`dependencies`、`defaultValue`、`valueFormat` 和数组字段结构 |
||||
|
- `dependencies.triggerFields` 与回调参数 |
||||
|
- 组件适配器和 `z` 重导出路径 |
||||
|
- 自定义 slot 中原有的 `componentField` 绑定对象 |
||||
|
|
||||
|
`formApi.form` 现在是库无关的 `FormContextApi`。它提供 values、errors、meta、字段读写、验证、提交、重置和数组操作,但不再暴露 vee `FormContext` 或原始 TanStack 实例。 |
||||
|
|
||||
|
新代码使用 `reset`、`submit`、`validateAndSubmit` 和 `clearValidation`。旧的 `resetForm`、`submitForm`、`validateAndSubmitForm` 和 `resetValidate` 仍会委托给新实现,并通过 `@deprecated` 与开发环境一次性 warning 提示迁移;生产环境不输出 warning。 |
||||
|
|
||||
|
## 本轮 Form UI API 变更 |
||||
|
|
||||
|
### 新增 API |
||||
|
|
||||
|
| API | 类型/位置 | 说明 | |
||||
|
| --- | --- | --- | |
||||
|
| `dependencies.resolve(context)` | `FormItemDependenciesResolve` | 根据声明的 `triggerFields` 一次计算完整动态 patch,并原子更新字段状态。context 包含只读 `values`、`actions`、`controller` 和数组行感知的 `schema`。 | |
||||
|
| `useValues()` | `FormContextApi` | 订阅完整表单值。仅在确实需要整表响应式值时使用。 | |
||||
|
| `useFieldValue(fieldName)` | `FormContextApi` | 订阅单字段值,避免无关字段变化触发组件更新。 | |
||||
|
| `useFieldValues(fieldNames)` | `FormContextApi` | 订阅一组字段值,主要用于声明式依赖计算。 | |
||||
|
| `useFieldError(fieldName)` | `FormContextApi` | 订阅单字段错误,不再依赖全量错误对象。 | |
||||
|
| `getRawValues()` | `FormApi` | 返回未执行字段映射与 `valueFormat` 的独立原始值快照。 | |
||||
|
| `formatValues(rawValues)` | `FormApi` | 对指定原始值执行统一格式化流水线。 | |
||||
|
| `getValueSnapshot()` | `FormApi` | 同时返回 `{ rawValues, values }`,其中 `values` 为格式化结果。 | |
||||
|
| `asyncDebounceMs` | `FormFieldOptions` | 设置 TanStack Field 异步校验防抖时间。 | |
||||
|
| `changeEventFallback` | `FormCommonConfig` / adapter config | 为只发送 `change`、不发送 `update:*` 的旧组件启用事件回退,默认 `false`。 | |
||||
|
|
||||
|
`dependencies.resolve` 可以返回 `if`、`show`、`disabled`、`required`、`rules`、`componentProps`、`help` 和 `renderComponentContent`。未返回 `rules` 时继续使用静态规则;显式返回 `rules: null` 时关闭静态规则。 |
||||
|
|
||||
|
### 变更的 API |
||||
|
|
||||
|
| API | 迁移前 | 迁移后 | |
||||
|
| --- | --- | --- | |
||||
|
| 提交回调 | `handleSubmit(values)` | `handleSubmit(values, rawValues)`;首参为格式化值,次参为同一次提交对应的只读原始快照。旧单参数函数仍可直接使用。 | |
||||
|
| 值变化回调 | `handleValuesChange(values, fieldsChanged)` | `handleValuesChange(rawValues, fieldsChanged, getFormattedValues)`;第三个参数为惰性格式化函数,不调用时不产生深拷贝和转换开销。 | |
||||
|
| 字段校验触发 | 四个 `validateOn*` 布尔项 | `validateOn?: readonly ('blur' \| 'change')[]`;submit 始终校验。 | |
||||
|
| change 事件兼容 | `disabledOnChangeListener: false` 表示启用 | `changeEventFallback: true` 表示启用,改为正向语义。 | |
||||
|
| 顶层动态渲染回调 | `componentProps(values, actions, ctx)`、`help(values, actions, ctx)`、`renderComponentContent(values, actions, ctx)` | 仅接收轻量 `FormSchemaContext`。依赖表单值的动态逻辑迁移到 `dependencies.resolve`。 | |
||||
|
| `validateAndSubmit()` | 自行调用底层校验并重复实现错误滚动,提交阶段可能再次校验 | 委托统一 `validate()` 与共享提交逻辑,无效时不提交,错误滚动只有一个实现。 | |
||||
|
| `getValues()` | 隐式完成所有字段转换 | 语义保持为“返回格式化值”;需要原始值时显式使用 `getRawValues()`。 | |
||||
|
|
||||
|
### 删除的 API |
||||
|
|
||||
|
| 已删除 API | 替代方式 | |
||||
|
| --- | --- | |
||||
|
| `FormValidationOptions` | `validate()` 与 `validateField(fieldName)` 不再接收 options。 | |
||||
|
| `force` / `silent` / `validated-only` validation mode | 这些 vee mode 在 TanStack runtime 中没有对应语义,直接删除。 | |
||||
|
| `validateOnBlur` / `validateOnChange` / `validateOnInput` / `validateOnModelUpdate` | 使用 `formFieldProps.validateOn`;input 与 model update 统一归入 `change`。 | |
||||
|
| `disabledOnChangeListener` | 使用正向语义的 `changeEventFallback`。 | |
||||
|
| `disabledOnInputListener` | 不再自动绑定 input listener;确需自定义 input 处理时在 `componentProps.onInput` 中显式提供。 | |
||||
|
| 顶层 schema 渲染函数中的 `values/actions` 参数 | 使用 `FormSchemaContext`;值相关联动使用 `dependencies.resolve`。 | |
||||
|
|
||||
|
### 已弃用但保留兼容 |
||||
|
|
||||
|
- `dependencies.if/show/disabled/required/rules/componentProps/trigger` 本轮仍完整兼容,但均已标记 `@deprecated`。开发环境首次使用时警告一次;新旧语法绕过类型同时存在时以 `resolve` 为准。 |
||||
|
- `resetForm`、`submitForm`、`resetValidate` 和 `validateAndSubmitForm` 继续转发到新方法。 |
||||
|
- `FormActions` 继续作为 `FormContextApi` 的弃用类型别名。 |
||||
|
- `setupVbenForm({ defineRules })` 继续兼容;与 `rules` 同名时新 API 优先。 |
||||
|
- `z` 重导出、`componentField` slot 和 `emptyStateValue` 保持不变。 |
||||
|
|
||||
|
### 非 API 行为调整 |
||||
|
|
||||
|
- 字段组件改用细粒度 value/error selector;全量错误聚合退出普通输入热路径。 |
||||
|
- async validator 通过 Vben generation 丢弃过期 Promise,不读取 TanStack 私有 AbortController 或 meta 字段。 |
||||
|
- dependencies 新旧语法共用一个原子执行器,异步旧结果不会覆盖新状态。 |
||||
|
- 值格式化按 array-to-string、时间范围映射、schema `valueFormat` 的固定顺序执行,并且每次格式化只深拷贝一次。 |
||||
|
|
||||
|
## 值类型与插槽类型 |
||||
|
|
||||
|
应用 adapter 保留 UI 组件类型,只把业务值类型作为泛型暴露: |
||||
|
|
||||
|
```ts |
||||
|
interface AccountFormValues { |
||||
|
email: string; |
||||
|
nickname: string; |
||||
|
} |
||||
|
|
||||
|
const [Form, formApi] = useVbenForm<AccountFormValues>({ |
||||
|
handleSubmit(values) { |
||||
|
return addAccount(values); |
||||
|
}, |
||||
|
schema: [ |
||||
|
{ component: 'Input', fieldName: 'email' }, |
||||
|
{ component: 'Input', fieldName: 'nickname' }, |
||||
|
], |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
`TValues` 会传递给 `VbenFormProps`、`FormSchema`、`FormApi`、`FormContextApi`、值读写 API、提交/变化回调、selector 和 schema 动态回调。返回的 `Form` 组件同时提供 typed slots:已知字段插槽的 `field.state.value` 与 `componentField.modelValue` 使用对应字段类型,并额外提供完整 `values` 与同型 `formApi`;默认和操作插槽也提供 `values/formApi`。未声明 `TValues` 的旧表单仍允许任意字段插槽并回退为宽泛类型。 |
||||
|
|
||||
|
## 新旧规则注册 API |
||||
|
|
||||
|
新代码使用 `rules`: |
||||
|
|
||||
|
```ts |
||||
|
setupVbenForm({ |
||||
|
rules: { |
||||
|
required(value, _params, context) { |
||||
|
const isEmpty = |
||||
|
value === undefined || |
||||
|
value === null || |
||||
|
value === '' || |
||||
|
(Array.isArray(value) && value.length === 0); |
||||
|
return isEmpty ? `${context.label} is required` : true; |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
旧的 `defineRules` 仍会转发到同一个规则注册表: |
||||
|
|
||||
|
```ts |
||||
|
setupVbenForm({ |
||||
|
defineRules: { |
||||
|
required: legacyRequiredRule, |
||||
|
}, |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
使用旧入口时,开发环境针对该弃用项只输出一次警告;生产环境不输出。若同时提供 `rules` 与 `defineRules` 的同名规则,`rules` 优先。`FormActions` 类型保留为 `FormContextApi` 的弃用别名,类型别名本身无法触发运行时警告,编辑器会通过 `@deprecated` 提示迁移。 |
||||
|
|
||||
|
## 使用迁移工具 |
||||
|
|
||||
|
建议在干净的 Git 工作树中按项目 tsconfig 执行固定版本工具: |
||||
|
|
||||
|
```bash |
||||
|
npx --yes zod-v3-to-v4@1.21.3 path/to/tsconfig.json |
||||
|
``` |
||||
|
|
||||
|
工具会原地修改 `.ts`、`.tsx` 和 `.vue` 文件,没有 dry-run 模式。执行后必须检查 `git diff`。 |
||||
|
|
||||
|
工具只能可靠识别直接从 `zod` 导入的调用。通过 `@vben/common-ui` 或应用 adapter 间接取得 `z` 的 schema 需要人工审计,尤其是构造器错误参数、字符串格式和动态 refine 参数。 |
||||
|
|
||||
|
## Zod 4 代码变更 |
||||
|
|
||||
|
### 错误参数 |
||||
|
|
||||
|
构造器中的 `required_error` 和 `invalid_type_error` 合并为 `error`: |
||||
|
|
||||
|
```ts |
||||
|
const count = z.number({ |
||||
|
error: (issue) => |
||||
|
issue.input === undefined ? 'Count is required' : 'Count must be a number', |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
refinement 继续支持字符串或对象参数。需要根据输入动态生成消息时,使用 `error(issue)`,不再传入返回 params 的第二个函数。 |
||||
|
|
||||
|
### 字符串格式 |
||||
|
|
||||
|
优先使用顶层格式 API: |
||||
|
|
||||
|
```ts |
||||
|
z.email('Invalid email'); |
||||
|
z.url('Invalid URL'); |
||||
|
z.uuid('Invalid UUID'); |
||||
|
``` |
||||
|
|
||||
|
旧的 `z.string().email()` 等形式不应继续新增。 |
||||
|
|
||||
|
### 错误列表 |
||||
|
|
||||
|
ZodError 使用 `issues`: |
||||
|
|
||||
|
```ts |
||||
|
const result = schema.safeParse(value); |
||||
|
if (!result.success) { |
||||
|
console.log(result.error.issues); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
不要读取已移除的 `.errors`。 |
||||
|
|
||||
|
### 默认值与 optional |
||||
|
|
||||
|
Zod 4 的 default 在输入为 `undefined` 时可以直接返回默认值。`.default().optional()` 的结果必须按实际 parse 语义复核,而不是通过类型名称猜测。 |
||||
|
|
||||
|
Vben 表单按以下优先级生成初值: |
||||
|
|
||||
|
1. schema 中显式 `defaultValue` |
||||
|
2. Zod schema 中的 `.default()` |
||||
|
3. `zod-defaults` 生成的对象、intersection 和基础空值 |
||||
|
4. Vben 组件约定的空字符串、空数组或空状态值 |
||||
|
|
||||
|
必填标记以 schema 是否接受 `undefined` 为准。 |
||||
|
|
||||
|
### 包装器、refine 与 transform |
||||
|
|
||||
|
不要读取 `_def`、`_zod.def` 或 `typeName`。公共包装器使用 `.unwrap()`;Zod 4 的 transform/pipe 使用公开的输入 schema。intersection 的默认值交给支持 Zod 4 的 `zod-defaults` 处理。 |
||||
|
|
||||
|
TanStack Form 使用 Standard Schema 校验时不会自动把 transform/coerce 的输出写回当前表单 state。提交 payload 需要转换时,继续使用 `valueFormat`;如果必须提交 schema transform 后的结果,应在提交边界显式调用 `parseAsync`。 |
||||
|
|
||||
|
### 其他需要复核的 API |
||||
|
|
||||
|
- `z.record()` 需要明确 key schema 与 value schema |
||||
|
- `z.enum()` 已覆盖原 `nativeEnum` 用法 |
||||
|
- number 的 `int`、Infinity 和 finite 约束需按 Zod 4 语义复核 |
||||
|
- object 的 strict、merge、unknown keys 行为需要通过测试确认 |
||||
|
- intersection 合并冲突现在可能直接抛出错误 |
||||
|
- coerce schema 的 input 类型默认为 `unknown` |
||||
|
- `ZodEffects`、`ZodTypeAny`、`AnyZodObject` 等 Zod 3 类型不应继续使用 |
||||
|
|
||||
|
## 表单引擎行为 |
||||
|
|
||||
|
### 验证触发 |
||||
|
|
||||
|
`formFieldProps.validateOn` 接收 `blur`、`change` 数组,默认两者都启用;所有字段仍会在 submit 时验证。`asyncDebounceMs` 映射到 TanStack Field 的异步防抖配置。原 vee 风格的四个 `validateOn*` 布尔项和 `force/silent/validated-only` mode 已删除。 |
||||
|
|
||||
|
### 错误与可访问性 |
||||
|
|
||||
|
shadcn form primitive 使用 Vben 自有字段上下文,不再注入 vee 的 `FieldContextKey`。`FormLabel`、`FormControl`、`FormDescription` 和 `FormMessage` 继续维护: |
||||
|
|
||||
|
- `for` 与 control id |
||||
|
- `aria-invalid` |
||||
|
- `aria-describedby` |
||||
|
- touched、dirty、valid 和错误消息 |
||||
|
|
||||
|
`clearValidation(fieldNames?)` 会递增 Vben validator generation 并清空公开错误状态,不依赖 TanStack 私有 AbortController。异步 Promise 即使随后完成也会因代次过期而被丢弃;省略字段参数时会处理全部已注册或已有错误的字段。 |
||||
|
|
||||
|
### 依赖与数组 |
||||
|
|
||||
|
`dependencies.resolve(context)` 是推荐语法:一次求值并原子提交完整动态 patch,过期异步结果整体丢弃。旧的 `if/show/disabled/required/rules/componentProps/trigger` 语法仍兼容,但已标记为 `@deprecated` 并在开发环境首次使用时提示迁移;内部仍归一到同一个执行器。两种语法都只根据 `triggerFields` 重算,无关字段变化不会执行回调。 |
||||
|
|
||||
|
`handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` 接收未格式化的只读当前值,第三个参数仅在调用时执行格式化。`getRawValues()` 和 `getValues()` 分别只生成原始或格式化快照;需要同时比较时使用 `getValueSnapshot()`。提交回调通过 `handleSubmit(values, rawValues)` 同时取得两种结构。格式化流水线只深拷贝一次,并按 array-to-string、时间范围映射、schema `valueFormat` 的顺序执行。数组字段继续使用 TanStack push/remove 操作和稳定行身份。 |
||||
|
|
||||
|
## 测试与验收 |
||||
|
|
||||
|
迁移至少需要覆盖以下层级: |
||||
|
|
||||
|
- Zod 4 helper:default、optional、nullable、intersection、pipe、transform、coerce 与错误参数 |
||||
|
- runtime:值读写、selector、reset、字段错误、validate 和异步校验 |
||||
|
- 组件:输入绑定、blur/change 触发、错误消息、ARIA、dependencies 和数组增删 |
||||
|
- 兼容:`rules`/`defineRules` 结果一致、开发 warning 去重、生产静默、类型别名 |
||||
|
- 集成:`useVbenForm` 生命周期、提交、`handleValuesChange`、submit-on-change 和 async race |
||||
|
|
||||
|
验收标准: |
||||
|
|
||||
|
1. 受影响 package、应用、playground 和 docs 无 TypeScript 错误 |
||||
|
2. form-ui 与所有应用构建成功 |
||||
|
3. 单元、组件和集成测试全部通过 |
||||
|
4. 浏览器 smoke 流程无 `pageerror`、`console.error` 或未处理 Promise |
||||
|
5. 修改文件通过 oxfmt 与 ESLint |
||||
|
6. 静态搜索中不再出现 vee 依赖、Zod 私有结构或 Zod 3 错误参数 |
||||
|
|
||||
|
## 参考资料 |
||||
|
|
||||
|
- [Zod 4 release notes](https://zod.dev/v4) |
||||
|
- [Zod migration guide](https://zod.dev/v4/changelog) |
||||
|
- [TanStack Form Vue overview](https://tanstack.com/form/latest/docs/framework/vue/overview) |
||||
|
- [TanStack Form validation](https://tanstack.com/form/latest/docs/framework/vue/guides/validation) |
||||
@ -0,0 +1,101 @@ |
|||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
|
import { setupVbenForm } from '../src/config'; |
||||
|
import { |
||||
|
resetDeprecationWarnings, |
||||
|
warnDeprecatedOnce, |
||||
|
} from '../src/deprecation'; |
||||
|
import { FormApi } from '../src/form-api'; |
||||
|
import { getFormRule } from '../src/rule-registry'; |
||||
|
|
||||
|
afterEach(() => { |
||||
|
resetDeprecationWarnings(); |
||||
|
vi.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
describe('form api compatibility', () => { |
||||
|
it('forwards defineRules and warns only once in development', async () => { |
||||
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
||||
|
const legacyRule = () => 'legacy error'; |
||||
|
|
||||
|
setupVbenForm({ defineRules: { legacy: legacyRule } }); |
||||
|
setupVbenForm({ defineRules: { legacy: legacyRule } }); |
||||
|
|
||||
|
expect(warning).toHaveBeenCalledOnce(); |
||||
|
expect(warning).toHaveBeenCalledWith( |
||||
|
'[Vben Form] `setupVbenForm({ defineRules })` is deprecated. Use `setupVbenForm({ rules })` instead.', |
||||
|
); |
||||
|
const registeredRule = getFormRule('legacy'); |
||||
|
expect(registeredRule).toBeDefined(); |
||||
|
if (!registeredRule) return; |
||||
|
expect( |
||||
|
await registeredRule('', [], { |
||||
|
field: { name: 'legacy' }, |
||||
|
name: 'legacy', |
||||
|
}), |
||||
|
).toBe('legacy error'); |
||||
|
}); |
||||
|
|
||||
|
it('prefers the new rules option when both APIs define the same rule', async () => { |
||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {}); |
||||
|
setupVbenForm({ |
||||
|
defineRules: { required: () => 'legacy error' }, |
||||
|
rules: { required: () => 'new error' }, |
||||
|
}); |
||||
|
|
||||
|
const registeredRule = getFormRule('required'); |
||||
|
expect(registeredRule).toBeDefined(); |
||||
|
if (!registeredRule) return; |
||||
|
expect( |
||||
|
await registeredRule('', [], { |
||||
|
field: { name: 'required' }, |
||||
|
name: 'required', |
||||
|
}), |
||||
|
).toBe('new error'); |
||||
|
}); |
||||
|
|
||||
|
it('does not emit deprecation warnings in production', () => { |
||||
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
||||
|
|
||||
|
warnDeprecatedOnce('legacy-api', 'deprecated', { production: true }); |
||||
|
|
||||
|
expect(warning).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('keeps legacy form methods and warns once for each name', async () => { |
||||
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
||||
|
const formApi = new FormApi(); |
||||
|
const form = { |
||||
|
clearValidation: vi.fn(), |
||||
|
meta: {}, |
||||
|
reset: vi.fn(), |
||||
|
submit: vi.fn(), |
||||
|
validate: vi.fn().mockResolvedValue({ errors: {}, valid: true }), |
||||
|
values: { name: 'Ada' }, |
||||
|
} as any; |
||||
|
formApi.mount(form); |
||||
|
|
||||
|
await formApi.resetForm(); |
||||
|
await formApi.resetForm(); |
||||
|
await formApi.resetValidate(); |
||||
|
await formApi.submitForm(); |
||||
|
await formApi.validateAndSubmitForm(); |
||||
|
|
||||
|
expect(form.reset).toHaveBeenCalledTimes(2); |
||||
|
expect(form.clearValidation).toHaveBeenCalledOnce(); |
||||
|
expect(form.submit).toHaveBeenCalledOnce(); |
||||
|
expect(warning).toHaveBeenCalledTimes(4); |
||||
|
expect(warning).toHaveBeenCalledWith( |
||||
|
'[Vben Form] `formApi.resetForm()` is deprecated. Use `formApi.reset()` instead.', |
||||
|
); |
||||
|
expect(warning).toHaveBeenCalledWith( |
||||
|
'[Vben Form] `formApi.resetValidate()` is deprecated. Use `formApi.clearValidation()` instead.', |
||||
|
); |
||||
|
expect(warning).toHaveBeenCalledWith( |
||||
|
'[Vben Form] `formApi.submitForm()` is deprecated. Use `formApi.submit()` instead.', |
||||
|
); |
||||
|
expect(warning).toHaveBeenCalledWith( |
||||
|
'[Vben Form] `formApi.validateAndSubmitForm()` is deprecated. Use `formApi.validateAndSubmit()` instead.', |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,656 @@ |
|||||
|
import type { VueWrapper } from '@vue/test-utils'; |
||||
|
|
||||
|
import type { FormSchemaRuleType } from '../src/types'; |
||||
|
|
||||
|
import { flushPromises, mount } from '@vue/test-utils'; |
||||
|
import { defineComponent, h, nextTick } from 'vue'; |
||||
|
|
||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; |
||||
|
import { z } from 'zod'; |
||||
|
|
||||
|
import { setupVbenForm } from '../src/config'; |
||||
|
import { resetDeprecationWarnings } from '../src/deprecation'; |
||||
|
import { useVbenForm } from '../src/use-vben-form'; |
||||
|
|
||||
|
const wrappers: VueWrapper[] = []; |
||||
|
|
||||
|
function createDeferred<T>() { |
||||
|
let resolvePromise: (value: T) => void = () => {}; |
||||
|
const promise = new Promise<T>((resolve) => { |
||||
|
resolvePromise = resolve; |
||||
|
}); |
||||
|
return { promise, resolve: resolvePromise }; |
||||
|
} |
||||
|
|
||||
|
const TestInput = defineComponent({ |
||||
|
inheritAttrs: false, |
||||
|
props: { |
||||
|
eventMode: { |
||||
|
default: 'model-value', |
||||
|
type: String, |
||||
|
}, |
||||
|
}, |
||||
|
emits: ['change', 'update:modelValue', 'update:value'], |
||||
|
setup(props, { attrs, emit }) { |
||||
|
function handleInput(event: Event) { |
||||
|
const target = event.target; |
||||
|
if (!(target instanceof HTMLInputElement)) { |
||||
|
return; |
||||
|
} |
||||
|
if (props.eventMode === 'change-only') { |
||||
|
emit('change', event); |
||||
|
return; |
||||
|
} |
||||
|
if (props.eventMode === 'value-and-change') { |
||||
|
emit('update:value', target.value); |
||||
|
emit('change', event); |
||||
|
return; |
||||
|
} |
||||
|
emit('update:modelValue', target.value); |
||||
|
} |
||||
|
|
||||
|
return () => |
||||
|
h('input', { |
||||
|
...attrs, |
||||
|
onInput: handleInput, |
||||
|
value: attrs.modelValue ?? '', |
||||
|
}); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
beforeAll(() => { |
||||
|
setupVbenForm({ |
||||
|
config: {}, |
||||
|
rules: { |
||||
|
required(value, _params, context) { |
||||
|
return value ? true : `${context.label} is required`; |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
for (const wrapper of wrappers.splice(0)) { |
||||
|
wrapper.unmount(); |
||||
|
} |
||||
|
vi.useRealTimers(); |
||||
|
vi.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
describe('useVbenForm integration', () => { |
||||
|
it('uses model updates as the primary channel and preserves empty strings', async () => { |
||||
|
const validateValue = vi.fn(); |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
componentProps: { eventMode: 'value-and-change' }, |
||||
|
defaultValue: 'initial', |
||||
|
fieldName: 'name', |
||||
|
modelPropName: 'value', |
||||
|
rules: z.string().superRefine((value) => validateValue(value)), |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
const initialValidationCount = validateValue.mock.calls.length; |
||||
|
|
||||
|
await wrapper.get('input').setValue(''); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(await formApi.getValues()).toEqual({ name: '' }); |
||||
|
expect(validateValue).toHaveBeenCalledTimes(initialValidationCount + 1); |
||||
|
}); |
||||
|
|
||||
|
it('supports a field-level change event fallback for legacy components', async () => { |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
componentProps: { eventMode: 'change-only' }, |
||||
|
changeEventFallback: true, |
||||
|
fieldName: 'name', |
||||
|
modelPropName: 'value', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
await wrapper.get('input').setValue('fallback'); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(await formApi.getValues()).toEqual({ name: 'fallback' }); |
||||
|
}); |
||||
|
|
||||
|
it('warns once for legacy dependency callbacks', async () => { |
||||
|
resetDeprecationWarnings(); |
||||
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
||||
|
const [Form] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
dependencies: { |
||||
|
show: true, |
||||
|
triggerFields: ['toggle'], |
||||
|
}, |
||||
|
fieldName: 'first', |
||||
|
}, |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
dependencies: { |
||||
|
disabled: false, |
||||
|
triggerFields: ['toggle'], |
||||
|
}, |
||||
|
fieldName: 'second', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(warning).toHaveBeenCalledOnce(); |
||||
|
expect(warning).toHaveBeenCalledWith( |
||||
|
'[Vben Form] Legacy dependency callbacks are deprecated. Use `dependencies.resolve(context)` instead.', |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('binds fields, renders accessible errors, and submits valid values', async () => { |
||||
|
const consoleError = vi |
||||
|
.spyOn(console, 'error') |
||||
|
.mockImplementation(() => {}); |
||||
|
const handleSubmit = vi.fn(); |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
handleSubmit, |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'name', |
||||
|
label: 'Name', |
||||
|
rules: z.string().min(1, 'Name is required'), |
||||
|
valueFormat: (value) => value.trim(), |
||||
|
}, |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'alias', |
||||
|
label: 'Alias', |
||||
|
rules: 'required', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form, { attachTo: document.body }); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(await formApi.validate()).toEqual({ |
||||
|
errors: { |
||||
|
alias: 'Alias is required', |
||||
|
name: 'Name is required', |
||||
|
}, |
||||
|
valid: false, |
||||
|
}); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
const inputs = wrapper.findAll('input'); |
||||
|
expect(inputs).toHaveLength(2); |
||||
|
expect(inputs[0]?.attributes('aria-invalid')).toBe('true'); |
||||
|
expect(wrapper.text()).toContain('Name is required'); |
||||
|
expect(wrapper.text()).toContain('Alias is required'); |
||||
|
|
||||
|
await inputs[0]?.setValue('Ada'); |
||||
|
await formApi.setFieldValue('alias', 'Countess', true); |
||||
|
await flushPromises(); |
||||
|
expect(wrapper.text()).not.toContain('Name is required'); |
||||
|
|
||||
|
expect(await formApi.validateField('name')).toEqual({ |
||||
|
errors: {}, |
||||
|
valid: true, |
||||
|
}); |
||||
|
expect(await formApi.validateAndSubmit()).toEqual({ |
||||
|
alias: 'Countess', |
||||
|
name: 'Ada', |
||||
|
}); |
||||
|
expect(handleSubmit).toHaveBeenCalledOnce(); |
||||
|
expect(handleSubmit).toHaveBeenCalledWith( |
||||
|
{ |
||||
|
alias: 'Countess', |
||||
|
name: 'Ada', |
||||
|
}, |
||||
|
{ |
||||
|
alias: 'Countess', |
||||
|
name: 'Ada', |
||||
|
}, |
||||
|
); |
||||
|
expect(consoleError).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('recomputes dependencies only from declared trigger fields', async () => { |
||||
|
const dependency = vi.fn((values: Record<string, any>) => { |
||||
|
return values.toggle === 'show'; |
||||
|
}); |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'toggle', |
||||
|
label: 'Toggle', |
||||
|
}, |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
dependencies: { |
||||
|
if: dependency, |
||||
|
triggerFields: ['toggle'], |
||||
|
}, |
||||
|
fieldName: 'details', |
||||
|
label: 'Details', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(wrapper.find('input[name="details"]').exists()).toBe(false); |
||||
|
const initialCalls = dependency.mock.calls.length; |
||||
|
|
||||
|
await formApi.setFieldValue('unrelated', 'value'); |
||||
|
await flushPromises(); |
||||
|
expect(dependency).toHaveBeenCalledTimes(initialCalls); |
||||
|
|
||||
|
await formApi.setFieldValue('toggle', 'show'); |
||||
|
await flushPromises(); |
||||
|
expect(wrapper.find('input[name="details"]').exists()).toBe(true); |
||||
|
expect(dependency.mock.calls.length).toBeGreaterThan(initialCalls); |
||||
|
}); |
||||
|
|
||||
|
it('resolves dependency patches atomically from declared fields', async () => { |
||||
|
const pendingPatch = createDeferred<{ |
||||
|
componentProps: { placeholder: string }; |
||||
|
if: boolean; |
||||
|
}>(); |
||||
|
const resolve = vi.fn(({ values }: { values: Record<string, any> }) => { |
||||
|
if (values.toggle === 'pending') { |
||||
|
return pendingPatch.promise; |
||||
|
} |
||||
|
return { |
||||
|
componentProps: { placeholder: 'initial' }, |
||||
|
if: false, |
||||
|
}; |
||||
|
}); |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'toggle', |
||||
|
label: 'Toggle', |
||||
|
}, |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
dependencies: { |
||||
|
resolve, |
||||
|
triggerFields: ['toggle'], |
||||
|
}, |
||||
|
fieldName: 'details', |
||||
|
label: 'Details', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(wrapper.find('input[name="details"]').exists()).toBe(false); |
||||
|
const initialCalls = resolve.mock.calls.length; |
||||
|
|
||||
|
await formApi.setFieldValue('unrelated', 'value'); |
||||
|
await flushPromises(); |
||||
|
expect(resolve).toHaveBeenCalledTimes(initialCalls); |
||||
|
|
||||
|
await formApi.setFieldValue('toggle', 'pending'); |
||||
|
await flushPromises(); |
||||
|
expect(wrapper.find('input[name="details"]').exists()).toBe(false); |
||||
|
|
||||
|
pendingPatch.resolve({ |
||||
|
componentProps: { placeholder: 'resolved' }, |
||||
|
if: true, |
||||
|
}); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
const details = wrapper.find('input[name="details"]'); |
||||
|
expect(details.exists()).toBe(true); |
||||
|
expect(details.attributes('placeholder')).toBe('resolved'); |
||||
|
}); |
||||
|
|
||||
|
it('applies required rules enabled by dependencies after mount', async () => { |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'toggle', |
||||
|
label: 'Toggle', |
||||
|
}, |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
dependencies: { |
||||
|
required(values) { |
||||
|
return values.toggle === true; |
||||
|
}, |
||||
|
triggerFields: ['toggle'], |
||||
|
}, |
||||
|
fieldName: 'details', |
||||
|
label: 'Details', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(await formApi.validate()).toEqual({ errors: {}, valid: true }); |
||||
|
|
||||
|
await formApi.setFieldValue('toggle', true); |
||||
|
await flushPromises(); |
||||
|
expect(await formApi.validate()).toEqual({ |
||||
|
errors: { details: 'Details is required' }, |
||||
|
valid: false, |
||||
|
}); |
||||
|
|
||||
|
await formApi.setFieldValue('details', 'ready'); |
||||
|
expect(await formApi.validate()).toEqual({ errors: {}, valid: true }); |
||||
|
}); |
||||
|
|
||||
|
it('allows dependencies to disable static rules with null', async () => { |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'toggle', |
||||
|
label: 'Toggle', |
||||
|
}, |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
dependencies: { |
||||
|
rules(values) { |
||||
|
return values.toggle === true |
||||
|
? z.string().min(1, 'Details is required') |
||||
|
: null; |
||||
|
}, |
||||
|
triggerFields: ['toggle'], |
||||
|
}, |
||||
|
fieldName: 'details', |
||||
|
label: 'Details', |
||||
|
rules: z.string().min(1, 'Static details rule'), |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(await formApi.validate()).toEqual({ errors: {}, valid: true }); |
||||
|
|
||||
|
await formApi.setFieldValue('toggle', true); |
||||
|
await flushPromises(); |
||||
|
expect(await formApi.validate()).toEqual({ |
||||
|
errors: { details: 'Details is required' }, |
||||
|
valid: false, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('ignores stale async dependency rule results', async () => { |
||||
|
const requiredRules = createDeferred<FormSchemaRuleType>(); |
||||
|
const optionalRules = createDeferred<FormSchemaRuleType>(); |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'mode', |
||||
|
label: 'Mode', |
||||
|
}, |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
dependencies: { |
||||
|
rules(values) { |
||||
|
if (values.mode === 'required') { |
||||
|
return requiredRules.promise; |
||||
|
} |
||||
|
if (values.mode === 'optional') { |
||||
|
return optionalRules.promise; |
||||
|
} |
||||
|
return null; |
||||
|
}, |
||||
|
triggerFields: ['mode'], |
||||
|
}, |
||||
|
fieldName: 'details', |
||||
|
label: 'Details', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
await formApi.setFieldValue('mode', 'required'); |
||||
|
await flushPromises(); |
||||
|
await formApi.setFieldValue('mode', 'optional'); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
optionalRules.resolve(null); |
||||
|
await flushPromises(); |
||||
|
requiredRules.resolve(z.string().min(1, 'Stale required rule')); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(await formApi.validate()).toEqual({ errors: {}, valid: true }); |
||||
|
}); |
||||
|
|
||||
|
it('keeps array values and rendered rows aligned after mutations', async () => { |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
children: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'name', |
||||
|
label: 'Name', |
||||
|
rules: z.string().min(1, 'Name is required'), |
||||
|
}, |
||||
|
], |
||||
|
defaultValue: [{ name: 'Ada' }], |
||||
|
fieldName: 'contacts', |
||||
|
type: 'array', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(wrapper.findAll('input')).toHaveLength(1); |
||||
|
formApi.form.pushFieldValue('contacts', { name: 'Grace' }); |
||||
|
await flushPromises(); |
||||
|
expect(wrapper.findAll('input')).toHaveLength(2); |
||||
|
expect(await formApi.getValues()).toEqual({ |
||||
|
contacts: [{ name: 'Ada' }, { name: 'Grace' }], |
||||
|
}); |
||||
|
|
||||
|
await formApi.form.removeFieldValue('contacts', 0); |
||||
|
await flushPromises(); |
||||
|
expect(wrapper.findAll('input')).toHaveLength(1); |
||||
|
expect(await formApi.getValues()).toEqual({ |
||||
|
contacts: [{ name: 'Grace' }], |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('scopes resolve dependencies to array rows', async () => { |
||||
|
const resolve = vi.fn(({ schema }: Record<string, any>) => ({ |
||||
|
componentProps: { |
||||
|
disabled: schema.row?.role === 'viewer', |
||||
|
}, |
||||
|
})); |
||||
|
const [Form] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
children: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'role', |
||||
|
label: 'Role', |
||||
|
}, |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
dependencies: { |
||||
|
resolve, |
||||
|
triggerFields: ['role'], |
||||
|
}, |
||||
|
fieldName: 'phone', |
||||
|
label: 'Phone', |
||||
|
}, |
||||
|
], |
||||
|
defaultValue: [{ phone: '', role: 'viewer' }], |
||||
|
fieldName: 'contacts', |
||||
|
type: 'array', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(resolve).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ |
||||
|
schema: expect.objectContaining({ |
||||
|
fieldName: 'contacts[0].phone', |
||||
|
row: { phone: '', role: 'viewer' }, |
||||
|
rowIndex: 0, |
||||
|
rowPath: 'contacts[0]', |
||||
|
}), |
||||
|
}), |
||||
|
); |
||||
|
expect( |
||||
|
wrapper.get('input[name="contacts[0].phone"]').attributes('disabled'), |
||||
|
).toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
it('reports changed fields and submits valid changes', async () => { |
||||
|
vi.useFakeTimers(); |
||||
|
const handleSubmit = vi.fn(); |
||||
|
const handleValuesChange = vi.fn(); |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
changeDebouncedTime: 0, |
||||
|
handleSubmit, |
||||
|
handleValuesChange, |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'name', |
||||
|
label: 'Name', |
||||
|
rules: z.string().min(1, 'Name is required'), |
||||
|
valueFormat: (value) => value.trim(), |
||||
|
}, |
||||
|
], |
||||
|
submitOnChange: true, |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
await formApi.setFieldValue('name', ' Ada '); |
||||
|
await nextTick(); |
||||
|
await vi.runAllTimersAsync(); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(handleValuesChange).toHaveBeenCalledWith( |
||||
|
{ name: ' Ada ' }, |
||||
|
['name'], |
||||
|
expect.any(Function), |
||||
|
); |
||||
|
const valuesChangeCall = handleValuesChange.mock.calls.at(0); |
||||
|
expect(valuesChangeCall).toBeDefined(); |
||||
|
if (!valuesChangeCall) return; |
||||
|
expect(valuesChangeCall[2]()).toEqual({ name: 'Ada' }); |
||||
|
expect(handleSubmit).toHaveBeenCalledWith( |
||||
|
{ name: 'Ada' }, |
||||
|
{ name: ' Ada ' }, |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('respects blur and change validation triggers', async () => { |
||||
|
const [Form] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
defaultValue: 'valid', |
||||
|
fieldName: 'name', |
||||
|
formFieldProps: { |
||||
|
validateOn: ['blur'], |
||||
|
}, |
||||
|
label: 'Name', |
||||
|
rules: z.string().min(1, 'Name is required'), |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
const input = wrapper.get('input'); |
||||
|
|
||||
|
await input.setValue(''); |
||||
|
await flushPromises(); |
||||
|
expect(wrapper.text()).not.toContain('Name is required'); |
||||
|
|
||||
|
await input.trigger('blur'); |
||||
|
await flushPromises(); |
||||
|
expect(wrapper.text()).toContain('Name is required'); |
||||
|
|
||||
|
await input.setValue('Ada'); |
||||
|
await flushPromises(); |
||||
|
expect(wrapper.text()).not.toContain('Name is required'); |
||||
|
|
||||
|
await input.trigger('blur'); |
||||
|
await flushPromises(); |
||||
|
expect(wrapper.text()).not.toContain('Name is required'); |
||||
|
}); |
||||
|
|
||||
|
it('ignores stale asynchronous validation results', async () => { |
||||
|
let resolveTaken: (() => void) | undefined; |
||||
|
const usernameRule = z.string().refine(async (value) => { |
||||
|
if (value === 'taken') { |
||||
|
await new Promise<void>((resolve) => { |
||||
|
resolveTaken = resolve; |
||||
|
}); |
||||
|
} |
||||
|
return value !== 'taken'; |
||||
|
}, 'Username is already taken'); |
||||
|
const [Form, formApi] = useVbenForm({ |
||||
|
schema: [ |
||||
|
{ |
||||
|
component: TestInput, |
||||
|
fieldName: 'username', |
||||
|
label: 'Username', |
||||
|
rules: usernameRule, |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form); |
||||
|
wrappers.push(wrapper); |
||||
|
await flushPromises(); |
||||
|
const input = wrapper.get('input'); |
||||
|
|
||||
|
await input.setValue('taken'); |
||||
|
await vi.waitFor(() => { |
||||
|
expect(resolveTaken).toBeDefined(); |
||||
|
}); |
||||
|
if (!resolveTaken) return; |
||||
|
|
||||
|
await input.setValue('available'); |
||||
|
await flushPromises(); |
||||
|
resolveTaken(); |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(formApi.form.getFieldError('username')).toBeUndefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,236 @@ |
|||||
|
import type { FormActions } from '../src/types'; |
||||
|
|
||||
|
import { flushPromises, mount } from '@vue/test-utils'; |
||||
|
import { defineComponent, h, nextTick, watch } from 'vue'; |
||||
|
|
||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
|
import { useFormRuntime } from '../src/form-runtime'; |
||||
|
|
||||
|
const wrappers: ReturnType<typeof mount>[] = []; |
||||
|
|
||||
|
function mountRuntime( |
||||
|
defaultValues: Record<string, any>, |
||||
|
validator?: (input: { value: any }) => Promise<string | undefined>, |
||||
|
) { |
||||
|
let form: FormActions | undefined; |
||||
|
const RuntimeHarness = defineComponent({ |
||||
|
setup() { |
||||
|
const runtime = useFormRuntime(defaultValues); |
||||
|
form = runtime; |
||||
|
return () => { |
||||
|
if (!validator) { |
||||
|
return h('div'); |
||||
|
} |
||||
|
return h( |
||||
|
runtime.fieldComponent, |
||||
|
{ |
||||
|
name: 'name', |
||||
|
validators: { |
||||
|
onSubmitAsync: validator, |
||||
|
}, |
||||
|
}, |
||||
|
{ |
||||
|
default: ({ field }: Record<string, any>) => |
||||
|
h('input', { |
||||
|
name: 'name', |
||||
|
onBlur: field.handleBlur, |
||||
|
onInput: (event: Event) => { |
||||
|
const target = event.target; |
||||
|
if (target instanceof HTMLInputElement) { |
||||
|
field.handleChange(target.value); |
||||
|
} |
||||
|
}, |
||||
|
value: field.state.value, |
||||
|
}), |
||||
|
}, |
||||
|
); |
||||
|
}; |
||||
|
}, |
||||
|
}); |
||||
|
const wrapper = mount(RuntimeHarness); |
||||
|
wrappers.push(wrapper); |
||||
|
return { form, wrapper }; |
||||
|
} |
||||
|
|
||||
|
afterEach(() => { |
||||
|
for (const wrapper of wrappers.splice(0)) { |
||||
|
wrapper.unmount(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
describe('form runtime', () => { |
||||
|
it('updates values and resets to defaults', async () => { |
||||
|
const { form } = mountRuntime({ name: 'initial' }); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
|
||||
|
await form.setFieldValue('name', 'updated'); |
||||
|
await nextTick(); |
||||
|
expect(form.values).toEqual({ name: 'updated' }); |
||||
|
|
||||
|
await form.reset(); |
||||
|
await nextTick(); |
||||
|
expect(form.values).toEqual({ name: 'initial' }); |
||||
|
}); |
||||
|
|
||||
|
it('preserves empty string field updates', async () => { |
||||
|
const { form, wrapper } = mountRuntime( |
||||
|
{ name: 'initial' }, |
||||
|
async () => undefined, |
||||
|
); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
|
||||
|
await wrapper.find('input').setValue(''); |
||||
|
await nextTick(); |
||||
|
|
||||
|
expect(form.values).toEqual({ name: '' }); |
||||
|
}); |
||||
|
|
||||
|
it('exposes reactive selectors', async () => { |
||||
|
const { form } = mountRuntime({ name: 'initial' }); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
const name = form.useSelector((state) => state.values.name); |
||||
|
|
||||
|
await form.setFieldValue('name', 'updated'); |
||||
|
await nextTick(); |
||||
|
expect(name.value).toBe('updated'); |
||||
|
}); |
||||
|
|
||||
|
it('updates only changed field value selectors', async () => { |
||||
|
const { form } = mountRuntime({ email: '', name: 'initial' }); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
const name = form.useFieldValue('name'); |
||||
|
const selectedValues = form.useFieldValues(['name'] as const); |
||||
|
const onNameChange = vi.fn(); |
||||
|
const stop = watch(name, onNameChange); |
||||
|
|
||||
|
await form.setFieldValue('email', 'ada@example.com'); |
||||
|
await nextTick(); |
||||
|
expect(onNameChange).not.toHaveBeenCalled(); |
||||
|
expect(selectedValues.value).toEqual(['initial']); |
||||
|
|
||||
|
await form.setFieldValue('name', 'Ada'); |
||||
|
await nextTick(); |
||||
|
expect(onNameChange).toHaveBeenCalledOnce(); |
||||
|
expect(name.value).toBe('Ada'); |
||||
|
expect(selectedValues.value).toEqual(['Ada']); |
||||
|
stop(); |
||||
|
}); |
||||
|
|
||||
|
it('exposes reactive field error selectors', async () => { |
||||
|
const { form } = mountRuntime({ email: '', name: '' }); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
const nameError = form.useFieldError('name'); |
||||
|
const onNameErrorChange = vi.fn(); |
||||
|
const stop = watch(nameError, onNameErrorChange); |
||||
|
|
||||
|
form.setFieldError('email', 'Email error'); |
||||
|
await nextTick(); |
||||
|
expect(onNameErrorChange).not.toHaveBeenCalled(); |
||||
|
|
||||
|
form.setFieldError('name', 'Name error'); |
||||
|
await nextTick(); |
||||
|
expect(nameError.value).toBe('Name error'); |
||||
|
expect(onNameErrorChange).toHaveBeenCalledOnce(); |
||||
|
stop(); |
||||
|
}); |
||||
|
|
||||
|
it('validates mounted fields and clears stale errors', async () => { |
||||
|
const { form } = mountRuntime({ name: '' }, async ({ value }) => { |
||||
|
return value ? undefined : 'Name is required'; |
||||
|
}); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
|
||||
|
expect(await form.validate()).toEqual({ |
||||
|
errors: { name: 'Name is required' }, |
||||
|
valid: false, |
||||
|
}); |
||||
|
|
||||
|
await form.setFieldValue('name', 'Ada'); |
||||
|
await flushPromises(); |
||||
|
expect(await form.validateField('name')).toEqual({ |
||||
|
errors: {}, |
||||
|
valid: true, |
||||
|
}); |
||||
|
expect(form.isFieldValid('name')).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('sets and clears manual field errors', async () => { |
||||
|
const { form } = mountRuntime({ name: '' }, async () => undefined); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
|
||||
|
form.setFieldError('name', 'Server error'); |
||||
|
await nextTick(); |
||||
|
expect(form.getFieldError('name')).toBe('Server error'); |
||||
|
expect(form.meta.valid).toBe(false); |
||||
|
|
||||
|
form.setFieldError('name'); |
||||
|
await nextTick(); |
||||
|
expect(form.getFieldError('name')).toBeUndefined(); |
||||
|
expect(form.meta.valid).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('clears manual errors when resetting the form', async () => { |
||||
|
const { form } = mountRuntime({ name: '' }); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
|
||||
|
form.setFieldError('name', 'Server error'); |
||||
|
await nextTick(); |
||||
|
expect(form.errors).toEqual({ name: 'Server error' }); |
||||
|
|
||||
|
await form.reset(); |
||||
|
await nextTick(); |
||||
|
expect(form.errors).toEqual({}); |
||||
|
expect(form.meta.valid).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('invalidates in-flight async validation when clearing validation', async () => { |
||||
|
let resolveValidation: ((error: string | undefined) => void) | undefined; |
||||
|
let notifyValidationStarted: (() => void) | undefined; |
||||
|
const validationStarted = new Promise<void>((resolve) => { |
||||
|
notifyValidationStarted = resolve; |
||||
|
}); |
||||
|
const validator = vi.fn(() => { |
||||
|
notifyValidationStarted?.(); |
||||
|
return new Promise<string | undefined>((resolve) => { |
||||
|
resolveValidation = resolve; |
||||
|
}); |
||||
|
}); |
||||
|
const { form } = mountRuntime({ name: '' }, validator); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
|
||||
|
const pendingValidation = form.validateField('name'); |
||||
|
await validationStarted; |
||||
|
form.clearValidation(); |
||||
|
resolveValidation?.('Name is already used'); |
||||
|
await pendingValidation; |
||||
|
await flushPromises(); |
||||
|
|
||||
|
expect(form.errors).toEqual({}); |
||||
|
expect(form.meta.validating).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('clears only the requested field validation state', async () => { |
||||
|
const { form } = mountRuntime({ email: '', name: '' }); |
||||
|
expect(form).toBeDefined(); |
||||
|
if (!form) return; |
||||
|
|
||||
|
form.setFieldError('name', 'Name error'); |
||||
|
form.setFieldError('email', 'Email error'); |
||||
|
await nextTick(); |
||||
|
|
||||
|
form.clearValidation('name'); |
||||
|
await nextTick(); |
||||
|
|
||||
|
expect(form.errors).toEqual({ email: 'Email error' }); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,153 @@ |
|||||
|
import type { |
||||
|
BaseFormComponentType, |
||||
|
ExtendedFormApi, |
||||
|
FormActions, |
||||
|
FormContextApi, |
||||
|
FormFieldOptions, |
||||
|
FormItemDependencies, |
||||
|
FormValidationResult, |
||||
|
VbenFormAdapterOptions, |
||||
|
VbenFormProps, |
||||
|
} from '../src/types'; |
||||
|
|
||||
|
import { describe, expectTypeOf, it } from 'vitest'; |
||||
|
|
||||
|
import { useVbenForm } from '../src/use-vben-form'; |
||||
|
|
||||
|
interface AccountFormValues { |
||||
|
email: string; |
||||
|
profile: { |
||||
|
nickname: string; |
||||
|
}; |
||||
|
roles: string[]; |
||||
|
} |
||||
|
|
||||
|
describe('form public types', () => { |
||||
|
it('keeps the compatibility alias and stable method signatures', () => { |
||||
|
expectTypeOf<FormActions>().toEqualTypeOf<FormContextApi>(); |
||||
|
expectTypeOf<FormActions['setFieldValue']>() |
||||
|
.parameter(0) |
||||
|
.toMatchTypeOf<string>(); |
||||
|
expectTypeOf< |
||||
|
FormActions['validate'] |
||||
|
>().returns.resolves.toEqualTypeOf<FormValidationResult>(); |
||||
|
expectTypeOf<Parameters<FormActions['validate']>>().toEqualTypeOf<[]>(); |
||||
|
expectTypeOf<Parameters<FormActions['validateField']>>().toEqualTypeOf< |
||||
|
[fieldName: string] |
||||
|
>(); |
||||
|
}); |
||||
|
|
||||
|
it('accepts both new and deprecated rule registration options', () => { |
||||
|
expectTypeOf<VbenFormAdapterOptions>().toMatchTypeOf<{ |
||||
|
defineRules?: Record<string, unknown>; |
||||
|
rules?: Record<string, unknown>; |
||||
|
}>(); |
||||
|
}); |
||||
|
|
||||
|
it('supports resolve and legacy dependency contracts', () => { |
||||
|
const resolveDependencies: FormItemDependencies<AccountFormValues> = { |
||||
|
resolve({ actions, controller, schema, values }) { |
||||
|
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>(); |
||||
|
expectTypeOf(actions).toEqualTypeOf<FormActions<AccountFormValues>>(); |
||||
|
expectTypeOf(controller).toEqualTypeOf< |
||||
|
ExtendedFormApi<AccountFormValues> |
||||
|
>(); |
||||
|
expectTypeOf(schema.fieldName).toEqualTypeOf<string | undefined>(); |
||||
|
return { disabled: !values.email, rules: null }; |
||||
|
}, |
||||
|
triggerFields: ['email'], |
||||
|
}; |
||||
|
const legacyDependencies: FormItemDependencies<AccountFormValues> = { |
||||
|
show(values) { |
||||
|
expectTypeOf(values).toEqualTypeOf<Partial<AccountFormValues>>(); |
||||
|
return Boolean(values.email); |
||||
|
}, |
||||
|
triggerFields: ['email'], |
||||
|
}; |
||||
|
const fieldOptions: FormFieldOptions = { |
||||
|
asyncDebounceMs: 200, |
||||
|
validateOn: ['blur', 'change'], |
||||
|
}; |
||||
|
|
||||
|
expectTypeOf(resolveDependencies).toMatchTypeOf< |
||||
|
FormItemDependencies<AccountFormValues> |
||||
|
>(); |
||||
|
expectTypeOf(legacyDependencies).toMatchTypeOf< |
||||
|
FormItemDependencies<AccountFormValues> |
||||
|
>(); |
||||
|
expectTypeOf(fieldOptions).toMatchTypeOf<FormFieldOptions>(); |
||||
|
}); |
||||
|
|
||||
|
it('propagates form value types through public APIs and callbacks', () => { |
||||
|
const options: VbenFormProps< |
||||
|
BaseFormComponentType, |
||||
|
Record<never, never>, |
||||
|
AccountFormValues |
||||
|
> = { |
||||
|
handleSubmit(values, rawValues) { |
||||
|
expectTypeOf(values).toEqualTypeOf<AccountFormValues>(); |
||||
|
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>(); |
||||
|
}, |
||||
|
handleValuesChange(values, _fieldsChanged, getFormattedValues) { |
||||
|
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>(); |
||||
|
expectTypeOf(getFormattedValues()).toEqualTypeOf<AccountFormValues>(); |
||||
|
}, |
||||
|
schema: [], |
||||
|
}; |
||||
|
const [Form, formApi] = useVbenForm<AccountFormValues>(options); |
||||
|
|
||||
|
expectTypeOf(formApi).toEqualTypeOf<ExtendedFormApi<AccountFormValues>>(); |
||||
|
|
||||
|
function assertContextApi( |
||||
|
contextApi: FormContextApi<AccountFormValues>, |
||||
|
typedFormApi: ExtendedFormApi<AccountFormValues>, |
||||
|
) { |
||||
|
expectTypeOf( |
||||
|
typedFormApi.getValues(), |
||||
|
).resolves.toEqualTypeOf<AccountFormValues>(); |
||||
|
expectTypeOf( |
||||
|
typedFormApi.getRawValues(), |
||||
|
).resolves.toEqualTypeOf<AccountFormValues>(); |
||||
|
expectTypeOf(typedFormApi.getValueSnapshot()).resolves.toEqualTypeOf<{ |
||||
|
rawValues: Readonly<AccountFormValues>; |
||||
|
values: AccountFormValues; |
||||
|
}>(); |
||||
|
expectTypeOf(typedFormApi.setValues) |
||||
|
.parameter(0) |
||||
|
.toEqualTypeOf<Partial<AccountFormValues>>(); |
||||
|
expectTypeOf(typedFormApi.form.values).toEqualTypeOf<AccountFormValues>(); |
||||
|
expectTypeOf(contextApi.getFieldValue('email')).toEqualTypeOf<string>(); |
||||
|
expectTypeOf( |
||||
|
contextApi.useSelector((state) => state.values.profile.nickname), |
||||
|
).toEqualTypeOf<Readonly<import('vue').Ref<string>>>(); |
||||
|
} |
||||
|
|
||||
|
expectTypeOf(assertContextApi).toBeFunction(); |
||||
|
|
||||
|
type FormSlots = InstanceType<typeof Form>['$slots']; |
||||
|
type EmailSlot = NonNullable<FormSlots['email']>; |
||||
|
type EmailSlotProps = Parameters<EmailSlot>[0]; |
||||
|
type DefaultSlot = NonNullable<FormSlots['default']>; |
||||
|
type DefaultSlotProps = Parameters<DefaultSlot>[0]; |
||||
|
|
||||
|
expectTypeOf< |
||||
|
EmailSlotProps['field']['state']['value'] |
||||
|
>().toEqualTypeOf<string>(); |
||||
|
expectTypeOf<EmailSlotProps['values']>().toEqualTypeOf<AccountFormValues>(); |
||||
|
expectTypeOf<EmailSlotProps['formApi']>().toEqualTypeOf< |
||||
|
ExtendedFormApi<AccountFormValues> |
||||
|
>(); |
||||
|
expectTypeOf< |
||||
|
DefaultSlotProps['values'] |
||||
|
>().toEqualTypeOf<AccountFormValues>(); |
||||
|
}); |
||||
|
|
||||
|
it('exposes canonical names alongside deprecated aliases', () => { |
||||
|
expectTypeOf<FormContextApi['reset']>().toEqualTypeOf< |
||||
|
FormContextApi['resetForm'] |
||||
|
>(); |
||||
|
expectTypeOf<FormContextApi['submit']>().toEqualTypeOf< |
||||
|
FormContextApi['submitForm'] |
||||
|
>(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,90 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { |
||||
|
applyFormValueFormats, |
||||
|
formatFormValues, |
||||
|
transformRangeTimeValues, |
||||
|
} from '../src/form-value-transform'; |
||||
|
|
||||
|
describe('form value transforms', () => { |
||||
|
it('maps array and range fields without mutating input values', () => { |
||||
|
const input = { |
||||
|
period: [1_710_000_000_000, 1_720_000_000_000], |
||||
|
tags: ['admin', 'editor'], |
||||
|
}; |
||||
|
|
||||
|
const result = transformRangeTimeValues( |
||||
|
input, |
||||
|
[['period', ['startTime', 'endTime'], null]], |
||||
|
['tags'], |
||||
|
); |
||||
|
|
||||
|
expect(result).toEqual({ |
||||
|
endTime: 1_720_000_000_000, |
||||
|
startTime: 1_710_000_000_000, |
||||
|
tags: 'admin,editor', |
||||
|
}); |
||||
|
expect(input).toEqual({ |
||||
|
period: [1_710_000_000_000, 1_720_000_000_000], |
||||
|
tags: ['admin', 'editor'], |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('formats array children with row and root paths', () => { |
||||
|
const values = { |
||||
|
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }], |
||||
|
}; |
||||
|
const schema = [ |
||||
|
{ |
||||
|
children: [ |
||||
|
{ |
||||
|
component: 'text', |
||||
|
fieldName: 'name', |
||||
|
valueFormat(value: string, setValue: any, _values: any, ctx: any) { |
||||
|
setValue('$row.normalizedName', value.trim()); |
||||
|
setValue('$root.lastRow', ctx.rowIndex); |
||||
|
}, |
||||
|
}, |
||||
|
], |
||||
|
fieldName: 'contacts', |
||||
|
type: 'array', |
||||
|
}, |
||||
|
] as any; |
||||
|
|
||||
|
const result = applyFormValueFormats(values, schema); |
||||
|
|
||||
|
expect(result).toEqual({ |
||||
|
contacts: [{ normalizedName: 'Ada' }, { normalizedName: 'Grace' }], |
||||
|
lastRow: 1, |
||||
|
}); |
||||
|
expect(values).toEqual({ |
||||
|
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }], |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('runs the unified formatting pipeline in a stable order', () => { |
||||
|
const result = formatFormValues( |
||||
|
{ |
||||
|
period: [1, 2], |
||||
|
tags: ['admin', 'editor'], |
||||
|
title: ' Ada ', |
||||
|
}, |
||||
|
[ |
||||
|
{ |
||||
|
component: 'text', |
||||
|
fieldName: 'title', |
||||
|
valueFormat: (value: string) => value.trim(), |
||||
|
}, |
||||
|
], |
||||
|
[['period', ['startTime', 'endTime'], null]], |
||||
|
['tags'], |
||||
|
); |
||||
|
|
||||
|
expect(result).toEqual({ |
||||
|
endTime: 2, |
||||
|
startTime: 1, |
||||
|
tags: 'admin,editor', |
||||
|
title: 'Ada', |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,69 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
import { z, ZodString } from 'zod'; |
||||
|
import { getDefaultsForSchema } from 'zod-defaults'; |
||||
|
|
||||
|
import { |
||||
|
getBaseRules, |
||||
|
getDefaultValueInZodStack, |
||||
|
} from '../src/form-render/helper'; |
||||
|
|
||||
|
describe('zod v4 schema helpers', () => { |
||||
|
it('unwraps optional and default schemas with public APIs', () => { |
||||
|
const schema = z.string().default('default value').optional(); |
||||
|
|
||||
|
expect(getBaseRules(schema)).toBeInstanceOf(ZodString); |
||||
|
expect(getDefaultValueInZodStack(schema)).toBe('default value'); |
||||
|
}); |
||||
|
|
||||
|
it('unwraps the input side of a transform pipe', () => { |
||||
|
const schema = z.string().transform((value) => value.length); |
||||
|
|
||||
|
expect(getBaseRules(schema)).toBeInstanceOf(ZodString); |
||||
|
}); |
||||
|
|
||||
|
it('returns undefined when a schema rejects undefined', () => { |
||||
|
expect(getDefaultValueInZodStack(z.string())).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('does not throw for an asynchronous default pipeline', () => { |
||||
|
const schema = z |
||||
|
.string() |
||||
|
.default('default value') |
||||
|
.transform(async (value) => value.toUpperCase()); |
||||
|
|
||||
|
expect(getDefaultValueInZodStack(schema)).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('uses zod v4 error callbacks for required and invalid inputs', () => { |
||||
|
const schema = z.number({ |
||||
|
error: (issue) => |
||||
|
issue.input === undefined ? 'required' : 'invalid number', |
||||
|
}); |
||||
|
|
||||
|
expect(schema.safeParse(undefined).error?.issues[0]?.message).toBe( |
||||
|
'required', |
||||
|
); |
||||
|
expect(schema.safeParse('1').error?.issues[0]?.message).toBe( |
||||
|
'invalid number', |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('extracts defaults from intersections without private schema access', () => { |
||||
|
const schema = z.intersection( |
||||
|
z.object({ enabled: z.boolean().default(true), name: z.string() }), |
||||
|
z.object({ count: z.number(), note: z.string().default('note') }), |
||||
|
); |
||||
|
|
||||
|
expect(getDefaultsForSchema(schema)).toEqual({ |
||||
|
count: 0, |
||||
|
enabled: true, |
||||
|
name: '', |
||||
|
note: 'note', |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('keeps nullable and coerce input semantics explicit', () => { |
||||
|
expect(z.string().nullable().safeParse(undefined).success).toBe(false); |
||||
|
expect(z.coerce.number().parse('42')).toBe(42); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,18 @@ |
|||||
|
const warnedDeprecations = new Set<string>(); |
||||
|
|
||||
|
export function resetDeprecationWarnings() { |
||||
|
warnedDeprecations.clear(); |
||||
|
} |
||||
|
|
||||
|
export function warnDeprecatedOnce( |
||||
|
key: string, |
||||
|
message: string, |
||||
|
options: { production?: boolean } = {}, |
||||
|
) { |
||||
|
const production = options.production ?? import.meta.env.PROD; |
||||
|
if (production || warnedDeprecations.has(key)) { |
||||
|
return; |
||||
|
} |
||||
|
warnedDeprecations.add(key); |
||||
|
console.warn(message); |
||||
|
} |
||||
@ -0,0 +1,77 @@ |
|||||
|
import type { Component } from 'vue'; |
||||
|
|
||||
|
import { defineComponent, h, markRaw, onUnmounted } from 'vue'; |
||||
|
|
||||
|
type AsyncFieldValidator = (...args: any[]) => Promise<unknown> | unknown; |
||||
|
|
||||
|
export type FieldValidationInvalidator = () => void; |
||||
|
|
||||
|
const asyncValidatorKeys = [ |
||||
|
'onBlurAsync', |
||||
|
'onChangeAsync', |
||||
|
'onDynamicAsync', |
||||
|
'onSubmitAsync', |
||||
|
] as const; |
||||
|
|
||||
|
export function createRuntimeFieldComponent( |
||||
|
fieldComponent: Component, |
||||
|
registerInvalidator: ( |
||||
|
fieldName: string, |
||||
|
invalidator: FieldValidationInvalidator, |
||||
|
) => () => void, |
||||
|
) { |
||||
|
return markRaw( |
||||
|
defineComponent({ |
||||
|
inheritAttrs: false, |
||||
|
setup(_, { attrs, slots }) { |
||||
|
const fieldName = String(attrs.name ?? ''); |
||||
|
let validationRunId = 0; |
||||
|
let cachedValidators: Record<string, any> | undefined; |
||||
|
let cachedWrappedValidators: Record<string, any> | undefined; |
||||
|
const unregisterInvalidator = registerInvalidator(fieldName, () => { |
||||
|
validationRunId += 1; |
||||
|
}); |
||||
|
onUnmounted(unregisterInvalidator); |
||||
|
|
||||
|
function wrapValidators(validators: Record<string, any>) { |
||||
|
if (validators === cachedValidators && cachedWrappedValidators) { |
||||
|
return cachedWrappedValidators; |
||||
|
} |
||||
|
const wrappedValidators = { ...validators }; |
||||
|
for (const key of asyncValidatorKeys) { |
||||
|
const validator = validators[key] as |
||||
|
| AsyncFieldValidator |
||||
|
| undefined; |
||||
|
if (!validator) { |
||||
|
continue; |
||||
|
} |
||||
|
wrappedValidators[key] = async (...args: any[]) => { |
||||
|
const currentValidationRunId = ++validationRunId; |
||||
|
const result = await validator(...args); |
||||
|
return currentValidationRunId === validationRunId |
||||
|
? result |
||||
|
: undefined; |
||||
|
}; |
||||
|
} |
||||
|
cachedValidators = validators; |
||||
|
cachedWrappedValidators = wrappedValidators; |
||||
|
return wrappedValidators; |
||||
|
} |
||||
|
|
||||
|
return () => { |
||||
|
const validators = attrs.validators as |
||||
|
| Record<string, any> |
||||
|
| undefined; |
||||
|
return h( |
||||
|
fieldComponent, |
||||
|
{ |
||||
|
...attrs, |
||||
|
...(validators ? { validators: wrapValidators(validators) } : {}), |
||||
|
}, |
||||
|
slots, |
||||
|
); |
||||
|
}; |
||||
|
}, |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,303 @@ |
|||||
|
import type { FieldValidationInvalidator } from './form-runtime-field'; |
||||
|
import type { |
||||
|
FormActions, |
||||
|
FormFieldName, |
||||
|
FormFieldValue, |
||||
|
FormResetOptions, |
||||
|
FormRuntimeState, |
||||
|
FormValues, |
||||
|
} from './types'; |
||||
|
|
||||
|
import { computed, shallowRef } from 'vue'; |
||||
|
|
||||
|
import { useForm } from '@tanstack/vue-form'; |
||||
|
|
||||
|
import { createRuntimeFieldComponent } from './form-runtime-field'; |
||||
|
|
||||
|
function normalizeError(error: unknown): string | undefined { |
||||
|
if (typeof error === 'string') { |
||||
|
return error; |
||||
|
} |
||||
|
if (error && typeof error === 'object' && 'message' in error) { |
||||
|
const message = Reflect.get(error, 'message'); |
||||
|
return typeof message === 'string' ? message : undefined; |
||||
|
} |
||||
|
return error === undefined || error === null ? undefined : String(error); |
||||
|
} |
||||
|
|
||||
|
function normalizeFieldMetaError(meta: unknown) { |
||||
|
if (!meta || typeof meta !== 'object' || !('errors' in meta)) { |
||||
|
return undefined; |
||||
|
} |
||||
|
const errors = Reflect.get(meta, 'errors'); |
||||
|
return normalizeError(Array.isArray(errors) ? errors[0] : undefined); |
||||
|
} |
||||
|
|
||||
|
export function useFormRuntime<TValues extends FormValues>( |
||||
|
defaultValues: TValues, |
||||
|
): FormActions<TValues> { |
||||
|
const rawForm = useForm({ |
||||
|
defaultValues, |
||||
|
onSubmit: () => {}, |
||||
|
}); |
||||
|
const values = rawForm.useSelector((formState) => formState.values); |
||||
|
const fieldMeta = rawForm.useSelector((formState) => formState.fieldMeta); |
||||
|
const isDirty = rawForm.useSelector((formState) => formState.isDirty); |
||||
|
const isSubmitting = rawForm.useSelector( |
||||
|
(formState) => formState.isSubmitting, |
||||
|
); |
||||
|
const isValid = rawForm.useSelector((formState) => formState.isValid); |
||||
|
const isValidating = rawForm.useSelector( |
||||
|
(formState) => formState.isValidating, |
||||
|
); |
||||
|
const manualErrors = shallowRef(new Map<string, string>()); |
||||
|
const validationInvalidators = new Map< |
||||
|
string, |
||||
|
Set<FieldValidationInvalidator> |
||||
|
>(); |
||||
|
|
||||
|
function registerValidationInvalidator( |
||||
|
fieldName: string, |
||||
|
invalidator: FieldValidationInvalidator, |
||||
|
) { |
||||
|
let fieldInvalidators = validationInvalidators.get(fieldName); |
||||
|
if (!fieldInvalidators) { |
||||
|
fieldInvalidators = new Set(); |
||||
|
validationInvalidators.set(fieldName, fieldInvalidators); |
||||
|
} |
||||
|
fieldInvalidators.add(invalidator); |
||||
|
return () => { |
||||
|
invalidator(); |
||||
|
fieldInvalidators.delete(invalidator); |
||||
|
if (fieldInvalidators.size === 0) { |
||||
|
validationInvalidators.delete(fieldName); |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function invalidateFieldValidation(fieldName: string) { |
||||
|
for (const invalidator of validationInvalidators.get(fieldName) ?? []) { |
||||
|
invalidator(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const RuntimeField = createRuntimeFieldComponent( |
||||
|
rawForm.Field, |
||||
|
registerValidationInvalidator, |
||||
|
); |
||||
|
|
||||
|
function getErrors() { |
||||
|
const result: Record<string, string> = {}; |
||||
|
for (const [fieldName, meta] of Object.entries(fieldMeta.value)) { |
||||
|
const error = normalizeFieldMetaError(meta); |
||||
|
if (error) { |
||||
|
result[fieldName] = error; |
||||
|
} |
||||
|
} |
||||
|
for (const [fieldName, error] of manualErrors.value) { |
||||
|
result[fieldName] = error; |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
const errors = computed(getErrors); |
||||
|
const meta = computed(() => ({ |
||||
|
dirty: isDirty.value, |
||||
|
submitting: isSubmitting.value, |
||||
|
valid: isValid.value && manualErrors.value.size === 0, |
||||
|
validating: isValidating.value, |
||||
|
})); |
||||
|
const runtimeState = computed<FormRuntimeState<TValues>>(() => ({ |
||||
|
errors: errors.value, |
||||
|
meta: meta.value, |
||||
|
values: values.value, |
||||
|
})); |
||||
|
|
||||
|
function getFieldError(fieldName: string) { |
||||
|
return ( |
||||
|
manualErrors.value.get(fieldName) ?? |
||||
|
normalizeFieldMetaError(Reflect.get(fieldMeta.value, fieldName)) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function useFieldError(fieldName: string) { |
||||
|
const schemaError = rawForm.useSelector((formState) => |
||||
|
normalizeFieldMetaError(Reflect.get(formState.fieldMeta, fieldName)), |
||||
|
); |
||||
|
return computed( |
||||
|
() => manualErrors.value.get(fieldName) ?? schemaError.value, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function useFieldValue<TFieldName extends FormFieldName<TValues>>( |
||||
|
fieldName: TFieldName, |
||||
|
) { |
||||
|
return rawForm.useSelector( |
||||
|
() => |
||||
|
rawForm.getFieldValue(fieldName as never) as FormFieldValue< |
||||
|
TValues, |
||||
|
TFieldName |
||||
|
>, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function useFieldValues<TFieldName extends FormFieldName<TValues>>( |
||||
|
fieldNames: readonly TFieldName[], |
||||
|
) { |
||||
|
const selectedValues = fieldNames.map((fieldName) => |
||||
|
useFieldValue(fieldName), |
||||
|
); |
||||
|
return computed(() => selectedValues.map((value) => value.value)); |
||||
|
} |
||||
|
|
||||
|
async function validateField(fieldName: string) { |
||||
|
await rawForm.validateField(fieldName as never, 'submit'); |
||||
|
const error = getFieldError(fieldName); |
||||
|
return { |
||||
|
errors: error ? { [fieldName]: error } : {}, |
||||
|
valid: !error, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
async function validate() { |
||||
|
await rawForm.validateAllFields('submit'); |
||||
|
const errors = getErrors(); |
||||
|
return { |
||||
|
errors, |
||||
|
valid: Object.keys(errors).length === 0, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function setFieldError(fieldName: string, error?: string) { |
||||
|
invalidateFieldValidation(fieldName); |
||||
|
|
||||
|
const nextManualErrors = new Map(manualErrors.value); |
||||
|
if (error) { |
||||
|
nextManualErrors.set(fieldName, error); |
||||
|
} else { |
||||
|
nextManualErrors.delete(fieldName); |
||||
|
} |
||||
|
manualErrors.value = nextManualErrors; |
||||
|
|
||||
|
if (error || !rawForm.getFieldMeta(fieldName as never)) { |
||||
|
return; |
||||
|
} |
||||
|
rawForm.setFieldMeta(fieldName as never, (meta) => ({ |
||||
|
...meta, |
||||
|
errorMap: {}, |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
function clearValidation( |
||||
|
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[], |
||||
|
) { |
||||
|
let requestedFieldNames: FormFieldName<TValues>[] | undefined; |
||||
|
if (Array.isArray(fieldNames)) { |
||||
|
requestedFieldNames = fieldNames; |
||||
|
} else if (fieldNames) { |
||||
|
requestedFieldNames = [fieldNames]; |
||||
|
} |
||||
|
const targetFieldNames = requestedFieldNames ?? [ |
||||
|
...new Set([ |
||||
|
...validationInvalidators.keys(), |
||||
|
...Object.keys(rawForm.getAllErrors().fields), |
||||
|
...manualErrors.value.keys(), |
||||
|
]), |
||||
|
]; |
||||
|
|
||||
|
for (const fieldName of targetFieldNames) { |
||||
|
setFieldError(fieldName, undefined); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function reset( |
||||
|
resetState?: { values?: Partial<TValues> }, |
||||
|
options?: FormResetOptions, |
||||
|
) { |
||||
|
for (const fieldName of validationInvalidators.keys()) { |
||||
|
invalidateFieldValidation(fieldName); |
||||
|
} |
||||
|
manualErrors.value = new Map(); |
||||
|
rawForm.reset(resetState?.values as TValues | undefined, options); |
||||
|
} |
||||
|
|
||||
|
async function submit() { |
||||
|
await rawForm.handleSubmit(); |
||||
|
} |
||||
|
|
||||
|
const actions: FormActions<TValues> = { |
||||
|
clearValidation, |
||||
|
get errors() { |
||||
|
return errors.value; |
||||
|
}, |
||||
|
fieldComponent: RuntimeField, |
||||
|
get meta() { |
||||
|
return meta.value; |
||||
|
}, |
||||
|
get values() { |
||||
|
return values.value; |
||||
|
}, |
||||
|
getFieldError, |
||||
|
getFieldValue(fieldName) { |
||||
|
return rawForm.getFieldValue(fieldName as never) as FormFieldValue< |
||||
|
TValues, |
||||
|
typeof fieldName |
||||
|
>; |
||||
|
}, |
||||
|
handleSubmit(callback) { |
||||
|
return async (event) => { |
||||
|
event?.preventDefault(); |
||||
|
event?.stopPropagation(); |
||||
|
const result = await validate(); |
||||
|
if (result.valid) { |
||||
|
await callback(values.value); |
||||
|
} |
||||
|
}; |
||||
|
}, |
||||
|
isFieldValid(fieldName) { |
||||
|
return !getFieldError(fieldName); |
||||
|
}, |
||||
|
pushFieldValue(fieldName, value) { |
||||
|
rawForm.pushFieldValue(fieldName as never, value as never); |
||||
|
}, |
||||
|
async removeFieldValue(fieldName, index) { |
||||
|
await rawForm.removeFieldValue(fieldName as never, index); |
||||
|
}, |
||||
|
reset, |
||||
|
resetForm: reset, |
||||
|
setFieldError, |
||||
|
async setFieldValue(fieldName, value, shouldValidate) { |
||||
|
rawForm.setFieldValue(fieldName as never, value as never, { |
||||
|
dontValidate: !shouldValidate, |
||||
|
}); |
||||
|
if (shouldValidate) { |
||||
|
await validateField(fieldName); |
||||
|
} |
||||
|
}, |
||||
|
async setValues(values, shouldValidate) { |
||||
|
for (const [fieldName, value] of Object.entries(values)) { |
||||
|
rawForm.setFieldValue(fieldName as never, value as never, { |
||||
|
dontValidate: !shouldValidate, |
||||
|
}); |
||||
|
} |
||||
|
if (shouldValidate) { |
||||
|
await validate(); |
||||
|
} |
||||
|
}, |
||||
|
submit, |
||||
|
submitForm: submit, |
||||
|
useSelector(selector) { |
||||
|
return computed(() => selector(runtimeState.value)); |
||||
|
}, |
||||
|
useFieldError, |
||||
|
useFieldValue, |
||||
|
useFieldValues, |
||||
|
useValues() { |
||||
|
return values; |
||||
|
}, |
||||
|
validate, |
||||
|
validateField, |
||||
|
}; |
||||
|
|
||||
|
return actions; |
||||
|
} |
||||
@ -0,0 +1,226 @@ |
|||||
|
import type { |
||||
|
ArrayToStringFields, |
||||
|
BaseFormComponentType, |
||||
|
FieldMappingTime, |
||||
|
FormSchema, |
||||
|
FormSchemaContext, |
||||
|
FormValues, |
||||
|
} from './types'; |
||||
|
|
||||
|
import { cloneDeep, formatDate, isFunction } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
import { |
||||
|
deleteValueByFieldName, |
||||
|
getValueByFieldName, |
||||
|
resolveValueFormatFieldName, |
||||
|
setValueByFieldName, |
||||
|
} from './field-name'; |
||||
|
import { |
||||
|
getFormArraySchemaChildren, |
||||
|
resolveArrayChildFieldName, |
||||
|
} from './form-render/schema'; |
||||
|
|
||||
|
type AnyFormSchema<TValues extends FormValues> = FormSchema< |
||||
|
BaseFormComponentType, |
||||
|
Record<string, any>, |
||||
|
TValues |
||||
|
>; |
||||
|
|
||||
|
function processFields( |
||||
|
fields: string[], |
||||
|
separator: string, |
||||
|
values: Record<string, any>, |
||||
|
) { |
||||
|
for (const field of fields) { |
||||
|
const value = values[field]; |
||||
|
if (value === undefined || value === null) { |
||||
|
continue; |
||||
|
} |
||||
|
if (Array.isArray(value)) { |
||||
|
values[field] = value.join(separator); |
||||
|
continue; |
||||
|
} |
||||
|
if (typeof value !== 'string') { |
||||
|
continue; |
||||
|
} |
||||
|
if (value === '') { |
||||
|
values[field] = []; |
||||
|
continue; |
||||
|
} |
||||
|
const escapedSeparator = separator.replaceAll( |
||||
|
/[.*+?^${}()|[\]\\]/g, |
||||
|
String.raw`\$&`, |
||||
|
); |
||||
|
values[field] = value.split(new RegExp(escapedSeparator)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function applyArrayToStringFields( |
||||
|
values: Record<string, any>, |
||||
|
arrayToStringFields?: ArrayToStringFields, |
||||
|
) { |
||||
|
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (arrayToStringFields.every((item) => typeof item === 'string')) { |
||||
|
const fieldsConfig = arrayToStringFields as string[]; |
||||
|
const lastItem = fieldsConfig.at(-1) ?? ''; |
||||
|
const hasSeparator = lastItem.length === 1; |
||||
|
const fields = hasSeparator ? fieldsConfig.slice(0, -1) : fieldsConfig; |
||||
|
processFields(fields, hasSeparator ? lastItem : ',', values); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
for (const fieldConfig of arrayToStringFields) { |
||||
|
if (!Array.isArray(fieldConfig)) { |
||||
|
continue; |
||||
|
} |
||||
|
const [fields, separator = ','] = fieldConfig; |
||||
|
if (!Array.isArray(fields)) { |
||||
|
console.warn( |
||||
|
`Invalid field configuration: fields should be an array of strings, got ${typeof fields}`, |
||||
|
); |
||||
|
continue; |
||||
|
} |
||||
|
processFields(fields, separator, values); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function applyRangeTimeFields( |
||||
|
values: Record<string, any>, |
||||
|
fieldMappingTime?: FieldMappingTime, |
||||
|
) { |
||||
|
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
for (const [ |
||||
|
field, |
||||
|
[startTimeKey, endTimeKey], |
||||
|
format = 'YYYY-MM-DD', |
||||
|
] of fieldMappingTime) { |
||||
|
if (startTimeKey && endTimeKey && values[field] === null) { |
||||
|
Reflect.deleteProperty(values, startTimeKey); |
||||
|
Reflect.deleteProperty(values, endTimeKey); |
||||
|
} |
||||
|
if (!values[field]) { |
||||
|
Reflect.deleteProperty(values, field); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const [startTime, endTime] = values[field]; |
||||
|
if (format === null) { |
||||
|
values[startTimeKey] = startTime; |
||||
|
values[endTimeKey] = endTime; |
||||
|
} else if (isFunction(format)) { |
||||
|
values[startTimeKey] = format(startTime, startTimeKey); |
||||
|
values[endTimeKey] = format(endTime, endTimeKey); |
||||
|
} else { |
||||
|
const [startTimeFormat, endTimeFormat] = Array.isArray(format) |
||||
|
? format |
||||
|
: [format, format]; |
||||
|
values[startTimeKey] = startTime |
||||
|
? formatDate(startTime, startTimeFormat) |
||||
|
: undefined; |
||||
|
values[endTimeKey] = endTime |
||||
|
? formatDate(endTime, endTimeFormat) |
||||
|
: undefined; |
||||
|
} |
||||
|
Reflect.deleteProperty(values, field); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function applyValueFormatBySchemas<TValues extends FormValues>( |
||||
|
schemas: AnyFormSchema<TValues>[], |
||||
|
values: Record<string, any>, |
||||
|
parentPath?: string, |
||||
|
parentContext?: FormSchemaContext<TValues>, |
||||
|
) { |
||||
|
for (const schema of schemas) { |
||||
|
const fieldName = parentPath |
||||
|
? resolveArrayChildFieldName(parentPath, schema.fieldName) |
||||
|
: schema.fieldName; |
||||
|
const row = |
||||
|
parentPath && parentContext?.rowPath |
||||
|
? getValueByFieldName(values, parentContext.rowPath) |
||||
|
: parentContext?.row; |
||||
|
const schemaContext: FormSchemaContext<TValues> = { |
||||
|
...parentContext, |
||||
|
fieldName, |
||||
|
originalFieldName: schema.fieldName, |
||||
|
rootValues: values as TValues, |
||||
|
row, |
||||
|
}; |
||||
|
|
||||
|
const children = getFormArraySchemaChildren<AnyFormSchema<TValues>>(schema); |
||||
|
if (children.length > 0) { |
||||
|
const arrayValue = getValueByFieldName(values, fieldName); |
||||
|
if (Array.isArray(arrayValue)) { |
||||
|
arrayValue.forEach((rowValue, index) => { |
||||
|
const rowPath = `${fieldName}[${index}]`; |
||||
|
applyValueFormatBySchemas(children, values, rowPath, { |
||||
|
arrayField: fieldName, |
||||
|
row: rowValue, |
||||
|
rowIndex: index, |
||||
|
rowPath, |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!schema.valueFormat) { |
||||
|
continue; |
||||
|
} |
||||
|
const value = getValueByFieldName(values, fieldName); |
||||
|
deleteValueByFieldName(values, fieldName); |
||||
|
const formattedValue = schema.valueFormat( |
||||
|
value, |
||||
|
(key, nextValue) => { |
||||
|
setValueByFieldName( |
||||
|
values, |
||||
|
resolveValueFormatFieldName(key, parentPath), |
||||
|
nextValue, |
||||
|
); |
||||
|
}, |
||||
|
values as TValues, |
||||
|
schemaContext, |
||||
|
); |
||||
|
if (formattedValue !== undefined) { |
||||
|
setValueByFieldName(values, fieldName, formattedValue); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function applyFormValueFormats<TValues extends FormValues>( |
||||
|
originValues: Record<string, any>, |
||||
|
schemas: AnyFormSchema<TValues>[], |
||||
|
) { |
||||
|
const values = cloneDeep(originValues); |
||||
|
applyValueFormatBySchemas(schemas, values); |
||||
|
return values; |
||||
|
} |
||||
|
|
||||
|
export function formatFormValues<TValues extends FormValues>( |
||||
|
originValues: Readonly<Record<string, any>>, |
||||
|
schemas: AnyFormSchema<TValues>[], |
||||
|
fieldMappingTime?: FieldMappingTime, |
||||
|
arrayToStringFields?: ArrayToStringFields, |
||||
|
) { |
||||
|
const values = cloneDeep(originValues); |
||||
|
applyArrayToStringFields(values, arrayToStringFields); |
||||
|
applyRangeTimeFields(values, fieldMappingTime); |
||||
|
applyValueFormatBySchemas(schemas, values); |
||||
|
return values; |
||||
|
} |
||||
|
|
||||
|
export function transformRangeTimeValues( |
||||
|
originValues: Record<string, any>, |
||||
|
fieldMappingTime?: FieldMappingTime, |
||||
|
arrayToStringFields?: ArrayToStringFields, |
||||
|
) { |
||||
|
const values = cloneDeep(originValues); |
||||
|
applyArrayToStringFields(values, arrayToStringFields); |
||||
|
applyRangeTimeFields(values, fieldMappingTime); |
||||
|
return values; |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
import type { FormRuleValidator } from './types'; |
||||
|
|
||||
|
const FORM_RULES = new Map<string, FormRuleValidator>(); |
||||
|
|
||||
|
export function getFormRule(name: string) { |
||||
|
return FORM_RULES.get(name); |
||||
|
} |
||||
|
|
||||
|
export function registerFormRules( |
||||
|
rules: Partial<Record<string, FormRuleValidator>>, |
||||
|
) { |
||||
|
for (const [name, validator] of Object.entries(rules)) { |
||||
|
if (validator) { |
||||
|
FORM_RULES.set(name, validator); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { provide, toRefs } from 'vue'; |
||||
|
|
||||
|
import { FORM_FIELD_INJECTION_KEY } from './injectionKeys'; |
||||
|
|
||||
|
const props = withDefaults( |
||||
|
defineProps<{ |
||||
|
dirty?: boolean; |
||||
|
error?: string; |
||||
|
name: string; |
||||
|
touched?: boolean; |
||||
|
valid?: boolean; |
||||
|
}>(), |
||||
|
{ |
||||
|
dirty: false, |
||||
|
error: undefined, |
||||
|
touched: false, |
||||
|
valid: true, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
const { dirty, error, name, touched, valid } = toRefs(props); |
||||
|
|
||||
|
provide(FORM_FIELD_INJECTION_KEY, { |
||||
|
dirty, |
||||
|
error, |
||||
|
name, |
||||
|
touched, |
||||
|
valid, |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<slot></slot> |
||||
|
</template> |
||||
@ -1,27 +1,24 @@ |
|||||
<script lang="ts" setup> |
<script lang="ts" setup> |
||||
import type { HTMLAttributes } from 'vue'; |
import type { HTMLAttributes } from 'vue'; |
||||
|
|
||||
import { toValue } from 'vue'; |
|
||||
|
|
||||
import { cn } from '@vben-core/shared/utils'; |
import { cn } from '@vben-core/shared/utils'; |
||||
|
|
||||
import { ErrorMessage } from 'vee-validate'; |
|
||||
|
|
||||
import { useFormField } from './useFormField'; |
import { useFormField } from './useFormField'; |
||||
|
|
||||
const props = defineProps<{ |
const props = defineProps<{ |
||||
class?: HTMLAttributes['class']; |
class?: HTMLAttributes['class']; |
||||
}>(); |
}>(); |
||||
|
|
||||
const { name, formMessageId } = useFormField(); |
const { error, formMessageId } = useFormField(); |
||||
</script> |
</script> |
||||
|
|
||||
<template> |
<template> |
||||
<ErrorMessage |
<p |
||||
|
v-if="error" |
||||
:id="formMessageId" |
:id="formMessageId" |
||||
data-slot="form-message" |
data-slot="form-message" |
||||
as="p" |
|
||||
:name="toValue(name)" |
|
||||
:class="cn('text-destructive text-sm', props.class)" |
:class="cn('text-destructive text-sm', props.class)" |
||||
/> |
> |
||||
|
{{ error }} |
||||
|
</p> |
||||
</template> |
</template> |
||||
|
|||||
@ -1,11 +1,10 @@ |
|||||
export { default as FormControl } from './FormControl.vue'; |
export { default as FormControl } from './FormControl.vue'; |
||||
export { default as FormDescription } from './FormDescription.vue'; |
export { default as FormDescription } from './FormDescription.vue'; |
||||
|
export { default as FormField } from './FormField.vue'; |
||||
export { default as FormItem } from './FormItem.vue'; |
export { default as FormItem } from './FormItem.vue'; |
||||
export { default as FormLabel } from './FormLabel.vue'; |
export { default as FormLabel } from './FormLabel.vue'; |
||||
export { default as FormMessage } from './FormMessage.vue'; |
export { default as FormMessage } from './FormMessage.vue'; |
||||
export { FORM_ITEM_INJECTION_KEY } from './injectionKeys'; |
|
||||
export { |
export { |
||||
Form, |
FORM_FIELD_INJECTION_KEY, |
||||
Field as FormField, |
FORM_ITEM_INJECTION_KEY, |
||||
FieldArray as FormFieldArray, |
} from './injectionKeys'; |
||||
} from 'vee-validate'; |
|
||||
|
|||||
@ -1,3 +1,13 @@ |
|||||
import type { InjectionKey } from 'vue'; |
import type { InjectionKey, Ref } from 'vue'; |
||||
|
|
||||
|
export interface FormFieldContext { |
||||
|
dirty: Readonly<Ref<boolean>>; |
||||
|
error: Readonly<Ref<string | undefined>>; |
||||
|
name: Readonly<Ref<string>>; |
||||
|
touched: Readonly<Ref<boolean>>; |
||||
|
valid: Readonly<Ref<boolean>>; |
||||
|
} |
||||
|
|
||||
export const FORM_ITEM_INJECTION_KEY = Symbol() as InjectionKey<string>; |
export const FORM_ITEM_INJECTION_KEY = Symbol() as InjectionKey<string>; |
||||
|
export const FORM_FIELD_INJECTION_KEY = |
||||
|
Symbol() as InjectionKey<FormFieldContext>; |
||||
|
|||||
Loading…
Reference in new issue