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: { resolve: {
alias:[ 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-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\/headless$/, replacement: path.join(__dirname, '../../packages/form/src/headless.ts') },
{ find: /^@tmagic\/form/, replacement: path.join(__dirname, '../../packages/form/src/index.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`,会把表单以弹层渲染出来供填写/确认。 无渲染实现按 `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-*` 自定义字段的渲染组件和无渲染校验都通过 `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 | 无需登记,直接校验 | | 自身带 `rules`,内部没有嵌套的父表单 FormItem | 无需登记,直接校验 |
| 内部只渲染叶子 UI或把子表单渲染在独立的 `MForm` / `MFormBox` 实例里 | `registerField('my-field')`(配置里有 `items` 但不属于父表单时,避免被当下钻) | | 内部只渲染叶子 UI或把子表单渲染在独立的 `MForm` / `MFormBox` 实例里 | `registerField('my-field')`(配置里有 `items` 但不属于父表单时,避免被当下钻) |
| 同时需要渲染组件 | `registerField('my-field', { component })` | | 同时需要渲染组件 | `registerField('my-field', { component })` |
| 容器组件(`m-form-*` | `registerField('my-box', { container, walk })` | | 容器组件(`m-form-*` | `registerField('my-box', { container, walk })` |
| 叶子字段,但挂载时会改写 model(类似 `display``initValue` | `registerField('my-field', { effect })` | | 叶子字段,但需要改写表单值(类似 `display``initValue` | `registerField('my-field', { effect })` |
| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { nested })` | | 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { innerConfig })`(需要改本字段的值时再加 `effect` |
| 自定义 `typeMatch` 类型校验 | `registerField('my-field', { typeMatch })` | | 自定义 `typeMatch` 类型校验 | `registerField('my-field', { typeMatch })` |
```ts ```ts
@ -38,8 +42,7 @@ registerFields({ 'my-color-picker': { component: MyColorPicker } });
// 需要挂到当前 app 时传入第二个参数 // 需要挂到当前 app 时传入第二个参数
registerFields({ 'my-color-picker': { component: MyColorPicker } }, app); registerFields({ 'my-color-picker': { component: MyColorPicker } }, app);
// 叶子字段但挂载setup时会改写 model传入 effect 让无渲染校验复刻这份写入, // 叶子字段,但需要改写表单值:写成 effect不要在组件 setup 里改 model
// 否则无渲染校验拿到的值会与渲染式校验不一致
registerField('my-status', { registerField('my-status', {
effect: ({ config, model }) => { effect: ({ config, model }) => {
if ((config as any).initValue && model) { if ((config as any).initValue && model) {
@ -50,55 +53,55 @@ registerField('my-status', {
// 复合字段:把组件内部渲染的 MContainer 配置交出来 // 复合字段:把组件内部渲染的 MContainer 配置交出来
registerField('my-composite', { registerField('my-composite', {
nested: ({ config, model, prop }) => ({ innerConfig: ({ config, model, prop }) => ({
// 对应组件内部 <MContainer :config="innerConfig" :model="model[name]" :prop="prop"> // 对应组件内部 <MContainer :config="childConfig" :model="model[name]" :prop="prop">
config: innerConfig, config: childConfig,
model: model[config.name], model: model[config.name],
prop, prop,
}), }),
}); });
// typeMatch覆盖或扩展该 type 的类型匹配校验,可与 nested / effect 同时登记 // typeMatch覆盖或扩展该 type 的类型匹配校验,可与 innerConfig / effect 同时登记
registerField('my-status', { registerField('my-status', {
typeMatch: (value, { message }) => (typeof value === 'string' ? undefined : message || '应为字符串'), 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 ```ts
registerField('my-list', { registerField('my-list', {
nested: ({ config, parentProp }) => ({ innerConfig: ({ config, parentProp }) => ({
config: { type: 'group-list', name: config.name, items: innerItems }, config: { type: 'group-list', name: config.name, items: innerItems },
prop: parentProp, 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 同一个 type 多次登记按字段浅合并,后一次只覆盖自己传入的 key
```ts ```ts
registerField('my-composite', { nested }); registerField('my-composite', { innerConfig });
registerField('my-composite', { component: MyComposite }); // nested 仍在 registerField('my-composite', { component: MyComposite }); // innerConfig 仍在
``` ```
登记分「内置」与「业务」两层。`app.use(MagicForm)` / `registerBuiltInFields` 写内置层,`registerField` / `registerFields` 写业务层;读取时业务层优先,`unregisterField` / `clearFields` 只清业务层,内置字段不受影响(单测里 `clearFields` 之后仍能校验 `text``tab` 等内置 type 登记分「内置」与「业务」两层。`app.use(MagicForm)` / `registerBuiltInFields` 写内置层,`registerField` / `registerFields` 写业务层;读取时业务层优先,`unregisterField` / `clearFields` 只清业务层,内置字段不受影响(单测里 `clearFields` 之后仍能校验 `text``tab` 等内置 type
因为是合并语义,把一个已登记 `nested` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销: 因为是合并语义,把一个已登记 `innerConfig` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销:
```ts ```ts
registerField('my-composite', {}); // ✗ 合并后 nested 还在,仍会下钻 registerField('my-composite', {}); // ✗ 合并后 innerConfig 还在,仍会下钻
unregisterField('my-composite'); // ✓ 先清掉业务层登记 unregisterField('my-composite'); // ✓ 先清掉业务层登记
registerField('my-composite', { component: MyComposite }); 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 字段内置规则 ### 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 | 期望值 | | 字段 type | 期望值 |
| --- | --- | | --- | --- |
@ -174,7 +174,7 @@ app.use(MagicForm, {
> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules避免单个子项非法导致整块表单标红。 > 容器类字段(`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"); app.mount("#app");
``` ```
也可在安装时传入自定义字段登记(叶子 / nested / typeMatch / component详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则) 也可在安装时传入自定义字段登记(叶子 / innerConfig / typeMatch / component详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则)
```javascript ```javascript
import MyField from './MyField.vue'; import MyField from './MyField.vue';

View File

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

View File

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

View File

@ -52,15 +52,12 @@ const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues
const codeConfig = computed(() => createCodeSelectConfig(props.config)); const codeConfig = computed(() => createCodeSelectConfig(props.config));
// applyMountValueEffects code-select effect
watch( watch(
() => props.model[props.name], () => props.model[props.name],
() => { () => {
//
normalizeCodeSelectValue(props.model, props.name); normalizeCodeSelectValue(props.model, props.name);
}, },
{
immediate: true,
},
); );
const changeHandler = (v: any, eventData: ContainerChangeEventData) => emit('change', v, eventData); 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` * `code-select` `{ hookType, hookData }`
* `watch(immediate)` `{ hookType, hookData }`
* *
* * `code-select` `effect` `applyMountValueEffects`
* `watch` immediate
*/ */
export const normalizeCodeSelectValue = (model: FormValue | undefined, name: string): void => { export const normalizeCodeSelectValue = (model: FormValue | undefined, name: string): void => {
if (!model) return; if (!model) return;

View File

@ -19,7 +19,8 @@
import { import {
type DisplayCondsConfig, type DisplayCondsConfig,
type EventSelectConfig, type EventSelectConfig,
type FieldNestedConfig, type FieldInnerConfig,
type FieldMountValueEffect,
filterFunction, filterFunction,
type FormItemConfig, type FormItemConfig,
type HeadlessFieldOptions, type HeadlessFieldOptions,
@ -35,19 +36,30 @@ import { createStyleSetterConfig } from './StyleSetter/configs';
const getName = (config: FormItemConfig): string => `${(config as any).name ?? ''}`; 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` * `fields/CodeSelect.vue`
* `<MContainer :config="codeConfig" :model="model[name]" :prop="prop">` * `<MContainer :config="codeConfig" :model="model[name]" :prop="prop">`
* *
* @param ctx - * @param ctx - innerConfig
* @returns config / model / prop * @returns config / model / prop
*/ */
const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => { const codeSelectInnerConfig: FieldInnerConfig = ({ config, model, prop }) => {
const name = getName(config); const name = getName(config);
// 组件在 watch(immediate) 里做的旧数据兼容,发生在校验之前
normalizeCodeSelectValue(model, name);
return { return {
config: createCodeSelectConfig(config as any), config: createCodeSelectConfig(config as any),
@ -57,14 +69,14 @@ const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
}; };
/** /**
* `display-conds` * `display-conds`
* *
* `fields/DisplayConds.vue` groupList `MGroupList` * `fields/DisplayConds.vue` groupList `MGroupList`
* *
* @param ctx - * @param ctx - innerConfig
* @returns group-list prop parentProp name * @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 name = getName(config);
const parentFields = const parentFields =
filterFunction<string[]>(mForm, (config as DisplayCondsConfig).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 * `fields/EventSelect.vue` group-list title slot
* `<prop>.<index>.name` * `<prop>.<index>.name`
* *
* @param ctx - * @param ctx - innerConfig
* @returns group-list null * @returns group-list null
*/ */
const eventSelectNestedConfig: FieldNestedConfig = ({ config, model, parentProp }) => { const eventSelectInnerConfig: FieldInnerConfig = ({ config, model, parentProp }) => {
const name = getName(config); const name = getName(config);
if (model && !Array.isArray(model[name])) {
model[name] = [];
}
const events = model?.[name]; const events = model?.[name];
// 旧数据格式走的是另一套表格配置,其中不含任何 rules不参与校验 // 旧数据格式走的是另一套表格配置,其中不含任何 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"` * `fields/StyleSetter/Index.vue`6 `:values="model[name]"``:prop="prop || name"`
* `theme` `useTheme` flexWrap UI childType * `theme` `useTheme` flexWrap UI childType
* *
* @param ctx - * @param ctx - innerConfig
* @returns style styleModel * @returns style styleModel
*/ */
const styleSetterNestedConfig: FieldNestedConfig = ({ config, model, prop }) => { const styleSetterInnerConfig: FieldInnerConfig = ({ config, model, prop }) => {
const name = getName(config); const name = getName(config);
const styleModel = (model?.[name] ?? {}) as Partial<StyleSchema>; const styleModel = (model?.[name] ?? {}) as Partial<StyleSchema>;
@ -132,10 +141,10 @@ const styleSetterNestedConfig: FieldNestedConfig = ({ config, model, prop }) =>
* plugin `component` `@tmagic/form` * plugin `component` `@tmagic/form`
* *
* - UI MForm / MFormBox * - UI MForm / MFormBox
* - nested MContainer / MPanel / MGroupList * - innerConfig MContainer / MPanel / MGroupList
* - typeMatch type * - typeMatch type
* *
* nested config / model / prop * innerConfig config / model / prop
* `fields/configs/` * `fields/configs/`
*/ */
export const editorFields: Record<string, HeadlessFieldOptions> = { 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-methods': { typeMatch: editorTypeMatchRules['data-source-methods'] },
'data-source-method-select': { typeMatch: editorTypeMatchRules['data-source-method-select'] }, 'data-source-method-select': { typeMatch: editorTypeMatchRules['data-source-method-select'] },
'data-source-field-select': { typeMatch: editorTypeMatchRules['data-source-field-select'] }, 'data-source-field-select': { typeMatch: editorTypeMatchRules['data-source-field-select'] },
'code-select': { nested: codeSelectNestedConfig, typeMatch: editorTypeMatchRules['code-select'] }, 'code-select': {
'display-conds': { nested: displayCondsNestedConfig, typeMatch: editorTypeMatchRules['display-conds'] }, effect: codeSelectEffect,
'event-select': { nested: eventSelectNestedConfig, typeMatch: editorTypeMatchRules['event-select'] }, innerConfig: codeSelectInnerConfig,
'style-setter': { nested: styleSetterNestedConfig, typeMatch: editorTypeMatchRules['style-setter'] }, 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'); expect(title).toBe('unknown');
}); });
test('空 model 时初始化为 { hookType, hookData }', () => { test('运行期被置空时补成 { hookType, hookData }', async () => {
const props = baseProps({ model: { cs: undefined } }); const wrapper = mount(CodeSelect, { props: baseProps() as any });
mount(CodeSelect, { props: props as any }); const model: Record<string, any> = { cs: '' };
expect((props.model.cs as any).hookData).toEqual([]);
await wrapper.setProps({ model });
expect(model.cs).toEqual({ hookType: 'code', hookData: [] });
}); });
test('codeType items 配置正确', () => { test('codeType items 配置正确', () => {

View File

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

View File

@ -83,7 +83,7 @@ describe('plugin install', () => {
expect(formInstall).toBeDefined(); expect(formInstall).toBeDefined();
const fields = formInstall[1].fields as Record< const fields = formInstall[1].fields as Record<
string, string,
{ component?: unknown; container?: unknown; nested?: unknown; typeMatch?: unknown } { component?: unknown; container?: unknown; innerConfig?: unknown; typeMatch?: unknown }
>; >;
expect(formInstall[1].someOption).toBe(true); expect(formInstall[1].someOption).toBe(true);
expect(Object.keys(fields)).toEqual( expect(Object.keys(fields)).toEqual(
@ -117,11 +117,14 @@ describe('plugin install', () => {
} }
expect(fields[type].component, `${type} 缺少 component`).toBeDefined(); 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['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['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.MEditor).toBeDefined();
expect(components['magic-code-editor']).toBeDefined(); expect(components['magic-code-editor']).toBeDefined();
expect(Object.keys(components)).toEqual(['MEditor', 'magic-code-editor']); expect(Object.keys(components)).toEqual(['MEditor', 'magic-code-editor']);
@ -138,7 +141,8 @@ describe('plugin install', () => {
} as any); } as any);
const formOpt = (app.use as any).mock.calls.find((call: any[]) => call[0] === formPlugin)[1]; 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'].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['code-select'].typeMatch).toEqual(expect.any(Function));
expect(formOpt.fields['event-select'].effect).toEqual(expect.any(Function)); expect(formOpt.fields['event-select'].effect).toEqual(expect.any(Function));
expect(formOpt.fields['my-field'].component).toEqual({ name: 'MyField' }); 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 { setValueByKeyPath } from '@tmagic/utils';
import Container from './containers/Container.vue'; import Container from './containers/Container.vue';
import { applyMountValueEffects } from './utils/collectFields';
import { applyExtendState, createFormStateBase, initValue } from './utils/form'; import { applyExtendState, createFormStateBase, initValue } from './utils/form';
import { formatValidateError as formatError, getTextByName as findTextByName } from './utils/validateError'; import { formatValidateError as formatError, getTextByName as findTextByName } from './utils/validateError';
import type { ChangeRecord, ContainerChangeEventData, FormConfig, FormSlots, FormState, FormValue } from './schema'; import type { ChangeRecord, ContainerChangeEventData, FormConfig, FormSlots, FormState, FormValue } from './schema';
@ -346,6 +347,9 @@ watch(
config: props.config, config: props.config,
}).then((value) => { }).then((value) => {
values.value = value; values.value = value;
// model
// effect type / display formValue
applyMountValueEffects(formState, props.config, values.value);
// //
initialized.value = !props.isCompare; initialized.value = !props.isCompare;
@ -363,6 +367,8 @@ watch(
config: props.config, config: props.config,
}).then((value) => { }).then((value) => {
lastValuesProcessed.value = value; lastValuesProcessed.value = value;
//
applyMountValueEffects(formState, props.config, lastValuesProcessed.value);
initialized.value = true; initialized.value = true;
}); });
} }

View File

@ -358,7 +358,7 @@ const type = computed((): string => resolveItemType(mForm, props.config, props))
const tagName = computed(() => { const tagName = computed(() => {
// `type: 'component'` Vue // `type: 'component'` Vue
// FormItem addField MContainer // FormItem addField MContainer
// registerField(type, { nested }) // registerField(type, { innerConfig })
if (type.value === 'component' && (props.config as ComponentConfig).component) { if (type.value === 'component' && (props.config as ComponentConfig).component) {
return (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 { getDesignConfig, TMagicBadge } from '@tmagic/design';
import type { ContainerChangeEventData, FormState, TabConfig, TabPaneConfig } from '../schema'; import type { ContainerChangeEventData, FormState, TabConfig, TabPaneConfig } from '../schema';
import { applyMountValueEffects } from '../utils/collectFields';
import { display as displayFunc, filterFunction, initValue } from '../utils/form'; import { display as displayFunc, filterFunction, initValue } from '../utils/form';
import Container from './Container.vue'; import Container from './Container.vue';
@ -205,6 +206,13 @@ const onTabAdd = async () => {
prop: props.prop, prop: props.prop,
config: props.config, 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]); emit('change', props.model[props.name]);
} else { } else {
const newObj = await initValue(mForm, { const newObj = await initValue(mForm, {
@ -212,6 +220,9 @@ const onTabAdd = async () => {
initValues: {}, initValues: {},
}); });
//
applyMountValueEffects(mForm, props.config.items, newObj);
newObj.title = `标签${tabs.value.length + 1}`; newObj.title = `标签${tabs.value.length + 1}`;
props.model[props.name].push(newObj); 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 { TMagicButton } from '@tmagic/design';
import type { GroupListConfig, TableConfig } from '@tmagic/form-schema'; import type { GroupListConfig, TableConfig } from '@tmagic/form-schema';
import type { ContainerChangeEventData } from '../../schema'; import type { ContainerChangeEventData } from '@form/schema';
import { isGroupListType, toGroupListConfig, toTableConfig } from '../../utils/tableGroupList'; import { isGroupListType, toGroupListConfig, toTableConfig } from '@form/utils/tableGroupList';
import MFormGroupList from '../GroupList.vue'; import MFormGroupList from '../GroupList.vue';
import MFormTable from '../table/Table.vue'; import MFormTable from '../table/Table.vue';

View File

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

View File

@ -1,6 +1,6 @@
import { computed, type Ref, ref } from 'vue'; import { computed, type Ref, ref } from 'vue';
import { getDataByPage } from '../../utils/form'; import { getDataByPage } from '@form/utils/form';
import type { TableProps } from './type'; 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 TMagicTable } from '@tmagic/design';
import type { FormState } from '@tmagic/form-schema'; import type { FormState } from '@tmagic/form-schema';
import { sortArray } from '../../utils/form'; import { sortArray } from '@form/utils/form';
import type { TableProps } from './type'; 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 TableColumnOptions, TMagicIcon, TMagicTooltip } from '@tmagic/design';
import type { FormItemConfig, FormState } from '@tmagic/form-schema'; import type { FormItemConfig, FormState } from '@tmagic/form-schema';
import type { ContainerChangeEventData } from '../../schema'; import type { ContainerChangeEventData } from '@form/schema';
import { isGlobalFlat } from '../../utils/config'; import { isGlobalFlat } from '@form/utils/config';
import { appendProp, display as displayFunc, getDataByPage, sortArray } from '../../utils/form'; import { appendProp, display as displayFunc, getDataByPage, sortArray } from '@form/utils/form';
import { isTableColumnRendered, makeTableColumnConfig } from '../../utils/tableGroupList'; import { isTableColumnRendered, makeTableColumnConfig } from '@form/utils/tableGroupList';
import Container from '../Container.vue'; import Container from '../Container.vue';
import ActionsColumn from './ActionsColumn.vue'; import ActionsColumn from './ActionsColumn.vue';

View File

@ -11,10 +11,9 @@ import { computed, inject } from 'vue';
import { TMagicCheckbox, TMagicCheckboxGroup } from '@tmagic/design'; import { TMagicCheckbox, TMagicCheckboxGroup } from '@tmagic/design';
import type { CheckboxGroupConfig, CheckboxGroupOption, FieldProps, FormState } from '../schema'; import type { CheckboxGroupConfig, CheckboxGroupOption, FieldProps, FormState } from '@form/schema';
import { initCheckboxGroupValue } from '../utils/fieldValueEffects'; import { filterFunction } from '@form/utils/form';
import { filterFunction } from '../utils/form'; import { useAddField } from '@form/utils/useAddField';
import { useAddField } from '../utils/useAddField';
defineOptions({ defineOptions({
name: 'MFormCheckGroup', name: 'MFormCheckGroup',
@ -26,8 +25,6 @@ const emit = defineEmits(['change']);
useAddField(props.prop); useAddField(props.prop);
initCheckboxGroupValue(props.model, props.name);
const changeHandler = (v: Array<string | number | boolean>) => { const changeHandler = (v: Array<string | number | boolean>) => {
emit('change', v); 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> <script lang="ts" setup>
import { TMagicDatePicker } from '@tmagic/design'; import { TMagicDatePicker } from '@tmagic/design';
import type { DateConfig, FieldProps } from '../schema'; import type { DateConfig, FieldProps } from '@form/schema';
import { normalizeDateValue } from '../utils/fieldValueEffects'; import { useAddField } from '@form/utils/useAddField';
import { useAddField } from '../utils/useAddField';
defineOptions({ defineOptions({
name: 'MFormDate', name: 'MFormDate',
@ -30,8 +29,6 @@ const emit = defineEmits<{
useAddField(props.prop); useAddField(props.prop);
normalizeDateValue(props.config, props.model, props.name);
const changeHandler = (v: string) => { const changeHandler = (v: string) => {
emit('change', v); 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> <script lang="ts" setup>
import { TMagicDatePicker } from '@tmagic/design'; import { TMagicDatePicker } from '@tmagic/design';
import type { DateTimeConfig, FieldProps } from '../schema'; import type { DateTimeConfig, FieldProps } from '@form/schema';
import { normalizeDateTimeValue } from '../utils/fieldValueEffects'; import { useAddField } from '@form/utils/useAddField';
import { useAddField } from '../utils/useAddField';
defineOptions({ defineOptions({
name: 'MFormDateTime', name: 'MFormDateTime',
@ -32,8 +31,6 @@ const emit = defineEmits<{
useAddField(props.prop); useAddField(props.prop);
normalizeDateTimeValue(props.config, props.model, props.name);
const changeHandler = (v: string) => { const changeHandler = (v: string) => {
emit('change', v); 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"> <script setup lang="ts">
import { computed, inject } from 'vue'; import { computed, inject } from 'vue';
import type { DisplayConfig, FieldProps, FormState } from '../schema'; import type { DisplayConfig, FieldProps, FormState } from '@form/schema';
import { applyDisplayInitValue } from '../utils/fieldValueEffects'; import { filterFunction } from '@form/utils/form';
import { filterFunction } from '../utils/form'; import { useAddField } from '@form/utils/useAddField';
import { useAddField } from '../utils/useAddField';
defineOptions({ defineOptions({
name: 'MFormDisplay', name: 'MFormDisplay',
@ -18,8 +17,6 @@ const props = defineProps<FieldProps<DisplayConfig>>();
const mForm = inject<FormState | undefined>('mForm'); const mForm = inject<FormState | undefined>('mForm');
applyDisplayInitValue(props.config, props.model, props.name);
const text = computed(() => { const text = computed(() => {
if (props.config.displayText) { if (props.config.displayText) {
return filterFunction<string>(mForm, props.config.displayText, props); 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 { TMagicForm, TMagicFormItem, TMagicInput } from '@tmagic/design';
import type { DynamicFieldConfig, FieldProps } from '../schema'; import type { DynamicFieldConfig, FieldProps } from '@form/schema';
import { getConfig } from '../utils/config'; import { getConfig } from '@form/utils/config';
import { eachDynamicField } from '../utils/fieldValueEffects'; import { useAddField } from '@form/utils/useAddField';
import { useAddField } from '../utils/useAddField';
import { eachDynamicField } from './effect';
defineOptions({ defineOptions({
name: 'MFormDynamicField', 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 { TMagicInput } from '@tmagic/design';
import type { FieldProps, NumberRangeConfig } from '../schema'; import type { FieldProps, NumberRangeConfig } from '@form/schema';
import { normalizeNumberRangeValue } from '../utils/fieldValueEffects'; import { useAddField } from '@form/utils/useAddField';
import { useAddField } from '../utils/useAddField';
defineOptions({ defineOptions({
name: 'MFormNumberRange', name: 'MFormNumberRange',
@ -54,8 +53,6 @@ watch(
useAddField(props.prop); useAddField(props.prop);
normalizeNumberRangeValue(props.model, props.name);
const minChangeHandler = (v: string) => { const minChangeHandler = (v: string) => {
emit('change', [Number(v), props.model[props.name][1]]); 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'; } from './utils/registerField';
export type { FieldOptions, HeadlessFieldOptions } 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 { isLeafFieldType } from './utils/fieldValueEffects';
export type { FieldMountValueEffect, FieldMountValueEffectContext } 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 type { CollectedField } from './utils/collectFields';
export { createHeadlessFormState, validateValues } from './utils/validateValues'; 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 MTableGroupList } from './containers/table-group-list/TableGroupList.vue';
export { default as MText } from './fields/Text.vue'; export { default as MText } from './fields/Text.vue';
export { default as MNumber } from './fields/Number.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 MTextarea } from './fields/Textarea.vue';
export { default as MHidden } from './fields/Hidden.vue'; export { default as MHidden } from './fields/Hidden.vue';
export { default as MDate } from './fields/Date.vue'; export { default as MDate } from './fields/Date/Index.vue';
export { default as MDateTime } from './fields/DateTime.vue'; export { default as MDateTime } from './fields/DateTime/Index.vue';
export { default as MTime } from './fields/Time.vue'; export { default as MTime } from './fields/Time.vue';
export { default as MCheckbox } from './fields/Checkbox.vue'; export { default as MCheckbox } from './fields/Checkbox.vue';
export { default as MSwitch } from './fields/Switch.vue'; export { default as MSwitch } from './fields/Switch.vue';
export { default as MDaterange } from './fields/Daterange.vue'; export { default as MDaterange } from './fields/Daterange.vue';
export { default as MTimerange } from './fields/Timerange.vue'; export { default as MTimerange } from './fields/Timerange.vue';
export { default as MColorPicker } from './fields/ColorPicker.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 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 MLink } from './fields/Link.vue';
export { default as MSelect } from './fields/Select.vue'; export { default as MSelect } from './fields/Select.vue';
export { default as MCascader } from './fields/Cascader.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'; export { builtInFields } from './utils/builtInFields';
@ -70,12 +70,17 @@ export {
} from './utils/registerField'; } from './utils/registerField';
export type { FieldOptions, HeadlessFieldOptions } 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 { isLeafFieldType } from './utils/fieldValueEffects';
export type { FieldMountValueEffect, FieldMountValueEffectContext } 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 type { CollectedField } from './utils/collectFields';
export { createHeadlessFormState, validateValues } from './utils/validateValues'; 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 Tabs from './containers/Tabs.vue';
import Cascader from './fields/Cascader.vue'; import Cascader from './fields/Cascader.vue';
import Checkbox from './fields/Checkbox.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 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 Daterange from './fields/Daterange.vue';
import DateTime from './fields/DateTime.vue'; import DateTime from './fields/DateTime/Index.vue';
import Display from './fields/Display.vue'; import Display from './fields/Display/Index.vue';
import DynamicField from './fields/DynamicField.vue'; import DynamicField from './fields/DynamicField/Index.vue';
import Hidden from './fields/Hidden.vue'; import Hidden from './fields/Hidden.vue';
import Link from './fields/Link.vue'; import Link from './fields/Link.vue';
import Number from './fields/Number.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 RadioGroup from './fields/RadioGroup.vue';
import Select from './fields/Select.vue'; import Select from './fields/Select.vue';
import Switch from './fields/Switch.vue'; import Switch from './fields/Switch.vue';
@ -63,7 +63,7 @@ export interface FormInstallOptions {
/** 是否启用全局 flat 模式。 */ /** 是否启用全局 flat 模式。 */
flat?: boolean; flat?: boolean;
/** /**
* type / nested / walk / typeMatch / component / container * type / innerConfig / walk / typeMatch / component / container
* `registerFields` * `registerFields`
*/ */
fields?: Record<string, FieldOptions>; fields?: Record<string, FieldOptions>;

View File

@ -16,15 +16,14 @@
* limitations under the License. * 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 { expandFieldset, expandPanel, expandRow, expandStep, expandTab, expandTableGroupList } from './collectFields';
import {
checkboxGroupEffect,
dateEffect,
dateTimeEffect,
displayEffect,
dynamicFieldEffect,
numberRangeEffect,
} from './fieldValueEffects';
import { type HeadlessFieldOptions } from './registerField'; import { type HeadlessFieldOptions } from './registerField';
/** /**

View File

@ -16,13 +16,11 @@
* limitations under the License. * limitations under the License.
*/ */
import type { ComputedRef } from 'vue';
import { toLine } from '@tmagic/utils'; import { toLine } from '@tmagic/utils';
import type { FormConfig, FormItemConfig, FormState, FormValue, Rule } from '../schema'; import type { FormConfig, FormItemConfig, FormState, FormValue, Rule } from '../schema';
import { getFieldNestedConfig } from './fieldNestedConfig'; import { getFieldInnerConfig } from './fieldInnerConfig';
import { getFieldMountValueEffect, isLeafFieldType } from './fieldValueEffects'; import { getFieldMountValueEffect, isLeafFieldType } from './fieldValueEffects';
import { import {
appendProp, appendProp,
@ -56,16 +54,16 @@ export interface CollectedField {
} }
// #endregion CollectedField // #endregion CollectedField
/** 已登记的嵌套配置回调自身抛错时抛出(机制故障,不是漏登记) */ /** 已登记的 innerConfig 回调自身抛错时抛出(机制故障,不是漏登记) */
export class FieldNestedConfigError extends Error { export class FieldInnerConfigError extends Error {
readonly code = 'FIELD_NESTED_CONFIG'; readonly code = 'FIELD_INNER_CONFIG';
readonly type: string; readonly type: string;
readonly prop: string; readonly prop: string;
constructor(type: string, prop: string, cause: unknown) { constructor(type: string, prop: string, cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause); const reason = cause instanceof Error ? cause.message : String(cause);
super(`[MForm] nested config for "${type}" at "${prop}" failed: ${reason}`); super(`[MForm] innerConfig for "${type}" at "${prop}" failed: ${reason}`);
this.name = 'FieldNestedConfigError'; this.name = 'FieldInnerConfigError';
this.type = type; this.type = type;
this.prop = prop; this.prop = prop;
if (cause instanceof Error) { if (cause instanceof Error) {
@ -74,16 +72,41 @@ export class FieldNestedConfigError extends Error {
} }
} }
export const isFieldNestedConfigError = (error: unknown): error is FieldNestedConfigError => export const isFieldInnerConfigError = (error: unknown): error is FieldInnerConfigError =>
error instanceof FieldNestedConfigError || error instanceof FieldInnerConfigError ||
(typeof error === 'object' && error !== null && (error as { code?: string }).code === 'FIELD_NESTED_CONFIG'); (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 { interface WalkContext {
mForm: FormState | undefined; 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[]; fields: CollectedField[];
/** 本次遍历处理的表单值根对象(对比模式下为 lastValues 那一份) */
values: FormValue;
mode: WalkMode;
} }
/** `effects` 模式下遍历全部配置,不受 display / 折叠状态影响 */
const ignoresDisplay = (ctx: WalkContext): boolean => ctx.mode === 'effects';
interface WalkNode { interface WalkNode {
config: FormItemConfig; config: FormItemConfig;
/** 所在层级的 model 切片(对应 Container 的 `props.model` */ /** 所在层级的 model 切片(对应 Container 的 `props.model` */
@ -118,6 +141,58 @@ export const clearContainerWalkers = (): void => extraContainerWalkers.clear();
const getItems = (config: any): FormItemConfig[] | undefined => config?.items; 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` * `Container.vue` `display`
* *
@ -125,12 +200,16 @@ const getItems = (config: any): FormItemConfig[] | undefined => config?.items;
* *
*/ */
const resolveDisplay = (ctx: WalkContext, config: any, nodeProps: any): boolean => { const resolveDisplay = (ctx: WalkContext, config: any, nodeProps: any): boolean => {
if (ignoresDisplay(ctx)) return true;
const value = displayFunction(ctx.mForm, config?.display, nodeProps); const value = displayFunction(ctx.mForm, config?.display, nodeProps);
if (value === 'expand') return true; if (value === 'expand') return true;
return Boolean(value); return Boolean(value);
}; };
const addField = (ctx: WalkContext, node: WalkNode, itemProp: string, nodeProps: any): void => { 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[]; const rules = getNativeRules(ctx.mForm, (node.config as any).rules, nodeProps, ctx.typeMatchValid) as Rule[];
if (!rules.length) return; if (!rules.length) return;
@ -165,13 +244,18 @@ export const expandTab = (ctx: WalkContext, node: WalkNode, itemProp: string): v
if ((config as any).dynamic) { if ((config as any).dynamic) {
if (!name) return; if (!name) return;
const tabs = model?.[name] || []; const tabs = model?.[name] || [];
if (!tabs.length) return;
// 每个标签页展开同一份 items逐页展开前先按 items 预判一次
if (ctx.mode === 'effects' && !itemsMayRunEffects(items)) return;
tabs.forEach((_tab: any, tabIndex: number) => { tabs.forEach((_tab: any, tabIndex: number) => {
walkChildren(ctx, items, childModel?.[tabIndex], appendProp(itemProp, tabIndex)); walkChildren(ctx, items, childModel?.[tabIndex], appendProp(itemProp, tabIndex));
}); });
return; 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) { for (const tab of tabs) {
const tabName = (tab as any).name; const tabName = (tab as any).name;
// tab.lazy 只影响渲染时机,不影响该标签页是否属于这份配置,无渲染校验一律遍历 // tab.lazy 只影响渲染时机,不影响该标签页是否属于这份配置,无渲染校验一律遍历
@ -199,7 +283,7 @@ export const expandFieldset = (ctx: WalkContext, node: WalkNode, itemProp: strin
const checkboxTrueValue = const checkboxTrueValue =
typeof checkbox === 'object' && typeof checkbox.trueValue !== 'undefined' ? checkbox.trueValue : 1; typeof checkbox === 'object' && typeof checkbox.trueValue !== 'undefined' ? checkbox.trueValue : 1;
// 勾选框关闭时整个 fieldset 的子项不渲染,语义上等于「该段配置未启用」,不参与校验 // 勾选框关闭时整个 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); walkChildren(ctx, items, childModel, itemProp);
}; };
@ -235,11 +319,14 @@ export const expandTableGroupList = (ctx: WalkContext, node: WalkNode, itemProp:
const { config, model } = node; const { config, model } = node;
const name = (config as any).name || ''; const name = (config as any).name || '';
const rows = model?.[name]; const rows = model?.[name];
if (!Array.isArray(rows)) return; if (!Array.isArray(rows) || !rows.length) return;
if (isGroupListType((config as any).type)) { if (isGroupListType((config as any).type)) {
const groupListConfig = toGroupListConfig(config as any); const groupListConfig = toGroupListConfig(config as any);
// 行数是配置项数的倍数,逐行展开前先按列配置预判一次,避免整表白跑
if (ctx.mode === 'effects' && !itemsMayRunEffects(groupListConfig.items)) return;
rows.forEach((row, index) => { rows.forEach((row, index) => {
walkNode(ctx, { walkNode(ctx, {
config: getGroupListRowConfig(groupListConfig, index, ctx.mForm?.keyProp) as FormItemConfig, 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; const tableItems = toTableConfig(config as any).items;
if (!Array.isArray(tableItems)) return; if (!Array.isArray(tableItems)) return;
if (ctx.mode === 'effects' && !itemsMayRunEffects(tableItems)) return;
// 列的 display 在 Table 层用「表格自身的 props」求值随后 makeTableColumnConfig 会删掉 display // 列的 display 在 Table 层用「表格自身的 props」求值随后 makeTableColumnConfig 会删掉 display
const tableProps = { model, config, prop: itemProp }; const tableProps = { model, config, prop: itemProp };
const evalDisplay = (display: any) => displayFunction(ctx.mForm, display, tableProps); const evalDisplay = (display: any) => displayFunction(ctx.mForm, display, tableProps);
const isRendered = (column: any) => ignoresDisplay(ctx) || isTableColumnRendered(column, evalDisplay);
rows.forEach((row, index) => { rows.forEach((row, index) => {
for (const column of tableItems) { for (const column of tableItems) {
if (!column || !isTableColumnRendered(column, evalDisplay)) continue; if (!column || !isRendered(column)) continue;
walkNode(ctx, { walkNode(ctx, {
config: makeTableColumnConfig(column, row) as FormItemConfig, 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; if (!resolve) return false;
let result; let result;
@ -285,15 +396,19 @@ const walkNestedConfig = (ctx: WalkContext, type: string, node: WalkNode, itemPr
mForm: ctx.mForm, mForm: ctx.mForm,
}); });
} catch (e) { } 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; if (!result) return true;
const nestedModel = result.model ?? node.model; const innerModel = result.model ?? node.model;
const nestedProp = result.prop ?? itemProp; const innerProp = result.prop ?? itemProp;
const nestedConfig = Array.isArray(result.config) ? result.config : [result.config]; const innerConfig = Array.isArray(result.config) ? result.config : [result.config];
walkChildren(ctx, nestedConfig, nestedModel, nestedProp); walkChildren(ctx, innerConfig, innerModel, innerProp);
return true; 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` * type `items`
* rules FormItem rules * rules FormItem rules
@ -359,10 +475,13 @@ const dispatchByType = (ctx: WalkContext, type: string, node: WalkNode, itemProp
return; 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)) { if (isLeafFieldType(type)) {
getFieldMountValueEffect(type)?.({ config: node.config, model: node.model, prop: itemProp, mForm: ctx.mForm });
return; return;
} }
@ -379,25 +498,28 @@ const dispatchByType = (ctx: WalkContext, type: string, node: WalkNode, itemProp
* `Container.vue` prop / rules * `Container.vue` prop / rules
* MForm `validate()` DOM * MForm `validate()` DOM
* *
* `values` * `applyMountValueEffects`
* * `validateValues`
* *
* @param mForm - `createHeadlessFormState` * @param mForm - `createHeadlessFormState`
* @param config - * @param config -
* @param values - * @param values -
* @param [typeMatchValid] - typeMatch * @param [typeMatchValid] - typeMatch true
* `typeMatch` `{ typeMatch: true }` type
* @returns * @returns
*/ */
export const collectValidatableFields = ( export const collectValidatableFields = (
mForm: FormState | undefined, mForm: FormState | undefined,
config: FormConfig, config: FormConfig,
values: FormValue, values: FormValue,
typeMatchValid?: ComputedRef<boolean>, typeMatchValid?: boolean,
): CollectedField[] => { ): CollectedField[] => {
const ctx: WalkContext = { const ctx: WalkContext = {
mForm, mForm,
typeMatchValid, typeMatchValid,
fields: [], fields: [],
values,
mode: 'collect',
}; };
if (Array.isArray(config)) { if (Array.isArray(config)) {
@ -406,3 +528,34 @@ export const collectValidatableFields = (
return ctx.fields; 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'; import type { FormItemConfig, FormState, FormValue } from '../schema';
// #region FieldNestedConfig // #region FieldInnerConfig
/** 嵌套配置回调的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */ /** innerConfig 回调的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */
export interface FieldNestedConfigContext { export interface FieldInnerConfigContext {
/** 字段自身的配置(已经过 filterFunction 之外的原样配置) */ /** 字段自身的配置(已经过 filterFunction 之外的原样配置) */
config: FormItemConfig; config: FormItemConfig;
/** 字段所在层级的 model 切片 */ /** 字段所在层级的 model 切片 */
@ -35,20 +35,20 @@ export interface FieldNestedConfigContext {
mForm: FormState | undefined; mForm: FormState | undefined;
} }
/** 嵌套配置回调的返回值:需要继续遍历的嵌套配置及其 model / prop 基准 */ /** innerConfig 回调的返回值:需要继续遍历的内部配置及其 model / prop 基准 */
export interface FieldNestedConfigResult { export interface FieldInnerConfigResult {
/** 嵌套配置(对应字段组件内部渲染的 `MContainer` 的 `config` */ /** 内部配置(对应字段组件内部渲染的 `MContainer` 的 `config` */
config: FormItemConfig | FormItemConfig[]; config: FormItemConfig | FormItemConfig[];
/** /**
* model 沿 `model` * model 沿 `model`
* *
* `code-select` `:model="model[name]"` `model[config.name]` * `code-select` `:model="model[name]"` `model[config.name]`
*/ */
model?: FormValue; model?: FormValue;
/** /**
* prop 沿 `prop` * prop 沿 `prop`
* *
* config `name` `name` * config `name` `name`
* `display-conds` group-list `props.name` `parentProp` * `display-conds` group-list `props.name` `parentProp`
* name * name
*/ */
@ -56,72 +56,72 @@ export interface FieldNestedConfigResult {
} }
/** /**
* *
* *
* `MContainer` config * `MContainer` config
* `code-select` / `event-select` / `style-setter` * `code-select` / `event-select` / `style-setter`
* FormItem`validateValues` * FormItem`validateValues`
* config * config
* `registerField(type, { nested })` * `registerField(type, { innerConfig })`
* *
* `null` / `undefined` * `null` / `undefined`
* *
* type `items` * innerConfig type `items`
* `rules` * `rules`
*/ */
export type FieldNestedConfig = (_ctx: FieldNestedConfigContext) => FieldNestedConfigResult | null | undefined | void; export type FieldInnerConfig = (_ctx: FieldInnerConfigContext) => FieldInnerConfigResult | null | undefined | void;
// #endregion FieldNestedConfig // #endregion FieldInnerConfig
/** 内置嵌套配置(由 `registerBuiltInFields` 写入;`clearFields` 不会清掉) */ /** 内置内部配置(由 `registerBuiltInFields` 写入;`clearFields` 不会清掉) */
const builtInNestedConfigs = new Map<string, FieldNestedConfig>(); const builtInInnerConfigs = new Map<string, FieldInnerConfig>();
/** 业务侧登记的嵌套配置 */ /** 业务侧登记的内部配置 */
const extraNestedConfigs = new Map<string, FieldNestedConfig>(); const extraInnerConfigs = new Map<string, FieldInnerConfig>();
/** /**
* type * type
* *
* `type` Container 线`codeSelect` `code-select` * `type` Container 线`codeSelect` `code-select`
* 便 * 便
* `builtIn` `deleteFieldNestedConfig` / `clearFieldNestedConfigs` * `builtIn` `deleteFieldInnerConfig` / `clearFieldInnerConfigs`
* *
* @param type - type * @param type - type
* @param resolve - * @param resolve - innerConfig
* @param [builtIn=false] - * @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; 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 * @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); const key = toLine(type);
return extraNestedConfigs.get(key) ?? builtInNestedConfigs.get(key); return extraInnerConfigs.get(key) ?? builtInInnerConfigs.get(key);
}; };
/** /**
* type * type innerConfig
* *
* @param type - type * @param type - type
* @returns * @returns
*/ */
export const hasFieldNestedConfig = (type: string): boolean => { export const hasFieldInnerConfig = (type: string): boolean => {
const key = toLine(type); const key = toLine(type);
return extraNestedConfigs.has(key) || builtInNestedConfigs.has(key); return extraInnerConfigs.has(key) || builtInInnerConfigs.has(key);
}; };
/** /**
* * innerConfig
* *
* @param type - type * @param type - type
* @returns * @returns
*/ */
export const deleteFieldNestedConfig = (type: string): boolean => extraNestedConfigs.delete(toLine(type)); export const deleteFieldInnerConfig = (type: string): boolean => extraInnerConfigs.delete(toLine(type));
/** 清空业务侧登记的嵌套配置(不影响内置;主要用于单测)。 */ /** 清空业务侧登记的 innerConfig(不影响内置;主要用于单测)。 */
export const clearFieldNestedConfigs = (): void => extraNestedConfigs.clear(); export const clearFieldInnerConfigs = (): void => extraInnerConfigs.clear();

View File

@ -17,36 +17,27 @@
*/ */
/** /**
* @fileoverview type model * @fileoverview type
* *
* type setup * type
* `registerField` / `registerFields` * `registerField` / `registerFields`
* `MagicForm.install` `registerBuiltInFields` * `MagicForm.install` `registerBuiltInFields`
* type `items` `rules` * innerConfig type `items` `rules`
*
* effect `fields/<Field>/effect.ts`
* `collectFields` `applyMountValueEffects``Form.vue`
* `validateValues`
* setup model
* *
* @module fieldValueEffects * @module fieldValueEffects
*/ */
import { setValueByKeyPath, toLine } from '@tmagic/utils'; import { toLine } from '@tmagic/utils';
import type { import type { FormItemConfig, FormState, FormValue } from '../schema';
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];
// #region FieldMountValueEffect // #region FieldMountValueEffect
/** mount effect 的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */ /** effect 的入参:字段自身的配置、所在层级的 model、完整字段路径、表单值根对象与表单状态 */
export interface FieldMountValueEffectContext { export interface FieldMountValueEffectContext {
/** 字段自身的配置 */ /** 字段自身的配置 */
config: FormItemConfig; config: FormItemConfig;
@ -54,164 +45,26 @@ export interface FieldMountValueEffectContext {
model: FormValue; model: FormValue;
/** 字段的完整 prop 路径(含父级前缀),对应 Container 的 `itemProp` */ /** 字段的完整 prop 路径(含父级前缀),对应 Container 的 `itemProp` */
prop: string; prop: string;
/**
* `prop`
*
* `mForm.values` lastValues
* tab / table `mForm.values`
*/
values: FormValue;
/** 表单状态 */ /** 表单状态 */
mForm: FormState | undefined; mForm: FormState | undefined;
} }
/** /**
* model * `display` `initValue``date`
* *
* effect type setup * `applyMountValueEffects`
* `initValues`
*/ */
export type FieldMountValueEffect = (_ctx: FieldMountValueEffectContext) => void; export type FieldMountValueEffect = (_ctx: FieldMountValueEffectContext) => void;
// #endregion FieldMountValueEffect // #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 不会清掉) */ /** 内置叶子字段(由 `MagicForm.install` 写入clearFields 不会清掉) */
const builtInLeafFieldTypes = new Set<string>(); const builtInLeafFieldTypes = new Set<string>();
const builtInMountValueEffects = new Map<string, FieldMountValueEffect>(); const builtInMountValueEffects = new Map<string, FieldMountValueEffect>();

View File

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

View File

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

View File

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

View File

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

View File

@ -17,10 +17,11 @@
*/ */
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { nextTick, ref } from 'vue'; import { nextTick, ref } from 'vue';
import MagicForm, { MForm } from '@form/index';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus'; import ElementPlus from 'element-plus';
import MagicForm, { MForm } from '@form/index';
const mountForm = (props: Record<string, any> = {}, options: Record<string, any> = {}) => const mountForm = (props: Record<string, any> = {}, options: Record<string, any> = {}) =>
mount(MForm, { mount(MForm, {
global: { 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 —— 配置变化是否触发重挂', () => { describe('Form.vue —— 配置变化是否触发重挂', () => {
const makeConfig = () => [ const makeConfig = () => [
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,9 +17,10 @@
*/ */
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import MagicForm, { FormConfig, MForm, MTabs } from '@form/index';
import { mount } from '@vue/test-utils'; 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 = ( const getWrapper = (
config: FormConfig = [ config: FormConfig = [
@ -83,4 +84,68 @@ describe('Tabs', () => {
const item = wrapper.findAllComponents({ name: 'TMFormItem' }).find((w) => w.props('prop') === 'text'); const item = wrapper.findAllComponents({ name: 'TMFormItem' }).find((w) => w.props('prop') === 'text');
expect(item?.props('labelPosition')).toBe('left'); 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 { describe, expect, test } from 'vitest';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import MagicForm, { MCascader, MForm } from '@form/index';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus'; import ElementPlus from 'element-plus';
import MagicForm, { MCascader, MForm } from '@form/index';
const mountForm = (config: any[], initValues: any = {}) => const mountForm = (config: any[], initValues: any = {}) =>
mount(MForm, { mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] }, global: { plugins: [ElementPlus as any, MagicForm as any] },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,9 +24,10 @@
* *
*/ */
import { type AppContext, createApp, defineComponent, h } from 'vue'; import { type AppContext, createApp, defineComponent, h } from 'vue';
import MagicForm from '@form/index';
import ElementPlus from 'element-plus'; import ElementPlus from 'element-plus';
import MagicForm from '@form/index';
/** 必填规则 */ /** 必填规则 */
export const required = (message = '必填') => [{ required: true, message }] as any; 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 { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { type AppContext, defineComponent, h, nextTick } from 'vue'; import { type AppContext, defineComponent, h, nextTick } from 'vue';
import { clearFields, registerFields, submitForm } from '@form/index'; import { clearFields, registerFields, submitForm } from '@form/index';
import { 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. * limitations under the License.
*/ */
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { getConfig, setConfig } from '@form/utils/config'; import { getConfig, setConfig } from '@form/utils/config';
describe('config.ts', () => { describe('config.ts', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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