gengx
This commit is contained in:
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
+190
@@ -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;
|
||||
/**
|
||||
* 所有表单项的空状态值,默认都是undefined,naive-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,
|
||||
});
|
||||
Reference in New Issue
Block a user