This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
@@ -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 ?? '',
});
},
});
@@ -0,0 +1,596 @@
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;
beforeEach(() => {
formApi = new FormApi();
});
it('should initialize with default state', () => {
expect(formApi.state).toEqual(
expect.objectContaining({
actionWrapperClass: '',
collapsed: false,
collapsedRows: 1,
commonConfig: {},
handleReset: undefined,
handleSubmit: undefined,
layout: 'horizontal',
resetButtonOptions: {},
schema: [],
showCollapseButton: false,
showDefaultActions: true,
submitButtonOptions: {},
wrapperClass: 'grid-cols-1',
}),
);
expect(formApi.isMounted).toBe(false);
});
it('should mount form actions', async () => {
const formActions: any = {
meta: {},
resetForm: vi.fn(),
setFieldValue: vi.fn(),
setValues: vi.fn(),
submitForm: vi.fn(),
validate: vi.fn(),
values: { name: 'test' },
};
await formApi.mount(formActions);
expect(formApi.isMounted).toBe(true);
expect(formApi.form).toEqual(formActions);
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
});
it('should get values from form', async () => {
const formActions: any = {
meta: {},
values: { name: 'test' },
};
await formApi.mount(formActions, new Map());
const values = await formApi.getValues();
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 () => {
formApi.setState({
schema: [
{
component: 'range-picker',
fieldName: 'filters.range',
valueFormat: (value, setValue) => {
setValue('filters.startTime', value?.[0]);
setValue('filters.endTime', value?.[1]);
},
},
],
});
const formActions: any = {
meta: {},
values: {
filters: {
range: [1_710_000_000_000, 1_720_000_000_000],
},
},
};
const originalValuesSnapshot = structuredClone(formActions.values);
await formApi.mount(formActions, new Map());
expect(formApi.getLatestSubmissionValues()).toEqual({
filters: {
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
},
});
const values = await formApi.getValues();
expect(values).toEqual({
filters: {
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
},
});
expect(await formApi.getRawValues()).toEqual(originalValuesSnapshot);
expect(await formApi.getValueSnapshot()).toEqual({
rawValues: originalValuesSnapshot,
values,
});
expect(formActions.values).toEqual(originalValuesSnapshot);
});
it('should encode submissions and decode complete values with a codec', async () => {
interface FilterFormValues {
period: [number, number];
tags: string[];
}
interface FilterSubmitValues {
endTime: number;
startTime: number;
tags: string;
}
const setValues = vi.fn();
const codecFormApi = new FormApi<
FilterFormValues,
BaseFormComponentType,
Record<never, never>,
FilterSubmitValues
>({
codec: {
decode(values) {
return {
period: [values.startTime, values.endTime],
tags: values.tags.split(','),
};
},
encode(values) {
return {
endTime: values.period[1],
startTime: values.period[0],
tags: values.tags.join(','),
};
},
},
});
const formActions: any = {
meta: {},
setValues,
values: { period: [1, 2], tags: ['admin', 'user'] },
};
await codecFormApi.mount(formActions, new Map());
expect(await codecFormApi.getValues()).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,user',
});
expect(await codecFormApi.getValueSnapshot()).toEqual({
rawValues: { period: [1, 2], tags: ['admin', 'user'] },
values: { endTime: 2, startTime: 1, tags: 'admin,user' },
});
await codecFormApi.setSubmitValues(
{ endTime: 4, startTime: 3, tags: 'editor' },
false,
);
expect(setValues).toHaveBeenCalledWith(
{ period: [3, 4], tags: ['editor'] },
false,
);
});
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<ProfileFormValues>({
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<never, never>,
{ 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 = {
component: 'text',
fieldName: 'name',
get children() {
return getChildren();
},
} as any;
const codecFormApi = new FormApi({
codec: {
decode: (values) => values,
encode: (values) => values,
},
schema: [schema],
});
const formActions: any = {
meta: {},
values: { name: 'Ada' },
};
await codecFormApi.mount(formActions, new Map());
expect(getChildren).toHaveBeenCalledTimes(1);
await codecFormApi.getValues();
await codecFormApi.getValues();
expect(getChildren).toHaveBeenCalledTimes(1);
});
it('should format child schema values inside array fields', async () => {
formApi.setState({
schema: [
{
children: [
{
component: 'text',
fieldName: 'name',
valueFormat: (
value: any,
setValue: any,
_values: any,
ctx: any,
) => {
setValue('normalizedName', value?.trim());
setValue('$root.firstRow', ctx?.rowIndex);
},
},
],
fieldName: 'contacts',
type: 'array',
} as any,
],
});
const formActions: any = {
meta: {},
values: {
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
},
};
await formApi.mount(formActions, new Map());
const values = await formApi.getValues();
expect(values).toEqual({
contacts: [{ normalizedName: 'Ada' }, { normalizedName: 'Grace' }],
firstRow: 1,
});
});
it('should set field value', async () => {
const setFieldValueMock = vi.fn();
const formActions: any = {
meta: {},
setFieldValue: setFieldValueMock,
values: { name: 'test' },
};
await formApi.mount(formActions, new Map());
await formApi.setFieldValue('name', 'new value');
expect(setFieldValueMock).toHaveBeenCalledWith(
'name',
'new value',
undefined,
);
});
it('should set only known fields without losing provided values', async () => {
const setValuesMock = vi.fn();
formApi.setState({
schema: [
{ component: 'text', fieldName: 'name' },
{ component: 'text', fieldName: 'profile.email' },
],
});
const formActions: any = {
meta: {},
setValues: setValuesMock,
values: {},
};
await formApi.mount(formActions, new Map());
await formApi.setValues({
name: 'Ada',
profile: {
email: 'ada@example.com',
ignored: true,
},
unknown: 'ignored',
});
expect(setValuesMock).toHaveBeenCalledWith(
{
name: 'Ada',
profile: {
email: 'ada@example.com',
},
},
false,
);
});
it('should reset form', async () => {
const resetMock = vi.fn();
const formActions: any = {
meta: {},
reset: resetMock,
values: { name: 'test' },
};
await formApi.mount(formActions, new Map());
await formApi.reset();
expect(resetMock).toHaveBeenCalled();
});
it('should call handleSubmit on submit', async () => {
const handleSubmitMock = vi.fn();
const formActions: any = {
meta: {},
submit: vi.fn().mockResolvedValue(true),
values: { name: 'test' },
};
const state = {
handleSubmit: handleSubmitMock,
};
formApi.setState(state);
await formApi.mount(formActions, new Map());
const result = await formApi.submit();
expect(formActions.submit).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith(
{ name: 'test' },
{ name: 'test' },
);
expect(result).toEqual({ name: 'test' });
});
it('should unmount form and reset state', () => {
formApi.unmount();
expect(formApi.isMounted).toBe(false);
});
it('should clear component refs on unmount before mounting again', async () => {
const formActions: any = {
meta: {},
resetForm: vi.fn(),
values: { name: 'test' },
};
const staleMap = new Map<string, unknown>([
[
'name',
{
$: {
type: { name: 'MockComponent' },
},
$el: {},
},
],
]);
await formApi.mount(formActions, staleMap);
expect(formApi.getFieldComponentRef('name')).toEqual({
$: {
type: { name: 'MockComponent' },
},
$el: {},
});
formApi.unmount();
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
await formApi.mount(formActions);
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
});
it('should validate form', async () => {
const validateMock = vi.fn().mockResolvedValue(true);
const formActions: any = {
meta: {},
validate: validateMock,
};
await formApi.mount(formActions, new Map());
const isValid = await formApi.validate();
expect(validateMock).toHaveBeenCalled();
expect(isValid).toBe(true);
});
it('should validate only once before submitting valid values', async () => {
const handleSubmit = vi.fn();
const formActions: any = {
meta: {},
submit: vi.fn(),
validate: vi.fn().mockResolvedValue({ errors: {}, valid: true }),
values: { name: 'Ada' },
};
formApi.setState({ handleSubmit });
await formApi.mount(formActions, new Map());
await expect(formApi.validateAndSubmit()).resolves.toEqual({ name: 'Ada' });
expect(formActions.validate).toHaveBeenCalledOnce();
expect(formActions.submit).not.toHaveBeenCalled();
expect(handleSubmit).toHaveBeenCalledOnce();
});
it('should not submit invalid values', async () => {
const handleSubmit = vi.fn();
const errors = { name: 'Name is required' };
const formActions: any = {
meta: {},
submit: vi.fn(),
validate: vi.fn().mockResolvedValue({ errors, valid: false }),
values: { name: '' },
};
const scrollToFirstError = vi
.spyOn(formApi as any, 'scrollToFirstError')
.mockImplementation(() => {});
formApi.setState({ handleSubmit, scrollToFirstError: true });
await formApi.mount(formActions, new Map());
await expect(formApi.validateAndSubmit()).resolves.toBeUndefined();
expect(formActions.validate).toHaveBeenCalledOnce();
expect(formActions.submit).not.toHaveBeenCalled();
expect(handleSubmit).not.toHaveBeenCalled();
expect(scrollToFirstError).toHaveBeenCalledOnce();
expect(scrollToFirstError).toHaveBeenCalledWith(errors);
});
});
describe('updateSchema', () => {
let instance: FormApi;
beforeEach(() => {
instance = new FormApi();
instance.state = {
schema: [
{ component: 'text', fieldName: 'name' },
{ component: 'number', fieldName: 'age', label: 'Age' },
],
};
});
it('should update the schema correctly when fieldName matches', () => {
const newSchema = [
{ component: 'text', fieldName: 'name' },
{ component: 'number', fieldName: 'age', label: 'Age' },
];
instance.updateSchema(newSchema);
expect(instance.state?.schema?.[0]?.component).toBe('text');
expect(instance.state?.schema?.[1]?.label).toBe('Age');
});
it('should update child schema by parent path', () => {
instance.state = {
schema: [
{
children: [
{ component: 'text', fieldName: 'name', label: 'Name' },
{ component: 'text', fieldName: 'phone', label: 'Phone' },
],
fieldName: 'contacts',
type: 'array',
} as any,
],
};
instance.updateSchema([
{
fieldName: 'contacts.name',
label: 'Full Name',
},
]);
expect((instance.state?.schema?.[0] as any)?.children?.[0]?.label).toBe(
'Full Name',
);
expect((instance.state?.schema?.[0] as any)?.children?.[1]?.label).toBe(
'Phone',
);
});
it('should log an error if fieldName is missing in some items', () => {
const newSchema: any[] = [
{ component: 'textarea', fieldName: 'name' },
{ component: 'number' },
];
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
instance.updateSchema(newSchema);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'All items in the schema array must have a valid `fieldName` property to be updated',
);
});
it('should not update schema if fieldName does not match', () => {
const newSchema = [{ component: 'textarea', fieldName: 'unknown' }];
instance.updateSchema(newSchema);
expect(instance.state?.schema?.[0]?.component).toBe('text');
expect(instance.state?.schema?.[1]?.component).toBe('number');
});
it('should not update schema if updatedMap is empty', () => {
const newSchema: any[] = [{ component: 'textarea' }];
instance.updateSchema(newSchema);
expect(instance.state?.schema?.[0]?.component).toBe('text');
expect(instance.state?.schema?.[1]?.component).toBe('number');
});
});
@@ -0,0 +1,81 @@
import type { FormCodec } from '../src/types';
import { describe, expect, expectTypeOf, it } from 'vitest';
import {
decodeFormValues,
encodeFormValues,
FormCodecError,
} from '../src/form-codec';
interface FilterFormValues {
period: [number, number];
tags: string[];
}
interface FilterSubmitValues {
endTime: number;
startTime: number;
tags: string;
}
const filterCodec: FormCodec<FilterFormValues, FilterSubmitValues> = {
decode(values) {
return {
period: [values.startTime, values.endTime],
tags: values.tags ? values.tags.split(',') : [],
};
},
encode(values) {
return {
endTime: values.period[1],
startTime: values.period[0],
tags: values.tags.join(','),
};
},
};
describe('form codec', () => {
it('encodes and decodes complete form values', () => {
const submitValues = encodeFormValues(filterCodec, {
period: [1, 2],
tags: ['admin', 'user'],
});
expect(submitValues).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,user',
});
expect(decodeFormValues(filterCodec, submitValues)).toEqual({
period: [1, 2],
tags: ['admin', 'user'],
});
expectTypeOf(submitValues).toEqualTypeOf<FilterSubmitValues>();
});
it('reports the failed codec phase without mutating inputs', () => {
const values = Object.freeze({ period: [1, 2], tags: ['admin'] }) as {
period: [number, number];
tags: string[];
};
const codec: FormCodec<FilterFormValues, FilterSubmitValues> = {
decode: filterCodec.decode,
encode() {
throw new Error('broken encoder');
},
};
expect(() => encodeFormValues(codec, values)).toThrowError(FormCodecError);
let codecError: unknown;
try {
encodeFormValues(codec, values);
} catch (error) {
codecError = error;
}
expect(codecError).toBeInstanceOf(FormCodecError);
expect(codecError).toMatchObject({ phase: 'encode' });
expect(values).toEqual({ period: [1, 2], tags: ['admin'] });
});
});
@@ -0,0 +1,180 @@
import type { BaseFormComponentType } from '../src/types';
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('keeps deprecated value transforms and warns once per API', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi({
arrayToStringFields: ['tags'],
fieldMappingTime: [['period', ['startTime', 'endTime'], null]],
schema: [
{
component: 'input',
fieldName: 'name',
valueFormat: (value: string) => value.trim(),
},
],
});
const form = {
meta: {},
values: {
name: ' Ada ',
period: [1, 2],
tags: ['admin', 'user'],
},
} as any;
formApi.mount(form);
expect(await formApi.getValues()).toEqual({
endTime: 2,
name: 'Ada',
startTime: 1,
tags: 'admin,user',
});
expect(warning).toHaveBeenCalledTimes(3);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `schema.valueFormat` is deprecated. Use the form-level `codec` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `fieldMappingTime` is deprecated. Use the form-level `codec` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `arrayToStringFields` is deprecated. Use the form-level `codec` instead.',
);
});
it('prefers the codec when deprecated transforms are also configured', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi<
{ name: string },
BaseFormComponentType,
Record<never, never>,
{ normalizedName: string }
>({
codec: {
decode(values) {
return { name: values.normalizedName };
},
encode(values) {
return { normalizedName: values.name.toUpperCase() };
},
},
schema: [
{
component: 'input',
fieldName: 'name',
valueFormat: () => 'legacy',
},
],
});
const form = { meta: {}, values: { name: 'Ada' } } as any;
formApi.mount(form);
expect(await formApi.getValues()).toEqual({ normalizedName: 'ADA' });
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'[Vben Form] The form `codec` takes precedence over deprecated `valueFormat`, `fieldMappingTime`, and `arrayToStringFields` options.',
);
});
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,190 @@
import type { FormSchema } from '../src/types';
import { flushPromises, mount } from '@vue/test-utils';
import { 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';
import { TestInput } from './benchmark-fixtures';
const BENCHMARK_OPTIONS = { time: 750, warmupTime: 150 } as const;
const FIELD_COUNT = 100;
const MOUNT_FIELD_COUNT = 50;
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<Record<string, string>>({
schema: flatSchema,
showDefaultActions: false,
});
const flatWrapper = mount(FlatForm);
const [ValidationForm, validationFormApi] = useVbenForm<Record<string, string>>(
{
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 }: { values: Record<string, string> }) => ({
disabled: values.mode === 'locked',
}),
triggerFields: ['mode'],
},
fieldName: `dependent${index}`,
label: `Dependent ${index}`,
})),
];
const [DependencyForm, dependencyFormApi] = useVbenForm<Record<string, string>>(
{
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<Record<string, string>>({
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,
);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
import { flushPromises, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterAll, bench, describe } from 'vitest';
import { setupVbenForm } from '../src/config';
import { encodeFormValues } from '../src/form-codec';
import { useVbenForm } from '../src/use-vben-form';
import { TestInput } from './benchmark-fixtures';
interface ContactValues {
enabled: boolean;
metadata: {
permissions: string[];
team: string;
};
name: string;
phone: string;
tags: string[];
}
interface PerformanceFormValues extends Record<string, unknown> {
contacts: ContactValues[];
settings: {
alerts: boolean;
locale: string;
sections: string[];
};
}
const ROW_COUNT = 100;
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<PerformanceFormValues>) => ({ ...values }),
encode: (values: Readonly<PerformanceFormValues>) => ({
...values,
contacts: values.contacts.map((contact) => ({
...contact,
name: contact.name.trim(),
})),
}),
};
const formValues = createFormValues();
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({
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();
codecWrapper.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 },
);
});
@@ -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,73 @@
import { describe, expect, it, vi } from 'vitest';
import {
createArrayChildSchema,
createFormFieldSchema,
} from '../src/form-render/schema';
describe('form schema normalization', () => {
it('resolves common component props with the field context', () => {
const componentProps = vi.fn(({ fieldName }) => ({
placeholder: `Enter ${fieldName}`,
}));
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'name' },
{ commonConfig: { componentProps } },
);
expect(componentProps).toHaveBeenCalledWith({ fieldName: 'name' });
expect(schema.commonComponentProps).toEqual({
placeholder: 'Enter name',
});
});
it('preserves common component props objects', () => {
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'name' },
{ commonConfig: { componentProps: { placeholder: 'Enter a name' } } },
);
expect(schema.commonComponentProps).toEqual({
placeholder: 'Enter a name',
});
});
it('resolves global common component props functions', () => {
const componentProps = vi.fn(({ fieldName }) => ({
title: `Global ${fieldName}`,
}));
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'email' },
{ globalCommonConfig: { componentProps } },
);
expect(componentProps).toHaveBeenCalledWith({ fieldName: 'email' });
expect(schema.commonComponentProps).toEqual({ title: 'Global email' });
});
it('resolves array common props with the row context', () => {
const componentProps = vi.fn(() => ({ placeholder: 'Contact name' }));
const schema = createArrayChildSchema(
{ component: 'VbenInput', fieldName: 'name' },
{
arrayField: 'contacts',
commonConfig: { componentProps },
index: 1,
},
);
expect(componentProps).toHaveBeenCalledWith({
arrayField: 'contacts',
fieldName: 'contacts[1].name',
originalFieldName: 'name',
rowIndex: 1,
rowPath: 'contacts[1]',
});
expect(schema.commonComponentProps).toEqual({
placeholder: 'Contact name',
});
});
});
@@ -0,0 +1,275 @@
import type {
BaseFormComponentType,
ExtendedFormApi,
FormActions,
FormContextApi,
FormFieldOptions,
FormItemDependencies,
FormValidationResult,
FormValueSnapshot,
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[];
}
interface AccountSubmitValues {
email: string;
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['componentProps']['modelValue']
>().toEqualTypeOf<string | undefined>();
expectTypeOf<EmailSlotProps['formApi']>().toEqualTypeOf<
ExtendedFormApi<AccountFormValues>
>();
expectTypeOf<
DefaultSlotProps['values']
>().toEqualTypeOf<AccountFormValues>();
const [WideForm] = useVbenForm<Record<string, unknown>>({ schema: [] });
type WideFormSlots = InstanceType<typeof WideForm>['$slots'];
type WideFieldSlot = NonNullable<WideFormSlots['dynamic-field']>;
type WideFieldSlotProps = Parameters<WideFieldSlot>[0];
expectTypeOf<WideFieldSlotProps>().not.toBeAny();
expectTypeOf<WideFieldSlotProps['modelValue']>().toEqualTypeOf<unknown>();
expectTypeOf<
WideFieldSlotProps['field']['state']['value']
>().toEqualTypeOf<unknown>();
expectTypeOf<
WideFieldSlotProps['componentField']['modelValue']
>().toEqualTypeOf<unknown>();
expectTypeOf<WideFieldSlotProps['name']>().toBeString();
expectTypeOf<WideFieldSlotProps['values']>().toEqualTypeOf<
Record<string, unknown>
>();
});
it('keeps form and submit values distinct with a codec', () => {
const options: VbenFormProps<
BaseFormComponentType,
Record<never, never>,
AccountFormValues,
AccountSubmitValues
> = {
codec: {
decode(values) {
return {
email: values.email,
profile: { nickname: values.nickname },
roles: values.roles.split(','),
};
},
encode(values) {
return {
email: values.email,
nickname: values.profile.nickname,
roles: values.roles.join(','),
};
},
},
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
handleReset(values) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
},
handleValuesChange(values, _fieldsChanged, getFormattedValues) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(getFormattedValues()).toEqualTypeOf<AccountSubmitValues>();
},
schema: [],
};
const [, formApi] = useVbenForm<
AccountFormValues,
BaseFormComponentType,
Record<never, never>,
AccountSubmitValues
>(options);
expectTypeOf(
formApi.getValues(),
).resolves.toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(
formApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(formApi.getValueSnapshot()).resolves.toEqualTypeOf<
FormValueSnapshot<AccountFormValues, AccountSubmitValues>
>();
expectTypeOf(formApi.setSubmitValues)
.parameter(0)
.toEqualTypeOf<AccountSubmitValues>();
});
it('infers submit values from an inline codec', () => {
const [, formApi] = useVbenForm({
codec: {
decode(values) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountSubmitValues>>();
return {
email: values.email,
profile: { nickname: values.nickname },
roles: values.roles.split(','),
};
},
encode(values: Readonly<AccountFormValues>) {
return {
email: values.email,
nickname: values.profile.nickname,
roles: values.roles.join(','),
};
},
},
handleReset(values) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
},
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
schema: [],
});
expectTypeOf(
formApi.getValues(),
).resolves.toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(
formApi.getRawValues(),
).resolves.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,140 @@
/* eslint-disable vue/one-component-per-file */
import type { PropType } from 'vue';
import type { FormLayout, FormRenderProps } from '../src/types';
import { mount } from '@vue/test-utils';
import { defineComponent, h, reactive, toRefs } from 'vue';
import { describe, expect, it } from 'vitest';
import {
provideFormRenderProps,
useFormContext,
} from '../src/form-render/context';
import { resolveLabelStyle, useFormLabelWidth } from '../src/form-render/utils';
describe('form label width context', () => {
it('keeps layout reactive when label width context is provided', async () => {
const Consumer = defineComponent({
setup() {
const { isVertical } = useFormContext();
return () =>
h('div', {
'data-layout': isVertical.value ? 'vertical' : 'horizontal',
});
},
});
const Provider = defineComponent({
props: {
layout: {
required: true,
type: String as PropType<FormLayout>,
},
},
setup(props) {
provideFormRenderProps(
reactive({
...toRefs(props as FormRenderProps),
...useFormLabelWidth(),
}),
);
return () => h(Consumer);
},
});
const wrapper = mount(Provider, {
props: { layout: 'horizontal' },
});
expect(wrapper.get('[data-layout]').attributes('data-layout')).toBe(
'horizontal',
);
await wrapper.setProps({ layout: 'vertical' });
expect(wrapper.get('[data-layout]').attributes('data-layout')).toBe(
'vertical',
);
});
});
describe('resolveLabelStyle', () => {
it('returns empty style for vertical layout', () => {
expect(
resolveLabelStyle({
labelWidth: 'auto',
labelClass: undefined,
isVertical: true,
autoLabelWidth: '120px',
computedWidth: 80,
}),
).toEqual({});
});
it('returns empty style when labelClass includes w-', () => {
expect(
resolveLabelStyle({
labelWidth: 100,
labelClass: 'w-32',
isVertical: false,
autoLabelWidth: '120px',
computedWidth: 80,
}),
).toEqual({});
});
it('aligns auto width with max label using marginLeft by default', () => {
expect(
resolveLabelStyle({
labelWidth: 'auto',
labelClass: undefined,
isVertical: false,
autoLabelWidth: '120px',
computedWidth: 80,
}),
).toEqual({
width: 'auto',
marginLeft: '40px',
});
});
it('uses marginRight when labelClass is justify-start', () => {
expect(
resolveLabelStyle({
labelWidth: 'auto',
labelClass: 'justify-start',
isVertical: false,
autoLabelWidth: '100px',
computedWidth: 60,
}),
).toEqual({
width: 'auto',
marginRight: '40px',
});
});
it('uses numeric labelWidth as px', () => {
expect(
resolveLabelStyle({
labelWidth: 100,
labelClass: undefined,
isVertical: false,
autoLabelWidth: '0',
computedWidth: 0,
}),
).toEqual({ width: '100px' });
});
it('passes through string labelWidth', () => {
expect(
resolveLabelStyle({
labelWidth: '8rem',
labelClass: undefined,
isVertical: false,
autoLabelWidth: '0',
computedWidth: 0,
}),
).toEqual({ width: '8rem' });
});
});
@@ -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,59 @@
{
"name": "@vben-core/form-ui",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/uikit/form-ui"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm exec tsdown",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"production": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@tanstack/store": "catalog:",
"@tanstack/vue-form": "catalog:",
"@vben-core/composables": "workspace:*",
"@vben-core/icons": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "catalog:",
"vue": "catalog:",
"zod": "catalog:",
"zod-defaults": "catalog:"
},
"devDependencies": {
"@vue/test-utils": "catalog:",
"unplugin-vue": "catalog:"
}
}
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, toRaw, unref, watch } from 'vue';
import { useSimpleLocale } from '@vben-core/composables';
import { VbenExpandableArrow } from '@vben-core/shadcn-ui';
import { cn, isFunction, triggerWindowResize } from '@vben-core/shared/utils';
import { COMPONENT_MAP } from '../config';
import { injectFormProps } from '../use-form-context';
const { $t } = useSimpleLocale();
const [rootProps, form] = injectFormProps();
const collapsed = defineModel({ default: false });
const resetButtonOptions = computed(() => {
return {
content: `${$t.value('reset')}`,
show: true,
...unref(rootProps).resetButtonOptions,
};
});
const submitButtonOptions = computed(() => {
return {
content: `${$t.value('submit')}`,
show: true,
...unref(rootProps).submitButtonOptions,
};
});
async function handleSubmit(e: Event) {
e?.preventDefault();
e?.stopPropagation();
const props = unref(rootProps);
if (!props.formApi) {
return;
}
await props.formApi.validateAndSubmit();
}
async function handleReset(e: Event) {
e?.preventDefault();
e?.stopPropagation();
const props = unref(rootProps);
const values = toRaw(await props.formApi?.getValues()) ?? {};
if (isFunction(props.handleReset)) {
await props.handleReset?.(values);
} else {
form.reset();
}
}
watch(
() => collapsed.value,
() => {
const props = unref(rootProps);
if (props.collapseTriggerResize) {
triggerWindowResize();
}
},
);
const actionWrapperClass = computed(() => {
const props = unref(rootProps);
const actionLayout = props.actionLayout || 'rowEnd';
const actionPosition = props.actionPosition || 'right';
const cls = [
'flex',
'items-center',
'gap-3',
props.compact ? 'pb-2' : 'pb-4',
props.layout === 'vertical' ? 'self-end' : 'self-center',
props.layout === 'inline' ? '' : 'w-full',
props.actionWrapperClass,
];
switch (actionLayout) {
case 'newLine': {
cls.push('col-span-full');
break;
}
case 'rowEnd': {
cls.push('col-[-2/-1]');
break;
}
// 'inline' 不需要额外类名,保持默认
}
switch (actionPosition) {
case 'center': {
cls.push('justify-center');
break;
}
case 'left': {
cls.push('justify-start');
break;
}
default: {
// case 'right': 默认右对齐
cls.push('justify-end');
break;
}
}
return cls.join(' ');
});
defineExpose({
handleReset,
handleSubmit,
});
</script>
<template>
<div :class="cn(actionWrapperClass)">
<template v-if="rootProps.actionButtonsReverse">
<!-- 提交按钮前 -->
<slot name="submit-before"></slot>
<component
:is="COMPONENT_MAP.PrimaryButton"
v-if="submitButtonOptions.show"
type="button"
@click="handleSubmit"
v-bind="submitButtonOptions"
>
{{ submitButtonOptions.content }}
</component>
</template>
<!-- 重置按钮前 -->
<slot name="reset-before"></slot>
<component
:is="COMPONENT_MAP.DefaultButton"
v-if="resetButtonOptions.show"
type="button"
@click="handleReset"
v-bind="resetButtonOptions"
>
{{ resetButtonOptions.content }}
</component>
<template v-if="!rootProps.actionButtonsReverse">
<!-- 提交按钮前 -->
<slot name="submit-before"></slot>
<component
:is="COMPONENT_MAP.PrimaryButton"
v-if="submitButtonOptions.show"
type="button"
@click="handleSubmit"
v-bind="submitButtonOptions"
>
{{ submitButtonOptions.content }}
</component>
</template>
<!-- 展开按钮前 -->
<slot name="expand-before"></slot>
<VbenExpandableArrow
class="ml-[-0.3em]"
v-if="rootProps.showCollapseButton"
v-model:model-value="collapsed"
>
<span>{{ collapsed ? $t('expand') : $t('collapse') }}</span>
</VbenExpandableArrow>
<!-- 展开按钮后 -->
<slot name="expand-after"></slot>
</div>
</template>
@@ -0,0 +1,234 @@
<script setup lang="ts">
// oxlint-disable unicorn/no-nested-ternary
import type { FormCommonConfig, FormSchema } from '../types';
import { computed } from 'vue';
import { Plus, X } from '@vben-core/icons';
import {
VbenButton,
VbenIconButton,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn, get, set } from '@vben-core/shared/utils';
import { injectRenderFormProps } from '../form-render/context';
import FormField from '../form-render/form-field.vue';
import { createArrayChildSchema } from '../form-render/schema';
defineOptions({ name: 'VbenFormFieldArray', inheritAttrs: false });
const props = withDefaults(
defineProps<{
/** 操作列表头文案 */
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig;
/**
* 新增一行时生成的默认数据;缺省时按 schema 的 fieldName 生成空对象
*/
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/** 字段路径,由外层 FormField 通过 componentField 透传 */
name?: string;
/**
* 列定义,每一列就是一个子字段(复用 FormSchema
*/
schema?: FormSchema[];
/** 是否显示序号列 */
showIndex?: boolean;
}>(),
{
actionText: '操作',
addButtonText: '添加一行',
createRow: undefined,
disabled: false,
emptyText: '暂无数据',
commonConfig: () => ({}),
globalCommonConfig: () => ({}),
max: Number.POSITIVE_INFINITY,
min: 0,
name: '',
schema: () => [],
showIndex: true,
},
);
const arrayPath = computed(() => props.name);
const formRenderProps = injectRenderFormProps();
const form = formRenderProps.form;
if (!form) {
throw new Error('Form api is required in <VbenFormFieldArray />');
}
const formActions = form;
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(() => arrayLength.value < props.max);
const canRemove = computed(() => arrayLength.value > props.min);
const gridStyle = computed(() => {
const columns = [
...(props.showIndex ? ['3rem'] : []),
...props.schema.map(() => 'minmax(0, 1fr)'),
'4rem',
];
return {
gridTemplateColumns: columns.join(' '),
};
});
function buildDefaultRow(): Record<string, any> {
if (props.createRow) {
return props.createRow();
}
const row: Record<string, any> = {};
props.schema.forEach((col) => {
let value: any = null;
if (Reflect.has(col, 'defaultValue') && col.defaultValue !== undefined) {
value = col.defaultValue;
} else if ('type' in col && col.type === 'array') {
value = [];
}
set(row, col.fieldName, value);
});
return row;
}
function addRow() {
if (props.disabled || !canAdd.value) {
return;
}
formActions.pushFieldValue(arrayPath.value, buildDefaultRow());
}
function removeRow(index: number) {
if (props.disabled || !canRemove.value) {
return;
}
void formActions.removeFieldValue(arrayPath.value, index);
}
function rowSchemas(index: number) {
return props.schema.map((col) =>
createArrayChildSchema(col as never, {
arrayField: arrayPath.value,
commonConfig: props.commonConfig,
disabled: props.disabled,
globalCommonConfig: props.globalCommonConfig,
index,
}),
);
}
const normalizedRowSchemas = computed(() =>
Array.from({ length: arrayLength.value }, (_, index) => rowSchemas(index)),
);
</script>
<template>
<div :class="cn('w-full', $attrs.class as string)">
<div class="border-border/70 overflow-hidden rounded-md border">
<div
class="bg-muted/30 border-border hidden border-b px-2 sm:grid"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
#
</div>
<div
v-for="col in schema"
:key="col.fieldName"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<VbenRenderContent :content="col.label" />
</div>
<div
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
{{ actionText }}
</div>
</div>
<div
v-for="index in rowIndexes"
:key="`${arrayPath}-${index}`"
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground mb-2 text-sm sm:mb-0 sm:px-4 sm:py-3"
>
<span class="sm:hidden">#</span>
{{ index + 1 }}
</div>
<template
v-for="(childSchema, childIndex) in normalizedRowSchemas[index]"
:key="childSchema.fieldName"
>
<div class="min-w-0 py-2 sm:px-2">
<div
class="text-muted-foreground mb-1 text-xs font-medium sm:hidden"
>
<VbenRenderContent :content="schema?.[childIndex]?.label" />
</div>
<FormField
v-bind="childSchema"
:class="childSchema.formItemClass"
/>
</div>
</template>
<div class="flex justify-end pt-1 sm:block sm:px-2 sm:py-3">
<VbenIconButton
type="button"
:disabled="disabled || !canRemove"
:on-click="() => removeRow(index)"
class="text-muted-foreground hover:text-destructive"
>
<X class="size-4" />
</VbenIconButton>
</div>
</div>
<div
v-if="arrayLength === 0"
class="text-muted-foreground py-6 text-center text-sm"
>
{{ emptyText }}
</div>
</div>
<VbenButton
variant="outline"
size="sm"
type="button"
:disabled="disabled || !canAdd"
class="mt-3 w-full border-dashed"
@click="addRow"
>
<Plus class="mr-1 size-4" />
{{ addButtonText }}
</VbenButton>
</div>
</template>
@@ -0,0 +1,91 @@
import type { Component } from 'vue';
import type {
BaseFormComponentType,
FormCommonConfig,
VbenFormAdapterOptions,
} from './types';
import { h } from 'vue';
import {
VbenButton,
VbenCheckbox,
Input as VbenInput,
VbenInputPassword,
VbenPinInput,
VbenSelect,
} from '@vben-core/shadcn-ui';
import { globalShareState } from '@vben-core/shared/global-state';
import VbenFormFieldArray from './components/form-field-array.vue';
import { warnDeprecatedOnce } from './deprecation';
import { registerFormRules } from './rule-registry';
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
export const DEFAULT_FORM_COMMON_CONFIG: FormCommonConfig = {};
export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
VbenCheckbox,
VbenFormFieldArray,
VbenInput,
VbenInputPassword,
VbenPinInput,
VbenSelect,
};
export const COMPONENT_BIND_EVENT_MAP: Partial<
Record<BaseFormComponentType, string>
> = {
VbenCheckbox: 'checked',
};
export function setupVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
>(options: VbenFormAdapterOptions<T>) {
const { config, defineRules, rules } = options;
const { changeEventFallback = false, emptyStateValue = undefined } =
(config || {}) as FormCommonConfig;
Object.assign(DEFAULT_FORM_COMMON_CONFIG, {
changeEventFallback,
emptyStateValue,
});
if (defineRules) {
warnDeprecatedOnce(
'setup-vben-form-define-rules',
'[Vben Form] `setupVbenForm({ defineRules })` is deprecated. Use `setupVbenForm({ rules })` instead.',
);
registerFormRules(defineRules);
}
if (rules) {
registerFormRules(rules);
}
const baseModelPropName =
config?.baseModelPropName ?? DEFAULT_MODEL_PROP_NAME;
const modelPropNameMap = config?.modelPropNameMap as
| Record<BaseFormComponentType, string>
| undefined;
const components = globalShareState.getComponents();
for (const component of Object.keys(components)) {
const key = component as BaseFormComponentType;
COMPONENT_MAP[key] = components[component as never];
if (baseModelPropName !== DEFAULT_MODEL_PROP_NAME) {
COMPONENT_BIND_EVENT_MAP[key] = baseModelPropName;
}
// 覆盖特殊组件的modelPropName
if (modelPropNameMap && modelPropNameMap[key]) {
COMPONENT_BIND_EVENT_MAP[key] = modelPropNameMap[key];
}
}
}
@@ -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,106 @@
import { get, isObject, set } from '@vben-core/shared/utils';
export function deleteValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { pathSegments, rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
Reflect.deleteProperty(values, rawKey);
return;
}
if (pathSegments.length === 0) {
Reflect.deleteProperty(values, fieldName);
return;
}
let target: Record<string, any> | undefined = values;
for (const segment of pathSegments.slice(0, -1)) {
if (!target || !isObject(target)) {
return;
}
target = target[segment];
}
const lastPathSegment = pathSegments.at(-1);
if (!target || !isObject(target) || !lastPathSegment) {
return;
}
Reflect.deleteProperty(target, lastPathSegment);
}
export function getValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
return rawKey ? values[rawKey] : get(values, fieldName);
}
export function resolveChildUpdateFieldName(
parentFieldName: string,
fieldName: string,
) {
if (fieldName.startsWith(`${parentFieldName}.`)) {
return fieldName.slice(parentFieldName.length + 1);
}
const indexedPrefix = `${parentFieldName}[`;
if (!fieldName.startsWith(indexedPrefix)) {
return;
}
const closeIndex = fieldName.indexOf(']', indexedPrefix.length);
if (closeIndex === -1 || fieldName[closeIndex + 1] !== '.') {
return;
}
return fieldName.slice(closeIndex + 2);
}
export function resolveFieldNamePath(fieldName: string) {
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
const rawKey = fieldName.slice(1, -1);
return {
pathSegments: [rawKey],
rawKey,
};
}
return {
pathSegments: fieldName.match(/[^.[\]]+/g) ?? [],
rawKey: undefined,
};
}
export function resolveValueFormatFieldName(
fieldName: string,
parentPath?: string,
) {
if (!parentPath) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${parentPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === parentPath || fieldName.startsWith(`${parentPath}.`)) {
return fieldName;
}
return `${parentPath}.${fieldName}`;
}
export function setValueByFieldName(
values: Record<string, any>,
fieldName: string,
value: any,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
values[rawKey] = value;
return;
}
set(values, fieldName, value);
}
@@ -0,0 +1,706 @@
import type { ComponentPublicInstance } from 'vue';
import type {
BaseFormComponentType,
FormActions,
FormFieldName,
FormFieldValue,
FormResetOptions,
FormResetState,
FormSchema,
FormValues,
FormValueSnapshot,
VbenFormProps,
} from './types';
import { isRef, toRaw } from 'vue';
import { Store } from '@vben-core/shared/store';
import {
bindMethods,
cloneDeep,
isDate,
isDayjsObject,
isFunction,
isObject,
mergeWithArrayOverride,
StateHandler,
} from '@vben-core/shared/utils';
import { warnDeprecatedOnce } from './deprecation';
import { resolveFieldNamePath } from './field-name';
import { decodeFormValues, encodeFormValues } from './form-codec';
import { updateFormSchemaList } from './form-render/schema';
import { formatFormValues } from './form-value-transform';
type FormApiProps<
TFormValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
TSubmitValues extends FormValues,
> = VbenFormProps<T, P, TFormValues, TSubmitValues>;
type FormApiSchema<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = FormSchema<T, P, TValues>;
function getDefaultState<
TFormValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
TSubmitValues extends FormValues,
>(): FormApiProps<TFormValues, T, P, TSubmitValues> {
return {
actionWrapperClass: '',
collapsed: false,
collapsedRows: 1,
collapseTriggerResize: false,
commonConfig: {},
handleReset: undefined,
handleSubmit: undefined,
handleValuesChange: undefined,
handleCollapsedChange: undefined,
layout: 'horizontal',
resetButtonOptions: {},
schema: [],
scrollToFirstError: false,
showCollapseButton: false,
showDefaultActions: true,
submitButtonOptions: {},
submitOnChange: false,
submitOnEnter: false,
wrapperClass: 'grid-cols-1',
};
}
export class FormApi<
TFormValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TFormValues,
> {
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
public form = {} as FormActions<TFormValues>;
isMounted = false;
public state: FormApiProps<TFormValues, T, P, TSubmitValues> | null = null;
stateHandler: StateHandler;
public store: Store<FormApiProps<TFormValues, T, P, TSubmitValues>>;
/**
* 组件实例映射
*/
private componentRefMap: Map<string, unknown> = new Map();
// 最后一次点击提交时的表单值
private latestSubmissionValues: null | Partial<TSubmitValues> = null;
private legacyTransformWarningState: null | Pick<
FormApiProps<TFormValues, T, P, TSubmitValues>,
'arrayToStringFields' | 'codec' | 'fieldMappingTime' | 'schema'
> = null;
private prevState: FormApiProps<TFormValues, T, P, TSubmitValues> | null =
null;
constructor(options: FormApiProps<TFormValues, T, P, TSubmitValues> = {}) {
const { ...storeState } = options;
const defaultState = getDefaultState<TFormValues, T, P, TSubmitValues>();
this.store = new Store<FormApiProps<TFormValues, T, P, TSubmitValues>>({
...defaultState,
...storeState,
});
this.store.subscribe((state) => {
this.prevState = this.state;
this.state = state;
this.updateState();
});
this.state = this.store.state;
this.stateHandler = new StateHandler();
bindMethods(this);
}
async clearValidation(
fieldNames?: FormFieldName<TFormValues> | FormFieldName<TFormValues>[],
) {
const form = await this.getForm();
form.clearValidation(fieldNames);
}
formatValues(rawValues: Readonly<TFormValues>): TSubmitValues;
/** @deprecated Declare the submit type on `useVbenForm` instead. */
formatValues<TResult extends FormValues>(
rawValues: Readonly<FormValues>,
): TResult;
formatValues(rawValues: Readonly<FormValues>): FormValues {
this.warnLegacyValueTransforms();
if (this.state?.codec) {
return encodeFormValues(
this.state.codec,
cloneDeep(toRaw(rawValues)) as Readonly<TFormValues>,
);
}
return formatFormValues(
toRaw(rawValues),
this.state?.schema ?? [],
this.state?.fieldMappingTime,
this.state?.arrayToStringFields,
);
}
/**
* 获取字段组件实例
* @param fieldName 字段名
* @returns 组件实例
*/
getFieldComponentRef<T = ComponentPublicInstance>(
fieldName: string,
): T | undefined {
let target = this.componentRefMap.has(fieldName)
? (this.componentRefMap.get(fieldName) as ComponentPublicInstance)
: undefined;
if (
target &&
target.$.type.name === 'AsyncComponentWrapper' &&
target.$.subTree.ref
) {
if (Array.isArray(target.$.subTree.ref)) {
if (
target.$.subTree.ref.length > 0 &&
isRef(target.$.subTree.ref[0]?.r)
) {
target = target.$.subTree.ref[0]?.r.value as ComponentPublicInstance;
}
} else if (isRef(target.$.subTree.ref.r)) {
target = target.$.subTree.ref.r.value as ComponentPublicInstance;
}
}
return target as T;
}
/**
* 获取当前聚焦的字段,如果没有聚焦的字段则返回undefined
*/
getFocusedField() {
for (const fieldName of this.componentRefMap.keys()) {
const ref = this.getFieldComponentRef(fieldName);
if (ref) {
let el: HTMLElement | null = null;
if (ref instanceof HTMLElement) {
el = ref;
} else if (ref.$el instanceof HTMLElement) {
el = ref.$el;
}
if (!el) {
continue;
}
if (
el === document.activeElement ||
el.contains(document.activeElement)
) {
return fieldName;
}
}
}
return undefined;
}
getLatestSubmissionValues() {
return this.latestSubmissionValues || {};
}
async getRawValues(): Promise<TFormValues>;
/** @deprecated Declare the form value type on `useVbenForm` instead. */
async getRawValues<TResult extends FormValues>(): Promise<TResult>;
async getRawValues(): Promise<FormValues> {
const form = await this.getForm();
return cloneDeep(toRaw(form.values ?? {}));
}
getState() {
return this.state;
}
async getValues(): Promise<TSubmitValues>;
/** @deprecated Declare the submit type on `useVbenForm` instead. */
async getValues<TResult extends FormValues>(): Promise<TResult>;
async getValues(): Promise<FormValues> {
const form = await this.getForm();
return this.formatValues(toRaw(form.values ?? {}));
}
async getValueSnapshot(): Promise<
FormValueSnapshot<TFormValues, TSubmitValues>
>;
/** @deprecated Declare form and submit value types on `useVbenForm`. */
async getValueSnapshot<TResult extends FormValues>(): Promise<
FormValueSnapshot<TResult>
>;
async getValueSnapshot(): Promise<FormValueSnapshot> {
const rawValues = await this.getRawValues();
return {
rawValues,
values: this.formatValues(rawValues),
};
}
async isFieldValid(fieldName: FormFieldName<TFormValues>) {
const form = await this.getForm();
return form.isFieldValid(fieldName);
}
merge(formApi: FormApi<any, any, any, any>) {
const chain = [this, formApi];
const proxy = new Proxy(formApi, {
get(target: any, prop: any) {
if (prop === 'merge') {
return (nextFormApi: FormApi<any, any, any, any>) => {
chain.push(nextFormApi);
return proxy;
};
}
if (prop === 'submitAllForm') {
return async (needMerge: boolean = true) => {
try {
const results = await Promise.all(
chain.map(async (api) => {
const validateResult = await api.validate();
if (!validateResult.valid) {
return;
}
const rawValues = toRaw((await api.getValues()) || {});
return rawValues;
}),
);
if (needMerge) {
const mergedResults = Object.assign({}, ...results);
return mergedResults;
}
return results;
} catch (error) {
console.error('Validation error:', error);
}
};
}
return target[prop];
},
});
return proxy;
}
mount(
formActions: FormActions<TFormValues>,
componentRefMap?: Map<string, unknown>,
) {
if (!this.isMounted) {
this.form = formActions;
this.stateHandler.setConditionTrue();
let initialValues: FormValues = {};
if (this.form.values) {
const rawInitialValues = toRaw(this.form.values);
try {
initialValues = this.formatValues(rawInitialValues);
} catch (error) {
if (!this.state?.codec) {
throw error;
}
console.warn(
'[Vben Form] Failed to encode initial values. Falling back to raw form values.',
error,
);
initialValues = cloneDeep(rawInitialValues);
}
}
this.setLatestSubmissionValues(initialValues as Partial<TSubmitValues>);
this.componentRefMap =
componentRefMap ?? this.componentRefMap ?? new Map();
this.isMounted = true;
}
}
/**
* 根据字段名移除表单项
* @param fields
*/
async removeSchemaByFields(fields: string[]) {
const fieldSet = new Set(fields);
const schema = this.state?.schema ?? [];
const filterSchema = schema.filter((item) => !fieldSet.has(item.fieldName));
this.setState({
schema: filterSchema,
});
}
/**
* 重置表单
*/
async reset(state?: FormResetState<TFormValues>, opts?: FormResetOptions) {
const form = await this.getForm();
return form.reset(state, opts);
}
/** @deprecated Use `reset` instead. */
async resetForm(
state?: FormResetState<TFormValues>,
opts?: FormResetOptions,
) {
warnDeprecatedOnce(
'form-api-reset-form',
'[Vben Form] `formApi.resetForm()` is deprecated. Use `formApi.reset()` instead.',
);
return this.reset(state, opts);
}
/** @deprecated Use `clearValidation` instead. */
async resetValidate() {
warnDeprecatedOnce(
'form-api-reset-validate',
'[Vben Form] `formApi.resetValidate()` is deprecated. Use `formApi.clearValidation()` instead.',
);
return this.clearValidation();
}
/**
* 滚动到第一个错误字段
* @param errors 验证错误对象
*/
scrollToFirstError(errors: Record<string, any> | string) {
const firstErrorFieldName =
typeof errors === 'string' ? errors : Object.keys(errors)[0];
if (!firstErrorFieldName) {
return;
}
let el = document.querySelector(
`[name="${firstErrorFieldName}"]`,
) as HTMLElement;
// 如果通过 name 属性找不到,尝试通过组件引用查找
if (!el) {
const componentRef = this.getFieldComponentRef(firstErrorFieldName);
if (componentRef && componentRef.$el instanceof HTMLElement) {
el = componentRef.$el;
}
}
if (el) {
// 滚动到错误字段,添加一些偏移量以确保字段完全可见
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
}
}
async setFieldError(fieldName: FormFieldName<TFormValues>, error?: string) {
const form = await this.getForm();
form.setFieldError(fieldName, error);
}
async setFieldValue<TFieldName extends FormFieldName<TFormValues>>(
field: TFieldName,
value: FormFieldValue<TFormValues, NoInfer<TFieldName>>,
shouldValidate?: boolean,
) {
const form = await this.getForm();
await form.setFieldValue(field, value, shouldValidate);
}
setLatestSubmissionValues(values: null | Partial<TSubmitValues>) {
this.latestSubmissionValues = {
...toRaw(values),
} as Partial<TSubmitValues>;
}
setState(
stateOrFn:
| ((
prev: FormApiProps<TFormValues, T, P, TSubmitValues>,
) => Partial<FormApiProps<TFormValues, T, P, TSubmitValues>>)
| Partial<FormApiProps<TFormValues, T, P, TSubmitValues>>,
) {
if (isFunction(stateOrFn)) {
this.store.setState((prev) => {
return mergeWithArrayOverride(stateOrFn(prev), prev);
});
} else {
this.store.setState((prev) => mergeWithArrayOverride(stateOrFn, prev));
}
}
async setSubmitValues(
values: TSubmitValues,
filterFields: boolean = true,
shouldValidate: boolean = false,
) {
const codec = this.state?.codec;
if (!codec) {
throw new Error(
'[Vben Form] `setSubmitValues()` requires a form `codec`.',
);
}
const formValues = decodeFormValues(codec, values);
await this.setValues(formValues, filterFields, shouldValidate);
}
/**
* 设置表单值
* @param fields record
* @param filterFields 过滤不在schema中定义的字段 默认为true
* @param shouldValidate
*/
async setValues(
fields: Partial<TFormValues>,
filterFields: boolean = true,
shouldValidate: boolean = false,
) {
const form = await this.getForm();
if (!filterFields) {
form.setValues(fields, shouldValidate);
return;
}
const schemaFieldPaths = (this.state?.schema ?? []).map(
(schema) => resolveFieldNamePath(schema.fieldName).pathSegments,
);
const filterValue = (
value: unknown,
parentPath: string[] = [],
): unknown => {
if (
!isObject(value) ||
Array.isArray(value) ||
isDate(value) ||
isDayjsObject(value)
) {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, currentValue] of Object.entries(value)) {
const currentPath = [...parentPath, key];
const matchingPaths = schemaFieldPaths.filter(
(schemaPath) =>
schemaPath.length >= currentPath.length &&
currentPath.every(
(pathSegment, index) => schemaPath[index] === pathSegment,
),
);
if (matchingPaths.length === 0) {
continue;
}
result[key] = matchingPaths.some(
(schemaPath) => schemaPath.length === currentPath.length,
)
? currentValue
: filterValue(currentValue, currentPath);
}
return result;
};
const filteredFields = filterValue(fields) as Partial<TFormValues>;
form.setValues(filteredFields as Partial<TFormValues>, shouldValidate);
}
async submit(e?: Event) {
e?.preventDefault();
e?.stopPropagation();
const form = await this.getForm();
await form.submit();
return this.submitValues();
}
/** @deprecated Use `submit` instead. */
async submitForm(e?: Event) {
warnDeprecatedOnce(
'form-api-submit-form',
'[Vben Form] `formApi.submitForm()` is deprecated. Use `formApi.submit()` instead.',
);
return this.submit(e);
}
unmount() {
this.form?.reset?.();
// this.state = null;
this.componentRefMap = new Map();
this.latestSubmissionValues = null;
this.isMounted = false;
this.stateHandler.reset();
}
updateSchema(schema: Partial<FormApiSchema<TFormValues, T, P>>[]) {
const updated: Partial<FormApiSchema<TFormValues, T, P>>[] = [...schema];
const hasField = updated.every(
(item) => Reflect.has(item, 'fieldName') && item.fieldName,
);
if (!hasField) {
console.error(
'All items in the schema array must have a valid `fieldName` property to be updated',
);
return;
}
const currentSchema = updateFormSchemaList(
[...(this.state?.schema ?? [])],
updated,
);
this.setState({ schema: currentSchema });
}
async validate() {
const form = await this.getForm();
const validateResult = await form.validate();
if (
Object.keys(validateResult?.errors ?? {}).length > 0 &&
this.state?.scrollToFirstError
) {
this.scrollToFirstError(validateResult.errors);
}
return validateResult;
}
async validateAndSubmit() {
const { valid } = await this.validate();
if (!valid) return;
return this.submitValues();
}
/** @deprecated Use `validateAndSubmit` instead. */
async validateAndSubmitForm() {
warnDeprecatedOnce(
'form-api-validate-and-submit-form',
'[Vben Form] `formApi.validateAndSubmitForm()` is deprecated. Use `formApi.validateAndSubmit()` instead.',
);
return this.validateAndSubmit();
}
async validateField(fieldName: FormFieldName<TFormValues>) {
const form = await this.getForm();
const validateResult = await form.validateField(fieldName);
if (
Object.keys(validateResult?.errors ?? {}).length > 0 &&
this.state?.scrollToFirstError
) {
this.scrollToFirstError(fieldName);
}
return validateResult;
}
private async getForm() {
if (!this.isMounted) {
// 等待form挂载
await this.stateHandler.waitForCondition();
}
if (!this.form?.meta) {
throw new Error('<VbenForm /> is not mounted');
}
return this.form;
}
private async submitValues() {
const { rawValues, values } = await this.getValueSnapshot();
this.setLatestSubmissionValues(values);
await this.state?.handleSubmit?.(values, rawValues);
return values;
}
private updateState() {
const currentSchema = this.state?.schema ?? [];
const prevSchema = this.prevState?.schema ?? [];
// 进行了删除schema操作
if (currentSchema.length < prevSchema.length) {
const currentFields = new Set(
currentSchema.map((item) => item.fieldName),
);
const deletedSchema = prevSchema.filter(
(item) => !currentFields.has(item.fieldName),
);
for (const schema of deletedSchema) {
this.form?.setFieldValue?.(
schema.fieldName,
undefined as FormFieldValue<TFormValues, string>,
);
}
}
}
private warnLegacyValueTransforms() {
const warningState = {
arrayToStringFields: this.state?.arrayToStringFields,
codec: this.state?.codec,
fieldMappingTime: this.state?.fieldMappingTime,
schema: this.state?.schema ?? [],
};
const previousState = this.legacyTransformWarningState;
if (
previousState &&
previousState.arrayToStringFields === warningState.arrayToStringFields &&
previousState.codec === warningState.codec &&
previousState.fieldMappingTime === warningState.fieldMappingTime &&
previousState.schema === warningState.schema
) {
return;
}
this.legacyTransformWarningState = warningState;
const hasValueFormat = (
items: FormApiSchema<TFormValues, T, P>[],
): boolean => {
return items.some((schema) => {
if (schema.valueFormat) {
return true;
}
const children = 'children' in schema ? schema.children : undefined;
return Array.isArray(children) && hasValueFormat(children);
});
};
const usesValueFormat = hasValueFormat(warningState.schema);
const usesFieldMappingTime =
(warningState.fieldMappingTime?.length ?? 0) > 0;
const usesArrayToStringFields =
(warningState.arrayToStringFields?.length ?? 0) > 0;
const usesLegacyTransform =
usesValueFormat || usesFieldMappingTime || usesArrayToStringFields;
if (warningState.codec && usesLegacyTransform) {
warnDeprecatedOnce(
'form-codec-legacy-transform-conflict',
'[Vben Form] The form `codec` takes precedence over deprecated `valueFormat`, `fieldMappingTime`, and `arrayToStringFields` options.',
);
return;
}
if (usesValueFormat) {
warnDeprecatedOnce(
'form-schema-value-format',
'[Vben Form] `schema.valueFormat` is deprecated. Use the form-level `codec` instead.',
);
}
if (usesFieldMappingTime) {
warnDeprecatedOnce(
'form-field-mapping-time',
'[Vben Form] `fieldMappingTime` is deprecated. Use the form-level `codec` instead.',
);
}
if (usesArrayToStringFields) {
warnDeprecatedOnce(
'form-array-to-string-fields',
'[Vben Form] `arrayToStringFields` is deprecated. Use the form-level `codec` instead.',
);
}
}
}
@@ -0,0 +1,40 @@
import type { FormCodec, FormValues } from './types';
export type FormCodecPhase = 'decode' | 'encode';
export class FormCodecError extends Error {
override readonly cause: unknown;
readonly phase: FormCodecPhase;
constructor(phase: FormCodecPhase, cause: unknown) {
super(`[Vben Form] Failed to ${phase} form values.`);
this.name = 'FormCodecError';
this.cause = cause;
this.phase = phase;
}
}
export function decodeFormValues<
TFormValues extends FormValues,
TSubmitValues extends FormValues,
>(
codec: FormCodec<TFormValues, TSubmitValues>,
values: Readonly<TSubmitValues>,
) {
try {
return codec.decode(values);
} catch (error) {
throw new FormCodecError('decode', error);
}
}
export function encodeFormValues<
TFormValues extends FormValues,
TSubmitValues extends FormValues,
>(codec: FormCodec<TFormValues, TSubmitValues>, values: Readonly<TFormValues>) {
try {
return codec.encode(values);
} catch (error) {
throw new FormCodecError('encode', error);
}
}
@@ -0,0 +1,25 @@
import type { FormLabelWidthContext, FormRenderProps } from '../types';
import { computed } from 'vue';
import { createContext } from '@vben-core/shadcn-ui';
export const [injectRenderFormProps, provideFormRenderProps] = createContext<
FormLabelWidthContext & FormRenderProps
>('FormRenderProps');
export const useFormContext = () => {
const formRenderProps = injectRenderFormProps();
const isVertical = computed(() => formRenderProps.layout === 'vertical');
const componentMap = computed(() => formRenderProps.componentMap);
const componentBindEventMap = computed(
() => formRenderProps.componentBindEventMap,
);
return {
componentBindEventMap,
componentMap,
isVertical,
};
};
@@ -0,0 +1,297 @@
import type {
ExtendedFormApi,
FormDependenciesResolveContext,
FormDependenciesResolvedState,
FormItemDependencies,
FormItemDependenciesLegacy,
FormItemDependenciesResolve,
FormSchemaContext,
FormSchemaRuleType,
MaybeComponentProps,
} from '../types';
import { computed, isRef, onScopeDispose, shallowRef, watch } from 'vue';
import {
cloneDeep,
get,
isBoolean,
isEqual,
isFunction,
} from '@vben-core/shared/utils';
import { warnDeprecatedOnce } from '../deprecation';
import { resolveFieldNamePath } from '../field-name';
import { injectFormProps } from '../use-form-context';
import { injectRenderFormProps } from './context';
interface DependencyState {
dynamicComponentProps: MaybeComponentProps;
dynamicHelp: FormDependenciesResolvedState['help'];
dynamicHelpResolved: boolean;
dynamicRenderComponentContent: FormDependenciesResolvedState['renderComponentContent'];
dynamicRenderComponentContentResolved: boolean;
dynamicRules: FormSchemaRuleType | undefined;
dynamicRulesResolved: boolean;
isDisabled: boolean;
isIf: boolean;
isRequired: boolean;
isShow: boolean;
}
const legacyDependencyKeys = [
'componentProps',
'disabled',
'if',
'required',
'rules',
'show',
'trigger',
] as const;
const mixedDependenciesWarnings = new WeakSet<object>();
/**
* 解析Nested Objects对应的字段值
* @param values 表单值
* @param fieldName 字段名
*/
function resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
// [] 表示禁用嵌套
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
}
return get(values, fieldName);
}
function createDependencyState(
patch: FormDependenciesResolvedState = {},
): DependencyState {
return {
dynamicComponentProps: patch.componentProps ?? {},
dynamicHelp: patch.help,
dynamicHelpResolved: Reflect.has(patch, 'help'),
dynamicRenderComponentContent: patch.renderComponentContent,
dynamicRenderComponentContentResolved: Reflect.has(
patch,
'renderComponentContent',
),
dynamicRules: patch.rules,
dynamicRulesResolved: Reflect.has(patch, 'rules'),
isDisabled: patch.disabled ?? false,
isIf: patch.if ?? true,
isRequired: patch.required ?? false,
isShow: patch.show ?? true,
};
}
function isResolveDependencies(
dependencies: FormItemDependencies,
): dependencies is FormItemDependenciesResolve {
return isFunction(dependencies.resolve);
}
function warnMixedDependencies(dependencies: FormItemDependenciesResolve) {
if (
import.meta.env.PROD ||
mixedDependenciesWarnings.has(dependencies) ||
!legacyDependencyKeys.some(
(key) => Reflect.get(dependencies, key) !== undefined,
)
) {
return;
}
mixedDependenciesWarnings.add(dependencies);
console.warn(
'[Vben Form] `dependencies.resolve` cannot be combined with legacy dependency callbacks. `resolve` takes precedence.',
);
}
async function resolveLegacyDependencies(
dependencies: FormItemDependenciesLegacy,
context: FormDependenciesResolveContext,
): Promise<FormDependenciesResolvedState> {
const patch: FormDependenciesResolvedState = {};
const { actions, controller, values } = context;
const {
componentProps,
disabled,
if: whenIf,
required,
rules,
show,
trigger,
} = dependencies;
if (isFunction(whenIf)) {
patch.if = !!(await whenIf(values, actions, controller));
} else if (isBoolean(whenIf)) {
patch.if = whenIf;
}
if (patch.if === false) {
return patch;
}
if (isFunction(show)) {
patch.show = !!(await show(values, actions, controller));
} else if (isBoolean(show)) {
patch.show = show;
}
if (isFunction(componentProps)) {
patch.componentProps = await componentProps(values, actions, controller);
}
if (isFunction(rules)) {
patch.rules = await rules(values, actions, controller);
}
if (isFunction(disabled)) {
patch.disabled = !!(await disabled(values, actions, controller));
} else if (isBoolean(disabled)) {
patch.disabled = disabled;
}
if (isFunction(required)) {
patch.required = !!(await required(values, actions, controller));
}
if (isFunction(trigger)) {
await trigger(values, actions, controller);
}
return patch;
}
export default function useDependencies(
getDependencies: () => FormItemDependencies | undefined,
getSchemaContext: () => FormSchemaContext = () => ({}),
) {
const [extendApi] = injectFormProps();
const formRenderProps = injectRenderFormProps();
const formApi = formRenderProps.form;
if (!formApi) {
throw new Error('Form api is required in useDependencies');
}
const values = formApi.useValues();
const initialTriggerFields = getDependencies()?.triggerFields ?? [];
const initialTriggerValues = formApi.useFieldValues(initialTriggerFields);
// 在 dependencies 里提供访问extendApi的能力
function getController(): ExtendedFormApi {
const controller = isRef(extendApi)
? extendApi.value.formApi
: extendApi.formApi;
if (!controller) {
throw new Error('formApi is required in useDependencies');
}
return controller as unknown as ExtendedFormApi;
}
const dependencyState = shallowRef(createDependencyState());
let previousDependencies: FormItemDependencies | undefined;
let previousTriggerValues: any[] | undefined;
let dependencyEvaluationId = 0;
const triggerFieldValues = computed(() => {
// 该字段可能会被多个字段触发
const triggerFields = getDependencies()?.triggerFields ?? [];
const usesInitialTriggerFields =
triggerFields.length === initialTriggerFields.length &&
triggerFields.every(
(fieldName, index) => fieldName === initialTriggerFields[index],
);
if (usesInitialTriggerFields) {
return initialTriggerValues.value;
}
return triggerFields.map((dep) => {
return resolveValueByFieldName(values.value, dep);
});
});
function resetConditionState() {
dependencyState.value = createDependencyState();
}
watch(
[triggerFieldValues, getDependencies],
async ([currentTriggerValues, dependencies]) => {
if (!dependencies || !dependencies?.triggerFields?.length) {
dependencyEvaluationId += 1;
previousDependencies = dependencies;
previousTriggerValues = undefined;
resetConditionState();
return;
}
if (
dependencies === previousDependencies &&
previousTriggerValues &&
isEqual(currentTriggerValues, previousTriggerValues)
) {
return;
}
previousDependencies = dependencies;
previousTriggerValues = cloneDeep(currentTriggerValues);
const currentEvaluationId = ++dependencyEvaluationId;
const context: FormDependenciesResolveContext = {
actions: formApi,
controller: getController(),
schema: {
...getSchemaContext(),
rootValues: values.value,
},
values: values.value,
};
let patch: FormDependenciesResolvedState | undefined;
if (isResolveDependencies(dependencies)) {
warnMixedDependencies(dependencies);
patch = await dependencies.resolve(context);
} else {
warnDeprecatedOnce(
'form-dependencies-legacy-callbacks',
'[Vben Form] Legacy dependency callbacks are deprecated. Use `dependencies.resolve(context)` instead.',
);
patch = await resolveLegacyDependencies(dependencies, context);
}
if (currentEvaluationId !== dependencyEvaluationId) {
return;
}
dependencyState.value = createDependencyState(patch);
},
{ immediate: true },
);
onScopeDispose(() => {
dependencyEvaluationId += 1;
});
return {
dynamicComponentProps: computed(
() => dependencyState.value.dynamicComponentProps,
),
dynamicHelp: computed(() => dependencyState.value.dynamicHelp),
dynamicHelpResolved: computed(
() => dependencyState.value.dynamicHelpResolved,
),
dynamicRenderComponentContent: computed(
() => dependencyState.value.dynamicRenderComponentContent,
),
dynamicRenderComponentContentResolved: computed(
() => dependencyState.value.dynamicRenderComponentContentResolved,
),
dynamicRules: computed(() => dependencyState.value.dynamicRules),
dynamicRulesResolved: computed(
() => dependencyState.value.dynamicRulesResolved,
),
isDisabled: computed(() => dependencyState.value.isDisabled),
isIf: computed(() => dependencyState.value.isIf),
isRequired: computed(() => dependencyState.value.isRequired),
isShow: computed(() => dependencyState.value.isShow),
};
}
@@ -0,0 +1,105 @@
import type { FormRenderProps } from '../types';
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import {
breakpointsTailwind,
useBreakpoints,
useElementVisibility,
} from '@vueuse/core';
/**
* 动态计算行数
*/
export function useExpandable(props: FormRenderProps) {
const wrapperRef = useTemplateRef<HTMLElement>('wrapperRef');
const isVisible = useElementVisibility(wrapperRef);
const rowMapping = ref<Record<number, number>>({});
// 是否已经计算过一次
const isCalculated = ref(false);
const breakpoints = useBreakpoints(breakpointsTailwind);
const keepFormItemIndex = computed(() => {
const rows = props.collapsedRows ?? 1;
const mapping = rowMapping.value;
let maxItem = 0;
for (let index = 1; index <= rows; index++) {
maxItem += mapping?.[index] ?? 0;
}
// 保持一行
return maxItem - 1 || 1;
});
watch(
[
() => props.showCollapseButton,
() => breakpoints.active().value,
() => props.schema?.length,
() => isVisible.value,
],
async ([val]) => {
if (val) {
await nextTick();
rowMapping.value = {};
isCalculated.value = false;
await calculateRowMapping();
}
},
);
async function calculateRowMapping() {
if (!props.showCollapseButton) {
return;
}
await nextTick();
if (!wrapperRef.value) {
return;
}
// 小屏幕不计算
// if (breakpoints.smaller('sm').value) {
// // 保持一行
// rowMapping.value = { 1: 2 };
// return;
// }
const formItems = [...wrapperRef.value.children];
const container = wrapperRef.value;
const containerStyles = window.getComputedStyle(container);
const rowHeights = containerStyles
.getPropertyValue('grid-template-rows')
.split(' ');
const containerRect = container?.getBoundingClientRect();
formItems.forEach((el) => {
const itemRect = el.getBoundingClientRect();
// 计算元素在第几行
const itemTop = itemRect.top - containerRect.top;
let rowStart = 0;
let cumulativeHeight = 0;
for (const [i, rowHeight] of rowHeights.entries()) {
cumulativeHeight += Number.parseFloat(rowHeight);
if (itemTop < cumulativeHeight) {
rowStart = i + 1;
break;
}
}
if (rowStart > (props?.collapsedRows ?? 1)) {
return;
}
rowMapping.value[rowStart] = (rowMapping.value[rowStart] ?? 0) + 1;
isCalculated.value = true;
});
}
onMounted(() => {
calculateRowMapping();
});
return { isCalculated, keepFormItemIndex, wrapperRef };
}
@@ -0,0 +1,565 @@
<script setup lang="ts">
import type { ZodType } from 'zod';
import type {
FormActions,
FormFieldProps,
FormRuleContext,
FormRuntimeField,
MaybeComponentProps,
} from '../types';
import {
computed,
markRaw,
nextTick,
onUnmounted,
ref,
toRaw,
useTemplateRef,
watch,
} from 'vue';
import { ChevronsDown, CircleAlert } from '@vben-core/icons';
import {
Button,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
VbenCollapsible,
VbenRenderContent,
VbenTooltip,
} from '@vben-core/shadcn-ui';
import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils';
import { getFormRule } from '../rule-registry';
import { injectComponentRefMap } from '../use-form-context';
import { injectRenderFormProps, useFormContext } from './context';
import useDependencies from './dependencies';
import FormLabel from './form-label.vue';
import { getBaseRules, isEventObjectLike } from './helper';
import { useFieldLabelWidth } from './utils';
interface Props extends FormFieldProps {}
interface RuntimeFieldSlotProps {
field: FormRuntimeField<any>;
}
const {
changeEventFallback,
colon,
commonComponentProps,
component,
componentProps,
dependencies,
description,
disabled,
emptyStateValue,
fieldName,
formFieldProps,
hide,
label,
labelClass,
labelWidth,
modelPropName,
renderComponentContent,
rules,
help,
collapsible,
defaultCollapsed = false,
} = defineProps<
Props & {
commonComponentProps: MaybeComponentProps;
}
>();
const { componentBindEventMap, componentMap, isVertical } = useFormContext();
const formRenderProps = injectRenderFormProps();
const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
const formApi = formRenderProps.form;
if (!formApi) {
throw new Error('Form api is required in <FormField />');
}
const error = formApi.useFieldError(fieldName);
const fieldValue = formApi.useFieldValue(fieldName);
const compact = computed(() => formRenderProps.compact);
const isInValid = computed(() => Boolean(error.value));
const shouldApplyInvalidStyle = computed(() => {
return isInValid.value && component !== 'VbenFormFieldArray';
});
const collapseOpen = ref(!defaultCollapsed);
function getFormApi(): FormActions {
if (!formApi) {
throw new Error('Form api is required in <FormField />');
}
return formApi;
}
const FieldComponent = computed(() => {
const finalComponent = isString(component)
? componentMap.value[component]
: component;
if (!finalComponent) {
// 组件未注册
console.warn(`Component ${component} is not registered`);
}
return finalComponent ? markRaw(toRaw(finalComponent)) : finalComponent;
});
const {
dynamicComponentProps,
dynamicHelp,
dynamicHelpResolved,
dynamicRenderComponentContent,
dynamicRenderComponentContentResolved,
dynamicRules,
dynamicRulesResolved,
isDisabled,
isIf,
isRequired,
isShow,
} = useDependencies(
() => dependencies,
() => ({ fieldName }),
);
// @ts-expect-error unused
const { labelRef, labelStyle } = useFieldLabelWidth({
labelWidth: () => labelWidth,
labelClass: () => labelClass,
isVertical,
labelWidthContext: formRenderProps,
});
const currentRules = computed(() => {
const currentRule = dynamicRulesResolved.value ? dynamicRules.value : rules;
return currentRule && !isString(currentRule)
? toRaw(currentRule)
: currentRule;
});
const visible = computed(() => {
return !hide && isIf.value && isShow.value;
});
const shouldRequired = computed(() => {
if (!visible.value) {
return false;
}
if (!currentRules.value) {
return isRequired.value;
}
if (isRequired.value) {
return true;
}
if (isString(currentRules.value)) {
return ['required', 'selectRequired'].includes(currentRules.value);
}
return !currentRules.value.isOptional();
});
const fieldRules = computed(() => {
if (!visible.value) {
return null;
}
let rules = currentRules.value;
if (!rules) {
return isRequired.value ? 'required' : null;
}
if (isString(rules)) {
return rules;
}
const isOptional = !shouldRequired.value;
if (!isOptional) {
rules = getBaseRules(rules) ?? rules;
}
return rules as ZodType;
});
async function validateFieldValue({ value }: { value: any }) {
const activeRules = fieldRules.value;
if (!activeRules) {
return;
}
if (isString(activeRules)) {
const validator = getFormRule(activeRules);
if (!validator) {
console.warn(`Form rule ${activeRules} is not registered`);
return;
}
const ruleContext: FormRuleContext = {
field: {
label: isString(label) ? label : undefined,
name: fieldName,
},
label: isString(label) ? label : undefined,
name: fieldName,
};
const result = await validator(value, [], ruleContext);
return result === true ? undefined : result;
}
const result = await activeRules.safeParseAsync(value);
return result.success ? undefined : result.error.issues[0]?.message;
}
const fieldValidators = computed(() => {
const validators: Record<string, typeof validateFieldValue> = {
onSubmitAsync: validateFieldValue,
};
const validateOn = new Set(formFieldProps?.validateOn ?? ['blur', 'change']);
if (validateOn.has('blur')) {
validators.onBlurAsync = validateFieldValue;
}
if (validateOn.has('change')) {
validators.onChangeAsync = validateFieldValue;
}
return validators;
});
const computedProps = computed(() => {
const finalComponentProps = isFunction(componentProps)
? componentProps({ fieldName })
: componentProps;
return {
...commonComponentProps,
...finalComponentProps,
...dynamicComponentProps.value,
};
});
// 自定义帮助信息
const computedHelp = computed(() => {
const helpContent = dynamicHelpResolved.value ? dynamicHelp.value : help;
if (!helpContent) {
return undefined;
}
return () =>
isFunction(helpContent) ? helpContent({ fieldName }) : helpContent;
});
watch(
() => computedProps.value?.autofocus,
(value) => {
if (value === true) {
nextTick(() => {
autofocus();
});
}
},
{ immediate: true },
);
const shouldDisabled = computed(() => {
return Boolean(isDisabled.value || disabled || computedProps.value?.disabled);
});
const customContentRender = computed(() => {
if (dynamicRenderComponentContentResolved.value) {
return dynamicRenderComponentContent.value ?? {};
}
if (!isFunction(renderComponentContent)) {
return {};
}
return renderComponentContent({ fieldName });
});
const renderContentKey = computed(() => {
return Object.keys(customContentRender.value);
});
const fieldProps = computed(() => {
return {
asyncDebounceMs: formFieldProps?.asyncDebounceMs,
validators: fieldValidators.value,
};
});
function createFieldSlotProps(slotProps: RuntimeFieldSlotProps) {
const { field } = slotProps;
function handleChange(value: any) {
getFormApi().setFieldError(fieldName);
field.handleChange(value);
}
return {
...slotProps,
componentField: {
name: fieldName,
modelValue: fieldValue.value,
onBlur: field.handleBlur,
onChange: handleChange,
onInput: handleChange,
'onUpdate:modelValue': handleChange,
},
};
}
function resolveModelPropName() {
return (
modelPropName ||
(isString(component) ? componentBindEventMap.value?.[component] : null)
);
}
function fieldBindEvent(
componentField: Record<string, any>,
bindEventField: null | string | undefined,
) {
const modelValue = componentField.modelValue;
const handler = componentField['onUpdate:modelValue'];
let value = modelValue;
// antd design 的一些组件会传递一个 event 对象
if (modelValue && isObject(modelValue) && bindEventField) {
value = isEventObjectLike(modelValue)
? modelValue?.target?.[bindEventField]
: (modelValue?.[bindEventField] ?? modelValue);
}
if (bindEventField) {
const eventField = bindEventField;
function handleChangeEvent(event: Record<string, any>) {
const value = isEventObjectLike(event)
? (event?.target?.[eventField] ?? event)
: event;
return handler?.(value);
}
return {
[`onUpdate:${eventField}`]: handler,
[eventField]: value === undefined ? emptyStateValue : value,
onChange: changeEventFallback ? handleChangeEvent : undefined,
onInput: undefined,
};
}
return {
onChange: changeEventFallback ? componentField.onChange : undefined,
onInput: undefined,
};
}
function createComponentProps(slotProps: RuntimeFieldSlotProps) {
const normalizedSlotProps = createFieldSlotProps(slotProps);
const bindEventField = resolveModelPropName();
const bindEvents = fieldBindEvent(
normalizedSlotProps.componentField,
bindEventField,
);
const binds = {
...computedProps.value,
...normalizedSlotProps.componentField,
...bindEvents,
disabled: shouldDisabled.value,
...(Reflect.has(computedProps.value, 'onChange')
? { onChange: computedProps.value.onChange }
: {}),
...(Reflect.has(computedProps.value, 'onInput')
? { onInput: computedProps.value.onInput }
: {}),
};
if (bindEventField && bindEventField !== 'modelValue') {
Reflect.deleteProperty(binds, 'modelValue');
Reflect.deleteProperty(binds, 'onUpdate:modelValue');
}
return binds;
}
function createFieldSlotScope(slotProps: RuntimeFieldSlotProps) {
return {
...createFieldSlotProps(slotProps),
componentProps: createComponentProps(slotProps),
disabled: shouldDisabled.value,
isInValid: isInValid.value,
modelValue: fieldValue.value,
name: fieldName,
};
}
function autofocus() {
if (
fieldComponentRef.value &&
isFunction(fieldComponentRef.value.focus) &&
// 检查当前是否有元素被聚焦
document.activeElement !== fieldComponentRef.value
) {
fieldComponentRef.value?.focus?.();
}
}
const shouldCollapsible = computed(() => {
return collapsible; /* && isVertical.value; */
});
function toggleCollapsed() {
collapseOpen.value = !collapseOpen.value;
}
const componentRefMap = injectComponentRefMap();
watch(fieldComponentRef, (componentRef) => {
componentRefMap?.set(fieldName, componentRef);
});
onUnmounted(() => {
if (componentRefMap?.has(fieldName)) {
componentRefMap.delete(fieldName);
}
});
</script>
<template>
<component
v-if="!hide && isIf"
:is="formApi.fieldComponent"
v-bind="fieldProps"
v-slot="slotProps"
:name="fieldName"
>
<FormField
:dirty="slotProps.field.state.meta.isDirty"
:error="error"
:name="fieldName"
:touched="slotProps.field.state.meta.isTouched"
:valid="slotProps.field.state.meta.isValid"
>
<FormItem
v-show="isShow"
:class="{
'form-valid-error': shouldApplyInvalidStyle,
'form-is-required': shouldRequired,
'flex-col': isVertical,
'flex-row items-center': !isVertical,
'pb-4': !compact,
'pb-2': compact,
}"
class="relative flex"
v-bind="$attrs"
>
<FormLabel
v-if="!hideLabel"
ref="labelRef"
:class="
cn(
'flex leading-6',
{
'flex-shrink-0 justify-end pr-3': !isVertical,
'mb-1 flex-row': isVertical,
'self-start': shouldCollapsible && !isVertical,
},
labelClass,
)
"
:help="computedHelp"
:colon="colon"
:label="label"
:required="shouldRequired && !hideRequiredMark"
:style="labelStyle"
>
<template v-if="label">
<VbenRenderContent :content="label" />
</template>
<template #extra>
<Button
class="ml-0.5"
variant="icon"
size="icon"
@click.prevent="toggleCollapsed"
v-if="shouldCollapsible"
>
<ChevronsDown
:size="16"
class="transition-transform"
:class="{
'rotate-180': !collapseOpen,
}"
/>
</Button>
</template>
</FormLabel>
<div class="flex-auto overflow-hidden p-px">
<VbenCollapsible :show-trigger="false" v-model:open="collapseOpen">
<template #collapsibleContent>
<div
:class="cn('relative flex w-full items-center', wrapperClass)"
>
<FormControl :class="cn(controlClass)">
<slot v-bind="createFieldSlotScope(slotProps)">
<component
:is="FieldComponent"
ref="fieldComponentRef"
:class="{
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
shouldApplyInvalidStyle,
}"
v-bind="createComponentProps(slotProps)"
>
<template
v-for="name in renderContentKey"
:key="name"
#[name]="renderSlotProps"
>
<VbenRenderContent
:content="customContentRender[name]"
v-bind="{
...renderSlotProps,
formContext: createFieldSlotProps(slotProps),
}"
/>
</template>
<!-- <slot></slot> -->
</component>
<VbenTooltip
v-if="compact && isInValid"
:delay-duration="300"
side="left"
>
<template #trigger>
<slot name="trigger">
<CircleAlert
:class="
cn(
'inline-flex size-5 cursor-pointer text-foreground/80 hover:text-foreground',
)
"
/>
</slot>
</template>
<FormMessage />
</VbenTooltip>
</slot>
</FormControl>
<!-- 自定义后缀 -->
<div v-if="suffix" class="ml-1">
<VbenRenderContent :content="suffix" />
</div>
</div>
</template>
</VbenCollapsible>
<FormDescription v-if="description" class="text-xs">
<VbenRenderContent :content="description" />
</FormDescription>
<Transition name="slide-up" v-if="!compact">
<FormMessage class="absolute" />
</Transition>
</div>
</FormItem>
</FormField>
</component>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { CustomRenderType } from '../types';
import { useForwardExpose } from '@vben-core/composables';
import {
FormLabel,
VbenHelpTooltip,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
interface Props {
class?: string;
colon?: boolean;
help?: CustomRenderType;
label?: CustomRenderType;
required?: boolean;
}
const props = defineProps<Props>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<FormLabel :ref="forwardRef" :class="cn('flex items-center', props.class)">
<span v-if="required" class="mr-0.5 text-destructive">*</span>
<slot></slot>
<VbenHelpTooltip v-if="help" trigger-class="size-3.5 ml-1">
<VbenRenderContent :content="help" />
</VbenHelpTooltip>
<slot name="extra"></slot>
<span v-if="colon && label" class="ml-0.5">:</span>
</FormLabel>
</template>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import type { ZodType } from 'zod';
import type { FormCommonConfig, FormRenderProps, FormShape } from '../types';
import type { NormalizedFormFieldSchema } from './schema';
import { computed, reactive, toRaw, toRefs } from 'vue';
import { cn, isString } from '@vben-core/shared/utils';
import { provideFormRenderProps } from './context';
import { useExpandable } from './expandable';
import FormField from './form-field.vue';
import { getBaseRules, getDefaultValueInZodStack } from './helper';
import { createFormFieldSchema } from './schema';
import { useFormLabelWidth } from './utils';
interface Props extends FormRenderProps {}
const props = withDefaults(
defineProps<Props & { globalCommonConfig?: FormCommonConfig }>(),
{
collapsedRows: 1,
commonConfig: () => ({}),
globalCommonConfig: () => ({}),
showCollapseButton: false,
wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
},
);
const emits = defineEmits<{
submit: [event: any];
}>();
const wrapperClass = computed(() => {
const cls = ['flex'];
if (props.layout === 'inline') {
cls.push('flex-wrap gap-x-2');
} else {
cls.push(props.compact ? 'gap-x-2' : 'gap-x-4', 'flex-col grid');
}
return cn(...cls, props.wrapperClass);
});
provideFormRenderProps(reactive({ ...toRefs(props), ...useFormLabelWidth() }));
// @ts-expect-error unused
const { isCalculated, keepFormItemIndex, wrapperRef } = useExpandable(props);
const shapes = computed(() => {
const resultShapes: FormShape[] = [];
props.schema?.forEach((schema) => {
const { fieldName } = schema;
const rules = toRaw(schema.rules) as ZodType;
const baseRules = getBaseRules(rules) as ZodType;
resultShapes.push({
default: getDefaultValueInZodStack(rules),
fieldName,
required: Boolean(rules && !isString(rules) && !rules.isOptional()),
rules: baseRules,
});
});
return resultShapes;
});
const formComponent = 'form';
const formComponentProps = computed(() => {
return props.form
? {
onSubmit: props.form.handleSubmit(() => emits('submit', undefined)),
}
: {
onSubmit: (event: Event) => {
event.preventDefault();
emits('submit', event);
},
};
});
const formCollapsed = computed(() => {
return props.collapsed && isCalculated.value;
});
const computedSchema = computed((): NormalizedFormFieldSchema[] => {
return (props.schema || []).map((schema, index) => {
const keepIndex = keepFormItemIndex.value;
const hidden =
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
props.showCollapseButton && !!formCollapsed.value && keepIndex
? keepIndex <= index
: false;
return createFormFieldSchema(schema as never, {
commonConfig: props.commonConfig,
globalCommonConfig: props.globalCommonConfig,
hidden,
});
});
});
</script>
<template>
<component :is="formComponent" v-bind="formComponentProps">
<div ref="wrapperRef" :class="wrapperClass">
<template v-for="cSchema in computedSchema" :key="cSchema.fieldName">
<!-- <div v-if="$slots[cSchema.fieldName]" :class="cSchema.formItemClass">
<slot :definition="cSchema" :name="cSchema.fieldName"> </slot>
</div> -->
<FormField
v-bind="cSchema"
:class="cSchema.formItemClass"
:rules="cSchema.rules"
>
<template #default="slotProps">
<slot v-bind="slotProps" :name="cSchema.fieldName"> </slot>
</template>
</FormField>
</template>
<slot :shapes="shapes"></slot>
</div>
</component>
</template>
@@ -0,0 +1,65 @@
import type { ZodType } from 'zod';
import { toRaw } from 'vue';
import { isObject, isString } from '@vben-core/shared/utils';
import { ZodPipe } from 'zod';
type UnwrappableZodType = ZodType & {
unwrap?: () => ZodType;
};
/**
* Get the lowest level Zod type.
* This will unpack optionals, refinements, etc.
*/
export function getBaseRules(schema?: null | string | ZodType): null | ZodType {
if (!schema || isString(schema)) return null;
const rawSchema = toRaw(schema);
if (rawSchema instanceof ZodPipe) {
return getBaseRules(rawSchema.in as ZodType);
}
// In zod v4, ZodArray also has an unwrap() that returns its element type
// (not an outer wrapper). We must not unwrap it, otherwise z.array(T) loses
// its array shell and array values fail validation.
const defType = (rawSchema as unknown as { _zod?: { def: { type: string } } })
._zod?.def?.type;
if (defType === 'array') {
return rawSchema;
}
const unwrappedSchema = (rawSchema as UnwrappableZodType).unwrap?.();
if (unwrappedSchema && unwrappedSchema !== rawSchema) {
return getBaseRules(unwrappedSchema);
}
return rawSchema;
}
/**
* Search for a "ZodDefault" in the Zod stack and return its value.
*/
export function getDefaultValueInZodStack(
schema?: null | string | ZodType,
): any {
if (!schema || isString(schema)) {
return;
}
try {
const result = toRaw(schema).safeParse(undefined);
return result.success ? result.data : undefined;
} catch {
return undefined;
}
}
export function isEventObjectLike(obj: any) {
if (!obj || !isObject(obj)) {
return false;
}
return Reflect.has(obj, 'target') && Reflect.has(obj, 'stopPropagation');
}
@@ -0,0 +1,3 @@
export { default as FormField } from './form-field.vue';
export { default as FormLabel } from './form-label.vue';
export { default as Form } from './form.vue';
@@ -0,0 +1,455 @@
import type {
BaseFormComponentType,
FormActions,
FormCommonConfig,
FormDependenciesResolveContext,
FormFieldProps,
FormItemDependencies,
FormItemDependenciesLegacy,
FormSchema,
FormSchemaContext,
MaybeComponentProps,
} from '../types';
import {
get,
isFunction,
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
import { resolveChildUpdateFieldName } from '../field-name';
type AnyFormSchema = FormSchema<BaseFormComponentType, Record<string, any>>;
export type NormalizedFormFieldSchema = FormFieldProps & {
commonComponentProps: MaybeComponentProps;
formFieldProps: Record<string, any>;
formItemClass: string;
};
interface CreateFormFieldSchemaOptions {
commonConfig?: FormCommonConfig;
disabled?: boolean;
forceHideLabel?: boolean;
globalCommonConfig?: FormCommonConfig;
hidden?: boolean;
}
interface CreateArrayChildSchemaOptions extends CreateFormFieldSchemaOptions {
arrayField: string;
index: number;
}
function createSchemaContext(
baseContext: FormSchemaContext,
values?: Partial<Record<string, any>>,
): FormSchemaContext {
const rootValues = values as Record<string, any> | undefined;
return {
...baseContext,
rootValues,
row:
baseContext.rowPath && rootValues
? get(rootValues, baseContext.rowPath)
: undefined,
};
}
function scopeRowFieldName(rowPath: string, fieldName: string) {
if (!fieldName) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${rowPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === rowPath || fieldName.startsWith(`${rowPath}.`)) {
return fieldName;
}
return `${rowPath}.${fieldName}`;
}
function wrapComponentProps(
componentProps: AnyFormSchema['componentProps'],
baseContext: FormSchemaContext,
) {
if (!isFunction(componentProps)) {
return componentProps;
}
return () => componentProps(baseContext);
}
function wrapCommonConfig(
commonConfig: FormCommonConfig | undefined,
baseContext: FormSchemaContext,
) {
if (!commonConfig || !isFunction(commonConfig.componentProps)) {
return commonConfig;
}
return {
...commonConfig,
componentProps: wrapComponentProps(
commonConfig.componentProps,
baseContext,
),
};
}
function wrapCustomParamsRender(
render: AnyFormSchema['help'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
return render;
}
return () => render(baseContext);
}
function wrapRenderComponentContent(
render: AnyFormSchema['renderComponentContent'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
return render;
}
return () => render(baseContext);
}
function wrapDependencyFn<T>(handler: T, baseContext: FormSchemaContext): T {
if (!isFunction(handler)) {
return handler;
}
return ((
values: Partial<Record<string, any>>,
actions: FormActions,
controller: any,
) =>
handler(
values,
actions,
controller,
createSchemaContext(baseContext, values),
)) as T;
}
function scopeDependencies(
dependencies: FormItemDependencies | undefined,
baseContext: FormSchemaContext,
): FormItemDependencies | undefined {
if (!dependencies) {
return dependencies;
}
const rowPath = baseContext.rowPath;
if (!rowPath) {
return dependencies;
}
const triggerFields =
dependencies.triggerFields?.map((fieldName) =>
scopeRowFieldName(rowPath, fieldName),
) ?? [];
if (isFunction(dependencies.resolve)) {
const resolve = dependencies.resolve;
return {
resolve(context: FormDependenciesResolveContext) {
return resolve({
...context,
schema: createSchemaContext(
baseContext,
context.values as Partial<Record<string, any>>,
),
});
},
triggerFields,
};
}
const legacyDependencies = dependencies as FormItemDependenciesLegacy;
return {
...legacyDependencies,
componentProps: wrapDependencyFn(
legacyDependencies.componentProps,
baseContext,
),
disabled: wrapDependencyFn(legacyDependencies.disabled, baseContext),
if: wrapDependencyFn(legacyDependencies.if, baseContext),
required: wrapDependencyFn(legacyDependencies.required, baseContext),
rules: wrapDependencyFn(legacyDependencies.rules, baseContext),
show: wrapDependencyFn(legacyDependencies.show, baseContext),
trigger: wrapDependencyFn(legacyDependencies.trigger, baseContext),
triggerFields,
};
}
function createArrayComponentProps(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions,
) {
const componentProps = schema.componentProps;
const arrayProps = 'arrayProps' in schema ? schema.arrayProps : undefined;
const children = getFormArraySchemaChildren(schema);
const commonConfig = options.commonConfig;
const globalCommonConfig = options.globalCommonConfig;
const schemaProps = children.length > 0 ? { schema: children } : {};
if (isFunction(componentProps)) {
return () => ({
...arrayProps,
...componentProps({ fieldName: schema.fieldName }),
commonConfig,
globalCommonConfig,
...schemaProps,
});
}
return {
...arrayProps,
...componentProps,
commonConfig,
globalCommonConfig,
...schemaProps,
};
}
function createArrayFieldSchema(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions,
) {
const restSchema = { ...(schema as AnyFormSchema & Record<string, any>) };
Reflect.deleteProperty(restSchema, 'arrayProps');
Reflect.deleteProperty(restSchema, 'children');
Reflect.deleteProperty(restSchema, 'type');
return {
...restSchema,
component: 'VbenFormFieldArray',
componentProps: createArrayComponentProps(schema, options),
};
}
interface FormArraySchemaLike {
children?: unknown;
componentProps?: unknown;
}
interface UpdatableFormSchemaLike extends FormArraySchemaLike {
fieldName: string;
}
function setSchemaChildren<TSchema extends UpdatableFormSchemaLike>(
schema: TSchema,
children: TSchema[],
) {
if ('children' in schema && Array.isArray(schema.children)) {
return {
...schema,
children,
} as TSchema;
}
if (
!isFunction(schema.componentProps) &&
schema.componentProps &&
Array.isArray((schema.componentProps as Record<string, any>).schema)
) {
return {
...schema,
componentProps: {
...(schema.componentProps as Record<string, any>),
schema: children,
},
} as TSchema;
}
return schema;
}
export function getFormArraySchemaChildren<TSchema = FormSchema>(
schema: FormArraySchemaLike,
): TSchema[] {
if ('children' in schema && Array.isArray(schema.children)) {
return schema.children as TSchema[];
}
const componentProps = schema.componentProps;
if (
!isFunction(componentProps) &&
componentProps &&
Array.isArray((componentProps as Record<string, any>).schema)
) {
return (componentProps as Record<string, any>).schema as TSchema[];
}
return [];
}
export function isFormArraySchema(schema: Partial<AnyFormSchema>) {
return (
('type' in schema && schema.type === 'array') ||
schema.component === 'VbenFormFieldArray' ||
getFormArraySchemaChildren(schema).length > 0
);
}
export function resolveArrayChildFieldName(rowPath: string, fieldName: string) {
return scopeRowFieldName(rowPath, fieldName);
}
export function updateFormSchemaList<TSchema extends UpdatableFormSchemaLike>(
currentSchema: TSchema[],
updated: Partial<TSchema>[],
): TSchema[] {
return currentSchema.map((schema) => {
const exactUpdatedData = updated.find(
(item) => item.fieldName === schema.fieldName,
);
if (exactUpdatedData) {
return mergeWithArrayOverride(exactUpdatedData, schema) as TSchema;
}
const children = getFormArraySchemaChildren<TSchema>(schema);
if (children.length === 0) {
return schema;
}
const childUpdates = updated.flatMap((item) => {
const fieldName = item.fieldName
? resolveChildUpdateFieldName(schema.fieldName, item.fieldName)
: undefined;
return fieldName ? [{ ...item, fieldName } as Partial<TSchema>] : [];
});
if (childUpdates.length === 0) {
return schema;
}
return setSchemaChildren(
schema,
updateFormSchemaList(children, childUpdates),
);
});
}
export function createFormFieldSchema(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions = {},
): NormalizedFormFieldSchema {
const commonConfig = mergeWithArrayOverride(
options.commonConfig ?? {},
options.globalCommonConfig ?? {},
);
const {
changeEventFallback = false,
colon = false,
componentProps = {},
controlClass = '',
disabled,
emptyStateValue = undefined,
formFieldProps = {},
formItemClass = '',
hideLabel = false,
hideRequiredMark = false,
labelClass = '',
labelWidth = 100,
modelPropName = '',
wrapperClass = '',
} = commonConfig;
const normalizedSchema = isFormArraySchema(schema)
? createArrayFieldSchema(schema, options)
: schema;
const commonComponentProps = isFunction(componentProps)
? componentProps({ fieldName: normalizedSchema.fieldName })
: componentProps;
let resolvedSchemaFormItemClass = normalizedSchema.formItemClass;
if (isFunction(normalizedSchema.formItemClass)) {
try {
resolvedSchemaFormItemClass = normalizedSchema.formItemClass();
} catch (error) {
console.error('Error calling formItemClass function:', error);
resolvedSchemaFormItemClass = '';
}
}
return {
changeEventFallback,
colon,
emptyStateValue,
hideRequiredMark,
labelWidth,
modelPropName,
wrapperClass,
...normalizedSchema,
commonComponentProps,
componentProps: normalizedSchema.componentProps,
controlClass: [controlClass, normalizedSchema.controlClass]
.filter(Boolean)
.join(' '),
formFieldProps: {
...formFieldProps,
...normalizedSchema.formFieldProps,
},
formItemClass: [
'shrink-0',
options.hidden ? 'hidden' : '',
formItemClass,
resolvedSchemaFormItemClass,
]
.filter(Boolean)
.join(' '),
labelClass: [labelClass, normalizedSchema.labelClass]
.filter(Boolean)
.join(' '),
disabled: options.disabled ?? normalizedSchema.disabled ?? disabled,
hideLabel:
options.forceHideLabel ?? normalizedSchema.hideLabel ?? hideLabel,
} as NormalizedFormFieldSchema;
}
export function createArrayChildSchema(
schema: AnyFormSchema,
options: CreateArrayChildSchemaOptions,
): NormalizedFormFieldSchema {
const rowPath = `${options.arrayField}[${options.index}]`;
const fieldName = resolveArrayChildFieldName(rowPath, schema.fieldName);
const baseContext: FormSchemaContext = {
arrayField: options.arrayField,
fieldName,
originalFieldName: schema.fieldName,
rowIndex: options.index,
rowPath,
};
return createFormFieldSchema(
{
...schema,
componentProps: wrapComponentProps(schema.componentProps, baseContext),
dependencies: scopeDependencies(schema.dependencies, baseContext),
fieldName,
help: wrapCustomParamsRender(schema.help, baseContext),
renderComponentContent: wrapRenderComponentContent(
schema.renderComponentContent,
baseContext,
),
},
{
commonConfig: wrapCommonConfig(options.commonConfig, baseContext),
disabled: options.disabled || schema.disabled,
forceHideLabel: true,
globalCommonConfig: wrapCommonConfig(
options.globalCommonConfig,
baseContext,
),
},
);
}
@@ -0,0 +1,170 @@
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import type { FormLabelWidthContext } from '../types';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
onUpdated,
ref,
toValue,
watch,
} from 'vue';
import { isString } from '@vben-core/shared/utils';
import { useResizeObserver } from '@vueuse/core';
export function useFormLabelWidth() {
const potentialLabelWidthArr = ref<number[]>([]);
const autoLabelWidth = computed(() => {
if (potentialLabelWidthArr.value.length === 0) return '0';
const max = Math.max(...potentialLabelWidthArr.value);
return max ? `${max}px` : '';
});
function getLabelWidthIndex(width: number) {
const index = potentialLabelWidthArr.value.indexOf(width);
if (index === -1 && autoLabelWidth.value === '0') {
console.warn(`unexpected width ${width}`);
}
return index;
}
function registerLabelWidth(val: number, oldVal: number) {
if (val && oldVal) {
const index = getLabelWidthIndex(oldVal);
potentialLabelWidthArr.value.splice(index, 1, val);
} else if (val) {
potentialLabelWidthArr.value.push(val);
}
}
function deregisterLabelWidth(val: number) {
const index = getLabelWidthIndex(val);
if (index > -1) {
potentialLabelWidthArr.value.splice(index, 1);
}
}
return {
autoLabelWidth,
registerLabelWidth,
deregisterLabelWidth,
};
}
export interface ResolveLabelStyleInput {
autoLabelWidth: string;
computedWidth: number;
isVertical: boolean;
labelClass: string | undefined;
labelWidth: number | string | undefined;
}
export function resolveLabelStyle(
input: ResolveLabelStyleInput,
): Record<string, string> {
const { labelWidth, labelClass, isVertical, autoLabelWidth, computedWidth } =
input;
if (labelClass?.includes('w-') || isVertical) {
return {};
}
if (labelWidth === 'auto' && autoLabelWidth) {
const marginWidth = Math.max(
0,
Number.parseInt(autoLabelWidth, 10) - computedWidth,
);
const labelPosition = labelClass === 'justify-start' ? 'left' : 'right';
const marginPosition =
labelPosition === 'left' ? 'marginRight' : 'marginLeft';
return {
width: 'auto',
[marginPosition]: `${marginWidth}px`,
};
}
return {
width: isString(labelWidth) ? labelWidth : `${labelWidth}px`,
};
}
export function useFieldLabelWidth(options: {
isVertical: ComputedRef<boolean> | Ref<boolean>;
labelClass: MaybeRefOrGetter<string | undefined>;
labelWidth: MaybeRefOrGetter<number | string | undefined>;
labelWidthContext: FormLabelWidthContext;
}) {
const { labelWidthContext, isVertical } = options;
const labelRef = ref();
const computedWidth = ref(0);
const labelStyle = computed(() =>
resolveLabelStyle({
labelWidth: toValue(options.labelWidth),
labelClass: toValue(options.labelClass),
isVertical: isVertical.value,
autoLabelWidth: labelWidthContext.autoLabelWidth,
computedWidth: computedWidth.value,
}),
);
const getLabelWidth = () => {
if (labelRef.value?.$el) {
const width = window.getComputedStyle(labelRef.value.$el).width;
return Math.ceil(Number.parseFloat(width));
}
return 0;
};
const updateLabelWidth = (action: 'remove' | 'update' = 'update') => {
nextTick(() => {
if (toValue(options.labelWidth) !== 'auto') {
return;
}
if (action === 'update') {
computedWidth.value = getLabelWidth();
} else if (action === 'remove') {
labelWidthContext.deregisterLabelWidth(computedWidth.value);
}
});
};
const updateLabelWidthFn = () => updateLabelWidth('update');
onMounted(updateLabelWidthFn);
onBeforeUnmount(() => updateLabelWidth('remove'));
onUpdated(updateLabelWidthFn);
watch(computedWidth, (val, oldVal) => {
if (!isVertical.value && toValue(options.labelWidth) === 'auto') {
labelWidthContext.registerLabelWidth(val, oldVal);
}
});
watch(isVertical, (vertical) => {
if (toValue(options.labelWidth) !== 'auto' || computedWidth.value === 0) {
return;
}
if (vertical) {
labelWidthContext.deregisterLabelWidth(computedWidth.value);
} else {
labelWidthContext.registerLabelWidth(computedWidth.value, 0);
}
});
useResizeObserver(
computed(() => (labelRef.value?.$el ?? null) as HTMLElement | null),
updateLabelWidthFn,
);
return {
labelRef,
labelStyle,
};
}
@@ -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,320 @@
import type { FieldValidationInvalidator } from './form-runtime-field';
import type {
FormActions,
FormFieldName,
FormFieldValue,
FormResetOptions,
FormRuntimeState,
FormValues,
} from './types';
import { computed, shallowRef } from 'vue';
import { mergeWithArrayOverride } from '@vben-core/shared/utils';
import { batch } from '@tanstack/store';
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();
const partialValues = resetState?.values;
let resetValues: TValues | undefined;
if (partialValues) {
resetValues = options?.force
? (partialValues as TValues)
: (mergeWithArrayOverride(
partialValues,
rawForm.options.defaultValues ?? defaultValues,
) as TValues);
}
rawForm.reset(resetValues, {
keepDefaultValues: options?.keepDefaultValues,
});
}
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) => {
event?.preventDefault();
event?.stopPropagation();
const result = await validate();
if (result.valid) {
await callback?.(values.value as TValues);
}
};
},
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) {
batch(() => {
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,29 @@
export { setupVbenForm } from './config';
export { FormCodecError } from './form-codec';
export type { FormCodecPhase } from './form-codec';
export type {
BaseFormComponentType,
ExtendedFormApi,
FormActions,
FormCodec,
FormContextApi,
FormLayout,
FormSchemaContext,
FormValues,
FormValueSnapshot,
VbenFormActionSlotProps,
VbenFormComponent,
VbenFormDefaultSlotProps,
VbenFormFieldArrayProps,
VbenFormFieldSlotProps,
VbenFormProps,
VbenFormResolvedComponentProps,
FormSchema as VbenFormSchema,
VbenFormSlots,
} from './types';
export * from './use-vben-form';
// export { default as VbenForm } from './vben-form.vue';
export * as z from 'zod';
@@ -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,980 @@
import type { ZodType } from 'zod';
import type { Component, HtmlHTMLAttributes, Ref, UnwrapNestedRefs } from 'vue';
import type { VbenButtonProps } from '@vben-core/shadcn-ui';
import type { ClassType, MaybeComputedRef } from '@vben-core/typings';
import type { FormApi } from './form-api';
import type { useFormLabelWidth } from './form-render/utils';
export type FormLabelWidthContext = UnwrapNestedRefs<
ReturnType<typeof useFormLabelWidth>
>;
export type FormValues = Record<string, any>;
export interface FormCodec<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> {
/** 将提交值转换为表单组件值。 */
decode: (values: Readonly<TSubmitValues>) => TFormValues;
/** 将表单组件值转换为提交值。 */
encode: (values: Readonly<TFormValues>) => TSubmitValues;
}
export type FormFieldName<TValues extends FormValues = FormValues> =
| Extract<keyof TValues, string>
| (Record<never, never> & string);
export type FormFieldValue<
TValues extends FormValues,
TFieldName extends string,
> = TFieldName extends keyof TValues ? TValues[TFieldName] : unknown;
export type FormLayout = 'horizontal' | 'inline' | 'vertical';
export type BaseFormComponentType =
| 'DefaultButton'
| 'PrimaryButton'
| 'VbenCheckbox'
| 'VbenFormFieldArray'
| 'VbenInput'
| 'VbenInputPassword'
| 'VbenPinInput'
| 'VbenSelect'
| (Record<never, never> & string);
type Breakpoints = '2xl:' | '3xl:' | '' | 'lg:' | 'md:' | 'sm:' | 'xl:';
type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
export type WrapperClassType =
| `${Breakpoints}grid-cols-${GridCols}`
| (Record<never, never> & string);
export type FormItemClassType =
| `${Breakpoints}cols-end-${'auto' | GridCols}`
| `${Breakpoints}cols-span-${'auto' | 'full' | GridCols}`
| `${Breakpoints}cols-start-${'auto' | GridCols}`
| (Record<never, never> & string)
| WrapperClassType;
export interface FormFieldOptions {
asyncDebounceMs?: number;
validateOn?: readonly FormValidationTrigger[];
}
export type FormValidationTrigger = 'blur' | 'change';
export interface FormShape {
/** 默认值 */
default?: any;
/** 字段名 */
fieldName: string;
/** 是否必填 */
required?: boolean;
rules?: ZodType;
}
export interface FormRuntimeField<TValue = unknown> {
handleBlur: () => void;
handleChange: (value: TValue) => void;
state: {
meta: {
errors: unknown[];
isDirty: boolean;
isTouched: boolean;
isValid: boolean;
};
value: TValue;
};
}
export interface FormComponentField<
TValue = unknown,
TFieldName extends string = string,
> {
modelValue: TValue;
name: TFieldName;
onBlur: () => void;
onChange: (value: TValue) => void;
onInput: (value: TValue) => void;
'onUpdate:modelValue': (value: TValue) => void;
}
export type MaybeComponentPropKey =
| 'options'
| 'placeholder'
| 'title'
| keyof HtmlHTMLAttributes
| (Record<never, never> & string);
export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
export interface FormMeta {
dirty: boolean;
submitting: boolean;
valid: boolean;
validating: boolean;
}
export interface FormRuntimeState<TValues extends FormValues = FormValues> {
errors: Record<string, string>;
meta: FormMeta;
values: TValues;
}
export interface FormValidationResult {
errors: Record<string, string>;
valid: boolean;
}
export interface FormValueSnapshot<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> {
rawValues: Readonly<TFormValues>;
values: TSubmitValues;
}
export interface FormResetState<TValues extends FormValues = FormValues> {
values?: Partial<TValues>;
}
export interface FormResetOptions {
force?: boolean;
keepDefaultValues?: boolean;
}
export interface FormContextApi<TValues extends FormValues = FormValues> {
clearValidation: (
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[],
) => void;
readonly errors: Record<string, string>;
readonly fieldComponent: Component;
getFieldError: (fieldName: string) => string | undefined;
getFieldValue: <TFieldName extends FormFieldName<TValues>>(
fieldName: TFieldName,
) => FormFieldValue<TValues, TFieldName>;
handleSubmit: (
callback?: (values: TValues) => Promise<void> | void,
) => (event?: Event) => Promise<void>;
isFieldValid: (fieldName: string) => boolean;
readonly meta: FormMeta;
pushFieldValue: (fieldName: string, value: any) => void;
removeFieldValue: (fieldName: string, index: number) => Promise<void>;
reset: (
state?: FormResetState<TValues>,
options?: FormResetOptions,
) => Promise<void>;
/** @deprecated Use `reset` instead. */
resetForm: (
state?: FormResetState<TValues>,
options?: FormResetOptions,
) => Promise<void>;
setFieldError: (fieldName: string, error?: string) => void;
setFieldValue: <TFieldName extends FormFieldName<TValues>>(
fieldName: TFieldName,
value: FormFieldValue<TValues, NoInfer<TFieldName>>,
shouldValidate?: boolean,
) => Promise<void>;
setValues: (
values: Partial<TValues>,
shouldValidate?: boolean,
) => Promise<void>;
submit: () => Promise<void>;
/** @deprecated Use `submit` instead. */
submitForm: () => Promise<void>;
useFieldError: (fieldName: string) => Readonly<Ref<string | undefined>>;
useFieldValue: <TFieldName extends FormFieldName<TValues>>(
fieldName: TFieldName,
) => Readonly<Ref<FormFieldValue<TValues, TFieldName>>>;
useFieldValues: <TFieldName extends FormFieldName<TValues>>(
fieldNames: readonly TFieldName[],
) => Readonly<Ref<FormFieldValue<TValues, TFieldName>[]>>;
useSelector: <T>(
selector: (state: FormRuntimeState<TValues>) => T,
) => Readonly<Ref<T>>;
useValues: () => Readonly<Ref<TValues>>;
validate: () => Promise<FormValidationResult>;
validateField: (fieldName: string) => Promise<FormValidationResult>;
readonly values: TValues;
}
/** @deprecated Use `FormContextApi` instead. */
export type FormActions<TValues extends FormValues = FormValues> =
FormContextApi<TValues>;
type ReservedFormSlotName =
| 'default'
| 'expand-after'
| 'expand-before'
| 'reset-before'
| 'submit-before';
type KnownFormFieldName<TValues extends FormValues> =
string extends Extract<keyof TValues, string>
? never
: Exclude<Extract<keyof TValues, string>, ReservedFormSlotName>;
export interface VbenFormActionSlotProps<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> {
formApi: ExtendedFormApi<TValues, T, P, TSubmitValues>;
values: TValues;
}
export interface VbenFormDefaultSlotProps<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> extends VbenFormActionSlotProps<TValues, T, P, TSubmitValues> {
shapes: FormShape[];
}
export interface VbenFormFieldSlotProps<
TValues extends FormValues = FormValues,
TFieldName extends FormFieldName<TValues> = FormFieldName<TValues>,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> extends VbenFormActionSlotProps<TValues, T, P, TSubmitValues> {
componentField: FormComponentField<
FormFieldValue<TValues, TFieldName>,
TFieldName
>;
componentProps: VbenFormResolvedComponentProps<
FormFieldValue<TValues, TFieldName>,
TFieldName
>;
disabled: boolean;
field: FormRuntimeField<FormFieldValue<TValues, TFieldName>>;
isInValid: boolean;
modelValue: FormFieldValue<TValues, TFieldName>;
name: TFieldName;
}
export type VbenFormResolvedComponentProps<
TValue = unknown,
TFieldName extends string = string,
> = MaybeComponentProps & {
disabled: boolean;
modelValue?: TValue;
name: TFieldName;
'onUpdate:modelValue'?: (value: TValue) => void;
};
type VbenFormFieldSlots<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
TSubmitValues extends FormValues,
> =
string extends Extract<keyof TValues, string>
? Record<
string,
| ((
props: VbenFormFieldSlotProps<
TValues,
FormFieldName<TValues>,
T,
P,
TSubmitValues
>,
) => any)
| undefined
>
: {
[TFieldName in KnownFormFieldName<TValues>]?: (
props: VbenFormFieldSlotProps<
TValues,
TFieldName,
T,
P,
TSubmitValues
>,
) => any;
};
export type VbenFormSlots<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> = VbenFormFieldSlots<TValues, T, P, TSubmitValues> & {
default?: (
props: VbenFormDefaultSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'expand-after'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'expand-before'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'reset-before'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'submit-before'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
};
export type VbenFormComponent<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> = new () => {
$props: VbenFormProps<T, P, TValues, TSubmitValues>;
$slots: VbenFormSlots<TValues, T, P, TSubmitValues>;
};
export interface FormSchemaContext<TValues extends FormValues = FormValues> {
/** 数组字段名,例如 contacts */
arrayField?: string;
/** 当前真实字段名,例如 contacts[0].name */
fieldName?: string;
/** 原始 schema 字段名,例如 name */
originalFieldName?: string;
/** 表单完整值 */
rootValues?: TValues;
/** 当前行数据 */
row?: Record<string, any>;
/** 当前行索引 */
rowIndex?: number;
/** 当前行路径,例如 contacts[0] */
rowPath?: string;
}
export type CustomRenderType = (() => Component | string) | string;
// 动态渲染参数
export type CustomParamsRenderType<TValues extends FormValues = FormValues> =
| ((ctx: FormSchemaContext<TValues>) => Component | string)
| string;
export type FormSchemaRuleType =
| 'required'
| 'selectRequired'
| null
| (Record<never, never> & string)
| ZodType;
type FormItemDependenciesCondition<
TValues extends FormValues,
TResult = boolean | PromiseLike<boolean>,
> = (
value: Partial<TValues>,
actions: FormActions<TValues>,
controller: ExtendedFormApi<TValues>, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext<TValues>,
) => TResult;
type FormItemDependenciesConditionWithRules<TValues extends FormValues> = (
value: Partial<TValues>,
actions: FormActions<TValues>,
controller: ExtendedFormApi<TValues>, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext<TValues>,
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
type FormItemDependenciesConditionWithProps<TValues extends FormValues> = (
value: Partial<TValues>,
actions: FormActions<TValues>,
controller: ExtendedFormApi<TValues>, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext<TValues>,
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
interface FormItemDependenciesBase {
/**
* 触发字段
*/
triggerFields: string[];
}
export interface FormDependenciesResolveContext<
TValues extends FormValues = FormValues,
> {
actions: FormActions<TValues>;
controller: ExtendedFormApi<TValues>;
schema: FormSchemaContext<TValues>;
values: Readonly<TValues>;
}
export interface FormDependenciesResolvedState {
componentProps?: MaybeComponentProps;
disabled?: boolean;
help?: CustomRenderType;
if?: boolean;
renderComponentContent?: Record<string, any>;
required?: boolean;
rules?: FormSchemaRuleType;
show?: boolean;
}
export interface FormItemDependenciesLegacy<
TValues extends FormValues = FormValues,
> extends FormItemDependenciesBase {
/**
* 组件参数
* @returns 组件参数
* @deprecated Use `dependencies.resolve` instead.
*/
componentProps?: FormItemDependenciesConditionWithProps<TValues>;
/**
* 是否禁用
* @returns 是否禁用
* @deprecated Use `dependencies.resolve` instead.
*/
disabled?: boolean | FormItemDependenciesCondition<TValues>;
/**
* 是否渲染(删除dom
* @returns 是否渲染
* @deprecated Use `dependencies.resolve` instead.
*/
if?: boolean | FormItemDependenciesCondition<TValues>;
/**
* 是否必填
* @returns 是否必填
* @deprecated Use `dependencies.resolve` instead.
*/
required?: FormItemDependenciesCondition<TValues>;
resolve?: never;
/**
* 字段规则
* @deprecated Use `dependencies.resolve` instead.
*/
rules?: FormItemDependenciesConditionWithRules<TValues>;
/**
* 是否隐藏(Css)
* @returns 是否隐藏
* @deprecated Use `dependencies.resolve` instead.
*/
show?: boolean | FormItemDependenciesCondition<TValues>;
/**
* 任意触发都会执行
* @deprecated Use `dependencies.resolve` instead.
*/
trigger?: FormItemDependenciesCondition<TValues, void>;
}
export interface FormItemDependenciesResolve<
TValues extends FormValues = FormValues,
> extends FormItemDependenciesBase {
componentProps?: never;
disabled?: never;
if?: never;
required?: never;
resolve: (
context: FormDependenciesResolveContext<TValues>,
) =>
| FormDependenciesResolvedState
| PromiseLike<FormDependenciesResolvedState | undefined>
| undefined;
rules?: never;
show?: never;
trigger?: never;
}
export type FormItemDependencies<TValues extends FormValues = FormValues> =
| FormItemDependenciesLegacy<TValues>
| FormItemDependenciesResolve<TValues>;
type ComponentProps<TValues extends FormValues = FormValues> =
| ((ctx: FormSchemaContext<TValues>) => MaybeComponentProps)
| MaybeComponentProps;
export interface FormCommonConfig<TValues extends FormValues = FormValues> {
/**
* 是否启用 change 事件兼容回退。
* 仅当组件不发送 update:*、只发送 change 时启用。
* @default false
*/
changeEventFallback?: boolean;
/**
* 是否可折叠的
* @default false
*/
collapsible?: boolean;
/**
* 在Label后显示一个冒号
*/
colon?: boolean;
/**
* 所有表单项的props
*/
componentProps?: ComponentProps<TValues>;
/**
* 所有表单项的控件样式
*/
controlClass?: string;
/**
* 默认折叠
* @default false
*/
defaultCollapsed?: boolean;
/**
* 所有表单项的禁用状态
* @default false
*/
disabled?: boolean;
/**
* 所有表单项的空状态值,默认都是undefinednaive-ui的空状态值是null
*/
emptyStateValue?: null | undefined;
/**
* 所有表单项的控件样式
* @default {}
*/
formFieldProps?: FormFieldOptions;
/**
* 所有表单项的栅格布局,支持函数形式
* @default ""
*/
formItemClass?: (() => string) | string;
/**
* 隐藏所有表单项label
* @default false
*/
hideLabel?: boolean;
/**
* 是否隐藏必填标记
* @default false
*/
hideRequiredMark?: boolean;
/**
* 所有表单项的label样式
* @default ""
*/
labelClass?: string;
/**
* 所有表单项的label宽度
* 设置为 `auto` 时,水平布局下会按当前表单可见 label 的最大宽度自动对齐
*/
labelWidth?: number | string;
/**
* 所有表单项的model属性名
* @default "modelValue"
*/
modelPropName?: string;
/**
* 所有表单项的wrapper样式
*/
wrapperClass?: string;
}
type RenderComponentContentType<TValues extends FormValues = FormValues> = (
ctx: FormSchemaContext<TValues>,
) => Record<string, any>;
type MappedComponentProps<P, TValues extends FormValues = FormValues> =
| ((ctx: FormSchemaContext<TValues>) => P & Record<string, any>)
| (P & Record<string, any>);
/**
* 格式化 `getValues()` 输出中的当前字段值。
* - 返回 `undefined`:保留当前字段已被移除的状态,通常配合 `setValue(key, nextValue)`
* 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime`
* - 返回其他值:会将当前字段恢复/写回为该返回值
* - `setValue` 回调签名为 `(key, nextValue) => void`
* @deprecated Use the form-level `codec` instead.
*/
export type FormValueFormat<TValues extends FormValues = FormValues> = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: TValues,
ctx?: FormSchemaContext<TValues>,
) => any;
interface FormSchemaBody<TValues extends FormValues = FormValues> extends Omit<
FormCommonConfig<TValues>,
'componentProps'
> {
/** 默认值 */
defaultValue?: any;
/** 依赖 */
dependencies?: FormItemDependencies<TValues>;
/** 描述 */
description?: CustomRenderType;
/** 字段名 */
fieldName: string;
/** 帮助信息 */
help?: CustomParamsRenderType<TValues>;
/** 是否隐藏表单项 */
hide?: boolean;
/** 表单项 */
label?: CustomRenderType;
// 自定义组件内部渲染
renderComponentContent?: RenderComponentContentType<TValues>;
/** 字段规则 */
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
/**
* 获取表单值时格式化当前字段。
* - 返回值不为 `undefined` 时,会回写到当前 fieldName
* - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段
* @deprecated Use the form-level `codec` instead.
*/
valueFormat?: FormValueFormat<TValues>;
}
type FormSchemaDiscriminated<
T extends BaseFormComponentType,
P extends Record<string, any>,
TValues extends FormValues,
> = {
[K in Extract<keyof P, T>]: {
/** 组件 */
component: K;
/** 组件参数 */
componentProps?: MappedComponentProps<P[K], TValues>;
} & FormSchemaBody<TValues>;
}[Extract<keyof P, T>];
type FormSchemaFallback<
T extends BaseFormComponentType,
TValues extends FormValues,
> = {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps<TValues>;
} & FormSchemaBody<TValues>;
type FormArraySchema<
T extends BaseFormComponentType,
P extends Record<string, any>,
TValues extends FormValues,
> = {
/** 内置数组编辑器参数 */
arrayProps?: Omit<
VbenFormFieldArrayProps<T, P, TValues>,
'disabled' | 'globalCommonConfig' | 'name' | 'schema'
>;
/** 数组子字段定义 */
children: FormSchema<T, P, TValues>[];
/** 兼容显式指定内置数组编辑器 */
component?: Component | T;
/** 兼容通过 componentProps 传递数组编辑器参数 */
componentProps?: ComponentProps<TValues>;
/** 数组字段标记 */
type: 'array';
} & FormSchemaBody<TValues>;
export type FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> =
| FormArraySchema<T, P, TValues>
| FormSchemaDiscriminated<T, P, TValues>
| FormSchemaFallback<T, TValues>;
/**
* 数组编辑器(VbenFormFieldArray)的组件参数
*/
export interface VbenFormFieldArrayProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> {
/** 操作列表头文案 */
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig<TValues>;
/** 新增一行时生成的默认数据;缺省时按列定义的 fieldName 生成空对象 */
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig<TValues>;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/** 数组字段路径,由外层 FormField 透传 */
name?: string;
/** 列定义,每一列是一个子字段(复用 FormSchema */
schema?: FormSchema<T, P, TValues>[];
/** 是否显示序号列 */
showIndex?: boolean;
}
export type HandleSubmitFn<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = (
values: NoInfer<TSubmitValues>,
rawValues: Readonly<TFormValues>,
) => Promise<void> | void;
export type HandleResetFn<TSubmitValues extends FormValues = FormValues> = (
values: TSubmitValues,
) => Promise<void> | void;
/** @deprecated Use the form-level `codec` instead. */
export type FieldMappingTimeItem = [
string,
[string, string],
(
| ((value: any, fieldName: string) => any)
| [string, string]
| null
| string
)?,
];
/** @deprecated Use the form-level `codec` instead. */
export type FieldMappingTime = FieldMappingTimeItem[];
/** @deprecated Use the form-level `codec` instead. */
export type ArrayToStringFields = Array<
| [string[], string?] // 嵌套数组格式,可选分隔符
| string // 单个字段,使用默认分隔符
| string[] // 简单数组格式,最后一个元素可以是分隔符
>;
export interface FormFieldProps<
T extends BaseFormComponentType = BaseFormComponentType,
TValues extends FormValues = FormValues,
> extends FormSchemaBody<TValues> {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps<TValues>;
}
export interface FormRenderProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> {
/**
* 表单字段数组映射字符串配置 默认使用","
* @deprecated Use the form-level `codec` instead.
*/
arrayToStringFields?: ArrayToStringFields;
/**
* 是否折叠,在showCollapseButton=true下生效
* true:折叠 false:展开
*/
collapsed?: boolean;
/**
* 折叠时保持行数
* @default 1
*/
collapsedRows?: number;
/**
* 是否触发resize事件
* @default false
*/
collapseTriggerResize?: boolean;
/**
* 表单项通用后备配置,当子项目没配置时使用这里的配置,子项目配置优先级高于此配置
*/
commonConfig?: FormCommonConfig<TValues>;
/**
* 紧凑模式(移除表单每一项底部为校验信息预留的空间)
*/
compact?: boolean;
/**
* 组件v-model事件绑定
*/
componentBindEventMap?: Partial<Record<BaseFormComponentType, string>>;
/**
* 组件集合
*/
componentMap: Record<BaseFormComponentType, Component>;
/**
* 表单字段映射到时间格式
* @deprecated Use the form-level `codec` instead.
*/
fieldMappingTime?: FieldMappingTime;
/**
* 表单实例
*/
form?: FormActions<TValues>;
/**
* 表单项布局
*/
layout?: FormLayout;
/**
* 表单定义
*/
schema?: FormSchema<T, P, TValues>[];
/**
* 是否显示展开/折叠
*/
showCollapseButton?: boolean;
/**
* 格式化日期
*/
/**
* 表单栅格布局
* @default "grid-cols-1"
*/
wrapperClass?: WrapperClassType;
}
export interface ActionButtonOptions extends VbenButtonProps {
[key: string]: any;
content?: MaybeComputedRef<string>;
show?: boolean;
}
export interface VbenFormProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TValues,
> extends Omit<
FormRenderProps<T, P, TValues>,
'componentBindEventMap' | 'componentMap' | 'form'
> {
/**
* 操作按钮是否反转(提交按钮前置)
*/
actionButtonsReverse?: boolean;
/**
* 操作按钮组的样式
* newLine: 在新行显示。rowEnd: 在行内显示,靠右对齐(默认)。inline: 使用grid默认样式
*/
actionLayout?: 'inline' | 'newLine' | 'rowEnd';
/**
* 操作按钮组显示位置,默认靠右显示
*/
actionPosition?: 'center' | 'left' | 'right';
/**
* 表单操作区域class
*/
actionWrapperClass?: ClassType;
/**
* 表单字段数组映射字符串配置 默认使用","
* @deprecated Use the form-level `codec` instead.
*/
arrayToStringFields?: ArrayToStringFields;
/**
* submitOnChange改变时防抖时间 | 默认300ms
*/
changeDebouncedTime?: number;
/** 表单组件值与提交值之间的双向编解码器。 */
codec?: FormCodec<TValues, TSubmitValues>;
/**
* 表单字段映射
* @deprecated Use the form-level `codec` instead.
*/
fieldMappingTime?: FieldMappingTime;
/**
* 表单收起展开状态变化回调
*/
handleCollapsedChange?: (collapsed: boolean) => void;
/**
* 表单重置回调
*/
handleReset?: HandleResetFn<NoInfer<TSubmitValues>>;
/**
* 表单提交回调
*/
handleSubmit?: HandleSubmitFn<TValues, TSubmitValues>;
/**
* 表单值变化回调
*/
handleValuesChange?: (
values: Readonly<TValues>,
fieldsChanged: string[],
getFormattedValues: () => TSubmitValues,
) => void;
/**
* 重置按钮参数
*/
resetButtonOptions?: ActionButtonOptions;
/**
* 验证失败时是否自动滚动到第一个错误字段
* @default false
*/
scrollToFirstError?: boolean;
/**
* 是否显示默认操作按钮
* @default true
*/
showDefaultActions?: boolean;
/**
* 提交按钮参数
*/
submitButtonOptions?: ActionButtonOptions;
/**
* 是否在字段值改变时提交表单
* @default false
*/
submitOnChange?: boolean;
/**
* 是否在回车时提交表单
* @default false
*/
submitOnEnter?: boolean;
}
export type ExtendedFormApi<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> = FormApi<TValues, T, P, TSubmitValues> & {
useStore: <TResult = NoInfer<VbenFormProps<T, P, TValues, TSubmitValues>>>(
selector?: (
state: NoInfer<VbenFormProps<T, P, TValues, TSubmitValues>>,
) => TResult,
) => Readonly<Ref<TResult>>;
};
export interface VbenFormAdapterOptions<
T extends BaseFormComponentType = BaseFormComponentType,
> {
config?: {
baseModelPropName?: string;
/**
* 是否启用 change 事件兼容回退。
* 仅用于只发送 change 的兼容组件。
* @default false
*/
changeEventFallback?: boolean;
emptyStateValue?: null | undefined;
modelPropNameMap?: Partial<Record<T, string>>;
};
/** @deprecated Use `rules` instead. */
defineRules?: Partial<Record<string, FormRuleValidator>>;
rules?: Partial<Record<string, FormRuleValidator>>;
}
export interface FormRuleContext {
field: {
label?: string;
name: string;
};
label?: string;
name: string;
}
export type FormRuleValidator = (
value: any,
params: any,
context: FormRuleContext,
) => boolean | Promise<boolean | string> | string;
@@ -0,0 +1,99 @@
import type { ZodType } from 'zod';
import type { ComputedRef } from 'vue';
import type { ExtendedFormApi, FormActions, VbenFormProps } from './types';
import { computed, toRaw, unref, useSlots } from 'vue';
import { createContext } from '@vben-core/shadcn-ui';
import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
import { getDefaultsForSchema } from 'zod-defaults';
import { useFormRuntime } from './form-runtime';
type ExtendFormProps = VbenFormProps & {
formApi?: ExtendedFormApi<any, any, any>;
};
export const [injectFormProps, provideFormProps] =
createContext<[ComputedRef<ExtendFormProps> | ExtendFormProps, FormActions]>(
'VbenFormProps',
);
export const [injectComponentRefMap, provideComponentRefMap] =
createContext<Map<string, unknown>>('ComponentRefMap');
export function useFormInitial(
props: ComputedRef<VbenFormProps> | VbenFormProps,
) {
const slots = useSlots();
const initialValues = generateInitialValues();
const form = useFormRuntime(initialValues);
const delegatedSlots = computed(() => {
const resultSlots: string[] = [];
for (const key of Object.keys(slots)) {
if (key !== 'default') {
resultSlots.push(key);
}
}
return resultSlots;
});
function generateInitialValues() {
const initialValues: Record<string, any> = {};
const zodObject: Record<string, ZodType> = {};
(unref(props).schema || []).forEach((item) => {
if (Reflect.has(item, 'defaultValue')) {
set(initialValues, item.fieldName, item.defaultValue);
} else if (item.rules && !isString(item.rules)) {
// 检查规则是否适合提取默认值
const rawRules = toRaw(item.rules);
const customDefaultValue = getCustomDefaultValue(rawRules);
zodObject[item.fieldName] = rawRules;
if (customDefaultValue !== undefined) {
initialValues[item.fieldName] = customDefaultValue;
}
}
});
const schemaInitialValues = getDefaultsForSchema(object(zodObject));
const zodDefaults: Record<string, any> = {};
for (const key in schemaInitialValues) {
set(zodDefaults, key, schemaInitialValues[key]);
}
return mergeWithArrayOverride(initialValues, zodDefaults);
}
// 自定义默认值提取逻辑
function getCustomDefaultValue(rule: any): any {
rule = toRaw(rule);
if (rule instanceof ZodString) {
return ''; // 默认为空字符串
} else if (rule instanceof ZodNumber) {
return null; // 默认为 null(避免显示 0)
} else if (rule instanceof ZodObject) {
// 递归提取嵌套对象的默认值
const defaultValues: Record<string, any> = {};
for (const [key, valueSchema] of Object.entries(rule.shape)) {
defaultValues[key] = getCustomDefaultValue(valueSchema);
}
return defaultValues;
} else if (rule instanceof ZodIntersection) {
return getDefaultsForSchema(rule);
} else {
return undefined; // 其他类型不提供默认值
}
}
return {
delegatedSlots,
form,
};
}
@@ -0,0 +1,81 @@
import type {
BaseFormComponentType,
ExtendedFormApi,
FormValues,
VbenFormComponent,
VbenFormProps,
} from './types';
import { defineComponent, h, isReactive, onBeforeUnmount, watch } from 'vue';
import { useSelector } from '@vben-core/shared/store';
import { FormApi } from './form-api';
import VbenUseForm from './vben-use-form.vue';
type UseVbenFormReturn<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
TSubmitValues extends FormValues = TValues,
> = readonly [
VbenFormComponent<TValues, T, P, TSubmitValues>,
ExtendedFormApi<TValues, T, P, TSubmitValues>,
];
export function useVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
>(options: VbenFormProps<T, P>): UseVbenFormReturn<FormValues, T, P>;
export function useVbenForm<
TValues extends FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
>(
options: VbenFormProps<T, P, TValues, TSubmitValues>,
): UseVbenFormReturn<TValues, T, P, TSubmitValues>;
export function useVbenForm(
options: VbenFormProps<any, any, any, any>,
): UseVbenFormReturn<any, any, any, any> {
const IS_REACTIVE = isReactive(options);
const api = new FormApi<any, any, any, any>(options);
const extendedApi = api as ExtendedFormApi<any, any, any, any>;
extendedApi.useStore = (selector: any) => {
return useSelector(api.store, selector);
};
const Form = defineComponent(
(props: VbenFormProps, { attrs, slots }) => {
onBeforeUnmount(() => {
api.unmount();
});
api.setState({ ...props, ...attrs });
return () =>
h(VbenUseForm, { ...props, ...attrs, formApi: extendedApi }, slots);
},
{
name: 'VbenUseForm',
inheritAttrs: false,
},
);
// Add reactivity support
if (IS_REACTIVE) {
watch(
() => options.schema,
() => {
api.setState({ schema: options.schema });
},
{ immediate: true },
);
}
return [Form, extendedApi] as unknown as UseVbenFormReturn<
any,
any,
any,
any
>;
}
@@ -0,0 +1,79 @@
<script setup lang="ts">
import type { VbenFormProps } from './types';
import { ref, watchEffect } from 'vue';
import { useForwardPropsEmits } from '@vben-core/composables';
import FormActions from './components/form-actions.vue';
import {
COMPONENT_BIND_EVENT_MAP,
COMPONENT_MAP,
DEFAULT_FORM_COMMON_CONFIG,
} from './config';
import { Form } from './form-render';
import { provideFormProps, useFormInitial } from './use-form-context';
// 通过 extends 会导致热更新卡死
interface Props extends VbenFormProps {}
const props = withDefaults(defineProps<Props>(), {
actionWrapperClass: '',
collapsed: false,
collapsedRows: 1,
commonConfig: () => ({}),
handleReset: undefined,
handleSubmit: undefined,
layout: 'horizontal',
resetButtonOptions: () => ({}),
showCollapseButton: false,
showDefaultActions: true,
submitButtonOptions: () => ({}),
wrapperClass: 'grid-cols-1',
});
const forward = useForwardPropsEmits(props);
const currentCollapsed = ref(false);
const { delegatedSlots, form } = useFormInitial(props);
provideFormProps([props, form]);
const handleUpdateCollapsed = (value: boolean) => {
currentCollapsed.value = value;
// 触发收起展开状态变化回调
props.handleCollapsedChange?.(value);
};
watchEffect(() => {
currentCollapsed.value = props.collapsed;
});
</script>
<template>
<Form
v-bind="forward"
:collapsed="currentCollapsed"
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
:component-map="COMPONENT_MAP"
:form="form"
:global-common-config="DEFAULT_FORM_COMMON_CONFIG"
>
<template
v-for="slotName in delegatedSlots"
:key="slotName"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps"></slot>
</template>
<template #default="slotProps">
<slot v-bind="slotProps">
<FormActions
v-if="showDefaultActions"
:model-value="currentCollapsed"
@update:model-value="handleUpdateCollapsed"
/>
</slot>
</template>
</Form>
</template>
@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { ExtendedFormApi, VbenFormProps, VbenFormSlots } from './types';
import { nextTick, onMounted, readonly, watch } from 'vue';
import { useForwardPriorityValues } from '@vben-core/composables';
import { get, isEqual } from '@vben-core/shared/utils';
import { useDebounceFn } from '@vueuse/core';
import FormActions from './components/form-actions.vue';
import {
COMPONENT_BIND_EVENT_MAP,
COMPONENT_MAP,
DEFAULT_FORM_COMMON_CONFIG,
} from './config';
import { Form } from './form-render';
import {
provideComponentRefMap,
provideFormProps,
useFormInitial,
} from './use-form-context';
// 通过 extends 会导致热更新卡死,所以重复写了一遍
interface Props extends VbenFormProps {
formApi?: ExtendedFormApi<any, any, any, any>;
}
const props = defineProps<Props>();
defineSlots<
Record<string, ((props: Record<string, any>) => any) | undefined> &
VbenFormSlots<any, any, any>
>();
const formApi = props.formApi;
if (!formApi) {
throw new Error('Form api is required in <VbenUseForm />');
}
const state = formApi.useStore();
const forward = useForwardPriorityValues(props, state);
const componentRefMap = new Map<string, unknown>();
const { delegatedSlots, form } = useFormInitial(forward);
const values = form.useValues();
provideFormProps([forward, form]);
provideComponentRefMap(componentRefMap);
formApi.mount(form, componentRefMap);
function handleUpdateCollapsed(value: boolean) {
props.formApi?.setState({ collapsed: value });
// 触发收起展开状态变化回调
forward.value.handleCollapsedChange?.(value);
}
function handleKeyDownEnter(event: KeyboardEvent) {
if (!state?.value.submitOnEnter || !forward.value.formApi?.isMounted) {
return;
}
// 如果是 textarea 不阻止默认行为,否则会导致无法换行。
// 跳过 textarea 的回车提交处理
if (event.target instanceof HTMLTextAreaElement) {
return;
}
event.preventDefault();
forward.value.formApi?.validateAndSubmit();
}
const handleValuesChangeDebounced = useDebounceFn(async () => {
state?.value.submitOnChange && forward.value.formApi?.validateAndSubmit();
}, state?.value?.changeDebouncedTime ?? 300);
let valuesChangeReady = false;
onMounted(async () => {
// 只在挂载后开始监听,form.values会有一个初始化的过程
await nextTick();
valuesChangeReady = true;
});
watch(values, (currentValues, previousValues) => {
if (!valuesChangeReady) {
return;
}
const handleValuesChange = forward.value.handleValuesChange;
const submitOnChange = state?.value.submitOnChange;
if (!handleValuesChange && !submitOnChange) {
return;
}
const fields = state?.value.schema?.map((item) => item.fieldName) ?? [];
if (handleValuesChange && fields.length > 0) {
const changedFields = fields.filter((field) => {
return !isEqual(
get(currentValues, field),
get(previousValues ?? {}, field),
);
});
if (changedFields.length > 0) {
handleValuesChange(readonly(currentValues), changedFields, () =>
formApi.formatValues(currentValues),
);
}
}
if (submitOnChange) {
handleValuesChangeDebounced();
}
});
</script>
<template>
<Form
@keydown.enter="handleKeyDownEnter"
v-bind="forward"
:collapsed="state?.collapsed"
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
:component-map="COMPONENT_MAP"
:form="form"
:global-common-config="DEFAULT_FORM_COMMON_CONFIG"
>
<template
v-for="slotName in delegatedSlots"
:key="slotName"
#[slotName]="slotProps"
>
<slot
:name="slotName"
v-bind="slotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
<template #default="slotProps">
<slot
v-if="$slots.default"
v-bind="slotProps"
:form-api="formApi"
:values="form.values"
></slot>
<FormActions
v-else-if="forward.showDefaultActions"
:model-value="state?.collapsed"
@update:model-value="handleUpdateCollapsed"
>
<template #reset-before="resetSlotProps">
<slot
name="reset-before"
v-bind="resetSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
<template #submit-before="submitSlotProps">
<slot
name="submit-before"
v-bind="submitSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
<template #expand-before="expandBeforeSlotProps">
<slot
name="expand-before"
v-bind="expandBeforeSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
<template #expand-after="expandAfterSlotProps">
<slot
name="expand-after"
v-bind="expandAfterSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
</FormActions>
</template>
</Form>
</template>
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src", "__tests__"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,21 @@
import { defineConfig } from 'tsdown';
import Vue from 'unplugin-vue/rolldown';
export default defineConfig({
clean: true,
deps: {
skipNodeModulesBundle: true,
},
dts: {
vue: true,
},
entry: ['src/index.ts'],
format: ['esm'],
outExtensions: () => ({
dts: '.d.ts',
js: '.mjs',
}),
platform: 'neutral',
plugins: [Vue({ isProduction: true })],
unbundle: true,
});