diff --git a/docs/src/components/common-ui/vben-form.md b/docs/src/components/common-ui/vben-form.md index 582e1fb14..29f616d49 100644 --- a/docs/src/components/common-ui/vben-form.md +++ b/docs/src/components/common-ui/vben-form.md @@ -636,10 +636,10 @@ export interface FormCommonConfig { ::: -::: details FormSchema +::: details FormFieldSchema ```ts -export interface FormSchema< +export interface FormFieldSchema< T extends BaseFormComponentType = BaseFormComponentType, TValues extends FormValues = FormValues, > extends FormCommonConfig { @@ -682,7 +682,7 @@ export interface FormSchema< ::: details FormGroupSchema -`schema` 数组中的每一项要么是字段(`FormFieldSchema`,即上面的 `FormSchema`),要么是分组(`FormGroupSchema`),以 `type: 'group'` 区分。 +`schema` 数组中的每一项要么是字段(`FormFieldSchema`),要么是分组(`FormGroupSchema`),以 `type: 'group'` 区分。 ```ts export interface FormGroupSchema< @@ -710,6 +710,11 @@ export interface FormGroupSchema< /** 分组内部的栅格布局,缺省继承表单的 wrapperClass */ wrapperClass?: WrapperClassType; } + +export type FormSchema< + T extends BaseFormComponentType = BaseFormComponentType, + TValues extends FormValues = FormValues, +> = FormFieldSchema | FormGroupSchema; ``` ::: diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-group.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-group.test.ts index 5dbed7304..95ad1b4d7 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-group.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-group.test.ts @@ -76,6 +76,7 @@ describe('form group rendering', () => { expect(wrapper.get('.form-group-title').text()).toBe('Contact'); expect(wrapper.text()).toContain('Optional'); + expect(wrapper.get('.form-group-trigger').text()).not.toContain('Optional'); expect(wrapper.findAll('input')).toHaveLength(3); expect(wrapper.get('.form-group').findAll('input')).toHaveLength(2); expect(getGroupState(wrapper)).toBe('open'); @@ -91,22 +92,34 @@ describe('form group rendering', () => { await flushPromises(); expect(getGroupState(wrapper)).toBe('closed'); - - await wrapper.get('.form-group-header').trigger('click'); + expect(wrapper.get('button.form-group-trigger').attributes('type')).toBe( + 'button', + ); + expect( + wrapper.get('button.form-group-trigger').attributes('aria-expanded'), + ).toBe('false'); + + await wrapper.get('button.form-group-trigger').trigger('click'); expect(getGroupState(wrapper)).toBe('open'); + expect( + wrapper.get('button.form-group-trigger').attributes('aria-expanded'), + ).toBe('true'); - await wrapper.get('.form-group-header').trigger('click'); + await wrapper.get('button.form-group-trigger').trigger('click'); expect(getGroupState(wrapper)).toBe('closed'); }); - it('keeps a non-collapsible group open when the header is clicked', async () => { + it('keeps a non-collapsible group open despite defaultCollapsed', async () => { const [Form] = useVbenForm({ - schema: [createContactGroup({ collapsible: false })], + schema: [ + createContactGroup({ collapsible: false, defaultCollapsed: true }), + ], }); const wrapper = mount(Form); wrappers.push(wrapper); await flushPromises(); + expect(wrapper.find('button.form-group-trigger').exists()).toBe(false); await wrapper.get('.form-group-header').trigger('click'); expect(getGroupState(wrapper)).toBe('open'); }); @@ -143,6 +156,43 @@ describe('form group rendering', () => { expect(wrapper.text()).toContain('Email is required'); }); + it('expands for validation errors in array descendants', async () => { + const [Form, formApi] = useVbenForm({ + schema: [ + createContactGroup({ + children: [ + { + children: [ + { + component: TestInput, + fieldName: 'phone', + label: 'Phone', + rules: z.string().min(1, 'Phone is required'), + }, + ], + defaultValue: [{ phone: '' }], + fieldName: 'contacts', + type: 'array', + }, + ], + defaultCollapsed: true, + }), + ], + }); + const wrapper = mount(Form); + wrappers.push(wrapper); + await flushPromises(); + + expect(getGroupState(wrapper)).toBe('closed'); + expect(await formApi.validate()).toEqual({ + errors: { 'contacts[0].phone': 'Phone is required' }, + valid: false, + }); + await flushPromises(); + + expect(getGroupState(wrapper)).toBe('open'); + }); + it('skips hidden groups and forwards field slots into groups', async () => { const [Form] = useVbenForm({ schema: [ diff --git a/packages/@core/ui-kit/form-ui/src/form-render/form-group.vue b/packages/@core/ui-kit/form-ui/src/form-render/form-group.vue index 56d0f748f..a50222e77 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/form-group.vue +++ b/packages/@core/ui-kit/form-ui/src/form-render/form-group.vue @@ -24,7 +24,9 @@ const props = withDefaults(defineProps(), { const formRenderProps = injectRenderFormProps(); -const collapseOpen = ref(!props.schema.defaultCollapsed); +const collapseOpen = ref( + props.schema.collapsible === false || !props.schema.defaultCollapsed, +); const shouldCollapsible = computed(() => { return props.schema.collapsible !== false; @@ -33,8 +35,14 @@ const shouldCollapsible = computed(() => { // 组内任一字段校验失败时自动展开,避免错误提示被折叠区域遮住 const hasInvalidField = computed(() => { const errors = formRenderProps.form?.errors ?? {}; - return props.schema.children.some((field) => - Boolean(errors[field.fieldName]), + return props.schema.children.some(({ fieldName }) => + Object.entries(errors).some( + ([errorFieldName, error]) => + Boolean(error) && + (errorFieldName === fieldName || + errorFieldName.startsWith(`${fieldName}.`) || + errorFieldName.startsWith(`${fieldName}[`)), + ), ); }); @@ -65,36 +73,47 @@ function toggleCollapsed() {