feat(form): 统一字段值 effect 机制并将 nested 重命名为 innerConfig

将字段挂载时的 model 写入迁移至 registerField effect,
渲染与无渲染校验共用 applyMountValueEffects;
nested 重命名为 innerConfig 以区分配置派生与值写入。
This commit is contained in:
roymondchen 2026-08-31 18:30:07 +08:00
parent 02eeb2e87f
commit f2ee7ae7b5
86 changed files with 1704 additions and 501 deletions

View File

@ -588,6 +588,7 @@ export default defineConfig({
},
resolve: {
alias:[
{ find: /^@form/, replacement: path.join(__dirname, '../../packages/form/src/') },
{ find: /^@tmagic\/form-schema/, replacement: path.join(__dirname, '../../packages/form-schema/src/index.ts') },
{ find: /^@tmagic\/form\/headless$/, replacement: path.join(__dirname, '../../packages/form/src/headless.ts') },
{ find: /^@tmagic\/form/, replacement: path.join(__dirname, '../../packages/form/src/index.ts') },

View File

@ -15,18 +15,22 @@
无渲染实现按 `Container.vue` 及各容器组件的模板规则遍历配置树,产出的字段 `prop` 与规则与「挂载 `MForm` 后调用 `validate()`」等价。需要 UI 时传入 `dialog: true`,会把表单以弹层渲染出来供填写/确认。
字段只要带了 `rules`(会包 FormItem就会校验自身不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { nested })`把内部会挂到父表单上的配置交出来。nested 回调自身抛错时,会以 `FieldNestedConfigError``code: 'FIELD_NESTED_CONFIG'`reject。
字段只要带了 `rules`(会包 FormItem就会校验自身不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { innerConfig })`把内部会挂到父表单上的配置交出来。innerConfig 回调自身抛错时,会以 `FieldInnerConfigError``code: 'FIELD_INNER_CONFIG'`reject。
innerConfig 回调在校验和表单值初始化两条链路上都会被调用(后者用于走到复合字段内部、找出需要执行 `effect` 的子字段),所以它应当只做配置派生、可重复调用、不要做重活。在表单值初始化链路上,回调抛错只会记录到 console 并跳过该子树,不会让表单渲染不出来。
自定义字段的渲染组件和无渲染校验都通过 `registerField` / `registerFields` 一次登记。`component` 会写入字段注册表(`getFormField`);传入 `app` 时同时 `app.component('m-fields-*')`。容器组件用 `container`,对应 `m-form-*`
字段对表单值的初始化写入统一登记为 `effect`,渲染与无渲染共用同一份登记表,执行点也只有一个:表单值初始化完成后(`MForm` 内部、`validateValues`、以及 tab / table 新增行时)各执行一次 `applyMountValueEffects`,字段组件自身不要在 `setup` 里改写 `model`。因此 effect 有两个约束:一是必须幂等,同一份值可能被执行多次(如 `initValues` 变化后重新初始化);二是不看 `display``display: false` 或函数返回假的字段也会被规整,避免字段由隐藏转为显示时漏掉)。`type: 'hidden'` 不同:遍历在该节点停止、不往下分派,内部字段不会执行 effect。需要按路径跨层级写值时用上下文里的 `values`(本次处理的值根对象,`prop` 即以它为根),不要用 `mForm.values`——对比模式处理的是 `lastValues` 那一份,新增行处理的则是还没挂到表单上的一行值。单个 effect 抛错只会记录到 console不影响其余字段与表单渲染。复合字段可以同时登记 `effect``innerConfig`:前者改本字段的值,后者只派生内部配置、不要在回调里改 `model`
| 字段形态 | 登记方式 |
| --------------------------------------------------------------------- | ------------------------------------------- |
| 自身带 `rules`,内部没有嵌套的父表单 FormItem | 无需登记,直接校验 |
| 内部只渲染叶子 UI或把子表单渲染在独立的 `MForm` / `MFormBox` 实例里 | `registerField('my-field')`(配置里有 `items` 但不属于父表单时,避免被当下钻) |
| 同时需要渲染组件 | `registerField('my-field', { component })` |
| 容器组件(`m-form-*` | `registerField('my-box', { container, walk })` |
| 叶子字段,但挂载时会改写 model(类似 `display``initValue` | `registerField('my-field', { effect })` |
| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { nested })` |
| 叶子字段,但需要改写表单值(类似 `display``initValue` | `registerField('my-field', { effect })` |
| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { innerConfig })`(需要改本字段的值时再加 `effect` |
| 自定义 `typeMatch` 类型校验 | `registerField('my-field', { typeMatch })` |
```ts
@ -38,8 +42,7 @@ registerFields({ 'my-color-picker': { component: MyColorPicker } });
// 需要挂到当前 app 时传入第二个参数
registerFields({ 'my-color-picker': { component: MyColorPicker } }, app);
// 叶子字段但挂载setup时会改写 model传入 effect 让无渲染校验复刻这份写入,
// 否则无渲染校验拿到的值会与渲染式校验不一致
// 叶子字段,但需要改写表单值:写成 effect不要在组件 setup 里改 model
registerField('my-status', {
effect: ({ config, model }) => {
if ((config as any).initValue && model) {
@ -50,55 +53,55 @@ registerField('my-status', {
// 复合字段:把组件内部渲染的 MContainer 配置交出来
registerField('my-composite', {
nested: ({ config, model, prop }) => ({
// 对应组件内部 <MContainer :config="innerConfig" :model="model[name]" :prop="prop">
config: innerConfig,
innerConfig: ({ config, model, prop }) => ({
// 对应组件内部 <MContainer :config="childConfig" :model="model[name]" :prop="prop">
config: childConfig,
model: model[config.name],
prop,
}),
});
// typeMatch覆盖或扩展该 type 的类型匹配校验,可与 nested / effect 同时登记
// typeMatch覆盖或扩展该 type 的类型匹配校验,可与 innerConfig / effect 同时登记
registerField('my-status', {
typeMatch: (value, { message }) => (typeof value === 'string' ? undefined : message || '应为字符串'),
});
```
返回的 `config``name` 会被追加到返回的 `prop` 上。因此当嵌套配置复用了字段自身的 `name`(例如内部渲染 `<MGroupList :config="{ name, items }" :model="model" :prop="prop">`)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次:
返回的 `config``name` 会被追加到返回的 `prop` 上。因此当内部配置复用了字段自身的 `name`(例如内部渲染 `<MGroupList :config="{ name, items }" :model="model" :prop="prop">`)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次:
```ts
registerField('my-list', {
nested: ({ config, parentProp }) => ({
innerConfig: ({ config, parentProp }) => ({
config: { type: 'group-list', name: config.name, items: innerItems },
prop: parentProp,
}),
});
```
编辑器侧四个复合字段(`code-select` / `display-conds` / `event-select` / `style-setter`)的登记可参考 `packages/editor/src/fields/headless-validation.ts`nested 与组件共用同一份配置工厂(`packages/editor/src/fields/configs/`),避免两条链路各写一份而逐渐跑偏。
编辑器侧四个复合字段(`code-select` / `display-conds` / `event-select` / `style-setter`)的登记可参考 `packages/editor/src/fields/headless-validation.ts`innerConfig 与组件共用同一份配置工厂(`packages/editor/src/fields/configs/`),避免两条链路各写一份而逐渐跑偏。
`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem需要嵌套表单项时应对该具体组件 `registerField(type, { nested })`。
`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem需要嵌套表单项时应对该具体组件 `registerField(type, { innerConfig })`。
### 重复登记与撤销
同一个 type 多次登记按字段浅合并,后一次只覆盖自己传入的 key
```ts
registerField('my-composite', { nested });
registerField('my-composite', { component: MyComposite }); // nested 仍在
registerField('my-composite', { innerConfig });
registerField('my-composite', { component: MyComposite }); // innerConfig 仍在
```
登记分「内置」与「业务」两层。`app.use(MagicForm)` / `registerBuiltInFields` 写内置层,`registerField` / `registerFields` 写业务层;读取时业务层优先,`unregisterField` / `clearFields` 只清业务层,内置字段不受影响(单测里 `clearFields` 之后仍能校验 `text``tab` 等内置 type
因为是合并语义,把一个已登记 `nested` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销:
因为是合并语义,把一个已登记 `innerConfig` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销:
```ts
registerField('my-composite', {}); // ✗ 合并后 nested 还在,仍会下钻
registerField('my-composite', {}); // ✗ 合并后 innerConfig 还在,仍会下钻
unregisterField('my-composite'); // ✓ 先清掉业务层登记
registerField('my-composite', { component: MyComposite });
```
一次登记里同时传多个形态时的优先级:`walk` > `nested` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。
一次登记里同时传多个形态时的优先级:`walk` > `innerConfig` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。
## 签名
@ -274,7 +277,7 @@ if (error) {
}
```
校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。无法完成校验时才会 reject例如嵌套配置回调失败抛出 `FieldNestedConfigError`)。
校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。无法完成校验时才会 reject例如 innerConfig 回调失败抛出 `FieldInnerConfigError`)。
## 运行环境

View File

@ -155,7 +155,7 @@ app.use(MagicForm, {
### Editor 字段内置规则
安装 `@tmagic/editor` 时会把 `editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless``@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key`nested` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
安装 `@tmagic/editor` 时会把 `editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless``@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key`innerConfig` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
| 字段 type | 期望值 |
| --- | --- |
@ -174,7 +174,7 @@ app.use(MagicForm, {
> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules避免单个子项非法导致整块表单标红。
业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `nested` / `walk` / `effect`
业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `innerConfig` / `walk` / `effect`
## 示例

View File

@ -45,7 +45,7 @@ app.use(MagicForm);
app.mount("#app");
```
也可在安装时传入自定义字段登记(叶子 / nested / typeMatch / component详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则)
也可在安装时传入自定义字段登记(叶子 / innerConfig / typeMatch / component详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则)
```javascript
import MyField from './MyField.vue';

View File

@ -22,7 +22,7 @@ export default {
['^(@tencent)(/.*|$)'],
['^(@tmagic)(/.*|$)'],
// Internal packages.
['^(@|src|editor-page|@editor|@data-source)(/.*|$)'],
['^(@|src|editor-page|@editor|@form|@data-source)(/.*|$)'],
// Side effect imports.
['^\\u0000'],
// Parent imports. Put `..` last.

View File

@ -60,9 +60,8 @@ const lastValuesProcessed = computed<FormValue>(() => {
/**
* `code-select` 字段在历史数据中存在两种"语义为空"的形态
* - 字符串 `''`旧数据 / 用户从未配置过钩子
* - `{ hookType: HookType.CODE, hookData: [] }`CodeSelect.vue 在挂载时
* 写入的默认结构参见 packages/editor/src/fields/CodeSelect.vue
* `props.model[props.name] = { hookType: HookType.CODE, hookData: [] }`
* - `{ hookType: HookType.CODE, hookData: [] }``normalizeCodeSelectValue`
* 写入的默认结构
*
* 直接 `isEqual` 会把两者判为不等从而在历史对比里对每个未配置过钩子的组件
* 都展示一份"差异"体验很糟糕这里把它们视为相等跳过对比

View File

@ -52,15 +52,12 @@ const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues
const codeConfig = computed(() => createCodeSelectConfig(props.config));
// applyMountValueEffects code-select effect
watch(
() => props.model[props.name],
() => {
//
normalizeCodeSelectValue(props.model, props.name);
},
{
immediate: true,
},
);
const changeHandler = (v: any, eventData: ContainerChangeEventData) => emit('change', v, eventData);

View File

@ -109,10 +109,10 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
};
/**
* `fields/CodeSelect.vue`
* `watch(immediate)` `{ hookType, hookData }`
* `code-select` `{ hookType, hookData }`
*
*
* `code-select` `effect` `applyMountValueEffects`
* `watch` immediate
*/
export const normalizeCodeSelectValue = (model: FormValue | undefined, name: string): void => {
if (!model) return;

View File

@ -19,7 +19,8 @@
import {
type DisplayCondsConfig,
type EventSelectConfig,
type FieldNestedConfig,
type FieldInnerConfig,
type FieldMountValueEffect,
filterFunction,
type FormItemConfig,
type HeadlessFieldOptions,
@ -35,19 +36,30 @@ import { createStyleSetterConfig } from './StyleSetter/configs';
const getName = (config: FormItemConfig): string => `${(config as any).name ?? ''}`;
/** `code-select` 旧数据兼容:空值改写成 `{ hookType, hookData }`。 */
const codeSelectEffect: FieldMountValueEffect = ({ config, model }) => {
normalizeCodeSelectValue(model, getName(config));
};
/** `event-select`:表单 init 对未声明 defaultValue 的自定义 type 会写成空串,统一收成数组。 */
const eventSelectEffect: FieldMountValueEffect = ({ config, model }) => {
const name = getName(config);
if (model && !Array.isArray(model[name])) {
model[name] = [];
}
};
/**
* `code-select`
* `code-select`
*
* `fields/CodeSelect.vue`
* `<MContainer :config="codeConfig" :model="model[name]" :prop="prop">`
*
* @param ctx -
* @param ctx - innerConfig
* @returns config / model / prop
*/
const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
const codeSelectInnerConfig: FieldInnerConfig = ({ config, model, prop }) => {
const name = getName(config);
// 组件在 watch(immediate) 里做的旧数据兼容,发生在校验之前
normalizeCodeSelectValue(model, name);
return {
config: createCodeSelectConfig(config as any),
@ -57,14 +69,14 @@ const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
};
/**
* `display-conds`
* `display-conds`
*
* `fields/DisplayConds.vue` groupList `MGroupList`
*
* @param ctx -
* @param ctx - innerConfig
* @returns group-list prop parentProp name
*/
const displayCondsNestedConfig: FieldNestedConfig = ({ config, model, prop, parentProp, mForm }) => {
const displayCondsInnerConfig: FieldInnerConfig = ({ config, model, prop, parentProp, mForm }) => {
const name = getName(config);
const parentFields =
filterFunction<string[]>(mForm, (config as DisplayCondsConfig).parentFields, {
@ -81,19 +93,16 @@ const displayCondsNestedConfig: FieldNestedConfig = ({ config, model, prop, pare
};
/**
* `event-select`
* `event-select`
*
* `fields/EventSelect.vue` group-list title slot
* `<prop>.<index>.name`
*
* @param ctx -
* @param ctx - innerConfig
* @returns group-list null
*/
const eventSelectNestedConfig: FieldNestedConfig = ({ config, model, parentProp }) => {
const eventSelectInnerConfig: FieldInnerConfig = ({ config, model, parentProp }) => {
const name = getName(config);
if (model && !Array.isArray(model[name])) {
model[name] = [];
}
const events = model?.[name];
// 旧数据格式走的是另一套表格配置,其中不含任何 rules不参与校验
@ -106,15 +115,15 @@ const eventSelectNestedConfig: FieldNestedConfig = ({ config, model, parentProp
};
/**
* `style-setter`
* `style-setter`
*
* `fields/StyleSetter/Index.vue`6 `:values="model[name]"``:prop="prop || name"`
* `theme` `useTheme` flexWrap UI childType
*
* @param ctx -
* @param ctx - innerConfig
* @returns style styleModel
*/
const styleSetterNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
const styleSetterInnerConfig: FieldInnerConfig = ({ config, model, prop }) => {
const name = getName(config);
const styleModel = (model?.[name] ?? {}) as Partial<StyleSchema>;
@ -132,10 +141,10 @@ const styleSetterNestedConfig: FieldNestedConfig = ({ config, model, prop }) =>
* plugin `component` `@tmagic/form`
*
* - UI MForm / MFormBox
* - nested MContainer / MPanel / MGroupList
* - innerConfig MContainer / MPanel / MGroupList
* - typeMatch type
*
* nested config / model / prop
* innerConfig config / model / prop
* `fields/configs/`
*/
export const editorFields: Record<string, HeadlessFieldOptions> = {
@ -153,8 +162,16 @@ export const editorFields: Record<string, HeadlessFieldOptions> = {
'data-source-methods': { typeMatch: editorTypeMatchRules['data-source-methods'] },
'data-source-method-select': { typeMatch: editorTypeMatchRules['data-source-method-select'] },
'data-source-field-select': { typeMatch: editorTypeMatchRules['data-source-field-select'] },
'code-select': { nested: codeSelectNestedConfig, typeMatch: editorTypeMatchRules['code-select'] },
'display-conds': { nested: displayCondsNestedConfig, typeMatch: editorTypeMatchRules['display-conds'] },
'event-select': { nested: eventSelectNestedConfig, typeMatch: editorTypeMatchRules['event-select'] },
'style-setter': { nested: styleSetterNestedConfig, typeMatch: editorTypeMatchRules['style-setter'] },
'code-select': {
effect: codeSelectEffect,
innerConfig: codeSelectInnerConfig,
typeMatch: editorTypeMatchRules['code-select'],
},
'display-conds': { innerConfig: displayCondsInnerConfig, typeMatch: editorTypeMatchRules['display-conds'] },
'event-select': {
effect: eventSelectEffect,
innerConfig: eventSelectInnerConfig,
typeMatch: editorTypeMatchRules['event-select'],
},
'style-setter': { innerConfig: styleSetterInnerConfig, typeMatch: editorTypeMatchRules['style-setter'] },
};

View File

@ -108,10 +108,13 @@ describe('CodeSelect', () => {
expect(title).toBe('unknown');
});
test('空 model 时初始化为 { hookType, hookData }', () => {
const props = baseProps({ model: { cs: undefined } });
mount(CodeSelect, { props: props as any });
expect((props.model.cs as any).hookData).toEqual([]);
test('运行期被置空时补成 { hookType, hookData }', async () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const model: Record<string, any> = { cs: '' };
await wrapper.setProps({ model });
expect(model.cs).toEqual({ hookType: 'code', hookData: [] });
});
test('codeType items 配置正确', () => {

View File

@ -4,9 +4,14 @@
* Copyright (C) 2025 Tencent.
*/
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { computed } from 'vue';
import { NODE_CONDS_KEY } from '@tmagic/core';
import { editorFields } from '@editor/fields/headless-validation';
import { fillConfig } from '@editor/utils/props';
// 走 @form 源码别名而非 @tmagic/form编辑器包在单测里解析到的是 form 的构建产物
import {
applyMountValueEffects,
builtInFields,
clearFields,
collectValidatableFields,
@ -15,11 +20,6 @@ import {
registerFields,
} from '@form/index';
import { NODE_CONDS_KEY } from '@tmagic/core';
import { editorFields } from '@editor/fields/headless-validation';
import { fillConfig } from '@editor/utils/props';
/**
* config
* prop FormItem
@ -29,12 +29,7 @@ const collect = (config: any[], values: any, typeMatchValid = true) => {
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
const fields = collectValidatableFields(
formState,
config,
values,
computed(() => typeMatchValid),
);
const fields = collectValidatableFields(formState, config, values, typeMatchValid);
return { props: fields.map((field) => field.prop) };
};
@ -82,12 +77,23 @@ describe('code-select', () => {
]);
});
test('空值按组件的旧数据兼容改写为 { hookType, hookData }', () => {
test('collect 只读,空值不会在收集字段时被改写', () => {
const config = [{ type: 'code-select', name: 'created' }];
const values: any = { created: [] };
collect(config, values);
expect(values.created).toEqual([]);
});
test('applyMountValueEffects 把空值改写成 { hookType, hookData }', () => {
const config = [{ type: 'code-select', name: 'created' }] as any;
const values: any = { created: [] };
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
applyMountValueEffects(formState, config, values);
expect(values.created).toEqual({ hookType: 'code', hookData: [] });
});
@ -199,6 +205,17 @@ describe('event-select', () => {
]);
});
test('applyMountValueEffects 把非数组收成空数组', () => {
const config = [{ type: 'event-select', name: 'events' }] as any;
const values: any = {};
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
applyMountValueEffects(formState, config, values);
expect(values.events).toEqual([]);
});
test('旧数据格式(列表项没有 actions不含校验规则不产出字段', () => {
const config = [{ type: 'event-select', name: 'events' }];
const values = { events: [{ name: 'click', to: 'node_1', method: 'show' }] };
@ -295,14 +312,7 @@ describe('fillConfig 通用属性表单', () => {
// 关掉独立样式面板,让 fillConfig 注入的 style tab 走 display从而覆盖 style-setter
(formState as any).services = { uiService: { get: (key: string) => key !== 'showStylePanel' } };
expect(() =>
collectValidatableFields(
formState,
config,
values,
computed(() => true),
),
).not.toThrow();
expect(() => collectValidatableFields(formState, config, values, true)).not.toThrow();
});
test('显示条件 tab 按 groupList 展开条件组与组内条件', () => {

View File

@ -83,7 +83,7 @@ describe('plugin install', () => {
expect(formInstall).toBeDefined();
const fields = formInstall[1].fields as Record<
string,
{ component?: unknown; container?: unknown; nested?: unknown; typeMatch?: unknown }
{ component?: unknown; container?: unknown; innerConfig?: unknown; typeMatch?: unknown }
>;
expect(formInstall[1].someOption).toBe(true);
expect(Object.keys(fields)).toEqual(
@ -117,11 +117,14 @@ describe('plugin install', () => {
}
expect(fields[type].component, `${type} 缺少 component`).toBeDefined();
}
expect(fields['code-select'].nested).toEqual(expect.any(Function));
expect(fields['code-select'].effect).toEqual(expect.any(Function));
expect(fields['code-select'].innerConfig).toEqual(expect.any(Function));
expect(fields['code-select'].typeMatch).toEqual(expect.any(Function));
expect(fields['style-setter'].nested).toEqual(expect.any(Function));
expect(fields['event-select'].effect).toEqual(expect.any(Function));
expect(fields['event-select'].innerConfig).toEqual(expect.any(Function));
expect(fields['style-setter'].innerConfig).toEqual(expect.any(Function));
expect(fields['ui-select'].typeMatch).toEqual(expect.any(Function));
expect(fields['vs-code'].nested).toBeUndefined();
expect(fields['vs-code'].innerConfig).toBeUndefined();
expect(components.MEditor).toBeDefined();
expect(components['magic-code-editor']).toBeDefined();
expect(Object.keys(components)).toEqual(['MEditor', 'magic-code-editor']);
@ -138,7 +141,8 @@ describe('plugin install', () => {
} as any);
const formOpt = (app.use as any).mock.calls.find((call: any[]) => call[0] === formPlugin)[1];
expect(formOpt.fields['code-select'].component).toEqual({ name: 'CustomCodeSelect' });
expect(formOpt.fields['code-select'].nested).toEqual(expect.any(Function));
expect(formOpt.fields['code-select'].effect).toEqual(expect.any(Function));
expect(formOpt.fields['code-select'].innerConfig).toEqual(expect.any(Function));
expect(formOpt.fields['code-select'].typeMatch).toEqual(expect.any(Function));
expect(formOpt.fields['event-select'].effect).toEqual(expect.any(Function));
expect(formOpt.fields['my-field'].component).toEqual({ name: 'MyField' });

View File

@ -56,6 +56,7 @@ import { M_THEME_KEY, TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagi
import { setValueByKeyPath } from '@tmagic/utils';
import Container from './containers/Container.vue';
import { applyMountValueEffects } from './utils/collectFields';
import { applyExtendState, createFormStateBase, initValue } from './utils/form';
import { formatValidateError as formatError, getTextByName as findTextByName } from './utils/validateError';
import type { ChangeRecord, ContainerChangeEventData, FormConfig, FormSlots, FormState, FormValue } from './schema';
@ -346,6 +347,9 @@ watch(
config: props.config,
}).then((value) => {
values.value = value;
// model
// effect type / display formValue
applyMountValueEffects(formState, props.config, values.value);
//
initialized.value = !props.isCompare;
@ -363,6 +367,8 @@ watch(
config: props.config,
}).then((value) => {
lastValuesProcessed.value = value;
//
applyMountValueEffects(formState, props.config, lastValuesProcessed.value);
initialized.value = true;
});
}

View File

@ -358,7 +358,7 @@ const type = computed((): string => resolveItemType(mForm, props.config, props))
const tagName = computed(() => {
// `type: 'component'` Vue
// FormItem addField MContainer
// registerField(type, { nested })
// registerField(type, { innerConfig })
if (type.value === 'component' && (props.config as ComponentConfig).component) {
return (props.config as ComponentConfig).component;
}

View File

@ -86,6 +86,7 @@ import { isEmpty } from 'lodash-es';
import { getDesignConfig, TMagicBadge } from '@tmagic/design';
import type { ContainerChangeEventData, FormState, TabConfig, TabPaneConfig } from '../schema';
import { applyMountValueEffects } from '../utils/collectFields';
import { display as displayFunc, filterFunction, initValue } from '../utils/form';
import Container from './Container.vue';
@ -205,6 +206,13 @@ const onTabAdd = async () => {
prop: props.prop,
config: props.config,
});
// push effect
const tabsModel = props.model[props.name];
if (Array.isArray(tabsModel) && Array.isArray(props.config.items)) {
for (const tab of tabsModel) {
applyMountValueEffects(mForm, props.config.items, tab);
}
}
emit('change', props.model[props.name]);
} else {
const newObj = await initValue(mForm, {
@ -212,6 +220,9 @@ const onTabAdd = async () => {
initValues: {},
});
//
applyMountValueEffects(mForm, props.config.items, newObj);
newObj.title = `标签${tabs.value.length + 1}`;
props.model[props.name].push(newObj);

View File

@ -58,8 +58,9 @@ import { Grid, Plus } from '@element-plus/icons-vue';
import { TMagicButton } from '@tmagic/design';
import type { GroupListConfig, TableConfig } from '@tmagic/form-schema';
import type { ContainerChangeEventData } from '../../schema';
import { isGroupListType, toGroupListConfig, toTableConfig } from '../../utils/tableGroupList';
import type { ContainerChangeEventData } from '@form/schema';
import { isGroupListType, toGroupListConfig, toTableConfig } from '@form/utils/tableGroupList';
import MFormGroupList from '../GroupList.vue';
import MFormTable from '../table/Table.vue';

View File

@ -3,7 +3,9 @@ import { computed, inject } from 'vue';
import { tMagicMessage } from '@tmagic/design';
import type { FormConfig, FormState, TableConfig, TableGroupListCommonConfig } from '@tmagic/form-schema';
import { initValue } from '../../utils/form';
import { applyMountValueEffects } from '@form/utils/collectFields';
import { initValue } from '@form/utils/form';
import type { TableProps } from '../table/type';
export const useAdd = (
@ -110,6 +112,9 @@ export const useAdd = (
});
}
// enum / Excel 导入的数组行与默认新增共用同一份规整,字段组件不再在 setup 里改 model
applyMountValueEffects(mForm, columns as FormConfig, inputs);
if (props.sortKey && length) {
inputs[props.sortKey] = list[length - 1][props.sortKey] - 1;
}

View File

@ -43,7 +43,7 @@ import { cloneDeep } from 'lodash-es';
import { TMagicButton, TMagicTooltip } from '@tmagic/design';
import type { FormState, TableConfig } from '../../schema';
import type { FormState, TableConfig } from '@form/schema';
const emit = defineEmits(['change']);

View File

@ -92,8 +92,8 @@ import { FullScreen } from '@element-plus/icons-vue';
import { TMagicButton, TMagicPagination, TMagicTable, TMagicTooltip, TMagicUpload } from '@tmagic/design';
import type { SortProp } from '../../schema';
import { sortChange } from '../../utils/form';
import type { SortProp } from '@form/schema';
import { sortChange } from '@form/utils/form';
import type { TableProps } from './type';
import { useFullscreen } from './useFullscreen';

View File

@ -1,6 +1,6 @@
import { computed, type Ref, ref } from 'vue';
import { getDataByPage } from '../../utils/form';
import { getDataByPage } from '@form/utils/form';
import type { TableProps } from './type';

View File

@ -4,7 +4,7 @@ import type { default as SortableType, SortableEvent } from 'sortablejs';
import { type TMagicTable } from '@tmagic/design';
import type { FormState } from '@tmagic/form-schema';
import { sortArray } from '../../utils/form';
import { sortArray } from '@form/utils/form';
import type { TableProps } from './type';

View File

@ -4,10 +4,11 @@ import { WarningFilled } from '@element-plus/icons-vue';
import { type TableColumnOptions, TMagicIcon, TMagicTooltip } from '@tmagic/design';
import type { FormItemConfig, FormState } from '@tmagic/form-schema';
import type { ContainerChangeEventData } from '../../schema';
import { isGlobalFlat } from '../../utils/config';
import { appendProp, display as displayFunc, getDataByPage, sortArray } from '../../utils/form';
import { isTableColumnRendered, makeTableColumnConfig } from '../../utils/tableGroupList';
import type { ContainerChangeEventData } from '@form/schema';
import { isGlobalFlat } from '@form/utils/config';
import { appendProp, display as displayFunc, getDataByPage, sortArray } from '@form/utils/form';
import { isTableColumnRendered, makeTableColumnConfig } from '@form/utils/tableGroupList';
import Container from '../Container.vue';
import ActionsColumn from './ActionsColumn.vue';

View File

@ -11,10 +11,9 @@ import { computed, inject } from 'vue';
import { TMagicCheckbox, TMagicCheckboxGroup } from '@tmagic/design';
import type { CheckboxGroupConfig, CheckboxGroupOption, FieldProps, FormState } from '../schema';
import { initCheckboxGroupValue } from '../utils/fieldValueEffects';
import { filterFunction } from '../utils/form';
import { useAddField } from '../utils/useAddField';
import type { CheckboxGroupConfig, CheckboxGroupOption, FieldProps, FormState } from '@form/schema';
import { filterFunction } from '@form/utils/form';
import { useAddField } from '@form/utils/useAddField';
defineOptions({
name: 'MFormCheckGroup',
@ -26,8 +25,6 @@ const emit = defineEmits(['change']);
useAddField(props.prop);
initCheckboxGroupValue(props.model, props.name);
const changeHandler = (v: Array<string | number | boolean>) => {
emit('change', v);
};

View File

@ -0,0 +1,27 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { FieldMountValueEffect } from '@form/utils/fieldValueEffects';
/** 空值初始化为空数组。 */
export const effect: FieldMountValueEffect = ({ config, model }) => {
const { name } = config as any;
if (model && !model[name]) {
model[name] = [];
}
};

View File

@ -14,9 +14,8 @@
<script lang="ts" setup>
import { TMagicDatePicker } from '@tmagic/design';
import type { DateConfig, FieldProps } from '../schema';
import { normalizeDateValue } from '../utils/fieldValueEffects';
import { useAddField } from '../utils/useAddField';
import type { DateConfig, FieldProps } from '@form/schema';
import { useAddField } from '@form/utils/useAddField';
defineOptions({
name: 'MFormDate',
@ -30,8 +29,6 @@ const emit = defineEmits<{
useAddField(props.prop);
normalizeDateValue(props.config, props.model, props.name);
const changeHandler = (v: string) => {
emit('change', v);
};

View File

@ -0,0 +1,29 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DateConfig } from '@form/schema';
import type { FieldMountValueEffect } from '@form/utils/fieldValueEffects';
import { datetimeFormatter } from '@form/utils/form';
/** 按 `valueFormat` 归一化日期值。 */
export const effect: FieldMountValueEffect = ({ config, model }) => {
if (!model) return;
const { name, valueFormat } = config as DateConfig & { name: string };
model[name] = datetimeFormatter(model[name], '', valueFormat || 'YYYY/MM/DD');
};

View File

@ -16,9 +16,8 @@
<script lang="ts" setup>
import { TMagicDatePicker } from '@tmagic/design';
import type { DateTimeConfig, FieldProps } from '../schema';
import { normalizeDateTimeValue } from '../utils/fieldValueEffects';
import { useAddField } from '../utils/useAddField';
import type { DateTimeConfig, FieldProps } from '@form/schema';
import { useAddField } from '@form/utils/useAddField';
defineOptions({
name: 'MFormDateTime',
@ -32,8 +31,6 @@ const emit = defineEmits<{
useAddField(props.prop);
normalizeDateTimeValue(props.config, props.model, props.name);
const changeHandler = (v: string) => {
emit('change', v);
};

View File

@ -0,0 +1,36 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DateTimeConfig } from '@form/schema';
import type { FieldMountValueEffect } from '@form/utils/fieldValueEffects';
import { datetimeFormatter } from '@form/utils/form';
/** 按 `valueFormat` 归一化日期时间值,空值与非法值统一为空字符串。 */
export const effect: FieldMountValueEffect = ({ config, model }) => {
if (!model) return;
const { name, valueFormat } = config as DateTimeConfig & { name: string };
const value = model[name]?.toString();
if (!value || value === 'Invalid Date') {
model[name] = '';
return;
}
model[name] = datetimeFormatter(model[name], '', valueFormat || 'YYYY/MM/DD HH:mm:ss');
};

View File

@ -5,10 +5,9 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
import type { DisplayConfig, FieldProps, FormState } from '../schema';
import { applyDisplayInitValue } from '../utils/fieldValueEffects';
import { filterFunction } from '../utils/form';
import { useAddField } from '../utils/useAddField';
import type { DisplayConfig, FieldProps, FormState } from '@form/schema';
import { filterFunction } from '@form/utils/form';
import { useAddField } from '@form/utils/useAddField';
defineOptions({
name: 'MFormDisplay',
@ -18,8 +17,6 @@ const props = defineProps<FieldProps<DisplayConfig>>();
const mForm = inject<FormState | undefined>('mForm');
applyDisplayInitValue(props.config, props.model, props.name);
const text = computed(() => {
if (props.config.displayText) {
return filterFunction<string>(mForm, props.config.displayText, props);

View File

@ -0,0 +1,28 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DisplayConfig } from '@form/schema';
import type { FieldMountValueEffect } from '@form/utils/fieldValueEffects';
/** 把 `initValue` 写入 model。 */
export const effect: FieldMountValueEffect = ({ config, model }) => {
const { initValue, name } = config as DisplayConfig & { name: string };
if (initValue && model) {
model[name] = initValue;
}
};

View File

@ -27,10 +27,11 @@ import { onBeforeUnmount, reactive, watch } from 'vue';
import { TMagicForm, TMagicFormItem, TMagicInput } from '@tmagic/design';
import type { DynamicFieldConfig, FieldProps } from '../schema';
import { getConfig } from '../utils/config';
import { eachDynamicField } from '../utils/fieldValueEffects';
import { useAddField } from '../utils/useAddField';
import type { DynamicFieldConfig, FieldProps } from '@form/schema';
import { getConfig } from '@form/utils/config';
import { useAddField } from '@form/utils/useAddField';
import { eachDynamicField } from './effect';
defineOptions({
name: 'MFormDynamicField',

View File

@ -0,0 +1,74 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { setValueByKeyPath } from '@tmagic/utils';
import type { DynamicFieldConfig, FormValue } from '@form/schema';
import { getConfig } from '@form/utils/config';
import type { FieldMountValueEffect } from '@form/utils/fieldValueEffects';
/** `dynamic-field` 的 `returnFields` 返回的单个字段描述 */
type DynamicFieldItem = ReturnType<DynamicFieldConfig['returnFields']>[number];
/**
* defaultValue
*
* `isDefaultApplied` `defaultValue`
* emit change `values`
*/
export const eachDynamicField = (
fields: DynamicFieldItem[],
model: FormValue | undefined,
onField: (_field: DynamicFieldItem, _value: any, _isDefaultApplied: boolean) => void,
): void => {
for (const field of fields) {
if (typeof field !== 'object' || field?.name === undefined) continue;
let value = model?.[field.name] || '';
let isDefaultApplied = false;
if (!value && field.defaultValue !== undefined) {
value = field.defaultValue;
isDefaultApplied = true;
}
onField(field, value, isDefaultApplied);
}
};
/**
* `returnFields` defaultValue prop
*
* model Container modifyKey `${prop}.${key}`
*
* returnFields watch
*/
export const effect: FieldMountValueEffect = ({ config, model, prop, values }) => {
const { returnFields, dynamicKey } = config as DynamicFieldConfig;
if (typeof returnFields !== 'function' || !model) return;
if (model[dynamicKey] === '') return;
const result = returnFields(config as DynamicFieldConfig, model, getConfig<Function>('request'));
if (!result || typeof (result as any).then === 'function' || !Array.isArray(result)) return;
eachDynamicField(result, model, (field, value, isDefaultApplied) => {
if (isDefaultApplied) {
setValueByKeyPath(`${prop}.${field.name}`, value, values || model);
}
});
};

View File

@ -23,9 +23,8 @@ import { ref, watch } from 'vue';
import { TMagicInput } from '@tmagic/design';
import type { FieldProps, NumberRangeConfig } from '../schema';
import { normalizeNumberRangeValue } from '../utils/fieldValueEffects';
import { useAddField } from '../utils/useAddField';
import type { FieldProps, NumberRangeConfig } from '@form/schema';
import { useAddField } from '@form/utils/useAddField';
defineOptions({
name: 'MFormNumberRange',
@ -54,8 +53,6 @@ watch(
useAddField(props.prop);
normalizeNumberRangeValue(props.model, props.name);
const minChangeHandler = (v: string) => {
emit('change', [Number(v), props.model[props.name][1]]);
};

View File

@ -0,0 +1,27 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { FieldMountValueEffect } from '@form/utils/fieldValueEffects';
/** 值不是数组时修正为空数组。 */
export const effect: FieldMountValueEffect = ({ config, model }) => {
const { name } = config as any;
if (model && !Array.isArray(model[name])) {
model[name] = [];
}
};

View File

@ -44,12 +44,17 @@ export {
} from './utils/registerField';
export type { FieldOptions, HeadlessFieldOptions } from './utils/registerField';
export type { FieldNestedConfig, FieldNestedConfigContext, FieldNestedConfigResult } from './utils/fieldNestedConfig';
export type { FieldInnerConfig, FieldInnerConfigContext, FieldInnerConfigResult } from './utils/fieldInnerConfig';
export { isLeafFieldType } from './utils/fieldValueEffects';
export type { FieldMountValueEffect, FieldMountValueEffectContext } from './utils/fieldValueEffects';
export { collectValidatableFields, FieldNestedConfigError, isFieldNestedConfigError } from './utils/collectFields';
export {
applyMountValueEffects,
collectValidatableFields,
FieldInnerConfigError,
isFieldInnerConfigError,
} from './utils/collectFields';
export type { CollectedField } from './utils/collectFields';
export { createHeadlessFormState, validateValues } from './utils/validateValues';

View File

@ -38,24 +38,24 @@ export { default as MGroupList } from './containers/table-group-list/TableGroupL
export { default as MTableGroupList } from './containers/table-group-list/TableGroupList.vue';
export { default as MText } from './fields/Text.vue';
export { default as MNumber } from './fields/Number.vue';
export { default as MNumberRange } from './fields/NumberRange.vue';
export { default as MNumberRange } from './fields/NumberRange/Index.vue';
export { default as MTextarea } from './fields/Textarea.vue';
export { default as MHidden } from './fields/Hidden.vue';
export { default as MDate } from './fields/Date.vue';
export { default as MDateTime } from './fields/DateTime.vue';
export { default as MDate } from './fields/Date/Index.vue';
export { default as MDateTime } from './fields/DateTime/Index.vue';
export { default as MTime } from './fields/Time.vue';
export { default as MCheckbox } from './fields/Checkbox.vue';
export { default as MSwitch } from './fields/Switch.vue';
export { default as MDaterange } from './fields/Daterange.vue';
export { default as MTimerange } from './fields/Timerange.vue';
export { default as MColorPicker } from './fields/ColorPicker.vue';
export { default as MCheckboxGroup } from './fields/CheckboxGroup.vue';
export { default as MCheckboxGroup } from './fields/CheckboxGroup/Index.vue';
export { default as MRadioGroup } from './fields/RadioGroup.vue';
export { default as MDisplay } from './fields/Display.vue';
export { default as MDisplay } from './fields/Display/Index.vue';
export { default as MLink } from './fields/Link.vue';
export { default as MSelect } from './fields/Select.vue';
export { default as MCascader } from './fields/Cascader.vue';
export { default as MDynamicField } from './fields/DynamicField.vue';
export { default as MDynamicField } from './fields/DynamicField/Index.vue';
export { builtInFields } from './utils/builtInFields';
@ -70,12 +70,17 @@ export {
} from './utils/registerField';
export type { FieldOptions, HeadlessFieldOptions } from './utils/registerField';
export type { FieldNestedConfig, FieldNestedConfigContext, FieldNestedConfigResult } from './utils/fieldNestedConfig';
export type { FieldInnerConfig, FieldInnerConfigContext, FieldInnerConfigResult } from './utils/fieldInnerConfig';
export { isLeafFieldType } from './utils/fieldValueEffects';
export type { FieldMountValueEffect, FieldMountValueEffectContext } from './utils/fieldValueEffects';
export { collectValidatableFields, FieldNestedConfigError, isFieldNestedConfigError } from './utils/collectFields';
export {
applyMountValueEffects,
collectValidatableFields,
FieldInnerConfigError,
isFieldInnerConfigError,
} from './utils/collectFields';
export type { CollectedField } from './utils/collectFields';
export { createHeadlessFormState, validateValues } from './utils/validateValues';

View File

@ -28,17 +28,17 @@ import TableGroupList from './containers/table-group-list/TableGroupList.vue';
import Tabs from './containers/Tabs.vue';
import Cascader from './fields/Cascader.vue';
import Checkbox from './fields/Checkbox.vue';
import CheckboxGroup from './fields/CheckboxGroup.vue';
import CheckboxGroup from './fields/CheckboxGroup/Index.vue';
import ColorPicker from './fields/ColorPicker.vue';
import Date from './fields/Date.vue';
import Date from './fields/Date/Index.vue';
import Daterange from './fields/Daterange.vue';
import DateTime from './fields/DateTime.vue';
import Display from './fields/Display.vue';
import DynamicField from './fields/DynamicField.vue';
import DateTime from './fields/DateTime/Index.vue';
import Display from './fields/Display/Index.vue';
import DynamicField from './fields/DynamicField/Index.vue';
import Hidden from './fields/Hidden.vue';
import Link from './fields/Link.vue';
import Number from './fields/Number.vue';
import NumberRange from './fields/NumberRange.vue';
import NumberRange from './fields/NumberRange/Index.vue';
import RadioGroup from './fields/RadioGroup.vue';
import Select from './fields/Select.vue';
import Switch from './fields/Switch.vue';
@ -63,7 +63,7 @@ export interface FormInstallOptions {
/** 是否启用全局 flat 模式。 */
flat?: boolean;
/**
* type / nested / walk / typeMatch / component / container
* type / innerConfig / walk / typeMatch / component / container
* `registerFields`
*/
fields?: Record<string, FieldOptions>;

View File

@ -16,15 +16,14 @@
* limitations under the License.
*/
import { effect as checkboxGroupEffect } from '../fields/CheckboxGroup/effect';
import { effect as dateEffect } from '../fields/Date/effect';
import { effect as dateTimeEffect } from '../fields/DateTime/effect';
import { effect as displayEffect } from '../fields/Display/effect';
import { effect as dynamicFieldEffect } from '../fields/DynamicField/effect';
import { effect as numberRangeEffect } from '../fields/NumberRange/effect';
import { expandFieldset, expandPanel, expandRow, expandStep, expandTab, expandTableGroupList } from './collectFields';
import {
checkboxGroupEffect,
dateEffect,
dateTimeEffect,
displayEffect,
dynamicFieldEffect,
numberRangeEffect,
} from './fieldValueEffects';
import { type HeadlessFieldOptions } from './registerField';
/**

View File

@ -16,13 +16,11 @@
* limitations under the License.
*/
import type { ComputedRef } from 'vue';
import { toLine } from '@tmagic/utils';
import type { FormConfig, FormItemConfig, FormState, FormValue, Rule } from '../schema';
import { getFieldNestedConfig } from './fieldNestedConfig';
import { getFieldInnerConfig } from './fieldInnerConfig';
import { getFieldMountValueEffect, isLeafFieldType } from './fieldValueEffects';
import {
appendProp,
@ -56,16 +54,16 @@ export interface CollectedField {
}
// #endregion CollectedField
/** 已登记的嵌套配置回调自身抛错时抛出(机制故障,不是漏登记) */
export class FieldNestedConfigError extends Error {
readonly code = 'FIELD_NESTED_CONFIG';
/** 已登记的 innerConfig 回调自身抛错时抛出(机制故障,不是漏登记) */
export class FieldInnerConfigError extends Error {
readonly code = 'FIELD_INNER_CONFIG';
readonly type: string;
readonly prop: string;
constructor(type: string, prop: string, cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause);
super(`[MForm] nested config for "${type}" at "${prop}" failed: ${reason}`);
this.name = 'FieldNestedConfigError';
super(`[MForm] innerConfig for "${type}" at "${prop}" failed: ${reason}`);
this.name = 'FieldInnerConfigError';
this.type = type;
this.prop = prop;
if (cause instanceof Error) {
@ -74,16 +72,41 @@ export class FieldNestedConfigError extends Error {
}
}
export const isFieldNestedConfigError = (error: unknown): error is FieldNestedConfigError =>
error instanceof FieldNestedConfigError ||
(typeof error === 'object' && error !== null && (error as { code?: string }).code === 'FIELD_NESTED_CONFIG');
export const isFieldInnerConfigError = (error: unknown): error is FieldInnerConfigError =>
error instanceof FieldInnerConfigError ||
(typeof error === 'object' && error !== null && (error as { code?: string }).code === 'FIELD_INNER_CONFIG');
/**
*
*
* - `collect` rules `display` FormItem
* - `effects` `display`
* `display: false` `type: 'hidden'`
*/
type WalkMode = 'collect' | 'effects';
interface WalkContext {
mForm: FormState | undefined;
typeMatchValid: ComputedRef<boolean> | undefined;
/**
* typeMatch MForm `typeMatchValid`
*
* `collect` `addField` `getNativeRules` true
* rules `typeMatch`true / false `{ typeMatch: true }`
* `type` `required`
* rules `typeMatch: false`
*
* `effects`
*/
typeMatchValid?: boolean;
fields: CollectedField[];
/** 本次遍历处理的表单值根对象(对比模式下为 lastValues 那一份) */
values: FormValue;
mode: WalkMode;
}
/** `effects` 模式下遍历全部配置,不受 display / 折叠状态影响 */
const ignoresDisplay = (ctx: WalkContext): boolean => ctx.mode === 'effects';
interface WalkNode {
config: FormItemConfig;
/** 所在层级的 model 切片(对应 Container 的 `props.model` */
@ -118,6 +141,58 @@ export const clearContainerWalkers = (): void => extraContainerWalkers.clear();
const getItems = (config: any): FormItemConfig[] | undefined => config?.items;
/**
* `resolveItemType` `type`
*
* type effect
*/
const staticItemType = (config: any): string => {
const type = 'type' in config ? config.type : '';
// form / container 都表示「仅嵌套,不渲染字段」
if (type === 'form' || type === 'container') return '';
return `${type || ''}`.replace(/([A-Z])/g, '-$1').toLowerCase() || (config.items ? '' : 'text');
};
/**
*
*
* false `type``itemsFunction`
* innerConfig
*
* `items` / tablegroup-listdynamic tab
* O() O( × ) effect
*
*
* `items`table / group-list
* `tableItems` / `groupItems`
*/
const mayRunEffects = (config: any): boolean => {
if (!config) return false;
if (typeof config.type === 'function' || typeof config.itemsFunction === 'function') return true;
const type = staticItemType(config);
// walkNode 对 hidden 只收集规则,不往下分派
if (type === 'hidden') return false;
if (type) {
const key = toLine(type);
// 业务登记的容器遍历路径未知
if (extraContainerWalkers.has(key)) return true;
if (!builtInContainerWalkers.has(key)) {
if (getFieldInnerConfig(type) || getFieldMountValueEffect(type)) return true;
// 叶子字段没有子树dispatchByType 到此为止
if (isLeafFieldType(type)) return false;
}
}
return itemsMayRunEffects(config.items);
};
const itemsMayRunEffects = (items: any): boolean =>
Array.isArray(items) && items.some((item: any) => mayRunEffects(item));
/**
* `Container.vue` `display`
*
@ -125,12 +200,16 @@ const getItems = (config: any): FormItemConfig[] | undefined => config?.items;
*
*/
const resolveDisplay = (ctx: WalkContext, config: any, nodeProps: any): boolean => {
if (ignoresDisplay(ctx)) return true;
const value = displayFunction(ctx.mForm, config?.display, nodeProps);
if (value === 'expand') return true;
return Boolean(value);
};
const addField = (ctx: WalkContext, node: WalkNode, itemProp: string, nodeProps: any): void => {
if (ctx.mode !== 'collect') return;
const rules = getNativeRules(ctx.mForm, (node.config as any).rules, nodeProps, ctx.typeMatchValid) as Rule[];
if (!rules.length) return;
@ -165,13 +244,18 @@ export const expandTab = (ctx: WalkContext, node: WalkNode, itemProp: string): v
if ((config as any).dynamic) {
if (!name) return;
const tabs = model?.[name] || [];
if (!tabs.length) return;
// 每个标签页展开同一份 items逐页展开前先按 items 预判一次
if (ctx.mode === 'effects' && !itemsMayRunEffects(items)) return;
tabs.forEach((_tab: any, tabIndex: number) => {
walkChildren(ctx, items, childModel?.[tabIndex], appendProp(itemProp, tabIndex));
});
return;
}
const tabs = (items || []).filter((item: any) => displayFunction(ctx.mForm, item?.display, tabsProps));
const tabs = ignoresDisplay(ctx)
? items || []
: (items || []).filter((item: any) => displayFunction(ctx.mForm, item?.display, tabsProps));
for (const tab of tabs) {
const tabName = (tab as any).name;
// tab.lazy 只影响渲染时机,不影响该标签页是否属于这份配置,无渲染校验一律遍历
@ -199,7 +283,7 @@ export const expandFieldset = (ctx: WalkContext, node: WalkNode, itemProp: strin
const checkboxTrueValue =
typeof checkbox === 'object' && typeof checkbox.trueValue !== 'undefined' ? checkbox.trueValue : 1;
// 勾选框关闭时整个 fieldset 的子项不渲染,语义上等于「该段配置未启用」,不参与校验
if ((config as any).expand && childModel?.[checkboxName] !== checkboxTrueValue) return;
if (!ignoresDisplay(ctx) && (config as any).expand && childModel?.[checkboxName] !== checkboxTrueValue) return;
walkChildren(ctx, items, childModel, itemProp);
};
@ -235,11 +319,14 @@ export const expandTableGroupList = (ctx: WalkContext, node: WalkNode, itemProp:
const { config, model } = node;
const name = (config as any).name || '';
const rows = model?.[name];
if (!Array.isArray(rows)) return;
if (!Array.isArray(rows) || !rows.length) return;
if (isGroupListType((config as any).type)) {
const groupListConfig = toGroupListConfig(config as any);
// 行数是配置项数的倍数,逐行展开前先按列配置预判一次,避免整表白跑
if (ctx.mode === 'effects' && !itemsMayRunEffects(groupListConfig.items)) return;
rows.forEach((row, index) => {
walkNode(ctx, {
config: getGroupListRowConfig(groupListConfig, index, ctx.mForm?.keyProp) as FormItemConfig,
@ -253,13 +340,16 @@ export const expandTableGroupList = (ctx: WalkContext, node: WalkNode, itemProp:
const tableItems = toTableConfig(config as any).items;
if (!Array.isArray(tableItems)) return;
if (ctx.mode === 'effects' && !itemsMayRunEffects(tableItems)) return;
// 列的 display 在 Table 层用「表格自身的 props」求值随后 makeTableColumnConfig 会删掉 display
const tableProps = { model, config, prop: itemProp };
const evalDisplay = (display: any) => displayFunction(ctx.mForm, display, tableProps);
const isRendered = (column: any) => ignoresDisplay(ctx) || isTableColumnRendered(column, evalDisplay);
rows.forEach((row, index) => {
for (const column of tableItems) {
if (!column || !isTableColumnRendered(column, evalDisplay)) continue;
if (!column || !isRendered(column)) continue;
walkNode(ctx, {
config: makeTableColumnConfig(column, row) as FormItemConfig,
@ -270,9 +360,30 @@ export const expandTableGroupList = (ctx: WalkContext, node: WalkNode, itemProp:
});
};
/** 遍历已登记嵌套配置的复合字段 */
const walkNestedConfig = (ctx: WalkContext, type: string, node: WalkNode, itemProp: string): boolean => {
const resolve = getFieldNestedConfig(type);
/**
*
*
* effect
*/
const runMountValueEffect = (ctx: WalkContext, type: string, node: WalkNode, itemProp: string): void => {
const effect = getFieldMountValueEffect(type);
if (!effect) return;
try {
effect({ config: node.config, model: node.model, prop: itemProp, mForm: ctx.mForm, values: ctx.values });
} catch (e) {
console.error(`[MForm] mount value effect for "${type}" at "${itemProp}" failed:`, e);
}
};
/**
* innerConfig
*
* `collect` `FieldInnerConfigError`
* `effects`
*/
const walkInnerConfig = (ctx: WalkContext, type: string, node: WalkNode, itemProp: string): boolean => {
const resolve = getFieldInnerConfig(type);
if (!resolve) return false;
let result;
@ -285,15 +396,19 @@ const walkNestedConfig = (ctx: WalkContext, type: string, node: WalkNode, itemPr
mForm: ctx.mForm,
});
} catch (e) {
throw new FieldNestedConfigError(type, itemProp, e);
if (ctx.mode === 'collect') {
throw new FieldInnerConfigError(type, itemProp, e);
}
console.error(new FieldInnerConfigError(type, itemProp, e));
return true;
}
if (!result) return true;
const nestedModel = result.model ?? node.model;
const nestedProp = result.prop ?? itemProp;
const nestedConfig = Array.isArray(result.config) ? result.config : [result.config];
walkChildren(ctx, nestedConfig, nestedModel, nestedProp);
const innerModel = result.model ?? node.model;
const innerProp = result.prop ?? itemProp;
const innerConfig = Array.isArray(result.config) ? result.config : [result.config];
walkChildren(ctx, innerConfig, innerModel, innerProp);
return true;
};
@ -346,8 +461,9 @@ const walkNode = (ctx: WalkContext, node: WalkNode): void => {
};
/**
* type walk
*
* type walk
* `effects` effect innerConfig `code-select`
* innerConfig
*
* type `items`
* rules FormItem rules
@ -359,10 +475,13 @@ const dispatchByType = (ctx: WalkContext, type: string, node: WalkNode, itemProp
return;
}
if (walkNestedConfig(ctx, type, node, itemProp)) return;
if (ctx.mode === 'effects') {
runMountValueEffect(ctx, type, node, itemProp);
}
if (walkInnerConfig(ctx, type, node, itemProp)) return;
if (isLeafFieldType(type)) {
getFieldMountValueEffect(type)?.({ config: node.config, model: node.model, prop: itemProp, mForm: ctx.mForm });
return;
}
@ -379,25 +498,28 @@ const dispatchByType = (ctx: WalkContext, type: string, node: WalkNode, itemProp
* `Container.vue` prop / rules
* MForm `validate()` DOM
*
* `values`
*
* `applyMountValueEffects`
* `validateValues`
*
* @param mForm - `createHeadlessFormState`
* @param config -
* @param values -
* @param [typeMatchValid] - typeMatch
* @param values -
* @param [typeMatchValid] - typeMatch true
* `typeMatch` `{ typeMatch: true }` type
* @returns
*/
export const collectValidatableFields = (
mForm: FormState | undefined,
config: FormConfig,
values: FormValue,
typeMatchValid?: ComputedRef<boolean>,
typeMatchValid?: boolean,
): CollectedField[] => {
const ctx: WalkContext = {
mForm,
typeMatchValid,
fields: [],
values,
mode: 'collect',
};
if (Array.isArray(config)) {
@ -406,3 +528,34 @@ export const collectValidatableFields = (
return ctx.fields;
};
/**
* config + values `values`
*
* setup model
* `Form.vue` / `validateValues`
* `mForm.values` effect `type`
* `formValue`
*
* `collectValidatableFields` `display``display: false`
* `type: 'hidden'` effect
*
* `prop` `values` `values` tab / table
*
*
* @param mForm - `createHeadlessFormState`
* @param config -
* @param values -
*/
export const applyMountValueEffects = (mForm: FormState | undefined, config: FormConfig, values: FormValue): void => {
if (!Array.isArray(config)) return;
const ctx: WalkContext = {
mForm,
fields: [],
values,
mode: 'effects',
};
walkChildren(ctx, config as FormItemConfig[], values, '');
};

View File

@ -20,9 +20,9 @@ import { toLine } from '@tmagic/utils';
import type { FormItemConfig, FormState, FormValue } from '../schema';
// #region FieldNestedConfig
/** 嵌套配置回调的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */
export interface FieldNestedConfigContext {
// #region FieldInnerConfig
/** innerConfig 回调的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */
export interface FieldInnerConfigContext {
/** 字段自身的配置(已经过 filterFunction 之外的原样配置) */
config: FormItemConfig;
/** 字段所在层级的 model 切片 */
@ -35,20 +35,20 @@ export interface FieldNestedConfigContext {
mForm: FormState | undefined;
}
/** 嵌套配置回调的返回值:需要继续遍历的嵌套配置及其 model / prop 基准 */
export interface FieldNestedConfigResult {
/** 嵌套配置(对应字段组件内部渲染的 `MContainer` 的 `config` */
/** innerConfig 回调的返回值:需要继续遍历的内部配置及其 model / prop 基准 */
export interface FieldInnerConfigResult {
/** 内部配置(对应字段组件内部渲染的 `MContainer` 的 `config` */
config: FormItemConfig | FormItemConfig[];
/**
* model 沿 `model`
* model 沿 `model`
*
* `code-select` `:model="model[name]"` `model[config.name]`
*/
model?: FormValue;
/**
* prop 沿 `prop`
* prop 沿 `prop`
*
* config `name` `name`
* config `name` `name`
* `display-conds` group-list `props.name` `parentProp`
* name
*/
@ -56,72 +56,72 @@ export interface FieldNestedConfigResult {
}
/**
*
*
*
* `MContainer` config
* `code-select` / `event-select` / `style-setter`
* `code-select` / `event-select` / `style-setter`
* FormItem`validateValues`
* config
* `registerField(type, { nested })`
* `registerField(type, { innerConfig })`
*
* `null` / `undefined`
* `null` / `undefined`
*
* type `items`
* innerConfig type `items`
* `rules`
*/
export type FieldNestedConfig = (_ctx: FieldNestedConfigContext) => FieldNestedConfigResult | null | undefined | void;
// #endregion FieldNestedConfig
export type FieldInnerConfig = (_ctx: FieldInnerConfigContext) => FieldInnerConfigResult | null | undefined | void;
// #endregion FieldInnerConfig
/** 内置嵌套配置(由 `registerBuiltInFields` 写入;`clearFields` 不会清掉) */
const builtInNestedConfigs = new Map<string, FieldNestedConfig>();
/** 业务侧登记的嵌套配置 */
const extraNestedConfigs = new Map<string, FieldNestedConfig>();
/** 内置内部配置(由 `registerBuiltInFields` 写入;`clearFields` 不会清掉) */
const builtInInnerConfigs = new Map<string, FieldInnerConfig>();
/** 业务侧登记的内部配置 */
const extraInnerConfigs = new Map<string, FieldInnerConfig>();
/**
* type
* type
*
* `type` Container 线`codeSelect` `code-select`
* 便
* `builtIn` `deleteFieldNestedConfig` / `clearFieldNestedConfigs`
* `builtIn` `deleteFieldInnerConfig` / `clearFieldInnerConfigs`
*
* @param type - type
* @param resolve -
* @param resolve - innerConfig
* @param [builtIn=false] -
*/
export const registerFieldNestedConfig = (type: string, resolve: FieldNestedConfig, builtIn = false): void => {
export const registerFieldInnerConfig = (type: string, resolve: FieldInnerConfig, builtIn = false): void => {
if (typeof type !== 'string' || !type || typeof resolve !== 'function') return;
(builtIn ? builtInNestedConfigs : extraNestedConfigs).set(toLine(type), resolve);
(builtIn ? builtInInnerConfigs : extraInnerConfigs).set(toLine(type), resolve);
};
/**
* type
* type innerConfig
*
* @param type - type
* @returns `undefined`
* @returns innerConfig `undefined`
*/
export const getFieldNestedConfig = (type: string): FieldNestedConfig | undefined => {
export const getFieldInnerConfig = (type: string): FieldInnerConfig | undefined => {
const key = toLine(type);
return extraNestedConfigs.get(key) ?? builtInNestedConfigs.get(key);
return extraInnerConfigs.get(key) ?? builtInInnerConfigs.get(key);
};
/**
* type
* type innerConfig
*
* @param type - type
* @returns
*/
export const hasFieldNestedConfig = (type: string): boolean => {
export const hasFieldInnerConfig = (type: string): boolean => {
const key = toLine(type);
return extraNestedConfigs.has(key) || builtInNestedConfigs.has(key);
return extraInnerConfigs.has(key) || builtInInnerConfigs.has(key);
};
/**
*
* innerConfig
*
* @param type - type
* @returns
*/
export const deleteFieldNestedConfig = (type: string): boolean => extraNestedConfigs.delete(toLine(type));
export const deleteFieldInnerConfig = (type: string): boolean => extraInnerConfigs.delete(toLine(type));
/** 清空业务侧登记的嵌套配置(不影响内置;主要用于单测)。 */
export const clearFieldNestedConfigs = (): void => extraNestedConfigs.clear();
/** 清空业务侧登记的 innerConfig(不影响内置;主要用于单测)。 */
export const clearFieldInnerConfigs = (): void => extraInnerConfigs.clear();

View File

@ -17,36 +17,27 @@
*/
/**
* @fileoverview type model
* @fileoverview type
*
* type setup
* type
* `registerField` / `registerFields`
* `MagicForm.install` `registerBuiltInFields`
* type `items` `rules`
* innerConfig type `items` `rules`
*
* effect `fields/<Field>/effect.ts`
* `collectFields` `applyMountValueEffects``Form.vue`
* `validateValues`
* setup model
*
* @module fieldValueEffects
*/
import { setValueByKeyPath, toLine } from '@tmagic/utils';
import { toLine } from '@tmagic/utils';
import type {
DateConfig,
DateTimeConfig,
DisplayConfig,
DynamicFieldConfig,
FormItemConfig,
FormState,
FormValue,
} from '../schema';
import { getConfig } from './config';
import { datetimeFormatter } from './form';
/** `dynamic-field` 的 `returnFields` 返回的单个字段描述 */
type DynamicFieldItem = ReturnType<DynamicFieldConfig['returnFields']>[number];
import type { FormItemConfig, FormState, FormValue } from '../schema';
// #region FieldMountValueEffect
/** mount effect 的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */
/** effect 的入参:字段自身的配置、所在层级的 model、完整字段路径、表单值根对象与表单状态 */
export interface FieldMountValueEffectContext {
/** 字段自身的配置 */
config: FormItemConfig;
@ -54,164 +45,26 @@ export interface FieldMountValueEffectContext {
model: FormValue;
/** 字段的完整 prop 路径(含父级前缀),对应 Container 的 `itemProp` */
prop: string;
/**
* `prop`
*
* `mForm.values` lastValues
* tab / table `mForm.values`
*/
values: FormValue;
/** 表单状态 */
mForm: FormState | undefined;
}
/**
* model
* `display` `initValue``date`
*
* effect type setup
* `applyMountValueEffects`
* `initValues`
*/
export type FieldMountValueEffect = (_ctx: FieldMountValueEffectContext) => void;
// #endregion FieldMountValueEffect
/**
* `fields/Display.vue` `initValue` model
*
* @param config - `initValue`
* @param model - model
* @param name - name
*/
export const applyDisplayInitValue = (
config: Pick<DisplayConfig, 'initValue'>,
model: FormValue | undefined,
name: string,
): void => {
if (config.initValue && model) {
model[name] = config.initValue;
}
};
/**
* `fields/NumberRange.vue`
*
* @param model - model
* @param name - name
*/
export const normalizeNumberRangeValue = (model: FormValue | undefined, name: string): void => {
if (model && !Array.isArray(model[name])) {
model[name] = [];
}
};
/**
* `fields/CheckboxGroup.vue`
*
* @param model - model
* @param name - name
*/
export const initCheckboxGroupValue = (model: FormValue | undefined, name: string): void => {
if (model && !model[name]) {
model[name] = [];
}
};
/**
* `fields/Date.vue` `valueFormat`
*
* @param config - `valueFormat`
* @param model - model
* @param name - name
*/
export const normalizeDateValue = (
config: Pick<DateConfig, 'valueFormat'>,
model: FormValue | undefined,
name: string,
): void => {
if (!model) return;
model[name] = datetimeFormatter(model[name], '', config.valueFormat || 'YYYY/MM/DD');
};
/**
* `fields/DateTime.vue` `valueFormat`
*
* @param config - `valueFormat`
* @param model - model
* @param name - name
*/
export const normalizeDateTimeValue = (
config: Pick<DateTimeConfig, 'valueFormat'>,
model: FormValue | undefined,
name: string,
): void => {
if (!model) return;
const value = model[name]?.toString();
if (!value || value === 'Invalid Date') {
model[name] = '';
return;
}
model[name] = datetimeFormatter(model[name], '', config.valueFormat || 'YYYY/MM/DD HH:mm:ss');
};
/**
* `fields/DynamicField.vue` defaultValue
*
*
* `isDefaultApplied` `defaultValue`
* emit change model
*
* @param fields - `returnFields`
* @param model - model
* @param onField -
*/
export const eachDynamicField = (
fields: DynamicFieldItem[],
model: FormValue | undefined,
onField: (_field: DynamicFieldItem, _value: any, _isDefaultApplied: boolean) => void,
): void => {
for (const field of fields) {
if (typeof field !== 'object' || field?.name === undefined) continue;
let value = model?.[field.name] || '';
let isDefaultApplied = false;
if (!value && field.defaultValue !== undefined) {
value = field.defaultValue;
isDefaultApplied = true;
}
onField(field, value, isDefaultApplied);
}
};
export const displayEffect: FieldMountValueEffect = ({ config, model }) =>
applyDisplayInitValue(config as DisplayConfig, model, (config as any).name);
export const numberRangeEffect: FieldMountValueEffect = ({ config, model }) =>
normalizeNumberRangeValue(model, (config as any).name);
export const checkboxGroupEffect: FieldMountValueEffect = ({ config, model }) =>
initCheckboxGroupValue(model, (config as any).name);
export const dateEffect: FieldMountValueEffect = ({ config, model }) =>
normalizeDateValue(config as DateConfig, model, (config as any).name);
export const dateTimeEffect: FieldMountValueEffect = ({ config, model }) =>
normalizeDateTimeValue(config as DateTimeConfig, model, (config as any).name);
export const dynamicFieldEffect: FieldMountValueEffect = ({ config, model, prop, mForm }) => {
// 该组件读取的是同级 model但写入走 Container 的 modifyKey 分支,落在 `${prop}.${key}`
// 这里保持与渲染一致(含这层不对称),避免两条链路产出不同的值。
const { returnFields, dynamicKey } = config as DynamicFieldConfig;
if (typeof returnFields !== 'function' || !model) return;
if (model[dynamicKey] === '') return;
const result = returnFields(config as DynamicFieldConfig, model, getConfig<Function>('request'));
// 同步返回才能在校验前生效;异步 returnFields 与渲染式校验一样存在时序不确定性,此处不等待
if (!result || typeof (result as any).then === 'function' || !Array.isArray(result)) return;
eachDynamicField(result, model, (field, value, isDefaultApplied) => {
if (isDefaultApplied) {
setValueByKeyPath(`${prop}.${field.name}`, value, mForm?.values || model);
}
});
};
/** 内置叶子字段(由 `MagicForm.install` 写入clearFields 不会清掉) */
const builtInLeafFieldTypes = new Set<string>();
const builtInMountValueEffects = new Map<string, FieldMountValueEffect>();

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { ComputedRef, readonly } from 'vue';
import { type MaybeRef, readonly, unref } from 'vue';
import dayjs from 'dayjs';
// dayjs 没有 exports 映射,原生 Node ESM 不会补扩展名,深路径必须写全 .js
import utc from 'dayjs/plugin/utc.js';
@ -401,7 +401,7 @@ const buildRules = function (
mForm: FormState | undefined,
r: Rule[] | Rule = [],
props: any,
typeMatchValid: ComputedRef<boolean> | undefined,
typeMatchValid?: MaybeRef<boolean>,
adapt: (_validator: AsyncValidatorFn) => AsyncValidatorFn = (validator) => validator,
) {
let rules = cloneDeep(r);
@ -410,7 +410,7 @@ const buildRules = function (
rules = [rules];
}
if (typeMatchValid?.value && !rules.some((r) => typeof r.typeMatch !== 'undefined')) {
if (unref(typeMatchValid) && !rules.some((r) => typeof r.typeMatch !== 'undefined')) {
rules.push({
typeMatch: true,
});
@ -463,7 +463,7 @@ export const getRules = function (
mForm: FormState | undefined,
r: Rule[] | Rule = [],
props: any,
typeMatchValid?: ComputedRef<boolean>,
typeMatchValid?: MaybeRef<boolean>,
) {
return buildRules(mForm, r, props, typeMatchValid, adaptFormValidator);
};
@ -478,7 +478,7 @@ export const getNativeRules = function (
mForm: FormState | undefined,
r: Rule[] | Rule = [],
props: any,
typeMatchValid?: ComputedRef<boolean>,
typeMatchValid?: MaybeRef<boolean>,
) {
return buildRules(mForm, r, props, typeMatchValid);
};

View File

@ -27,11 +27,11 @@ import {
registerContainerWalker,
} from './collectFields';
import {
clearFieldNestedConfigs,
deleteFieldNestedConfig,
type FieldNestedConfig,
registerFieldNestedConfig,
} from './fieldNestedConfig';
clearFieldInnerConfigs,
deleteFieldInnerConfig,
type FieldInnerConfig,
registerFieldInnerConfig,
} from './fieldInnerConfig';
import {
clearLeafFieldTypes,
deleteLeafFieldType,
@ -57,19 +57,19 @@ export interface FieldOptions {
* `app` `app.component('m-form-*')`
*/
container?: Component;
/** 叶子字段挂载时改写 model 的副作用。 */
/** 字段挂载时改写 model 的副作用。可与 `innerConfig` 同时登记。 */
effect?: FieldMountValueEffect;
/**
* tab / table
* `nested` / `effect` `walk`
* `innerConfig` / `effect` `walk`
*/
walk?: ContainerWalker;
/**
*
* `effect` `effect`
* `effect` `effect` `innerConfig`
*/
nested?: FieldNestedConfig;
/** 该 type 的 typeMatch 校验可与叶子、walk 或 nested 同时登记。 */
innerConfig?: FieldInnerConfig;
/** 该 type 的 typeMatch 校验可与叶子、walk 或 innerConfig 同时登记。 */
typeMatch?: TypeMatchValidator;
}
@ -139,7 +139,7 @@ export const mergeFieldOptions = (
return result;
};
const FIELD_OPTION_KEYS = ['component', 'container', 'effect', 'walk', 'nested', 'typeMatch'] as const;
const FIELD_OPTION_KEYS = ['component', 'container', 'effect', 'walk', 'innerConfig', 'typeMatch'] as const;
const pickDefinedFieldOptions = (options?: FieldOptions): FieldOptions => {
if (!options) return {};
@ -183,15 +183,10 @@ const registerFieldImpl = (type: string, options: FieldOptions | undefined, app:
const merged: FieldOptions = { ...store.get(key), ...incoming };
store.set(key, merged);
if (incoming.walk && (incoming.nested || typeof incoming.effect === 'function')) {
if (incoming.walk && (incoming.innerConfig || typeof incoming.effect === 'function')) {
console.warn(
`[MForm] registerField("${key}"): walk is set together with nested/effect; ` +
'headless validation will use walk and nested/effect will be ignored.',
);
} else if (incoming.nested && typeof incoming.effect === 'function') {
console.warn(
`[MForm] registerField("${key}"): nested and effect are both set; ` +
'headless validation will use nested and the mount value effect will be ignored.',
`[MForm] registerField("${key}"): walk is set together with innerConfig/effect; ` +
'headless validation will use walk and innerConfig/effect will be ignored.',
);
}
@ -218,7 +213,7 @@ const registerFieldImpl = (type: string, options: FieldOptions | undefined, app:
registerContainerWalker(type, merged.walk, builtIn);
if (!builtIn) {
deleteLeafFieldType(type);
deleteFieldNestedConfig(type);
deleteFieldInnerConfig(type);
}
return;
}
@ -227,9 +222,13 @@ const registerFieldImpl = (type: string, options: FieldOptions | undefined, app:
deleteContainerWalker(type);
}
if (merged.nested) {
if (!builtIn) deleteLeafFieldType(type);
registerFieldNestedConfig(type, merged.nested, builtIn);
if (merged.innerConfig) {
registerFieldInnerConfig(type, merged.innerConfig, builtIn);
if (typeof merged.effect === 'function') {
registerLeafFieldType(type, merged.effect, builtIn);
} else if (!builtIn) {
deleteLeafFieldType(type);
}
return;
}
@ -240,7 +239,7 @@ const registerFieldImpl = (type: string, options: FieldOptions | undefined, app:
}
if (!builtIn) {
deleteFieldNestedConfig(type);
deleteFieldInnerConfig(type);
}
registerLeafFieldType(type, merged.effect, builtIn);
};
@ -292,7 +291,7 @@ export const registerBuiltInFields = (fields: Record<string, FieldOptions>, app?
export const unregisterField = (type: string): void => {
extraFieldOptions.delete(toLine(type));
deleteLeafFieldType(type);
deleteFieldNestedConfig(type);
deleteFieldInnerConfig(type);
deleteTypeMatchRule(type);
deleteContainerWalker(type);
removeFormComponent(type);
@ -302,7 +301,7 @@ export const unregisterField = (type: string): void => {
export const clearFields = (): void => {
extraFieldOptions.clear();
clearLeafFieldTypes();
clearFieldNestedConfigs();
clearFieldInnerConfigs();
clearTypeMatchRules();
clearContainerWalkers();
clearFormComponents();

View File

@ -16,12 +16,12 @@
* limitations under the License.
*/
import { computed, reactive } from 'vue';
import { reactive } from 'vue';
import Schema from 'async-validator';
import type { FormConfig, FormState, FormValue } from '../schema';
import { type CollectedField, collectValidatableFields } from './collectFields';
import { applyMountValueEffects, type CollectedField, collectValidatableFields } from './collectFields';
import { applyExtendState, createFormStateBase, initValue } from './form';
import { formatValidateError } from './validateError';
@ -119,7 +119,7 @@ export interface ValidateValuesOptions extends HeadlessFormStateOptions {
// #region ValidateValuesResult
/** `validateValues` 结果 */
export interface ValidateValuesResult {
/** 经 `initValue` 初始化并复刻挂载副作用后的表单值 */
/** 经 `initValue` 初始化并执行字段值初始化写入后的表单值 */
values: FormValue;
/** 汇总后的错误文案(多条以 `<br>` 拼接),校验通过为空字符串 */
error: string;
@ -135,8 +135,9 @@ export interface ValidateValuesResult {
*
* 1. headless `formState` `extendState`
* 2. `initValue` `onInitValue`
* 3. config FormItem
* 4. async-validator
* 3. `applyMountValueEffects`
* 4. config FormItem
* 5. async-validator
*
* @example
* ```ts
@ -165,13 +166,10 @@ export const validateValues = async (options: ValidateValuesOptions): Promise<Va
const values = await initValue(formState, { initValues, config });
formState.values = values;
// 与 Form.vue 一致:值挂到 formState 之后再执行字段的值初始化写入
applyMountValueEffects(formState, config, values);
const fields = collectValidatableFields(
formState,
config,
values,
computed(() => Boolean(typeMatchValid)),
);
const fields = collectValidatableFields(formState, config, values, Boolean(typeMatchValid));
const invalidFields: Record<string, any> = {};
for (const field of fields) {

View File

@ -17,6 +17,7 @@
*/
import { afterEach, describe, expect, test } from 'vitest';
import { builtInFields, clearFields, registerBuiltInFields, submitForm, validateForm } from '@form/headless';
afterEach(() => {

View File

@ -17,10 +17,11 @@
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { nextTick, ref } from 'vue';
import MagicForm, { MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm } from '@form/index';
const mountForm = (props: Record<string, any> = {}, options: Record<string, any> = {}) =>
mount(MForm, {
global: {
@ -701,6 +702,113 @@ describe('Form.vue —— config 变化', () => {
});
});
/**
* `date` `display` `initValue` `applyMountValueEffects`
* setup model
*/
describe('Form.vue —— 字段值初始化统一执行', () => {
test('字段未渲染出来时值同样被规整', async () => {
const wrapper = mountForm({
config: [
{
type: 'fieldset',
name: 'wrap',
expand: true,
checkbox: { name: 'value', trueValue: 1, falseValue: 0 },
items: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }],
},
],
initValues: { wrap: { value: 0, start: '2021/07/17 15:37:00' } },
});
await nextTick();
await nextTick();
// 勾选框未勾选,内部字段没有渲染
expect(wrapper.findComponent({ name: 'MFormDate' }).exists()).toBe(false);
expect(wrapper.vm.values.wrap.start).toBe('2021-07-17');
});
test('initValues 变化后重新初始化,值仍被规整', async () => {
const config = [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }];
const wrapper = mountForm({ config, initValues: { start: '2021/07/17 15:37:00' } });
await nextTick();
await nextTick();
expect(wrapper.vm.values.start).toBe('2021-07-17');
await wrapper.setProps({ initValues: { start: '2022/08/18 15:37:00' } });
await nextTick();
await nextTick();
expect(wrapper.vm.values.start).toBe('2022-08-18');
});
test('对比模式下待对比的那份值同样被规整', async () => {
const wrapper = mountForm({
isCompare: true,
config: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }],
initValues: { start: '2021/07/17 15:37:00' },
lastValues: { start: '2021/07/17 09:00:00' },
});
await nextTick();
await nextTick();
await nextTick();
expect(wrapper.vm.values.start).toBe('2021-07-17');
// 两份值都归一化后才不会比出「只是格式不同」的假差异
expect(wrapper.vm.lastValuesProcessed.start).toBe('2021-07-17');
});
test('group-list 新增行的值被规整', async () => {
const wrapper = mountForm({
config: [
{
type: 'group-list',
name: 'list',
items: [
{
type: 'date',
name: 'start',
text: '开始',
valueFormat: 'YYYY-MM-DD',
defaultValue: '2021/07/17 15:37:00',
},
],
},
],
initValues: { list: [] },
});
await nextTick();
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
await addButton?.trigger('click');
await nextTick();
expect(wrapper.vm.values.list[0].start).toBe('2021-07-17');
});
test('group-list 走 enum 新增时值也被规整', async () => {
const wrapper = mountForm({
config: [
{
type: 'group-list',
name: 'list',
enum: [{ id: 1, start: '2021/07/17 15:37:00' }],
items: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }],
},
],
initValues: { list: [] },
});
await nextTick();
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
await addButton?.trigger('click');
await nextTick();
expect(wrapper.vm.values.list[0].start).toBe('2021-07-17');
});
});
describe('Form.vue —— 配置变化是否触发重挂', () => {
const makeConfig = () => [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { createForm, MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { createForm, MForm } from '@form/index';
describe('表单', () => {
test('初始化', async () => {
const initValues = {};

View File

@ -5,10 +5,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MFormBox, MFormDialog, MFormDrawer } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MFormBox, MFormDialog, MFormDrawer } from '@form/index';
describe('FormDialog/FormDrawer/FormBox', () => {
test('FormDialog 基础渲染', async () => {
const wrapper = mount(MFormDialog, {

View File

@ -5,10 +5,11 @@
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm } from '@form/index';
const mountForm = (config: any[], initValues: any = {}, extra: any = {}) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] },

View File

@ -5,10 +5,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm } from '@form/index';
const mountForm = (config: any[], initValues: any = {}) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] },

View File

@ -5,10 +5,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm } from '@form/index';
const mountForm = (config: any[], initValues: any = {}, props: any = {}) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] },

View File

@ -5,11 +5,12 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import Table from '@form/containers/table/Table.vue';
import MagicForm from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import Table from '@form/containers/table/Table.vue';
import MagicForm from '@form/index';
// el-table 在 happy-dom 下的 MutationObserver 会报错,这里直接 stub 掉表格本体;
// 导入 / 清空 / 新增按钮的显隐只取决于 importable & isCompare与表格渲染无关。
const mountTable = (props: any) =>

View File

@ -17,9 +17,10 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { FormConfig, MForm, MTabs } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import ElementPlus, { ElTabs } from 'element-plus';
import MagicForm, { FormConfig, MForm, MTabs } from '@form/index';
const getWrapper = (
config: FormConfig = [
@ -83,4 +84,68 @@ describe('Tabs', () => {
const item = wrapper.findAllComponents({ name: 'TMFormItem' }).find((w) => w.props('prop') === 'text');
expect(item?.props('labelPosition')).toBe('left');
});
test('dynamic 新增标签页的值被规整', async () => {
const wrapper = getWrapper(
[
{
type: 'tab',
name: 'tabs',
dynamic: true,
editable: true,
items: [
{
type: 'date',
name: 'start',
text: '开始',
valueFormat: 'YYYY-MM-DD',
defaultValue: '2021/07/17 15:37:00',
},
],
},
] as any,
{ tabs: [] },
);
await nextTick();
wrapper.findComponent(ElTabs).vm.$emit('tabAdd');
await nextTick();
await nextTick();
expect((wrapper.vm as any).values.tabs[0].start).toBe('2021-07-17');
});
test('自定义 onTabAdd 之后新增页的值也被规整', async () => {
const wrapper = getWrapper(
[
{
type: 'tab',
name: 'tabs',
dynamic: true,
editable: true,
onTabAdd: (_mForm: any, { model }: any) => {
model.tabs.push({ start: '2021/07/17 15:37:00' });
},
items: [
{
type: 'date',
name: 'start',
text: '开始',
valueFormat: 'YYYY-MM-DD',
},
],
},
] as any,
{ tabs: [] },
);
await nextTick();
wrapper.findComponent(ElTabs).vm.$emit('tabAdd');
await nextTick();
await nextTick();
expect((wrapper.vm as any).values.tabs[0].start).toBe('2021-07-17');
});
});

View File

@ -0,0 +1,150 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
import { defineComponent } from 'vue';
import { mount } from '@vue/test-utils';
import { useAdd } from '@form/containers/table-group-list/useAdd';
import { builtInFields, clearFields, registerBuiltInFields } from '@form/index';
const dateColumn = { type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' };
const mountAdd = (config: any, model: any) => {
let added: any;
let addedEventData: any;
const comp = defineComponent({
setup() {
const { newHandler } = useAdd(
{ name: 'list', model, prop: 'list', config } as any,
((event: string, value: any, eventData: any) => {
if (event === 'change') {
added = value;
addedEventData = eventData;
}
}) as any,
);
return { newHandler };
},
template: '<div />',
});
const wrapper = mount(comp, {
global: {
provide: { mForm: { values: model } },
},
});
return {
newHandler: (row?: any) => (wrapper.vm as any).newHandler(row),
getAdded: () => added,
getAddedEventData: () => addedEventData,
};
};
beforeAll(() => {
registerBuiltInFields(builtInFields);
});
afterEach(() => {
clearFields();
});
describe('useAdd —— 新增行值规整', () => {
test('enum 路径会对日期列执行 applyMountValueEffects', async () => {
const model = { list: [] };
const { newHandler, getAdded } = mountAdd(
{
name: 'list',
items: [dateColumn],
enum: [{ id: 1, start: '2021/07/17 15:37:00' }],
},
model,
);
await newHandler();
expect(getAdded()[0].start).toBe('2021-07-17');
});
test('数组行路径Excel 导入)会对日期列执行 applyMountValueEffects', async () => {
const model = { list: [] };
const { newHandler, getAdded } = mountAdd(
{
name: 'list',
items: [dateColumn],
},
model,
);
await newHandler(['2021/07/17 15:37:00']);
expect(getAdded()[0].start).toBe('2021-07-17');
});
test('model 非数组时按空列表处理,抛出的新值是完整数组', async () => {
const model: any = {};
const { newHandler, getAdded, getAddedEventData } = mountAdd(
{
name: 'list',
items: [{ name: 'text', type: 'text' }],
defaultAdd: { text: 'n' },
},
model,
);
expect(await newHandler()).toBe(1);
expect(getAdded()).toHaveLength(1);
expect(getAdded()[0].text).toBe('n');
expect(getAddedEventData().changeRecords[0].propPath).toBe('list.0');
});
test('只抛 change 不直接改 model写回交给宿主', async () => {
const model: any = { list: [{ text: 'a' }] };
const { newHandler, getAdded, getAddedEventData } = mountAdd(
{
name: 'list',
items: [{ name: 'text', type: 'text' }],
defaultAdd: { text: 'b' },
},
model,
);
expect(await newHandler()).toBe(2);
// 宿主(这里的假 emit没有写回model 必须保持原样
expect(model.list).toEqual([{ text: 'a' }]);
expect(getAdded()).toHaveLength(2);
expect(getAddedEventData().changeRecords[0].propPath).toBe('list.1');
});
test('beforeAddRow 拦下时不新增也不抛 change', async () => {
const model: any = { list: [{ text: 'a' }] };
const { newHandler, getAdded } = mountAdd(
{
name: 'list',
beforeAddRow: () => false,
items: [{ name: 'text', type: 'text' }],
defaultAdd: { text: 'b' },
},
model,
);
expect(await newHandler()).toBeNull();
expect(getAdded()).toBeUndefined();
expect(model.list).toEqual([{ text: 'a' }]);
});
});

View File

@ -5,10 +5,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MCascader, MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MCascader, MForm } from '@form/index';
const mountForm = (config: any[], initValues: any = {}) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] },

View File

@ -18,10 +18,11 @@
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MCheckbox, MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MCheckbox, MForm } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MCheckboxGroup, MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MCheckboxGroup, MForm } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MColorPicker, MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus, { ElColorPicker } from 'element-plus';
import MagicForm, { MColorPicker, MForm } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MDate, MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus, { ElInput } from 'element-plus';
import MagicForm, { MDate, MForm } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MDateTime, MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus, { ElInput } from 'element-plus';
import MagicForm, { MDateTime, MForm } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MDaterange, MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MDaterange, MForm } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MDisplay, MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MDisplay, MForm } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MHidden } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm, MHidden } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { FormState, MForm, MFormDialog, MLink } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus, { ElButton } from 'element-plus';
import MagicForm, { FormState, MForm, MFormDialog, MLink } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MNumber } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm, MNumber } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -5,10 +5,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MNumberRange } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm, MNumberRange } from '@form/index';
const getWrapper = (initValues: any = { range: [10, 20] }) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] },

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MRadioGroup } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm, MRadioGroup } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -5,11 +5,12 @@
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MSelect } from '@form/index';
import { setConfig } from '@form/utils/config';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm, MSelect } from '@form/index';
import { setConfig } from '@form/utils/config';
const mountForm = (config: any[], initValues: any = {}) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] },

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MSwitch } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm, MSwitch } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MText } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus, { ElButton } from 'element-plus';
import MagicForm, { MForm, MText } from '@form/index';
/**
* mock的Text实例
* @param config

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MTextarea } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm, MTextarea } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -17,10 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MTime } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm, MTime } from '@form/index';
const getWrapper = (
config: any = [
{

View File

@ -5,10 +5,11 @@
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm, MTimerange } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm, MTimerange } from '@form/index';
const mountForm = (config: any[], initValues: any) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] },

View File

@ -24,9 +24,10 @@
*
*/
import { type AppContext, createApp, defineComponent, h } from 'vue';
import MagicForm from '@form/index';
import ElementPlus from 'element-plus';
import MagicForm from '@form/index';
/** 必填规则 */
export const required = (message = '必填') => [{ required: true, message }] as any;

View File

@ -17,6 +17,7 @@
*/
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { type AppContext, defineComponent, h, nextTick } from 'vue';
import { clearFields, registerFields, submitForm } from '@form/index';
import {

View File

@ -0,0 +1,566 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { effect as dateEffect } from '@form/fields/Date/effect';
import { effect as dateTimeEffect } from '@form/fields/DateTime/effect';
import {
applyMountValueEffects,
builtInFields,
clearFields,
collectValidatableFields,
createHeadlessFormState,
type FieldMountValueEffectContext,
isFieldInnerConfigError,
registerBuiltInFields,
registerField,
} from '@form/index';
const apply = (config: any, values: any) => {
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
applyMountValueEffects(formState, config, values);
return values;
};
beforeAll(() => {
registerBuiltInFields(builtInFields);
});
afterEach(() => {
clearFields();
});
describe('applyMountValueEffects —— 内置字段的值初始化', () => {
test('display 的 initValue 会写入表单值', () => {
const values = apply([{ type: 'display', name: 'status', text: '状态', initValue: 'ready' }], {});
expect(values.status).toBe('ready');
});
test('date 按 valueFormat 归一化datetime 的非法值归一化为空字符串', () => {
const values = apply(
[
{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
{ type: 'datetime', name: 'end', text: '结束' },
{ type: 'datetime', name: 'meet', text: '会议', valueFormat: 'YYYY-MM-DD HH:mm:ss' },
],
{ start: '2021/07/17 15:37:00', end: new Date('nonsense'), meet: '2021/07/17 15:37:00' },
);
expect(values.start).toBe('2021-07-17');
expect(values.end).toBe('');
expect(values.meet).toBe('2021-07-17 15:37:00');
});
test('date / datetime 在 model 缺失时不抛错', () => {
const ctx = { config: { name: 'd' }, model: undefined, prop: 'd', values: {}, mForm: undefined } as any;
expect(() => dateEffect(ctx)).not.toThrow();
expect(() => dateTimeEffect(ctx)).not.toThrow();
});
test('number-range 的非数组值与 checkbox-group 的空值都被修正为空数组', () => {
const values = apply(
[
{ type: 'number-range', name: 'range', text: '区间' },
{ type: 'checkbox-group', name: 'tags', text: '标签' },
],
{ range: 'not-an-array' },
);
expect(values.range).toEqual([]);
expect(values.tags).toEqual([]);
});
test('重复执行结果不变(幂等)', () => {
const config = [
{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
{ type: 'display', name: 'status', text: '状态', initValue: 'ready' },
];
const values = apply(config, { start: '2021/07/17 15:37:00' });
const once = { ...values };
apply(config, values);
expect(values).toEqual(once);
});
test('config 不是数组时直接返回,不抛错', () => {
expect(() => applyMountValueEffects(undefined, {} as any, {})).not.toThrow();
});
test('mForm 缺省时也能执行', () => {
const values: any = { start: '2021/07/17 15:37:00' };
applyMountValueEffects(undefined, [{ type: 'date', name: 'start', text: '开始' }] as any, values);
expect(values.start).toBe('2021/07/17');
});
});
describe('applyMountValueEffects —— 不受 display 影响', () => {
test('display 为 false 的字段同样被规整', () => {
const values = apply([{ type: 'date', name: 'start', text: '开始', display: false }], {
start: '2021/07/17 15:37:00',
});
expect(values.start).toBe('2021/07/17');
});
test('display 为函数且返回 false 的字段同样被规整', () => {
const values = apply([{ type: 'date', name: 'start', text: '开始', display: () => false }], {
start: '2021/07/17 15:37:00',
});
expect(values.start).toBe('2021/07/17');
});
test('fieldset 勾选框未勾选时,内部字段同样被规整', () => {
const values = apply(
[
{
type: 'fieldset',
name: 'wrap',
expand: true,
checkbox: { name: 'value', trueValue: 1, falseValue: 0 },
items: [{ type: 'date', name: 'start', text: '开始' }],
},
],
{ wrap: { value: 0, start: '2021/07/17 15:37:00' } },
);
expect(values.wrap.start).toBe('2021/07/17');
});
test('display 为假的 tab 页内字段同样被规整', () => {
const values = apply(
[
{
type: 'tab',
items: [
{
title: '隐藏页',
display: false,
items: [{ type: 'date', name: 'start', text: '开始' }],
},
],
},
],
{ start: '2021/07/17 15:37:00' },
);
expect(values.start).toBe('2021/07/17');
});
test('display 为假的表格列,行内字段同样被规整', () => {
const values = apply(
[
{
type: 'table',
name: 'list',
items: [{ type: 'date', name: 'start', label: '开始', display: false }],
},
],
{ list: [{ start: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].start).toBe('2021/07/17');
});
});
describe('applyMountValueEffects —— 登记字段', () => {
test('effect 收到完整 prop 路径与本次处理的表单值根对象', () => {
const seen: Array<Pick<FieldMountValueEffectContext, 'prop'> & { isRoot: boolean; model: any }> = [];
registerField('my-probe', {
effect: ({ prop, values, model }) => {
seen.push({ prop, isRoot: values === rootValues, model });
},
});
const rootValues: any = { wrap: { inner: 'v' } };
apply([{ name: 'wrap', items: [{ type: 'my-probe', name: 'inner', text: '内部' }] }], rootValues);
expect(seen).toHaveLength(1);
expect(seen[0].prop).toBe('wrap.inner');
expect(seen[0].isRoot).toBe(true);
expect(seen[0].model).toBe(rootValues.wrap);
});
test('业务登记的 effect 可覆盖内置字段', () => {
registerField('display', {
effect: ({ config, model }) => {
model[(config as any).name] = 'overridden';
},
});
const values = apply([{ type: 'display', name: 'status', text: '状态', initValue: 'ready' }], {});
expect(values.status).toBe('overridden');
});
test('复合字段同时登记 effect 与 innerConfig 时,先执行本字段 effect 再下钻', () => {
registerField('my-composite', {
effect: ({ config, model }) => {
const { name } = config as any;
if (model && !model[name]) {
model[name] = { start: '2021/07/17 15:37:00' };
}
},
innerConfig: ({ config, model }) => ({
config: { type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
model: model[(config as any).name],
}),
});
const values = apply([{ type: 'my-composite', name: 'wrap', text: '包裹' }], {});
expect(values.wrap.start).toBe('2021-07-17');
});
test('innerConfig 登记的复合字段,其内部叶子字段的 effect 也会执行', () => {
registerField('my-composite', {
innerConfig: ({ config, model }) => ({
config: { type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
model: model[(config as any).name],
}),
});
const values = apply([{ type: 'my-composite', name: 'wrap', text: '包裹' }], {
wrap: { start: '2021/07/17 15:37:00' },
});
expect(values.wrap.start).toBe('2021-07-17');
});
test('dynamic-field 的 defaultValue 按 prop 写入传入的值根对象', () => {
const values = apply(
[
{
name: 'wrap',
items: [
{
type: 'dynamic-field',
name: 'dynamic',
dynamicKey: 'kind',
returnFields: () => [{ name: 'extra', label: '附加', defaultValue: 'fallback' }],
},
],
},
],
{ wrap: { kind: 'a' } },
);
expect(values.wrap.dynamic.extra).toBe('fallback');
});
test('returnFields 异步时不写入,交由组件挂载后的 watch 处理', () => {
const values = apply(
[
{
type: 'dynamic-field',
name: 'dynamic',
dynamicKey: 'kind',
returnFields: async () => [{ name: 'extra', label: '附加', defaultValue: 'fallback' }],
},
],
{ kind: 'a' },
);
expect(values.dynamic).toBeUndefined();
});
});
/**
* table / group-list / dynamic tab items items
* effectwalkNode text
*
*/
describe('applyMountValueEffects —— 跳过不含 effect 的重复展开', () => {
const probe = () => vi.fn(() => '探针');
test('列里没有 effect 的表格不逐行展开', () => {
const text = probe();
apply(
[
{ type: 'table', name: 'list', items: [{ type: 'text', name: 'k', text }] },
{ type: 'date', name: 'day', text: '日期', valueFormat: 'YYYY-MM-DD' },
],
{ list: Array.from({ length: 5 }, (_, i) => ({ k: `k${i}` })), day: '2021/07/17 15:37:00' },
);
expect(text).not.toHaveBeenCalled();
});
test('列里有 effect 的表格照常逐行展开', () => {
const text = probe();
const values = apply(
[
{
type: 'table',
name: 'list',
items: [
{ type: 'text', name: 'k', text },
{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' },
],
},
],
{ list: [{ k: 'k0', d: '2021/07/17 15:37:00' }, { k: 'k1' }] },
);
expect(text).toHaveBeenCalled();
expect(values.list[0].d).toBe('2021-07-17');
});
test('group-list 形态同样按列配置预判', () => {
const text = probe();
apply([{ type: 'group-list', name: 'list', items: [{ type: 'text', name: 'k', text }] }], {
list: [{ k: 'k0' }, { k: 'k1' }],
});
expect(text).not.toHaveBeenCalled();
});
test('dynamic tab 的标签页内没有 effect 时不逐页展开', () => {
const text = probe();
apply([{ type: 'tab', name: 'panes', dynamic: true, items: [{ type: 'text', name: 'k', text }] }], {
panes: [{ k: 'k0' }, { k: 'k1' }],
});
expect(text).not.toHaveBeenCalled();
});
test('dynamic tab 的标签页内有 effect 时照常逐页展开', () => {
const values = apply(
[
{
type: 'tab',
name: 'panes',
dynamic: true,
items: [{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' }],
},
],
{ panes: [{ d: '2021/07/17 15:37:00' }, { d: '2021/07/18 15:37:00' }] },
);
expect(values.panes.map((pane: any) => pane.d)).toEqual(['2021-07-17', '2021-07-18']);
});
test('空列、hidden 列、container 型列都不会让预判误判为可能有 effect', () => {
const text = probe();
const values = apply(
[
{
type: 'table',
name: 'list',
items: [
null,
// hidden 只收集规则,不往下分派,内部字段本就不参与值初始化
{ type: 'hidden', name: 'h', items: [{ type: 'date', name: 'd', valueFormat: 'YYYY-MM-DD' }] },
{ type: 'container', name: 'c', items: [{ type: 'text', name: 'k', text }] },
],
},
],
{ list: [{ h: {}, d: '2021/07/17 15:37:00', c: { k: 'k0' } }] },
);
expect(text).not.toHaveBeenCalled();
expect(values.list[0].d).toBe('2021/07/17 15:37:00');
});
test('container 型列里的 effect 字段不会被跳过', () => {
const values = apply(
[
{
type: 'table',
name: 'list',
items: [{ type: 'container', items: [{ type: 'date', name: 'd', valueFormat: 'YYYY-MM-DD' }] }],
},
],
{ list: [{ d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021-07-17');
});
test('列的函数型 type 静态看不出来,不会被跳过', () => {
const values = apply(
[{ type: 'table', name: 'list', items: [{ type: () => 'date', name: 'd', valueFormat: 'YYYY-MM-DD' }] }],
{ list: [{ d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021-07-17');
});
test('列里嵌套多层后才出现的 effect 字段不会被跳过', () => {
const values = apply(
[
{
type: 'table',
name: 'list',
items: [
{
items: [{ items: [{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' }] }],
},
],
},
],
{ list: [{ d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021-07-17');
});
test('itemsFunction 按行生成的列不会被跳过', () => {
const values = apply(
[
{
type: 'table',
name: 'list',
items: [{ itemsFunction: () => [{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' }] }],
},
],
{ list: [{ k: 'k0', d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021-07-17');
});
test('另一形态的 tableItems 不参与值初始化,与遍历范围一致', () => {
const values = apply(
[
{
type: 'group-list',
name: 'list',
items: [{ type: 'text', name: 'k', text: 'k' }],
tableItems: [{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' }],
},
],
{ list: [{ k: 'k0', d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021/07/17 15:37:00');
});
test('列是 innerConfig 复合字段时不会被跳过:内部配置运行期才产生', () => {
const innerConfig = vi.fn(() => null);
registerField('my-composite', { innerConfig });
apply([{ type: 'table', name: 'list', items: [{ type: 'my-composite', name: 'wrap' }] }], {
list: [{ wrap: {} }],
});
expect(innerConfig).toHaveBeenCalled();
});
test('列是业务登记的容器时不会被跳过:遍历路径未知', () => {
const walk = vi.fn();
registerField('my-box', { walk });
apply([{ type: 'table', name: 'list', items: [{ type: 'my-box', name: 'box', items: [] }] }], {
list: [{ box: {} }],
});
expect(walk).toHaveBeenCalled();
});
});
describe('applyMountValueEffects —— 单个字段出错不影响整体', () => {
test('effect 抛错时记录并继续处理后续字段', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
registerField('my-boom', {
effect: () => {
throw new Error('boom');
},
});
const values = apply(
[
{ type: 'my-boom', name: 'bad', text: '坏字段' },
{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
],
{ start: '2021/07/17 15:37:00' },
);
expect(values.start).toBe('2021-07-17');
expect(spy.mock.calls[0][0]).toContain('[MForm] mount value effect for "my-boom" at "bad" failed:');
spy.mockRestore();
});
test('innerConfig 抛错时记录并继续,不像校验那样抛出', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
registerField('my-composite', {
innerConfig: () => {
throw new Error('boom');
},
});
const config = [
{ type: 'my-composite', name: 'wrap', text: '包裹' },
{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
];
const values = apply(config, { start: '2021/07/17 15:37:00' });
expect(values.start).toBe('2021-07-17');
expect(isFieldInnerConfigError(spy.mock.calls[0][0])).toBe(true);
spy.mockRestore();
});
test('collect 模式下 innerConfig 抛错仍然抛出 FieldInnerConfigError', () => {
registerField('my-composite', {
innerConfig: () => {
throw new Error('boom');
},
});
const config = [{ type: 'my-composite', name: 'wrap', text: '包裹' }] as any;
const formState = createHeadlessFormState({ config, initValues: {} });
expect(() => collectValidatableFields(formState, config, {})).toThrow(/FIELD_INNER_CONFIG|innerConfig/);
});
});
describe('collectValidatableFields —— 只读', () => {
test('收集字段不再改写表单值,值初始化由 applyMountValueEffects 负责', () => {
const config = [{ type: 'display', name: 'status', text: '状态', initValue: 'ready' }] as any;
const values: any = {};
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
collectValidatableFields(formState, config, values);
expect(values.status).toBeUndefined();
});
test('innerConfig 回调在 collect 时也不改写本字段的值', () => {
registerField('my-composite', {
effect: ({ config, model }) => {
model[(config as any).name] = { start: 'from-effect' };
},
innerConfig: ({ config, model }) => ({
config: { type: 'text', name: 'start', text: '开始' },
model: model[(config as any).name],
}),
});
const config = [{ type: 'my-composite', name: 'wrap', text: '包裹' }] as any;
const values: any = { wrap: {} };
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
collectValidatableFields(formState, config, values);
expect(values.wrap).toEqual({});
});
});

View File

@ -16,6 +16,7 @@
* limitations under the License.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { getConfig, setConfig } from '@form/utils/config';
describe('config.ts', () => {

View File

@ -17,6 +17,7 @@
*/
import { describe, expect, test, vi } from 'vitest';
import type { FormState } from '@form/index';
import {
applyExtendState,

View File

@ -18,6 +18,7 @@
import { afterEach, describe, expect, test } from 'vitest';
import { createApp, defineComponent } from 'vue';
import {
builtInFields,
clearFields,
@ -168,21 +169,21 @@ describe('builtInFields', () => {
});
test('mergeFieldOptions 后一份只覆盖自己带的 key', () => {
const nested = () => undefined;
const innerConfig = () => undefined;
const typeMatch = () => undefined;
const merged = mergeFieldOptions(
{ 'code-select': { nested, typeMatch } },
{ 'code-select': { innerConfig, typeMatch } },
{ 'code-select': { component: FakeA } },
{ 'code-select': { component: FakeB }, 'my-field': { component: FakeA } },
);
expect(merged['code-select'].component).toBe(FakeB);
expect(merged['code-select'].nested).toBe(nested);
expect(merged['code-select'].innerConfig).toBe(innerConfig);
expect(merged['code-select'].typeMatch).toBe(typeMatch);
expect(merged['my-field'].component).toBe(FakeA);
});
test('多次 registerField 按字段合并typeMatch 不会丢掉 nested', () => {
registerField('my-composite', { nested: innerTextNested });
test('多次 registerField 按字段合并typeMatch 不会丢掉 innerConfig', () => {
registerField('my-composite', { innerConfig: innerTextNested });
registerField('my-composite', { typeMatch: () => undefined });
expect(getTypeMatchRule('my-composite')).toBeTypeOf('function');
@ -201,8 +202,8 @@ describe('builtInFields', () => {
expect(getTypeMatchRule('built-in-match')).toBeTypeOf('function');
});
test('registerBuiltInFields 的 nested 不会被 clearFields / unregisterField 清掉', () => {
registerBuiltInFields({ 'built-in-nested': { nested: innerTextNested } });
test('registerBuiltInFields 的 innerConfig 不会被 clearFields / unregisterField 清掉', () => {
registerBuiltInFields({ 'built-in-nested': { innerConfig: innerTextNested } });
const collect = () =>
collectValidatableFields(undefined, [{ type: 'built-in-nested', name: 'outer' }] as any, {
@ -218,9 +219,9 @@ describe('builtInFields', () => {
expect(collect()).toEqual(['outer.inner']);
});
test('业务侧 nested 覆盖内置unregisterField 后回落到内置', () => {
registerBuiltInFields({ 'both-nested': { nested: innerTextNested } });
registerField('both-nested', { nested: renameInnerNested });
test('业务侧 innerConfig 覆盖内置unregisterField 后回落到内置', () => {
registerBuiltInFields({ 'both-nested': { innerConfig: innerTextNested } });
registerField('both-nested', { innerConfig: renameInnerNested });
const collect = () =>
collectValidatableFields(undefined, [{ type: 'both-nested', name: 'outer' }] as any, {
@ -233,11 +234,11 @@ describe('builtInFields', () => {
expect(collect()).toEqual(['outer.inner']);
});
test('内置登记 nested 不会清掉业务侧已登记的叶子', () => {
test('内置登记 innerConfig 不会清掉业务侧已登记的叶子', () => {
registerField('leaf-then-built-in', {});
expect(isLeafFieldType('leaf-then-built-in')).toBe(true);
registerBuiltInFields({ 'leaf-then-built-in': { nested: innerTextNested } });
registerBuiltInFields({ 'leaf-then-built-in': { innerConfig: innerTextNested } });
expect(isLeafFieldType('leaf-then-built-in')).toBe(true);
});
});

View File

@ -4,6 +4,7 @@
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test } from 'vitest';
import { getGroupListRowConfig } from '@form/utils/tableGroupList';
describe('getGroupListRowConfig', () => {

View File

@ -17,6 +17,10 @@
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { setDesignConfig } from '@tmagic/design';
import { getDesignConfig } from '@tmagic/design/headless';
import type { FormState } from '@form/index';
import { getRules } from '@form/utils/form';
import { clearFields } from '@form/utils/registerField';
@ -30,9 +34,6 @@ import {
validateTypeMatch,
} from '@form/utils/typeMatch';
import { setDesignConfig } from '@tmagic/design';
import { getDesignConfig } from '@tmagic/design/headless';
const mForm: FormState = {
config: [],
initValues: {},

View File

@ -17,13 +17,14 @@
*/
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { createApp, defineComponent } from 'vue';
import MagicForm, {
builtInFields,
clearFields,
collectValidatableFields,
createHeadlessFormState,
getTypeMatchRule,
isFieldNestedConfigError,
isFieldInnerConfigError,
isLeafFieldType,
registerBuiltInFields,
registerField,
@ -679,9 +680,9 @@ describe('validateValues —— 未登记 type 与扩展登记', () => {
expect(props).toEqual(['wrap.inner']);
});
test('registerField nested 可遍历复合字段的内部配置', async () => {
test('registerField innerConfig 可遍历复合字段的内部配置', async () => {
registerField('my-composite', {
nested: ({ config, model }) => ({
innerConfig: ({ config, model }) => ({
config: { type: 'text', name: 'inner', text: '内部', rules: required('内部必填') },
model: model[(config as any).name],
}),
@ -692,30 +693,30 @@ describe('validateValues —— 未登记 type 与扩展登记', () => {
initValues: { wrap: { inner: '' } },
});
// 嵌套配置不在调用方传入的 config 树上getTextByName 找不到 text回退为 prop 路径
// 内部配置不在调用方传入的 config 树上getTextByName 找不到 text回退为 prop 路径
expect(error).toBe('wrap.inner -> 内部必填');
});
test('nested 返回 null 表示该字段没有内部字段', () => {
registerField('my-composite', { nested: () => null });
test('innerConfig 返回 null 表示该字段没有内部字段', () => {
registerField('my-composite', { innerConfig: () => null });
expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).not.toThrow();
});
test('nested 抛错时把失败原因带出去', () => {
test('innerConfig 抛错时把失败原因带出去', () => {
registerField('my-composite', {
nested: () => {
innerConfig: () => {
throw new Error('boom');
},
});
expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).toThrow(
/\[MForm\] nested config for "my-composite" at "a" failed: boom/,
/\[MForm\] innerConfig for "my-composite" at "a" failed: boom/,
);
});
test('nested 抛错时抛出 FieldNestedConfigError可按 code 判别', () => {
test('innerConfig 抛错时抛出 FieldInnerConfigError可按 code 判别', () => {
registerField('my-composite', {
nested: () => {
innerConfig: () => {
throw new Error('boom');
},
});
@ -724,31 +725,30 @@ describe('validateValues —— 未登记 type 与扩展登记', () => {
collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' });
expect.unreachable('should throw');
} catch (e) {
expect(isFieldNestedConfigError(e)).toBe(true);
expect((e as { code?: string }).code).toBe('FIELD_NESTED_CONFIG');
expect(isFieldInnerConfigError(e)).toBe(true);
expect((e as { code?: string }).code).toBe('FIELD_INNER_CONFIG');
expect((e as { type?: string; prop?: string }).type).toBe('my-composite');
expect((e as { type?: string; prop?: string }).prop).toBe('a');
}
});
test('nested 的 type 名支持驼峰与中划线互通', () => {
registerField('myComposite', { nested: () => null });
test('innerConfig 的 type 名支持驼峰与中划线互通', () => {
registerField('myComposite', { innerConfig: () => null });
expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).not.toThrow();
});
test('同时传 nested 与 effect 时告警 effect 会被忽略', () => {
test('同时传 innerConfig 与 effect 时不告警,两者并存', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
registerField('my-both', { nested: () => null, effect: () => undefined });
registerField('my-both', { innerConfig: () => null, effect: () => undefined });
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[MForm] registerField("my-both")'));
expect(spy.mock.calls[0][0]).toContain('mount value effect will be ignored');
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
test('后一次 registerField 覆盖前一次,不告警', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
registerField('my-both', { effect: () => undefined });
registerField('my-both', { nested: () => null });
registerField('my-both', { innerConfig: () => null });
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});

View File

@ -17,6 +17,7 @@
*/
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { type AppContext, defineComponent, h, nextTick } from 'vue';
import { clearFields, registerFields, validateForm } from '@form/index';
import {

View File

@ -1,6 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
},
"exclude": [
"**/dist/**/*"

View File

@ -80,6 +80,7 @@ export default defineConfig({
},
{ find: /^@tmagic\/core/, replacement: path.join(__dirname, '../packages/core/src/index.ts') },
{ find: /^@editor/, replacement: path.join(__dirname, '../packages/editor/src/') },
{ find: /^@form/, replacement: path.join(__dirname, '../packages/form/src/') },
// `/headless` 必须在下方通用的 `^@tmagic/<pkg>` 规则之前命中,否则会被改写成
// `.../src/index.ts/headless` 导致 Vite 解析失败。
{ find: /^@tmagic\/editor\/headless$/, replacement: path.join(__dirname, '../packages/editor/src/headless.ts') },

View File

@ -159,6 +159,7 @@ async function build({ packageName, format, pkg, packagesDir, entry, name, fileN
alias: [
{ find: /^@data-source/, replacement: path.join(packagesDir, '/data-source/src') },
{ find: /^@editor/, replacement: path.join(packagesDir, './editor/src') },
{ find: /^@form/, replacement: path.join(packagesDir, './form/src') },
],
},
});