mirror of
https://github.com/imdap/ruoyi-plus-vben5.git
synced 2026-05-07 02:31:58 +08:00
feat(form-ui): support schema valueFormat for getValues payload shaping (#7804)
* feat(@vben-core/form-ui): support schema valueFormat on getValues Some form fields emit UI-friendly structures such as time-range arrays, while consumers and backend APIs often need a different payload shape. This adds schema-level `valueFormat` hooks so `getValues()` can normalize field output at read time without forcing callers to post-process every submission path. Constraint: Must preserve existing range-time mapping and nested field behavior Constraint: Must not mutate live vee-validate form state while formatting output Rejected: Global formatter config | too coarse for per-field payload shaping Rejected: Post-submit-only transform | misses reset/query/change handlers Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep `getValues()` output derivation side-effect free Directive: Clone raw form values before formatting derived payloads Tested: vitest form-api test for valueFormat and existing getValues paths Tested: oxlint on changed form-ui source and test files Not-tested: Full repo typecheck baseline has unrelated .vue module resolution errors * fix(@vben-core/form-ui): restore mount compatibility and share field path parsing Follow-up review found two real regressions and one missing assertion in the new value formatting flow. `FormApi.mount()` had become breaking by requiring `componentRefMap`, and delete path resolution duplicated field-name parsing instead of sharing the reader grammar. This patch restores backward compatibility, centralizes field-name path parsing, and extends the test to prove formatting does not mutate live form values. Constraint: Must preserve current valueFormat behavior and nested field support Constraint: Must not reintroduce mutation of live vee-validate values Rejected: Keep duplicated delete parsing | risks grammar drift from read path Rejected: Only loosen mount tests | would leave consumer-facing API breakage Confidence: high Scope-risk: narrow Reversibility: clean Directive: Reuse shared field-name parsing for read/delete semantics in form-ui Tested: vitest form-api test suite Tested: oxlint on changed form-ui files Not-tested: Full repo typecheck baseline has unrelated .vue module resolution errors EOF && git push hekx feature-form-value-format * fix(@vben-core/form-ui): clear stale component refs on unmount A follow-up review found that `unmount()` left the private component ref map populated. Because `mount()` now accepts an optional `componentRefMap`, a later mount without a new map could silently reuse stale refs from a prior form instance. This change clears the ref map on unmount and adds a regression test covering remount behavior without a new ref map. Constraint: Must preserve backward-compatible optional `mount()` ref map behavior Constraint: Focus and field-ref lookups must not observe stale refs after unmount Rejected: Clear refs only during next mount | stale state would still leak between lifecycle calls Rejected: Remove mount fallback entirely | would undo the compatibility fix Confidence: high Scope-risk: narrow Reversibility: clean Directive: When mount falls back to internal refs, unmount must always reset that cache Tested: vitest form-api test suite Tested: oxlint on changed form-api source and test files Not-tested: Full repo typecheck baseline has unrelated .vue module resolution errors * refactor(@vben-core/form-ui): trim redundant valueFormat plumbing Review feedback identified a few small cleanups in the value formatting path. This removes an unnecessary shallow clone in `getValues()`, reuses the already-parsed `rawKey` from `resolveFieldNamePath()` instead of re-resolving it in multiple helpers, and clarifies the `FormValueFormat` contract for undefined-as-delete decomposition behavior. Constraint: Must not change runtime valueFormat behavior or payload shape Constraint: Documentation and helper cleanup should stay behavior-preserving Rejected: Leave duplicate raw-key resolution in place | adds needless parsing churn Rejected: Expand the formatter API further | outside the scope of this cleanup Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep read/format helper plumbing lean and avoid duplicate field-name parsing Tested: vitest form-api test suite Tested: oxlint on changed form-ui source and test files Not-tested: Full repo typecheck baseline has unrelated .vue module resolution errors * feat(@vben-core/form-ui): document valueFormat with live examples The new `valueFormat` feature needed a concrete usage path in both the playground and the docs so users can understand how raw component values differ from the final payload returned by `getValues()`. This adds a dedicated form example, wires it into the playground menu, and documents the API with an interactive docs demo. The preview panels now stay in sync when values are set, reset, or submitted. Constraint: Must demonstrate both return-value and setValue decomposition flows Constraint: Example previews must react to setValues, reset, and manual edits Rejected: Only document via markdown snippet | insufficient for verifying live payload behavior Rejected: Reuse an existing basic form page | would bury feature-specific behavior Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep playground and docs demos behaviorally aligned when extending valueFormat examples Tested: eslint on playground/docs valueFormat demo files and route module Tested: oxlint on playground route module Not-tested: Full docs/playground app runtime was not launched in this session * chore(@vben-core/form-ui): normalize valueFormat demo formatting The previous feature/docs commit left a few formatter-only adjustments unstaged after hooks rewrote line wrapping in the new demo and docs pages. This commit captures those final non-behavioral formatting updates so the branch matches the current working tree. Constraint: Must not change runtime behavior or docs meaning Rejected: Leave post-hook diffs unstaged | branch would not reflect local state Confidence: high Scope-risk: narrow Reversibility: clean Directive: After hook-driven rewrites, verify the working tree is clean before final push Tested: Git diff inspection of remaining changes Not-tested: No additional runtime verification needed; formatting-only follow-up EOF && git push hekx feature-form-value-format * fix(@vben-core/form-ui): remove docs demo dayjs dependency The docs valueFormat demo imported `dayjs` directly even though the docs package does not declare it as a dependency. That caused `@vben/docs:build` to fail in CI during VitePress bundling. This change removes the direct import, keeps the preview formatter generic for day-like values, and drops the docs-only preset button that required constructing dayjs instances. Constraint: Docs build must succeed without adding new package dependencies Constraint: Playground example should remain unchanged and fully interactive Rejected: Add dayjs to docs dependencies | unnecessary for a small display demo Rejected: Externalize dayjs in VitePress build | hides a package boundary issue Confidence: high Scope-risk: narrow Reversibility: clean Directive: Docs demos should avoid imports only available through transitive deps Tested: pnpm exec eslint docs/src/demos/vben-form/value-format/index.vue Tested: pnpm --dir docs run build Not-tested: No browser-side manual verification of the docs demo in this session --------- Co-authored-by: caisin <caisin@caisins-Mac-mini.local>
This commit is contained in:
@@ -44,6 +44,7 @@ describe('formApi', () => {
|
||||
await formApi.mount(formActions);
|
||||
expect(formApi.isMounted).toBe(true);
|
||||
expect(formApi.form).toEqual(formActions);
|
||||
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should get values from form', async () => {
|
||||
@@ -52,11 +53,54 @@ describe('formApi', () => {
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
const values = await formApi.getValues();
|
||||
expect(values).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
it('should format schema values when getting values', async () => {
|
||||
formApi.setState({
|
||||
schema: [
|
||||
{
|
||||
component: 'range-picker',
|
||||
fieldName: 'filters.range',
|
||||
valueFormat: (value, setValue) => {
|
||||
setValue('filters.startTime', value?.[0]);
|
||||
setValue('filters.endTime', value?.[1]);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
values: {
|
||||
filters: {
|
||||
range: [1_710_000_000_000, 1_720_000_000_000],
|
||||
},
|
||||
},
|
||||
};
|
||||
const originalValuesSnapshot = structuredClone(formActions.values);
|
||||
|
||||
await formApi.mount(formActions, new Map());
|
||||
|
||||
expect(formApi.getLatestSubmissionValues()).toEqual({
|
||||
filters: {
|
||||
endTime: 1_720_000_000_000,
|
||||
startTime: 1_710_000_000_000,
|
||||
},
|
||||
});
|
||||
|
||||
const values = await formApi.getValues();
|
||||
expect(values).toEqual({
|
||||
filters: {
|
||||
endTime: 1_720_000_000_000,
|
||||
startTime: 1_710_000_000_000,
|
||||
},
|
||||
});
|
||||
expect(formActions.values).toEqual(originalValuesSnapshot);
|
||||
});
|
||||
|
||||
it('should set field value', async () => {
|
||||
const setFieldValueMock = vi.fn();
|
||||
const formActions: any = {
|
||||
@@ -65,7 +109,7 @@ describe('formApi', () => {
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
await formApi.setFieldValue('name', 'new value');
|
||||
expect(setFieldValueMock).toHaveBeenCalledWith(
|
||||
'name',
|
||||
@@ -82,7 +126,7 @@ describe('formApi', () => {
|
||||
values: { name: 'test' },
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
await formApi.resetForm();
|
||||
expect(resetFormMock).toHaveBeenCalled();
|
||||
});
|
||||
@@ -100,7 +144,7 @@ describe('formApi', () => {
|
||||
};
|
||||
|
||||
formApi.setState(state);
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
|
||||
const result = await formApi.submitForm();
|
||||
expect(formActions.submitForm).toHaveBeenCalled();
|
||||
@@ -113,6 +157,39 @@ describe('formApi', () => {
|
||||
expect(formApi.isMounted).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear component refs on unmount before mounting again', async () => {
|
||||
const formActions: any = {
|
||||
meta: {},
|
||||
resetForm: vi.fn(),
|
||||
values: { name: 'test' },
|
||||
};
|
||||
const staleMap = new Map<string, unknown>([
|
||||
[
|
||||
'name',
|
||||
{
|
||||
$: {
|
||||
type: { name: 'MockComponent' },
|
||||
},
|
||||
$el: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await formApi.mount(formActions, staleMap);
|
||||
expect(formApi.getFieldComponentRef('name')).toEqual({
|
||||
$: {
|
||||
type: { name: 'MockComponent' },
|
||||
},
|
||||
$el: {},
|
||||
});
|
||||
|
||||
formApi.unmount();
|
||||
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
|
||||
|
||||
await formApi.mount(formActions);
|
||||
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate form', async () => {
|
||||
const validateMock = vi.fn().mockResolvedValue(true);
|
||||
const formActions: any = {
|
||||
@@ -120,7 +197,7 @@ describe('formApi', () => {
|
||||
validate: validateMock,
|
||||
};
|
||||
|
||||
await formApi.mount(formActions);
|
||||
await formApi.mount(formActions, new Map());
|
||||
const isValid = await formApi.validate();
|
||||
expect(validateMock).toHaveBeenCalled();
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
14
packages/@core/ui-kit/form-ui/src/field-name.ts
Normal file
14
packages/@core/ui-kit/form-ui/src/field-name.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function resolveFieldNamePath(fieldName: string) {
|
||||
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
|
||||
const rawKey = fieldName.slice(1, -1);
|
||||
return {
|
||||
pathSegments: [rawKey],
|
||||
rawKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pathSegments: fieldName.match(/[^.[\]]+/g) ?? [],
|
||||
rawKey: undefined,
|
||||
};
|
||||
}
|
||||
@@ -16,16 +16,21 @@ import { isRef, toRaw } from 'vue';
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import {
|
||||
bindMethods,
|
||||
cloneDeep,
|
||||
createMerge,
|
||||
formatDate,
|
||||
get,
|
||||
isDate,
|
||||
isDayjsObject,
|
||||
isFunction,
|
||||
isObject,
|
||||
mergeWithArrayOverride,
|
||||
set,
|
||||
StateHandler,
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
import { resolveFieldNamePath } from './field-name';
|
||||
|
||||
function getDefaultState(): VbenFormProps {
|
||||
return {
|
||||
actionWrapperClass: '',
|
||||
@@ -158,7 +163,10 @@ export class FormApi {
|
||||
|
||||
async getValues<T = Recordable<any>>() {
|
||||
const form = await this.getForm();
|
||||
return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T;
|
||||
const values = form.values
|
||||
? this.handleRangeTimeValue(cloneDeep(toRaw(form.values)))
|
||||
: {};
|
||||
return this.handleValueFormat(values) as T;
|
||||
}
|
||||
|
||||
async isFieldValid(fieldName: string) {
|
||||
@@ -206,14 +214,18 @@ export class FormApi {
|
||||
return proxy;
|
||||
}
|
||||
|
||||
mount(formActions: FormActions, componentRefMap: Map<string, unknown>) {
|
||||
mount(formActions: FormActions, componentRefMap?: Map<string, unknown>) {
|
||||
if (!this.isMounted) {
|
||||
Object.assign(this.form, formActions);
|
||||
this.stateHandler.setConditionTrue();
|
||||
const initialValues = this.form.values
|
||||
? this.handleRangeTimeValue(cloneDeep(toRaw(this.form.values)))
|
||||
: {};
|
||||
this.setLatestSubmissionValues({
|
||||
...toRaw(this.handleRangeTimeValue(this.form.values)),
|
||||
...this.handleValueFormat(initialValues),
|
||||
});
|
||||
this.componentRefMap = componentRefMap;
|
||||
this.componentRefMap =
|
||||
componentRefMap ?? this.componentRefMap ?? new Map();
|
||||
this.isMounted = true;
|
||||
}
|
||||
}
|
||||
@@ -363,6 +375,7 @@ export class FormApi {
|
||||
unmount() {
|
||||
this.form?.resetForm?.();
|
||||
// this.state = null;
|
||||
this.componentRefMap = new Map();
|
||||
this.latestSubmissionValues = null;
|
||||
this.isMounted = false;
|
||||
this.stateHandler.reset();
|
||||
@@ -443,6 +456,42 @@ export class FormApi {
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
private deleteValueByFieldName(
|
||||
values: Record<string, any>,
|
||||
fieldName: string,
|
||||
) {
|
||||
const { pathSegments, rawKey } = resolveFieldNamePath(fieldName);
|
||||
if (rawKey) {
|
||||
Reflect.deleteProperty(values, rawKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pathSegments || pathSegments.length === 0) {
|
||||
Reflect.deleteProperty(values, fieldName);
|
||||
return;
|
||||
}
|
||||
|
||||
let target: Record<string, any> | undefined = values;
|
||||
|
||||
for (const segment of pathSegments.slice(0, -1)) {
|
||||
if (!target || !isObject(target)) {
|
||||
return;
|
||||
}
|
||||
target = target[segment];
|
||||
}
|
||||
|
||||
if (!target || !isObject(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPathSegment = pathSegments.at(-1);
|
||||
if (!lastPathSegment) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reflect.deleteProperty(target, lastPathSegment);
|
||||
}
|
||||
|
||||
private async getForm() {
|
||||
if (!this.isMounted) {
|
||||
// 等待form挂载
|
||||
@@ -560,6 +609,36 @@ export class FormApi {
|
||||
return values;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
private processFields = (
|
||||
fields: string[],
|
||||
separator: string,
|
||||
@@ -575,6 +654,32 @@ export class FormApi {
|
||||
});
|
||||
};
|
||||
|
||||
private resolveValueByFieldName(
|
||||
values: Record<string, any>,
|
||||
fieldName: string,
|
||||
) {
|
||||
const { rawKey } = resolveFieldNamePath(fieldName);
|
||||
if (rawKey) {
|
||||
return values[rawKey];
|
||||
}
|
||||
|
||||
return get(values, fieldName);
|
||||
}
|
||||
|
||||
private setValueByFieldName(
|
||||
values: Record<string, any>,
|
||||
fieldName: string,
|
||||
value: any,
|
||||
) {
|
||||
const { rawKey } = resolveFieldNamePath(fieldName);
|
||||
if (rawKey) {
|
||||
values[rawKey] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
set(values, fieldName, value);
|
||||
}
|
||||
|
||||
private updateState() {
|
||||
const currentSchema = this.state?.schema ?? [];
|
||||
const prevSchema = this.prevState?.schema ?? [];
|
||||
|
||||
@@ -10,6 +10,7 @@ import { get, isBoolean, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { useFormValues } from 'vee-validate';
|
||||
|
||||
import { resolveFieldNamePath } from '../field-name';
|
||||
import { injectRenderFormProps } from './context';
|
||||
|
||||
/**
|
||||
@@ -22,8 +23,8 @@ function resolveValueByFieldName(
|
||||
fieldName: string,
|
||||
) {
|
||||
// vee-validate:[] 表示禁用嵌套
|
||||
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
|
||||
const rawKey = fieldName.slice(1, -1);
|
||||
const { rawKey } = resolveFieldNamePath(fieldName);
|
||||
if (rawKey) {
|
||||
return values[rawKey];
|
||||
}
|
||||
|
||||
|
||||
@@ -228,6 +228,19 @@ type MappedComponentProps<P> =
|
||||
) => P & Record<string, any>)
|
||||
| (P & Record<string, any>);
|
||||
|
||||
/**
|
||||
* 格式化 `getValues()` 输出中的当前字段值。
|
||||
* - 返回 `undefined`:保留当前字段已被移除的状态,通常配合 `setValue(key, nextValue)`
|
||||
* 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime`
|
||||
* - 返回其他值:会将当前字段恢复/写回为该返回值
|
||||
* - `setValue` 回调签名为 `(key, nextValue) => void`
|
||||
*/
|
||||
export type FormValueFormat = (
|
||||
value: any,
|
||||
setValue: (fieldName: string, value: any) => void,
|
||||
values: Record<string, any>,
|
||||
) => any;
|
||||
|
||||
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
|
||||
/** 默认值 */
|
||||
defaultValue?: any;
|
||||
@@ -249,6 +262,12 @@ interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
|
||||
rules?: FormSchemaRuleType;
|
||||
/** 后缀 */
|
||||
suffix?: CustomRenderType;
|
||||
/**
|
||||
* 获取表单值时格式化当前字段。
|
||||
* - 返回值不为 `undefined` 时,会回写到当前 fieldName
|
||||
* - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段
|
||||
*/
|
||||
valueFormat?: FormValueFormat;
|
||||
}
|
||||
|
||||
type FormSchemaDiscriminated<
|
||||
|
||||
Reference in New Issue
Block a user