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 @@ - + + + + + + + + >; +type AnyFormFieldSchema = FormFieldSchema< + BaseFormComponentType, + Record +>; export type NormalizedFormFieldSchema = FormFieldProps & { commonComponentProps: MaybeComponentProps; @@ -76,7 +82,7 @@ function scopeRowFieldName(rowPath: string, fieldName: string) { } function wrapComponentProps( - componentProps: AnyFormSchema['componentProps'], + componentProps: AnyFormFieldSchema['componentProps'], baseContext: FormSchemaContext, ) { if (!isFunction(componentProps)) { @@ -104,7 +110,7 @@ function wrapCommonConfig( } function wrapCustomParamsRender( - render: AnyFormSchema['help'], + render: AnyFormFieldSchema['help'], baseContext: FormSchemaContext, ) { if (!isFunction(render)) { @@ -115,7 +121,7 @@ function wrapCustomParamsRender( } function wrapRenderComponentContent( - render: AnyFormSchema['renderComponentContent'], + render: AnyFormFieldSchema['renderComponentContent'], baseContext: FormSchemaContext, ) { if (!isFunction(render)) { @@ -195,7 +201,7 @@ function scopeDependencies( } function createArrayComponentProps( - schema: AnyFormSchema, + schema: AnyFormFieldSchema, options: CreateFormFieldSchemaOptions, ) { const componentProps = schema.componentProps; @@ -225,10 +231,12 @@ function createArrayComponentProps( } function createArrayFieldSchema( - schema: AnyFormSchema, + schema: AnyFormFieldSchema, options: CreateFormFieldSchemaOptions, ) { - const restSchema = { ...(schema as AnyFormSchema & Record) }; + const restSchema = { + ...(schema as AnyFormFieldSchema & Record), + }; Reflect.deleteProperty(restSchema, 'arrayProps'); Reflect.deleteProperty(restSchema, 'children'); Reflect.deleteProperty(restSchema, 'type'); @@ -246,7 +254,34 @@ interface FormArraySchemaLike { } interface UpdatableFormSchemaLike extends FormArraySchemaLike { - fieldName: string; + /** 分组没有 fieldName,因此这里是可选的 */ + fieldName?: string; + type?: string; +} + +export function isFormGroupSchema< + T extends BaseFormComponentType, + P extends Record, + TValues extends FormValues, +>(schema: FormSchema): schema is FormGroupSchema; +export function isFormGroupSchema( + schema: TSchema, +): schema is Extract; +export function isFormGroupSchema(schema: object) { + return 'type' in schema && schema.type === 'group'; +} + +/** + * 展开分组,得到表单中全部字段 schema + */ +export function getFormFieldSchemas< + T extends BaseFormComponentType, + P extends Record, + TValues extends FormValues, +>(schemas: FormSchema[]): FormFieldSchema[] { + return schemas.flatMap((schema) => + isFormGroupSchema(schema) ? schema.children : [schema], + ); } function setSchemaChildren( @@ -276,7 +311,7 @@ function setSchemaChildren( return schema; } -export function getFormArraySchemaChildren( +export function getFormArraySchemaChildren( schema: FormArraySchemaLike, ): TSchema[] { if ('children' in schema && Array.isArray(schema.children)) { @@ -295,7 +330,7 @@ export function getFormArraySchemaChildren( return []; } -export function isFormArraySchema(schema: Partial) { +export function isFormArraySchema(schema: Partial) { return ( ('type' in schema && schema.type === 'array') || schema.component === 'VbenFormFieldArray' || @@ -312,8 +347,21 @@ export function updateFormSchemaList( updated: Partial[], ): TSchema[] { return currentSchema.map((schema) => { + // 分组本身不是字段,直接把更新下发给组内字段 + if (isFormGroupSchema(schema)) { + return setSchemaChildren( + schema, + updateFormSchemaList((schema.children ?? []) as TSchema[], updated), + ); + } + + const { fieldName: schemaFieldName } = schema; + if (!schemaFieldName) { + return schema; + } + const exactUpdatedData = updated.find( - (item) => item.fieldName === schema.fieldName, + (item) => item.fieldName === schemaFieldName, ); if (exactUpdatedData) { return mergeWithArrayOverride(exactUpdatedData, schema) as TSchema; @@ -325,7 +373,7 @@ export function updateFormSchemaList( } const childUpdates = updated.flatMap((item) => { const fieldName = item.fieldName - ? resolveChildUpdateFieldName(schema.fieldName, item.fieldName) + ? resolveChildUpdateFieldName(schemaFieldName, item.fieldName) : undefined; return fieldName ? [{ ...item, fieldName } as Partial] : []; }); @@ -339,8 +387,39 @@ export function updateFormSchemaList( }); } +/** + * 按字段名移除 schema,分组内的字段一并处理,分组壳子保留 + */ +export function removeFormSchemaByFields< + TSchema extends UpdatableFormSchemaLike, +>(currentSchema: TSchema[], fields: string[]): TSchema[] { + const fieldSet = new Set(fields); + const result: TSchema[] = []; + + for (const schema of currentSchema) { + if (isFormGroupSchema(schema)) { + result.push( + setSchemaChildren( + schema, + removeFormSchemaByFields( + (schema.children ?? []) as TSchema[], + fields, + ), + ), + ); + continue; + } + + if (!schema.fieldName || !fieldSet.has(schema.fieldName)) { + result.push(schema); + } + } + + return result; +} + export function createFormFieldSchema( - schema: AnyFormSchema, + schema: AnyFormFieldSchema, options: CreateFormFieldSchemaOptions = {}, ): NormalizedFormFieldSchema { const commonConfig = mergeWithArrayOverride( @@ -417,7 +496,7 @@ export function createFormFieldSchema( } export function createArrayChildSchema( - schema: AnyFormSchema, + schema: AnyFormFieldSchema, options: CreateArrayChildSchemaOptions, ): NormalizedFormFieldSchema { const rowPath = `${options.arrayField}[${options.index}]`; diff --git a/packages/@core/ui-kit/form-ui/src/form-value-transform.ts b/packages/@core/ui-kit/form-ui/src/form-value-transform.ts index 1fa172cc8..6bfb98661 100644 --- a/packages/@core/ui-kit/form-ui/src/form-value-transform.ts +++ b/packages/@core/ui-kit/form-ui/src/form-value-transform.ts @@ -2,6 +2,7 @@ import type { ArrayToStringFields, BaseFormComponentType, FieldMappingTime, + FormFieldSchema, FormSchema, FormSchemaContext, FormValues, @@ -17,6 +18,7 @@ import { } from './field-name'; import { getFormArraySchemaChildren, + getFormFieldSchemas, resolveArrayChildFieldName, } from './form-render/schema'; @@ -26,6 +28,12 @@ type AnyFormSchema = FormSchema< TValues >; +type AnyFormFieldSchema = FormFieldSchema< + BaseFormComponentType, + Record, + TValues +>; + function processFields( fields: string[], separator: string, @@ -132,7 +140,7 @@ function applyRangeTimeFields( } function applyValueFormatBySchemas( - schemas: AnyFormSchema[], + schemas: AnyFormFieldSchema[], values: Record, parentPath?: string, parentContext?: FormSchemaContext, @@ -153,7 +161,8 @@ function applyValueFormatBySchemas( row, }; - const children = getFormArraySchemaChildren>(schema); + const children = + getFormArraySchemaChildren>(schema); if (children.length > 0) { const arrayValue = getValueByFieldName(values, fieldName); if (Array.isArray(arrayValue)) { @@ -197,7 +206,7 @@ export function applyFormValueFormats( schemas: AnyFormSchema[], ) { const values = cloneDeep(originValues); - applyValueFormatBySchemas(schemas, values); + applyValueFormatBySchemas(getFormFieldSchemas(schemas), values); return values; } @@ -210,7 +219,7 @@ export function formatFormValues( const values = cloneDeep(originValues); applyArrayToStringFields(values, arrayToStringFields); applyRangeTimeFields(values, fieldMappingTime); - applyValueFormatBySchemas(schemas, values); + applyValueFormatBySchemas(getFormFieldSchemas(schemas), values); return values; } diff --git a/packages/@core/ui-kit/form-ui/src/index.ts b/packages/@core/ui-kit/form-ui/src/index.ts index db7b7e9d3..e71d15711 100644 --- a/packages/@core/ui-kit/form-ui/src/index.ts +++ b/packages/@core/ui-kit/form-ui/src/index.ts @@ -9,6 +9,7 @@ export type { FormActions, FormCodec, FormContextApi, + FormGroupSchema, FormLayout, FormSchemaContext, FormValues, @@ -17,7 +18,9 @@ export type { VbenFormComponent, VbenFormDefaultSlotProps, VbenFormFieldArrayProps, + FormFieldSchema as VbenFormFieldSchema, VbenFormFieldSlotProps, + FormGroupSchema as VbenFormGroupSchema, VbenFormProps, VbenFormResolvedComponentProps, FormSchema as VbenFormSchema, diff --git a/packages/@core/ui-kit/form-ui/src/types.ts b/packages/@core/ui-kit/form-ui/src/types.ts index 84665507e..8bcf2ffe5 100644 --- a/packages/@core/ui-kit/form-ui/src/types.ts +++ b/packages/@core/ui-kit/form-ui/src/types.ts @@ -667,7 +667,7 @@ type FormArraySchema< 'disabled' | 'globalCommonConfig' | 'name' | 'schema' >; /** 数组子字段定义 */ - children: FormSchema[]; + children: FormFieldSchema[]; /** 兼容显式指定内置数组编辑器 */ component?: Component | T; /** 兼容通过 componentProps 传递数组编辑器参数 */ @@ -676,7 +676,51 @@ type FormArraySchema< type: 'array'; } & FormSchemaBody; -export type FormSchema< +/** + * 表单分组,用于把若干字段组织成一个可折叠的区块。 + * 分组本身不是字段,不参与取值与校验。 + */ +export interface FormGroupSchema< + T extends BaseFormComponentType = BaseFormComponentType, + P extends Record = Record, + TValues extends FormValues = FormValues, +> { + /** 分组内的字段定义 */ + children: FormFieldSchema[]; + /** + * 是否允许折叠 + * @default true + */ + collapsible?: boolean; + /** 分组不是字段,禁止指定组件 */ + component?: never; + /** + * 是否默认折叠 + * @default false + */ + defaultCollapsed?: boolean; + /** 标题右侧的附加内容 */ + extra?: CustomRenderType; + /** 分组不是字段,禁止指定字段名 */ + fieldName?: never; + /** 分组容器在表单栅格中的样式,默认占满一行 */ + formItemClass?: FormItemClassType; + /** 是否隐藏分组 */ + hide?: boolean; + /** 分组标识,用于渲染时的稳定 key,缺省按索引 */ + name?: string; + /** 分组标题 */ + title?: CustomRenderType; + /** 分组标记 */ + type: 'group'; + /** 分组内部的栅格布局,缺省继承表单的 wrapperClass */ + wrapperClass?: WrapperClassType; +} + +/** + * 单个表单字段的 schema(普通字段 / 数组字段) + */ +export type FormFieldSchema< T extends BaseFormComponentType = BaseFormComponentType, P extends Record = Record, TValues extends FormValues = FormValues, @@ -685,6 +729,15 @@ export type FormSchema< | FormSchemaDiscriminated | FormSchemaFallback; +/** + * 表单 schema 项:字段或分组,以 `type` 区分 + */ +export type FormSchema< + T extends BaseFormComponentType = BaseFormComponentType, + P extends Record = Record, + TValues extends FormValues = FormValues, +> = FormFieldSchema | FormGroupSchema; + /** * 数组编辑器(VbenFormFieldArray)的组件参数 */ @@ -712,8 +765,8 @@ export interface VbenFormFieldArrayProps< min?: number; /** 数组字段路径,由外层 FormField 透传 */ name?: string; - /** 列定义,每一列是一个子字段(复用 FormSchema) */ - schema?: FormSchema[]; + /** 列定义,每一列是一个子字段(复用 FormFieldSchema) */ + schema?: FormFieldSchema[]; /** 是否显示序号列 */ showIndex?: boolean; } diff --git a/packages/@core/ui-kit/form-ui/src/use-form-context.ts b/packages/@core/ui-kit/form-ui/src/use-form-context.ts index ae3d69f90..262b49663 100644 --- a/packages/@core/ui-kit/form-ui/src/use-form-context.ts +++ b/packages/@core/ui-kit/form-ui/src/use-form-context.ts @@ -12,6 +12,7 @@ import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils'; import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod'; import { getDefaultsForSchema } from 'zod-defaults'; +import { getFormFieldSchemas } from './form-render/schema'; import { useFormRuntime } from './form-runtime'; type ExtendFormProps = VbenFormProps & { @@ -49,7 +50,7 @@ export function useFormInitial( const initialValues: Record = {}; const zodObject: Record = {}; - (unref(props).schema || []).forEach((item) => { + getFormFieldSchemas(unref(props).schema ?? []).forEach((item) => { if (Reflect.has(item, 'defaultValue')) { set(initialValues, item.fieldName, item.defaultValue); } else if (item.rules && !isString(item.rules)) { diff --git a/packages/@core/ui-kit/form-ui/src/vben-use-form.vue b/packages/@core/ui-kit/form-ui/src/vben-use-form.vue index d6cac0f47..fff1596f5 100644 --- a/packages/@core/ui-kit/form-ui/src/vben-use-form.vue +++ b/packages/@core/ui-kit/form-ui/src/vben-use-form.vue @@ -15,6 +15,7 @@ import { DEFAULT_FORM_COMMON_CONFIG, } from './config'; import { Form } from './form-render'; +import { getFormFieldSchemas } from './form-render/schema'; import { provideComponentRefMap, provideFormProps, @@ -92,7 +93,9 @@ watch(values, (currentValues, previousValues) => { if (!handleValuesChange && !submitOnChange) { return; } - const fields = state?.value.schema?.map((item) => item.fieldName) ?? []; + const fields = getFormFieldSchemas(state?.value.schema ?? []).map( + (item) => item.fieldName, + ); if (handleValuesChange && fields.length > 0) { const changedFields = fields.filter((field) => { return !isEqual( diff --git a/playground/src/views/examples/form/collapsible.vue b/playground/src/views/examples/form/collapsible.vue index 338c70bdc..e10754c50 100644 --- a/playground/src/views/examples/form/collapsible.vue +++ b/playground/src/views/examples/form/collapsible.vue @@ -232,12 +232,122 @@ const [BaseForm, baseFormApi] = useVbenForm({ wrapperClass: 'grid-cols-12', }); +// 通过 schema 的 type: 'group' 把字段组织成可折叠的分组 +const [GroupForm, groupFormApi] = useVbenForm({ + showDefaultActions: false, + commonConfig: { + componentProps: { + class: 'w-full', + }, + }, + handleSubmit: onSubmit, + schema: [ + { + component: 'Input', + fieldName: 'name', + label: '任务名称', + rules: 'required', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: 'SFT', value: 'sft' }, + { label: 'DPO', value: 'dpo' }, + ], + }, + defaultValue: 'sft', + fieldName: 'method', + label: '训练方式', + }, + { + type: 'group', + name: 'training', + title: '训练参数', + children: [ + { + component: 'InputNumber', + defaultValue: 32, + fieldName: 'batchSize', + label: '批次大小', + }, + { + component: 'InputNumber', + defaultValue: 1e-5, + fieldName: 'learningRate', + label: '学习率', + }, + { + component: 'InputNumber', + defaultValue: 3, + fieldName: 'epochs', + label: '循环次数', + }, + { + component: 'InputNumber', + defaultValue: 32_768, + fieldName: 'maxLength', + label: '序列长度', + }, + ], + }, + { + type: 'group', + name: 'advanced', + title: '高级选项', + extra: () => + h( + 'span', + { class: 'text-muted-foreground text-xs' }, + '默认折叠,校验失败时自动展开', + ), + defaultCollapsed: true, + children: [ + { + component: 'Input', + fieldName: 'checkpoint', + label: 'Checkpoint', + rules: 'required', + }, + { + component: 'Switch', + componentProps: { + class: 'w-auto', + }, + defaultValue: false, + fieldName: 'enableEval', + label: '定期评估', + }, + { + component: 'Textarea', + fieldName: 'remark', + formItemClass: 'col-span-2', + label: '备注', + }, + ], + }, + ], + wrapperClass: 'grid-cols-2', +}); + function onSubmit(values: Record) { message.info({ content: `form values: ${JSON.stringify(values)}`, }); } +async function handleSubmitGroupForm() { + const { valid } = await groupFormApi.validate(); + + if (valid) { + groupFormApi.submit(); + } +} + +function handleResetGroupForm() { + groupFormApi.reset(undefined, { force: true }); +} + function onLayoutChange() { baseFormApi.setState({ layout: layout.value, @@ -277,7 +387,7 @@ async function handleSubmitFormValue() { > - 可折叠表单项、以及可折叠参数配置组件示例 + 可折叠表单项、可折叠参数配置组件,以及 schema 分组折叠示例 @@ -307,5 +417,25 @@ async function handleSubmitFormValue() { + + + + + 提交表单 + + + 重置表单 + + + + + 在 schema 中使用 type: 'group' + 把字段组织成可折叠区块。分组本身不是字段,组内字段与顶层字段完全等价; + 「高级选项」默认折叠,直接提交时会因校验失败自动展开。 + + + + +
可折叠表单项、以及可折叠参数配置组件示例
可折叠表单项、可折叠参数配置组件,以及 schema 分组折叠示例
+ 在 schema 中使用 type: 'group' + 把字段组织成可折叠区块。分组本身不是字段,组内字段与顶层字段完全等价; + 「高级选项」默认折叠,直接提交时会因校验失败自动展开。 +
type: 'group'