From 483fc1576533ae33507d82ffcba5e2aab5f2a401 Mon Sep 17 00:00:00 2001 From: Zehui Chan Date: Mon, 7 Sep 2026 08:27:33 +0800 Subject: [PATCH] feat(@vben-core/form-ui): support grouped schema for collapsible sections Add `type: 'group'` to `FormSchema`, parallel to `type: 'array'`, so a set of fields can be organised into a collapsible section without turning the group itself into a field. Co-authored-by: Cursor --- .changeset/grouped-form-sections.md | 5 + docs/src/components/common-ui/vben-form.md | 63 ++++++ docs/src/en/components/common-ui/vben-form.md | 30 +++ .../ui-kit/form-ui/__tests__/form-api.test.ts | 93 ++++++++- .../form-ui/__tests__/form-group.test.ts | 189 ++++++++++++++++++ .../form-ui/__tests__/form-schema.test.ts | 78 ++++++++ .../form-ui/__tests__/form-types.test.ts | 54 +++++ .../__tests__/form-value-transform.test.ts | 23 +++ .../src/components/form-field-array.vue | 6 +- packages/@core/ui-kit/form-ui/src/form-api.ts | 32 ++- .../form-ui/src/form-render/form-group.vue | 107 ++++++++++ .../ui-kit/form-ui/src/form-render/form.vue | 122 +++++++++-- .../ui-kit/form-ui/src/form-render/schema.ts | 107 ++++++++-- .../form-ui/src/form-value-transform.ts | 17 +- packages/@core/ui-kit/form-ui/src/index.ts | 3 + packages/@core/ui-kit/form-ui/src/types.ts | 61 +++++- .../ui-kit/form-ui/src/use-form-context.ts | 3 +- .../ui-kit/form-ui/src/vben-use-form.vue | 5 +- .../src/views/examples/form/collapsible.vue | 132 +++++++++++- 19 files changed, 1069 insertions(+), 61 deletions(-) create mode 100644 .changeset/grouped-form-sections.md create mode 100644 packages/@core/ui-kit/form-ui/__tests__/form-group.test.ts create mode 100644 packages/@core/ui-kit/form-ui/src/form-render/form-group.vue diff --git a/.changeset/grouped-form-sections.md b/.changeset/grouped-form-sections.md new file mode 100644 index 000000000..b36507920 --- /dev/null +++ b/.changeset/grouped-form-sections.md @@ -0,0 +1,5 @@ +--- +'@vben-core/form-ui': minor +--- + +feat: support grouped schema for collapsible sections diff --git a/docs/src/components/common-ui/vben-form.md b/docs/src/components/common-ui/vben-form.md index 1a2df1ab3..582e1fb14 100644 --- a/docs/src/components/common-ui/vben-form.md +++ b/docs/src/components/common-ui/vben-form.md @@ -264,6 +264,35 @@ export { initComponentAdapter }; +## 表单分组 + +在 `schema` 中加入 `type: 'group'` 项,可以把若干字段组织成一个可折叠的区块。分组本身不是字段:没有 `fieldName`,不参与取值与校验;`children` 内的字段与顶层字段完全等价,`setValues`、`updateSchema`、`removeSchemaByFields` 以及字段插槽都按 `fieldName` 直接作用于组内字段。 + +```ts +const [Form, formApi] = useVbenForm({ + schema: [ + { component: 'Input', fieldName: 'name', label: '名称' }, + { + type: 'group', + title: '高级选项', + defaultCollapsed: true, + children: [ + { component: 'Input', fieldName: 'remark', label: '备注' }, + { component: 'Switch', fieldName: 'enabled', label: '启用' }, + ], + }, + ], +}); + +// 组内字段照常按 fieldName 更新 +formApi.updateSchema([{ fieldName: 'remark', label: '说明' }]); +``` + +- `collapsible: false` 时分组不可折叠,仅作为带标题的区块。 +- 分组默认占满一行,可通过 `formItemClass` 调整;`wrapperClass` 控制分组内部的栅格,缺省继承表单的 `wrapperClass`。 +- 分组内任一字段校验失败时会自动展开,避免错误提示被折叠区域遮住。 +- 分组只支持一层,`children` 只能是字段,不能再嵌套分组;数组字段的 `children` 同样只能是字段。 + ## 表单值编解码 当组件值与后端 payload 不一致时,使用表单级 `codec` 统一定义双向转换。`encode` 接收完整 `TFormValues` 并返回完整 `TSubmitValues`;`decode` 执行反向转换。多字段拆分、合并和删除都在一个纯函数边界完成,不依赖 schema 顺序或字符串路径写入。 @@ -651,6 +680,40 @@ export interface FormSchema< ::: +::: details FormGroupSchema + +`schema` 数组中的每一项要么是字段(`FormFieldSchema`,即上面的 `FormSchema`),要么是分组(`FormGroupSchema`),以 `type: 'group'` 区分。 + +```ts +export interface FormGroupSchema< + T extends BaseFormComponentType = BaseFormComponentType, + TValues extends FormValues = FormValues, +> { + /** 分组内的字段定义,只能是字段,不能再嵌套分组 */ + children: FormFieldSchema[]; + /** 是否允许折叠,默认 true */ + collapsible?: boolean; + /** 是否默认折叠,默认 false */ + defaultCollapsed?: boolean; + /** 标题右侧的附加内容 */ + extra?: CustomRenderType; + /** 分组容器在表单栅格中的样式,默认占满一行 */ + formItemClass?: FormItemClassType; + /** 是否隐藏分组 */ + hide?: boolean; + /** 分组标识,用于渲染时的稳定 key,缺省按索引 */ + name?: string; + /** 分组标题 */ + title?: CustomRenderType; + /** 分组标记 */ + type: 'group'; + /** 分组内部的栅格布局,缺省继承表单的 wrapperClass */ + wrapperClass?: WrapperClassType; +} +``` + +::: + ::: details FormValueFormat `FormValueFormat` 是兼容类型,已标记为 `@deprecated`。新代码应使用 `FormCodec`。 diff --git a/docs/src/en/components/common-ui/vben-form.md b/docs/src/en/components/common-ui/vben-form.md index e12c8d587..ef9528fbc 100644 --- a/docs/src/en/components/common-ui/vben-form.md +++ b/docs/src/en/components/common-ui/vben-form.md @@ -292,6 +292,35 @@ Control bindings are grouped under `componentProps`. It contains the model value Root metadata remains available for template logic through `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`; it is not forwarded automatically to the rendered control. +## Field Groups + +Add a `type: 'group'` item to `schema` to organize fields into a collapsible section. A group is not a field: it has no `fieldName` and takes no part in values or validation. Fields in `children` behave exactly like top-level fields, so `setValues`, `updateSchema`, `removeSchemaByFields`, and named field slots address them by `fieldName`. + +```ts +const [Form, formApi] = useVbenForm({ + schema: [ + { component: 'Input', fieldName: 'name', label: 'Name' }, + { + type: 'group', + title: 'Advanced', + defaultCollapsed: true, + children: [ + { component: 'Input', fieldName: 'remark', label: 'Remark' }, + { component: 'Switch', fieldName: 'enabled', label: 'Enabled' }, + ], + }, + ], +}); + +// grouped fields are still updated by fieldName +formApi.updateSchema([{ fieldName: 'remark', label: 'Description' }]); +``` + +- `collapsible: false` renders a titled section that cannot be collapsed. +- A group spans the full row by default; adjust it with `formItemClass`. `wrapperClass` controls the grid inside the group and inherits the form `wrapperClass` by default. +- A collapsed group expands automatically when one of its fields fails validation. +- Groups are single-level: `children` only accepts fields, and array-field `children` cannot contain groups either. + ## Form Codec Use the form-level `codec` when component values and the backend payload have different shapes. `encode` converts the complete `TFormValues` object to `TSubmitValues`; `decode` performs the inverse conversion. Multi-field splits and merges are atomic and do not depend on schema order or string-path writes. @@ -363,6 +392,7 @@ Use benchmark results to compare relative changes on the same machine and runtim - top-level `componentProps`, `help`, and `renderComponentContent` functions receive `FormSchemaContext`; value-dependent rendering belongs in `dependencies.resolve` - use `formFieldProps.validateOn` with `blur` and/or `change`; submit always validates, and `asyncDebounceMs` debounces async validators - use `changeEventFallback: true` only for components that emit `change` without an `update:*` event +- `type: 'group'` schema items render collapsible sections; `FormSchema` is `FormFieldSchema | FormGroupSchema`, and `updateSchema` only accepts field schemas ## Reference diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts index e215caf6b..96b12a7e7 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts @@ -374,6 +374,71 @@ describe('formApi', () => { ); }); + it('should treat fields inside groups as known fields when filtering', async () => { + const setValuesMock = vi.fn(); + formApi.setState({ + schema: [ + { component: 'text', fieldName: 'name' }, + { + children: [{ component: 'text', fieldName: 'email' }], + title: 'Contact', + type: 'group', + }, + ], + }); + const formActions: any = { + meta: {}, + setValues: setValuesMock, + values: {}, + }; + + await formApi.mount(formActions, new Map()); + await formApi.setValues({ + email: 'ada@example.com', + name: 'Ada', + unknown: 'ignored', + }); + + expect(setValuesMock).toHaveBeenCalledWith( + { email: 'ada@example.com', name: 'Ada' }, + false, + ); + }); + + it('should clear values of fields removed from a group', async () => { + const setFieldValueMock = vi.fn(); + formApi.setState({ + schema: [ + { + children: [ + { component: 'text', fieldName: 'email' }, + { component: 'text', fieldName: 'phone' }, + ], + title: 'Contact', + type: 'group', + }, + ], + }); + const formActions: any = { + meta: {}, + setFieldValue: setFieldValueMock, + values: { email: 'ada@example.com', phone: '123' }, + }; + + await formApi.mount(formActions, new Map()); + formApi.removeSchemaByFields(['phone']); + + expect(formApi.state?.schema).toEqual([ + { + children: [{ component: 'text', fieldName: 'email' }], + title: 'Contact', + type: 'group', + }, + ]); + expect(setFieldValueMock).toHaveBeenCalledWith('phone', undefined); + expect(setFieldValueMock).not.toHaveBeenCalledWith('email', undefined); + }); + it('should preserve nested schema siblings in touched branches', async () => { const setValuesMock = vi.fn(); formApi.setState({ @@ -676,7 +741,7 @@ describe('updateSchema', () => { instance.updateSchema(newSchema); expect(instance.state?.schema?.[0]?.component).toBe('text'); - expect(instance.state?.schema?.[1]?.label).toBe('Age'); + expect(instance.state?.schema?.[1]).toMatchObject({ label: 'Age' }); }); it('should update child schema by parent path', () => { @@ -708,6 +773,32 @@ describe('updateSchema', () => { ); }); + it('should update fields nested inside groups', () => { + instance.state = { + schema: [ + { component: 'text', fieldName: 'name' }, + { + children: [ + { component: 'text', fieldName: 'email', label: 'Email' }, + { component: 'text', fieldName: 'phone', label: 'Phone' }, + ], + title: 'Contact', + type: 'group', + }, + ], + }; + + instance.updateSchema([{ fieldName: 'phone', label: 'Mobile' }]); + + expect(instance.state?.schema?.[1]).toMatchObject({ + children: [ + { fieldName: 'email', label: 'Email' }, + { fieldName: 'phone', label: 'Mobile' }, + ], + type: 'group', + }); + }); + it('should log an error if fieldName is missing in some items', () => { const newSchema: any[] = [ { component: 'textarea', fieldName: 'name' }, 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 new file mode 100644 index 000000000..5dbed7304 --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-group.test.ts @@ -0,0 +1,189 @@ +import type { VueWrapper } from '@vue/test-utils'; + +import type { FormSchema } from '../src/types'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { setupVbenForm } from '../src/config'; +import { useVbenForm } from '../src/use-vben-form'; + +const wrappers: VueWrapper[] = []; + +const TestInput = defineComponent({ + inheritAttrs: false, + emits: ['update:modelValue'], + setup(_props, { attrs, emit }) { + return () => + h('input', { + ...attrs, + onInput: (event: Event) => { + emit('update:modelValue', (event.target as HTMLInputElement).value); + }, + value: attrs.modelValue ?? '', + }); + }, +}); + +beforeAll(() => { + setupVbenForm({ config: {} }); +}); + +afterEach(() => { + for (const wrapper of wrappers.splice(0)) { + wrapper.unmount(); + } +}); + +function createContactGroup( + overrides: Partial> = {}, +): FormSchema { + return { + children: [ + { + component: TestInput, + defaultValue: 'ada@example.com', + fieldName: 'email', + label: 'Email', + }, + { component: TestInput, fieldName: 'phone', label: 'Phone' }, + ], + name: 'contact', + title: 'Contact', + type: 'group', + ...overrides, + }; +} + +function getGroupState(wrapper: VueWrapper) { + return wrapper.get('.form-group [data-state]').attributes('data-state'); +} + +describe('form group rendering', () => { + it('renders grouped fields as regular form fields', async () => { + const [Form, formApi] = useVbenForm({ + schema: [ + { component: TestInput, fieldName: 'name', label: 'Name' }, + createContactGroup({ extra: 'Optional' }), + ], + }); + const wrapper = mount(Form); + wrappers.push(wrapper); + await flushPromises(); + + expect(wrapper.get('.form-group-title').text()).toBe('Contact'); + expect(wrapper.text()).toContain('Optional'); + expect(wrapper.findAll('input')).toHaveLength(3); + expect(wrapper.get('.form-group').findAll('input')).toHaveLength(2); + expect(getGroupState(wrapper)).toBe('open'); + expect(await formApi.getValues()).toEqual({ email: 'ada@example.com' }); + }); + + it('toggles the group from its header and honors defaultCollapsed', async () => { + const [Form] = useVbenForm({ + schema: [createContactGroup({ defaultCollapsed: true })], + }); + const wrapper = mount(Form); + wrappers.push(wrapper); + await flushPromises(); + + expect(getGroupState(wrapper)).toBe('closed'); + + await wrapper.get('.form-group-header').trigger('click'); + expect(getGroupState(wrapper)).toBe('open'); + + await wrapper.get('.form-group-header').trigger('click'); + expect(getGroupState(wrapper)).toBe('closed'); + }); + + it('keeps a non-collapsible group open when the header is clicked', async () => { + const [Form] = useVbenForm({ + schema: [createContactGroup({ collapsible: false })], + }); + const wrapper = mount(Form); + wrappers.push(wrapper); + await flushPromises(); + + await wrapper.get('.form-group-header').trigger('click'); + expect(getGroupState(wrapper)).toBe('open'); + }); + + it('expands a collapsed group when one of its fields fails validation', async () => { + const [Form, formApi] = useVbenForm({ + schema: [ + createContactGroup({ + children: [ + { + component: TestInput, + fieldName: 'email', + label: 'Email', + rules: z.string().min(1, 'Email is required'), + }, + ], + defaultCollapsed: true, + }), + ], + }); + const wrapper = mount(Form); + wrappers.push(wrapper); + await flushPromises(); + + expect(getGroupState(wrapper)).toBe('closed'); + + expect(await formApi.validate()).toEqual({ + errors: { email: 'Email is required' }, + valid: false, + }); + await flushPromises(); + + expect(getGroupState(wrapper)).toBe('open'); + expect(wrapper.text()).toContain('Email is required'); + }); + + it('skips hidden groups and forwards field slots into groups', async () => { + const [Form] = useVbenForm({ + schema: [ + createContactGroup(), + { + children: [{ component: TestInput, fieldName: 'secret' }], + hide: true, + name: 'hidden', + type: 'group', + }, + ], + }); + const wrapper = mount(Form, { + slots: { + phone: (slotProps: Record) => + h(TestInput, { + ...slotProps.componentProps, + class: 'slot-phone', + }), + }, + }); + wrappers.push(wrapper); + await flushPromises(); + + expect(wrapper.findAll('.form-group')).toHaveLength(1); + expect(wrapper.findAll('input')).toHaveLength(2); + expect(wrapper.get('.form-group').find('.slot-phone').exists()).toBe(true); + }); + + it('re-renders grouped fields after updateSchema', async () => { + const [Form, formApi] = useVbenForm({ + schema: [createContactGroup()], + }); + const wrapper = mount(Form); + wrappers.push(wrapper); + await flushPromises(); + + formApi.updateSchema([{ fieldName: 'phone', label: 'Mobile' }]); + await flushPromises(); + + expect(wrapper.text()).toContain('Mobile'); + expect(wrapper.text()).not.toContain('Phone'); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-schema.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-schema.test.ts index b4294ef28..4ae4f958b 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-schema.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-schema.test.ts @@ -1,8 +1,14 @@ +import type { FormSchema } from '../src/types'; + import { describe, expect, it, vi } from 'vitest'; import { createArrayChildSchema, createFormFieldSchema, + getFormFieldSchemas, + isFormGroupSchema, + removeFormSchemaByFields, + updateFormSchemaList, } from '../src/form-render/schema'; describe('form schema normalization', () => { @@ -71,3 +77,75 @@ describe('form schema normalization', () => { }); }); }); + +describe('form group schema', () => { + const nameSchema: FormSchema = { component: 'VbenInput', fieldName: 'name' }; + const contactGroup: FormSchema = { + children: [ + { component: 'VbenInput', fieldName: 'email' }, + { component: 'VbenInput', fieldName: 'phone' }, + ], + name: 'contact', + title: 'Contact', + type: 'group', + }; + const tagsArray: FormSchema = { + children: [{ component: 'VbenInput', fieldName: 'label' }], + fieldName: 'tags', + type: 'array', + }; + const schema: FormSchema[] = [nameSchema, contactGroup, tagsArray]; + + it('distinguishes groups from fields and arrays', () => { + expect(isFormGroupSchema(nameSchema)).toBe(false); + expect(isFormGroupSchema(contactGroup)).toBe(true); + expect(isFormGroupSchema(tagsArray)).toBe(false); + }); + + it('flattens groups into field schemas while keeping arrays intact', () => { + const fields = getFormFieldSchemas(schema); + + expect(fields.map((item) => item.fieldName)).toEqual([ + 'name', + 'email', + 'phone', + 'tags', + ]); + expect(fields[3]).toBe(schema[2]); + }); + + it('updates fields nested inside groups', () => { + const updated = updateFormSchemaList(schema, [ + { fieldName: 'phone', label: 'Phone' }, + { fieldName: 'tags.label', label: 'Tag' }, + ]); + + expect(updated[0]).toBe(schema[0]); + expect(updated[1]).toMatchObject({ + children: [ + { fieldName: 'email' }, + { fieldName: 'phone', label: 'Phone' }, + ], + name: 'contact', + type: 'group', + }); + expect(updated[2]).toMatchObject({ + children: [{ fieldName: 'label', label: 'Tag' }], + fieldName: 'tags', + }); + // 原 schema 不应被修改 + expect(schema[1]).not.toHaveProperty('children.1.label'); + }); + + it('removes fields nested inside groups without dropping the group', () => { + const result = removeFormSchemaByFields(schema, ['name', 'email']); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + children: [{ fieldName: 'phone' }], + name: 'contact', + type: 'group', + }); + expect(result[1]).toBe(schema[2]); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts index 1fd9946bd..660700932 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts @@ -4,7 +4,10 @@ import type { FormActions, FormContextApi, FormFieldOptions, + FormFieldSchema, + FormGroupSchema, FormItemDependencies, + FormSchema, FormValidationResult, FormValuePatch, FormValueSnapshot, @@ -271,6 +274,57 @@ describe('form public types', () => { ).resolves.toEqualTypeOf(); }); + it('discriminates group schemas from field schemas by type', () => { + const [, formApi] = useVbenForm({ + schema: [ + { component: 'VbenInput', fieldName: 'email' }, + { + children: [{ component: 'VbenInput', fieldName: 'profile.nickname' }], + defaultCollapsed: true, + title: 'Profile', + type: 'group', + }, + ], + }); + + expectTypeOf().toEqualTypeOf< + FormFieldSchema | FormGroupSchema + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + FormFieldSchema[] + >(); + // updateSchema 只接受字段更新,分组本身不可被更新 + expectTypeOf(formApi.updateSchema) + .parameter(0) + .toEqualTypeOf< + Partial< + FormFieldSchema< + BaseFormComponentType, + Record, + AccountFormValues + > + >[] + >(); + + // @ts-expect-error 分组不能声明 fieldName + const invalidGroup: FormSchema = { + children: [], + fieldName: 'group', + type: 'group', + }; + // @ts-expect-error 数组子字段不能是分组 + const invalidArrayChildren: FormSchema = { + children: [{ children: [], type: 'group' }], + fieldName: 'contacts', + type: 'array', + }; + + expectTypeOf(invalidGroup).toMatchTypeOf(); + expectTypeOf(invalidArrayChildren).toMatchTypeOf(); + }); + it('exposes canonical names alongside deprecated aliases', () => { expectTypeOf().toEqualTypeOf< FormContextApi['resetForm'] diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts index 463a19e2f..927d1a512 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts @@ -62,6 +62,29 @@ describe('form value transforms', () => { }); }); + it('formats fields nested inside groups', () => { + const schema = [ + { + children: [ + { + component: 'text', + fieldName: 'email', + valueFormat: (value: string) => value.trim().toLowerCase(), + }, + ], + title: 'Contact', + type: 'group', + }, + ] as any; + + const result = applyFormValueFormats( + { email: ' Ada@Example.com ' }, + schema, + ); + + expect(result).toEqual({ email: 'ada@example.com' }); + }); + it('runs the unified formatting pipeline in a stable order', () => { const result = formatFormValues( { diff --git a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue index 9f22067ef..225e54b8c 100644 --- a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue +++ b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue @@ -1,6 +1,6 @@ + + diff --git a/packages/@core/ui-kit/form-ui/src/form-render/form.vue b/packages/@core/ui-kit/form-ui/src/form-render/form.vue index 76b6544a2..498d7eec6 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/form.vue +++ b/packages/@core/ui-kit/form-ui/src/form-render/form.vue @@ -1,7 +1,13 @@