fix(@vben-core/form-ui): restore lock screen form submission (#8195)
* fix(@vben-core/form-ui): avoid stale form context access * fix(@vben/playground): constrain date range codec fields * test(@vben-core/form-ui): harden benchmark fixtures * docs(@vben/docs): document form runtime and benchmarks * fix(@vben/docs): 明确 getValues/submit 返回值及 submit 参数类型
This commit is contained in:
@@ -269,6 +269,22 @@ const [Form, formApi] = useVbenForm({
|
|||||||
|
|
||||||
<DemoPreview dir="demos/vben-form/value-format" />
|
<DemoPreview dir="demos/vben-form/value-format" />
|
||||||
|
|
||||||
|
## 性能基准
|
||||||
|
|
||||||
|
表单性能基准覆盖组件初始化、单字段与批量更新、重置、Zod 校验、动态 schema、字段联动、codec 编码与快照,以及数组字段编辑、增删和子 schema 更新。完整运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:benchmark
|
||||||
|
```
|
||||||
|
|
||||||
|
只检查表单相关基准时,可以直接指定文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec vitest bench --run packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
基准结果用于比较同一环境、同一场景在修改前后的相对变化,不应把单次运行的绝对耗时作为跨机器阈值。运行前应停止开发服务器等高 CPU 任务,并保持 Node.js 版本一致。benchmark 文件不会进入普通 `test:unit` 流程。
|
||||||
|
|
||||||
## 表单校验
|
## 表单校验
|
||||||
|
|
||||||
表单校验是一个非常重要的功能,可以通过 `rules` 属性进行校验。
|
表单校验是一个非常重要的功能,可以通过 `rules` 属性进行校验。
|
||||||
@@ -320,7 +336,7 @@ const [Form, formApi] = useVbenForm({
|
|||||||
|
|
||||||
### 类型传递与插槽
|
### 类型传递与插槽
|
||||||
|
|
||||||
使用 `useVbenForm<TFormValues, TSubmitValues>` 分别声明组件表单值和提交值。schema、slots、`setValues`、`formApi.form.values` 使用 `TFormValues`;`getValues`、submit 和 `handleSubmit` 第一参数使用 `TSubmitValues`。两种结构相同时只传一个泛型即可。
|
使用 `useVbenForm<TFormValues, TSubmitValues>` 分别声明组件表单值和提交值。schema、slots、`setValues`、`getRawValues()` 使用 `TFormValues`;`getValues()` 和 `submit()` 返回 `Promise<TSubmitValues>`,其中 `submit()` 只接收可选的原生 `Event`;`handleSubmit` 第一参数使用 `TSubmitValues`。两种结构相同时只传一个泛型即可。
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -450,6 +466,12 @@ const submitting = formApi.form.useSelector((state) => state.meta.submitting);
|
|||||||
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
|
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
|
||||||
| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | `boolean` | false |
|
| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | `boolean` | false |
|
||||||
|
|
||||||
|
::: warning formApi.form 的挂载时机
|
||||||
|
|
||||||
|
`formApi.form` 是 `<Form />` 挂载后注入的 `FormContextApi`。不要在调用 `useVbenForm` 时从第二个返回值中解构或缓存 `form`,否则会保留挂载前的空引用。业务操作优先使用 `formApi` 上会等待挂载的公开方法,例如 `getRawValues()`、`setFieldError()`、`setFieldValue()` 和 `validate()`;只有在已经挂载的表单上下文中才直接使用 `formApi.form` 的细粒度订阅方法。
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
::: tip handleValuesChange
|
::: tip handleValuesChange
|
||||||
|
|
||||||
`handleValuesChange` 的第一个参数是未编码的只读 `TFormValues`,第二个参数是本次发生变化的 schema 字段名。第三个参数 `getFormattedValues` 是惰性函数:不调用就不会执行 codec 或旧格式化管道。
|
`handleValuesChange` 的第一个参数是未编码的只读 `TFormValues`,第二个参数是本次发生变化的 schema 字段名。第三个参数 `getFormattedValues` 是惰性函数:不调用就不会执行 codec 或旧格式化管道。
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ function handleValuesChange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
|
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
|
||||||
liveValues.value = { ...(values ?? formApi.form?.values ?? {}) };
|
const rawValues = values ?? (await formApi.getRawValues());
|
||||||
|
liveValues.value = { ...rawValues };
|
||||||
transformedValues.value = await formApi.getValues();
|
transformedValues.value = await formApi.getValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ onMounted(async () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<Card title="原始 form.values(组件值)">
|
<Card title="getRawValues() 输出(组件值)">
|
||||||
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
|
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
|
||||||
liveValuesPreview
|
liveValuesPreview
|
||||||
}}</pre>
|
}}</pre>
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ Create the form through `useVbenForm`:
|
|||||||
|
|
||||||
## Typed Values and Slots
|
## Typed Values and Slots
|
||||||
|
|
||||||
Use `useVbenForm<TFormValues, TSubmitValues>` to declare component-facing form values and submission values separately. Schema, slots, selectors, and `setValues` use `TFormValues`; `getValues`, submit, and the first `handleSubmit` argument use `TSubmitValues`. Pass one generic when both shapes are identical.
|
Use `useVbenForm<TFormValues, TSubmitValues>` to declare component-facing form values and submission values separately. Schema, slots, selectors, and `setValues` use `TFormValues`; `getValues()` and `submit()` return `Promise<TSubmitValues>`, while `submit()` only accepts an optional native `Event`; the first `handleSubmit` argument is `TSubmitValues`. Pass one generic when both shapes are identical.
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -281,6 +281,28 @@ const [Form, formApi] = useVbenForm({
|
|||||||
|
|
||||||
`schema.valueFormat`, `fieldMappingTime`, and `arrayToStringFields` remain runtime-compatible but are deprecated. When a codec is configured it takes precedence and deprecated transforms are ignored.
|
`schema.valueFormat`, `fieldMappingTime`, and `arrayToStringFields` remain runtime-compatible but are deprecated. When a codec is configured it takes precedence and deprecated transforms are ignored.
|
||||||
|
|
||||||
|
## Performance Benchmarks
|
||||||
|
|
||||||
|
The form benchmarks cover component initialization, single-field and batch updates, reset, Zod validation, dynamic schemas, dependencies, codec encoding and snapshots, plus array editing, row mutations, and child-schema updates. Run the complete benchmark suite with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:benchmark
|
||||||
|
```
|
||||||
|
|
||||||
|
To run only the form benchmarks, pass both files explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec vitest bench --run packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Use benchmark results to compare relative changes on the same machine and runtime; do not treat one run's absolute timings as portable thresholds. Stop CPU-intensive development servers first and keep the Node.js version consistent. Benchmark files are not included in the regular `test:unit` command.
|
||||||
|
|
||||||
|
::: warning Mounted form context
|
||||||
|
|
||||||
|
`formApi.form` is the `FormContextApi` injected after `<Form />` mounts. Do not destructure or cache `form` from the second `useVbenForm` return value during setup, because that captures the pre-mount empty reference. Prefer mount-aware public methods such as `getRawValues()`, `setFieldError()`, `setFieldValue()`, and `validate()` for business actions. Access fine-grained subscription methods on `formApi.form` only from an already-mounted form context.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
## Key API Notes
|
## Key API Notes
|
||||||
|
|
||||||
- `useVbenForm` returns `[Form, formApi]`
|
- `useVbenForm` returns `[Form, formApi]`
|
||||||
@@ -295,7 +317,7 @@ const [Form, formApi] = useVbenForm({
|
|||||||
- `handleSubmit(values, rawValues)` receives the formatted payload and its corresponding raw snapshot
|
- `handleSubmit(values, rawValues)` receives the formatted payload and its corresponding raw snapshot
|
||||||
- `fieldMappingTime`, `arrayToStringFields`, and `schema.valueFormat` are deprecated compatibility options
|
- `fieldMappingTime`, `arrayToStringFields`, and `schema.valueFormat` are deprecated compatibility options
|
||||||
- `codec.encode` defines the `getValues()` payload and `codec.decode` powers complete `setSubmitValues()` fills
|
- `codec.encode` defines the `getValues()` payload and `codec.decode` powers complete `setSubmitValues()` fills
|
||||||
- `formApi.form` is the stable `FormContextApi`; raw TanStack generics are intentionally not exposed
|
- `formApi.form` exposes the mounted `FormContextApi`; do not destructure or cache it before `<Form />` mounts
|
||||||
- prefer `formApi.form.useFieldValue`, `useFieldValues`, and `useFieldError` for fine-grained subscriptions; use `useValues` only when the whole form is required
|
- prefer `formApi.form.useFieldValue`, `useFieldValues`, and `useFieldError` for fine-grained subscriptions; use `useValues` only when the whole form is required
|
||||||
- `useSelector` remains the compatibility selector for combined `{ values, errors, meta }` state
|
- `useSelector` remains the compatibility selector for combined `{ values, errors, meta }` state
|
||||||
- legacy `setupVbenForm({ defineRules })` still works, warns once in development, and is silent in production; use `rules` for new code
|
- legacy `setupVbenForm({ defineRules })` still works, warns once in development, and is silent in production; use `rules` for new code
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
|
||||||
|
export const TestInput = defineComponent({
|
||||||
|
inheritAttrs: false,
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(_props, { attrs, emit }) {
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLInputElement) {
|
||||||
|
emit('update:modelValue', target.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('input', {
|
||||||
|
...attrs,
|
||||||
|
onInput: handleInput,
|
||||||
|
value: attrs.modelValue ?? '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -61,6 +61,20 @@ describe('formApi', () => {
|
|||||||
expect(values).toEqual({ name: 'test' });
|
expect(values).toEqual({ name: 'test' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set a field error through the public api', async () => {
|
||||||
|
const setFieldError = vi.fn();
|
||||||
|
const formActions: any = {
|
||||||
|
meta: {},
|
||||||
|
setFieldError,
|
||||||
|
values: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
formApi.mount(formActions, new Map());
|
||||||
|
await formApi.setFieldError('password', 'Invalid password');
|
||||||
|
|
||||||
|
expect(setFieldError).toHaveBeenCalledWith('password', 'Invalid password');
|
||||||
|
});
|
||||||
|
|
||||||
it('should format schema values when getting values', async () => {
|
it('should format schema values when getting values', async () => {
|
||||||
formApi.setState({
|
formApi.setState({
|
||||||
schema: [
|
schema: [
|
||||||
|
|||||||
@@ -1,38 +1,19 @@
|
|||||||
import type { FormSchema } from '../src/types';
|
import type { FormSchema } from '../src/types';
|
||||||
|
|
||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h, nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
import { afterAll, bench, describe } from 'vitest';
|
import { afterAll, bench, describe } from 'vitest';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { setupVbenForm } from '../src/config';
|
import { setupVbenForm } from '../src/config';
|
||||||
import { useVbenForm } from '../src/use-vben-form';
|
import { useVbenForm } from '../src/use-vben-form';
|
||||||
|
import { TestInput } from './benchmark-fixtures';
|
||||||
|
|
||||||
const BENCHMARK_OPTIONS = { time: 750, warmupTime: 150 } as const;
|
const BENCHMARK_OPTIONS = { time: 750, warmupTime: 150 } as const;
|
||||||
const FIELD_COUNT = 100;
|
const FIELD_COUNT = 100;
|
||||||
const MOUNT_FIELD_COUNT = 50;
|
const MOUNT_FIELD_COUNT = 50;
|
||||||
|
|
||||||
const TestInput = defineComponent({
|
|
||||||
inheritAttrs: false,
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
setup(_props, { attrs, emit }) {
|
|
||||||
function handleInput(event: Event) {
|
|
||||||
const target = event.target;
|
|
||||||
if (target instanceof HTMLInputElement) {
|
|
||||||
emit('update:modelValue', target.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () =>
|
|
||||||
h('input', {
|
|
||||||
...attrs,
|
|
||||||
onInput: handleInput,
|
|
||||||
value: attrs.modelValue ?? '',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function createFlatSchema(
|
function createFlatSchema(
|
||||||
fieldCount: number,
|
fieldCount: number,
|
||||||
withRules: boolean = false,
|
withRules: boolean = false,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h, nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
import { afterAll, bench, describe } from 'vitest';
|
import { afterAll, bench, describe } from 'vitest';
|
||||||
|
|
||||||
import { setupVbenForm } from '../src/config';
|
import { setupVbenForm } from '../src/config';
|
||||||
import { FormApi } from '../src/form-api';
|
|
||||||
import { encodeFormValues } from '../src/form-codec';
|
import { encodeFormValues } from '../src/form-codec';
|
||||||
import { useVbenForm } from '../src/use-vben-form';
|
import { useVbenForm } from '../src/use-vben-form';
|
||||||
|
import { TestInput } from './benchmark-fixtures';
|
||||||
|
|
||||||
interface ContactValues {
|
interface ContactValues {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -30,26 +30,6 @@ interface PerformanceFormValues extends Record<string, unknown> {
|
|||||||
|
|
||||||
const ROW_COUNT = 100;
|
const ROW_COUNT = 100;
|
||||||
|
|
||||||
const TestInput = defineComponent({
|
|
||||||
inheritAttrs: false,
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
setup(_props, { attrs, emit }) {
|
|
||||||
function handleInput(event: Event) {
|
|
||||||
const target = event.target;
|
|
||||||
if (target instanceof HTMLInputElement) {
|
|
||||||
emit('update:modelValue', target.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () =>
|
|
||||||
h('input', {
|
|
||||||
...attrs,
|
|
||||||
onInput: handleInput,
|
|
||||||
value: attrs.modelValue ?? '',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function createFormValues(): PerformanceFormValues {
|
function createFormValues(): PerformanceFormValues {
|
||||||
return {
|
return {
|
||||||
contacts: Array.from({ length: ROW_COUNT }, (_, index) => ({
|
contacts: Array.from({ length: ROW_COUNT }, (_, index) => ({
|
||||||
@@ -82,10 +62,24 @@ const codec = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formValues = createFormValues();
|
const formValues = createFormValues();
|
||||||
const codecFormApi = new FormApi<PerformanceFormValues>({ codec });
|
|
||||||
codecFormApi.mount({ meta: {}, values: formValues } as never, new Map());
|
|
||||||
|
|
||||||
setupVbenForm({ config: {}, rules: {} });
|
setupVbenForm({ config: {}, rules: {} });
|
||||||
|
const [CodecForm, codecFormApi] = useVbenForm<PerformanceFormValues>({
|
||||||
|
codec,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: TestInput,
|
||||||
|
defaultValue: formValues.contacts,
|
||||||
|
fieldName: 'contacts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: TestInput,
|
||||||
|
defaultValue: formValues.settings,
|
||||||
|
fieldName: 'settings',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
const codecWrapper = mount(CodecForm);
|
||||||
const [ArrayForm, arrayFormApi] = useVbenForm({
|
const [ArrayForm, arrayFormApi] = useVbenForm({
|
||||||
schema: [
|
schema: [
|
||||||
{
|
{
|
||||||
@@ -115,6 +109,7 @@ let arraySchemaIteration = 0;
|
|||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
arrayWrapper.unmount();
|
arrayWrapper.unmount();
|
||||||
|
codecWrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('form codec performance', () => {
|
describe('form codec performance', () => {
|
||||||
|
|||||||
@@ -404,6 +404,11 @@ export class FormApi<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setFieldError(fieldName: FormFieldName<TFormValues>, error?: string) {
|
||||||
|
const form = await this.getForm();
|
||||||
|
form.setFieldError(fieldName, error);
|
||||||
|
}
|
||||||
|
|
||||||
async setFieldValue<TFieldName extends FormFieldName<TFormValues>>(
|
async setFieldValue<TFieldName extends FormFieldName<TFormValues>>(
|
||||||
field: TFieldName,
|
field: TFieldName,
|
||||||
value: FormFieldValue<TFormValues, NoInfer<TFieldName>>,
|
value: FormFieldValue<TFormValues, NoInfer<TFieldName>>,
|
||||||
|
|||||||
@@ -37,38 +37,38 @@ const date = useDateFormat(now, 'YYYY-MM-DD dddd', { locales: locale.value });
|
|||||||
const showUnlockForm = ref(false);
|
const showUnlockForm = ref(false);
|
||||||
const { lockScreenPassword } = storeToRefs(accessStore);
|
const { lockScreenPassword } = storeToRefs(accessStore);
|
||||||
|
|
||||||
const [Form, { form, validate, getFieldComponentRef }] = useVbenForm(
|
const [Form, { getFieldComponentRef, getRawValues, setFieldError, validate }] =
|
||||||
reactive({
|
useVbenForm(
|
||||||
commonConfig: {
|
reactive({
|
||||||
hideLabel: true,
|
commonConfig: {
|
||||||
hideRequiredMark: true,
|
hideLabel: true,
|
||||||
},
|
hideRequiredMark: true,
|
||||||
schema: computed(() => [
|
|
||||||
{
|
|
||||||
component: 'VbenInputPassword' as const,
|
|
||||||
componentProps: {
|
|
||||||
placeholder: $t('ui.widgets.lockScreen.placeholder'),
|
|
||||||
},
|
|
||||||
fieldName: 'password',
|
|
||||||
label: $t('authentication.password'),
|
|
||||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
|
||||||
},
|
},
|
||||||
]),
|
schema: computed(() => [
|
||||||
showDefaultActions: false,
|
{
|
||||||
}),
|
component: 'VbenInputPassword' as const,
|
||||||
);
|
componentProps: {
|
||||||
|
placeholder: $t('ui.widgets.lockScreen.placeholder'),
|
||||||
const validPass = computed(
|
},
|
||||||
() => lockScreenPassword?.value === form?.values?.password,
|
fieldName: 'password',
|
||||||
);
|
label: $t('authentication.password'),
|
||||||
|
rules: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: $t('authentication.passwordTip') }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
showDefaultActions: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const { valid } = await validate();
|
const { valid } = await validate();
|
||||||
if (valid) {
|
if (valid) {
|
||||||
if (validPass.value) {
|
const { password } = await getRawValues();
|
||||||
|
if (lockScreenPassword?.value === password) {
|
||||||
accessStore.unlockScreen();
|
accessStore.unlockScreen();
|
||||||
} else {
|
} else {
|
||||||
form.setFieldError('password', $t('authentication.passwordErrorTip'));
|
await setFieldError('password', $t('authentication.passwordErrorTip'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,7 +339,6 @@ async function init() {
|
|||||||
'query',
|
'query',
|
||||||
formOptions.value ? ((await formApi.getValues()) ?? {}) : {},
|
formOptions.value ? ((await formApi.getValues()) ?? {}) : {},
|
||||||
);
|
);
|
||||||
// props.api.reload(formApi.form?.values ?? {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// form 由 vben-form代替,所以不适配formConfig,这里给出警告
|
// form 由 vben-form代替,所以不适配formConfig,这里给出警告
|
||||||
|
|||||||
@@ -17,6 +17,37 @@ const codec = createDateRangeCodec<SearchFormValues>()({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('date range codec', () => {
|
describe('date range codec', () => {
|
||||||
|
it('only accepts optional date range fields', () => {
|
||||||
|
interface RangeFieldCandidates extends Record<string, unknown> {
|
||||||
|
optionalRange?: [Dayjs, Dayjs];
|
||||||
|
optionalText?: string;
|
||||||
|
requiredRange: [Dayjs, Dayjs];
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCodec = createDateRangeCodec<RangeFieldCandidates>();
|
||||||
|
const optionalRangeCodec = createCodec({
|
||||||
|
endField: 'end',
|
||||||
|
rangeField: 'optionalRange',
|
||||||
|
startField: 'start',
|
||||||
|
});
|
||||||
|
expect(optionalRangeCodec).toEqual({
|
||||||
|
decode: expect.any(Function),
|
||||||
|
encode: expect.any(Function),
|
||||||
|
});
|
||||||
|
createCodec({
|
||||||
|
endField: 'end',
|
||||||
|
// @ts-expect-error A required range cannot be omitted by decode.
|
||||||
|
rangeField: 'requiredRange',
|
||||||
|
startField: 'start',
|
||||||
|
});
|
||||||
|
createCodec({
|
||||||
|
endField: 'end',
|
||||||
|
// @ts-expect-error The range field must contain a DateRange.
|
||||||
|
rangeField: 'optionalText',
|
||||||
|
startField: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('encodes and decodes configurable date range fields', () => {
|
it('encodes and decodes configurable date range fields', () => {
|
||||||
const submitValues = codec.encode({
|
const submitValues = codec.encode({
|
||||||
createdAt: [dayjs('2026-07-01'), dayjs('2026-07-23')],
|
createdAt: [dayjs('2026-07-01'), dayjs('2026-07-23')],
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import dayjs from 'dayjs';
|
|||||||
|
|
||||||
type DateRange = [Dayjs, Dayjs];
|
type DateRange = [Dayjs, Dayjs];
|
||||||
|
|
||||||
|
type OptionalDateRangeField<
|
||||||
|
TFormValues extends Record<string, unknown>,
|
||||||
|
TRangeField extends keyof TFormValues,
|
||||||
|
> =
|
||||||
|
Record<never, never> extends Pick<TFormValues, TRangeField>
|
||||||
|
? [Exclude<TFormValues[TRangeField], undefined>] extends [never]
|
||||||
|
? never
|
||||||
|
: Exclude<TFormValues[TRangeField], undefined> extends DateRange
|
||||||
|
? TRangeField
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
|
||||||
type DateRangeSubmitValues<
|
type DateRangeSubmitValues<
|
||||||
TFormValues extends Record<string, unknown>,
|
TFormValues extends Record<string, unknown>,
|
||||||
TRangeField extends keyof TFormValues,
|
TRangeField extends keyof TFormValues,
|
||||||
@@ -19,7 +31,7 @@ interface DateRangeCodecOptions<
|
|||||||
TEndField extends string,
|
TEndField extends string,
|
||||||
> {
|
> {
|
||||||
endField: TEndField;
|
endField: TEndField;
|
||||||
rangeField: TRangeField;
|
rangeField: OptionalDateRangeField<TFormValues, TRangeField> & TRangeField;
|
||||||
startField: TStartField;
|
startField: TStartField;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +42,16 @@ export function createDateRangeCodec<
|
|||||||
TRangeField extends keyof TFormValues & string,
|
TRangeField extends keyof TFormValues & string,
|
||||||
TStartField extends string,
|
TStartField extends string,
|
||||||
TEndField extends string,
|
TEndField extends string,
|
||||||
>({
|
>(
|
||||||
endField,
|
options: DateRangeCodecOptions<
|
||||||
rangeField,
|
TFormValues,
|
||||||
startField,
|
TRangeField,
|
||||||
}: DateRangeCodecOptions<TFormValues, TRangeField, TStartField, TEndField>) {
|
TStartField,
|
||||||
|
TEndField
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const { endField, startField } = options;
|
||||||
|
const rangeField = options.rangeField as TRangeField;
|
||||||
type SubmitValues = DateRangeSubmitValues<
|
type SubmitValues = DateRangeSubmitValues<
|
||||||
TFormValues,
|
TFormValues,
|
||||||
TRangeField,
|
TRangeField,
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ function handleValuesChange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
|
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
|
||||||
liveValues.value = { ...(values ?? formApi.form?.values) };
|
const rawValues = values ?? (await formApi.getRawValues());
|
||||||
|
liveValues.value = { ...rawValues };
|
||||||
transformedValues.value = await formApi.getValues();
|
transformedValues.value = await formApi.getValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ onMounted(async () => {
|
|||||||
<template #description>
|
<template #description>
|
||||||
<div class="text-muted-foreground space-y-2">
|
<div class="text-muted-foreground space-y-2">
|
||||||
<p>
|
<p>
|
||||||
<code>form.values</code> 保持组件原始值,<code>getValues()</code> /
|
<code>getRawValues()</code> 返回组件原始值,<code>getValues()</code> /
|
||||||
提交时会按 <code>codec.encode</code> 输出 payload,回填时通过
|
提交时会按 <code>codec.encode</code> 输出 payload,回填时通过
|
||||||
<code>codec.decode</code> 恢复组件值。
|
<code>codec.decode</code> 恢复组件值。
|
||||||
</p>
|
</p>
|
||||||
@@ -179,7 +180,7 @@ onMounted(async () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<Card title="原始 form.values(组件值)">
|
<Card title="getRawValues() 输出(组件值)">
|
||||||
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
|
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
|
||||||
liveValuesPreview
|
liveValuesPreview
|
||||||
}}</pre>
|
}}</pre>
|
||||||
|
|||||||
Reference in New Issue
Block a user