Browse Source
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>pull/8379/head
19 changed files with 1069 additions and 61 deletions
@ -0,0 +1,5 @@ |
|||
--- |
|||
'@vben-core/form-ui': minor |
|||
--- |
|||
|
|||
feat: support grouped schema for collapsible sections |
|||
@ -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<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.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<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,107 @@ |
|||
<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.defaultCollapsed); |
|||
|
|||
const shouldCollapsible = computed(() => { |
|||
return props.schema.collapsible !== false; |
|||
}); |
|||
|
|||
// 组内任一字段校验失败时自动展开,避免错误提示被折叠区域遮住 |
|||
const hasInvalidField = computed(() => { |
|||
const errors = formRenderProps.form?.errors ?? {}; |
|||
return props.schema.children.some((field) => |
|||
Boolean(errors[field.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=" |
|||
cn( |
|||
'form-group-header mb-2 flex min-h-7 flex-1 items-center gap-2', |
|||
{ |
|||
'cursor-pointer select-none': shouldCollapsible, |
|||
}, |
|||
) |
|||
" |
|||
@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> |
|||
<div v-if="props.schema.extra" class="ml-auto" @click.stop> |
|||
<VbenRenderContent :content="props.schema.extra" /> |
|||
</div> |
|||
<ChevronsDown |
|||
v-if="shouldCollapsible" |
|||
:size="16" |
|||
:class=" |
|||
cn('text-muted-foreground transition-transform', { |
|||
'ml-auto': !props.schema.extra, |
|||
'rotate-180': collapseOpen, |
|||
}) |
|||
" |
|||
/> |
|||
</div> |
|||
</template> |
|||
<template #collapsibleContent> |
|||
<div :class="props.contentClass"> |
|||
<slot></slot> |
|||
</div> |
|||
</template> |
|||
</VbenCollapsible> |
|||
</div> |
|||
</template> |
|||
Loading…
Reference in new issue