diff --git a/package.json b/package.json index 7496acf9..7698e7c4 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "preview": "turbo-run preview", "publint": "vsh publint", "reinstall": "pnpm run clean -- --del-lock && pnpm install", + "test:benchmark": "vitest bench --run", "test:unit": "vitest run --dom", "test:e2e": "turbo run test:e2e", "update:deps": "npx taze -r -w", diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts index 0a150a65..deaa429f 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts @@ -3,6 +3,7 @@ import type { BaseFormComponentType } from '../src/types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FormApi } from '../src/form-api'; +import { FormCodecError } from '../src/form-codec'; describe('formApi', () => { let formApi: FormApi; @@ -171,6 +172,71 @@ describe('formApi', () => { ); }); + it('should isolate codec results from live form values', async () => { + interface ProfileFormValues { + profile: { name: string }; + tags: string[]; + } + + const values: ProfileFormValues = { + profile: { name: 'Ada' }, + tags: ['admin'], + }; + const codecFormApi = new FormApi({ + codec: { + decode: (submitValues) => submitValues, + encode: (formValues) => ({ + profile: formValues.profile, + tags: formValues.tags, + }), + }, + }); + const formActions: any = { meta: {}, values }; + + codecFormApi.mount(formActions, new Map()); + const initialSubmissionValues = codecFormApi.getLatestSubmissionValues(); + values.profile.name = 'Grace'; + values.tags.push('user'); + + expect(initialSubmissionValues).toEqual({ + profile: { name: 'Ada' }, + tags: ['admin'], + }); + const submissionValues = await codecFormApi.getValues(); + expect(submissionValues.profile).not.toBe(values.profile); + expect(submissionValues.tags).not.toBe(values.tags); + }); + + it('should fall back to raw values when the initial codec encode fails', async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const codecFormApi = new FormApi< + { name?: string }, + BaseFormComponentType, + Record, + { normalizedName: string } + >({ + codec: { + decode: (values) => ({ name: values.normalizedName }), + encode() { + throw new Error('incomplete initial values'); + }, + }, + }); + const formActions: any = { meta: {}, values: { name: 'Ada' } }; + + expect(() => codecFormApi.mount(formActions, new Map())).not.toThrow(); + + expect(codecFormApi.isMounted).toBe(true); + expect(codecFormApi.getLatestSubmissionValues()).toEqual({ name: 'Ada' }); + expect(warning).toHaveBeenCalledWith( + '[Vben Form] Failed to encode initial values. Falling back to raw form values.', + expect.objectContaining({ phase: 'encode' }), + ); + await expect(codecFormApi.getValues()).rejects.toBeInstanceOf( + FormCodecError, + ); + }); + it('should scan deprecated schema transforms once for unchanged state', async () => { const getChildren = vi.fn(() => []); const schema = { diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts b/packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts new file mode 100644 index 00000000..498fa83d --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts @@ -0,0 +1,207 @@ +import type { FormSchema } from '../src/types'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h, nextTick } from 'vue'; + +import { afterAll, bench, describe } from 'vitest'; +import { z } from 'zod'; + +import { setupVbenForm } from '../src/config'; +import { useVbenForm } from '../src/use-vben-form'; + +const BENCHMARK_OPTIONS = { time: 750, warmupTime: 150 } as const; +const FIELD_COUNT = 100; +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( + fieldCount: number, + withRules: boolean = false, +): FormSchema[] { + const rule = withRules ? z.string().min(1) : undefined; + return Array.from({ length: fieldCount }, (_, index) => ({ + component: TestInput, + defaultValue: `Value ${index}`, + fieldName: `field${index}`, + label: `Field ${index}`, + rules: rule, + })); +} + +function createFlatValues(prefix: string) { + return Object.fromEntries( + Array.from({ length: FIELD_COUNT }, (_, index) => [ + `field${index}`, + `${prefix} ${index}`, + ]), + ); +} + +setupVbenForm({ config: {}, rules: {} }); + +const flatSchema = createFlatSchema(FIELD_COUNT); +const [FlatForm, flatFormApi] = useVbenForm>({ + schema: flatSchema, + showDefaultActions: false, +}); +const flatWrapper = mount(FlatForm); + +const [ValidationForm, validationFormApi] = useVbenForm>( + { + schema: createFlatSchema(FIELD_COUNT, true), + showDefaultActions: false, + }, +); +const validationWrapper = mount(ValidationForm); + +const dependencySchema: FormSchema[] = [ + { + component: TestInput, + defaultValue: 'editable', + fieldName: 'mode', + label: 'Mode', + }, + ...Array.from({ length: 50 }, (_, index) => ({ + component: TestInput, + defaultValue: `Value ${index}`, + dependencies: { + resolve: ({ values }) => ({ disabled: values.mode === 'locked' }), + triggerFields: ['mode'], + }, + fieldName: `dependent${index}`, + label: `Dependent ${index}`, + })), +]; +const [DependencyForm, dependencyFormApi] = useVbenForm>( + { + schema: dependencySchema, + showDefaultActions: false, + }, +); +const dependencyWrapper = mount(DependencyForm); + +await flushPromises(); + +const batchValues = [createFlatValues('Alpha'), createFlatValues('Beta')]; +const schemaPatches = [false, true].map((disabled) => + Array.from({ length: FIELD_COUNT }, (_, index) => ({ + componentProps: { disabled }, + fieldName: `field${index}`, + })), +); +let batchIteration = 0; +let dependencyIteration = 0; +let fieldIteration = 0; +let resetIteration = 0; +let schemaIteration = 0; + +afterAll(() => { + dependencyWrapper.unmount(); + flatWrapper.unmount(); + validationWrapper.unmount(); +}); + +describe('form render performance', () => { + bench( + 'initialize, mount, and unmount 50 fields', + async () => { + const [Form] = useVbenForm>({ + schema: createFlatSchema(MOUNT_FIELD_COUNT), + showDefaultActions: false, + }); + const wrapper = mount(Form); + await flushPromises(); + wrapper.unmount(); + }, + BENCHMARK_OPTIONS, + ); +}); + +describe('form value performance', () => { + bench( + 'update one field in a 100-field form', + async () => { + fieldIteration += 1; + await flatFormApi.setFieldValue('field50', `Value ${fieldIteration}`); + await nextTick(); + }, + BENCHMARK_OPTIONS, + ); + + bench( + 'set 100 fields in one batch', + async () => { + batchIteration += 1; + await flatFormApi.setValues(batchValues[batchIteration % 2] ?? {}); + await nextTick(); + }, + BENCHMARK_OPTIONS, + ); + + bench( + 'reset 100 fields to alternate values', + async () => { + resetIteration += 1; + await flatFormApi.reset( + { values: batchValues[resetIteration % 2] ?? {} }, + { force: true }, + ); + await nextTick(); + }, + BENCHMARK_OPTIONS, + ); +}); + +describe('form validation performance', () => { + bench( + 'validate 100 fields with zod rules', + async () => { + await validationFormApi.validate(); + }, + BENCHMARK_OPTIONS, + ); +}); + +describe('form schema performance', () => { + bench( + 'update 100 schema entries', + async () => { + schemaIteration += 1; + flatFormApi.updateSchema(schemaPatches[schemaIteration % 2] ?? []); + await nextTick(); + }, + BENCHMARK_OPTIONS, + ); + + bench( + 'resolve 50 dependencies from one trigger', + async () => { + dependencyIteration += 1; + await dependencyFormApi.setFieldValue( + 'mode', + dependencyIteration % 2 === 0 ? 'editable' : 'locked', + ); + await flushPromises(); + }, + BENCHMARK_OPTIONS, + ); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts index d45e9791..e5491a9d 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts @@ -104,6 +104,34 @@ describe('useVbenForm integration', () => { expect(validateValue).toHaveBeenCalledTimes(initialValidationCount + 1); }); + it('keeps values reactive when exposed through the default slot', async () => { + const [Form, formApi] = useVbenForm({ + schema: [ + { + component: TestInput, + defaultValue: 'Ada', + fieldName: 'name', + }, + ], + showDefaultActions: false, + }); + const wrapper = mount(Form, { + slots: { + default: ({ values }: { values: Record }) => + h('span', { class: 'slot-value' }, values.name), + }, + }); + wrappers.push(wrapper); + await flushPromises(); + + expect(wrapper.get('.slot-value').text()).toBe('Ada'); + + await formApi.setFieldValue('name', 'Grace'); + await flushPromises(); + + expect(wrapper.get('.slot-value').text()).toBe('Grace'); + }); + it('supports a field-level change event fallback for legacy components', async () => { const [Form, formApi] = useVbenForm({ schema: [ @@ -485,6 +513,85 @@ describe('useVbenForm integration', () => { }); }); + it('preserves array row inputs and focus while editing', async () => { + const [Form] = useVbenForm({ + schema: [ + { + children: [ + { + component: TestInput, + fieldName: 'name', + label: 'Name', + }, + ], + defaultValue: [{ name: 'Ada' }], + fieldName: 'contacts', + type: 'array', + }, + ], + }); + const wrapper = mount(Form, { attachTo: document.body }); + wrappers.push(wrapper); + await flushPromises(); + const input = wrapper.get('input'); + const inputElement = input.element; + inputElement.focus(); + + await input.setValue('Ada Lovelace'); + await flushPromises(); + + expect(wrapper.get('input').element).toBe(inputElement); + expect((input.element as HTMLInputElement).value).toBe('Ada Lovelace'); + expect(document.activeElement).toBe(inputElement); + }); + + it('updates optimized array rows when values and schemas change', async () => { + const [Form, formApi] = useVbenForm({ + schema: [ + { + children: [ + { + component: TestInput, + fieldName: 'name', + label: 'Name', + }, + ], + defaultValue: [{ name: 'Ada' }, { name: 'Grace' }], + fieldName: 'contacts', + type: 'array', + }, + ], + }); + const wrapper = mount(Form); + wrappers.push(wrapper); + await flushPromises(); + const firstInput = wrapper.get('input[name="contacts[0].name"]'); + const firstInputElement = firstInput.element; + + await formApi.setFieldValue('contacts[0].name', 'Ada Lovelace'); + await flushPromises(); + + expect(wrapper.get('input[name="contacts[0].name"]').element).toBe( + firstInputElement, + ); + expect(firstInput.element.getAttribute('value')).toBe('Ada Lovelace'); + + formApi.updateSchema([ + { + componentProps: { disabled: true }, + fieldName: 'contacts.name', + }, + ]); + await flushPromises(); + + expect( + wrapper.get('input[name="contacts[0].name"]').attributes(), + ).toHaveProperty('disabled'); + expect( + wrapper.get('input[name="contacts[1].name"]').attributes(), + ).toHaveProperty('disabled'); + }); + it('scopes resolve dependencies to array rows', async () => { const resolve = vi.fn(({ schema }: Record) => ({ componentProps: { diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts b/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts new file mode 100644 index 00000000..11e7c8a9 --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts @@ -0,0 +1,182 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h, nextTick } from 'vue'; + +import { afterAll, bench, describe } from 'vitest'; + +import { setupVbenForm } from '../src/config'; +import { FormApi } from '../src/form-api'; +import { encodeFormValues } from '../src/form-codec'; +import { useVbenForm } from '../src/use-vben-form'; + +interface ContactValues { + enabled: boolean; + metadata: { + permissions: string[]; + team: string; + }; + name: string; + phone: string; + tags: string[]; +} + +interface PerformanceFormValues extends Record { + contacts: ContactValues[]; + settings: { + alerts: boolean; + locale: string; + sections: string[]; + }; +} + +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 { + return { + contacts: Array.from({ length: ROW_COUNT }, (_, index) => ({ + enabled: index % 2 === 0, + metadata: { + permissions: ['read', 'write', 'review'], + team: `team-${index % 10}`, + }, + name: ` Contact ${index} `, + phone: `10086-${index}`, + tags: ['primary', 'on-call', `group-${index % 5}`], + })), + settings: { + alerts: true, + locale: 'zh-CN', + sections: ['profile', 'security', 'notifications'], + }, + }; +} + +const codec = { + decode: (values: Readonly) => ({ ...values }), + encode: (values: Readonly) => ({ + ...values, + contacts: values.contacts.map((contact) => ({ + ...contact, + name: contact.name.trim(), + })), + }), +}; + +const formValues = createFormValues(); +const codecFormApi = new FormApi({ codec }); +codecFormApi.mount({ meta: {}, values: formValues } as never, new Map()); + +setupVbenForm({ config: {}, rules: {} }); +const [ArrayForm, arrayFormApi] = useVbenForm({ + schema: [ + { + children: [ + { + component: TestInput, + fieldName: 'name', + label: 'Name', + }, + ], + defaultValue: Array.from({ length: ROW_COUNT }, (_, index) => ({ + name: `Contact ${index}`, + })), + fieldName: 'contacts', + type: 'array', + }, + ], +}); +const arrayWrapper = mount(ArrayForm); +await flushPromises(); +const arraySchemaPatches = [false, true].map((disabled) => ({ + componentProps: { disabled }, + fieldName: 'contacts.name', +})); +let arrayEditIteration = 0; +let arraySchemaIteration = 0; + +afterAll(() => { + arrayWrapper.unmount(); +}); + +describe('form codec performance', () => { + bench( + 'encode 100 nested rows without isolation', + () => { + encodeFormValues(codec, formValues); + }, + { time: 1000, warmupTime: 200 }, + ); + + bench( + 'encode 100 nested rows with isolated input', + () => { + codecFormApi.formatValues(formValues); + }, + { time: 1000, warmupTime: 200 }, + ); + + bench( + 'create submit snapshot for 100 nested rows', + async () => { + await codecFormApi.getValueSnapshot(); + }, + { time: 1000, warmupTime: 200 }, + ); +}); + +describe('form array performance', () => { + bench( + 'edit one field in a 100-row array', + async () => { + arrayEditIteration += 1; + await arrayFormApi.setFieldValue( + 'contacts[50].name', + `Contact ${arrayEditIteration}`, + ); + await nextTick(); + }, + { time: 1000, warmupTime: 200 }, + ); + + bench( + 'append and remove one row from a 100-row array', + async () => { + arrayFormApi.form.pushFieldValue('contacts', { name: 'Temporary' }); + await nextTick(); + await arrayFormApi.form.removeFieldValue('contacts', ROW_COUNT); + await nextTick(); + }, + { time: 1000, warmupTime: 200 }, + ); + + bench( + 'update one child schema across 100 rows', + async () => { + arraySchemaIteration += 1; + arrayFormApi.updateSchema([ + arraySchemaPatches[arraySchemaIteration % 2] ?? {}, + ]); + await nextTick(); + }, + { time: 1000, warmupTime: 200 }, + ); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts index 430edeff..aee61d1e 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts @@ -228,6 +228,10 @@ describe('form public types', () => { handleReset(values) { expectTypeOf(values).toEqualTypeOf(); }, + handleSubmit(values, rawValues) { + expectTypeOf(values).toEqualTypeOf(); + expectTypeOf(rawValues).toEqualTypeOf>(); + }, schema: [], }); diff --git a/packages/@core/ui-kit/form-ui/package.json b/packages/@core/ui-kit/form-ui/package.json index 87e5b46b..cc54a078 100644 --- a/packages/@core/ui-kit/form-ui/package.json +++ b/packages/@core/ui-kit/form-ui/package.json @@ -40,6 +40,7 @@ } }, "dependencies": { + "@tanstack/store": "catalog:", "@tanstack/vue-form": "catalog:", "@vben-core/composables": "workspace:*", "@vben-core/icons": "workspace:*", diff --git a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue index 963e547a..9f22067e 100644 --- a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue +++ b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue @@ -10,7 +10,7 @@ import { VbenIconButton, VbenRenderContent, } from '@vben-core/shadcn-ui'; -import { cn, isObject, set } from '@vben-core/shared/utils'; +import { cn, get, set } from '@vben-core/shared/utils'; import { injectRenderFormProps } from '../form-render/context'; import FormField from '../form-render/form-field.vue'; @@ -71,16 +71,16 @@ if (!form) { throw new Error('Form api is required in '); } const formActions = form; -const arrayValue = formActions.useFieldValue(props.name); -const rowKeys = new WeakMap(); -let nextRowKey = 0; - -const fields = computed[]>(() => { - return Array.isArray(arrayValue.value) ? arrayValue.value : []; +const arrayLength = formActions.useSelector((state) => { + const value = get(state.values, props.name); + return Array.isArray(value) ? value.length : 0; }); +const rowIndexes = computed(() => + Array.from({ length: arrayLength.value }, (_, index) => index), +); -const canAdd = computed(() => fields.value.length < props.max); -const canRemove = computed(() => fields.value.length > props.min); +const canAdd = computed(() => arrayLength.value < props.max); +const canRemove = computed(() => arrayLength.value > props.min); const gridStyle = computed(() => { const columns = [ ...(props.showIndex ? ['3rem'] : []), @@ -124,20 +124,6 @@ function removeRow(index: number) { void formActions.removeFieldValue(arrayPath.value, index); } -function getRowKey(row: Record, index: number) { - if (!isObject(row)) { - return `${arrayPath.value}-${index}`; - } - const existingKey = rowKeys.get(row); - if (existingKey) { - return existingKey; - } - nextRowKey += 1; - const key = `${arrayPath.value}-${nextRowKey}`; - rowKeys.set(row, key); - return key; -} - function rowSchemas(index: number) { return props.schema.map((col) => createArrayChildSchema(col as never, { @@ -149,6 +135,10 @@ function rowSchemas(index: number) { }), ); } + +const normalizedRowSchemas = computed(() => + Array.from({ length: arrayLength.value }, (_, index) => rowSchemas(index)), +); diff --git a/playground/__tests__/unit/date-range-codec.test.ts b/playground/__tests__/unit/date-range-codec.test.ts new file mode 100644 index 00000000..589ffe30 --- /dev/null +++ b/playground/__tests__/unit/date-range-codec.test.ts @@ -0,0 +1,47 @@ +import type { Dayjs } from 'dayjs'; + +import dayjs from 'dayjs'; +import { describe, expect, it } from 'vitest'; + +import { createDateRangeCodec } from '../../src/utils/date-range-codec'; + +interface SearchFormValues extends Record { + createdAt?: [Dayjs, Dayjs]; + keyword?: string; +} + +const codec = createDateRangeCodec()({ + endField: 'finishedAt', + rangeField: 'createdAt', + startField: 'startedAt', +}); + +describe('date range codec', () => { + it('encodes and decodes configurable date range fields', () => { + const submitValues = codec.encode({ + createdAt: [dayjs('2026-07-01'), dayjs('2026-07-23')], + keyword: 'admin', + }); + + expect(submitValues).toEqual({ + finishedAt: '2026-07-23', + keyword: 'admin', + startedAt: '2026-07-01', + }); + const formValues = codec.decode(submitValues); + expect(formValues.keyword).toBe('admin'); + expect( + formValues.createdAt?.map((value) => value.format('YYYY-MM-DD')), + ).toEqual(['2026-07-01', '2026-07-23']); + }); + + it('does not reconstruct a range with a missing bound', () => { + expect( + codec.decode({ + finishedAt: undefined, + keyword: 'admin', + startedAt: '2026-07-01', + }), + ).toEqual({ keyword: 'admin' }); + }); +}); diff --git a/playground/src/utils/date-range-codec.ts b/playground/src/utils/date-range-codec.ts new file mode 100644 index 00000000..00243e7f --- /dev/null +++ b/playground/src/utils/date-range-codec.ts @@ -0,0 +1,64 @@ +import type { Dayjs } from 'dayjs'; + +import dayjs from 'dayjs'; + +type DateRange = [Dayjs, Dayjs]; + +type DateRangeSubmitValues< + TFormValues extends Record, + TRangeField extends keyof TFormValues, + TStartField extends string, + TEndField extends string, +> = Omit & + Record; + +interface DateRangeCodecOptions< + TFormValues extends Record, + TRangeField extends keyof TFormValues & string, + TStartField extends string, + TEndField extends string, +> { + endField: TEndField; + rangeField: TRangeField; + startField: TStartField; +} + +export function createDateRangeCodec< + TFormValues extends Record, +>() { + return function createCodec< + TRangeField extends keyof TFormValues & string, + TStartField extends string, + TEndField extends string, + >({ + endField, + rangeField, + startField, + }: DateRangeCodecOptions) { + type SubmitValues = DateRangeSubmitValues< + TFormValues, + TRangeField, + TStartField, + TEndField + >; + + return { + decode(values: Readonly): TFormValues { + const { [endField]: end, [startField]: start, ...formValues } = values; + return { + ...formValues, + ...(start && end ? { [rangeField]: [dayjs(start), dayjs(end)] } : {}), + } as TFormValues; + }, + encode(values: Readonly): SubmitValues { + const { [rangeField]: value, ...formValues } = values; + const range = value as DateRange | undefined; + return { + ...formValues, + [endField]: range?.[1]?.format('YYYY-MM-DD'), + [startField]: range?.[0]?.format('YYYY-MM-DD'), + } as SubmitValues; + }, + }; + }; +} diff --git a/playground/src/views/demos/form-array/index.vue b/playground/src/views/demos/form-array/index.vue index 536508c3..702c1960 100644 --- a/playground/src/views/demos/form-array/index.vue +++ b/playground/src/views/demos/form-array/index.vue @@ -121,7 +121,7 @@ const schema: VbenFormSchema[] = [ defaultValue: '', fieldName: 'name', label: '姓名', - rules: z.string().min(1, '请输入姓名'), + rules: z.string().trim().min(1, '请输入姓名'), }, { component: 'Select', diff --git a/playground/src/views/examples/vxe-table/form.vue b/playground/src/views/examples/vxe-table/form.vue index ed0645ba..f4d83c55 100644 --- a/playground/src/views/examples/vxe-table/form.vue +++ b/playground/src/views/examples/vxe-table/form.vue @@ -11,6 +11,7 @@ import dayjs from 'dayjs'; import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { getExampleTableApi } from '#/api'; +import { createDateRangeCodec } from '#/utils/date-range-codec'; interface RowType { category: string; @@ -29,32 +30,16 @@ interface SearchFormValues extends Record { productName?: string; } -function encodeSearchFormValues(values: Readonly) { - const { date, ...formValues } = values; - return { - ...formValues, - end: date?.[1]?.format('YYYY-MM-DD'), - start: date?.[0]?.format('YYYY-MM-DD'), - }; -} +const searchCodec = createDateRangeCodec()({ + endField: 'end', + rangeField: 'date', + startField: 'start', +}); -type SearchSubmitValues = ReturnType; - -function decodeSearchFormValues( - values: Readonly, -): SearchFormValues { - const { end, start, ...formValues } = values; - return { - ...formValues, - ...(start && end ? { date: [dayjs(start), dayjs(end)] } : {}), - }; -} +type SearchSubmitValues = ReturnType; const formOptions: VbenFormProps = { - codec: { - decode: decodeSearchFormValues, - encode: encodeSearchFormValues, - }, + codec: searchCodec, // 默认展开 collapsed: false, schema: [ diff --git a/playground/src/views/system/menu/modules/form.vue b/playground/src/views/system/menu/modules/form.vue index 8129e83d..7ce50f05 100644 --- a/playground/src/views/system/menu/modules/form.vue +++ b/playground/src/views/system/menu/modules/form.vue @@ -40,15 +40,19 @@ function encodeMenuFormValues( ): MenuSubmitValues { const { linkSrc, ...formValues } = values; if (values.type === 'link') { + const meta = { ...values.meta, link: linkSrc }; + Reflect.deleteProperty(meta, 'iframeSrc'); return { ...formValues, - meta: { ...values.meta, link: linkSrc }, + meta, }; } if (values.type === 'embedded') { + const meta = { ...values.meta, iframeSrc: linkSrc }; + Reflect.deleteProperty(meta, 'link'); return { ...formValues, - meta: { ...values.meta, iframeSrc: linkSrc }, + meta, }; } return formValues; diff --git a/playground/src/views/system/role/list.vue b/playground/src/views/system/role/list.vue index 3802753b..321eef7d 100644 --- a/playground/src/views/system/role/list.vue +++ b/playground/src/views/system/role/list.vue @@ -13,11 +13,11 @@ import { Page, useVbenDrawer } from '@vben/common-ui'; import { Plus } from '@vben/icons'; import { Button, message, Modal } from 'antdv-next'; -import dayjs from 'dayjs'; import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { deleteRole, getRoleList, updateRole } from '#/api'; import { $t } from '#/locales'; +import { createDateRangeCodec } from '#/utils/date-range-codec'; import { useColumns, useGridFormSchema } from './data'; import Form from './modules/form.vue'; @@ -26,28 +26,13 @@ interface RoleSearchFormValues extends Record { createTime?: [Dayjs, Dayjs]; } -function encodeRoleSearchValues(values: Readonly) { - const { createTime, ...formValues } = values; - return { - ...formValues, - endTime: createTime?.[1]?.format('YYYY-MM-DD'), - startTime: createTime?.[0]?.format('YYYY-MM-DD'), - }; -} +const roleSearchCodec = createDateRangeCodec()({ + endField: 'endTime', + rangeField: 'createTime', + startField: 'startTime', +}); -type RoleSearchSubmitValues = ReturnType; - -function decodeRoleSearchValues( - values: Readonly, -): RoleSearchFormValues { - const { endTime, startTime, ...formValues } = values; - return { - ...formValues, - ...(startTime && endTime - ? { createTime: [dayjs(startTime), dayjs(endTime)] } - : {}), - }; -} +type RoleSearchSubmitValues = ReturnType; const [FormDrawer, formDrawerApi] = useVbenDrawer({ connectedComponent: Form, @@ -56,10 +41,7 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({ const [Grid, gridApi] = useVbenVxeGrid({ formOptions: { - codec: { - decode: decodeRoleSearchValues, - encode: encodeRoleSearchValues, - }, + codec: roleSearchCodec, schema: useGridFormSchema(), submitOnChange: true, }, diff --git a/playground/src/views/system/user/list.vue b/playground/src/views/system/user/list.vue index a73b4bc0..d4b65dea 100644 --- a/playground/src/views/system/user/list.vue +++ b/playground/src/views/system/user/list.vue @@ -12,11 +12,11 @@ import { Page, Tree, useVbenDrawer } from '@vben/common-ui'; import { Plus } from '@vben/icons'; import { Button, Card, InputSearch, message, Modal } from 'antdv-next'; -import dayjs from 'dayjs'; import { useVbenVxeGrid, VbenTableAction } from '#/adapter/vxe-table'; import { deleteUser, getDeptList, getUserList, updateUser } from '#/api'; import { $t } from '#/locales'; +import { createDateRangeCodec } from '#/utils/date-range-codec'; import { useColumns, useGridFormSchema } from './data'; import Detail from './modules/detail.vue'; @@ -26,28 +26,13 @@ interface UserSearchFormValues extends Record { createTime?: [Dayjs, Dayjs]; } -function encodeUserSearchValues(values: Readonly) { - const { createTime, ...formValues } = values; - return { - ...formValues, - endTime: createTime?.[1]?.format('YYYY-MM-DD'), - startTime: createTime?.[0]?.format('YYYY-MM-DD'), - }; -} +const userSearchCodec = createDateRangeCodec()({ + endField: 'endTime', + rangeField: 'createTime', + startField: 'startTime', +}); -type UserSearchSubmitValues = ReturnType; - -function decodeUserSearchValues( - values: Readonly, -): UserSearchFormValues { - const { endTime, startTime, ...formValues } = values; - return { - ...formValues, - ...(startTime && endTime - ? { createTime: [dayjs(startTime), dayjs(endTime)] } - : {}), - }; -} +type UserSearchSubmitValues = ReturnType; const deptList = ref([]); const inputSearchValue = ref(''); @@ -65,10 +50,7 @@ const [DetailDrawer, detailDrawerApi] = useVbenDrawer({ const [Grid, gridApi] = useVbenVxeGrid({ formOptions: { - codec: { - decode: decodeUserSearchValues, - encode: encodeUserSearchValues, - }, + codec: userSearchCodec, schema: useGridFormSchema(), submitOnChange: true, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a51b710f..d31b203f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ catalogs: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3 + '@tanstack/store': + specifier: ^0.11.0 + version: 0.11.0 '@tanstack/vue-form': specifier: ^1.33.2 version: 1.33.2 @@ -1400,6 +1403,9 @@ importers: packages/@core/ui-kit/form-ui: dependencies: + '@tanstack/store': + specifier: 'catalog:' + version: 0.11.0 '@tanstack/vue-form': specifier: 'catalog:' version: 1.33.2(vue@3.5.40(typescript@6.0.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fce71719..1770501c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -62,6 +62,7 @@ catalog: '@stylistic/stylelint-plugin': ^5.2.1 '@tailwindcss/typography': ^0.5.20 '@tailwindcss/vite': ^4.3.3 + '@tanstack/store': ^0.11.0 '@tanstack/vue-form': ^1.33.2 '@tanstack/vue-query': ^5.101.2 '@tanstack/vue-store': ^0.11.0