refactor: 修改form-field-array支持dependencies
This commit is contained in:
@@ -101,6 +101,47 @@ describe('formApi', () => {
|
||||
expect(formActions.values).toEqual(originalValuesSnapshot);
|
||||
});
|
||||
|
||||
it('should format child schema values inside array fields', async () => {
|
||||
formApi.setState({
|
||||
schema: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
component: 'text',
|
||||
fieldName: 'name',
|
||||
valueFormat: (
|
||||
value: any,
|
||||
setValue: any,
|
||||
_values: any,
|
||||
ctx: any,
|
||||
) => {
|
||||
setValue('normalizedName', value?.trim());
|
||||
setValue('$root.firstRow', ctx?.rowIndex);
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldName: 'contacts',
|
||||
type: 'array',
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
values: {
|
||||
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
|
||||
},
|
||||
};
|
||||
|
||||
await formApi.mount(formActions, new Map());
|
||||
|
||||
const values = await formApi.getValues();
|
||||
expect(values).toEqual({
|
||||
contacts: [{ normalizedName: 'Ada' }, { normalizedName: 'Grace' }],
|
||||
firstRow: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set field value', async () => {
|
||||
const setFieldValueMock = vi.fn();
|
||||
const formActions: any = {
|
||||
@@ -229,6 +270,35 @@ describe('updateSchema', () => {
|
||||
expect(instance.state?.schema?.[1]?.label).toBe('Age');
|
||||
});
|
||||
|
||||
it('should update child schema by parent path', () => {
|
||||
instance.state = {
|
||||
schema: [
|
||||
{
|
||||
children: [
|
||||
{ component: 'text', fieldName: 'name', label: 'Name' },
|
||||
{ component: 'text', fieldName: 'phone', label: 'Phone' },
|
||||
],
|
||||
fieldName: 'contacts',
|
||||
type: 'array',
|
||||
} as any,
|
||||
],
|
||||
};
|
||||
|
||||
instance.updateSchema([
|
||||
{
|
||||
fieldName: 'contacts.name',
|
||||
label: 'Full Name',
|
||||
},
|
||||
]);
|
||||
|
||||
expect((instance.state?.schema?.[0] as any)?.children?.[0]?.label).toBe(
|
||||
'Full Name',
|
||||
);
|
||||
expect((instance.state?.schema?.[0] as any)?.children?.[1]?.label).toBe(
|
||||
'Phone',
|
||||
);
|
||||
});
|
||||
|
||||
it('should log an error if fieldName is missing in some items', () => {
|
||||
const newSchema: any[] = [
|
||||
{ component: 'textarea', fieldName: 'name' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormSchema } from '../types';
|
||||
import type { FormCommonConfig, FormSchema } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
VbenIconButton,
|
||||
VbenRenderContent,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
import { cn, set } from '@vben-core/shared/utils';
|
||||
|
||||
import { useFieldArray } from 'vee-validate';
|
||||
|
||||
import FormField from '../form-render/form-field.vue';
|
||||
import { createArrayChildSchema } from '../form-render/schema';
|
||||
|
||||
defineOptions({ name: 'VbenFormFieldArray', inheritAttrs: false });
|
||||
|
||||
@@ -23,6 +24,8 @@ const props = withDefaults(
|
||||
actionText?: string;
|
||||
/** 「添加」按钮文案 */
|
||||
addButtonText?: string;
|
||||
/** 子字段通用配置 */
|
||||
commonConfig?: FormCommonConfig;
|
||||
/**
|
||||
* 新增一行时生成的默认数据;缺省时按 schema 的 fieldName 生成空对象
|
||||
*/
|
||||
@@ -30,6 +33,8 @@ const props = withDefaults(
|
||||
disabled?: boolean;
|
||||
/** 空数据文案 */
|
||||
emptyText?: string;
|
||||
/** 子字段全局通用配置 */
|
||||
globalCommonConfig?: FormCommonConfig;
|
||||
/** 最多行数 */
|
||||
max?: number;
|
||||
/** 最少行数 */
|
||||
@@ -51,6 +56,8 @@ const props = withDefaults(
|
||||
createRow: undefined,
|
||||
disabled: false,
|
||||
emptyText: '暂无数据',
|
||||
commonConfig: () => ({}),
|
||||
globalCommonConfig: () => ({}),
|
||||
max: Number.POSITIVE_INFINITY,
|
||||
min: 0,
|
||||
name: '',
|
||||
@@ -67,12 +74,33 @@ const { fields, push, remove } = useFieldArray<Record<string, any>>(
|
||||
|
||||
const canAdd = computed(() => fields.value.length < props.max);
|
||||
const canRemove = computed(() => fields.value.length > props.min);
|
||||
const gridStyle = computed(() => {
|
||||
const columns = [
|
||||
...(props.showIndex ? ['3rem'] : []),
|
||||
...props.schema.map(() => 'minmax(0, 1fr)'),
|
||||
'4rem',
|
||||
];
|
||||
return {
|
||||
gridTemplateColumns: columns.join(' '),
|
||||
};
|
||||
});
|
||||
|
||||
function buildDefaultRow(): Record<string, any> {
|
||||
if (props.createRow) {
|
||||
return props.createRow();
|
||||
}
|
||||
return Object.fromEntries(props.schema.map((col) => [col.fieldName, null]));
|
||||
|
||||
const row: Record<string, any> = {};
|
||||
props.schema.forEach((col) => {
|
||||
const value =
|
||||
Reflect.has(col, 'defaultValue') && col.defaultValue !== undefined
|
||||
? col.defaultValue
|
||||
: 'type' in col && col.type === 'array'
|
||||
? []
|
||||
: null;
|
||||
set(row, col.fieldName, value);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
@@ -89,83 +117,101 @@ function removeRow(index: number) {
|
||||
remove(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把列定义转换为子单元格 FormField 所需的 props。
|
||||
* - fieldName 替换为嵌套路径 `name[index].fieldName`,让校验与取值落在数组元素上
|
||||
* - hideLabel:表头已展示列名,单元格不重复显示
|
||||
*/
|
||||
function cellProps(col: FormSchema, index: number) {
|
||||
return {
|
||||
...col,
|
||||
commonComponentProps: {},
|
||||
disabled: props.disabled,
|
||||
fieldName: `${arrayPath.value}[${index}].${col.fieldName}`,
|
||||
formFieldProps: {},
|
||||
hideLabel: true,
|
||||
};
|
||||
function rowSchemas(index: number) {
|
||||
return props.schema.map((col) =>
|
||||
createArrayChildSchema(col as never, {
|
||||
arrayField: arrayPath.value,
|
||||
commonConfig: props.commonConfig,
|
||||
disabled: props.disabled,
|
||||
globalCommonConfig: props.globalCommonConfig,
|
||||
index,
|
||||
}),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('w-full', $attrs.class as string)">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="border-border border-b">
|
||||
<th
|
||||
v-if="showIndex"
|
||||
class="text-muted-foreground w-12 px-2 py-2 text-left text-sm font-normal"
|
||||
>
|
||||
#
|
||||
</th>
|
||||
<th
|
||||
v-for="col in schema"
|
||||
:key="col.fieldName"
|
||||
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
|
||||
>
|
||||
<VbenRenderContent :content="col.label" />
|
||||
</th>
|
||||
<th
|
||||
class="text-muted-foreground w-16 px-2 py-2 text-left text-sm font-normal"
|
||||
>
|
||||
{{ actionText }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(entry, index) in fields"
|
||||
:key="entry.key"
|
||||
class="border-border/60 border-b align-top"
|
||||
<div class="border-border/70 overflow-hidden rounded-md border">
|
||||
<div
|
||||
class="bg-muted/30 border-border hidden border-b px-2 sm:grid"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<div
|
||||
v-if="showIndex"
|
||||
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
|
||||
>
|
||||
<td v-if="showIndex" class="text-muted-foreground px-2 py-3 text-sm">
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td v-for="col in schema" :key="col.fieldName" class="px-2 py-2">
|
||||
<FormField v-bind="cellProps(col, index)" />
|
||||
</td>
|
||||
<td class="px-2 py-3">
|
||||
<VbenIconButton
|
||||
:disabled="disabled || !canRemove"
|
||||
:on-click="() => removeRow(index)"
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</VbenIconButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
#
|
||||
</div>
|
||||
<div
|
||||
v-for="col in schema"
|
||||
:key="col.fieldName"
|
||||
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
|
||||
>
|
||||
<VbenRenderContent :content="col.label" />
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
|
||||
>
|
||||
{{ actionText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="fields.length === 0"
|
||||
class="text-muted-foreground border-border/60 border-b py-6 text-center text-sm"
|
||||
>
|
||||
{{ emptyText }}
|
||||
<div
|
||||
v-for="(entry, index) in fields"
|
||||
:key="entry.key"
|
||||
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<div
|
||||
v-if="showIndex"
|
||||
class="text-muted-foreground mb-2 text-sm sm:mb-0 sm:px-4 sm:py-3"
|
||||
>
|
||||
<span class="sm:hidden">#</span>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-for="(childSchema, childIndex) in rowSchemas(index)"
|
||||
:key="childSchema.fieldName"
|
||||
>
|
||||
<div class="min-w-0 py-2 sm:px-2">
|
||||
<div
|
||||
class="text-muted-foreground mb-1 text-xs font-medium sm:hidden"
|
||||
>
|
||||
<VbenRenderContent :content="schema?.[childIndex]?.label" />
|
||||
</div>
|
||||
<FormField
|
||||
v-bind="childSchema"
|
||||
:class="childSchema.formItemClass"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end pt-1 sm:block sm:px-2 sm:py-3">
|
||||
<VbenIconButton
|
||||
type="button"
|
||||
:disabled="disabled || !canRemove"
|
||||
:on-click="() => removeRow(index)"
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="fields.length === 0"
|
||||
class="text-muted-foreground py-6 text-center text-sm"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VbenButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
:disabled="disabled || !canAdd"
|
||||
class="mt-3 w-full border-dashed"
|
||||
@click="addRow"
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
import { resolveFieldNamePath } from './field-name';
|
||||
import {
|
||||
getFormArraySchemaChildren,
|
||||
resolveArrayChildFieldName,
|
||||
} from './form-render/schema';
|
||||
|
||||
function getDefaultState(): VbenFormProps {
|
||||
return {
|
||||
@@ -393,25 +397,10 @@ export class FormApi {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const currentSchema = [...(this.state?.schema ?? [])];
|
||||
|
||||
const updatedMap: Record<string, any> = {};
|
||||
|
||||
updated.forEach((item) => {
|
||||
if (item.fieldName) {
|
||||
updatedMap[item.fieldName] = item;
|
||||
}
|
||||
});
|
||||
|
||||
currentSchema.forEach((schema, index) => {
|
||||
const updatedData = updatedMap[schema.fieldName];
|
||||
if (updatedData) {
|
||||
currentSchema[index] = mergeWithArrayOverride(
|
||||
updatedData,
|
||||
schema,
|
||||
) as FormSchema;
|
||||
}
|
||||
});
|
||||
const currentSchema = this.updateSchemaList(
|
||||
[...(this.state?.schema ?? [])],
|
||||
updated,
|
||||
);
|
||||
this.setState({ schema: currentSchema });
|
||||
}
|
||||
|
||||
@@ -456,6 +445,79 @@ export class FormApi {
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
private applyValueFormatBySchemas(
|
||||
schemas: FormSchema[],
|
||||
values: Record<string, any>,
|
||||
parentPath?: string,
|
||||
parentContext?: {
|
||||
arrayField?: string;
|
||||
row?: Record<string, any>;
|
||||
rowIndex?: number;
|
||||
rowPath?: string;
|
||||
},
|
||||
) {
|
||||
schemas.forEach((schema) => {
|
||||
const fieldName = parentPath
|
||||
? resolveArrayChildFieldName(parentPath, schema.fieldName)
|
||||
: schema.fieldName;
|
||||
const row =
|
||||
parentPath && parentContext?.rowPath
|
||||
? this.resolveValueByFieldName(values, parentContext.rowPath)
|
||||
: parentContext?.row;
|
||||
const schemaContext = {
|
||||
...parentContext,
|
||||
fieldName,
|
||||
originalFieldName: schema.fieldName,
|
||||
rootValues: values,
|
||||
row,
|
||||
};
|
||||
|
||||
const children = getFormArraySchemaChildren(schema);
|
||||
if (children.length > 0) {
|
||||
const arrayValue = this.resolveValueByFieldName(values, fieldName);
|
||||
if (Array.isArray(arrayValue)) {
|
||||
arrayValue.forEach((rowValue, index) => {
|
||||
const rowPath = `${fieldName}[${index}]`;
|
||||
this.applyValueFormatBySchemas(
|
||||
children as FormSchema[],
|
||||
values,
|
||||
rowPath,
|
||||
{
|
||||
arrayField: fieldName,
|
||||
row: rowValue,
|
||||
rowIndex: index,
|
||||
rowPath,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.valueFormat) {
|
||||
const value = this.resolveValueByFieldName(values, fieldName);
|
||||
|
||||
this.deleteValueByFieldName(values, fieldName);
|
||||
|
||||
const formattedValue = schema.valueFormat(
|
||||
value,
|
||||
(key, nextValue) => {
|
||||
this.setValueByFieldName(
|
||||
values,
|
||||
this.resolveValueFormatFieldName(key, parentPath),
|
||||
nextValue,
|
||||
);
|
||||
},
|
||||
values,
|
||||
schemaContext,
|
||||
);
|
||||
|
||||
if (formattedValue !== undefined) {
|
||||
this.setValueByFieldName(values, fieldName, formattedValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private deleteValueByFieldName(
|
||||
values: Record<string, any>,
|
||||
fieldName: string,
|
||||
@@ -611,30 +673,7 @@ export class FormApi {
|
||||
|
||||
private handleValueFormat = (originValues: Record<string, any>) => {
|
||||
const values = { ...originValues };
|
||||
const currentSchema = this.state?.schema ?? [];
|
||||
|
||||
currentSchema.forEach((schema) => {
|
||||
if (!schema.valueFormat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName = schema.fieldName;
|
||||
const value = this.resolveValueByFieldName(values, fieldName);
|
||||
|
||||
this.deleteValueByFieldName(values, fieldName);
|
||||
|
||||
const formattedValue = schema.valueFormat(
|
||||
value,
|
||||
(key, nextValue) => {
|
||||
this.setValueByFieldName(values, key, nextValue);
|
||||
},
|
||||
values,
|
||||
);
|
||||
|
||||
if (formattedValue !== undefined) {
|
||||
this.setValueByFieldName(values, fieldName, formattedValue);
|
||||
}
|
||||
});
|
||||
this.applyValueFormatBySchemas(this.state?.schema ?? [], values);
|
||||
|
||||
return values;
|
||||
};
|
||||
@@ -654,6 +693,27 @@ export class FormApi {
|
||||
});
|
||||
};
|
||||
|
||||
private resolveChildUpdateFieldName(
|
||||
parentFieldName: string,
|
||||
fieldName: string,
|
||||
) {
|
||||
if (fieldName.startsWith(`${parentFieldName}.`)) {
|
||||
return fieldName.slice(parentFieldName.length + 1);
|
||||
}
|
||||
|
||||
const indexedPrefix = `${parentFieldName}[`;
|
||||
if (!fieldName.startsWith(indexedPrefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closeIndex = fieldName.indexOf(']', indexedPrefix.length);
|
||||
if (closeIndex === -1 || fieldName[closeIndex + 1] !== '.') {
|
||||
return;
|
||||
}
|
||||
|
||||
return fieldName.slice(closeIndex + 2);
|
||||
}
|
||||
|
||||
private resolveValueByFieldName(
|
||||
values: Record<string, any>,
|
||||
fieldName: string,
|
||||
@@ -666,6 +726,51 @@ export class FormApi {
|
||||
return get(values, fieldName);
|
||||
}
|
||||
|
||||
private resolveValueFormatFieldName(fieldName: string, parentPath?: string) {
|
||||
if (!parentPath) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
if (fieldName.startsWith('$root.')) {
|
||||
return fieldName.slice('$root.'.length);
|
||||
}
|
||||
|
||||
if (fieldName.startsWith('$row.')) {
|
||||
return `${parentPath}.${fieldName.slice('$row.'.length)}`;
|
||||
}
|
||||
|
||||
if (fieldName === parentPath || fieldName.startsWith(`${parentPath}.`)) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
return `${parentPath}.${fieldName}`;
|
||||
}
|
||||
|
||||
private setSchemaChildren(schema: FormSchema, children: FormSchema[]) {
|
||||
if ('children' in schema && Array.isArray(schema.children)) {
|
||||
return {
|
||||
...schema,
|
||||
children,
|
||||
} as FormSchema;
|
||||
}
|
||||
|
||||
if (
|
||||
!isFunction(schema.componentProps) &&
|
||||
schema.componentProps &&
|
||||
Array.isArray((schema.componentProps as Record<string, any>).schema)
|
||||
) {
|
||||
return {
|
||||
...schema,
|
||||
componentProps: {
|
||||
...(schema.componentProps as Record<string, any>),
|
||||
schema: children,
|
||||
},
|
||||
} as FormSchema;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
private setValueByFieldName(
|
||||
values: Record<string, any>,
|
||||
fieldName: string,
|
||||
@@ -680,6 +785,48 @@ export class FormApi {
|
||||
set(values, fieldName, value);
|
||||
}
|
||||
|
||||
private updateSchemaList(
|
||||
currentSchema: FormSchema[],
|
||||
updated: Partial<FormSchema>[],
|
||||
): FormSchema[] {
|
||||
return currentSchema.map((schema): FormSchema => {
|
||||
const exactUpdatedData = updated.find(
|
||||
(item) => item.fieldName === schema.fieldName,
|
||||
);
|
||||
if (exactUpdatedData) {
|
||||
return mergeWithArrayOverride(exactUpdatedData, schema) as FormSchema;
|
||||
}
|
||||
|
||||
const children = getFormArraySchemaChildren(schema);
|
||||
if (children.length === 0) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const childUpdates = updated
|
||||
.map((item) => {
|
||||
const fieldName = item.fieldName
|
||||
? this.resolveChildUpdateFieldName(schema.fieldName, item.fieldName)
|
||||
: undefined;
|
||||
return fieldName
|
||||
? ({
|
||||
...item,
|
||||
fieldName,
|
||||
} as Partial<FormSchema>)
|
||||
: undefined;
|
||||
})
|
||||
.filter(Boolean) as Partial<FormSchema>[];
|
||||
|
||||
if (childUpdates.length === 0) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
return this.setSchemaChildren(
|
||||
schema,
|
||||
this.updateSchemaList(children as FormSchema[], childUpdates),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private updateState() {
|
||||
const currentSchema = this.state?.schema ?? [];
|
||||
const prevSchema = this.prevState?.schema ?? [];
|
||||
|
||||
@@ -78,6 +78,9 @@ const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
|
||||
const formApi = formRenderProps.form;
|
||||
const compact = computed(() => formRenderProps.compact);
|
||||
const isInValid = computed(() => errors.value?.length > 0);
|
||||
const shouldApplyInvalidStyle = computed(() => {
|
||||
return isInValid.value && component !== 'VbenFormFieldArray';
|
||||
});
|
||||
const collapseOpen = ref(!defaultCollapsed);
|
||||
|
||||
function getFormApi(): FormActions {
|
||||
@@ -338,7 +341,7 @@ onUnmounted(() => {
|
||||
<FormItem
|
||||
v-show="isShow"
|
||||
:class="{
|
||||
'form-valid-error': isInValid,
|
||||
'form-valid-error': shouldApplyInvalidStyle,
|
||||
'form-is-required': shouldRequired,
|
||||
'flex-col': isVertical,
|
||||
'flex-row items-center': !isVertical,
|
||||
@@ -406,7 +409,7 @@ onUnmounted(() => {
|
||||
ref="fieldComponentRef"
|
||||
:class="{
|
||||
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
|
||||
isInValid,
|
||||
shouldApplyInvalidStyle,
|
||||
}"
|
||||
v-bind="createComponentProps(slotProps)"
|
||||
:disabled="shouldDisabled"
|
||||
|
||||
@@ -2,27 +2,19 @@
|
||||
import type { GenericObject } from 'vee-validate';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
import type {
|
||||
FormCommonConfig,
|
||||
FormRenderProps,
|
||||
FormSchema,
|
||||
FormShape,
|
||||
} from '../types';
|
||||
import type { FormCommonConfig, FormRenderProps, FormShape } from '../types';
|
||||
import type { NormalizedFormFieldSchema } from './schema';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Form } from '@vben-core/shadcn-ui';
|
||||
import {
|
||||
cn,
|
||||
isFunction,
|
||||
isString,
|
||||
mergeWithArrayOverride,
|
||||
} from '@vben-core/shared/utils';
|
||||
import { cn, isString } from '@vben-core/shared/utils';
|
||||
|
||||
import { provideFormRenderProps } from './context';
|
||||
import { useExpandable } from './expandable';
|
||||
import FormField from './form-field.vue';
|
||||
import { getBaseRules, getDefaultValueInZodStack } from './helper';
|
||||
import { createFormFieldSchema } from './schema';
|
||||
|
||||
interface Props extends FormRenderProps {}
|
||||
|
||||
@@ -95,78 +87,23 @@ const formCollapsed = computed(() => {
|
||||
return props.collapsed && isCalculated.value;
|
||||
});
|
||||
|
||||
const computedSchema = computed(
|
||||
(): (Omit<FormSchema, 'formFieldProps'> & {
|
||||
commonComponentProps: Record<string, any>;
|
||||
formFieldProps: Record<string, any>;
|
||||
})[] => {
|
||||
const {
|
||||
colon = false,
|
||||
componentProps = {},
|
||||
controlClass = '',
|
||||
disabled,
|
||||
disabledOnChangeListener = true,
|
||||
disabledOnInputListener = true,
|
||||
emptyStateValue = undefined,
|
||||
formFieldProps = {},
|
||||
formItemClass = '',
|
||||
hideLabel = false,
|
||||
hideRequiredMark = false,
|
||||
labelClass = '',
|
||||
labelWidth = 100,
|
||||
modelPropName = '',
|
||||
wrapperClass = '',
|
||||
} = mergeWithArrayOverride(props.commonConfig, props.globalCommonConfig);
|
||||
return (props.schema || []).map((schema, index) => {
|
||||
const keepIndex = keepFormItemIndex.value;
|
||||
const computedSchema = computed((): NormalizedFormFieldSchema[] => {
|
||||
return (props.schema || []).map((schema, index) => {
|
||||
const keepIndex = keepFormItemIndex.value;
|
||||
|
||||
const hidden =
|
||||
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
|
||||
props.showCollapseButton && !!formCollapsed.value && keepIndex
|
||||
? keepIndex <= index
|
||||
: false;
|
||||
const hidden =
|
||||
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
|
||||
props.showCollapseButton && !!formCollapsed.value && keepIndex
|
||||
? keepIndex <= index
|
||||
: false;
|
||||
|
||||
// 处理函数形式的formItemClass
|
||||
let resolvedSchemaFormItemClass = schema.formItemClass;
|
||||
if (isFunction(schema.formItemClass)) {
|
||||
try {
|
||||
resolvedSchemaFormItemClass = schema.formItemClass();
|
||||
} catch (error) {
|
||||
console.error('Error calling formItemClass function:', error);
|
||||
resolvedSchemaFormItemClass = '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
colon,
|
||||
disabled,
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
hideLabel,
|
||||
hideRequiredMark,
|
||||
labelWidth,
|
||||
modelPropName,
|
||||
wrapperClass,
|
||||
...schema,
|
||||
commonComponentProps: componentProps,
|
||||
componentProps: schema.componentProps,
|
||||
controlClass: cn(controlClass, schema.controlClass),
|
||||
formFieldProps: {
|
||||
...formFieldProps,
|
||||
...schema.formFieldProps,
|
||||
},
|
||||
formItemClass: cn(
|
||||
'shrink-0',
|
||||
{ hidden },
|
||||
formItemClass,
|
||||
resolvedSchemaFormItemClass,
|
||||
),
|
||||
labelClass: cn(labelClass, schema.labelClass),
|
||||
};
|
||||
return createFormFieldSchema(schema as never, {
|
||||
commonConfig: props.commonConfig,
|
||||
globalCommonConfig: props.globalCommonConfig,
|
||||
hidden,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
341
packages/@core/ui-kit/form-ui/src/form-render/schema.ts
Normal file
341
packages/@core/ui-kit/form-ui/src/form-render/schema.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import type {
|
||||
BaseFormComponentType,
|
||||
FormActions,
|
||||
FormCommonConfig,
|
||||
FormFieldProps,
|
||||
FormItemDependencies,
|
||||
FormSchema,
|
||||
FormSchemaContext,
|
||||
MaybeComponentProps,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
get,
|
||||
isFunction,
|
||||
mergeWithArrayOverride,
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
type AnyFormSchema = FormSchema<BaseFormComponentType, Record<string, any>>;
|
||||
|
||||
export type NormalizedFormFieldSchema = FormFieldProps & {
|
||||
commonComponentProps: MaybeComponentProps;
|
||||
formFieldProps: Record<string, any>;
|
||||
formItemClass: string;
|
||||
};
|
||||
|
||||
interface CreateFormFieldSchemaOptions {
|
||||
commonConfig?: FormCommonConfig;
|
||||
disabled?: boolean;
|
||||
forceHideLabel?: boolean;
|
||||
globalCommonConfig?: FormCommonConfig;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface CreateArrayChildSchemaOptions extends CreateFormFieldSchemaOptions {
|
||||
arrayField: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function createSchemaContext(
|
||||
baseContext: FormSchemaContext,
|
||||
values?: Partial<Record<string, any>>,
|
||||
): FormSchemaContext {
|
||||
const rootValues = values as Record<string, any> | undefined;
|
||||
return {
|
||||
...baseContext,
|
||||
rootValues,
|
||||
row:
|
||||
baseContext.rowPath && rootValues
|
||||
? get(rootValues, baseContext.rowPath)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function scopeRowFieldName(rowPath: string, fieldName: string) {
|
||||
if (!fieldName) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
if (fieldName.startsWith('$root.')) {
|
||||
return fieldName.slice('$root.'.length);
|
||||
}
|
||||
|
||||
if (fieldName.startsWith('$row.')) {
|
||||
return `${rowPath}.${fieldName.slice('$row.'.length)}`;
|
||||
}
|
||||
|
||||
if (fieldName === rowPath || fieldName.startsWith(`${rowPath}.`)) {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
return `${rowPath}.${fieldName}`;
|
||||
}
|
||||
|
||||
function wrapComponentProps(
|
||||
componentProps: AnyFormSchema['componentProps'],
|
||||
baseContext: FormSchemaContext,
|
||||
) {
|
||||
if (!isFunction(componentProps)) {
|
||||
return componentProps;
|
||||
}
|
||||
|
||||
return (values: Partial<Record<string, any>>, actions: FormActions) =>
|
||||
componentProps(values, actions, createSchemaContext(baseContext, values));
|
||||
}
|
||||
|
||||
function wrapCustomParamsRender(
|
||||
render: AnyFormSchema['help'],
|
||||
baseContext: FormSchemaContext,
|
||||
) {
|
||||
if (!isFunction(render)) {
|
||||
return render;
|
||||
}
|
||||
|
||||
return (values: Partial<Record<string, any>>, actions: FormActions) =>
|
||||
render(values, actions, createSchemaContext(baseContext, values));
|
||||
}
|
||||
|
||||
function wrapRenderComponentContent(
|
||||
render: AnyFormSchema['renderComponentContent'],
|
||||
baseContext: FormSchemaContext,
|
||||
) {
|
||||
if (!isFunction(render)) {
|
||||
return render;
|
||||
}
|
||||
|
||||
return (values: Partial<Record<string, any>>, actions: FormActions) =>
|
||||
render(values, actions, createSchemaContext(baseContext, values));
|
||||
}
|
||||
|
||||
function wrapDependencyFn<T>(handler: T, baseContext: FormSchemaContext): T {
|
||||
if (!isFunction(handler)) {
|
||||
return handler;
|
||||
}
|
||||
|
||||
return ((
|
||||
values: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
controller: any,
|
||||
) =>
|
||||
handler(
|
||||
values,
|
||||
actions,
|
||||
controller,
|
||||
createSchemaContext(baseContext, values),
|
||||
)) as T;
|
||||
}
|
||||
|
||||
function scopeDependencies(
|
||||
dependencies: FormItemDependencies | undefined,
|
||||
baseContext: FormSchemaContext,
|
||||
) {
|
||||
if (!dependencies) {
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
const rowPath = baseContext.rowPath;
|
||||
if (!rowPath) {
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
return {
|
||||
...dependencies,
|
||||
componentProps: wrapDependencyFn(dependencies.componentProps, baseContext),
|
||||
disabled: wrapDependencyFn(dependencies.disabled, baseContext),
|
||||
if: wrapDependencyFn(dependencies.if, baseContext),
|
||||
required: wrapDependencyFn(dependencies.required, baseContext),
|
||||
rules: wrapDependencyFn(dependencies.rules, baseContext),
|
||||
show: wrapDependencyFn(dependencies.show, baseContext),
|
||||
trigger: wrapDependencyFn(dependencies.trigger, baseContext),
|
||||
triggerFields:
|
||||
dependencies.triggerFields?.map((fieldName) =>
|
||||
scopeRowFieldName(rowPath, fieldName),
|
||||
) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function createArrayComponentProps(
|
||||
schema: AnyFormSchema,
|
||||
options: CreateFormFieldSchemaOptions,
|
||||
) {
|
||||
const componentProps = schema.componentProps;
|
||||
const arrayProps = 'arrayProps' in schema ? schema.arrayProps : undefined;
|
||||
const children = getFormArraySchemaChildren(schema);
|
||||
const commonConfig = options.commonConfig;
|
||||
const globalCommonConfig = options.globalCommonConfig;
|
||||
const schemaProps = children.length > 0 ? { schema: children } : {};
|
||||
|
||||
if (isFunction(componentProps)) {
|
||||
return (values: Partial<Record<string, any>>, actions: FormActions) => ({
|
||||
...arrayProps,
|
||||
...componentProps(values, actions),
|
||||
commonConfig,
|
||||
globalCommonConfig,
|
||||
...schemaProps,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...arrayProps,
|
||||
...componentProps,
|
||||
commonConfig,
|
||||
globalCommonConfig,
|
||||
...schemaProps,
|
||||
};
|
||||
}
|
||||
|
||||
function createArrayFieldSchema(
|
||||
schema: AnyFormSchema,
|
||||
options: CreateFormFieldSchemaOptions,
|
||||
) {
|
||||
const restSchema = { ...(schema as AnyFormSchema & Record<string, any>) };
|
||||
Reflect.deleteProperty(restSchema, 'arrayProps');
|
||||
Reflect.deleteProperty(restSchema, 'children');
|
||||
Reflect.deleteProperty(restSchema, 'type');
|
||||
|
||||
return {
|
||||
...restSchema,
|
||||
component: 'VbenFormFieldArray',
|
||||
componentProps: createArrayComponentProps(schema, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function getFormArraySchemaChildren(schema: Partial<AnyFormSchema>) {
|
||||
if ('children' in schema && Array.isArray(schema.children)) {
|
||||
return schema.children;
|
||||
}
|
||||
|
||||
const componentProps = schema.componentProps;
|
||||
if (
|
||||
!isFunction(componentProps) &&
|
||||
componentProps &&
|
||||
Array.isArray((componentProps as Record<string, any>).schema)
|
||||
) {
|
||||
return (componentProps as Record<string, any>).schema;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function isFormArraySchema(schema: Partial<AnyFormSchema>) {
|
||||
return (
|
||||
('type' in schema && schema.type === 'array') ||
|
||||
schema.component === 'VbenFormFieldArray' ||
|
||||
getFormArraySchemaChildren(schema).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveArrayChildFieldName(rowPath: string, fieldName: string) {
|
||||
return scopeRowFieldName(rowPath, fieldName);
|
||||
}
|
||||
|
||||
export function createFormFieldSchema(
|
||||
schema: AnyFormSchema,
|
||||
options: CreateFormFieldSchemaOptions = {},
|
||||
): NormalizedFormFieldSchema {
|
||||
const commonConfig = mergeWithArrayOverride(
|
||||
options.commonConfig ?? {},
|
||||
options.globalCommonConfig ?? {},
|
||||
);
|
||||
const {
|
||||
colon = false,
|
||||
componentProps = {},
|
||||
controlClass = '',
|
||||
disabled,
|
||||
disabledOnChangeListener = true,
|
||||
disabledOnInputListener = true,
|
||||
emptyStateValue = undefined,
|
||||
formFieldProps = {},
|
||||
formItemClass = '',
|
||||
hideLabel = false,
|
||||
hideRequiredMark = false,
|
||||
labelClass = '',
|
||||
labelWidth = 100,
|
||||
modelPropName = '',
|
||||
wrapperClass = '',
|
||||
} = commonConfig;
|
||||
|
||||
const normalizedSchema = isFormArraySchema(schema)
|
||||
? createArrayFieldSchema(schema, options)
|
||||
: schema;
|
||||
|
||||
let resolvedSchemaFormItemClass = normalizedSchema.formItemClass;
|
||||
if (isFunction(normalizedSchema.formItemClass)) {
|
||||
try {
|
||||
resolvedSchemaFormItemClass = normalizedSchema.formItemClass();
|
||||
} catch (error) {
|
||||
console.error('Error calling formItemClass function:', error);
|
||||
resolvedSchemaFormItemClass = '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
colon,
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
hideRequiredMark,
|
||||
labelWidth,
|
||||
modelPropName,
|
||||
wrapperClass,
|
||||
...normalizedSchema,
|
||||
commonComponentProps: componentProps as MaybeComponentProps,
|
||||
componentProps: normalizedSchema.componentProps,
|
||||
controlClass: [controlClass, normalizedSchema.controlClass]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
formFieldProps: {
|
||||
...formFieldProps,
|
||||
...normalizedSchema.formFieldProps,
|
||||
},
|
||||
formItemClass: [
|
||||
'shrink-0',
|
||||
options.hidden ? 'hidden' : '',
|
||||
formItemClass,
|
||||
resolvedSchemaFormItemClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
labelClass: [labelClass, normalizedSchema.labelClass]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
disabled: options.disabled ?? normalizedSchema.disabled ?? disabled,
|
||||
hideLabel:
|
||||
options.forceHideLabel ?? normalizedSchema.hideLabel ?? hideLabel,
|
||||
} as NormalizedFormFieldSchema;
|
||||
}
|
||||
|
||||
export function createArrayChildSchema(
|
||||
schema: AnyFormSchema,
|
||||
options: CreateArrayChildSchemaOptions,
|
||||
): NormalizedFormFieldSchema {
|
||||
const rowPath = `${options.arrayField}[${options.index}]`;
|
||||
const fieldName = resolveArrayChildFieldName(rowPath, schema.fieldName);
|
||||
const baseContext: FormSchemaContext = {
|
||||
arrayField: options.arrayField,
|
||||
fieldName,
|
||||
originalFieldName: schema.fieldName,
|
||||
rowIndex: options.index,
|
||||
rowPath,
|
||||
};
|
||||
|
||||
return createFormFieldSchema(
|
||||
{
|
||||
...schema,
|
||||
componentProps: wrapComponentProps(schema.componentProps, baseContext),
|
||||
dependencies: scopeDependencies(schema.dependencies, baseContext),
|
||||
fieldName,
|
||||
help: wrapCustomParamsRender(schema.help, baseContext),
|
||||
renderComponentContent: wrapRenderComponentContent(
|
||||
schema.renderComponentContent,
|
||||
baseContext,
|
||||
),
|
||||
},
|
||||
{
|
||||
commonConfig: options.commonConfig,
|
||||
disabled: options.disabled || schema.disabled,
|
||||
forceHideLabel: true,
|
||||
globalCommonConfig: options.globalCommonConfig,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export type {
|
||||
BaseFormComponentType,
|
||||
ExtendedFormApi,
|
||||
FormLayout,
|
||||
FormSchemaContext,
|
||||
VbenFormFieldArrayProps,
|
||||
VbenFormProps,
|
||||
FormSchema as VbenFormSchema,
|
||||
|
||||
@@ -66,6 +66,23 @@ export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
|
||||
|
||||
export type FormActions = FormContext<GenericObject>;
|
||||
|
||||
export interface FormSchemaContext {
|
||||
/** 数组字段名,例如 contacts */
|
||||
arrayField?: string;
|
||||
/** 当前真实字段名,例如 contacts[0].name */
|
||||
fieldName?: string;
|
||||
/** 原始 schema 字段名,例如 name */
|
||||
originalFieldName?: string;
|
||||
/** 表单完整值 */
|
||||
rootValues?: Record<string, any>;
|
||||
/** 当前行数据 */
|
||||
row?: Record<string, any>;
|
||||
/** 当前行索引 */
|
||||
rowIndex?: number;
|
||||
/** 当前行路径,例如 contacts[0] */
|
||||
rowPath?: string;
|
||||
}
|
||||
|
||||
export type CustomRenderType = (() => Component | string) | string;
|
||||
|
||||
// 动态渲染参数
|
||||
@@ -73,6 +90,7 @@ export type CustomParamsRenderType =
|
||||
| ((
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
ctx?: FormSchemaContext,
|
||||
) => Component | string)
|
||||
| string;
|
||||
|
||||
@@ -87,18 +105,21 @@ type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
|
||||
ctx?: FormSchemaContext,
|
||||
) => T;
|
||||
|
||||
type FormItemDependenciesConditionWithRules = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
|
||||
ctx?: FormSchemaContext,
|
||||
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
|
||||
|
||||
type FormItemDependenciesConditionWithProps = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
|
||||
ctx?: FormSchemaContext,
|
||||
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
|
||||
|
||||
export interface FormItemDependencies {
|
||||
@@ -145,6 +166,7 @@ type ComponentProps =
|
||||
| ((
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
ctx?: FormSchemaContext,
|
||||
) => MaybeComponentProps)
|
||||
| MaybeComponentProps;
|
||||
|
||||
@@ -233,12 +255,14 @@ export interface FormCommonConfig {
|
||||
type RenderComponentContentType = (
|
||||
value: Partial<Record<string, any>>,
|
||||
api: FormActions,
|
||||
ctx?: FormSchemaContext,
|
||||
) => Record<string, any>;
|
||||
|
||||
type MappedComponentProps<P> =
|
||||
| ((
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
ctx?: FormSchemaContext,
|
||||
) => P & Record<string, any>)
|
||||
| (P & Record<string, any>);
|
||||
|
||||
@@ -253,6 +277,7 @@ export type FormValueFormat = (
|
||||
value: any,
|
||||
setValue: (fieldName: string, value: any) => void,
|
||||
values: Record<string, any>,
|
||||
ctx?: FormSchemaContext,
|
||||
) => any;
|
||||
|
||||
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
|
||||
@@ -303,10 +328,32 @@ type FormSchemaFallback<T extends BaseFormComponentType> = {
|
||||
componentProps?: ComponentProps;
|
||||
} & FormSchemaBody;
|
||||
|
||||
type FormArraySchema<
|
||||
T extends BaseFormComponentType,
|
||||
P extends Record<string, any>,
|
||||
> = {
|
||||
/** 内置数组编辑器参数 */
|
||||
arrayProps?: Omit<
|
||||
VbenFormFieldArrayProps<T, P>,
|
||||
'disabled' | 'globalCommonConfig' | 'name' | 'schema'
|
||||
>;
|
||||
/** 数组子字段定义 */
|
||||
children: FormSchema<T, P>[];
|
||||
/** 兼容显式指定内置数组编辑器 */
|
||||
component?: Component | T;
|
||||
/** 兼容通过 componentProps 传递数组编辑器参数 */
|
||||
componentProps?: ComponentProps;
|
||||
/** 数组字段标记 */
|
||||
type: 'array';
|
||||
} & FormSchemaBody;
|
||||
|
||||
export type FormSchema<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
P extends Record<string, any> = Record<never, never>,
|
||||
> = FormSchemaDiscriminated<T, P> | FormSchemaFallback<T>;
|
||||
> =
|
||||
| FormArraySchema<T, P>
|
||||
| FormSchemaDiscriminated<T, P>
|
||||
| FormSchemaFallback<T>;
|
||||
|
||||
/**
|
||||
* 数组编辑器(VbenFormFieldArray)的组件参数
|
||||
@@ -319,17 +366,23 @@ export interface VbenFormFieldArrayProps<
|
||||
actionText?: string;
|
||||
/** 「添加」按钮文案 */
|
||||
addButtonText?: string;
|
||||
/** 子字段通用配置 */
|
||||
commonConfig?: FormCommonConfig;
|
||||
/** 新增一行时生成的默认数据;缺省时按列定义的 fieldName 生成空对象 */
|
||||
createRow?: () => Record<string, any>;
|
||||
disabled?: boolean;
|
||||
/** 空数据文案 */
|
||||
emptyText?: string;
|
||||
/** 子字段全局通用配置 */
|
||||
globalCommonConfig?: FormCommonConfig;
|
||||
/** 最多行数 */
|
||||
max?: number;
|
||||
/** 最少行数 */
|
||||
min?: number;
|
||||
/** 数组字段路径,由外层 FormField 透传 */
|
||||
name?: string;
|
||||
/** 列定义,每一列是一个子字段(复用 FormSchema) */
|
||||
schema: FormSchema<T, P>[];
|
||||
schema?: FormSchema<T, P>[];
|
||||
/** 是否显示序号列 */
|
||||
showIndex?: boolean;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,15 @@ const routes: RouteRecordRaw[] = [
|
||||
title: $t('examples.form.collapsible'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'FormArrayDemo',
|
||||
path: '/form-array-demo',
|
||||
component: () => import('#/views/demos/form-array/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:list-plus',
|
||||
title: '表单数组 Demo',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
325
playground/src/views/demos/form-array/README.md
Normal file
325
playground/src/views/demos/form-array/README.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Form Array 数组字段用法
|
||||
|
||||
这个 demo 展示 `form-ui` 的数组字段 schema 写法。业务侧推荐使用 `type: 'array' + children` 描述数组结构,不需要直接指定 `component: 'VbenFormFieldArray'`。
|
||||
|
||||
示例页面:
|
||||
|
||||
- [index.vue](/Users/lht/workspace/webstorm_workspace/hotline-ui/apps/hotline-web/src/views/demos/form-array/index.vue)
|
||||
|
||||
核心实现:
|
||||
|
||||
- [schema.ts](/Users/lht/workspace/webstorm_workspace/hotline-ui/packages/@core/ui-kit/form-ui/src/form-render/schema.ts)
|
||||
- [form-field-array.vue](/Users/lht/workspace/webstorm_workspace/hotline-ui/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue)
|
||||
- [form-api.ts](/Users/lht/workspace/webstorm_workspace/hotline-ui/packages/@core/ui-kit/form-ui/src/form-api.ts)
|
||||
|
||||
## 快速开始
|
||||
|
||||
```ts
|
||||
const schema: VbenFormSchema[] = [
|
||||
{
|
||||
type: 'array',
|
||||
fieldName: 'contacts',
|
||||
label: '联系人',
|
||||
formItemClass: 'col-span-1 md:col-span-2',
|
||||
defaultValue: [
|
||||
{
|
||||
enabled: true,
|
||||
name: '张三',
|
||||
phone: '10086',
|
||||
role: 'owner',
|
||||
},
|
||||
],
|
||||
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
|
||||
arrayProps: {
|
||||
addButtonText: '添加联系人',
|
||||
min: 1,
|
||||
max: 5,
|
||||
createRow: () => ({
|
||||
enabled: true,
|
||||
name: '',
|
||||
phone: '',
|
||||
role: 'member',
|
||||
}),
|
||||
},
|
||||
children: [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '姓名',
|
||||
rules: z.string().min(1, '请输入姓名'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'role',
|
||||
label: '角色',
|
||||
rules: 'selectRequired',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '负责人', value: 'owner' },
|
||||
{ label: '成员', value: 'member' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## 字段职责
|
||||
|
||||
### `type: 'array'`
|
||||
|
||||
声明这是一个数组字段。渲染前会被内部转换为 `VbenFormFieldArray`,所以业务 schema 不需要写具体组件名。
|
||||
|
||||
### `fieldName`
|
||||
|
||||
数组字段名。假设为 `contacts`,第 1 行子字段 `name` 会被转换成:
|
||||
|
||||
```ts
|
||||
contacts[0].name
|
||||
```
|
||||
|
||||
### `children`
|
||||
|
||||
数组每一行的子字段定义。每个 child 都是完整的 `FormSchema`,可以继续使用:
|
||||
|
||||
- `component`
|
||||
- `componentProps`
|
||||
- `rules`
|
||||
- `dependencies`
|
||||
- `defaultValue`
|
||||
- `help`
|
||||
- `suffix`
|
||||
- `renderComponentContent`
|
||||
- `valueFormat`
|
||||
- `formFieldProps`
|
||||
- `disabled`
|
||||
- `hide`
|
||||
- `labelClass`
|
||||
- `controlClass`
|
||||
|
||||
### `arrayProps`
|
||||
|
||||
传给数组编辑器的配置:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `addButtonText` | 新增按钮文案 |
|
||||
| `actionText` | 操作列表头文案 |
|
||||
| `emptyText` | 空数据文案 |
|
||||
| `min` | 最少行数,达到后禁用删除 |
|
||||
| `max` | 最多行数,达到后禁用新增 |
|
||||
| `showIndex` | 是否显示序号 |
|
||||
| `createRow` | 新增行时生成默认数据 |
|
||||
|
||||
## 校验建议
|
||||
|
||||
数组父级 `rules` 建议只写数组级规则,例如至少一行:
|
||||
|
||||
```ts
|
||||
rules: z.array(z.any()).min(1, '请至少添加一个联系人')
|
||||
```
|
||||
|
||||
每个子字段的必填、长度、格式校验写在 children 自己的 `rules`:
|
||||
|
||||
```ts
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '姓名',
|
||||
rules: z.string().min(1, '请输入姓名'),
|
||||
}
|
||||
```
|
||||
|
||||
不要在父级数组规则里重复写 `z.object({ name: ... })`,否则某一行子字段失败时,父级数组也会失败,容易出现重复错误提示。
|
||||
|
||||
## dependencies 用法
|
||||
|
||||
children 里的 `dependencies.triggerFields` 默认是“当前行相对路径”。例如:
|
||||
|
||||
```ts
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'phone',
|
||||
label: '电话',
|
||||
dependencies: {
|
||||
triggerFields: ['role'],
|
||||
componentProps: (_values, _form, _api, ctx) => ({
|
||||
disabled: ctx?.row?.role === 'viewer',
|
||||
placeholder:
|
||||
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
|
||||
}),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
在第 1 行中,`triggerFields: ['role']` 会被转换成:
|
||||
|
||||
```ts
|
||||
contacts[0].role
|
||||
```
|
||||
|
||||
回调多了一个可选 `ctx` 参数:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `ctx.row` | 当前行数据 |
|
||||
| `ctx.rowIndex` | 当前行索引 |
|
||||
| `ctx.rowPath` | 当前行路径,例如 `contacts[0]` |
|
||||
| `ctx.arrayField` | 数组字段名,例如 `contacts` |
|
||||
| `ctx.fieldName` | 当前真实字段名,例如 `contacts[0].phone` |
|
||||
| `ctx.originalFieldName` | 原始 child 字段名,例如 `phone` |
|
||||
| `ctx.rootValues` | 表单完整值 |
|
||||
|
||||
如果 child 需要依赖表单根字段,可以使用 `$root.` 前缀:
|
||||
|
||||
```ts
|
||||
dependencies: {
|
||||
triggerFields: ['$root.planName'],
|
||||
componentProps: (values) => ({
|
||||
disabled: !values.planName,
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
如果想显式写当前行字段,也可以使用 `$row.` 前缀:
|
||||
|
||||
```ts
|
||||
triggerFields: ['$row.role']
|
||||
```
|
||||
|
||||
## valueFormat 用法
|
||||
|
||||
children 里的 `valueFormat` 也会按行执行:
|
||||
|
||||
```ts
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'phone',
|
||||
label: '电话',
|
||||
valueFormat: (value, setValue) => {
|
||||
const nextValue = value?.trim();
|
||||
if (!nextValue) {
|
||||
return;
|
||||
}
|
||||
setValue('phone', nextValue);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
在 `contacts[0]` 中,`setValue('phone', nextValue)` 会自动写到:
|
||||
|
||||
```ts
|
||||
contacts[0].phone
|
||||
```
|
||||
|
||||
如果要写根字段,用 `$root.`:
|
||||
|
||||
```ts
|
||||
setValue('$root.firstContactPhone', value)
|
||||
```
|
||||
|
||||
如果要显式写当前行字段,用 `$row.`:
|
||||
|
||||
```ts
|
||||
setValue('$row.phone', value)
|
||||
```
|
||||
|
||||
## updateSchema 用法
|
||||
|
||||
可以用父级路径更新 child schema:
|
||||
|
||||
```ts
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'contacts.phone',
|
||||
rules: z.string().min(5, '电话至少 5 位'),
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
如果传入带索引路径,也会更新对应 child 定义:
|
||||
|
||||
```ts
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'contacts[0].phone',
|
||||
rules: z.string().min(5, '电话至少 5 位'),
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
注意:`updateSchema` 更新的是 schema 定义,不是单行实例。因此 `contacts[0].phone` 这种写法目前会解析到 child `phone`,实际影响所有行的该列。
|
||||
|
||||
## 新增行默认值
|
||||
|
||||
优先使用 `arrayProps.createRow`:
|
||||
|
||||
```ts
|
||||
arrayProps: {
|
||||
createRow: () => ({
|
||||
name: '',
|
||||
role: 'member',
|
||||
phone: '',
|
||||
enabled: true,
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
没有 `createRow` 时,会根据 children 的 `defaultValue` 生成行数据;没有 defaultValue 的字段会给 `null`。
|
||||
|
||||
## 内部流程
|
||||
|
||||
数组字段从 schema 到渲染大致是这条链路:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["业务 schema: type='array' + children"] --> B["form.vue computedSchema"]
|
||||
B --> C["createFormFieldSchema"]
|
||||
C --> D["识别数组 schema"]
|
||||
D --> E["转换为 component='VbenFormFieldArray'"]
|
||||
E --> F["children 放入 componentProps.schema"]
|
||||
F --> G["form-field.vue 渲染外层 FormField"]
|
||||
G --> H["form-field-array.vue useFieldArray 管理行"]
|
||||
H --> I["createArrayChildSchema"]
|
||||
I --> J["child.fieldName 转为 contacts[index].xxx"]
|
||||
I --> K["scope dependencies triggerFields"]
|
||||
I --> L["包装 componentProps/help/render/valueFormat ctx"]
|
||||
J --> M["继续复用 FormField 渲染 child"]
|
||||
```
|
||||
|
||||
几个关键点:
|
||||
|
||||
- 外层 `type: 'array'` 只是语义声明。
|
||||
- 具体展示仍复用内部 `VbenFormFieldArray`。
|
||||
- child 最终仍然走 `FormField`,所以现有 FormSchema 能力不会丢。
|
||||
- `dependencies` 不改核心调用链,而是在 `createArrayChildSchema` 里做路径和 ctx 适配。
|
||||
- `valueFormat` 和 `updateSchema` 在 `FormApi` 里递归处理 children。
|
||||
|
||||
## 小屏幕展示
|
||||
|
||||
`form-field-array` 在大屏下按表格式 grid 展示,在小屏下每行转为纵向堆叠,并显示每个 child 的 label。业务侧通常不需要额外处理移动端布局。
|
||||
|
||||
## 兼容旧写法
|
||||
|
||||
旧写法仍可用:
|
||||
|
||||
```ts
|
||||
{
|
||||
component: 'VbenFormFieldArray',
|
||||
fieldName: 'contacts',
|
||||
componentProps: {
|
||||
schema: [...],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
新代码推荐:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: 'array',
|
||||
fieldName: 'contacts',
|
||||
children: [...],
|
||||
}
|
||||
```
|
||||
187
playground/src/views/demos/form-array/index.vue
Normal file
187
playground/src/views/demos/form-array/index.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, message, Space } from 'antdv-next';
|
||||
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
|
||||
const submitValues = ref<Record<string, any>>({});
|
||||
|
||||
const schema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入方案名称',
|
||||
},
|
||||
defaultValue: '值班联络人配置',
|
||||
fieldName: 'planName',
|
||||
label: '方案名称',
|
||||
rules: z.string().min(1, '请输入方案名称'),
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
dependencies: {
|
||||
componentProps: (values) => {
|
||||
const planName = values.planName as string | undefined;
|
||||
return {
|
||||
disabled: !planName,
|
||||
placeholder: planName ? `${planName} 的补充说明` : '请先填写方案名称',
|
||||
rows: 2,
|
||||
};
|
||||
},
|
||||
required: (values) => {
|
||||
return String(values.planName ?? '').includes('值班');
|
||||
},
|
||||
rules: (values) => {
|
||||
return String(values.planName ?? '').includes('值班')
|
||||
? z.string().min(2, '请输入至少 2 个字')
|
||||
: z.string().optional();
|
||||
},
|
||||
triggerFields: ['planName'],
|
||||
},
|
||||
fieldName: 'description',
|
||||
formItemClass: 'col-span-1 md:col-span-2',
|
||||
label: '方案说明',
|
||||
},
|
||||
{
|
||||
arrayProps: {
|
||||
addButtonText: '添加联系人',
|
||||
createRow: () => ({
|
||||
enabled: true,
|
||||
name: '',
|
||||
phone: '',
|
||||
role: 'member',
|
||||
}),
|
||||
max: 5,
|
||||
min: 1,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: (_values, _form, ctx) => ({
|
||||
placeholder: `第 ${(ctx?.rowIndex ?? 0) + 1} 行姓名`,
|
||||
}),
|
||||
defaultValue: '',
|
||||
fieldName: 'name',
|
||||
label: '姓名',
|
||||
rules: z.string().min(1, '请输入姓名'),
|
||||
valueFormat: (value) => value?.trim(),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '负责人', value: 'owner' },
|
||||
{ label: '成员', value: 'member' },
|
||||
{ label: '观察员', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
defaultValue: 'member',
|
||||
fieldName: 'role',
|
||||
label: '角色',
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
componentProps: (_values, _form, _api, ctx) => ({
|
||||
disabled: ctx?.row?.role === 'viewer',
|
||||
placeholder:
|
||||
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
|
||||
}),
|
||||
triggerFields: ['role'],
|
||||
},
|
||||
fieldName: 'phone',
|
||||
label: '电话',
|
||||
rules: z.string().optional(),
|
||||
valueFormat: (value, setValue) => {
|
||||
const nextValue = value?.trim();
|
||||
if (!nextValue) {
|
||||
return;
|
||||
}
|
||||
setValue('phone', nextValue);
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Switch',
|
||||
componentProps: {
|
||||
checkedChildren: '启用',
|
||||
unCheckedChildren: '停用',
|
||||
},
|
||||
defaultValue: true,
|
||||
fieldName: 'enabled',
|
||||
label: '状态',
|
||||
},
|
||||
],
|
||||
defaultValue: [
|
||||
{
|
||||
enabled: true,
|
||||
name: '张三',
|
||||
phone: ' 10086 ',
|
||||
role: 'owner',
|
||||
},
|
||||
],
|
||||
fieldName: 'contacts',
|
||||
formItemClass: 'col-span-1 md:col-span-2',
|
||||
label: '联系人',
|
||||
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
|
||||
type: 'array',
|
||||
},
|
||||
];
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
labelWidth: 90,
|
||||
},
|
||||
handleSubmit: (values) => {
|
||||
submitValues.value = values;
|
||||
message.success('已通过校验');
|
||||
},
|
||||
schema,
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-1 gap-x-4 md:grid-cols-2',
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
await formApi.validateAndSubmitForm();
|
||||
}
|
||||
|
||||
async function handleGetValues() {
|
||||
submitValues.value = await formApi.getValues();
|
||||
}
|
||||
|
||||
function handlePatchChildRule() {
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'contacts.phone',
|
||||
rules: z.string().min(5, '电话至少 5 位'),
|
||||
},
|
||||
]);
|
||||
message.success('已动态更新子字段规则');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="Form Array Demo">
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<Card title="数组字段">
|
||||
<Form />
|
||||
<Space class="mt-4 flex flex-wrap">
|
||||
<Button type="primary" @click="handleSubmit">提交</Button>
|
||||
<Button @click="handleGetValues">获取值</Button>
|
||||
<Button @click="handlePatchChildRule">更新电话规则</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="输出">
|
||||
<pre
|
||||
class="bg-muted text-muted-foreground max-h-[420px] overflow-auto rounded-md p-3 text-xs"
|
||||
>{{ JSON.stringify(submitValues, null, 2) }}</pre>
|
||||
</Card>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
Reference in New Issue
Block a user