feat(@vben-core/form-ui): add typed form value codecs (#8189)
* feat(@vben-core/form-ui): add typed form value codecs * test(@vben-core/form-ui): cover codec value boundaries * refactor(project): propagate form codec generics * feat(@vben/plugins): propagate form codecs through vxe grids * refactor(@vben/playground): migrate form codec examples * refactor(@vben/playground): migrate query forms to codecs * docs(@vben/docs): document form value codecs
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadFile } from 'antdv-next';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { h, ref, toRaw } from 'vue';
|
||||
|
||||
@@ -18,6 +19,36 @@ import DocButton from '../doc-button.vue';
|
||||
|
||||
const keyword = ref('');
|
||||
const fetching = ref(false);
|
||||
|
||||
interface BasicFormValues extends Record<string, any> {
|
||||
cropImage?: UploadFile[];
|
||||
files?: UploadFile[];
|
||||
rangePicker?: [Dayjs, Dayjs];
|
||||
}
|
||||
|
||||
function encodeBasicFormValues(values: Readonly<BasicFormValues>) {
|
||||
const { rangePicker, ...formValues } = values;
|
||||
return {
|
||||
...formValues,
|
||||
endTime: rangePicker?.[1]?.format('YYYY-MM-DD'),
|
||||
startTime: rangePicker?.[0]?.format('YYYY-MM-DD'),
|
||||
};
|
||||
}
|
||||
|
||||
type BasicSubmitValues = ReturnType<typeof encodeBasicFormValues>;
|
||||
|
||||
function decodeBasicFormValues(
|
||||
values: Readonly<BasicSubmitValues>,
|
||||
): BasicFormValues {
|
||||
const { endTime, startTime, ...formValues } = values;
|
||||
return {
|
||||
...formValues,
|
||||
...(startTime && endTime
|
||||
? { rangePicker: [dayjs(startTime), dayjs(endTime)] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// 模拟远程获取数据
|
||||
function fetchRemoteOptions({ keyword = '选项' }: Record<string, any>) {
|
||||
fetching.value = true;
|
||||
@@ -34,6 +65,10 @@ function fetchRemoteOptions({ keyword = '选项' }: Record<string, any>) {
|
||||
}
|
||||
|
||||
const [BaseForm, baseFormApi] = useVbenForm({
|
||||
codec: {
|
||||
decode: decodeBasicFormValues,
|
||||
encode: encodeBasicFormValues,
|
||||
},
|
||||
// 所有表单项共用,可单独在表单内覆盖
|
||||
commonConfig: {
|
||||
// 在label后显示一个冒号
|
||||
@@ -43,7 +78,6 @@ const [BaseForm, baseFormApi] = useVbenForm({
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
fieldMappingTime: [['rangePicker', ['startTime', 'endTime'], 'YYYY-MM-DD']],
|
||||
// 提交函数
|
||||
handleSubmit: onSubmit,
|
||||
handleValuesChange(_values, fieldsChanged) {
|
||||
@@ -425,8 +459,8 @@ const [BaseForm, baseFormApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
});
|
||||
|
||||
function onSubmit(values: Record<string, any>) {
|
||||
const files = toRaw(values.files) as UploadFile[];
|
||||
function onSubmit(values: BasicSubmitValues) {
|
||||
const files = (toRaw(values.files) ?? []) as UploadFile[];
|
||||
const cropImage = (toRaw(values.cropImage) ?? []) as UploadFile[];
|
||||
const doneFiles = files.filter((file) => file.status === 'done');
|
||||
const failedFiles = files.filter((file) => file.status !== 'done');
|
||||
|
||||
@@ -9,7 +9,40 @@ import { useVbenForm, z } from '#/adapter/form';
|
||||
|
||||
import TwoFields from './modules/two-fields.vue';
|
||||
|
||||
interface CustomFormValues extends Record<string, unknown> {
|
||||
field?: string;
|
||||
field1?: string;
|
||||
field2?: string;
|
||||
field3?: string;
|
||||
field4?: [string | undefined, string];
|
||||
}
|
||||
|
||||
function encodeCustomFormValues(values: Readonly<CustomFormValues>) {
|
||||
const { field4, ...formValues } = values;
|
||||
return {
|
||||
...formValues,
|
||||
phoneNumber: field4?.[1],
|
||||
phoneType: field4?.[0],
|
||||
};
|
||||
}
|
||||
|
||||
type CustomSubmitValues = ReturnType<typeof encodeCustomFormValues>;
|
||||
|
||||
function decodeCustomFormValues(
|
||||
values: Readonly<CustomSubmitValues>,
|
||||
): CustomFormValues {
|
||||
const { phoneNumber, phoneType, ...formValues } = values;
|
||||
return {
|
||||
...formValues,
|
||||
field4: [phoneType, phoneNumber ?? ''],
|
||||
};
|
||||
}
|
||||
|
||||
const [Form] = useVbenForm({
|
||||
codec: {
|
||||
decode: decodeCustomFormValues,
|
||||
encode: encodeCustomFormValues,
|
||||
},
|
||||
// 所有表单项共用,可单独在表单内覆盖
|
||||
commonConfig: {
|
||||
// 所有表单项
|
||||
@@ -18,7 +51,6 @@ const [Form] = useVbenForm({
|
||||
},
|
||||
labelClass: 'w-2/6',
|
||||
},
|
||||
fieldMappingTime: [['field4', ['phoneType', 'phoneNumber'], null]],
|
||||
// 提交函数
|
||||
handleSubmit: onSubmit,
|
||||
// 垂直布局,label和input在不同行,值为vertical
|
||||
@@ -80,7 +112,7 @@ const [Form] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2',
|
||||
});
|
||||
|
||||
function onSubmit(values: Record<string, any>) {
|
||||
function onSubmit(values: CustomSubmitValues) {
|
||||
message.success({
|
||||
content: `form values: ${JSON.stringify(values)}`,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
@@ -10,35 +12,65 @@ import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import DocButton from '../doc-button.vue';
|
||||
|
||||
const transformedValues = ref<Record<string, any>>({});
|
||||
const liveValues = ref<Record<string, any>>({});
|
||||
interface ValueFormatFormValues {
|
||||
deadline?: Dayjs;
|
||||
keyword?: string;
|
||||
reportRange?: [Dayjs, Dayjs];
|
||||
}
|
||||
|
||||
function encodeValueFormatValues(values: Readonly<ValueFormatFormValues>) {
|
||||
return {
|
||||
deadline: values.deadline?.valueOf(),
|
||||
endTime: values.reportRange?.[1].valueOf(),
|
||||
keyword: values.keyword,
|
||||
startTime: values.reportRange?.[0].valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
type ValueFormatSubmitValues = ReturnType<typeof encodeValueFormatValues>;
|
||||
|
||||
function decodeValueFormatValues(
|
||||
values: Readonly<ValueFormatSubmitValues>,
|
||||
): ValueFormatFormValues {
|
||||
let reportRange: [Dayjs, Dayjs] | undefined;
|
||||
if (values.startTime !== undefined && values.endTime !== undefined) {
|
||||
reportRange = [dayjs(values.startTime), dayjs(values.endTime)];
|
||||
}
|
||||
return {
|
||||
deadline:
|
||||
values.deadline === undefined ? undefined : dayjs(values.deadline),
|
||||
keyword: values.keyword,
|
||||
reportRange,
|
||||
};
|
||||
}
|
||||
|
||||
const transformedValues = ref<Partial<ValueFormatSubmitValues>>({});
|
||||
const liveValues = ref<Partial<ValueFormatFormValues>>({});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
codec: {
|
||||
decode: decodeValueFormatValues,
|
||||
encode: encodeValueFormatValues,
|
||||
},
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
handleSubmit,
|
||||
handleValuesChange,
|
||||
schema: [
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'reportRange',
|
||||
help: '通过 setValue 拆分为 startTime / endTime,并移除原字段',
|
||||
help: '由表单 codec 拆分为 startTime / endTime',
|
||||
label: '统计时间范围',
|
||||
valueFormat(value, setValue) {
|
||||
setValue('startTime', value?.[0]?.valueOf());
|
||||
setValue('endTime', value?.[1]?.valueOf());
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
fieldName: 'deadline',
|
||||
help: '直接 return 时间戳,保留原字段名',
|
||||
help: '由表单 codec 编码为时间戳',
|
||||
label: '截止时间',
|
||||
valueFormat(value) {
|
||||
return value?.valueOf();
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
@@ -58,7 +90,7 @@ const transformedValuesPreview = computed(() => {
|
||||
return formatJsonPreview(transformedValues.value);
|
||||
});
|
||||
|
||||
function formatJsonPreview(value: Record<string, any>) {
|
||||
function formatJsonPreview(value: unknown) {
|
||||
return JSON.stringify(
|
||||
value,
|
||||
(_key, currentValue) => {
|
||||
@@ -83,49 +115,50 @@ function handleSetExampleValue() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(values: Record<string, any>) {
|
||||
function handleSubmit(values: ValueFormatSubmitValues) {
|
||||
transformedValues.value = values;
|
||||
message.success({
|
||||
content: `getValues output: ${JSON.stringify(values)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function syncPreviewValues(values?: Record<string, any>) {
|
||||
liveValues.value = values ?? formApi.form?.values ?? {};
|
||||
function handleValuesChange(
|
||||
values: Readonly<ValueFormatFormValues>,
|
||||
_fieldsChanged: string[],
|
||||
getFormattedValues: () => ValueFormatSubmitValues,
|
||||
) {
|
||||
liveValues.value = { ...values };
|
||||
transformedValues.value = getFormattedValues();
|
||||
}
|
||||
|
||||
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
|
||||
liveValues.value = { ...(values ?? formApi.form?.values) };
|
||||
transformedValues.value = await formApi.getValues();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
watch(
|
||||
() => formApi.form?.values,
|
||||
async (values) => {
|
||||
await syncPreviewValues(values);
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
await syncPreviewValues();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page
|
||||
content-class="flex flex-col gap-4"
|
||||
description="演示 schema.valueFormat 如何把组件值转换为提交/查询所需的 payload。"
|
||||
title="表单 valueFormat"
|
||||
description="演示表单级 codec 如何双向转换组件值和提交 payload。"
|
||||
title="表单 Codec"
|
||||
>
|
||||
<template #description>
|
||||
<div class="text-muted-foreground space-y-2">
|
||||
<p>
|
||||
<code>form.values</code> 保持组件原始值,<code>getValues()</code> /
|
||||
提交时会按 <code>schema.valueFormat</code> 输出转换后的 payload。
|
||||
提交时会按 <code>codec.encode</code> 输出 payload,回填时通过
|
||||
<code>codec.decode</code> 恢复组件值。
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Tag color="processing">return 值:回写当前字段</Tag>
|
||||
<Tag color="success">setValue:拆分写入其他字段</Tag>
|
||||
<Tag color="warning">return undefined:保持原字段删除</Tag>
|
||||
<Tag color="processing">encode:生成完整提交值</Tag>
|
||||
<Tag color="success">decode:恢复完整表单值</Tag>
|
||||
<Tag color="warning">多字段转换原子执行</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -133,7 +166,7 @@ onMounted(async () => {
|
||||
<DocButton path="/components/common-ui/vben-form" />
|
||||
</template>
|
||||
|
||||
<Card title="valueFormat 示例">
|
||||
<Card title="Codec 示例">
|
||||
<template #extra>
|
||||
<Space wrap>
|
||||
<Button @click="handleSetExampleValue">填充示例数据</Button>
|
||||
@@ -151,7 +184,7 @@ onMounted(async () => {
|
||||
liveValuesPreview
|
||||
}}</pre>
|
||||
</Card>
|
||||
<Card title="getValues / submit 输出(valueFormat 后)">
|
||||
<Card title="getValues / submit 输出(codec.encode 后)">
|
||||
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
|
||||
transformedValuesPreview
|
||||
}}</pre>
|
||||
|
||||
Reference in New Issue
Block a user