fix(@vben-core/form-ui)!: group field slot component props (#8215)

* refactor(@vben-core/form-ui)!: group field slot component props

* fix(@vben-core/form-ui): stabilize grouped field slot bindings

Resolve function-based common component props with field context.

Preserve internal model and event bindings when custom field slots spread componentProps.

Add regression coverage for array contexts, disabled precedence, and stale binding isolation.

* feat(@vben/vite-config): warn about field slot migration

Print the temporary field-slot migration warning when the development server starts.

Inject the same message into the browser console and keep the plugin serve-only.

* docs(@vben/docs): document field slot migration

Place the breaking field-slot notice at the start of both form documents.

Explain old and new bindings, development warnings, and planned notice removal.

* feat(@vben/playground): extend custom form example

Add a switchable input and select field to demonstrate schema component updates.

Update the composite phone field to emit new tuple values so validation observes changes.
This commit is contained in:
dream-weave
2026-07-31 18:13:49 +08:00
committed by GitHub
parent 32495e40b2
commit 9f5b1cd9fb
19 changed files with 498 additions and 50 deletions

View File

@@ -65,7 +65,9 @@ const dependencySchema: FormSchema[] = [
component: TestInput,
defaultValue: `Value ${index}`,
dependencies: {
resolve: ({ values }) => ({ disabled: values.mode === 'locked' }),
resolve: ({ values }: { values: Record<string, string> }) => ({
disabled: values.mode === 'locked',
}),
triggerFields: ['mode'],
},
fieldName: `dependent${index}`,

View File

@@ -1,6 +1,6 @@
import type { VueWrapper } from '@vue/test-utils';
import type { FormSchemaRuleType } from '../src/types';
import type { FormSchemaRuleType, VbenFormFieldSlotProps } from '../src/types';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
@@ -104,18 +104,27 @@ describe('useVbenForm integration', () => {
expect(validateValue).toHaveBeenCalledTimes(initialValidationCount + 1);
});
it('keeps only the active model protocol and boolean disabled in field slots', async () => {
it('keeps only the active model protocol in field slot componentProps', async () => {
interface ModelProtocolValues {
defaultField: string;
valueField: string;
}
const staleDefaultBlur = vi.fn();
const staleDefaultUpdate = vi.fn();
const staleValueUpdate = vi.fn();
let defaultSlotProps: Record<string, any> | undefined;
let valueSlotProps: Record<string, any> | undefined;
const [Form, formApi] = useVbenForm<ModelProtocolValues>({
schema: [
{
component: TestInput,
componentProps: {
modelValue: 'stale-default-value',
name: 'stale-default-name',
onBlur: staleDefaultBlur,
'onUpdate:modelValue': staleDefaultUpdate,
},
defaultValue: 'default-initial',
fieldName: 'defaultField',
},
@@ -124,6 +133,8 @@ describe('useVbenForm integration', () => {
componentProps: {
eventMode: 'value-and-change',
modelValue: 'stale-model-value',
'onUpdate:value': staleValueUpdate,
value: 'stale-value',
},
defaultValue: 'value-initial',
fieldName: 'valueField',
@@ -136,18 +147,15 @@ describe('useVbenForm integration', () => {
defaultField(slotProps: Record<string, any>) {
defaultSlotProps = slotProps;
return h(TestInput, {
...slotProps.componentProps,
class: 'default-protocol-input',
modelValue: slotProps.modelValue,
'onUpdate:modelValue': slotProps['onUpdate:modelValue'],
});
},
valueField(slotProps: Record<string, any>) {
valueSlotProps = slotProps;
return h(TestInput, {
...slotProps.componentProps,
class: 'value-protocol-input',
eventMode: slotProps.eventMode,
value: slotProps.value,
'onUpdate:value': slotProps['onUpdate:value'],
});
},
},
@@ -159,21 +167,31 @@ describe('useVbenForm integration', () => {
expect(valueSlotProps).toBeDefined();
if (!defaultSlotProps || !valueSlotProps) return;
expect(defaultSlotProps.disabled).toBe(false);
expect(defaultSlotProps.modelValue).toBe('default-initial');
expect(defaultSlotProps).toHaveProperty('onUpdate:modelValue');
expect(defaultSlotProps).not.toHaveProperty('value');
expect(defaultSlotProps.componentProps.disabled).toBe(false);
expect(defaultSlotProps.componentProps.modelValue).toBe('default-initial');
expect(defaultSlotProps.componentProps.name).toBe('defaultField');
expect(defaultSlotProps.componentProps).toHaveProperty(
'onUpdate:modelValue',
);
expect(defaultSlotProps.componentProps).not.toHaveProperty('value');
expect(valueSlotProps.disabled).toBe(false);
expect(valueSlotProps.value).toBe('value-initial');
expect(valueSlotProps).toHaveProperty('onUpdate:value');
expect(valueSlotProps).not.toHaveProperty('modelValue');
expect(valueSlotProps).not.toHaveProperty('onUpdate:modelValue');
expect(valueSlotProps.componentProps.disabled).toBe(false);
expect(valueSlotProps.componentProps.value).toBe('value-initial');
expect(valueSlotProps.componentProps).toHaveProperty('onUpdate:value');
expect(valueSlotProps.componentProps).not.toHaveProperty('modelValue');
expect(valueSlotProps.componentProps).not.toHaveProperty(
'onUpdate:modelValue',
);
await wrapper.get('.default-protocol-input').trigger('blur');
await wrapper.get('.default-protocol-input').setValue('default-updated');
await wrapper.get('.value-protocol-input').setValue('value-updated');
await flushPromises();
expect(defaultSlotProps.field.state.meta.isTouched).toBe(true);
expect(staleDefaultBlur).not.toHaveBeenCalled();
expect(staleDefaultUpdate).not.toHaveBeenCalled();
expect(staleValueUpdate).not.toHaveBeenCalled();
expect(await formApi.getValues()).toEqual({
defaultField: 'default-updated',
valueField: 'value-updated',
@@ -208,6 +226,109 @@ describe('useVbenForm integration', () => {
expect(wrapper.get('.slot-value').text()).toBe('Grace');
});
it('groups control bindings in field slot componentProps', async () => {
interface SlotFormValues {
name: string;
}
let latestSlotProps:
| undefined
| VbenFormFieldSlotProps<SlotFormValues, 'name'>;
const [Form, formApi] = useVbenForm<SlotFormValues>({
schema: [
{
component: TestInput,
defaultValue: 'Ada',
fieldName: 'name',
},
],
});
const wrapper = mount(Form, {
slots: {
name(slotProps: VbenFormFieldSlotProps<SlotFormValues, 'name'>) {
latestSlotProps = slotProps;
return h(TestInput, {
...slotProps.componentProps,
class: 'slot-component',
});
},
},
});
wrappers.push(wrapper);
await flushPromises();
expect(latestSlotProps).toBeDefined();
if (!latestSlotProps) return;
expect(latestSlotProps.formApi).toBe(formApi);
expect(latestSlotProps.values).toEqual({ name: 'Ada' });
expect(latestSlotProps.modelValue).toBe('Ada');
expect(latestSlotProps.componentProps.modelValue).toBe('Ada');
expect(latestSlotProps.componentProps.disabled).toBe(false);
expect(latestSlotProps.componentProps).toHaveProperty(
'onUpdate:modelValue',
);
expect(latestSlotProps.componentProps).not.toHaveProperty('formApi');
expect(latestSlotProps.componentProps).not.toHaveProperty('values');
expect(latestSlotProps).not.toHaveProperty('onUpdate:modelValue');
await wrapper.get('.slot-component').setValue('Grace');
await flushPromises();
expect(latestSlotProps.modelValue).toBe('Grace');
expect(latestSlotProps.values.name).toBe('Grace');
});
it('normalizes disabled state in field slot componentProps', async () => {
interface DisabledFormValues {
commonDisabled: string;
dependencyDisabled: string;
}
const fieldSlotProps: Record<string, Record<string, any>> = {};
const [Form] = useVbenForm<DisabledFormValues>({
commonConfig: { disabled: true },
schema: [
{
component: TestInput,
componentProps: { disabled: false },
fieldName: 'commonDisabled',
},
{
component: TestInput,
componentProps: { disabled: false },
dependencies: {
resolve: () => ({ disabled: true }),
triggerFields: [],
},
fieldName: 'dependencyDisabled',
},
],
});
const wrapper = mount(Form, {
slots: {
commonDisabled(slotProps: Record<string, any>) {
fieldSlotProps.commonDisabled = slotProps;
return h(TestInput, slotProps.componentProps);
},
dependencyDisabled(slotProps: Record<string, any>) {
fieldSlotProps.dependencyDisabled = slotProps;
return h(TestInput, slotProps.componentProps);
},
},
});
wrappers.push(wrapper);
await flushPromises();
expect(fieldSlotProps.commonDisabled).toBeDefined();
expect(fieldSlotProps.dependencyDisabled).toBeDefined();
expect(fieldSlotProps.commonDisabled?.disabled).toBe(true);
expect(fieldSlotProps.commonDisabled?.componentProps.disabled).toBe(true);
expect(fieldSlotProps.dependencyDisabled?.disabled).toBe(true);
expect(fieldSlotProps.dependencyDisabled?.componentProps.disabled).toBe(
true,
);
});
it('supports a field-level change event fallback for legacy components', async () => {
const [Form, formApi] = useVbenForm({
schema: [

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import {
createArrayChildSchema,
createFormFieldSchema,
} from '../src/form-render/schema';
describe('form schema normalization', () => {
it('resolves common component props with the field context', () => {
const componentProps = vi.fn(({ fieldName }) => ({
placeholder: `Enter ${fieldName}`,
}));
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'name' },
{ commonConfig: { componentProps } },
);
expect(componentProps).toHaveBeenCalledWith({ fieldName: 'name' });
expect(schema.commonComponentProps).toEqual({
placeholder: 'Enter name',
});
});
it('preserves common component props objects', () => {
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'name' },
{ commonConfig: { componentProps: { placeholder: 'Enter a name' } } },
);
expect(schema.commonComponentProps).toEqual({
placeholder: 'Enter a name',
});
});
it('resolves global common component props functions', () => {
const componentProps = vi.fn(({ fieldName }) => ({
title: `Global ${fieldName}`,
}));
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'email' },
{ globalCommonConfig: { componentProps } },
);
expect(componentProps).toHaveBeenCalledWith({ fieldName: 'email' });
expect(schema.commonComponentProps).toEqual({ title: 'Global email' });
});
it('resolves array common props with the row context', () => {
const componentProps = vi.fn(() => ({ placeholder: 'Contact name' }));
const schema = createArrayChildSchema(
{ component: 'VbenInput', fieldName: 'name' },
{
arrayField: 'contacts',
commonConfig: { componentProps },
index: 1,
},
);
expect(componentProps).toHaveBeenCalledWith({
arrayField: 'contacts',
fieldName: 'contacts[1].name',
originalFieldName: 'name',
rowIndex: 1,
rowPath: 'contacts[1]',
});
expect(schema.commonComponentProps).toEqual({
placeholder: 'Contact name',
});
});
});

View File

@@ -141,6 +141,9 @@ describe('form public types', () => {
EmailSlotProps['field']['state']['value']
>().toEqualTypeOf<string>();
expectTypeOf<EmailSlotProps['values']>().toEqualTypeOf<AccountFormValues>();
expectTypeOf<
EmailSlotProps['componentProps']['modelValue']
>().toEqualTypeOf<string | undefined>();
expectTypeOf<EmailSlotProps['formApi']>().toEqualTypeOf<
ExtendedFormApi<AccountFormValues>
>();

View File

@@ -361,9 +361,10 @@ function createComponentProps(slotProps: RuntimeFieldSlotProps) {
);
const binds = {
...normalizedSlotProps.componentField,
...computedProps.value,
...normalizedSlotProps.componentField,
...bindEvents,
disabled: shouldDisabled.value,
...(Reflect.has(computedProps.value, 'onChange')
? { onChange: computedProps.value.onChange }
: {}),
@@ -379,6 +380,17 @@ function createComponentProps(slotProps: RuntimeFieldSlotProps) {
return binds;
}
function createFieldSlotScope(slotProps: RuntimeFieldSlotProps) {
return {
...createFieldSlotProps(slotProps),
componentProps: createComponentProps(slotProps),
disabled: shouldDisabled.value,
isInValid: isInValid.value,
modelValue: fieldValue.value,
name: fieldName,
};
}
function autofocus() {
if (
fieldComponentRef.value &&
@@ -484,14 +496,7 @@ onUnmounted(() => {
:class="cn('relative flex w-full items-center', wrapperClass)"
>
<FormControl :class="cn(controlClass)">
<slot
v-bind="{
...createFieldSlotProps(slotProps),
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
}"
>
<slot v-bind="createFieldSlotScope(slotProps)">
<component
:is="FieldComponent"
ref="fieldComponentRef"
@@ -500,7 +505,6 @@ onUnmounted(() => {
shouldApplyInvalidStyle,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"
>
<template
v-for="name in renderContentKey"

View File

@@ -86,6 +86,23 @@ function wrapComponentProps(
return () => componentProps(baseContext);
}
function wrapCommonConfig(
commonConfig: FormCommonConfig | undefined,
baseContext: FormSchemaContext,
) {
if (!commonConfig || !isFunction(commonConfig.componentProps)) {
return commonConfig;
}
return {
...commonConfig,
componentProps: wrapComponentProps(
commonConfig.componentProps,
baseContext,
),
};
}
function wrapCustomParamsRender(
render: AnyFormSchema['help'],
baseContext: FormSchemaContext,
@@ -350,6 +367,9 @@ export function createFormFieldSchema(
const normalizedSchema = isFormArraySchema(schema)
? createArrayFieldSchema(schema, options)
: schema;
const commonComponentProps = isFunction(componentProps)
? componentProps({ fieldName: normalizedSchema.fieldName })
: componentProps;
let resolvedSchemaFormItemClass = normalizedSchema.formItemClass;
if (isFunction(normalizedSchema.formItemClass)) {
@@ -370,7 +390,7 @@ export function createFormFieldSchema(
modelPropName,
wrapperClass,
...normalizedSchema,
commonComponentProps: componentProps as MaybeComponentProps,
commonComponentProps,
componentProps: normalizedSchema.componentProps,
controlClass: [controlClass, normalizedSchema.controlClass]
.filter(Boolean)
@@ -423,10 +443,13 @@ export function createArrayChildSchema(
),
},
{
commonConfig: options.commonConfig,
commonConfig: wrapCommonConfig(options.commonConfig, baseContext),
disabled: options.disabled || schema.disabled,
forceHideLabel: true,
globalCommonConfig: options.globalCommonConfig,
globalCommonConfig: wrapCommonConfig(
options.globalCommonConfig,
baseContext,
),
},
);
}

View File

@@ -19,6 +19,7 @@ export type {
VbenFormFieldArrayProps,
VbenFormFieldSlotProps,
VbenFormProps,
VbenFormResolvedComponentProps,
FormSchema as VbenFormSchema,
VbenFormSlots,
} from './types';

View File

@@ -244,6 +244,10 @@ export interface VbenFormFieldSlotProps<
FormFieldValue<TValues, TFieldName>,
TFieldName
>;
componentProps: VbenFormResolvedComponentProps<
FormFieldValue<TValues, TFieldName>,
TFieldName
>;
disabled: boolean;
field: FormRuntimeField<FormFieldValue<TValues, TFieldName>>;
isInValid: boolean;
@@ -251,6 +255,16 @@ export interface VbenFormFieldSlotProps<
name: TFieldName;
}
export type VbenFormResolvedComponentProps<
TValue = unknown,
TFieldName extends string = string,
> = MaybeComponentProps & {
disabled: boolean;
modelValue?: TValue;
name: TFieldName;
'onUpdate:modelValue'?: (value: TValue) => void;
};
type VbenFormFieldSlots<
TValues extends FormValues,
T extends BaseFormComponentType,