Browse Source

fix(@vben-core/form-ui): harden grouped section behavior

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/8379/head
Zehui Chan 3 weeks ago
parent
commit
3d6937211c
  1. 11
      docs/src/components/common-ui/vben-form.md
  2. 60
      packages/@core/ui-kit/form-ui/__tests__/form-group.test.ts
  3. 77
      packages/@core/ui-kit/form-ui/src/form-render/form-group.vue

11
docs/src/components/common-ui/vben-form.md

@ -636,10 +636,10 @@ export interface FormCommonConfig {
::: :::
::: details FormSchema ::: details FormFieldSchema
```ts ```ts
export interface FormSchema< export interface FormFieldSchema<
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
TValues extends FormValues = FormValues, TValues extends FormValues = FormValues,
> extends FormCommonConfig { > extends FormCommonConfig {
@ -682,7 +682,7 @@ export interface FormSchema<
::: details FormGroupSchema ::: details FormGroupSchema
`schema` 数组中的每一项要么是字段(`FormFieldSchema`,即上面的 `FormSchema`),要么是分组(`FormGroupSchema`),以 `type: 'group'` 区分。 `schema` 数组中的每一项要么是字段(`FormFieldSchema`),要么是分组(`FormGroupSchema`),以 `type: 'group'` 区分。
```ts ```ts
export interface FormGroupSchema< export interface FormGroupSchema<
@ -710,6 +710,11 @@ export interface FormGroupSchema<
/** 分组内部的栅格布局,缺省继承表单的 wrapperClass */ /** 分组内部的栅格布局,缺省继承表单的 wrapperClass */
wrapperClass?: WrapperClassType; wrapperClass?: WrapperClassType;
} }
export type FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
TValues extends FormValues = FormValues,
> = FormFieldSchema<T, TValues> | FormGroupSchema<T, TValues>;
``` ```
::: :::

60
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.get('.form-group-title').text()).toBe('Contact');
expect(wrapper.text()).toContain('Optional'); expect(wrapper.text()).toContain('Optional');
expect(wrapper.get('.form-group-trigger').text()).not.toContain('Optional');
expect(wrapper.findAll('input')).toHaveLength(3); expect(wrapper.findAll('input')).toHaveLength(3);
expect(wrapper.get('.form-group').findAll('input')).toHaveLength(2); expect(wrapper.get('.form-group').findAll('input')).toHaveLength(2);
expect(getGroupState(wrapper)).toBe('open'); expect(getGroupState(wrapper)).toBe('open');
@ -91,22 +92,34 @@ describe('form group rendering', () => {
await flushPromises(); await flushPromises();
expect(getGroupState(wrapper)).toBe('closed'); expect(getGroupState(wrapper)).toBe('closed');
expect(wrapper.get('button.form-group-trigger').attributes('type')).toBe(
await wrapper.get('.form-group-header').trigger('click'); '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(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'); 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({ const [Form] = useVbenForm({
schema: [createContactGroup({ collapsible: false })], schema: [
createContactGroup({ collapsible: false, defaultCollapsed: true }),
],
}); });
const wrapper = mount(Form); const wrapper = mount(Form);
wrappers.push(wrapper); wrappers.push(wrapper);
await flushPromises(); await flushPromises();
expect(wrapper.find('button.form-group-trigger').exists()).toBe(false);
await wrapper.get('.form-group-header').trigger('click'); await wrapper.get('.form-group-header').trigger('click');
expect(getGroupState(wrapper)).toBe('open'); expect(getGroupState(wrapper)).toBe('open');
}); });
@ -143,6 +156,43 @@ describe('form group rendering', () => {
expect(wrapper.text()).toContain('Email is required'); 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 () => { it('skips hidden groups and forwards field slots into groups', async () => {
const [Form] = useVbenForm({ const [Form] = useVbenForm({
schema: [ schema: [

77
packages/@core/ui-kit/form-ui/src/form-render/form-group.vue

@ -24,7 +24,9 @@ const props = withDefaults(defineProps<Props>(), {
const formRenderProps = injectRenderFormProps(); const formRenderProps = injectRenderFormProps();
const collapseOpen = ref(!props.schema.defaultCollapsed); const collapseOpen = ref(
props.schema.collapsible === false || !props.schema.defaultCollapsed,
);
const shouldCollapsible = computed(() => { const shouldCollapsible = computed(() => {
return props.schema.collapsible !== false; return props.schema.collapsible !== false;
@ -33,8 +35,14 @@ const shouldCollapsible = computed(() => {
// 组内任一字段校验失败时自动展开,避免错误提示被折叠区域遮住 // 组内任一字段校验失败时自动展开,避免错误提示被折叠区域遮住
const hasInvalidField = computed(() => { const hasInvalidField = computed(() => {
const errors = formRenderProps.form?.errors ?? {}; const errors = formRenderProps.form?.errors ?? {};
return props.schema.children.some((field) => return props.schema.children.some(({ fieldName }) =>
Boolean(errors[field.fieldName]), Object.entries(errors).some(
([errorFieldName, error]) =>
Boolean(error) &&
(errorFieldName === fieldName ||
errorFieldName.startsWith(`${fieldName}.`) ||
errorFieldName.startsWith(`${fieldName}[`)),
),
); );
}); });
@ -65,36 +73,47 @@ function toggleCollapsed() {
<VbenCollapsible v-model:open="collapseOpen" :show-trigger="false"> <VbenCollapsible v-model:open="collapseOpen" :show-trigger="false">
<template #label> <template #label>
<div <div
:class=" class="form-group-header mb-2 flex min-h-7 flex-1 items-center gap-2"
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> <component
<span :is="shouldCollapsible ? 'button' : 'div'"
v-if="props.schema.title" :aria-expanded="shouldCollapsible ? collapseOpen : undefined"
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=" :class="
cn('text-muted-foreground transition-transform', { cn('flex min-w-0 flex-1 items-center gap-2 text-left', {
'ml-auto': !props.schema.extra, 'focus-visible:ring-ring cursor-pointer select-none rounded-sm outline-none focus-visible:ring-2':
'rotate-180': collapseOpen, 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> </div>
</template> </template>
<template #collapsibleContent> <template #collapsibleContent>

Loading…
Cancel
Save