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> |
|||
import type { HTMLAttributes } from 'vue'; |
|||
|
|||
import { toValue } from 'vue'; |
|||
|
|||
import { cn } from '@vben-core/shared/utils'; |
|||
|
|||
import { ErrorMessage } from 'vee-validate'; |
|||
|
|||
import { useFormField } from './useFormField'; |
|||
|
|||
const props = defineProps<{ |
|||
class?: HTMLAttributes['class']; |
|||
}>(); |
|||
|
|||
const { name, formMessageId } = useFormField(); |
|||
const { error, formMessageId } = useFormField(); |
|||
</script> |
|||
|
|||
<template> |
|||
<ErrorMessage |
|||
<p |
|||
v-if="error" |
|||
:id="formMessageId" |
|||
data-slot="form-message" |
|||
as="p" |
|||
:name="toValue(name)" |
|||
:class="cn('text-destructive text-sm', props.class)" |
|||
/> |
|||
> |
|||
{{ error }} |
|||
</p> |
|||
</template> |
|||
|
|||
@ -1,11 +1,10 @@ |
|||
export { default as FormControl } from './FormControl.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 FormLabel } from './FormLabel.vue'; |
|||
export { default as FormMessage } from './FormMessage.vue'; |
|||
export { FORM_ITEM_INJECTION_KEY } from './injectionKeys'; |
|||
export { |
|||
Form, |
|||
Field as FormField, |
|||
FieldArray as FormFieldArray, |
|||
} from 'vee-validate'; |
|||
FORM_FIELD_INJECTION_KEY, |
|||
FORM_ITEM_INJECTION_KEY, |
|||
} from './injectionKeys'; |
|||
|
|||
@ -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_FIELD_INJECTION_KEY = |
|||
Symbol() as InjectionKey<FormFieldContext>; |
|||
|
|||
Loading…
Reference in new issue