refactor(@vben-core/form-ui): migrate to Zod 4 and TanStack Form (#8176)

* build(@vben-core/form-ui): update form validation dependencies

* refactor(@vben-core/form-ui): replace vee-validate with TanStack Form

* test(@vben-core/form-ui): cover TanStack Form migration

* docs(@vben/docs): document Zod 4 form migration

* refactor(project): update form adapters

* refactor(project): migrate application form consumers

* refactor(@vben/playground): migrate form examples

* docs(@vben/docs): fix validate return type and required rule empty check in form docs

* fix(@vben-core/form-ui): resolve oxlint and eslint errors

- rewrite nested ternary expressions as if/else in form-field-array.vue and form-runtime.ts
- sort @tanstack/vue-form before @vben-core/composables in package.json

* chore: fix merge artifacts and formatting

- move dependencies before devDependencies in root package.json
- update preferences snapshot for widget positioning fields
- format form-array demo README

* refactor(@vben-core/form-ui): optimize form runtime and value flow

- add fine-grained field selectors and atomic dependency resolution
- expose raw and formatted value snapshots with focused regression coverage

* fix(@vben/layouts): dispose sortable instance on unmount

* docs(@vben/docs): document form runtime API changes

- list added, changed, removed, and deprecated form APIs
- document atomic dependencies and raw versus formatted values
This commit is contained in:
dream-weave
2026-07-22 19:47:07 +08:00
committed by GitHub
parent 6b6708bcf2
commit 28757fb9c8
81 changed files with 4683 additions and 1435 deletions

View File

@@ -98,6 +98,11 @@ describe('formApi', () => {
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);
});
@@ -159,24 +164,59 @@ describe('formApi', () => {
);
});
it('should reset form', async () => {
const resetFormMock = vi.fn();
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: {},
resetForm: resetFormMock,
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.resetForm();
expect(resetFormMock).toHaveBeenCalled();
await formApi.reset();
expect(resetMock).toHaveBeenCalled();
});
it('should call handleSubmit on submit', async () => {
const handleSubmitMock = vi.fn();
const formActions: any = {
meta: {},
submitForm: vi.fn().mockResolvedValue(true),
submit: vi.fn().mockResolvedValue(true),
values: { name: 'test' },
};
@@ -187,9 +227,12 @@ describe('formApi', () => {
formApi.setState(state);
await formApi.mount(formActions, new Map());
const result = await formApi.submitForm();
expect(formActions.submitForm).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith({ name: 'test' });
const result = await formApi.submit();
expect(formActions.submit).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith(
{ name: 'test' },
{ name: 'test' },
);
expect(result).toEqual({ name: 'test' });
});
@@ -243,6 +286,48 @@ describe('formApi', () => {
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', () => {

View File

@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setupVbenForm } from '../src/config';
import {
resetDeprecationWarnings,
warnDeprecatedOnce,
} from '../src/deprecation';
import { FormApi } from '../src/form-api';
import { getFormRule } from '../src/rule-registry';
afterEach(() => {
resetDeprecationWarnings();
vi.restoreAllMocks();
});
describe('form api compatibility', () => {
it('forwards defineRules and warns only once in development', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const legacyRule = () => 'legacy error';
setupVbenForm({ defineRules: { legacy: legacyRule } });
setupVbenForm({ defineRules: { legacy: legacyRule } });
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `setupVbenForm({ defineRules })` is deprecated. Use `setupVbenForm({ rules })` instead.',
);
const registeredRule = getFormRule('legacy');
expect(registeredRule).toBeDefined();
if (!registeredRule) return;
expect(
await registeredRule('', [], {
field: { name: 'legacy' },
name: 'legacy',
}),
).toBe('legacy error');
});
it('prefers the new rules option when both APIs define the same rule', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
setupVbenForm({
defineRules: { required: () => 'legacy error' },
rules: { required: () => 'new error' },
});
const registeredRule = getFormRule('required');
expect(registeredRule).toBeDefined();
if (!registeredRule) return;
expect(
await registeredRule('', [], {
field: { name: 'required' },
name: 'required',
}),
).toBe('new error');
});
it('does not emit deprecation warnings in production', () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
warnDeprecatedOnce('legacy-api', 'deprecated', { production: true });
expect(warning).not.toHaveBeenCalled();
});
it('keeps legacy form methods and warns once for each name', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi();
const form = {
clearValidation: vi.fn(),
meta: {},
reset: vi.fn(),
submit: vi.fn(),
validate: vi.fn().mockResolvedValue({ errors: {}, valid: true }),
values: { name: 'Ada' },
} as any;
formApi.mount(form);
await formApi.resetForm();
await formApi.resetForm();
await formApi.resetValidate();
await formApi.submitForm();
await formApi.validateAndSubmitForm();
expect(form.reset).toHaveBeenCalledTimes(2);
expect(form.clearValidation).toHaveBeenCalledOnce();
expect(form.submit).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledTimes(4);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.resetForm()` is deprecated. Use `formApi.reset()` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.resetValidate()` is deprecated. Use `formApi.clearValidation()` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.submitForm()` is deprecated. Use `formApi.submit()` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.validateAndSubmitForm()` is deprecated. Use `formApi.validateAndSubmit()` instead.',
);
});
});

View File

@@ -0,0 +1,656 @@
import type { VueWrapper } from '@vue/test-utils';
import type { FormSchemaRuleType } from '../src/types';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { setupVbenForm } from '../src/config';
import { resetDeprecationWarnings } from '../src/deprecation';
import { useVbenForm } from '../src/use-vben-form';
const wrappers: VueWrapper[] = [];
function createDeferred<T>() {
let resolvePromise: (value: T) => void = () => {};
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: resolvePromise };
}
const TestInput = defineComponent({
inheritAttrs: false,
props: {
eventMode: {
default: 'model-value',
type: String,
},
},
emits: ['change', 'update:modelValue', 'update:value'],
setup(props, { attrs, emit }) {
function handleInput(event: Event) {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
if (props.eventMode === 'change-only') {
emit('change', event);
return;
}
if (props.eventMode === 'value-and-change') {
emit('update:value', target.value);
emit('change', event);
return;
}
emit('update:modelValue', target.value);
}
return () =>
h('input', {
...attrs,
onInput: handleInput,
value: attrs.modelValue ?? '',
});
},
});
beforeAll(() => {
setupVbenForm({
config: {},
rules: {
required(value, _params, context) {
return value ? true : `${context.label} is required`;
},
},
});
});
afterEach(() => {
for (const wrapper of wrappers.splice(0)) {
wrapper.unmount();
}
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('useVbenForm integration', () => {
it('uses model updates as the primary channel and preserves empty strings', async () => {
const validateValue = vi.fn();
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
componentProps: { eventMode: 'value-and-change' },
defaultValue: 'initial',
fieldName: 'name',
modelPropName: 'value',
rules: z.string().superRefine((value) => validateValue(value)),
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
const initialValidationCount = validateValue.mock.calls.length;
await wrapper.get('input').setValue('');
await flushPromises();
expect(await formApi.getValues()).toEqual({ name: '' });
expect(validateValue).toHaveBeenCalledTimes(initialValidationCount + 1);
});
it('supports a field-level change event fallback for legacy components', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
componentProps: { eventMode: 'change-only' },
changeEventFallback: true,
fieldName: 'name',
modelPropName: 'value',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await wrapper.get('input').setValue('fallback');
await flushPromises();
expect(await formApi.getValues()).toEqual({ name: 'fallback' });
});
it('warns once for legacy dependency callbacks', async () => {
resetDeprecationWarnings();
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const [Form] = useVbenForm({
schema: [
{
component: TestInput,
dependencies: {
show: true,
triggerFields: ['toggle'],
},
fieldName: 'first',
},
{
component: TestInput,
dependencies: {
disabled: false,
triggerFields: ['toggle'],
},
fieldName: 'second',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'[Vben Form] Legacy dependency callbacks are deprecated. Use `dependencies.resolve(context)` instead.',
);
});
it('binds fields, renders accessible errors, and submits valid values', async () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const handleSubmit = vi.fn();
const [Form, formApi] = useVbenForm({
handleSubmit,
schema: [
{
component: TestInput,
fieldName: 'name',
label: 'Name',
rules: z.string().min(1, 'Name is required'),
valueFormat: (value) => value.trim(),
},
{
component: TestInput,
fieldName: 'alias',
label: 'Alias',
rules: 'required',
},
],
});
const wrapper = mount(Form, { attachTo: document.body });
wrappers.push(wrapper);
await flushPromises();
expect(await formApi.validate()).toEqual({
errors: {
alias: 'Alias is required',
name: 'Name is required',
},
valid: false,
});
await flushPromises();
const inputs = wrapper.findAll('input');
expect(inputs).toHaveLength(2);
expect(inputs[0]?.attributes('aria-invalid')).toBe('true');
expect(wrapper.text()).toContain('Name is required');
expect(wrapper.text()).toContain('Alias is required');
await inputs[0]?.setValue('Ada');
await formApi.setFieldValue('alias', 'Countess', true);
await flushPromises();
expect(wrapper.text()).not.toContain('Name is required');
expect(await formApi.validateField('name')).toEqual({
errors: {},
valid: true,
});
expect(await formApi.validateAndSubmit()).toEqual({
alias: 'Countess',
name: 'Ada',
});
expect(handleSubmit).toHaveBeenCalledOnce();
expect(handleSubmit).toHaveBeenCalledWith(
{
alias: 'Countess',
name: 'Ada',
},
{
alias: 'Countess',
name: 'Ada',
},
);
expect(consoleError).not.toHaveBeenCalled();
});
it('recomputes dependencies only from declared trigger fields', async () => {
const dependency = vi.fn((values: Record<string, any>) => {
return values.toggle === 'show';
});
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'toggle',
label: 'Toggle',
},
{
component: TestInput,
dependencies: {
if: dependency,
triggerFields: ['toggle'],
},
fieldName: 'details',
label: 'Details',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(wrapper.find('input[name="details"]').exists()).toBe(false);
const initialCalls = dependency.mock.calls.length;
await formApi.setFieldValue('unrelated', 'value');
await flushPromises();
expect(dependency).toHaveBeenCalledTimes(initialCalls);
await formApi.setFieldValue('toggle', 'show');
await flushPromises();
expect(wrapper.find('input[name="details"]').exists()).toBe(true);
expect(dependency.mock.calls.length).toBeGreaterThan(initialCalls);
});
it('resolves dependency patches atomically from declared fields', async () => {
const pendingPatch = createDeferred<{
componentProps: { placeholder: string };
if: boolean;
}>();
const resolve = vi.fn(({ values }: { values: Record<string, any> }) => {
if (values.toggle === 'pending') {
return pendingPatch.promise;
}
return {
componentProps: { placeholder: 'initial' },
if: false,
};
});
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'toggle',
label: 'Toggle',
},
{
component: TestInput,
dependencies: {
resolve,
triggerFields: ['toggle'],
},
fieldName: 'details',
label: 'Details',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(wrapper.find('input[name="details"]').exists()).toBe(false);
const initialCalls = resolve.mock.calls.length;
await formApi.setFieldValue('unrelated', 'value');
await flushPromises();
expect(resolve).toHaveBeenCalledTimes(initialCalls);
await formApi.setFieldValue('toggle', 'pending');
await flushPromises();
expect(wrapper.find('input[name="details"]').exists()).toBe(false);
pendingPatch.resolve({
componentProps: { placeholder: 'resolved' },
if: true,
});
await flushPromises();
const details = wrapper.find('input[name="details"]');
expect(details.exists()).toBe(true);
expect(details.attributes('placeholder')).toBe('resolved');
});
it('applies required rules enabled by dependencies after mount', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'toggle',
label: 'Toggle',
},
{
component: TestInput,
dependencies: {
required(values) {
return values.toggle === true;
},
triggerFields: ['toggle'],
},
fieldName: 'details',
label: 'Details',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(await formApi.validate()).toEqual({ errors: {}, valid: true });
await formApi.setFieldValue('toggle', true);
await flushPromises();
expect(await formApi.validate()).toEqual({
errors: { details: 'Details is required' },
valid: false,
});
await formApi.setFieldValue('details', 'ready');
expect(await formApi.validate()).toEqual({ errors: {}, valid: true });
});
it('allows dependencies to disable static rules with null', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'toggle',
label: 'Toggle',
},
{
component: TestInput,
dependencies: {
rules(values) {
return values.toggle === true
? z.string().min(1, 'Details is required')
: null;
},
triggerFields: ['toggle'],
},
fieldName: 'details',
label: 'Details',
rules: z.string().min(1, 'Static details rule'),
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(await formApi.validate()).toEqual({ errors: {}, valid: true });
await formApi.setFieldValue('toggle', true);
await flushPromises();
expect(await formApi.validate()).toEqual({
errors: { details: 'Details is required' },
valid: false,
});
});
it('ignores stale async dependency rule results', async () => {
const requiredRules = createDeferred<FormSchemaRuleType>();
const optionalRules = createDeferred<FormSchemaRuleType>();
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'mode',
label: 'Mode',
},
{
component: TestInput,
dependencies: {
rules(values) {
if (values.mode === 'required') {
return requiredRules.promise;
}
if (values.mode === 'optional') {
return optionalRules.promise;
}
return null;
},
triggerFields: ['mode'],
},
fieldName: 'details',
label: 'Details',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('mode', 'required');
await flushPromises();
await formApi.setFieldValue('mode', 'optional');
await flushPromises();
optionalRules.resolve(null);
await flushPromises();
requiredRules.resolve(z.string().min(1, 'Stale required rule'));
await flushPromises();
expect(await formApi.validate()).toEqual({ errors: {}, valid: true });
});
it('keeps array values and rendered rows aligned after mutations', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{
children: [
{
component: TestInput,
fieldName: 'name',
label: 'Name',
rules: z.string().min(1, 'Name is required'),
},
],
defaultValue: [{ name: 'Ada' }],
fieldName: 'contacts',
type: 'array',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(wrapper.findAll('input')).toHaveLength(1);
formApi.form.pushFieldValue('contacts', { name: 'Grace' });
await flushPromises();
expect(wrapper.findAll('input')).toHaveLength(2);
expect(await formApi.getValues()).toEqual({
contacts: [{ name: 'Ada' }, { name: 'Grace' }],
});
await formApi.form.removeFieldValue('contacts', 0);
await flushPromises();
expect(wrapper.findAll('input')).toHaveLength(1);
expect(await formApi.getValues()).toEqual({
contacts: [{ name: 'Grace' }],
});
});
it('scopes resolve dependencies to array rows', async () => {
const resolve = vi.fn(({ schema }: Record<string, any>) => ({
componentProps: {
disabled: schema.row?.role === 'viewer',
},
}));
const [Form] = useVbenForm({
schema: [
{
children: [
{
component: TestInput,
fieldName: 'role',
label: 'Role',
},
{
component: TestInput,
dependencies: {
resolve,
triggerFields: ['role'],
},
fieldName: 'phone',
label: 'Phone',
},
],
defaultValue: [{ phone: '', role: 'viewer' }],
fieldName: 'contacts',
type: 'array',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(resolve).toHaveBeenCalledWith(
expect.objectContaining({
schema: expect.objectContaining({
fieldName: 'contacts[0].phone',
row: { phone: '', role: 'viewer' },
rowIndex: 0,
rowPath: 'contacts[0]',
}),
}),
);
expect(
wrapper.get('input[name="contacts[0].phone"]').attributes('disabled'),
).toBeDefined();
});
it('reports changed fields and submits valid changes', async () => {
vi.useFakeTimers();
const handleSubmit = vi.fn();
const handleValuesChange = vi.fn();
const [Form, formApi] = useVbenForm({
changeDebouncedTime: 0,
handleSubmit,
handleValuesChange,
schema: [
{
component: TestInput,
fieldName: 'name',
label: 'Name',
rules: z.string().min(1, 'Name is required'),
valueFormat: (value) => value.trim(),
},
],
submitOnChange: true,
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('name', ' Ada ');
await nextTick();
await vi.runAllTimersAsync();
await flushPromises();
expect(handleValuesChange).toHaveBeenCalledWith(
{ name: ' Ada ' },
['name'],
expect.any(Function),
);
const valuesChangeCall = handleValuesChange.mock.calls.at(0);
expect(valuesChangeCall).toBeDefined();
if (!valuesChangeCall) return;
expect(valuesChangeCall[2]()).toEqual({ name: 'Ada' });
expect(handleSubmit).toHaveBeenCalledWith(
{ name: 'Ada' },
{ name: ' Ada ' },
);
});
it('respects blur and change validation triggers', async () => {
const [Form] = useVbenForm({
schema: [
{
component: TestInput,
defaultValue: 'valid',
fieldName: 'name',
formFieldProps: {
validateOn: ['blur'],
},
label: 'Name',
rules: z.string().min(1, 'Name is required'),
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
const input = wrapper.get('input');
await input.setValue('');
await flushPromises();
expect(wrapper.text()).not.toContain('Name is required');
await input.trigger('blur');
await flushPromises();
expect(wrapper.text()).toContain('Name is required');
await input.setValue('Ada');
await flushPromises();
expect(wrapper.text()).not.toContain('Name is required');
await input.trigger('blur');
await flushPromises();
expect(wrapper.text()).not.toContain('Name is required');
});
it('ignores stale asynchronous validation results', async () => {
let resolveTaken: (() => void) | undefined;
const usernameRule = z.string().refine(async (value) => {
if (value === 'taken') {
await new Promise<void>((resolve) => {
resolveTaken = resolve;
});
}
return value !== 'taken';
}, 'Username is already taken');
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'username',
label: 'Username',
rules: usernameRule,
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
const input = wrapper.get('input');
await input.setValue('taken');
await vi.waitFor(() => {
expect(resolveTaken).toBeDefined();
});
if (!resolveTaken) return;
await input.setValue('available');
await flushPromises();
resolveTaken();
await flushPromises();
expect(formApi.form.getFieldError('username')).toBeUndefined();
});
});

View File

@@ -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' });
});
});

View File

@@ -0,0 +1,153 @@
import type {
BaseFormComponentType,
ExtendedFormApi,
FormActions,
FormContextApi,
FormFieldOptions,
FormItemDependencies,
FormValidationResult,
VbenFormAdapterOptions,
VbenFormProps,
} from '../src/types';
import { describe, expectTypeOf, it } from 'vitest';
import { useVbenForm } from '../src/use-vben-form';
interface AccountFormValues {
email: string;
profile: {
nickname: string;
};
roles: string[];
}
describe('form public types', () => {
it('keeps the compatibility alias and stable method signatures', () => {
expectTypeOf<FormActions>().toEqualTypeOf<FormContextApi>();
expectTypeOf<FormActions['setFieldValue']>()
.parameter(0)
.toMatchTypeOf<string>();
expectTypeOf<
FormActions['validate']
>().returns.resolves.toEqualTypeOf<FormValidationResult>();
expectTypeOf<Parameters<FormActions['validate']>>().toEqualTypeOf<[]>();
expectTypeOf<Parameters<FormActions['validateField']>>().toEqualTypeOf<
[fieldName: string]
>();
});
it('accepts both new and deprecated rule registration options', () => {
expectTypeOf<VbenFormAdapterOptions>().toMatchTypeOf<{
defineRules?: Record<string, unknown>;
rules?: Record<string, unknown>;
}>();
});
it('supports resolve and legacy dependency contracts', () => {
const resolveDependencies: FormItemDependencies<AccountFormValues> = {
resolve({ actions, controller, schema, values }) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(actions).toEqualTypeOf<FormActions<AccountFormValues>>();
expectTypeOf(controller).toEqualTypeOf<
ExtendedFormApi<AccountFormValues>
>();
expectTypeOf(schema.fieldName).toEqualTypeOf<string | undefined>();
return { disabled: !values.email, rules: null };
},
triggerFields: ['email'],
};
const legacyDependencies: FormItemDependencies<AccountFormValues> = {
show(values) {
expectTypeOf(values).toEqualTypeOf<Partial<AccountFormValues>>();
return Boolean(values.email);
},
triggerFields: ['email'],
};
const fieldOptions: FormFieldOptions = {
asyncDebounceMs: 200,
validateOn: ['blur', 'change'],
};
expectTypeOf(resolveDependencies).toMatchTypeOf<
FormItemDependencies<AccountFormValues>
>();
expectTypeOf(legacyDependencies).toMatchTypeOf<
FormItemDependencies<AccountFormValues>
>();
expectTypeOf(fieldOptions).toMatchTypeOf<FormFieldOptions>();
});
it('propagates form value types through public APIs and callbacks', () => {
const options: VbenFormProps<
BaseFormComponentType,
Record<never, never>,
AccountFormValues
> = {
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountFormValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
handleValuesChange(values, _fieldsChanged, getFormattedValues) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(getFormattedValues()).toEqualTypeOf<AccountFormValues>();
},
schema: [],
};
const [Form, formApi] = useVbenForm<AccountFormValues>(options);
expectTypeOf(formApi).toEqualTypeOf<ExtendedFormApi<AccountFormValues>>();
function assertContextApi(
contextApi: FormContextApi<AccountFormValues>,
typedFormApi: ExtendedFormApi<AccountFormValues>,
) {
expectTypeOf(
typedFormApi.getValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(
typedFormApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(typedFormApi.getValueSnapshot()).resolves.toEqualTypeOf<{
rawValues: Readonly<AccountFormValues>;
values: AccountFormValues;
}>();
expectTypeOf(typedFormApi.setValues)
.parameter(0)
.toEqualTypeOf<Partial<AccountFormValues>>();
expectTypeOf(typedFormApi.form.values).toEqualTypeOf<AccountFormValues>();
expectTypeOf(contextApi.getFieldValue('email')).toEqualTypeOf<string>();
expectTypeOf(
contextApi.useSelector((state) => state.values.profile.nickname),
).toEqualTypeOf<Readonly<import('vue').Ref<string>>>();
}
expectTypeOf(assertContextApi).toBeFunction();
type FormSlots = InstanceType<typeof Form>['$slots'];
type EmailSlot = NonNullable<FormSlots['email']>;
type EmailSlotProps = Parameters<EmailSlot>[0];
type DefaultSlot = NonNullable<FormSlots['default']>;
type DefaultSlotProps = Parameters<DefaultSlot>[0];
expectTypeOf<
EmailSlotProps['field']['state']['value']
>().toEqualTypeOf<string>();
expectTypeOf<EmailSlotProps['values']>().toEqualTypeOf<AccountFormValues>();
expectTypeOf<EmailSlotProps['formApi']>().toEqualTypeOf<
ExtendedFormApi<AccountFormValues>
>();
expectTypeOf<
DefaultSlotProps['values']
>().toEqualTypeOf<AccountFormValues>();
});
it('exposes canonical names alongside deprecated aliases', () => {
expectTypeOf<FormContextApi['reset']>().toEqualTypeOf<
FormContextApi['resetForm']
>();
expectTypeOf<FormContextApi['submit']>().toEqualTypeOf<
FormContextApi['submitForm']
>();
});
});

View File

@@ -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',
});
});
});

View File

@@ -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);
});
});

View File

@@ -40,19 +40,19 @@
}
},
"dependencies": {
"@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:*",
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:",
"zod": "catalog:",
"zod-defaults": "catalog:"
},
"devDependencies": {
"@vue/test-utils": "catalog:",
"unplugin-vue": "catalog:"
}
}

View File

@@ -38,13 +38,7 @@ async function handleSubmit(e: Event) {
return;
}
const { valid } = await props.formApi.validate();
if (!valid) {
return;
}
const values = toRaw(await props.formApi.getValues()) ?? {};
await props.handleSubmit?.(values);
await props.formApi.validateAndSubmit();
}
async function handleReset(e: Event) {
@@ -57,7 +51,7 @@ async function handleReset(e: Event) {
if (isFunction(props.handleReset)) {
await props.handleReset?.(values);
} else {
form.resetForm();
form.reset();
}
}

View File

@@ -10,10 +10,9 @@ import {
VbenIconButton,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn, set } from '@vben-core/shared/utils';
import { useFieldArray } from 'vee-validate';
import { cn, isObject, 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';
@@ -40,9 +39,7 @@ const props = withDefaults(
max?: number;
/** 最少行数 */
min?: number;
/**
* 字段路径,由外层 FormField 通过 componentField 透传vee-validate 的 name
*/
/** 字段路径,由外层 FormField 通过 componentField 透传 */
name?: string;
/**
* 列定义,每一列就是一个子字段(复用 FormSchema
@@ -68,10 +65,19 @@ const props = withDefaults(
);
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 arrayValue = formActions.useFieldValue(props.name);
const rowKeys = new WeakMap<object, string>();
let nextRowKey = 0;
const { fields, push, remove } = useFieldArray<Record<string, any>>(
() => arrayPath.value,
);
const fields = computed<Record<string, any>[]>(() => {
return Array.isArray(arrayValue.value) ? arrayValue.value : [];
});
const canAdd = computed(() => fields.value.length < props.max);
const canRemove = computed(() => fields.value.length > props.min);
@@ -93,12 +99,12 @@ function buildDefaultRow(): Record<string, any> {
const row: Record<string, any> = {};
props.schema.forEach((col) => {
const value =
Reflect.has(col, 'defaultValue') && col.defaultValue !== undefined
? col.defaultValue
: 'type' in col && col.type === 'array'
? []
: null;
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;
@@ -108,14 +114,28 @@ function addRow() {
if (props.disabled || !canAdd.value) {
return;
}
push(buildDefaultRow());
formActions.pushFieldValue(arrayPath.value, buildDefaultRow());
}
function removeRow(index: number) {
if (props.disabled || !canRemove.value) {
return;
}
remove(index);
void formActions.removeFieldValue(arrayPath.value, index);
}
function getRowKey(row: Record<string, any>, index: number) {
if (!isObject(row)) {
return `${arrayPath.value}-${index}`;
}
const existingKey = rowKeys.get(row);
if (existingKey) {
return existingKey;
}
nextRowKey += 1;
const key = `${arrayPath.value}-${nextRowKey}`;
rowKeys.set(row, key);
return key;
}
function rowSchemas(index: number) {
@@ -160,7 +180,7 @@ function rowSchemas(index: number) {
<div
v-for="(entry, index) in fields"
:key="entry.key"
:key="getRowKey(entry, index)"
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
:style="gridStyle"
>

View File

@@ -18,9 +18,9 @@ import {
} from '@vben-core/shadcn-ui';
import { globalShareState } from '@vben-core/shared/global-state';
import { defineRule } from 'vee-validate';
import VbenFormFieldArray from './components/form-field-array.vue';
import { warnDeprecatedOnce } from './deprecation';
import { registerFormRules } from './rule-registry';
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
@@ -46,24 +46,25 @@ export const COMPONENT_BIND_EVENT_MAP: Partial<
export function setupVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
>(options: VbenFormAdapterOptions<T>) {
const { config, defineRules } = options;
const { config, defineRules, rules } = options;
const {
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
} = (config || {}) as FormCommonConfig;
const { changeEventFallback = false, emptyStateValue = undefined } =
(config || {}) as FormCommonConfig;
Object.assign(DEFAULT_FORM_COMMON_CONFIG, {
disabledOnChangeListener,
disabledOnInputListener,
changeEventFallback,
emptyStateValue,
});
if (defineRules) {
for (const key of Object.keys(defineRules)) {
defineRule(key, defineRules[key as never]);
}
warnDeprecatedOnce(
'setup-vben-form-define-rules',
'[Vben Form] `setupVbenForm({ defineRules })` is deprecated. Use `setupVbenForm({ rules })` instead.',
);
registerFormRules(defineRules);
}
if (rules) {
registerFormRules(rules);
}
const baseModelPropName =

View File

@@ -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);
}

View File

@@ -1,3 +1,63 @@
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);
@@ -12,3 +72,35 @@ export function resolveFieldNamePath(fieldName: string) {
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);
}

View File

@@ -1,15 +1,17 @@
import type {
FormState,
GenericObject,
ResetFormOpts,
ValidationOptions,
} from 'vee-validate';
import type { ComponentPublicInstance } from 'vue';
import type { Recordable } from '@vben-core/typings';
import type { FormActions, FormSchema, VbenFormProps } from './types';
import type {
BaseFormComponentType,
FormActions,
FormFieldName,
FormFieldValue,
FormResetOptions,
FormResetState,
FormSchema,
FormValues,
FormValueSnapshot,
VbenFormProps,
} from './types';
import { isRef, toRaw } from 'vue';
@@ -17,25 +19,36 @@ import { Store } from '@vben-core/shared/store';
import {
bindMethods,
cloneDeep,
createMerge,
formatDate,
get,
isDate,
isDayjsObject,
isFunction,
isObject,
mergeWithArrayOverride,
set,
StateHandler,
} from '@vben-core/shared/utils';
import { warnDeprecatedOnce } from './deprecation';
import { resolveFieldNamePath } from './field-name';
import {
getFormArraySchemaChildren,
resolveArrayChildFieldName,
} from './form-render/schema';
import { updateFormSchemaList } from './form-render/schema';
import { formatFormValues } from './form-value-transform';
function getDefaultState(): VbenFormProps {
type FormApiProps<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = VbenFormProps<T, P, TValues>;
type FormApiSchema<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = FormSchema<T, P, TValues>;
function getDefaultState<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
>(): FormApiProps<TValues, T, P> {
return {
actionWrapperClass: '',
collapsed: false,
@@ -59,15 +72,19 @@ function getDefaultState(): VbenFormProps {
};
}
export class FormApi {
export class FormApi<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> {
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
public form = {} as FormActions;
public form = {} as FormActions<TValues>;
isMounted = false;
public state: null | VbenFormProps = null;
public state: FormApiProps<TValues, T, P> | null = null;
stateHandler: StateHandler;
public store: Store<VbenFormProps>;
public store: Store<FormApiProps<TValues, T, P>>;
/**
* 组件实例映射
@@ -75,16 +92,16 @@ export class FormApi {
private componentRefMap: Map<string, unknown> = new Map();
// 最后一次点击提交时的表单值
private latestSubmissionValues: null | Recordable<any> = null;
private latestSubmissionValues: null | Partial<TValues> = null;
private prevState: null | VbenFormProps = null;
private prevState: FormApiProps<TValues, T, P> | null = null;
constructor(options: VbenFormProps = {}) {
constructor(options: FormApiProps<TValues, T, P> = {}) {
const { ...storeState } = options;
const defaultState = getDefaultState();
const defaultState = getDefaultState<TValues, T, P>();
this.store = new Store<VbenFormProps>({
this.store = new Store<FormApiProps<TValues, T, P>>({
...defaultState,
...storeState,
});
@@ -100,6 +117,24 @@ export class FormApi {
bindMethods(this);
}
async clearValidation(
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[],
) {
const form = await this.getForm();
form.clearValidation(fieldNames);
}
formatValues<TResult extends FormValues = TValues>(
rawValues: Readonly<FormValues>,
) {
return formatFormValues(
toRaw(rawValues),
this.state?.schema ?? [],
this.state?.fieldMappingTime,
this.state?.arrayToStringFields,
) as TResult;
}
/**
* 获取字段组件实例
* @param fieldName 字段名
@@ -161,29 +196,41 @@ export class FormApi {
return this.latestSubmissionValues || {};
}
async getRawValues<TResult extends FormValues = TValues>() {
const form = await this.getForm();
return cloneDeep(toRaw(form.values ?? {})) as unknown as TResult;
}
getState() {
return this.state;
}
async getValues<T = Recordable<any>>() {
async getValues<TResult extends FormValues = TValues>() {
const form = await this.getForm();
const values = form.values
? this.handleRangeTimeValue(cloneDeep(toRaw(form.values)))
: {};
return this.handleValueFormat(values) as T;
return this.formatValues<TResult>(toRaw(form.values ?? {}));
}
async isFieldValid(fieldName: string) {
async getValueSnapshot<TResult extends FormValues = TValues>(): Promise<
FormValueSnapshot<TResult>
> {
const rawValues = await this.getRawValues<TResult>();
return {
rawValues,
values: this.formatValues<TResult>(rawValues),
};
}
async isFieldValid(fieldName: FormFieldName<TValues>) {
const form = await this.getForm();
return form.isFieldValid(fieldName);
}
merge(formApi: FormApi) {
merge(formApi: FormApi<any, any, any>) {
const chain = [this, formApi];
const proxy = new Proxy(formApi, {
get(target: any, prop: any) {
if (prop === 'merge') {
return (nextFormApi: FormApi) => {
return (nextFormApi: FormApi<any, any, any>) => {
chain.push(nextFormApi);
return proxy;
};
@@ -218,16 +265,19 @@ export class FormApi {
return proxy;
}
mount(formActions: FormActions, componentRefMap?: Map<string, unknown>) {
mount(
formActions: FormActions<TValues>,
componentRefMap?: Map<string, unknown>,
) {
if (!this.isMounted) {
Object.assign(this.form, formActions);
this.form = formActions;
this.stateHandler.setConditionTrue();
const initialValues = this.form.values
? this.handleRangeTimeValue(cloneDeep(toRaw(this.form.values)))
? this.formatValues(toRaw(this.form.values))
: {};
this.setLatestSubmissionValues({
...this.handleValueFormat(initialValues),
});
...initialValues,
} as Partial<TValues>);
this.componentRefMap =
componentRefMap ?? this.componentRefMap ?? new Map();
this.isMounted = true;
@@ -252,20 +302,27 @@ export class FormApi {
/**
* 重置表单
*/
async resetForm(
state?: Partial<FormState<GenericObject>> | undefined,
opts?: Partial<ResetFormOpts>,
) {
async reset(state?: FormResetState<TValues>, opts?: FormResetOptions) {
const form = await this.getForm();
return form.resetForm(state, opts);
return form.reset(state, opts);
}
/** @deprecated Use `reset` instead. */
async resetForm(state?: FormResetState<TValues>, 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() {
const form = await this.getForm();
const fields = Object.keys(form.errors.value);
fields.forEach((field) => {
form.setFieldError(field, undefined);
});
warnDeprecatedOnce(
'form-api-reset-validate',
'[Vben Form] `formApi.resetValidate()` is deprecated. Use `formApi.clearValidation()` instead.',
);
return this.clearValidation();
}
/**
@@ -273,7 +330,6 @@ export class FormApi {
* @param errors 验证错误对象
*/
scrollToFirstError(errors: Record<string, any> | string) {
// https://github.com/logaretm/vee-validate/discussions/3835
const firstErrorFieldName =
typeof errors === 'string' ? errors : Object.keys(errors)[0];
@@ -285,7 +341,7 @@ export class FormApi {
`[name="${firstErrorFieldName}"]`,
) as HTMLElement;
// 如果通过 name 属性找不到,尝试通过组件引用查找, 正常情况下不会走到这,怕哪天 vee-validate 改了 name 属性有个兜底的
// 如果通过 name 属性找不到,尝试通过组件引用查找
if (!el) {
const componentRef = this.getFieldComponentRef(firstErrorFieldName);
if (componentRef && componentRef.$el instanceof HTMLElement) {
@@ -303,19 +359,27 @@ export class FormApi {
}
}
async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
async setFieldValue<TFieldName extends FormFieldName<TValues>>(
field: TFieldName,
value: FormFieldValue<TValues, NoInfer<TFieldName>>,
shouldValidate?: boolean,
) {
const form = await this.getForm();
form.setFieldValue(field, value, shouldValidate);
await form.setFieldValue(field, value, shouldValidate);
}
setLatestSubmissionValues(values: null | Recordable<any>) {
this.latestSubmissionValues = { ...toRaw(values) };
setLatestSubmissionValues(values: null | Partial<TValues>) {
this.latestSubmissionValues = {
...toRaw(values),
} as Partial<TValues>;
}
setState(
stateOrFn:
| ((prev: VbenFormProps) => Partial<VbenFormProps>)
| Partial<VbenFormProps>,
| ((
prev: FormApiProps<TValues, T, P>,
) => Partial<FormApiProps<TValues, T, P>>)
| Partial<FormApiProps<TValues, T, P>>,
) {
if (isFunction(stateOrFn)) {
this.store.setState((prev) => {
@@ -333,7 +397,7 @@ export class FormApi {
* @param shouldValidate
*/
async setValues(
fields: Record<string, any>,
fields: Partial<TValues>,
filterFields: boolean = true,
shouldValidate: boolean = false,
) {
@@ -343,41 +407,67 @@ export class FormApi {
return;
}
/**
* 合并算法有待改进目前的算法不支持object类型的值。
* antd的日期时间相关组件的值类型为dayjs对象
* element-plus的日期时间相关组件的值类型可能为Date对象
* 以上两种类型需要排除深度合并
*/
const fieldMergeFn = createMerge((obj, key, value) => {
if (key in obj) {
obj[key] =
!Array.isArray(obj[key]) &&
isObject(obj[key]) &&
!isDayjsObject(obj[key]) &&
!isDate(obj[key])
? fieldMergeFn(value, obj[key])
: value;
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;
}
return true;
});
const filteredFields = fieldMergeFn(fields, form.values);
form.setValues(filteredFields, shouldValidate);
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<TValues>;
form.setValues(filteredFields as Partial<TValues>, shouldValidate);
}
async submitForm(e?: Event) {
async submit(e?: Event) {
e?.preventDefault();
e?.stopPropagation();
const form = await this.getForm();
await form.submitForm();
const rawValues = toRaw(await this.getValues());
await this.state?.handleSubmit?.(rawValues);
await form.submit();
return this.submitValues();
}
return rawValues;
/** @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?.resetForm?.();
this.form?.reset?.();
// this.state = null;
this.componentRefMap = new Map();
this.latestSubmissionValues = null;
@@ -385,8 +475,8 @@ export class FormApi {
this.stateHandler.reset();
}
updateSchema(schema: Partial<FormSchema>[]) {
const updated: Partial<FormSchema>[] = [...schema];
updateSchema(schema: Partial<FormApiSchema<TValues, T, P>>[]) {
const updated: Partial<FormApiSchema<TValues, T, P>>[] = [...schema];
const hasField = updated.every(
(item) => Reflect.has(item, 'fieldName') && item.fieldName,
);
@@ -397,163 +487,55 @@ export class FormApi {
);
return;
}
const currentSchema = this.updateSchemaList(
const currentSchema = updateFormSchemaList(
[...(this.state?.schema ?? [])],
updated,
);
this.setState({ schema: currentSchema });
}
async validate(opts?: Partial<ValidationOptions>) {
async validate() {
const form = await this.getForm();
const validateResult = await form.validate(opts);
const validateResult = await form.validate();
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
console.error('validate error', validateResult?.errors);
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(validateResult.errors);
}
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() {
const form = await this.getForm();
const { valid, errors } = await form.validate();
if (!valid) {
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(errors);
}
return;
}
return await this.submitForm();
warnDeprecatedOnce(
'form-api-validate-and-submit-form',
'[Vben Form] `formApi.validateAndSubmitForm()` is deprecated. Use `formApi.validateAndSubmit()` instead.',
);
return this.validateAndSubmit();
}
async validateField(fieldName: string, opts?: Partial<ValidationOptions>) {
async validateField(fieldName: FormFieldName<TValues>) {
const form = await this.getForm();
const validateResult = await form.validateField(fieldName, opts);
const validateResult = await form.validateField(fieldName);
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
console.error('validate error', validateResult?.errors);
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(fieldName);
}
if (
Object.keys(validateResult?.errors ?? {}).length > 0 &&
this.state?.scrollToFirstError
) {
this.scrollToFirstError(fieldName);
}
return validateResult;
}
private applyValueFormatBySchemas(
schemas: FormSchema[],
values: Record<string, any>,
parentPath?: string,
parentContext?: {
arrayField?: string;
row?: Record<string, any>;
rowIndex?: number;
rowPath?: string;
},
) {
schemas.forEach((schema) => {
const fieldName = parentPath
? resolveArrayChildFieldName(parentPath, schema.fieldName)
: schema.fieldName;
const row =
parentPath && parentContext?.rowPath
? this.resolveValueByFieldName(values, parentContext.rowPath)
: parentContext?.row;
const schemaContext = {
...parentContext,
fieldName,
originalFieldName: schema.fieldName,
rootValues: values,
row,
};
const children = getFormArraySchemaChildren(schema);
if (children.length > 0) {
const arrayValue = this.resolveValueByFieldName(values, fieldName);
if (Array.isArray(arrayValue)) {
arrayValue.forEach((rowValue, index) => {
const rowPath = `${fieldName}[${index}]`;
this.applyValueFormatBySchemas(
children as FormSchema[],
values,
rowPath,
{
arrayField: fieldName,
row: rowValue,
rowIndex: index,
rowPath,
},
);
});
}
}
if (schema.valueFormat) {
const value = this.resolveValueByFieldName(values, fieldName);
this.deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
this.setValueByFieldName(
values,
this.resolveValueFormatFieldName(key, parentPath),
nextValue,
);
},
values,
schemaContext,
);
if (formattedValue !== undefined) {
this.setValueByFieldName(values, fieldName, formattedValue);
}
}
});
}
private deleteValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { pathSegments, rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
Reflect.deleteProperty(values, rawKey);
return;
}
if (!pathSegments || 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];
}
if (!target || !isObject(target)) {
return;
}
const lastPathSegment = pathSegments.at(-1);
if (!lastPathSegment) {
return;
}
Reflect.deleteProperty(target, lastPathSegment);
}
private async getForm() {
if (!this.isMounted) {
// 等待form挂载
@@ -565,266 +547,11 @@ export class FormApi {
return this.form;
}
private handleMultiFields = (originValues: Record<string, any>) => {
const arrayToStringFields = this.state?.arrayToStringFields;
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
return;
}
const processFields = (fields: string[], separator: string = ',') => {
this.processFields(fields, separator, originValues, (value, sep) => {
if (Array.isArray(value)) {
return value.join(sep);
} else if (typeof value === 'string') {
// 处理空字符串的情况
if (value === '') {
return [];
}
// 处理复杂分隔符的情况
const escapedSeparator = sep.replaceAll(
/[.*+?^${}()|[\]\\]/g,
String.raw`\$&`,
);
return value.split(new RegExp(escapedSeparator));
} else {
return value;
}
});
};
// 处理简单数组格式 ['field1', 'field2', ';'] 或 ['field1', 'field2']
if (arrayToStringFields.every((item) => typeof item === 'string')) {
const lastItem =
arrayToStringFields[arrayToStringFields.length - 1] || '';
const fields =
lastItem.length === 1
? arrayToStringFields.slice(0, -1)
: arrayToStringFields;
const separator = lastItem.length === 1 ? lastItem : ',';
processFields(fields, separator);
return;
}
// 处理嵌套数组格式 [['field1'], ';']
arrayToStringFields.forEach((fieldConfig) => {
if (Array.isArray(fieldConfig)) {
const [fields, separator = ','] = fieldConfig;
// 根据类型定义fields 应该始终是字符串数组
if (!Array.isArray(fields)) {
console.warn(
`Invalid field configuration: fields should be an array of strings, got ${typeof fields}`,
);
return;
}
processFields(fields, separator);
}
});
};
private handleRangeTimeValue = (originValues: Record<string, any>) => {
const values = { ...originValues };
const fieldMappingTime = this.state?.fieldMappingTime;
this.handleMultiFields(values);
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) {
return values;
}
fieldMappingTime.forEach(
([field, [startTimeKey, endTimeKey], format = 'YYYY-MM-DD']) => {
if (startTimeKey && endTimeKey && values[field] === null) {
Reflect.deleteProperty(values, startTimeKey);
Reflect.deleteProperty(values, endTimeKey);
// delete values[startTimeKey];
// delete values[endTimeKey];
}
if (!values[field]) {
Reflect.deleteProperty(values, field);
// delete values[field];
return;
}
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;
}
// delete values[field];
Reflect.deleteProperty(values, field);
},
);
private async submitValues() {
const { rawValues, values } = await this.getValueSnapshot();
this.setLatestSubmissionValues(values);
await this.state?.handleSubmit?.(values, rawValues);
return values;
};
private handleValueFormat = (originValues: Record<string, any>) => {
const values = { ...originValues };
this.applyValueFormatBySchemas(this.state?.schema ?? [], values);
return values;
};
private processFields = (
fields: string[],
separator: string,
originValues: Record<string, any>,
transformFn: (value: any, separator: string) => any,
) => {
fields.forEach((field) => {
const value = originValues[field];
if (value === undefined || value === null) {
return;
}
originValues[field] = transformFn(value, separator);
});
};
private 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);
}
private resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
}
return get(values, fieldName);
}
private 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}`;
}
private setSchemaChildren(schema: FormSchema, children: FormSchema[]) {
if ('children' in schema && Array.isArray(schema.children)) {
return {
...schema,
children,
} as FormSchema;
}
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 FormSchema;
}
return schema;
}
private setValueByFieldName(
values: Record<string, any>,
fieldName: string,
value: any,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
values[rawKey] = value;
return;
}
set(values, fieldName, value);
}
private updateSchemaList(
currentSchema: FormSchema[],
updated: Partial<FormSchema>[],
): FormSchema[] {
return currentSchema.map((schema): FormSchema => {
const exactUpdatedData = updated.find(
(item) => item.fieldName === schema.fieldName,
);
if (exactUpdatedData) {
return mergeWithArrayOverride(exactUpdatedData, schema) as FormSchema;
}
const children = getFormArraySchemaChildren(schema);
if (children.length === 0) {
return schema;
}
const childUpdates = updated
.map((item) => {
const fieldName = item.fieldName
? this.resolveChildUpdateFieldName(schema.fieldName, item.fieldName)
: undefined;
return fieldName
? ({
...item,
fieldName,
} as Partial<FormSchema>)
: undefined;
})
.filter(Boolean) as Partial<FormSchema>[];
if (childUpdates.length === 0) {
return schema;
}
return this.setSchemaChildren(
schema,
this.updateSchemaList(children as FormSchema[], childUpdates),
);
});
}
private updateState() {
@@ -839,7 +566,10 @@ export class FormApi {
(item) => !currentFields.has(item.fieldName),
);
for (const schema of deletedSchema) {
this.form?.setFieldValue?.(schema.fieldName, undefined);
this.form?.setFieldValue?.(
schema.fieldName,
undefined as FormFieldValue<TValues, string>,
);
}
}
}

View File

@@ -1,20 +1,56 @@
import type {
ExtendedFormApi,
FormDependenciesResolveContext,
FormDependenciesResolvedState,
FormItemDependencies,
FormItemDependenciesLegacy,
FormItemDependenciesResolve,
FormSchemaContext,
FormSchemaRuleType,
MaybeComponentProps,
} from '../types';
import { computed, isRef, ref, watch } from 'vue';
import { computed, isRef, onScopeDispose, shallowRef, watch } from 'vue';
import { get, isBoolean, isFunction } from '@vben-core/shared/utils';
import { useFormValues } from 'vee-validate';
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 表单值
@@ -24,7 +60,7 @@ function resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
// vee-validate[] 表示禁用嵌套
// [] 表示禁用嵌套
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
@@ -32,11 +68,106 @@ function resolveValueByFieldName(
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 values = useFormValues();
const [extendApi] = injectFormProps();
const formRenderProps = injectRenderFormProps();
@@ -46,12 +177,12 @@ export default function useDependencies(
throw new Error('Form api is required in useDependencies');
}
if (!values) {
throw new Error('useDependencies should be used within <VbenForm>');
}
const values = formApi.useValues();
const initialTriggerFields = getDependencies()?.triggerFields ?? [];
const initialTriggerValues = formApi.useFieldValues(initialTriggerFields);
// 在 dependencies 里提供访问extendApi的能力
const getController = (): ExtendedFormApi => {
function getController(): ExtendedFormApi {
const controller = isRef(extendApi)
? extendApi.value.formApi
: extendApi.formApi;
@@ -60,112 +191,107 @@ export default function useDependencies(
throw new Error('formApi is required in useDependencies');
}
return controller;
};
return controller as unknown as ExtendedFormApi;
}
const isIf = ref(true);
const isDisabled = ref(false);
const isShow = ref(true);
const isRequired = ref(false);
const dynamicComponentProps = ref<MaybeComponentProps>({});
const dynamicRules = ref<FormSchemaRuleType>();
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);
});
});
const resetConditionState = () => {
isDisabled.value = false;
isIf.value = true;
isShow.value = true;
isRequired.value = false;
dynamicRules.value = undefined;
dynamicComponentProps.value = {};
};
function resetConditionState() {
dependencyState.value = createDependencyState();
}
watch(
[triggerFieldValues, getDependencies],
async ([_values, dependencies]) => {
async ([currentTriggerValues, dependencies]) => {
if (!dependencies || !dependencies?.triggerFields?.length) {
dependencyEvaluationId += 1;
previousDependencies = dependencies;
previousTriggerValues = undefined;
resetConditionState();
return;
}
resetConditionState();
const {
componentProps,
disabled,
if: whenIf,
required,
rules,
show,
trigger,
} = dependencies;
// 1. 优先判断if如果if为false则不渲染dom后续判断也不再执行
const formValues = values.value;
if (isFunction(whenIf)) {
isIf.value = !!(await whenIf(formValues, formApi, getController()));
// 不渲染
if (!isIf.value) return;
} else if (isBoolean(whenIf)) {
isIf.value = whenIf;
if (!isIf.value) return;
if (
dependencies === previousDependencies &&
previousTriggerValues &&
isEqual(currentTriggerValues, previousTriggerValues)
) {
return;
}
// 2. 判断show如果show为false则隐藏
if (isFunction(show)) {
isShow.value = !!(await show(formValues, formApi, getController()));
} else if (isBoolean(show)) {
isShow.value = show;
}
if (isFunction(componentProps)) {
dynamicComponentProps.value = await componentProps(
formValues,
formApi,
getController(),
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 (isFunction(rules)) {
dynamicRules.value = await rules(formValues, formApi, getController());
}
if (isFunction(disabled)) {
isDisabled.value = !!(await disabled(
formValues,
formApi,
getController(),
));
} else if (isBoolean(disabled)) {
isDisabled.value = disabled;
}
if (isFunction(required)) {
isRequired.value = !!(await required(
formValues,
formApi,
getController(),
));
}
if (isFunction(trigger)) {
await trigger(formValues, formApi, getController());
if (currentEvaluationId !== dependencyEvaluationId) {
return;
}
dependencyState.value = createDependencyState(patch);
},
{ deep: true, immediate: true },
{ immediate: true },
);
onScopeDispose(() => {
dependencyEvaluationId += 1;
});
return {
dynamicComponentProps,
dynamicRules,
isDisabled,
isIf,
isRequired,
isShow,
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),
};
}

View File

@@ -4,14 +4,18 @@ 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';
@@ -30,18 +34,21 @@ import {
} from '@vben-core/shadcn-ui';
import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils';
import { toTypedSchema } from '@vee-validate/zod';
import { useFieldError, useFormValues } from 'vee-validate';
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 { isEventObjectLike } from './helper';
import { getBaseRules, isEventObjectLike } from './helper';
interface Props extends FormFieldProps {}
interface RuntimeFieldSlotProps {
field: FormRuntimeField<any>;
}
const {
changeEventFallback,
colon,
commonComponentProps,
component,
@@ -49,8 +56,6 @@ const {
dependencies,
description,
disabled,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
fieldName,
formFieldProps,
@@ -72,12 +77,14 @@ const {
const { componentBindEventMap, componentMap, isVertical } = useFormContext();
const formRenderProps = injectRenderFormProps();
const values = useFormValues();
const errors = useFieldError(fieldName);
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 compact = computed(() => formRenderProps.compact);
const isInValid = computed(() => errors.value?.length > 0);
const isInValid = computed(() => Boolean(error.value));
const shouldApplyInvalidStyle = computed(() => {
return isInValid.value && component !== 'VbenFormFieldArray';
});
@@ -99,17 +106,25 @@ const FieldComponent = computed(() => {
// 组件未注册
console.warn(`Component ${component} is not registered`);
}
return finalComponent;
return finalComponent ? markRaw(toRaw(finalComponent)) : finalComponent;
});
const {
dynamicComponentProps,
dynamicHelp,
dynamicHelpResolved,
dynamicRenderComponentContent,
dynamicRenderComponentContentResolved,
dynamicRules,
dynamicRulesResolved,
isDisabled,
isIf,
isRequired,
isShow,
} = useDependencies(() => dependencies);
} = useDependencies(
() => dependencies,
() => ({ fieldName }),
);
const labelStyle = computed(() => {
return labelClass?.includes('w-') || isVertical.value
@@ -120,7 +135,10 @@ const labelStyle = computed(() => {
});
const currentRules = computed(() => {
return dynamicRules.value || rules;
const currentRule = dynamicRulesResolved.value ? dynamicRules.value : rules;
return currentRule && !isString(currentRule)
? toRaw(currentRule)
: currentRule;
});
const visible = computed(() => {
@@ -144,18 +162,7 @@ const shouldRequired = computed(() => {
return ['required', 'selectRequired'].includes(currentRules.value);
}
let isOptional = currentRules?.value?.isOptional?.();
// 如果有设置默认值,则不是必填,需要特殊处理
const typeName = currentRules?.value?._def?.typeName;
if (typeName === 'ZodDefault') {
const innerType = currentRules?.value?._def.innerType;
if (innerType) {
isOptional = innerType.isOptional?.();
}
}
return !isOptional;
return !currentRules.value.isOptional();
});
const fieldRules = computed(() => {
@@ -174,17 +181,56 @@ const fieldRules = computed(() => {
const isOptional = !shouldRequired.value;
if (!isOptional) {
const unwrappedRules = (rules as any)?.unwrap?.();
if (unwrappedRules) {
rules = unwrappedRules;
}
rules = getBaseRules(rules) ?? rules;
}
return toTypedSchema(rules as ZodType);
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(values.value, getFormApi())
? componentProps({ fieldName })
: componentProps;
return {
@@ -196,14 +242,12 @@ const computedProps = computed(() => {
// 自定义帮助信息
const computedHelp = computed(() => {
const helpContent = help;
const helpContent = dynamicHelpResolved.value ? dynamicHelp.value : help;
if (!helpContent) {
return undefined;
}
return () =>
isFunction(helpContent)
? helpContent(values.value, getFormApi())
: helpContent;
isFunction(helpContent) ? helpContent({ fieldName }) : helpContent;
});
watch(
@@ -223,10 +267,13 @@ const shouldDisabled = computed(() => {
});
const customContentRender = computed(() => {
if (dynamicRenderComponentContentResolved.value) {
return dynamicRenderComponentContent.value ?? {};
}
if (!isFunction(renderComponentContent)) {
return {};
}
return renderComponentContent(values.value, getFormApi());
return renderComponentContent({ fieldName });
});
const renderContentKey = computed(() => {
@@ -234,18 +281,34 @@ const renderContentKey = computed(() => {
});
const fieldProps = computed(() => {
const rules = fieldRules.value;
return {
keepValue: true,
label: isString(label) ? label : '',
...(rules ? { rules } : {}),
...(formFieldProps as Record<string, any>),
asyncDebounceMs: formFieldProps?.asyncDebounceMs,
validators: fieldValidators.value,
};
});
function fieldBindEvent(slotProps: Record<string, any>) {
const modelValue = slotProps.componentField.modelValue;
const handler = slotProps.componentField['onUpdate:modelValue'];
function createFieldSlotProps(slotProps: RuntimeFieldSlotProps) {
const { field } = slotProps;
function handleChange(value: any) {
getFormApi().setFieldError(fieldName);
field.handleChange(value);
}
return {
...slotProps,
componentField: {
name: fieldName,
modelValue: field.state.value,
onBlur: field.handleBlur,
onChange: handleChange,
onInput: handleChange,
'onUpdate:modelValue': handleChange,
},
};
}
function fieldBindEvent(componentField: Record<string, any>) {
const modelValue = componentField.modelValue;
const handler = componentField['onUpdate:modelValue'];
const bindEventField =
modelPropName ||
@@ -260,34 +323,34 @@ function fieldBindEvent(slotProps: Record<string, any>) {
}
if (bindEventField) {
return {
[`onUpdate:${bindEventField}`]: handler,
[bindEventField]: value === undefined ? emptyStateValue : value,
onChange: disabledOnChangeListener
? undefined
: (e: Record<string, any>) => {
const shouldUnwrap = isEventObjectLike(e);
const onChange = slotProps?.componentField?.onChange;
if (!shouldUnwrap) {
return onChange?.(e);
}
const eventField = bindEventField;
return onChange?.(e?.target?.[bindEventField] ?? e);
},
...(disabledOnInputListener ? { onInput: undefined } : {}),
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 {
...(disabledOnInputListener ? { onInput: undefined } : {}),
...(disabledOnChangeListener ? { onChange: undefined } : {}),
onChange: changeEventFallback ? componentField.onChange : undefined,
onInput: undefined,
};
}
function createComponentProps(slotProps: Record<string, any>) {
const bindEvents = fieldBindEvent(slotProps);
function createComponentProps(slotProps: RuntimeFieldSlotProps) {
const normalizedSlotProps = createFieldSlotProps(slotProps);
const bindEvents = fieldBindEvent(normalizedSlotProps.componentField);
const binds = {
...slotProps.componentField,
...normalizedSlotProps.componentField,
...computedProps.value,
...bindEvents,
...(Reflect.has(computedProps.value, 'onChange')
@@ -332,136 +395,150 @@ onUnmounted(() => {
</script>
<template>
<FormField
<component
v-if="!hide && isIf"
:is="formApi.fieldComponent"
v-bind="fieldProps"
v-slot="slotProps"
:name="fieldName"
>
<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"
<FormField
:dirty="slotProps.field.state.meta.isDirty"
:error="error"
:name="fieldName"
:touched="slotProps.field.state.meta.isTouched"
:valid="slotProps.field.state.meta.isValid"
>
<FormLabel
v-if="!hideLabel"
:class="
cn(
'flex leading-6',
{
'mr-2 shrink-0 justify-end': !isVertical,
'mb-1 flex-row': isVertical,
'self-start': shouldCollapsible && !isVertical,
},
labelClass,
)
"
:help="computedHelp"
:colon="colon"
:label="label"
:required="shouldRequired && !hideRequiredMark"
:style="labelStyle"
<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"
>
<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="{
...slotProps,
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
}"
>
<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)"
:disabled="shouldDisabled"
>
<template
v-for="name in renderContentKey"
:key="name"
#[name]="renderSlotProps"
>
<VbenRenderContent
:content="customContentRender[name]"
v-bind="{ ...renderSlotProps, formContext: 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>
<FormLabel
v-if="!hideLabel"
:class="
cn(
'flex leading-6',
{
'mr-2 shrink-0 justify-end': !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>
</VbenCollapsible>
<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="{
...createFieldSlotProps(slotProps),
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
}"
>
<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)"
:disabled="shouldDisabled"
>
<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>
<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>
<Transition name="slide-up" v-if="!compact">
<FormMessage class="absolute" />
</Transition>
</div>
</FormItem>
</FormField>
</component>
</template>

View File

@@ -1,13 +1,11 @@
<script setup lang="ts">
import type { GenericObject } from 'vee-validate';
import type { ZodTypeAny } from 'zod';
import type { ZodType } from 'zod';
import type { FormCommonConfig, FormRenderProps, FormShape } from '../types';
import type { NormalizedFormFieldSchema } from './schema';
import { computed } from 'vue';
import { computed, toRaw } from 'vue';
import { Form } from '@vben-core/shadcn-ui';
import { cn, isString } from '@vben-core/shared/utils';
import { provideFormRenderProps } from './context';
@@ -52,26 +50,21 @@ const shapes = computed(() => {
const resultShapes: FormShape[] = [];
props.schema?.forEach((schema) => {
const { fieldName } = schema;
const rules = schema.rules as ZodTypeAny;
const rules = toRaw(schema.rules) as ZodType;
let typeName = '';
if (rules && !isString(rules)) {
typeName = rules._def.typeName;
}
const baseRules = getBaseRules(rules) as ZodTypeAny;
const baseRules = getBaseRules(rules) as ZodType;
resultShapes.push({
default: getDefaultValueInZodStack(rules),
fieldName,
required: !['ZodNullable', 'ZodOptional'].includes(typeName),
required: Boolean(rules && !isString(rules) && !rules.isOptional()),
rules: baseRules,
});
});
return resultShapes;
});
const formComponent = computed(() => (props.form ? 'form' : Form));
const formComponent = 'form';
const formComponentProps = computed(() => {
return props.form
@@ -79,7 +72,10 @@ const formComponentProps = computed(() => {
onSubmit: props.form.handleSubmit((val) => emits('submit', val)),
}
: {
onSubmit: (val: GenericObject) => emits('submit', val),
onSubmit: (event: Event) => {
event.preventDefault();
emits('submit', event);
},
};
});

View File

@@ -1,55 +1,51 @@
import type {
AnyZodObject,
ZodDefault,
ZodEffects,
ZodNumber,
ZodString,
ZodTypeAny,
} from 'zod';
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<
ChildType extends AnyZodObject | ZodTypeAny = ZodTypeAny,
>(schema: ChildType | ZodEffects<ChildType>): ChildType | null {
export function getBaseRules(schema?: null | string | ZodType): null | ZodType {
if (!schema || isString(schema)) return null;
if ('innerType' in schema._def)
return getBaseRules(schema._def.innerType as ChildType);
const rawSchema = toRaw(schema);
if ('schema' in schema._def)
return getBaseRules(schema._def.schema as ChildType);
if (rawSchema instanceof ZodPipe) {
return getBaseRules(rawSchema.in as ZodType);
}
return schema as ChildType;
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: ZodTypeAny): any {
export function getDefaultValueInZodStack(
schema?: null | string | ZodType,
): any {
if (!schema || isString(schema)) {
return;
}
const typedSchema = schema as unknown as ZodDefault<ZodNumber | ZodString>;
if (typedSchema._def.typeName === 'ZodDefault')
return typedSchema._def.defaultValue();
if ('innerType' in typedSchema._def) {
return getDefaultValueInZodStack(
typedSchema._def.innerType as unknown as ZodTypeAny,
);
try {
const result = toRaw(schema).safeParse(undefined);
return result.success ? result.data : undefined;
} catch {
return undefined;
}
if ('schema' in typedSchema._def) {
return getDefaultValueInZodStack(
(typedSchema._def as any).schema as ZodTypeAny,
);
}
return undefined;
}
export function isEventObjectLike(obj: any) {

View File

@@ -2,8 +2,10 @@ import type {
BaseFormComponentType,
FormActions,
FormCommonConfig,
FormDependenciesResolveContext,
FormFieldProps,
FormItemDependencies,
FormItemDependenciesLegacy,
FormSchema,
FormSchemaContext,
MaybeComponentProps,
@@ -15,6 +17,8 @@ import {
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
import { resolveChildUpdateFieldName } from '../field-name';
type AnyFormSchema = FormSchema<BaseFormComponentType, Record<string, any>>;
export type NormalizedFormFieldSchema = FormFieldProps & {
@@ -79,8 +83,7 @@ function wrapComponentProps(
return componentProps;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
componentProps(values, actions, createSchemaContext(baseContext, values));
return () => componentProps(baseContext);
}
function wrapCustomParamsRender(
@@ -91,8 +94,7 @@ function wrapCustomParamsRender(
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
return () => render(baseContext);
}
function wrapRenderComponentContent(
@@ -103,8 +105,7 @@ function wrapRenderComponentContent(
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
return () => render(baseContext);
}
function wrapDependencyFn<T>(handler: T, baseContext: FormSchemaContext): T {
@@ -128,7 +129,7 @@ function wrapDependencyFn<T>(handler: T, baseContext: FormSchemaContext): T {
function scopeDependencies(
dependencies: FormItemDependencies | undefined,
baseContext: FormSchemaContext,
) {
): FormItemDependencies | undefined {
if (!dependencies) {
return dependencies;
}
@@ -138,19 +139,41 @@ function scopeDependencies(
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 {
...dependencies,
componentProps: wrapDependencyFn(dependencies.componentProps, baseContext),
disabled: wrapDependencyFn(dependencies.disabled, baseContext),
if: wrapDependencyFn(dependencies.if, baseContext),
required: wrapDependencyFn(dependencies.required, baseContext),
rules: wrapDependencyFn(dependencies.rules, baseContext),
show: wrapDependencyFn(dependencies.show, baseContext),
trigger: wrapDependencyFn(dependencies.trigger, baseContext),
triggerFields:
dependencies.triggerFields?.map((fieldName) =>
scopeRowFieldName(rowPath, fieldName),
) ?? [],
...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,
};
}
@@ -166,9 +189,9 @@ function createArrayComponentProps(
const schemaProps = children.length > 0 ? { schema: children } : {};
if (isFunction(componentProps)) {
return (values: Partial<Record<string, any>>, actions: FormActions) => ({
return () => ({
...arrayProps,
...componentProps(values, actions),
...componentProps({ fieldName: schema.fieldName }),
commonConfig,
globalCommonConfig,
...schemaProps,
@@ -200,9 +223,47 @@ function createArrayFieldSchema(
};
}
export function getFormArraySchemaChildren(schema: Partial<AnyFormSchema>) {
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;
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;
@@ -211,7 +272,7 @@ export function getFormArraySchemaChildren(schema: Partial<AnyFormSchema>) {
componentProps &&
Array.isArray((componentProps as Record<string, any>).schema)
) {
return (componentProps as Record<string, any>).schema;
return (componentProps as Record<string, any>).schema as TSchema[];
}
return [];
@@ -229,6 +290,38 @@ 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 = {},
@@ -238,12 +331,11 @@ export function createFormFieldSchema(
options.globalCommonConfig ?? {},
);
const {
changeEventFallback = false,
colon = false,
componentProps = {},
controlClass = '',
disabled,
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
formFieldProps = {},
formItemClass = '',
@@ -270,9 +362,8 @@ export function createFormFieldSchema(
}
return {
changeEventFallback,
colon,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
hideRequiredMark,
labelWidth,

View File

@@ -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,
);
};
},
}),
);
}

View File

@@ -0,0 +1,303 @@
import type { FieldValidationInvalidator } from './form-runtime-field';
import type {
FormActions,
FormFieldName,
FormFieldValue,
FormResetOptions,
FormRuntimeState,
FormValues,
} from './types';
import { computed, shallowRef } from 'vue';
import { useForm } from '@tanstack/vue-form';
import { createRuntimeFieldComponent } from './form-runtime-field';
function normalizeError(error: unknown): string | undefined {
if (typeof error === 'string') {
return error;
}
if (error && typeof error === 'object' && 'message' in error) {
const message = Reflect.get(error, 'message');
return typeof message === 'string' ? message : undefined;
}
return error === undefined || error === null ? undefined : String(error);
}
function normalizeFieldMetaError(meta: unknown) {
if (!meta || typeof meta !== 'object' || !('errors' in meta)) {
return undefined;
}
const errors = Reflect.get(meta, 'errors');
return normalizeError(Array.isArray(errors) ? errors[0] : undefined);
}
export function useFormRuntime<TValues extends FormValues>(
defaultValues: TValues,
): FormActions<TValues> {
const rawForm = useForm({
defaultValues,
onSubmit: () => {},
});
const values = rawForm.useSelector((formState) => formState.values);
const fieldMeta = rawForm.useSelector((formState) => formState.fieldMeta);
const isDirty = rawForm.useSelector((formState) => formState.isDirty);
const isSubmitting = rawForm.useSelector(
(formState) => formState.isSubmitting,
);
const isValid = rawForm.useSelector((formState) => formState.isValid);
const isValidating = rawForm.useSelector(
(formState) => formState.isValidating,
);
const manualErrors = shallowRef(new Map<string, string>());
const validationInvalidators = new Map<
string,
Set<FieldValidationInvalidator>
>();
function registerValidationInvalidator(
fieldName: string,
invalidator: FieldValidationInvalidator,
) {
let fieldInvalidators = validationInvalidators.get(fieldName);
if (!fieldInvalidators) {
fieldInvalidators = new Set();
validationInvalidators.set(fieldName, fieldInvalidators);
}
fieldInvalidators.add(invalidator);
return () => {
invalidator();
fieldInvalidators.delete(invalidator);
if (fieldInvalidators.size === 0) {
validationInvalidators.delete(fieldName);
}
};
}
function invalidateFieldValidation(fieldName: string) {
for (const invalidator of validationInvalidators.get(fieldName) ?? []) {
invalidator();
}
}
const RuntimeField = createRuntimeFieldComponent(
rawForm.Field,
registerValidationInvalidator,
);
function getErrors() {
const result: Record<string, string> = {};
for (const [fieldName, meta] of Object.entries(fieldMeta.value)) {
const error = normalizeFieldMetaError(meta);
if (error) {
result[fieldName] = error;
}
}
for (const [fieldName, error] of manualErrors.value) {
result[fieldName] = error;
}
return result;
}
const errors = computed(getErrors);
const meta = computed(() => ({
dirty: isDirty.value,
submitting: isSubmitting.value,
valid: isValid.value && manualErrors.value.size === 0,
validating: isValidating.value,
}));
const runtimeState = computed<FormRuntimeState<TValues>>(() => ({
errors: errors.value,
meta: meta.value,
values: values.value,
}));
function getFieldError(fieldName: string) {
return (
manualErrors.value.get(fieldName) ??
normalizeFieldMetaError(Reflect.get(fieldMeta.value, fieldName))
);
}
function useFieldError(fieldName: string) {
const schemaError = rawForm.useSelector((formState) =>
normalizeFieldMetaError(Reflect.get(formState.fieldMeta, fieldName)),
);
return computed(
() => manualErrors.value.get(fieldName) ?? schemaError.value,
);
}
function useFieldValue<TFieldName extends FormFieldName<TValues>>(
fieldName: TFieldName,
) {
return rawForm.useSelector(
() =>
rawForm.getFieldValue(fieldName as never) as FormFieldValue<
TValues,
TFieldName
>,
);
}
function useFieldValues<TFieldName extends FormFieldName<TValues>>(
fieldNames: readonly TFieldName[],
) {
const selectedValues = fieldNames.map((fieldName) =>
useFieldValue(fieldName),
);
return computed(() => selectedValues.map((value) => value.value));
}
async function validateField(fieldName: string) {
await rawForm.validateField(fieldName as never, 'submit');
const error = getFieldError(fieldName);
return {
errors: error ? { [fieldName]: error } : {},
valid: !error,
};
}
async function validate() {
await rawForm.validateAllFields('submit');
const errors = getErrors();
return {
errors,
valid: Object.keys(errors).length === 0,
};
}
function setFieldError(fieldName: string, error?: string) {
invalidateFieldValidation(fieldName);
const nextManualErrors = new Map(manualErrors.value);
if (error) {
nextManualErrors.set(fieldName, error);
} else {
nextManualErrors.delete(fieldName);
}
manualErrors.value = nextManualErrors;
if (error || !rawForm.getFieldMeta(fieldName as never)) {
return;
}
rawForm.setFieldMeta(fieldName as never, (meta) => ({
...meta,
errorMap: {},
}));
}
function clearValidation(
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[],
) {
let requestedFieldNames: FormFieldName<TValues>[] | undefined;
if (Array.isArray(fieldNames)) {
requestedFieldNames = fieldNames;
} else if (fieldNames) {
requestedFieldNames = [fieldNames];
}
const targetFieldNames = requestedFieldNames ?? [
...new Set([
...validationInvalidators.keys(),
...Object.keys(rawForm.getAllErrors().fields),
...manualErrors.value.keys(),
]),
];
for (const fieldName of targetFieldNames) {
setFieldError(fieldName, undefined);
}
}
async function reset(
resetState?: { values?: Partial<TValues> },
options?: FormResetOptions,
) {
for (const fieldName of validationInvalidators.keys()) {
invalidateFieldValidation(fieldName);
}
manualErrors.value = new Map();
rawForm.reset(resetState?.values as TValues | undefined, options);
}
async function submit() {
await rawForm.handleSubmit();
}
const actions: FormActions<TValues> = {
clearValidation,
get errors() {
return errors.value;
},
fieldComponent: RuntimeField,
get meta() {
return meta.value;
},
get values() {
return values.value;
},
getFieldError,
getFieldValue(fieldName) {
return rawForm.getFieldValue(fieldName as never) as FormFieldValue<
TValues,
typeof fieldName
>;
},
handleSubmit(callback) {
return async (event) => {
event?.preventDefault();
event?.stopPropagation();
const result = await validate();
if (result.valid) {
await callback(values.value);
}
};
},
isFieldValid(fieldName) {
return !getFieldError(fieldName);
},
pushFieldValue(fieldName, value) {
rawForm.pushFieldValue(fieldName as never, value as never);
},
async removeFieldValue(fieldName, index) {
await rawForm.removeFieldValue(fieldName as never, index);
},
reset,
resetForm: reset,
setFieldError,
async setFieldValue(fieldName, value, shouldValidate) {
rawForm.setFieldValue(fieldName as never, value as never, {
dontValidate: !shouldValidate,
});
if (shouldValidate) {
await validateField(fieldName);
}
},
async setValues(values, shouldValidate) {
for (const [fieldName, value] of Object.entries(values)) {
rawForm.setFieldValue(fieldName as never, value as never, {
dontValidate: !shouldValidate,
});
}
if (shouldValidate) {
await validate();
}
},
submit,
submitForm: submit,
useSelector(selector) {
return computed(() => selector(runtimeState.value));
},
useFieldError,
useFieldValue,
useFieldValues,
useValues() {
return values;
},
validate,
validateField,
};
return actions;
}

View File

@@ -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;
}

View File

@@ -3,11 +3,19 @@ export { setupVbenForm } from './config';
export type {
BaseFormComponentType,
ExtendedFormApi,
FormActions,
FormContextApi,
FormLayout,
FormSchemaContext,
FormValues,
VbenFormActionSlotProps,
VbenFormComponent,
VbenFormDefaultSlotProps,
VbenFormFieldArrayProps,
VbenFormFieldSlotProps,
VbenFormProps,
FormSchema as VbenFormSchema,
VbenFormSlots,
} from './types';
export * from './use-vben-form';

View File

@@ -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);
}
}
}

View File

@@ -1,5 +1,4 @@
import type { FieldOptions, FormContext, GenericObject } from 'vee-validate';
import type { ZodTypeAny } from 'zod';
import type { ZodType } from 'zod';
import type { Component, HtmlHTMLAttributes, Ref } from 'vue';
@@ -8,6 +7,17 @@ import type { ClassType, MaybeComputedRef } from '@vben-core/typings';
import type { FormApi } from './form-api';
export type FormValues = Record<string, any>;
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 =
@@ -36,14 +46,12 @@ export type FormItemClassType =
| (Record<never, never> & string)
| WrapperClassType;
export type FormFieldOptions = Partial<
FieldOptions & {
validateOnBlur?: boolean;
validateOnChange?: boolean;
validateOnInput?: boolean;
validateOnModelUpdate?: boolean;
}
>;
export interface FormFieldOptions {
asyncDebounceMs?: number;
validateOn?: readonly FormValidationTrigger[];
}
export type FormValidationTrigger = 'blur' | 'change';
export interface FormShape {
/** 默认值 */
@@ -52,7 +60,33 @@ export interface FormShape {
fieldName: string;
/** 是否必填 */
required?: boolean;
rules?: ZodTypeAny;
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 =
@@ -64,9 +98,175 @@ export type MaybeComponentPropKey =
export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
export type FormActions = FormContext<GenericObject>;
export interface FormMeta {
dirty: boolean;
submitting: boolean;
valid: boolean;
validating: boolean;
}
export interface FormSchemaContext {
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<TValues extends FormValues = FormValues> {
rawValues: Readonly<TValues>;
values: TValues;
}
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>,
> {
formApi: ExtendedFormApi<TValues, T, P>;
values: TValues;
}
export interface VbenFormDefaultSlotProps<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> extends VbenFormActionSlotProps<TValues, T, P> {
shapes: FormShape[];
}
export interface VbenFormFieldSlotProps<
TValues extends FormValues = FormValues,
TFieldName extends KnownFormFieldName<TValues> = KnownFormFieldName<TValues>,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> extends VbenFormActionSlotProps<TValues, T, P> {
componentField: FormComponentField<TValues[TFieldName], TFieldName>;
disabled: boolean;
field: FormRuntimeField<TValues[TFieldName]>;
isInValid: boolean;
modelValue: TValues[TFieldName];
name: TFieldName;
}
type VbenFormFieldSlots<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> =
string extends Extract<keyof TValues, string>
? Record<string, ((props: any) => any) | undefined>
: {
[TFieldName in KnownFormFieldName<TValues>]?: (
props: VbenFormFieldSlotProps<TValues, TFieldName, T, P>,
) => any;
};
export type VbenFormSlots<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = VbenFormFieldSlots<TValues, T, P> & {
default?: (props: VbenFormDefaultSlotProps<TValues, T, P>) => any;
'expand-after'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
'expand-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
'reset-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
'submit-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
};
export type VbenFormComponent<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = new () => {
$props: VbenFormProps<T, P, TValues>;
$slots: VbenFormSlots<TValues, T, P>;
};
export interface FormSchemaContext<TValues extends FormValues = FormValues> {
/** 数组字段名,例如 contacts */
arrayField?: string;
/** 当前真实字段名,例如 contacts[0].name */
@@ -74,7 +274,7 @@ export interface FormSchemaContext {
/** 原始 schema 字段名,例如 name */
originalFieldName?: string;
/** 表单完整值 */
rootValues?: Record<string, any>;
rootValues?: TValues;
/** 当前行数据 */
row?: Record<string, any>;
/** 当前行索引 */
@@ -86,12 +286,8 @@ export interface FormSchemaContext {
export type CustomRenderType = (() => Component | string) | string;
// 动态渲染参数
export type CustomParamsRenderType =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => Component | string)
export type CustomParamsRenderType<TValues extends FormValues = FormValues> =
| ((ctx: FormSchemaContext<TValues>) => Component | string)
| string;
export type FormSchemaRuleType =
@@ -99,78 +295,138 @@ export type FormSchemaRuleType =
| 'selectRequired'
| null
| (Record<never, never> & string)
| ZodTypeAny;
| ZodType;
type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => T;
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 = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
type FormItemDependenciesConditionWithRules<TValues extends FormValues> = (
value: Partial<TValues>,
actions: FormActions<TValues>,
controller: ExtendedFormApi<TValues>, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext<TValues>,
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
type FormItemDependenciesConditionWithProps = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
type FormItemDependenciesConditionWithProps<TValues extends FormValues> = (
value: Partial<TValues>,
actions: FormActions<TValues>,
controller: ExtendedFormApi<TValues>, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext<TValues>,
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
export interface FormItemDependencies {
/**
* 组件参数
* @returns 组件参数
*/
componentProps?: FormItemDependenciesConditionWithProps;
/**
* 是否禁用
* @returns 是否禁用
*/
disabled?: boolean | FormItemDependenciesCondition;
/**
* 是否渲染删除dom
* @returns 是否渲染
*/
if?: boolean | FormItemDependenciesCondition;
/**
* 是否必填
* @returns 是否必填
*/
required?: FormItemDependenciesCondition;
/**
* 字段规则
*/
rules?: FormItemDependenciesConditionWithRules;
/**
* 是否隐藏(Css)
* @returns 是否隐藏
*/
show?: boolean | FormItemDependenciesCondition;
/**
* 任意触发都会执行
*/
trigger?: FormItemDependenciesCondition<void>;
interface FormItemDependenciesBase {
/**
* 触发字段
*/
triggerFields: string[];
}
type ComponentProps =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => MaybeComponentProps)
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 {
export interface FormCommonConfig<TValues extends FormValues = FormValues> {
/**
* 是否启用 change 事件兼容回退。
* 仅当组件不发送 update:*、只发送 change 时启用。
* @default false
*/
changeEventFallback?: boolean;
/**
* 是否可折叠的
* @default false
@@ -183,7 +439,7 @@ export interface FormCommonConfig {
/**
* 所有表单项的props
*/
componentProps?: ComponentProps;
componentProps?: ComponentProps<TValues>;
/**
* 所有表单项的控件样式
*/
@@ -198,16 +454,6 @@ export interface FormCommonConfig {
* @default false
*/
disabled?: boolean;
/**
* 是否禁用所有表单项的change事件监听
* @default true
*/
disabledOnChangeListener?: boolean;
/**
* 是否禁用所有表单项的input事件监听
* @default true
*/
disabledOnInputListener?: boolean;
/**
* 所有表单项的空状态值,默认都是undefinednaive-ui的空状态值是null
*/
@@ -252,18 +498,12 @@ export interface FormCommonConfig {
wrapperClass?: string;
}
type RenderComponentContentType = (
value: Partial<Record<string, any>>,
api: FormActions,
ctx?: FormSchemaContext,
type RenderComponentContentType<TValues extends FormValues = FormValues> = (
ctx: FormSchemaContext<TValues>,
) => Record<string, any>;
type MappedComponentProps<P> =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => P & Record<string, any>)
type MappedComponentProps<P, TValues extends FormValues = FormValues> =
| ((ctx: FormSchemaContext<TValues>) => P & Record<string, any>)
| (P & Record<string, any>);
/**
@@ -273,30 +513,33 @@ type MappedComponentProps<P> =
* - 返回其他值:会将当前字段恢复/写回为该返回值
* - `setValue` 回调签名为 `(key, nextValue) => void`
*/
export type FormValueFormat = (
export type FormValueFormat<TValues extends FormValues = FormValues> = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
ctx?: FormSchemaContext,
values: TValues,
ctx?: FormSchemaContext<TValues>,
) => any;
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
interface FormSchemaBody<TValues extends FormValues = FormValues> extends Omit<
FormCommonConfig<TValues>,
'componentProps'
> {
/** 默认值 */
defaultValue?: any;
/** 依赖 */
dependencies?: FormItemDependencies;
dependencies?: FormItemDependencies<TValues>;
/** 描述 */
description?: CustomRenderType;
/** 字段名 */
fieldName: string;
/** 帮助信息 */
help?: CustomParamsRenderType;
help?: CustomParamsRenderType<TValues>;
/** 是否隐藏表单项 */
hide?: boolean;
/** 表单项 */
label?: CustomRenderType;
// 自定义组件内部渲染
renderComponentContent?: RenderComponentContentType;
renderComponentContent?: RenderComponentContentType<TValues>;
/** 字段规则 */
rules?: FormSchemaRuleType;
/** 后缀 */
@@ -306,54 +549,60 @@ interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
* - 返回值不为 `undefined` 时,会回写到当前 fieldName
* - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段
*/
valueFormat?: FormValueFormat;
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]>;
} & FormSchemaBody;
componentProps?: MappedComponentProps<P[K], TValues>;
} & FormSchemaBody<TValues>;
}[Extract<keyof P, T>];
type FormSchemaFallback<T extends BaseFormComponentType> = {
type FormSchemaFallback<
T extends BaseFormComponentType,
TValues extends FormValues,
> = {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps;
} & FormSchemaBody;
componentProps?: ComponentProps<TValues>;
} & FormSchemaBody<TValues>;
type FormArraySchema<
T extends BaseFormComponentType,
P extends Record<string, any>,
TValues extends FormValues,
> = {
/** 内置数组编辑器参数 */
arrayProps?: Omit<
VbenFormFieldArrayProps<T, P>,
VbenFormFieldArrayProps<T, P, TValues>,
'disabled' | 'globalCommonConfig' | 'name' | 'schema'
>;
/** 数组子字段定义 */
children: FormSchema<T, P>[];
children: FormSchema<T, P, TValues>[];
/** 兼容显式指定内置数组编辑器 */
component?: Component | T;
/** 兼容通过 componentProps 传递数组编辑器参数 */
componentProps?: ComponentProps;
componentProps?: ComponentProps<TValues>;
/** 数组字段标记 */
type: 'array';
} & FormSchemaBody;
} & FormSchemaBody<TValues>;
export type FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> =
| FormArraySchema<T, P>
| FormSchemaDiscriminated<T, P>
| FormSchemaFallback<T>;
| FormArraySchema<T, P, TValues>
| FormSchemaDiscriminated<T, P, TValues>
| FormSchemaFallback<T, TValues>;
/**
* 数组编辑器VbenFormFieldArray的组件参数
@@ -361,20 +610,21 @@ export type FormSchema<
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;
commonConfig?: FormCommonConfig<TValues>;
/** 新增一行时生成的默认数据;缺省时按列定义的 fieldName 生成空对象 */
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
globalCommonConfig?: FormCommonConfig<TValues>;
/** 最多行数 */
max?: number;
/** 最少行数 */
@@ -382,17 +632,18 @@ export interface VbenFormFieldArrayProps<
/** 数组字段路径,由外层 FormField 透传 */
name?: string;
/** 列定义,每一列是一个子字段(复用 FormSchema */
schema?: FormSchema<T, P>[];
schema?: FormSchema<T, P, TValues>[];
/** 是否显示序号列 */
showIndex?: boolean;
}
export type HandleSubmitFn = (
values: Record<string, any>,
export type HandleSubmitFn<TValues extends FormValues = FormValues> = (
values: TValues,
rawValues: Readonly<TValues>,
) => Promise<void> | void;
export type HandleResetFn = (
values: Record<string, any>,
export type HandleResetFn<TValues extends FormValues = FormValues> = (
values: TValues,
) => Promise<void> | void;
export type FieldMappingTimeItem = [
@@ -416,16 +667,18 @@ export type ArrayToStringFields = Array<
export interface FormFieldProps<
T extends BaseFormComponentType = BaseFormComponentType,
> extends FormSchemaBody {
TValues extends FormValues = FormValues,
> extends FormSchemaBody<TValues> {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps;
componentProps?: ComponentProps<TValues>;
}
export interface FormRenderProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> {
/**
* 表单字段数组映射字符串配置 默认使用","
@@ -449,7 +702,7 @@ export interface FormRenderProps<
/**
* 表单项通用后备配置,当子项目没配置时使用这里的配置,子项目配置优先级高于此配置
*/
commonConfig?: FormCommonConfig;
commonConfig?: FormCommonConfig<TValues>;
/**
* 紧凑模式(移除表单每一项底部为校验信息预留的空间)
*/
@@ -469,7 +722,7 @@ export interface FormRenderProps<
/**
* 表单实例
*/
form?: FormContext<GenericObject>;
form?: FormActions<TValues>;
/**
* 表单项布局
*/
@@ -477,7 +730,7 @@ export interface FormRenderProps<
/**
* 表单定义
*/
schema?: FormSchema<T, P>[];
schema?: FormSchema<T, P, TValues>[];
/**
* 是否显示展开/折叠
@@ -503,8 +756,9 @@ export interface ActionButtonOptions extends VbenButtonProps {
export interface VbenFormProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> extends Omit<
FormRenderProps<T, P>,
FormRenderProps<T, P, TValues>,
'componentBindEventMap' | 'componentMap' | 'form'
> {
/**
@@ -544,17 +798,18 @@ export interface VbenFormProps<
/**
* 表单重置回调
*/
handleReset?: HandleResetFn;
handleReset?: HandleResetFn<TValues>;
/**
* 表单提交回调
*/
handleSubmit?: HandleSubmitFn;
handleSubmit?: HandleSubmitFn<TValues>;
/**
* 表单值变化回调
*/
handleValuesChange?: (
values: Record<string, any>,
values: Readonly<TValues>,
fieldsChanged: string[],
getFormattedValues: () => TValues,
) => void;
/**
@@ -592,10 +847,14 @@ export interface VbenFormProps<
submitOnEnter?: boolean;
}
export type ExtendedFormApi = FormApi & {
useStore: <T = NoInfer<VbenFormProps>>(
selector?: (state: NoInfer<VbenFormProps>) => T,
) => Readonly<Ref<T>>;
export type ExtendedFormApi<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = FormApi<TValues, T, P> & {
useStore: <TResult = NoInfer<VbenFormProps<T, P, TValues>>>(
selector?: (state: NoInfer<VbenFormProps<T, P, TValues>>) => TResult,
) => Readonly<Ref<TResult>>;
};
export interface VbenFormAdapterOptions<
@@ -603,21 +862,31 @@ export interface VbenFormAdapterOptions<
> {
config?: {
baseModelPropName?: string;
disabledOnChangeListener?: boolean;
disabledOnInputListener?: boolean;
/**
* 是否启用 change 事件兼容回退。
* 仅用于只发送 change 的兼容组件。
* @default false
*/
changeEventFallback?: boolean;
emptyStateValue?: null | undefined;
modelPropNameMap?: Partial<Record<T, string>>;
};
defineRules?: {
required?: (
value: any,
params: any,
ctx: Record<string, any>,
) => boolean | string;
selectRequired?: (
value: any,
params: any,
ctx: Record<string, any>,
) => boolean | 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;

View File

@@ -1,19 +1,22 @@
import type { ZodRawShape } from 'zod';
import type { ZodType } from 'zod';
import type { ComputedRef } from 'vue';
import type { ExtendedFormApi, FormActions, VbenFormProps } from './types';
import { computed, unref, useSlots } from 'vue';
import { computed, toRaw, unref, useSlots } from 'vue';
import { createContext } from '@vben-core/shadcn-ui';
import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
import { useForm } from 'vee-validate';
import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
import { getDefaultsForSchema } from 'zod-defaults';
type ExtendFormProps = VbenFormProps & { formApi?: ExtendedFormApi };
import { useFormRuntime } from './form-runtime';
type ExtendFormProps = VbenFormProps & {
formApi?: ExtendedFormApi<any, any, any>;
};
export const [injectFormProps, provideFormProps] =
createContext<[ComputedRef<ExtendFormProps> | ExtendFormProps, FormActions]>(
@@ -29,9 +32,7 @@ export function useFormInitial(
const slots = useSlots();
const initialValues = generateInitialValues();
const form = useForm({
...(Object.keys(initialValues)?.length ? { initialValues } : {}),
});
const form = useFormRuntime(initialValues);
const delegatedSlots = computed(() => {
const resultSlots: string[] = [];
@@ -47,14 +48,15 @@ export function useFormInitial(
function generateInitialValues() {
const initialValues: Record<string, any> = {};
const zodObject: ZodRawShape = {};
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 customDefaultValue = getCustomDefaultValue(item.rules);
zodObject[item.fieldName] = item.rules;
const rawRules = toRaw(item.rules);
const customDefaultValue = getCustomDefaultValue(rawRules);
zodObject[item.fieldName] = rawRules;
if (customDefaultValue !== undefined) {
initialValues[item.fieldName] = customDefaultValue;
}
@@ -71,6 +73,7 @@ export function useFormInitial(
}
// 自定义默认值提取逻辑
function getCustomDefaultValue(rule: any): any {
rule = toRaw(rule);
if (rule instanceof ZodString) {
return ''; // 默认为空字符串
} else if (rule instanceof ZodNumber) {
@@ -83,20 +86,7 @@ export function useFormInitial(
}
return defaultValues;
} else if (rule instanceof ZodIntersection) {
// 对于交集类型从schema 提取默认值
const leftDefaultValue = getCustomDefaultValue(rule._def.left);
const rightDefaultValue = getCustomDefaultValue(rule._def.right);
// 如果左右两边都能提取默认值,合并它们
if (
typeof leftDefaultValue === 'object' &&
typeof rightDefaultValue === 'object'
) {
return { ...leftDefaultValue, ...rightDefaultValue };
}
// 否则优先使用左边的默认值
return leftDefaultValue ?? rightDefaultValue;
return getDefaultsForSchema(rule);
} else {
return undefined; // 其他类型不提供默认值
}

View File

@@ -1,6 +1,8 @@
import type {
BaseFormComponentType,
ExtendedFormApi,
FormValues,
VbenFormComponent,
VbenFormProps,
} from './types';
@@ -11,14 +13,30 @@ 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>,
> = readonly [VbenFormComponent<TValues, T, P>, ExtendedFormApi<TValues, T, P>];
export function useVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
>(options: VbenFormProps<T, P>) {
>(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>,
>(options: VbenFormProps<T, P, TValues>): UseVbenFormReturn<TValues, T, P>;
export function useVbenForm(
options: VbenFormProps<any, any, any>,
): UseVbenFormReturn<any, any, any> {
const IS_REACTIVE = isReactive(options);
const api = new FormApi(options as unknown as VbenFormProps);
const extendedApi: ExtendedFormApi = api as never;
extendedApi.useStore = (selector) => {
const api = new FormApi<any, any, any>(options);
const extendedApi = api as ExtendedFormApi<any, any, any>;
extendedApi.useStore = (selector: any) => {
return useSelector(api.store, selector);
};
@@ -47,5 +65,5 @@ export function useVbenForm<
);
}
return [Form, extendedApi] as const;
return [Form, extendedApi] as unknown as UseVbenFormReturn<any, any, any>;
}

View File

@@ -1,12 +1,10 @@
<script setup lang="ts">
import type { Recordable } from '@vben-core/typings';
import type { ExtendedFormApi, VbenFormProps, VbenFormSlots } from './types';
import type { ExtendedFormApi, VbenFormProps } from './types';
import { nextTick, onMounted, watch } from 'vue';
import { nextTick, onMounted, readonly, watch } from 'vue';
import { useForwardPriorityValues } from '@vben-core/composables';
import { cloneDeep, get, isEqual, set } from '@vben-core/shared/utils';
import { get, isEqual } from '@vben-core/shared/utils';
import { useDebounceFn } from '@vueuse/core';
@@ -25,29 +23,39 @@ import {
// 通过 extends 会导致热更新卡死,所以重复写了一遍
interface Props extends VbenFormProps {
formApi?: ExtendedFormApi;
formApi?: ExtendedFormApi<any, any, any>;
}
const props = defineProps<Props>();
defineSlots<
Record<string, ((props: Record<string, any>) => any) | undefined> &
VbenFormSlots<any, any, any>
>();
const state = props.formApi?.useStore?.();
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);
props.formApi?.mount?.(form, componentRefMap);
formApi.mount(form, componentRefMap);
const handleUpdateCollapsed = (value: boolean) => {
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) {
@@ -60,51 +68,42 @@ function handleKeyDownEnter(event: KeyboardEvent) {
}
event.preventDefault();
forward.value.formApi?.validateAndSubmitForm();
forward.value.formApi?.validateAndSubmit();
}
const handleValuesChangeDebounced = useDebounceFn(async () => {
state?.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
state?.value.submitOnChange && forward.value.formApi?.validateAndSubmit();
}, state?.value?.changeDebouncedTime ?? 300);
const valuesCache: Recordable<any> = {};
let valuesChangeReady = false;
onMounted(async () => {
// 只在挂载后开始监听form.values会有一个初始化的过程
await nextTick();
watch(
() => form.values,
async (newVal) => {
if (forward.value.handleValuesChange) {
const fields = state?.value.schema?.map((item) => {
return item.fieldName;
});
valuesChangeReady = true;
});
if (fields && fields.length > 0) {
const changedFields: string[] = [];
fields.forEach((field) => {
const newFieldValue = get(newVal, field);
const oldFieldValue = get(valuesCache, field);
if (!isEqual(newFieldValue, oldFieldValue)) {
changedFields.push(field);
set(valuesCache, field, cloneDeep(newFieldValue));
}
});
if (changedFields.length > 0) {
// 调用handleValuesChange回调传入所有表单值的深拷贝和变更的字段列表
const values = await forward.value.formApi?.getValues();
forward.value.handleValuesChange(
cloneDeep(values ?? {}) as Record<string, any>,
changedFields,
);
}
}
}
handleValuesChangeDebounced();
},
{ deep: true },
);
watch(values, (currentValues, previousValues) => {
if (!valuesChangeReady) {
return;
}
const fields = state?.value.schema?.map((item) => item.fieldName) ?? [];
if (forward.value.handleValuesChange && fields.length > 0) {
const changedFields = fields.filter((field) => {
return !isEqual(
get(currentValues, field),
get(previousValues ?? {}, field),
);
});
if (changedFields.length > 0) {
forward.value.handleValuesChange(
readonly(currentValues),
changedFields,
() => formApi.formatValues(currentValues),
);
}
}
handleValuesChangeDebounced();
});
</script>
@@ -123,26 +122,51 @@ onMounted(async () => {
:key="slotName"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps"></slot>
<slot
:name="slotName"
v-bind="slotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
<template #default="slotProps">
<slot v-bind="slotProps">
<slot v-bind="slotProps" :form-api="formApi" :values="form.values">
<FormActions
v-if="forward.showDefaultActions"
:model-value="state?.collapsed"
@update:model-value="handleUpdateCollapsed"
>
<template #reset-before="resetSlotProps">
<slot name="reset-before" v-bind="resetSlotProps"></slot>
<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"></slot>
<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"></slot>
<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"></slot>
<slot
name="expand-after"
v-bind="expandAfterSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
</FormActions>
</slot>

View File

@@ -46,7 +46,6 @@
"@vueuse/core": "catalog:",
"class-variance-authority": "catalog:",
"reka-ui": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:"
}
}

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { provide, toRefs } from 'vue';
import { FORM_FIELD_INJECTION_KEY } from './injectionKeys';
const props = withDefaults(
defineProps<{
dirty?: boolean;
error?: string;
name: string;
touched?: boolean;
valid?: boolean;
}>(),
{
dirty: false,
error: undefined,
touched: false,
valid: true,
},
);
const { dirty, error, name, touched, valid } = toRefs(props);
provide(FORM_FIELD_INJECTION_KEY, {
dirty,
error,
name,
touched,
valid,
});
</script>
<template>
<slot></slot>
</template>

View File

@@ -1,27 +1,24 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue';
import { toValue } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { ErrorMessage } from 'vee-validate';
import { useFormField } from './useFormField';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
const { name, formMessageId } = useFormField();
const { error, formMessageId } = useFormField();
</script>
<template>
<ErrorMessage
<p
v-if="error"
:id="formMessageId"
data-slot="form-message"
as="p"
:name="toValue(name)"
:class="cn('text-destructive text-sm', props.class)"
/>
>
{{ error }}
</p>
</template>

View File

@@ -1,11 +1,10 @@
export { default as FormControl } from './FormControl.vue';
export { default as FormDescription } from './FormDescription.vue';
export { default as FormField } from './FormField.vue';
export { default as FormItem } from './FormItem.vue';
export { default as FormLabel } from './FormLabel.vue';
export { default as FormMessage } from './FormMessage.vue';
export { FORM_ITEM_INJECTION_KEY } from './injectionKeys';
export {
Form,
Field as FormField,
FieldArray as FormFieldArray,
} from 'vee-validate';
FORM_FIELD_INJECTION_KEY,
FORM_ITEM_INJECTION_KEY,
} from './injectionKeys';

View File

@@ -1,3 +1,13 @@
import type { InjectionKey } from 'vue';
import type { InjectionKey, Ref } from 'vue';
export interface FormFieldContext {
dirty: Readonly<Ref<boolean>>;
error: Readonly<Ref<string | undefined>>;
name: Readonly<Ref<string>>;
touched: Readonly<Ref<boolean>>;
valid: Readonly<Ref<boolean>>;
}
export const FORM_ITEM_INJECTION_KEY = Symbol() as InjectionKey<string>;
export const FORM_FIELD_INJECTION_KEY =
Symbol() as InjectionKey<FormFieldContext>;

View File

@@ -1,23 +1,26 @@
import { computed, inject } from 'vue';
import { FieldContextKey } from 'vee-validate';
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys';
import {
FORM_FIELD_INJECTION_KEY,
FORM_ITEM_INJECTION_KEY,
} from './injectionKeys';
export function useFormField() {
const fieldContext = inject(FieldContextKey);
const fieldContext = inject(FORM_FIELD_INJECTION_KEY);
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY);
if (!fieldContext)
throw new Error('useFormField should be used within <FormField>');
if (!fieldItemContext)
throw new Error('useFormField should be used within <FormItem>');
const { name, errorMessage: error, meta } = fieldContext;
const { dirty, error, name, touched, valid } = fieldContext;
const id = fieldItemContext;
const fieldState = {
valid: computed(() => meta.valid),
isDirty: computed(() => meta.dirty),
isTouched: computed(() => meta.touched),
valid: computed(() => valid.value),
isDirty: computed(() => dirty.value),
isTouched: computed(() => touched.value),
error,
};

View File

@@ -4,7 +4,6 @@ import type { Recordable } from '@vben/types';
import type { SettingProps } from './types';
import {
Form,
FormControl,
FormDescription,
FormField,
@@ -26,7 +25,7 @@ function handleChange(fieldName: string, value: boolean) {
}
</script>
<template>
<Form class="space-y-8">
<form class="space-y-8">
<div class="space-y-4">
<template v-for="item in formSchema" :key="item.fieldName">
<FormField type="checkbox" :name="item.fieldName">
@@ -49,5 +48,5 @@ function handleChange(fieldName: string, value: boolean) {
</FormField>
</template>
</div>
</Form>
</form>
</template>

View File

@@ -4,7 +4,6 @@ import type { Recordable } from '@vben/types';
import type { SettingProps } from './types';
import {
Form,
FormControl,
FormDescription,
FormField,
@@ -26,7 +25,7 @@ function handleChange(fieldName: string, value: boolean) {
}
</script>
<template>
<Form class="space-y-8">
<form class="space-y-8">
<div class="space-y-4">
<template v-for="item in formSchema" :key="item.fieldName">
<FormField type="checkbox" :name="item.fieldName">
@@ -49,5 +48,5 @@ function handleChange(fieldName: string, value: boolean) {
</FormField>
</template>
</div>
</Form>
</form>
</template>

View File

@@ -41,7 +41,7 @@ const [Form, { resetForm, validate, getValues, getFieldComponentRef }] =
placeholder: $t('ui.widgets.lockScreen.placeholder'),
},
fieldName: 'lockScreenPassword',
formFieldProps: { validateOnBlur: false },
formFieldProps: { validateOn: ['change'] as const },
label: $t('authentication.password'),
rules: z
.string()

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { SelectOption } from '@vben/types';
import { computed, nextTick, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { GripVertical } from '@vben/icons';
import { $t } from '@vben/locales';
@@ -62,6 +62,10 @@ function initSortable() {
}
onMounted(initSortable);
onUnmounted(() => {
sortableInstance?.destroy();
sortableInstance = null;
});
function setPosition(key: string, event: Event) {
const value = (event.target as HTMLSelectElement).value as

View File

@@ -131,7 +131,7 @@ const [Form, formApi] = useTableForm({
},
handleReset: async () => {
const prevValues = await formApi.getValues();
await formApi.resetForm();
await formApi.reset();
const formValues = await formApi.getValues();
formApi.setLatestSubmissionValues(formValues);
// 如果值发生了变化submitOnChange会触发刷新。所以只在submitOnChange为false或者值没有发生变化时手动刷新