Browse Source
* 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 <cursoragent@cursor.com> * fix(@vben-core/form-ui): harden grouped section behavior Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>pull/8383/head
committed by
GitHub
19 changed files with 1145 additions and 63 deletions
@ -0,0 +1,5 @@ |
|||||
|
--- |
||||
|
'@vben-core/form-ui': minor |
||||
|
--- |
||||
|
|
||||
|
feat: support grouped schema for collapsible sections |
||||
@ -0,0 +1,239 @@ |
|||||
|
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<Extract<FormSchema, { type: 'group' }>> = {}, |
||||
|
): 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.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'); |
||||
|
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'); |
||||
|
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('button.form-group-trigger').trigger('click'); |
||||
|
expect(getGroupState(wrapper)).toBe('closed'); |
||||
|
}); |
||||
|
|
||||
|
it('keeps a non-collapsible group open despite defaultCollapsed', async () => { |
||||
|
const [Form] = useVbenForm({ |
||||
|
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'); |
||||
|
}); |
||||
|
|
||||
|
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('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: [ |
||||
|
createContactGroup(), |
||||
|
{ |
||||
|
children: [{ component: TestInput, fieldName: 'secret' }], |
||||
|
hide: true, |
||||
|
name: 'hidden', |
||||
|
type: 'group', |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
|
const wrapper = mount(Form, { |
||||
|
slots: { |
||||
|
phone: (slotProps: Record<string, any>) => |
||||
|
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'); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,126 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import type { FormGroupSchema } from '../types'; |
||||
|
|
||||
|
import { computed, ref, watch } from 'vue'; |
||||
|
|
||||
|
import { ChevronsDown } from '@vben-core/icons'; |
||||
|
import { VbenCollapsible, VbenRenderContent } from '@vben-core/shadcn-ui'; |
||||
|
import { cn } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
import { injectRenderFormProps } from './context'; |
||||
|
|
||||
|
interface Props { |
||||
|
/** 分组内容区(字段栅格)的样式 */ |
||||
|
contentClass?: string; |
||||
|
/** 表单折叠按钮收起时是否隐藏当前分组 */ |
||||
|
hidden?: boolean; |
||||
|
schema: FormGroupSchema; |
||||
|
} |
||||
|
|
||||
|
const props = withDefaults(defineProps<Props>(), { |
||||
|
contentClass: '', |
||||
|
hidden: false, |
||||
|
}); |
||||
|
|
||||
|
const formRenderProps = injectRenderFormProps(); |
||||
|
|
||||
|
const collapseOpen = ref( |
||||
|
props.schema.collapsible === false || !props.schema.defaultCollapsed, |
||||
|
); |
||||
|
|
||||
|
const shouldCollapsible = computed(() => { |
||||
|
return props.schema.collapsible !== false; |
||||
|
}); |
||||
|
|
||||
|
// 组内任一字段校验失败时自动展开,避免错误提示被折叠区域遮住 |
||||
|
const hasInvalidField = computed(() => { |
||||
|
const errors = formRenderProps.form?.errors ?? {}; |
||||
|
return props.schema.children.some(({ fieldName }) => |
||||
|
Object.entries(errors).some( |
||||
|
([errorFieldName, error]) => |
||||
|
Boolean(error) && |
||||
|
(errorFieldName === fieldName || |
||||
|
errorFieldName.startsWith(`${fieldName}.`) || |
||||
|
errorFieldName.startsWith(`${fieldName}[`)), |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
watch(hasInvalidField, (invalid) => { |
||||
|
if (invalid && !collapseOpen.value) { |
||||
|
collapseOpen.value = true; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
function toggleCollapsed() { |
||||
|
if (!shouldCollapsible.value) { |
||||
|
return; |
||||
|
} |
||||
|
collapseOpen.value = !collapseOpen.value; |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div |
||||
|
:class=" |
||||
|
cn( |
||||
|
'form-group col-span-full flex w-full flex-col', |
||||
|
{ hidden: props.hidden }, |
||||
|
props.schema.formItemClass, |
||||
|
) |
||||
|
" |
||||
|
> |
||||
|
<VbenCollapsible v-model:open="collapseOpen" :show-trigger="false"> |
||||
|
<template #label> |
||||
|
<div |
||||
|
class="form-group-header mb-2 flex min-h-7 flex-1 items-center gap-2" |
||||
|
> |
||||
|
<component |
||||
|
:is="shouldCollapsible ? 'button' : 'div'" |
||||
|
:aria-expanded="shouldCollapsible ? collapseOpen : undefined" |
||||
|
:class=" |
||||
|
cn('flex min-w-0 flex-1 items-center gap-2 text-left', { |
||||
|
'focus-visible:ring-ring cursor-pointer select-none rounded-sm outline-none focus-visible:ring-2': |
||||
|
shouldCollapsible, |
||||
|
}) |
||||
|
" |
||||
|
:type="shouldCollapsible ? 'button' : undefined" |
||||
|
class="form-group-trigger" |
||||
|
@click="toggleCollapsed" |
||||
|
> |
||||
|
<span |
||||
|
class="bg-primary h-3.5 w-[3px] flex-none rounded-full" |
||||
|
></span> |
||||
|
<span |
||||
|
v-if="props.schema.title" |
||||
|
class="form-group-title text-sm font-medium leading-6" |
||||
|
> |
||||
|
<VbenRenderContent :content="props.schema.title" /> |
||||
|
</span> |
||||
|
<ChevronsDown |
||||
|
v-if="shouldCollapsible" |
||||
|
aria-hidden="true" |
||||
|
:size="16" |
||||
|
:class=" |
||||
|
cn( |
||||
|
'text-muted-foreground ml-auto flex-none transition-transform', |
||||
|
{ |
||||
|
'rotate-180': collapseOpen, |
||||
|
}, |
||||
|
) |
||||
|
" |
||||
|
/> |
||||
|
</component> |
||||
|
<div v-if="props.schema.extra" class="flex-none"> |
||||
|
<VbenRenderContent :content="props.schema.extra" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
<template #collapsibleContent> |
||||
|
<div :class="props.contentClass"> |
||||
|
<slot></slot> |
||||
|
</div> |
||||
|
</template> |
||||
|
</VbenCollapsible> |
||||
|
</div> |
||||
|
</template> |
||||
Loading…
Reference in new issue