mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-12 15:09:43 +00:00
feat(form): 统一字段值 effect 机制并将 nested 重命名为 innerConfig
将字段挂载时的 model 写入迁移至 registerField effect, 渲染与无渲染校验共用 applyMountValueEffects; nested 重命名为 innerConfig 以区分配置派生与值写入。
This commit is contained in:
parent
02eeb2e87f
commit
f2ee7ae7b5
@ -588,6 +588,7 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias:[
|
||||
{ find: /^@form/, replacement: path.join(__dirname, '../../packages/form/src/') },
|
||||
{ find: /^@tmagic\/form-schema/, replacement: path.join(__dirname, '../../packages/form-schema/src/index.ts') },
|
||||
{ find: /^@tmagic\/form\/headless$/, replacement: path.join(__dirname, '../../packages/form/src/headless.ts') },
|
||||
{ find: /^@tmagic\/form/, replacement: path.join(__dirname, '../../packages/form/src/index.ts') },
|
||||
|
||||
@ -15,18 +15,22 @@
|
||||
|
||||
无渲染实现按 `Container.vue` 及各容器组件的模板规则遍历配置树,产出的字段 `prop` 与规则与「挂载 `MForm` 后调用 `validate()`」等价。需要 UI 时传入 `dialog: true`,会把表单以弹层渲染出来供填写/确认。
|
||||
|
||||
字段只要带了 `rules`(会包 FormItem),就会校验自身,不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { nested })`,把内部会挂到父表单上的配置交出来。nested 回调自身抛错时,会以 `FieldNestedConfigError`(`code: 'FIELD_NESTED_CONFIG'`)reject。
|
||||
字段只要带了 `rules`(会包 FormItem),就会校验自身,不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { innerConfig })`,把内部会挂到父表单上的配置交出来。innerConfig 回调自身抛错时,会以 `FieldInnerConfigError`(`code: 'FIELD_INNER_CONFIG'`)reject。
|
||||
|
||||
innerConfig 回调在校验和表单值初始化两条链路上都会被调用(后者用于走到复合字段内部、找出需要执行 `effect` 的子字段),所以它应当只做配置派生、可重复调用、不要做重活。在表单值初始化链路上,回调抛错只会记录到 console 并跳过该子树,不会让表单渲染不出来。
|
||||
|
||||
自定义字段的渲染组件和无渲染校验都通过 `registerField` / `registerFields` 一次登记。`component` 会写入字段注册表(`getFormField`);传入 `app` 时同时 `app.component('m-fields-*')`。容器组件用 `container`,对应 `m-form-*`。
|
||||
|
||||
字段对表单值的初始化写入统一登记为 `effect`,渲染与无渲染共用同一份登记表,执行点也只有一个:表单值初始化完成后(`MForm` 内部、`validateValues`、以及 tab / table 新增行时)各执行一次 `applyMountValueEffects`,字段组件自身不要在 `setup` 里改写 `model`。因此 effect 有两个约束:一是必须幂等,同一份值可能被执行多次(如 `initValues` 变化后重新初始化);二是不看 `display`(`display: false` 或函数返回假的字段也会被规整,避免字段由隐藏转为显示时漏掉)。`type: 'hidden'` 不同:遍历在该节点停止、不往下分派,内部字段不会执行 effect。需要按路径跨层级写值时用上下文里的 `values`(本次处理的值根对象,`prop` 即以它为根),不要用 `mForm.values`——对比模式处理的是 `lastValues` 那一份,新增行处理的则是还没挂到表单上的一行值。单个 effect 抛错只会记录到 console,不影响其余字段与表单渲染。复合字段可以同时登记 `effect` 与 `innerConfig`:前者改本字段的值,后者只派生内部配置、不要在回调里改 `model`。
|
||||
|
||||
| 字段形态 | 登记方式 |
|
||||
| --------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| 自身带 `rules`,内部没有嵌套的父表单 FormItem | 无需登记,直接校验 |
|
||||
| 内部只渲染叶子 UI,或把子表单渲染在独立的 `MForm` / `MFormBox` 实例里 | `registerField('my-field')`(配置里有 `items` 但不属于父表单时,避免被当下钻) |
|
||||
| 同时需要渲染组件 | `registerField('my-field', { component })` |
|
||||
| 容器组件(`m-form-*`) | `registerField('my-box', { container, walk })` |
|
||||
| 叶子字段,但挂载时会改写 model(类似 `display` 的 `initValue`) | `registerField('my-field', { effect })` |
|
||||
| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { nested })` |
|
||||
| 叶子字段,但需要改写表单值(类似 `display` 的 `initValue`) | `registerField('my-field', { effect })` |
|
||||
| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { innerConfig })`(需要改本字段的值时再加 `effect`) |
|
||||
| 自定义 `typeMatch` 类型校验 | `registerField('my-field', { typeMatch })` |
|
||||
|
||||
```ts
|
||||
@ -38,8 +42,7 @@ registerFields({ 'my-color-picker': { component: MyColorPicker } });
|
||||
// 需要挂到当前 app 时传入第二个参数
|
||||
registerFields({ 'my-color-picker': { component: MyColorPicker } }, app);
|
||||
|
||||
// 叶子字段,但挂载(setup)时会改写 model:传入 effect 让无渲染校验复刻这份写入,
|
||||
// 否则无渲染校验拿到的值会与渲染式校验不一致
|
||||
// 叶子字段,但需要改写表单值:写成 effect,不要在组件 setup 里改 model
|
||||
registerField('my-status', {
|
||||
effect: ({ config, model }) => {
|
||||
if ((config as any).initValue && model) {
|
||||
@ -50,55 +53,55 @@ registerField('my-status', {
|
||||
|
||||
// 复合字段:把组件内部渲染的 MContainer 配置交出来
|
||||
registerField('my-composite', {
|
||||
nested: ({ config, model, prop }) => ({
|
||||
// 对应组件内部 <MContainer :config="innerConfig" :model="model[name]" :prop="prop">
|
||||
config: innerConfig,
|
||||
innerConfig: ({ config, model, prop }) => ({
|
||||
// 对应组件内部 <MContainer :config="childConfig" :model="model[name]" :prop="prop">
|
||||
config: childConfig,
|
||||
model: model[config.name],
|
||||
prop,
|
||||
}),
|
||||
});
|
||||
|
||||
// typeMatch:覆盖或扩展该 type 的类型匹配校验,可与 nested / effect 同时登记
|
||||
// typeMatch:覆盖或扩展该 type 的类型匹配校验,可与 innerConfig / effect 同时登记
|
||||
registerField('my-status', {
|
||||
typeMatch: (value, { message }) => (typeof value === 'string' ? undefined : message || '应为字符串'),
|
||||
});
|
||||
```
|
||||
|
||||
返回的 `config` 的 `name` 会被追加到返回的 `prop` 上。因此当嵌套配置复用了字段自身的 `name`(例如内部渲染 `<MGroupList :config="{ name, items }" :model="model" :prop="prop">`)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次:
|
||||
返回的 `config` 的 `name` 会被追加到返回的 `prop` 上。因此当内部配置复用了字段自身的 `name`(例如内部渲染 `<MGroupList :config="{ name, items }" :model="model" :prop="prop">`)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次:
|
||||
|
||||
```ts
|
||||
registerField('my-list', {
|
||||
nested: ({ config, parentProp }) => ({
|
||||
innerConfig: ({ config, parentProp }) => ({
|
||||
config: { type: 'group-list', name: config.name, items: innerItems },
|
||||
prop: parentProp,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
编辑器侧四个复合字段(`code-select` / `display-conds` / `event-select` / `style-setter`)的登记可参考 `packages/editor/src/fields/headless-validation.ts`:nested 与组件共用同一份配置工厂(`packages/editor/src/fields/configs/`),避免两条链路各写一份而逐渐跑偏。
|
||||
编辑器侧四个复合字段(`code-select` / `display-conds` / `event-select` / `style-setter`)的登记可参考 `packages/editor/src/fields/headless-validation.ts`:innerConfig 与组件共用同一份配置工厂(`packages/editor/src/fields/configs/`),避免两条链路各写一份而逐渐跑偏。
|
||||
|
||||
`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem;需要嵌套表单项时,应对该具体组件 `registerField(type, { nested })`。
|
||||
`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem;需要嵌套表单项时,应对该具体组件 `registerField(type, { innerConfig })`。
|
||||
|
||||
### 重复登记与撤销
|
||||
|
||||
同一个 type 多次登记按字段浅合并,后一次只覆盖自己传入的 key:
|
||||
|
||||
```ts
|
||||
registerField('my-composite', { nested });
|
||||
registerField('my-composite', { component: MyComposite }); // nested 仍在
|
||||
registerField('my-composite', { innerConfig });
|
||||
registerField('my-composite', { component: MyComposite }); // innerConfig 仍在
|
||||
```
|
||||
|
||||
登记分「内置」与「业务」两层。`app.use(MagicForm)` / `registerBuiltInFields` 写内置层,`registerField` / `registerFields` 写业务层;读取时业务层优先,`unregisterField` / `clearFields` 只清业务层,内置字段不受影响(单测里 `clearFields` 之后仍能校验 `text`、`tab` 等内置 type)。
|
||||
|
||||
因为是合并语义,把一个已登记 `nested` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销:
|
||||
因为是合并语义,把一个已登记 `innerConfig` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销:
|
||||
|
||||
```ts
|
||||
registerField('my-composite', {}); // ✗ 合并后 nested 还在,仍会下钻
|
||||
registerField('my-composite', {}); // ✗ 合并后 innerConfig 还在,仍会下钻
|
||||
unregisterField('my-composite'); // ✓ 先清掉业务层登记
|
||||
registerField('my-composite', { component: MyComposite });
|
||||
```
|
||||
|
||||
一次登记里同时传多个形态时的优先级:`walk` > `nested` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。
|
||||
一次登记里同时传多个形态时的优先级:`walk` > `innerConfig` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。
|
||||
|
||||
## 签名
|
||||
|
||||
@ -274,7 +277,7 @@ if (error) {
|
||||
}
|
||||
```
|
||||
|
||||
校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。无法完成校验时才会 reject(例如嵌套配置回调失败抛出 `FieldNestedConfigError`)。
|
||||
校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。无法完成校验时才会 reject(例如 innerConfig 回调失败抛出 `FieldInnerConfigError`)。
|
||||
|
||||
## 运行环境
|
||||
|
||||
|
||||
@ -155,7 +155,7 @@ app.use(MagicForm, {
|
||||
|
||||
### Editor 字段内置规则
|
||||
|
||||
安装 `@tmagic/editor` 时会把 `editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless` 与 `@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key(如 `nested` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
|
||||
安装 `@tmagic/editor` 时会把 `editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless` 与 `@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key(如 `innerConfig` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
|
||||
|
||||
| 字段 type | 期望值 |
|
||||
| --- | --- |
|
||||
@ -174,7 +174,7 @@ app.use(MagicForm, {
|
||||
|
||||
> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules,避免单个子项非法导致整块表单标红。
|
||||
|
||||
业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `nested` / `walk` / `effect`。
|
||||
业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `innerConfig` / `walk` / `effect`。
|
||||
|
||||
## 示例
|
||||
|
||||
|
||||
@ -45,7 +45,7 @@ app.use(MagicForm);
|
||||
app.mount("#app");
|
||||
```
|
||||
|
||||
也可在安装时传入自定义字段登记(叶子 / nested / typeMatch / component),详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则):
|
||||
也可在安装时传入自定义字段登记(叶子 / innerConfig / typeMatch / component),详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则):
|
||||
|
||||
```javascript
|
||||
import MyField from './MyField.vue';
|
||||
|
||||
@ -22,7 +22,7 @@ export default {
|
||||
['^(@tencent)(/.*|$)'],
|
||||
['^(@tmagic)(/.*|$)'],
|
||||
// Internal packages.
|
||||
['^(@|src|editor-page|@editor|@data-source)(/.*|$)'],
|
||||
['^(@|src|editor-page|@editor|@form|@data-source)(/.*|$)'],
|
||||
// Side effect imports.
|
||||
['^\\u0000'],
|
||||
// Parent imports. Put `..` last.
|
||||
|
||||
@ -60,9 +60,8 @@ const lastValuesProcessed = computed<FormValue>(() => {
|
||||
/**
|
||||
* `code-select` 字段在历史数据中存在两种"语义为空"的形态:
|
||||
* - 字符串 `''`(旧数据 / 用户从未配置过钩子);
|
||||
* - `{ hookType: HookType.CODE, hookData: [] }`(CodeSelect.vue 在挂载时
|
||||
* 写入的默认结构,参见 packages/editor/src/fields/CodeSelect.vue 中
|
||||
* `props.model[props.name] = { hookType: HookType.CODE, hookData: [] }`)。
|
||||
* - `{ hookType: HookType.CODE, hookData: [] }`(`normalizeCodeSelectValue`
|
||||
* 写入的默认结构)。
|
||||
*
|
||||
* 直接 `isEqual` 会把两者判为不等,从而在历史对比里对每个未配置过钩子的组件
|
||||
* 都展示一份"差异",体验很糟糕。这里把它们视为相等,跳过对比。
|
||||
|
||||
@ -52,15 +52,12 @@ const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues
|
||||
|
||||
const codeConfig = computed(() => createCodeSelectConfig(props.config));
|
||||
|
||||
// 挂载时的归一化由 applyMountValueEffects → code-select 的 effect 完成,这里只兜运行期被置空
|
||||
watch(
|
||||
() => props.model[props.name],
|
||||
() => {
|
||||
// 兼容旧的数据结构
|
||||
normalizeCodeSelectValue(props.model, props.name);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
const changeHandler = (v: any, eventData: ContainerChangeEventData) => emit('change', v, eventData);
|
||||
|
||||
@ -109,10 +109,10 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
|
||||
};
|
||||
|
||||
/**
|
||||
* `fields/CodeSelect.vue` 挂载时的值兼容:旧数据结构里钩子值可能是空值或空数组,
|
||||
* 组件用 `watch(immediate)` 把它改写成 `{ hookType, hookData }`。
|
||||
* `code-select` 的旧数据兼容:钩子值可能是空值或空数组,改写成 `{ hookType, hookData }`。
|
||||
*
|
||||
* 这个写入发生在校验之前,无渲染校验必须同样执行,否则内部字段的取值层级不一致。
|
||||
* 表单值初始化时由 `code-select` 的 `effect` 执行(`applyMountValueEffects`);
|
||||
* 组件里的 `watch`(无 immediate)只兜运行期被置空。必须幂等。
|
||||
*/
|
||||
export const normalizeCodeSelectValue = (model: FormValue | undefined, name: string): void => {
|
||||
if (!model) return;
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
import {
|
||||
type DisplayCondsConfig,
|
||||
type EventSelectConfig,
|
||||
type FieldNestedConfig,
|
||||
type FieldInnerConfig,
|
||||
type FieldMountValueEffect,
|
||||
filterFunction,
|
||||
type FormItemConfig,
|
||||
type HeadlessFieldOptions,
|
||||
@ -35,19 +36,30 @@ import { createStyleSetterConfig } from './StyleSetter/configs';
|
||||
|
||||
const getName = (config: FormItemConfig): string => `${(config as any).name ?? ''}`;
|
||||
|
||||
/** `code-select` 旧数据兼容:空值改写成 `{ hookType, hookData }`。 */
|
||||
const codeSelectEffect: FieldMountValueEffect = ({ config, model }) => {
|
||||
normalizeCodeSelectValue(model, getName(config));
|
||||
};
|
||||
|
||||
/** `event-select`:表单 init 对未声明 defaultValue 的自定义 type 会写成空串,统一收成数组。 */
|
||||
const eventSelectEffect: FieldMountValueEffect = ({ config, model }) => {
|
||||
const name = getName(config);
|
||||
if (model && !Array.isArray(model[name])) {
|
||||
model[name] = [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `code-select` 的嵌套配置。
|
||||
* `code-select` 的内部配置。
|
||||
*
|
||||
* 对应 `fields/CodeSelect.vue` 内部
|
||||
* `<MContainer :config="codeConfig" :model="model[name]" :prop="prop">`。
|
||||
*
|
||||
* @param ctx - 嵌套配置回调入参
|
||||
* @param ctx - innerConfig 回调入参
|
||||
* @returns 内部容器的 config / model / prop
|
||||
*/
|
||||
const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
|
||||
const codeSelectInnerConfig: FieldInnerConfig = ({ config, model, prop }) => {
|
||||
const name = getName(config);
|
||||
// 组件在 watch(immediate) 里做的旧数据兼容,发生在校验之前
|
||||
normalizeCodeSelectValue(model, name);
|
||||
|
||||
return {
|
||||
config: createCodeSelectConfig(config as any),
|
||||
@ -57,14 +69,14 @@ const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* `display-conds` 的嵌套配置。
|
||||
* `display-conds` 的内部配置。
|
||||
*
|
||||
* 对应 `fields/DisplayConds.vue` 内部把同一份 groupList 配置交给 `MGroupList`。
|
||||
*
|
||||
* @param ctx - 嵌套配置回调入参
|
||||
* @param ctx - innerConfig 回调入参
|
||||
* @returns 内部 group-list 配置;prop 基准为 parentProp,避免 name 被拼两次
|
||||
*/
|
||||
const displayCondsNestedConfig: FieldNestedConfig = ({ config, model, prop, parentProp, mForm }) => {
|
||||
const displayCondsInnerConfig: FieldInnerConfig = ({ config, model, prop, parentProp, mForm }) => {
|
||||
const name = getName(config);
|
||||
const parentFields =
|
||||
filterFunction<string[]>(mForm, (config as DisplayCondsConfig).parentFields, {
|
||||
@ -81,19 +93,16 @@ const displayCondsNestedConfig: FieldNestedConfig = ({ config, model, prop, pare
|
||||
};
|
||||
|
||||
/**
|
||||
* `event-select` 的嵌套配置。
|
||||
* `event-select` 的内部配置。
|
||||
*
|
||||
* 对应 `fields/EventSelect.vue`:列表走 group-list,事件名渲染在 title slot,
|
||||
* 无渲染校验仍把事件名当作列表项字段,路径为 `<prop>.<index>.name`。
|
||||
*
|
||||
* @param ctx - 嵌套配置回调入参
|
||||
* @param ctx - innerConfig 回调入参
|
||||
* @returns 合成的 group-list 配置;旧数据格式返回 null,不参与校验
|
||||
*/
|
||||
const eventSelectNestedConfig: FieldNestedConfig = ({ config, model, parentProp }) => {
|
||||
const eventSelectInnerConfig: FieldInnerConfig = ({ config, model, parentProp }) => {
|
||||
const name = getName(config);
|
||||
if (model && !Array.isArray(model[name])) {
|
||||
model[name] = [];
|
||||
}
|
||||
const events = model?.[name];
|
||||
|
||||
// 旧数据格式走的是另一套表格配置,其中不含任何 rules,不参与校验
|
||||
@ -106,15 +115,15 @@ const eventSelectNestedConfig: FieldNestedConfig = ({ config, model, parentProp
|
||||
};
|
||||
|
||||
/**
|
||||
* `style-setter` 的嵌套配置。
|
||||
* `style-setter` 的内部配置。
|
||||
*
|
||||
* 对应 `fields/StyleSetter/Index.vue`:6 个面板共用 `:values="model[name]"`、`:prop="prop || name"`。
|
||||
* `theme` 按 `useTheme` 缺省空串传入,只改 flexWrap 的 UI childType,不影响校验字段。
|
||||
*
|
||||
* @param ctx - 嵌套配置回调入参
|
||||
* @param ctx - innerConfig 回调入参
|
||||
* @returns style 面板配置及 styleModel
|
||||
*/
|
||||
const styleSetterNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
|
||||
const styleSetterInnerConfig: FieldInnerConfig = ({ config, model, prop }) => {
|
||||
const name = getName(config);
|
||||
const styleModel = (model?.[name] ?? {}) as Partial<StyleSchema>;
|
||||
|
||||
@ -132,10 +141,10 @@ const styleSetterNestedConfig: FieldNestedConfig = ({ config, model, prop }) =>
|
||||
* 安装编辑器时由 plugin 把 `component` 叠上去再传给 `@tmagic/form`。
|
||||
*
|
||||
* - 叶子:内部只渲染叶子 UI,或把子表单渲染在独立的 MForm / MFormBox 实例里
|
||||
* - nested:内部再渲染 MContainer / MPanel / MGroupList,把运行期配置交出来
|
||||
* - innerConfig:内部再渲染 MContainer / MPanel / MGroupList,把运行期配置交出来
|
||||
* - typeMatch:该 type 自身的类型匹配校验
|
||||
*
|
||||
* nested 返回的 config / model / prop 与组件模板里传给内部容器的那一组保持一一对应,
|
||||
* innerConfig 返回的 config / model / prop 与组件模板里传给内部容器的那一组保持一一对应,
|
||||
* 且配置本身与组件共用同一份工厂(`fields/configs/`)。
|
||||
*/
|
||||
export const editorFields: Record<string, HeadlessFieldOptions> = {
|
||||
@ -153,8 +162,16 @@ export const editorFields: Record<string, HeadlessFieldOptions> = {
|
||||
'data-source-methods': { typeMatch: editorTypeMatchRules['data-source-methods'] },
|
||||
'data-source-method-select': { typeMatch: editorTypeMatchRules['data-source-method-select'] },
|
||||
'data-source-field-select': { typeMatch: editorTypeMatchRules['data-source-field-select'] },
|
||||
'code-select': { nested: codeSelectNestedConfig, typeMatch: editorTypeMatchRules['code-select'] },
|
||||
'display-conds': { nested: displayCondsNestedConfig, typeMatch: editorTypeMatchRules['display-conds'] },
|
||||
'event-select': { nested: eventSelectNestedConfig, typeMatch: editorTypeMatchRules['event-select'] },
|
||||
'style-setter': { nested: styleSetterNestedConfig, typeMatch: editorTypeMatchRules['style-setter'] },
|
||||
'code-select': {
|
||||
effect: codeSelectEffect,
|
||||
innerConfig: codeSelectInnerConfig,
|
||||
typeMatch: editorTypeMatchRules['code-select'],
|
||||
},
|
||||
'display-conds': { innerConfig: displayCondsInnerConfig, typeMatch: editorTypeMatchRules['display-conds'] },
|
||||
'event-select': {
|
||||
effect: eventSelectEffect,
|
||||
innerConfig: eventSelectInnerConfig,
|
||||
typeMatch: editorTypeMatchRules['event-select'],
|
||||
},
|
||||
'style-setter': { innerConfig: styleSetterInnerConfig, typeMatch: editorTypeMatchRules['style-setter'] },
|
||||
};
|
||||
|
||||
@ -108,10 +108,13 @@ describe('CodeSelect', () => {
|
||||
expect(title).toBe('unknown');
|
||||
});
|
||||
|
||||
test('空 model 时初始化为 { hookType, hookData }', () => {
|
||||
const props = baseProps({ model: { cs: undefined } });
|
||||
mount(CodeSelect, { props: props as any });
|
||||
expect((props.model.cs as any).hookData).toEqual([]);
|
||||
test('运行期被置空时补成 { hookType, hookData }', async () => {
|
||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||
const model: Record<string, any> = { cs: '' };
|
||||
|
||||
await wrapper.setProps({ model });
|
||||
|
||||
expect(model.cs).toEqual({ hookType: 'code', hookData: [] });
|
||||
});
|
||||
|
||||
test('codeType items 配置正确', () => {
|
||||
|
||||
@ -4,9 +4,14 @@
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { NODE_CONDS_KEY } from '@tmagic/core';
|
||||
|
||||
import { editorFields } from '@editor/fields/headless-validation';
|
||||
import { fillConfig } from '@editor/utils/props';
|
||||
// 走 @form 源码别名而非 @tmagic/form:编辑器包在单测里解析到的是 form 的构建产物
|
||||
import {
|
||||
applyMountValueEffects,
|
||||
builtInFields,
|
||||
clearFields,
|
||||
collectValidatableFields,
|
||||
@ -15,11 +20,6 @@ import {
|
||||
registerFields,
|
||||
} from '@form/index';
|
||||
|
||||
import { NODE_CONDS_KEY } from '@tmagic/core';
|
||||
|
||||
import { editorFields } from '@editor/fields/headless-validation';
|
||||
import { fillConfig } from '@editor/utils/props';
|
||||
|
||||
/**
|
||||
* 无渲染校验只遍历 config 树,看不到复合字段在组件内部渲染出来的嵌套字段。
|
||||
* 这些用例锁定嵌套配置交出来的 prop 路径——它必须与组件真实渲染出的 FormItem 路径一致,
|
||||
@ -29,12 +29,7 @@ const collect = (config: any[], values: any, typeMatchValid = true) => {
|
||||
const formState = createHeadlessFormState({ config, initValues: values });
|
||||
formState.values = values;
|
||||
|
||||
const fields = collectValidatableFields(
|
||||
formState,
|
||||
config,
|
||||
values,
|
||||
computed(() => typeMatchValid),
|
||||
);
|
||||
const fields = collectValidatableFields(formState, config, values, typeMatchValid);
|
||||
|
||||
return { props: fields.map((field) => field.prop) };
|
||||
};
|
||||
@ -82,12 +77,23 @@ describe('code-select', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('空值按组件的旧数据兼容改写为 { hookType, hookData }', () => {
|
||||
test('collect 只读,空值不会在收集字段时被改写', () => {
|
||||
const config = [{ type: 'code-select', name: 'created' }];
|
||||
const values: any = { created: [] };
|
||||
|
||||
collect(config, values);
|
||||
|
||||
expect(values.created).toEqual([]);
|
||||
});
|
||||
|
||||
test('applyMountValueEffects 把空值改写成 { hookType, hookData }', () => {
|
||||
const config = [{ type: 'code-select', name: 'created' }] as any;
|
||||
const values: any = { created: [] };
|
||||
const formState = createHeadlessFormState({ config, initValues: values });
|
||||
formState.values = values;
|
||||
|
||||
applyMountValueEffects(formState, config, values);
|
||||
|
||||
expect(values.created).toEqual({ hookType: 'code', hookData: [] });
|
||||
});
|
||||
|
||||
@ -199,6 +205,17 @@ describe('event-select', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('applyMountValueEffects 把非数组收成空数组', () => {
|
||||
const config = [{ type: 'event-select', name: 'events' }] as any;
|
||||
const values: any = {};
|
||||
const formState = createHeadlessFormState({ config, initValues: values });
|
||||
formState.values = values;
|
||||
|
||||
applyMountValueEffects(formState, config, values);
|
||||
|
||||
expect(values.events).toEqual([]);
|
||||
});
|
||||
|
||||
test('旧数据格式(列表项没有 actions)不含校验规则,不产出字段', () => {
|
||||
const config = [{ type: 'event-select', name: 'events' }];
|
||||
const values = { events: [{ name: 'click', to: 'node_1', method: 'show' }] };
|
||||
@ -295,14 +312,7 @@ describe('fillConfig 通用属性表单', () => {
|
||||
// 关掉独立样式面板,让 fillConfig 注入的 style tab 走 display,从而覆盖 style-setter
|
||||
(formState as any).services = { uiService: { get: (key: string) => key !== 'showStylePanel' } };
|
||||
|
||||
expect(() =>
|
||||
collectValidatableFields(
|
||||
formState,
|
||||
config,
|
||||
values,
|
||||
computed(() => true),
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(() => collectValidatableFields(formState, config, values, true)).not.toThrow();
|
||||
});
|
||||
|
||||
test('显示条件 tab 按 groupList 展开条件组与组内条件', () => {
|
||||
|
||||
@ -83,7 +83,7 @@ describe('plugin install', () => {
|
||||
expect(formInstall).toBeDefined();
|
||||
const fields = formInstall[1].fields as Record<
|
||||
string,
|
||||
{ component?: unknown; container?: unknown; nested?: unknown; typeMatch?: unknown }
|
||||
{ component?: unknown; container?: unknown; innerConfig?: unknown; typeMatch?: unknown }
|
||||
>;
|
||||
expect(formInstall[1].someOption).toBe(true);
|
||||
expect(Object.keys(fields)).toEqual(
|
||||
@ -117,11 +117,14 @@ describe('plugin install', () => {
|
||||
}
|
||||
expect(fields[type].component, `${type} 缺少 component`).toBeDefined();
|
||||
}
|
||||
expect(fields['code-select'].nested).toEqual(expect.any(Function));
|
||||
expect(fields['code-select'].effect).toEqual(expect.any(Function));
|
||||
expect(fields['code-select'].innerConfig).toEqual(expect.any(Function));
|
||||
expect(fields['code-select'].typeMatch).toEqual(expect.any(Function));
|
||||
expect(fields['style-setter'].nested).toEqual(expect.any(Function));
|
||||
expect(fields['event-select'].effect).toEqual(expect.any(Function));
|
||||
expect(fields['event-select'].innerConfig).toEqual(expect.any(Function));
|
||||
expect(fields['style-setter'].innerConfig).toEqual(expect.any(Function));
|
||||
expect(fields['ui-select'].typeMatch).toEqual(expect.any(Function));
|
||||
expect(fields['vs-code'].nested).toBeUndefined();
|
||||
expect(fields['vs-code'].innerConfig).toBeUndefined();
|
||||
expect(components.MEditor).toBeDefined();
|
||||
expect(components['magic-code-editor']).toBeDefined();
|
||||
expect(Object.keys(components)).toEqual(['MEditor', 'magic-code-editor']);
|
||||
@ -138,7 +141,8 @@ describe('plugin install', () => {
|
||||
} as any);
|
||||
const formOpt = (app.use as any).mock.calls.find((call: any[]) => call[0] === formPlugin)[1];
|
||||
expect(formOpt.fields['code-select'].component).toEqual({ name: 'CustomCodeSelect' });
|
||||
expect(formOpt.fields['code-select'].nested).toEqual(expect.any(Function));
|
||||
expect(formOpt.fields['code-select'].effect).toEqual(expect.any(Function));
|
||||
expect(formOpt.fields['code-select'].innerConfig).toEqual(expect.any(Function));
|
||||
expect(formOpt.fields['code-select'].typeMatch).toEqual(expect.any(Function));
|
||||
expect(formOpt.fields['event-select'].effect).toEqual(expect.any(Function));
|
||||
expect(formOpt.fields['my-field'].component).toEqual({ name: 'MyField' });
|
||||
|
||||
@ -56,6 +56,7 @@ import { M_THEME_KEY, TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagi
|
||||
import { setValueByKeyPath } from '@tmagic/utils';
|
||||
|
||||
import Container from './containers/Container.vue';
|
||||
import { applyMountValueEffects } from './utils/collectFields';
|
||||
import { applyExtendState, createFormStateBase, initValue } from './utils/form';
|
||||
import { formatValidateError as formatError, getTextByName as findTextByName } from './utils/validateError';
|
||||
import type { ChangeRecord, ContainerChangeEventData, FormConfig, FormSlots, FormState, FormValue } from './schema';
|
||||
@ -346,6 +347,9 @@ watch(
|
||||
config: props.config,
|
||||
}).then((value) => {
|
||||
values.value = value;
|
||||
// 字段的值初始化写入统一在这里执行,字段组件自身不再改写 model。
|
||||
// 必须放在赋值之后:effect 与动态 type / display 回调读到的 formValue 才是这一轮的值。
|
||||
applyMountValueEffects(formState, props.config, values.value);
|
||||
// 非对比模式,初始化完成
|
||||
initialized.value = !props.isCompare;
|
||||
|
||||
@ -363,6 +367,8 @@ watch(
|
||||
config: props.config,
|
||||
}).then((value) => {
|
||||
lastValuesProcessed.value = value;
|
||||
// 对比模式下待对比的那份值同样要规整,否则会与当前值比出「格式差异」这种假差异
|
||||
applyMountValueEffects(formState, props.config, lastValuesProcessed.value);
|
||||
initialized.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
@ -358,7 +358,7 @@ const type = computed((): string => resolveItemType(mForm, props.config, props))
|
||||
const tagName = computed(() => {
|
||||
// `type: 'component'` 把任意 Vue 组件当字段渲染。无渲染校验把它当叶子、不遍历内部结构,
|
||||
// 因此该组件不得再向父表单注册 FormItem(不要在内部挂会 addField 的 MContainer)。
|
||||
// 需要嵌套表单项时,应对该具体组件 registerField(type, { nested })。
|
||||
// 需要嵌套表单项时,应对该具体组件 registerField(type, { innerConfig })。
|
||||
if (type.value === 'component' && (props.config as ComponentConfig).component) {
|
||||
return (props.config as ComponentConfig).component;
|
||||
}
|
||||
|
||||
@ -86,6 +86,7 @@ import { isEmpty } from 'lodash-es';
|
||||
import { getDesignConfig, TMagicBadge } from '@tmagic/design';
|
||||
|
||||
import type { ContainerChangeEventData, FormState, TabConfig, TabPaneConfig } from '../schema';
|
||||
import { applyMountValueEffects } from '../utils/collectFields';
|
||||
import { display as displayFunc, filterFunction, initValue } from '../utils/form';
|
||||
|
||||
import Container from './Container.vue';
|
||||
@ -205,6 +206,13 @@ const onTabAdd = async () => {
|
||||
prop: props.prop,
|
||||
config: props.config,
|
||||
});
|
||||
// 自定义回调可能直接 push 原始对象;对现有标签页再规整一遍(effect 必须幂等)
|
||||
const tabsModel = props.model[props.name];
|
||||
if (Array.isArray(tabsModel) && Array.isArray(props.config.items)) {
|
||||
for (const tab of tabsModel) {
|
||||
applyMountValueEffects(mForm, props.config.items, tab);
|
||||
}
|
||||
}
|
||||
emit('change', props.model[props.name]);
|
||||
} else {
|
||||
const newObj = await initValue(mForm, {
|
||||
@ -212,6 +220,9 @@ const onTabAdd = async () => {
|
||||
initValues: {},
|
||||
});
|
||||
|
||||
// 新增标签页的值在挂到表单上之前先规整一遍,与整表初始化走同一份登记表
|
||||
applyMountValueEffects(mForm, props.config.items, newObj);
|
||||
|
||||
newObj.title = `标签${tabs.value.length + 1}`;
|
||||
|
||||
props.model[props.name].push(newObj);
|
||||
|
||||
@ -58,8 +58,9 @@ import { Grid, Plus } from '@element-plus/icons-vue';
|
||||
import { TMagicButton } from '@tmagic/design';
|
||||
import type { GroupListConfig, TableConfig } from '@tmagic/form-schema';
|
||||
|
||||
import type { ContainerChangeEventData } from '../../schema';
|
||||
import { isGroupListType, toGroupListConfig, toTableConfig } from '../../utils/tableGroupList';
|
||||
import type { ContainerChangeEventData } from '@form/schema';
|
||||
import { isGroupListType, toGroupListConfig, toTableConfig } from '@form/utils/tableGroupList';
|
||||
|
||||
import MFormGroupList from '../GroupList.vue';
|
||||
import MFormTable from '../table/Table.vue';
|
||||
|
||||
|
||||
@ -3,7 +3,9 @@ import { computed, inject } from 'vue';
|
||||
import { tMagicMessage } from '@tmagic/design';
|
||||
import type { FormConfig, FormState, TableConfig, TableGroupListCommonConfig } from '@tmagic/form-schema';
|
||||
|
||||
import { initValue } from '../../utils/form';
|
||||
import { applyMountValueEffects } from '@form/utils/collectFields';
|
||||
import { initValue } from '@form/utils/form';
|
||||
|
||||
import type { TableProps } from '../table/type';
|
||||
|
||||
export const useAdd = (
|
||||
@ -110,6 +112,9 @@ export const useAdd = (
|
||||
});
|
||||
}
|
||||
|
||||
// enum / Excel 导入的数组行与默认新增共用同一份规整,字段组件不再在 setup 里改 model
|
||||
applyMountValueEffects(mForm, columns as FormConfig, inputs);
|
||||
|
||||
if (props.sortKey && length) {
|
||||
inputs[props.sortKey] = list[length - 1][props.sortKey] - 1;
|
||||
}
|
||||
|
||||
@ -43,7 +43,7 @@ import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import { TMagicButton, TMagicTooltip } from '@tmagic/design';
|
||||
|
||||
import type { FormState, TableConfig } from '../../schema';
|
||||
import type { FormState, TableConfig } from '@form/schema';
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
|
||||
@ -92,8 +92,8 @@ import { FullScreen } from '@element-plus/icons-vue';
|
||||
|
||||
import { TMagicButton, TMagicPagination, TMagicTable, TMagicTooltip, TMagicUpload } from '@tmagic/design';
|
||||
|
||||
import type { SortProp } from '../../schema';
|
||||
import { sortChange } from '../../utils/form';
|
||||
import type { SortProp } from '@form/schema';
|
||||
import { sortChange } from '@form/utils/form';
|
||||
|
||||
import type { TableProps } from './type';
|
||||
import { useFullscreen } from './useFullscreen';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { computed, type Ref, ref } from 'vue';
|
||||
|
||||
import { getDataByPage } from '../../utils/form';
|
||||
import { getDataByPage } from '@form/utils/form';
|
||||
|
||||
import type { TableProps } from './type';
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import type { default as SortableType, SortableEvent } from 'sortablejs';
|
||||
import { type TMagicTable } from '@tmagic/design';
|
||||
import type { FormState } from '@tmagic/form-schema';
|
||||
|
||||
import { sortArray } from '../../utils/form';
|
||||
import { sortArray } from '@form/utils/form';
|
||||
|
||||
import type { TableProps } from './type';
|
||||
|
||||
|
||||
@ -4,10 +4,11 @@ import { WarningFilled } from '@element-plus/icons-vue';
|
||||
import { type TableColumnOptions, TMagicIcon, TMagicTooltip } from '@tmagic/design';
|
||||
import type { FormItemConfig, FormState } from '@tmagic/form-schema';
|
||||
|
||||
import type { ContainerChangeEventData } from '../../schema';
|
||||
import { isGlobalFlat } from '../../utils/config';
|
||||
import { appendProp, display as displayFunc, getDataByPage, sortArray } from '../../utils/form';
|
||||
import { isTableColumnRendered, makeTableColumnConfig } from '../../utils/tableGroupList';
|
||||
import type { ContainerChangeEventData } from '@form/schema';
|
||||
import { isGlobalFlat } from '@form/utils/config';
|
||||
import { appendProp, display as displayFunc, getDataByPage, sortArray } from '@form/utils/form';
|
||||
import { isTableColumnRendered, makeTableColumnConfig } from '@form/utils/tableGroupList';
|
||||
|
||||
import Container from '../Container.vue';
|
||||
|
||||
import ActionsColumn from './ActionsColumn.vue';
|
||||
|
||||
@ -11,10 +11,9 @@ import { computed, inject } from 'vue';
|
||||
|
||||
import { TMagicCheckbox, TMagicCheckboxGroup } from '@tmagic/design';
|
||||
|
||||
import type { CheckboxGroupConfig, CheckboxGroupOption, FieldProps, FormState } from '../schema';
|
||||
import { initCheckboxGroupValue } from '../utils/fieldValueEffects';
|
||||
import { filterFunction } from '../utils/form';
|
||||
import { useAddField } from '../utils/useAddField';
|
||||
import type { CheckboxGroupConfig, CheckboxGroupOption, FieldProps, FormState } from '@form/schema';
|
||||
import { filterFunction } from '@form/utils/form';
|
||||
import { useAddField } from '@form/utils/useAddField';
|
||||
|
||||
defineOptions({
|
||||
name: 'MFormCheckGroup',
|
||||
@ -26,8 +25,6 @@ const emit = defineEmits(['change']);
|
||||
|
||||
useAddField(props.prop);
|
||||
|
||||
initCheckboxGroupValue(props.model, props.name);
|
||||
|
||||
const changeHandler = (v: Array<string | number | boolean>) => {
|
||||
emit('change', v);
|
||||
};
|
||||
27
packages/form/src/fields/CheckboxGroup/effect.ts
Normal file
27
packages/form/src/fields/CheckboxGroup/effect.ts
Normal 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] = [];
|
||||
}
|
||||
};
|
||||
@ -14,9 +14,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { TMagicDatePicker } from '@tmagic/design';
|
||||
|
||||
import type { DateConfig, FieldProps } from '../schema';
|
||||
import { normalizeDateValue } from '../utils/fieldValueEffects';
|
||||
import { useAddField } from '../utils/useAddField';
|
||||
import type { DateConfig, FieldProps } from '@form/schema';
|
||||
import { useAddField } from '@form/utils/useAddField';
|
||||
|
||||
defineOptions({
|
||||
name: 'MFormDate',
|
||||
@ -30,8 +29,6 @@ const emit = defineEmits<{
|
||||
|
||||
useAddField(props.prop);
|
||||
|
||||
normalizeDateValue(props.config, props.model, props.name);
|
||||
|
||||
const changeHandler = (v: string) => {
|
||||
emit('change', v);
|
||||
};
|
||||
29
packages/form/src/fields/Date/effect.ts
Normal file
29
packages/form/src/fields/Date/effect.ts
Normal 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');
|
||||
};
|
||||
@ -16,9 +16,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { TMagicDatePicker } from '@tmagic/design';
|
||||
|
||||
import type { DateTimeConfig, FieldProps } from '../schema';
|
||||
import { normalizeDateTimeValue } from '../utils/fieldValueEffects';
|
||||
import { useAddField } from '../utils/useAddField';
|
||||
import type { DateTimeConfig, FieldProps } from '@form/schema';
|
||||
import { useAddField } from '@form/utils/useAddField';
|
||||
|
||||
defineOptions({
|
||||
name: 'MFormDateTime',
|
||||
@ -32,8 +31,6 @@ const emit = defineEmits<{
|
||||
|
||||
useAddField(props.prop);
|
||||
|
||||
normalizeDateTimeValue(props.config, props.model, props.name);
|
||||
|
||||
const changeHandler = (v: string) => {
|
||||
emit('change', v);
|
||||
};
|
||||
36
packages/form/src/fields/DateTime/effect.ts
Normal file
36
packages/form/src/fields/DateTime/effect.ts
Normal 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');
|
||||
};
|
||||
@ -5,10 +5,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
import type { DisplayConfig, FieldProps, FormState } from '../schema';
|
||||
import { applyDisplayInitValue } from '../utils/fieldValueEffects';
|
||||
import { filterFunction } from '../utils/form';
|
||||
import { useAddField } from '../utils/useAddField';
|
||||
import type { DisplayConfig, FieldProps, FormState } from '@form/schema';
|
||||
import { filterFunction } from '@form/utils/form';
|
||||
import { useAddField } from '@form/utils/useAddField';
|
||||
|
||||
defineOptions({
|
||||
name: 'MFormDisplay',
|
||||
@ -18,8 +17,6 @@ const props = defineProps<FieldProps<DisplayConfig>>();
|
||||
|
||||
const mForm = inject<FormState | undefined>('mForm');
|
||||
|
||||
applyDisplayInitValue(props.config, props.model, props.name);
|
||||
|
||||
const text = computed(() => {
|
||||
if (props.config.displayText) {
|
||||
return filterFunction<string>(mForm, props.config.displayText, props);
|
||||
28
packages/form/src/fields/Display/effect.ts
Normal file
28
packages/form/src/fields/Display/effect.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -27,10 +27,11 @@ import { onBeforeUnmount, reactive, watch } from 'vue';
|
||||
|
||||
import { TMagicForm, TMagicFormItem, TMagicInput } from '@tmagic/design';
|
||||
|
||||
import type { DynamicFieldConfig, FieldProps } from '../schema';
|
||||
import { getConfig } from '../utils/config';
|
||||
import { eachDynamicField } from '../utils/fieldValueEffects';
|
||||
import { useAddField } from '../utils/useAddField';
|
||||
import type { DynamicFieldConfig, FieldProps } from '@form/schema';
|
||||
import { getConfig } from '@form/utils/config';
|
||||
import { useAddField } from '@form/utils/useAddField';
|
||||
|
||||
import { eachDynamicField } from './effect';
|
||||
|
||||
defineOptions({
|
||||
name: 'MFormDynamicField',
|
||||
74
packages/form/src/fields/DynamicField/effect.ts
Normal file
74
packages/form/src/fields/DynamicField/effect.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -23,9 +23,8 @@ import { ref, watch } from 'vue';
|
||||
|
||||
import { TMagicInput } from '@tmagic/design';
|
||||
|
||||
import type { FieldProps, NumberRangeConfig } from '../schema';
|
||||
import { normalizeNumberRangeValue } from '../utils/fieldValueEffects';
|
||||
import { useAddField } from '../utils/useAddField';
|
||||
import type { FieldProps, NumberRangeConfig } from '@form/schema';
|
||||
import { useAddField } from '@form/utils/useAddField';
|
||||
|
||||
defineOptions({
|
||||
name: 'MFormNumberRange',
|
||||
@ -54,8 +53,6 @@ watch(
|
||||
|
||||
useAddField(props.prop);
|
||||
|
||||
normalizeNumberRangeValue(props.model, props.name);
|
||||
|
||||
const minChangeHandler = (v: string) => {
|
||||
emit('change', [Number(v), props.model[props.name][1]]);
|
||||
};
|
||||
27
packages/form/src/fields/NumberRange/effect.ts
Normal file
27
packages/form/src/fields/NumberRange/effect.ts
Normal 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] = [];
|
||||
}
|
||||
};
|
||||
@ -44,12 +44,17 @@ export {
|
||||
} from './utils/registerField';
|
||||
export type { FieldOptions, HeadlessFieldOptions } from './utils/registerField';
|
||||
|
||||
export type { FieldNestedConfig, FieldNestedConfigContext, FieldNestedConfigResult } from './utils/fieldNestedConfig';
|
||||
export type { FieldInnerConfig, FieldInnerConfigContext, FieldInnerConfigResult } from './utils/fieldInnerConfig';
|
||||
|
||||
export { isLeafFieldType } from './utils/fieldValueEffects';
|
||||
export type { FieldMountValueEffect, FieldMountValueEffectContext } from './utils/fieldValueEffects';
|
||||
|
||||
export { collectValidatableFields, FieldNestedConfigError, isFieldNestedConfigError } from './utils/collectFields';
|
||||
export {
|
||||
applyMountValueEffects,
|
||||
collectValidatableFields,
|
||||
FieldInnerConfigError,
|
||||
isFieldInnerConfigError,
|
||||
} from './utils/collectFields';
|
||||
export type { CollectedField } from './utils/collectFields';
|
||||
|
||||
export { createHeadlessFormState, validateValues } from './utils/validateValues';
|
||||
|
||||
@ -38,24 +38,24 @@ export { default as MGroupList } from './containers/table-group-list/TableGroupL
|
||||
export { default as MTableGroupList } from './containers/table-group-list/TableGroupList.vue';
|
||||
export { default as MText } from './fields/Text.vue';
|
||||
export { default as MNumber } from './fields/Number.vue';
|
||||
export { default as MNumberRange } from './fields/NumberRange.vue';
|
||||
export { default as MNumberRange } from './fields/NumberRange/Index.vue';
|
||||
export { default as MTextarea } from './fields/Textarea.vue';
|
||||
export { default as MHidden } from './fields/Hidden.vue';
|
||||
export { default as MDate } from './fields/Date.vue';
|
||||
export { default as MDateTime } from './fields/DateTime.vue';
|
||||
export { default as MDate } from './fields/Date/Index.vue';
|
||||
export { default as MDateTime } from './fields/DateTime/Index.vue';
|
||||
export { default as MTime } from './fields/Time.vue';
|
||||
export { default as MCheckbox } from './fields/Checkbox.vue';
|
||||
export { default as MSwitch } from './fields/Switch.vue';
|
||||
export { default as MDaterange } from './fields/Daterange.vue';
|
||||
export { default as MTimerange } from './fields/Timerange.vue';
|
||||
export { default as MColorPicker } from './fields/ColorPicker.vue';
|
||||
export { default as MCheckboxGroup } from './fields/CheckboxGroup.vue';
|
||||
export { default as MCheckboxGroup } from './fields/CheckboxGroup/Index.vue';
|
||||
export { default as MRadioGroup } from './fields/RadioGroup.vue';
|
||||
export { default as MDisplay } from './fields/Display.vue';
|
||||
export { default as MDisplay } from './fields/Display/Index.vue';
|
||||
export { default as MLink } from './fields/Link.vue';
|
||||
export { default as MSelect } from './fields/Select.vue';
|
||||
export { default as MCascader } from './fields/Cascader.vue';
|
||||
export { default as MDynamicField } from './fields/DynamicField.vue';
|
||||
export { default as MDynamicField } from './fields/DynamicField/Index.vue';
|
||||
|
||||
export { builtInFields } from './utils/builtInFields';
|
||||
|
||||
@ -70,12 +70,17 @@ export {
|
||||
} from './utils/registerField';
|
||||
export type { FieldOptions, HeadlessFieldOptions } from './utils/registerField';
|
||||
|
||||
export type { FieldNestedConfig, FieldNestedConfigContext, FieldNestedConfigResult } from './utils/fieldNestedConfig';
|
||||
export type { FieldInnerConfig, FieldInnerConfigContext, FieldInnerConfigResult } from './utils/fieldInnerConfig';
|
||||
|
||||
export { isLeafFieldType } from './utils/fieldValueEffects';
|
||||
export type { FieldMountValueEffect, FieldMountValueEffectContext } from './utils/fieldValueEffects';
|
||||
|
||||
export { collectValidatableFields, FieldNestedConfigError, isFieldNestedConfigError } from './utils/collectFields';
|
||||
export {
|
||||
applyMountValueEffects,
|
||||
collectValidatableFields,
|
||||
FieldInnerConfigError,
|
||||
isFieldInnerConfigError,
|
||||
} from './utils/collectFields';
|
||||
export type { CollectedField } from './utils/collectFields';
|
||||
|
||||
export { createHeadlessFormState, validateValues } from './utils/validateValues';
|
||||
|
||||
@ -28,17 +28,17 @@ import TableGroupList from './containers/table-group-list/TableGroupList.vue';
|
||||
import Tabs from './containers/Tabs.vue';
|
||||
import Cascader from './fields/Cascader.vue';
|
||||
import Checkbox from './fields/Checkbox.vue';
|
||||
import CheckboxGroup from './fields/CheckboxGroup.vue';
|
||||
import CheckboxGroup from './fields/CheckboxGroup/Index.vue';
|
||||
import ColorPicker from './fields/ColorPicker.vue';
|
||||
import Date from './fields/Date.vue';
|
||||
import Date from './fields/Date/Index.vue';
|
||||
import Daterange from './fields/Daterange.vue';
|
||||
import DateTime from './fields/DateTime.vue';
|
||||
import Display from './fields/Display.vue';
|
||||
import DynamicField from './fields/DynamicField.vue';
|
||||
import DateTime from './fields/DateTime/Index.vue';
|
||||
import Display from './fields/Display/Index.vue';
|
||||
import DynamicField from './fields/DynamicField/Index.vue';
|
||||
import Hidden from './fields/Hidden.vue';
|
||||
import Link from './fields/Link.vue';
|
||||
import Number from './fields/Number.vue';
|
||||
import NumberRange from './fields/NumberRange.vue';
|
||||
import NumberRange from './fields/NumberRange/Index.vue';
|
||||
import RadioGroup from './fields/RadioGroup.vue';
|
||||
import Select from './fields/Select.vue';
|
||||
import Switch from './fields/Switch.vue';
|
||||
@ -63,7 +63,7 @@ export interface FormInstallOptions {
|
||||
/** 是否启用全局 flat 模式。 */
|
||||
flat?: boolean;
|
||||
/**
|
||||
* 自定义字段 type 的登记(叶子 / nested / walk / typeMatch / component / container)。
|
||||
* 自定义字段 type 的登记(叶子 / innerConfig / walk / typeMatch / component / container)。
|
||||
* 与 `registerFields` 相同。
|
||||
*/
|
||||
fields?: Record<string, FieldOptions>;
|
||||
|
||||
@ -16,15 +16,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { effect as checkboxGroupEffect } from '../fields/CheckboxGroup/effect';
|
||||
import { effect as dateEffect } from '../fields/Date/effect';
|
||||
import { effect as dateTimeEffect } from '../fields/DateTime/effect';
|
||||
import { effect as displayEffect } from '../fields/Display/effect';
|
||||
import { effect as dynamicFieldEffect } from '../fields/DynamicField/effect';
|
||||
import { effect as numberRangeEffect } from '../fields/NumberRange/effect';
|
||||
|
||||
import { expandFieldset, expandPanel, expandRow, expandStep, expandTab, expandTableGroupList } from './collectFields';
|
||||
import {
|
||||
checkboxGroupEffect,
|
||||
dateEffect,
|
||||
dateTimeEffect,
|
||||
displayEffect,
|
||||
dynamicFieldEffect,
|
||||
numberRangeEffect,
|
||||
} from './fieldValueEffects';
|
||||
import { type HeadlessFieldOptions } from './registerField';
|
||||
|
||||
/**
|
||||
|
||||
@ -16,13 +16,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ComputedRef } from 'vue';
|
||||
|
||||
import { toLine } from '@tmagic/utils';
|
||||
|
||||
import type { FormConfig, FormItemConfig, FormState, FormValue, Rule } from '../schema';
|
||||
|
||||
import { getFieldNestedConfig } from './fieldNestedConfig';
|
||||
import { getFieldInnerConfig } from './fieldInnerConfig';
|
||||
import { getFieldMountValueEffect, isLeafFieldType } from './fieldValueEffects';
|
||||
import {
|
||||
appendProp,
|
||||
@ -56,16 +54,16 @@ export interface CollectedField {
|
||||
}
|
||||
// #endregion CollectedField
|
||||
|
||||
/** 已登记的嵌套配置回调自身抛错时抛出(机制故障,不是漏登记) */
|
||||
export class FieldNestedConfigError extends Error {
|
||||
readonly code = 'FIELD_NESTED_CONFIG';
|
||||
/** 已登记的 innerConfig 回调自身抛错时抛出(机制故障,不是漏登记) */
|
||||
export class FieldInnerConfigError extends Error {
|
||||
readonly code = 'FIELD_INNER_CONFIG';
|
||||
readonly type: string;
|
||||
readonly prop: string;
|
||||
|
||||
constructor(type: string, prop: string, cause: unknown) {
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
super(`[MForm] nested config for "${type}" at "${prop}" failed: ${reason}`);
|
||||
this.name = 'FieldNestedConfigError';
|
||||
super(`[MForm] innerConfig for "${type}" at "${prop}" failed: ${reason}`);
|
||||
this.name = 'FieldInnerConfigError';
|
||||
this.type = type;
|
||||
this.prop = prop;
|
||||
if (cause instanceof Error) {
|
||||
@ -74,16 +72,41 @@ export class FieldNestedConfigError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export const isFieldNestedConfigError = (error: unknown): error is FieldNestedConfigError =>
|
||||
error instanceof FieldNestedConfigError ||
|
||||
(typeof error === 'object' && error !== null && (error as { code?: string }).code === 'FIELD_NESTED_CONFIG');
|
||||
export const isFieldInnerConfigError = (error: unknown): error is FieldInnerConfigError =>
|
||||
error instanceof FieldInnerConfigError ||
|
||||
(typeof error === 'object' && error !== null && (error as { code?: string }).code === 'FIELD_INNER_CONFIG');
|
||||
|
||||
/**
|
||||
* 遍历模式。同一套遍历规则服务两个用途,避免两条链路各写一份而产生偏差:
|
||||
*
|
||||
* - `collect`:收集带 rules 的字段,按 `display` 过滤(对应「渲染出来的 FormItem 集合」);
|
||||
* - `effects`:执行字段登记的值初始化写入,不收集字段,且不看 `display`
|
||||
* (`display: false` 的字段同样要规整;`type: 'hidden'` 在该节点停止,不往下分派)。
|
||||
*/
|
||||
type WalkMode = 'collect' | 'effects';
|
||||
|
||||
interface WalkContext {
|
||||
mForm: FormState | undefined;
|
||||
typeMatchValid: ComputedRef<boolean> | undefined;
|
||||
/**
|
||||
* 是否自动给字段注入 typeMatch 规则,对应 MForm 的 `typeMatchValid`。
|
||||
*
|
||||
* 只在 `collect` 模式下有用:`addField` 把它交给 `getNativeRules`。为 true 且字段
|
||||
* rules 尚未显式声明 `typeMatch`(true / false)时,自动补一条 `{ typeMatch: true }`,
|
||||
* 按字段 `type` 校验当前值形态是否合法(空值放行,必填仍靠 `required`)。
|
||||
* 单字段可在 rules 里写 `typeMatch: false` 关闭自动注入。
|
||||
*
|
||||
* `effects` 模式不收集规则,无需传入。
|
||||
*/
|
||||
typeMatchValid?: boolean;
|
||||
fields: CollectedField[];
|
||||
/** 本次遍历处理的表单值根对象(对比模式下为 lastValues 那一份) */
|
||||
values: FormValue;
|
||||
mode: WalkMode;
|
||||
}
|
||||
|
||||
/** `effects` 模式下遍历全部配置,不受 display / 折叠状态影响 */
|
||||
const ignoresDisplay = (ctx: WalkContext): boolean => ctx.mode === 'effects';
|
||||
|
||||
interface WalkNode {
|
||||
config: FormItemConfig;
|
||||
/** 所在层级的 model 切片(对应 Container 的 `props.model`) */
|
||||
@ -118,6 +141,58 @@ export const clearContainerWalkers = (): void => extraContainerWalkers.clear();
|
||||
|
||||
const getItems = (config: any): FormItemConfig[] | undefined => config?.items;
|
||||
|
||||
/**
|
||||
* `resolveItemType` 的静态版本:不求值函数型 `type`,由调用方按「未知」处理。
|
||||
*
|
||||
* 两者必须保持一致,否则预扫描判定的 type 与实际遍历的不同,会漏执行 effect。
|
||||
*/
|
||||
const staticItemType = (config: any): string => {
|
||||
const type = 'type' in config ? config.type : '';
|
||||
// form / container 都表示「仅嵌套,不渲染字段」
|
||||
if (type === 'form' || type === 'container') return '';
|
||||
return `${type || ''}`.replace(/([A-Z])/g, '-$1').toLowerCase() || (config.items ? '' : 'text');
|
||||
};
|
||||
|
||||
/**
|
||||
* 静态预判一份配置子树是否可能触发值初始化写入。
|
||||
*
|
||||
* 只有「确定不会」时才返回 false。函数型 `type`、`itemsFunction`、业务登记的容器遍历器、
|
||||
* 登记了 innerConfig 的复合字段,其运行期结构静态看不出来,一律按可能触发处理。
|
||||
*
|
||||
* 只用在同一份 `items` 会被逐行 / 逐标签页重复展开的地方(table、group-list、dynamic tab):
|
||||
* 预判成本是 O(子项数),跳过省下的是 O(行数 × 子项数),无 effect 的多行表格能省一个量级。
|
||||
* 其余位置不做预判——扫描与遍历的单节点成本相当,扫完再遍历只会更慢。
|
||||
*
|
||||
* 只看 `items`:table / group-list 只遍历当前形态的子项,另一形态的
|
||||
* `tableItems` / `groupItems` 不参与遍历,判定范围与遍历保持一致。
|
||||
*/
|
||||
const mayRunEffects = (config: any): boolean => {
|
||||
if (!config) return false;
|
||||
if (typeof config.type === 'function' || typeof config.itemsFunction === 'function') return true;
|
||||
|
||||
const type = staticItemType(config);
|
||||
|
||||
// walkNode 对 hidden 只收集规则,不往下分派
|
||||
if (type === 'hidden') return false;
|
||||
|
||||
if (type) {
|
||||
const key = toLine(type);
|
||||
// 业务登记的容器遍历路径未知
|
||||
if (extraContainerWalkers.has(key)) return true;
|
||||
|
||||
if (!builtInContainerWalkers.has(key)) {
|
||||
if (getFieldInnerConfig(type) || getFieldMountValueEffect(type)) return true;
|
||||
// 叶子字段没有子树,dispatchByType 到此为止
|
||||
if (isLeafFieldType(type)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return itemsMayRunEffects(config.items);
|
||||
};
|
||||
|
||||
const itemsMayRunEffects = (items: any): boolean =>
|
||||
Array.isArray(items) && items.some((item: any) => mayRunEffects(item));
|
||||
|
||||
/**
|
||||
* 复刻 `Container.vue` 的 `display`。
|
||||
*
|
||||
@ -125,12 +200,16 @@ const getItems = (config: any): FormItemConfig[] | undefined => config?.items;
|
||||
* 「是否已展开」纯粹是交互状态,不改变字段是否属于这份配置,无渲染校验按配置声明的范围校验。
|
||||
*/
|
||||
const resolveDisplay = (ctx: WalkContext, config: any, nodeProps: any): boolean => {
|
||||
if (ignoresDisplay(ctx)) return true;
|
||||
|
||||
const value = displayFunction(ctx.mForm, config?.display, nodeProps);
|
||||
if (value === 'expand') return true;
|
||||
return Boolean(value);
|
||||
};
|
||||
|
||||
const addField = (ctx: WalkContext, node: WalkNode, itemProp: string, nodeProps: any): void => {
|
||||
if (ctx.mode !== 'collect') return;
|
||||
|
||||
const rules = getNativeRules(ctx.mForm, (node.config as any).rules, nodeProps, ctx.typeMatchValid) as Rule[];
|
||||
if (!rules.length) return;
|
||||
|
||||
@ -165,13 +244,18 @@ export const expandTab = (ctx: WalkContext, node: WalkNode, itemProp: string): v
|
||||
if ((config as any).dynamic) {
|
||||
if (!name) return;
|
||||
const tabs = model?.[name] || [];
|
||||
if (!tabs.length) return;
|
||||
// 每个标签页展开同一份 items,逐页展开前先按 items 预判一次
|
||||
if (ctx.mode === 'effects' && !itemsMayRunEffects(items)) return;
|
||||
tabs.forEach((_tab: any, tabIndex: number) => {
|
||||
walkChildren(ctx, items, childModel?.[tabIndex], appendProp(itemProp, tabIndex));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tabs = (items || []).filter((item: any) => displayFunction(ctx.mForm, item?.display, tabsProps));
|
||||
const tabs = ignoresDisplay(ctx)
|
||||
? items || []
|
||||
: (items || []).filter((item: any) => displayFunction(ctx.mForm, item?.display, tabsProps));
|
||||
for (const tab of tabs) {
|
||||
const tabName = (tab as any).name;
|
||||
// tab.lazy 只影响渲染时机,不影响该标签页是否属于这份配置,无渲染校验一律遍历
|
||||
@ -199,7 +283,7 @@ export const expandFieldset = (ctx: WalkContext, node: WalkNode, itemProp: strin
|
||||
const checkboxTrueValue =
|
||||
typeof checkbox === 'object' && typeof checkbox.trueValue !== 'undefined' ? checkbox.trueValue : 1;
|
||||
// 勾选框关闭时整个 fieldset 的子项不渲染,语义上等于「该段配置未启用」,不参与校验
|
||||
if ((config as any).expand && childModel?.[checkboxName] !== checkboxTrueValue) return;
|
||||
if (!ignoresDisplay(ctx) && (config as any).expand && childModel?.[checkboxName] !== checkboxTrueValue) return;
|
||||
walkChildren(ctx, items, childModel, itemProp);
|
||||
};
|
||||
|
||||
@ -235,11 +319,14 @@ export const expandTableGroupList = (ctx: WalkContext, node: WalkNode, itemProp:
|
||||
const { config, model } = node;
|
||||
const name = (config as any).name || '';
|
||||
const rows = model?.[name];
|
||||
if (!Array.isArray(rows)) return;
|
||||
if (!Array.isArray(rows) || !rows.length) return;
|
||||
|
||||
if (isGroupListType((config as any).type)) {
|
||||
const groupListConfig = toGroupListConfig(config as any);
|
||||
|
||||
// 行数是配置项数的倍数,逐行展开前先按列配置预判一次,避免整表白跑
|
||||
if (ctx.mode === 'effects' && !itemsMayRunEffects(groupListConfig.items)) return;
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
walkNode(ctx, {
|
||||
config: getGroupListRowConfig(groupListConfig, index, ctx.mForm?.keyProp) as FormItemConfig,
|
||||
@ -253,13 +340,16 @@ export const expandTableGroupList = (ctx: WalkContext, node: WalkNode, itemProp:
|
||||
const tableItems = toTableConfig(config as any).items;
|
||||
if (!Array.isArray(tableItems)) return;
|
||||
|
||||
if (ctx.mode === 'effects' && !itemsMayRunEffects(tableItems)) return;
|
||||
|
||||
// 列的 display 在 Table 层用「表格自身的 props」求值,随后 makeTableColumnConfig 会删掉 display
|
||||
const tableProps = { model, config, prop: itemProp };
|
||||
const evalDisplay = (display: any) => displayFunction(ctx.mForm, display, tableProps);
|
||||
const isRendered = (column: any) => ignoresDisplay(ctx) || isTableColumnRendered(column, evalDisplay);
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
for (const column of tableItems) {
|
||||
if (!column || !isTableColumnRendered(column, evalDisplay)) continue;
|
||||
if (!column || !isRendered(column)) continue;
|
||||
|
||||
walkNode(ctx, {
|
||||
config: makeTableColumnConfig(column, row) as FormItemConfig,
|
||||
@ -270,9 +360,30 @@ export const expandTableGroupList = (ctx: WalkContext, node: WalkNode, itemProp:
|
||||
});
|
||||
};
|
||||
|
||||
/** 遍历已登记嵌套配置的复合字段 */
|
||||
const walkNestedConfig = (ctx: WalkContext, type: string, node: WalkNode, itemProp: string): boolean => {
|
||||
const resolve = getFieldNestedConfig(type);
|
||||
/**
|
||||
* 执行某个叶子字段登记的值初始化写入。
|
||||
*
|
||||
* 跑在表单初始化路径上,单个字段的 effect 抛错不应该让整张表单渲染不出来,因此只记录并继续。
|
||||
*/
|
||||
const runMountValueEffect = (ctx: WalkContext, type: string, node: WalkNode, itemProp: string): void => {
|
||||
const effect = getFieldMountValueEffect(type);
|
||||
if (!effect) return;
|
||||
|
||||
try {
|
||||
effect({ config: node.config, model: node.model, prop: itemProp, mForm: ctx.mForm, values: ctx.values });
|
||||
} catch (e) {
|
||||
console.error(`[MForm] mount value effect for "${type}" at "${itemProp}" failed:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 遍历已登记 innerConfig 的复合字段。
|
||||
*
|
||||
* `collect` 模式下回调抛错视为机制故障,包装成 `FieldInnerConfigError` 抛出,由校验流程暴露。
|
||||
* `effects` 模式跑在表单初始化路径上,抛出会让整张表单渲染不出来,因此只记录并跳过该子树。
|
||||
*/
|
||||
const walkInnerConfig = (ctx: WalkContext, type: string, node: WalkNode, itemProp: string): boolean => {
|
||||
const resolve = getFieldInnerConfig(type);
|
||||
if (!resolve) return false;
|
||||
|
||||
let result;
|
||||
@ -285,15 +396,19 @@ const walkNestedConfig = (ctx: WalkContext, type: string, node: WalkNode, itemPr
|
||||
mForm: ctx.mForm,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new FieldNestedConfigError(type, itemProp, e);
|
||||
if (ctx.mode === 'collect') {
|
||||
throw new FieldInnerConfigError(type, itemProp, e);
|
||||
}
|
||||
console.error(new FieldInnerConfigError(type, itemProp, e));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!result) return true;
|
||||
|
||||
const nestedModel = result.model ?? node.model;
|
||||
const nestedProp = result.prop ?? itemProp;
|
||||
const nestedConfig = Array.isArray(result.config) ? result.config : [result.config];
|
||||
walkChildren(ctx, nestedConfig, nestedModel, nestedProp);
|
||||
const innerModel = result.model ?? node.model;
|
||||
const innerProp = result.prop ?? itemProp;
|
||||
const innerConfig = Array.isArray(result.config) ? result.config : [result.config];
|
||||
walkChildren(ctx, innerConfig, innerModel, innerProp);
|
||||
return true;
|
||||
};
|
||||
|
||||
@ -346,8 +461,9 @@ const walkNode = (ctx: WalkContext, node: WalkNode): void => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 按 type 分派:已登记 walk 的容器 → 按容器模板遍历;登记了嵌套配置的复合字段 → 遍历其内部配置;
|
||||
* 叶子字段 → 无子树(先复刻其挂载副作用,若登记了的话)。
|
||||
* 按 type 分派:已登记 walk 的容器 → 按容器模板遍历;
|
||||
* `effects` 模式下先执行本字段的 effect(可与 innerConfig 并存,如 `code-select` 先归一化再下钻);
|
||||
* 登记了 innerConfig 的复合字段 → 遍历其内部配置;叶子字段无子树。
|
||||
*
|
||||
* 未登记的 type:配置里若有 `items` 则按普通容器下钻;否则视为没有嵌套表单项。
|
||||
* 自身 rules 已在 FormItem 分支收集,有 rules 就会校验。
|
||||
@ -359,10 +475,13 @@ const dispatchByType = (ctx: WalkContext, type: string, node: WalkNode, itemProp
|
||||
return;
|
||||
}
|
||||
|
||||
if (walkNestedConfig(ctx, type, node, itemProp)) return;
|
||||
if (ctx.mode === 'effects') {
|
||||
runMountValueEffect(ctx, type, node, itemProp);
|
||||
}
|
||||
|
||||
if (walkInnerConfig(ctx, type, node, itemProp)) return;
|
||||
|
||||
if (isLeafFieldType(type)) {
|
||||
getFieldMountValueEffect(type)?.({ config: node.config, model: node.model, prop: itemProp, mForm: ctx.mForm });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -379,25 +498,28 @@ const dispatchByType = (ctx: WalkContext, type: string, node: WalkNode, itemProp
|
||||
* 遍历规则与 `Container.vue` 及各容器组件的模板一一对应,因此产出的 prop / rules
|
||||
* 与渲染式校验(挂载 MForm 后调用 `validate()`)等价,无需任何 DOM 或组件实例。
|
||||
*
|
||||
* 注意:`values` 会被就地修改(复刻少数字段组件挂载时的值初始化副作用),
|
||||
* 调用方若不希望污染原对象应先自行拷贝。
|
||||
* 本函数只读不写。字段的值初始化副作用由 `applyMountValueEffects` 负责,
|
||||
* 调用方需在初始化表单值之后、收集字段之前执行一次(`validateValues` 已经这么做)。
|
||||
*
|
||||
* @param mForm - 表单状态;无渲染时由 `createHeadlessFormState` 构造
|
||||
* @param config - 表单配置
|
||||
* @param values - 表单值(会被就地修改)
|
||||
* @param [typeMatchValid] - 是否启用 typeMatch 校验
|
||||
* @param values - 表单值
|
||||
* @param [typeMatchValid] - 是否自动注入 typeMatch 规则;为 true 时,未显式声明
|
||||
* `typeMatch` 的字段会补上 `{ typeMatch: true }`,按字段 type 校验值形态
|
||||
* @returns 带规则的字段列表
|
||||
*/
|
||||
export const collectValidatableFields = (
|
||||
mForm: FormState | undefined,
|
||||
config: FormConfig,
|
||||
values: FormValue,
|
||||
typeMatchValid?: ComputedRef<boolean>,
|
||||
typeMatchValid?: boolean,
|
||||
): CollectedField[] => {
|
||||
const ctx: WalkContext = {
|
||||
mForm,
|
||||
typeMatchValid,
|
||||
fields: [],
|
||||
values,
|
||||
mode: 'collect',
|
||||
};
|
||||
|
||||
if (Array.isArray(config)) {
|
||||
@ -406,3 +528,34 @@ export const collectValidatableFields = (
|
||||
|
||||
return ctx.fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行「一份 config + 一份 values」中所有叶子字段登记的值初始化写入,就地改写 `values`。
|
||||
*
|
||||
* 渲染与无渲染两条链路共用的唯一执行点:字段组件自身不再在 setup 里改写 model,
|
||||
* 由持有完整表单值的一方(`Form.vue` / `validateValues`)在表单值初始化完成、
|
||||
* 且已挂到 `mForm.values` 之后调用一次。这样 effect 与动态 `type` 回调读到的
|
||||
* `formValue` 就是最新值,而不是上一轮的旧值。
|
||||
*
|
||||
* 与 `collectValidatableFields` 的差异:不看 `display`(`display: false` 的字段也会规整)。
|
||||
* `type: 'hidden'` 会在该节点停止遍历,内部字段不执行 effect。
|
||||
*
|
||||
* `prop` 与 `values` 都以传入的 `values` 为根,因此也可以用于 tab / table 新增行这类
|
||||
* 「先构造一份子树的值,再挂到表单上」的场景:传入子树配置与该行的值即可。
|
||||
*
|
||||
* @param mForm - 表单状态;无渲染时由 `createHeadlessFormState` 构造
|
||||
* @param config - 表单配置
|
||||
* @param values - 表单值(会被就地修改)
|
||||
*/
|
||||
export const applyMountValueEffects = (mForm: FormState | undefined, config: FormConfig, values: FormValue): void => {
|
||||
if (!Array.isArray(config)) return;
|
||||
|
||||
const ctx: WalkContext = {
|
||||
mForm,
|
||||
fields: [],
|
||||
values,
|
||||
mode: 'effects',
|
||||
};
|
||||
|
||||
walkChildren(ctx, config as FormItemConfig[], values, '');
|
||||
};
|
||||
|
||||
@ -20,9 +20,9 @@ import { toLine } from '@tmagic/utils';
|
||||
|
||||
import type { FormItemConfig, FormState, FormValue } from '../schema';
|
||||
|
||||
// #region FieldNestedConfig
|
||||
/** 嵌套配置回调的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */
|
||||
export interface FieldNestedConfigContext {
|
||||
// #region FieldInnerConfig
|
||||
/** innerConfig 回调的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */
|
||||
export interface FieldInnerConfigContext {
|
||||
/** 字段自身的配置(已经过 filterFunction 之外的原样配置) */
|
||||
config: FormItemConfig;
|
||||
/** 字段所在层级的 model 切片 */
|
||||
@ -35,20 +35,20 @@ export interface FieldNestedConfigContext {
|
||||
mForm: FormState | undefined;
|
||||
}
|
||||
|
||||
/** 嵌套配置回调的返回值:需要继续遍历的嵌套配置及其 model / prop 基准 */
|
||||
export interface FieldNestedConfigResult {
|
||||
/** 嵌套配置(对应字段组件内部渲染的 `MContainer` 的 `config`) */
|
||||
/** innerConfig 回调的返回值:需要继续遍历的内部配置及其 model / prop 基准 */
|
||||
export interface FieldInnerConfigResult {
|
||||
/** 内部配置(对应字段组件内部渲染的 `MContainer` 的 `config`) */
|
||||
config: FormItemConfig | FormItemConfig[];
|
||||
/**
|
||||
* 嵌套配置对应的 model 切片,默认沿用字段所在层级的 `model`。
|
||||
* 内部配置对应的 model 切片,默认沿用字段所在层级的 `model`。
|
||||
*
|
||||
* 例如 `code-select` 内部是 `:model="model[name]"`,就应返回 `model[config.name]`。
|
||||
*/
|
||||
model?: FormValue;
|
||||
/**
|
||||
* 嵌套配置对应的 prop 基准,默认沿用字段自身的 `prop`。
|
||||
* 内部配置对应的 prop 基准,默认沿用字段自身的 `prop`。
|
||||
*
|
||||
* 返回的 config 的 `name` 会被追加到这个基准上。所以当嵌套配置复用了字段自身的 `name`
|
||||
* 返回的 config 的 `name` 会被追加到这个基准上。所以当内部配置复用了字段自身的 `name`
|
||||
* (如 `display-conds` 内部的 group-list 就叫 `props.name`)时,要返回 `parentProp`,
|
||||
* 否则 name 会被拼两次。
|
||||
*/
|
||||
@ -56,72 +56,72 @@ export interface FieldNestedConfigResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 复合字段的嵌套配置回调。
|
||||
* 复合字段的内部配置回调。
|
||||
*
|
||||
* 有一类字段组件会在自身内部再渲染 `MContainer` 并传入组件内部临时算出来的 config
|
||||
* (如编辑器的 `code-select` / `event-select` / `style-setter`),这些嵌套字段同样会向
|
||||
* (如编辑器的 `code-select` / `event-select` / `style-setter`),这些内部字段同样会向
|
||||
* 父级表单注册 FormItem、参与父表单校验。无渲染校验(`validateValues`)只遍历调用方
|
||||
* 传入的 config 树,看不到这些运行期才产生的配置,因此需要字段作者通过
|
||||
* `registerField(type, { nested })` 把内部配置交出来。
|
||||
* `registerField(type, { innerConfig })` 把内部配置交出来。
|
||||
*
|
||||
* 返回 `null` / `undefined` 表示该字段本次没有嵌套配置(例如某些分支下不渲染子表单)。
|
||||
* 返回 `null` / `undefined` 表示该字段本次没有内部配置(例如某些分支下不渲染子表单)。
|
||||
*
|
||||
* 既未登记嵌套配置、也不在叶子字段表里的 type:配置里有 `items` 会下钻子项,
|
||||
* 既未登记 innerConfig、也不在叶子字段表里的 type:配置里有 `items` 会下钻子项,
|
||||
* 自身有 `rules` 会校验自身。
|
||||
*/
|
||||
export type FieldNestedConfig = (_ctx: FieldNestedConfigContext) => FieldNestedConfigResult | null | undefined | void;
|
||||
// #endregion FieldNestedConfig
|
||||
export type FieldInnerConfig = (_ctx: FieldInnerConfigContext) => FieldInnerConfigResult | null | undefined | void;
|
||||
// #endregion FieldInnerConfig
|
||||
|
||||
/** 内置嵌套配置(由 `registerBuiltInFields` 写入;`clearFields` 不会清掉) */
|
||||
const builtInNestedConfigs = new Map<string, FieldNestedConfig>();
|
||||
/** 业务侧登记的嵌套配置 */
|
||||
const extraNestedConfigs = new Map<string, FieldNestedConfig>();
|
||||
/** 内置内部配置(由 `registerBuiltInFields` 写入;`clearFields` 不会清掉) */
|
||||
const builtInInnerConfigs = new Map<string, FieldInnerConfig>();
|
||||
/** 业务侧登记的内部配置 */
|
||||
const extraInnerConfigs = new Map<string, FieldInnerConfig>();
|
||||
|
||||
/**
|
||||
* 登记复合字段的嵌套配置:无渲染校验遇到该 type 时,用返回值继续遍历内部字段。
|
||||
* 登记复合字段的内部配置:无渲染校验遇到该 type 时,用返回值继续遍历内部字段。
|
||||
*
|
||||
* `type` 会按 Container 的规则归一化为中划线形式(`codeSelect` 与 `code-select` 等价)。
|
||||
* 重复登记以最后一次为准,便于业务侧覆盖内置实现。
|
||||
* `builtIn` 登记不受 `deleteFieldNestedConfig` / `clearFieldNestedConfigs` 影响。
|
||||
* `builtIn` 登记不受 `deleteFieldInnerConfig` / `clearFieldInnerConfigs` 影响。
|
||||
*
|
||||
* @param type - 字段 type
|
||||
* @param resolve - 嵌套配置回调
|
||||
* @param resolve - innerConfig 回调
|
||||
* @param [builtIn=false] - 是否写入内置表
|
||||
*/
|
||||
export const registerFieldNestedConfig = (type: string, resolve: FieldNestedConfig, builtIn = false): void => {
|
||||
export const registerFieldInnerConfig = (type: string, resolve: FieldInnerConfig, builtIn = false): void => {
|
||||
if (typeof type !== 'string' || !type || typeof resolve !== 'function') return;
|
||||
(builtIn ? builtInNestedConfigs : extraNestedConfigs).set(toLine(type), resolve);
|
||||
(builtIn ? builtInInnerConfigs : extraInnerConfigs).set(toLine(type), resolve);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定 type 的嵌套配置回调(业务侧优先于内置)。
|
||||
* 获取指定 type 的 innerConfig 回调(业务侧优先于内置)。
|
||||
*
|
||||
* @param type - 字段 type
|
||||
* @returns 嵌套配置回调;未登记则为 `undefined`
|
||||
* @returns innerConfig 回调;未登记则为 `undefined`
|
||||
*/
|
||||
export const getFieldNestedConfig = (type: string): FieldNestedConfig | undefined => {
|
||||
export const getFieldInnerConfig = (type: string): FieldInnerConfig | undefined => {
|
||||
const key = toLine(type);
|
||||
return extraNestedConfigs.get(key) ?? builtInNestedConfigs.get(key);
|
||||
return extraInnerConfigs.get(key) ?? builtInInnerConfigs.get(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* 是否已登记指定 type 的嵌套配置(内置 ∪ 已登记)。
|
||||
* 是否已登记指定 type 的 innerConfig(内置 ∪ 已登记)。
|
||||
*
|
||||
* @param type - 字段 type
|
||||
* @returns 是否已登记
|
||||
*/
|
||||
export const hasFieldNestedConfig = (type: string): boolean => {
|
||||
export const hasFieldInnerConfig = (type: string): boolean => {
|
||||
const key = toLine(type);
|
||||
return extraNestedConfigs.has(key) || builtInNestedConfigs.has(key);
|
||||
return extraInnerConfigs.has(key) || builtInInnerConfigs.has(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除业务侧登记的嵌套配置(不影响内置)。
|
||||
* 删除业务侧登记的 innerConfig(不影响内置)。
|
||||
*
|
||||
* @param type - 字段 type
|
||||
* @returns 是否删除成功
|
||||
*/
|
||||
export const deleteFieldNestedConfig = (type: string): boolean => extraNestedConfigs.delete(toLine(type));
|
||||
export const deleteFieldInnerConfig = (type: string): boolean => extraInnerConfigs.delete(toLine(type));
|
||||
|
||||
/** 清空业务侧登记的嵌套配置(不影响内置;主要用于单测)。 */
|
||||
export const clearFieldNestedConfigs = (): void => extraNestedConfigs.clear();
|
||||
/** 清空业务侧登记的 innerConfig(不影响内置;主要用于单测)。 */
|
||||
export const clearFieldInnerConfigs = (): void => extraInnerConfigs.clear();
|
||||
@ -17,36 +17,27 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview 叶子字段登记表:哪些 type 是叶子,以及挂载阶段对 model 的写入。
|
||||
* @fileoverview 叶子字段登记表:哪些 type 是叶子,以及该字段对表单值的初始化写入。
|
||||
*
|
||||
* 无渲染校验据此判定该 type 没有属于父表单的嵌套字段,并复刻组件 setup 阶段的写入。
|
||||
* 无渲染校验据此判定该 type 没有属于父表单的嵌套字段。
|
||||
* 业务自定义字段通过 `registerField` / `registerFields` 登记。
|
||||
* 内置叶子字段由 `MagicForm.install` 的 `registerBuiltInFields` 写入。
|
||||
* 未登记且没有嵌套配置的 type:有 `items` 会下钻子项,自身有 `rules` 会校验自身。
|
||||
* 未登记且没有 innerConfig 的 type:有 `items` 会下钻子项,自身有 `rules` 会校验自身。
|
||||
*
|
||||
* 这里只是登记表。各字段的 effect 写在对应组件同目录的 `fields/<Field>/effect.ts`,
|
||||
* 执行收口在 `collectFields` 的 `applyMountValueEffects`:渲染(`Form.vue`)
|
||||
* 与无渲染(`validateValues`)都在表单值初始化完成后调用它一次,
|
||||
* 字段组件自身不再在 setup 里改写 model。
|
||||
*
|
||||
* @module fieldValueEffects
|
||||
*/
|
||||
|
||||
import { setValueByKeyPath, toLine } from '@tmagic/utils';
|
||||
import { toLine } from '@tmagic/utils';
|
||||
|
||||
import type {
|
||||
DateConfig,
|
||||
DateTimeConfig,
|
||||
DisplayConfig,
|
||||
DynamicFieldConfig,
|
||||
FormItemConfig,
|
||||
FormState,
|
||||
FormValue,
|
||||
} from '../schema';
|
||||
|
||||
import { getConfig } from './config';
|
||||
import { datetimeFormatter } from './form';
|
||||
|
||||
/** `dynamic-field` 的 `returnFields` 返回的单个字段描述 */
|
||||
type DynamicFieldItem = ReturnType<DynamicFieldConfig['returnFields']>[number];
|
||||
import type { FormItemConfig, FormState, FormValue } from '../schema';
|
||||
|
||||
// #region FieldMountValueEffect
|
||||
/** mount effect 的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */
|
||||
/** effect 的入参:字段自身的配置、所在层级的 model、完整字段路径、表单值根对象与表单状态 */
|
||||
export interface FieldMountValueEffectContext {
|
||||
/** 字段自身的配置 */
|
||||
config: FormItemConfig;
|
||||
@ -54,164 +45,26 @@ export interface FieldMountValueEffectContext {
|
||||
model: FormValue;
|
||||
/** 字段的完整 prop 路径(含父级前缀),对应 Container 的 `itemProp` */
|
||||
prop: string;
|
||||
/**
|
||||
* 本次处理的表单值根对象,`prop` 即以它为根。
|
||||
*
|
||||
* 需要按路径跨层级写值时用它,不要用 `mForm.values`:对比模式下处理的是 lastValues 那一份,
|
||||
* tab / table 新增行处理的则是还没挂到表单上的一行值,两者都与 `mForm.values` 不是同一个对象。
|
||||
*/
|
||||
values: FormValue;
|
||||
/** 表单状态 */
|
||||
mForm: FormState | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段挂载时改写 model 的副作用。
|
||||
* 字段对表单值的初始化写入(如 `display` 的 `initValue`、`date` 的格式归一化)。
|
||||
*
|
||||
* 无渲染校验遇到登记了 effect 的 type 时会调用它,以复刻组件 setup 阶段的写入。
|
||||
* 由 `applyMountValueEffects` 在表单值初始化完成后统一执行一次,渲染与无渲染共用。
|
||||
* 因为可能对同一份值重复执行(如 `initValues` 变化后重新初始化),实现必须幂等。
|
||||
*/
|
||||
export type FieldMountValueEffect = (_ctx: FieldMountValueEffectContext) => void;
|
||||
// #endregion FieldMountValueEffect
|
||||
|
||||
/**
|
||||
* `fields/Display.vue`:把 `initValue` 写入 model。
|
||||
*
|
||||
* @param config - 含 `initValue` 的字段配置
|
||||
* @param model - 所在层级的 model 切片
|
||||
* @param name - 字段 name
|
||||
*/
|
||||
export const applyDisplayInitValue = (
|
||||
config: Pick<DisplayConfig, 'initValue'>,
|
||||
model: FormValue | undefined,
|
||||
name: string,
|
||||
): void => {
|
||||
if (config.initValue && model) {
|
||||
model[name] = config.initValue;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `fields/NumberRange.vue`:值不是数组时修正为空数组。
|
||||
*
|
||||
* @param model - 所在层级的 model 切片
|
||||
* @param name - 字段 name
|
||||
*/
|
||||
export const normalizeNumberRangeValue = (model: FormValue | undefined, name: string): void => {
|
||||
if (model && !Array.isArray(model[name])) {
|
||||
model[name] = [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `fields/CheckboxGroup.vue`:空值初始化为空数组。
|
||||
*
|
||||
* @param model - 所在层级的 model 切片
|
||||
* @param name - 字段 name
|
||||
*/
|
||||
export const initCheckboxGroupValue = (model: FormValue | undefined, name: string): void => {
|
||||
if (model && !model[name]) {
|
||||
model[name] = [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `fields/Date.vue`:按 `valueFormat` 归一化日期值。
|
||||
*
|
||||
* @param config - 含 `valueFormat` 的字段配置
|
||||
* @param model - 所在层级的 model 切片
|
||||
* @param name - 字段 name
|
||||
*/
|
||||
export const normalizeDateValue = (
|
||||
config: Pick<DateConfig, 'valueFormat'>,
|
||||
model: FormValue | undefined,
|
||||
name: string,
|
||||
): void => {
|
||||
if (!model) return;
|
||||
|
||||
model[name] = datetimeFormatter(model[name], '', config.valueFormat || 'YYYY/MM/DD');
|
||||
};
|
||||
|
||||
/**
|
||||
* `fields/DateTime.vue`:按 `valueFormat` 归一化日期时间值,空值与非法值统一为空字符串。
|
||||
*
|
||||
* @param config - 含 `valueFormat` 的字段配置
|
||||
* @param model - 所在层级的 model 切片
|
||||
* @param name - 字段 name
|
||||
*/
|
||||
export const normalizeDateTimeValue = (
|
||||
config: Pick<DateTimeConfig, 'valueFormat'>,
|
||||
model: FormValue | undefined,
|
||||
name: string,
|
||||
): void => {
|
||||
if (!model) return;
|
||||
|
||||
const value = model[name]?.toString();
|
||||
|
||||
if (!value || value === 'Invalid Date') {
|
||||
model[name] = '';
|
||||
return;
|
||||
}
|
||||
|
||||
model[name] = datetimeFormatter(model[name], '', config.valueFormat || 'YYYY/MM/DD HH:mm:ss');
|
||||
};
|
||||
|
||||
/**
|
||||
* `fields/DynamicField.vue`:遍历动态字段列表,按「原值为空且声明了 defaultValue 则取默认值」
|
||||
* 求出每个字段当前的值。
|
||||
*
|
||||
* `isDefaultApplied` 为真表示该值来自 `defaultValue`,需要写回表单值
|
||||
* (组件通过 emit change 写回,无渲染校验直接写 model)。
|
||||
*
|
||||
* @param fields - `returnFields` 返回的字段描述列表
|
||||
* @param model - 所在层级的 model 切片
|
||||
* @param onField - 每个字段的回调
|
||||
*/
|
||||
export const eachDynamicField = (
|
||||
fields: DynamicFieldItem[],
|
||||
model: FormValue | undefined,
|
||||
onField: (_field: DynamicFieldItem, _value: any, _isDefaultApplied: boolean) => void,
|
||||
): void => {
|
||||
for (const field of fields) {
|
||||
if (typeof field !== 'object' || field?.name === undefined) continue;
|
||||
|
||||
let value = model?.[field.name] || '';
|
||||
let isDefaultApplied = false;
|
||||
|
||||
if (!value && field.defaultValue !== undefined) {
|
||||
value = field.defaultValue;
|
||||
isDefaultApplied = true;
|
||||
}
|
||||
|
||||
onField(field, value, isDefaultApplied);
|
||||
}
|
||||
};
|
||||
|
||||
export const displayEffect: FieldMountValueEffect = ({ config, model }) =>
|
||||
applyDisplayInitValue(config as DisplayConfig, model, (config as any).name);
|
||||
|
||||
export const numberRangeEffect: FieldMountValueEffect = ({ config, model }) =>
|
||||
normalizeNumberRangeValue(model, (config as any).name);
|
||||
|
||||
export const checkboxGroupEffect: FieldMountValueEffect = ({ config, model }) =>
|
||||
initCheckboxGroupValue(model, (config as any).name);
|
||||
|
||||
export const dateEffect: FieldMountValueEffect = ({ config, model }) =>
|
||||
normalizeDateValue(config as DateConfig, model, (config as any).name);
|
||||
|
||||
export const dateTimeEffect: FieldMountValueEffect = ({ config, model }) =>
|
||||
normalizeDateTimeValue(config as DateTimeConfig, model, (config as any).name);
|
||||
|
||||
export const dynamicFieldEffect: FieldMountValueEffect = ({ config, model, prop, mForm }) => {
|
||||
// 该组件读取的是同级 model,但写入走 Container 的 modifyKey 分支,落在 `${prop}.${key}`,
|
||||
// 这里保持与渲染一致(含这层不对称),避免两条链路产出不同的值。
|
||||
const { returnFields, dynamicKey } = config as DynamicFieldConfig;
|
||||
if (typeof returnFields !== 'function' || !model) return;
|
||||
if (model[dynamicKey] === '') return;
|
||||
|
||||
const result = returnFields(config as DynamicFieldConfig, model, getConfig<Function>('request'));
|
||||
// 同步返回才能在校验前生效;异步 returnFields 与渲染式校验一样存在时序不确定性,此处不等待
|
||||
if (!result || typeof (result as any).then === 'function' || !Array.isArray(result)) return;
|
||||
|
||||
eachDynamicField(result, model, (field, value, isDefaultApplied) => {
|
||||
if (isDefaultApplied) {
|
||||
setValueByKeyPath(`${prop}.${field.name}`, value, mForm?.values || model);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** 内置叶子字段(由 `MagicForm.install` 写入;clearFields 不会清掉) */
|
||||
const builtInLeafFieldTypes = new Set<string>();
|
||||
const builtInMountValueEffects = new Map<string, FieldMountValueEffect>();
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ComputedRef, readonly } from 'vue';
|
||||
import { type MaybeRef, readonly, unref } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
// dayjs 没有 exports 映射,原生 Node ESM 不会补扩展名,深路径必须写全 .js
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
@ -401,7 +401,7 @@ const buildRules = function (
|
||||
mForm: FormState | undefined,
|
||||
r: Rule[] | Rule = [],
|
||||
props: any,
|
||||
typeMatchValid: ComputedRef<boolean> | undefined,
|
||||
typeMatchValid?: MaybeRef<boolean>,
|
||||
adapt: (_validator: AsyncValidatorFn) => AsyncValidatorFn = (validator) => validator,
|
||||
) {
|
||||
let rules = cloneDeep(r);
|
||||
@ -410,7 +410,7 @@ const buildRules = function (
|
||||
rules = [rules];
|
||||
}
|
||||
|
||||
if (typeMatchValid?.value && !rules.some((r) => typeof r.typeMatch !== 'undefined')) {
|
||||
if (unref(typeMatchValid) && !rules.some((r) => typeof r.typeMatch !== 'undefined')) {
|
||||
rules.push({
|
||||
typeMatch: true,
|
||||
});
|
||||
@ -463,7 +463,7 @@ export const getRules = function (
|
||||
mForm: FormState | undefined,
|
||||
r: Rule[] | Rule = [],
|
||||
props: any,
|
||||
typeMatchValid?: ComputedRef<boolean>,
|
||||
typeMatchValid?: MaybeRef<boolean>,
|
||||
) {
|
||||
return buildRules(mForm, r, props, typeMatchValid, adaptFormValidator);
|
||||
};
|
||||
@ -478,7 +478,7 @@ export const getNativeRules = function (
|
||||
mForm: FormState | undefined,
|
||||
r: Rule[] | Rule = [],
|
||||
props: any,
|
||||
typeMatchValid?: ComputedRef<boolean>,
|
||||
typeMatchValid?: MaybeRef<boolean>,
|
||||
) {
|
||||
return buildRules(mForm, r, props, typeMatchValid);
|
||||
};
|
||||
|
||||
@ -27,11 +27,11 @@ import {
|
||||
registerContainerWalker,
|
||||
} from './collectFields';
|
||||
import {
|
||||
clearFieldNestedConfigs,
|
||||
deleteFieldNestedConfig,
|
||||
type FieldNestedConfig,
|
||||
registerFieldNestedConfig,
|
||||
} from './fieldNestedConfig';
|
||||
clearFieldInnerConfigs,
|
||||
deleteFieldInnerConfig,
|
||||
type FieldInnerConfig,
|
||||
registerFieldInnerConfig,
|
||||
} from './fieldInnerConfig';
|
||||
import {
|
||||
clearLeafFieldTypes,
|
||||
deleteLeafFieldType,
|
||||
@ -57,19 +57,19 @@ export interface FieldOptions {
|
||||
* 传入 `app` 时同时 `app.component('m-form-*')`。
|
||||
*/
|
||||
container?: Component;
|
||||
/** 叶子字段挂载时改写 model 的副作用。 */
|
||||
/** 字段挂载时改写 model 的副作用。可与 `innerConfig` 同时登记。 */
|
||||
effect?: FieldMountValueEffect;
|
||||
/**
|
||||
* 按容器模板遍历(tab / table 等)。
|
||||
* 与 `nested` / `effect` 同时传入时 `walk` 优先。
|
||||
* 与 `innerConfig` / `effect` 同时传入时 `walk` 优先。
|
||||
*/
|
||||
walk?: ContainerWalker;
|
||||
/**
|
||||
* 把内部会挂到父表单的配置交出来。
|
||||
* 与 `effect` 同时传入时 `effect` 被忽略。
|
||||
* 可与 `effect` 同时传入:`effect` 负责本字段的值初始化,`innerConfig` 只做配置派生。
|
||||
*/
|
||||
nested?: FieldNestedConfig;
|
||||
/** 该 type 的 typeMatch 校验;可与叶子、walk 或 nested 同时登记。 */
|
||||
innerConfig?: FieldInnerConfig;
|
||||
/** 该 type 的 typeMatch 校验;可与叶子、walk 或 innerConfig 同时登记。 */
|
||||
typeMatch?: TypeMatchValidator;
|
||||
}
|
||||
|
||||
@ -139,7 +139,7 @@ export const mergeFieldOptions = (
|
||||
return result;
|
||||
};
|
||||
|
||||
const FIELD_OPTION_KEYS = ['component', 'container', 'effect', 'walk', 'nested', 'typeMatch'] as const;
|
||||
const FIELD_OPTION_KEYS = ['component', 'container', 'effect', 'walk', 'innerConfig', 'typeMatch'] as const;
|
||||
|
||||
const pickDefinedFieldOptions = (options?: FieldOptions): FieldOptions => {
|
||||
if (!options) return {};
|
||||
@ -183,15 +183,10 @@ const registerFieldImpl = (type: string, options: FieldOptions | undefined, app:
|
||||
const merged: FieldOptions = { ...store.get(key), ...incoming };
|
||||
store.set(key, merged);
|
||||
|
||||
if (incoming.walk && (incoming.nested || typeof incoming.effect === 'function')) {
|
||||
if (incoming.walk && (incoming.innerConfig || typeof incoming.effect === 'function')) {
|
||||
console.warn(
|
||||
`[MForm] registerField("${key}"): walk is set together with nested/effect; ` +
|
||||
'headless validation will use walk and nested/effect will be ignored.',
|
||||
);
|
||||
} else if (incoming.nested && typeof incoming.effect === 'function') {
|
||||
console.warn(
|
||||
`[MForm] registerField("${key}"): nested and effect are both set; ` +
|
||||
'headless validation will use nested and the mount value effect will be ignored.',
|
||||
`[MForm] registerField("${key}"): walk is set together with innerConfig/effect; ` +
|
||||
'headless validation will use walk and innerConfig/effect will be ignored.',
|
||||
);
|
||||
}
|
||||
|
||||
@ -218,7 +213,7 @@ const registerFieldImpl = (type: string, options: FieldOptions | undefined, app:
|
||||
registerContainerWalker(type, merged.walk, builtIn);
|
||||
if (!builtIn) {
|
||||
deleteLeafFieldType(type);
|
||||
deleteFieldNestedConfig(type);
|
||||
deleteFieldInnerConfig(type);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -227,9 +222,13 @@ const registerFieldImpl = (type: string, options: FieldOptions | undefined, app:
|
||||
deleteContainerWalker(type);
|
||||
}
|
||||
|
||||
if (merged.nested) {
|
||||
if (!builtIn) deleteLeafFieldType(type);
|
||||
registerFieldNestedConfig(type, merged.nested, builtIn);
|
||||
if (merged.innerConfig) {
|
||||
registerFieldInnerConfig(type, merged.innerConfig, builtIn);
|
||||
if (typeof merged.effect === 'function') {
|
||||
registerLeafFieldType(type, merged.effect, builtIn);
|
||||
} else if (!builtIn) {
|
||||
deleteLeafFieldType(type);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -240,7 +239,7 @@ const registerFieldImpl = (type: string, options: FieldOptions | undefined, app:
|
||||
}
|
||||
|
||||
if (!builtIn) {
|
||||
deleteFieldNestedConfig(type);
|
||||
deleteFieldInnerConfig(type);
|
||||
}
|
||||
registerLeafFieldType(type, merged.effect, builtIn);
|
||||
};
|
||||
@ -292,7 +291,7 @@ export const registerBuiltInFields = (fields: Record<string, FieldOptions>, app?
|
||||
export const unregisterField = (type: string): void => {
|
||||
extraFieldOptions.delete(toLine(type));
|
||||
deleteLeafFieldType(type);
|
||||
deleteFieldNestedConfig(type);
|
||||
deleteFieldInnerConfig(type);
|
||||
deleteTypeMatchRule(type);
|
||||
deleteContainerWalker(type);
|
||||
removeFormComponent(type);
|
||||
@ -302,7 +301,7 @@ export const unregisterField = (type: string): void => {
|
||||
export const clearFields = (): void => {
|
||||
extraFieldOptions.clear();
|
||||
clearLeafFieldTypes();
|
||||
clearFieldNestedConfigs();
|
||||
clearFieldInnerConfigs();
|
||||
clearTypeMatchRules();
|
||||
clearContainerWalkers();
|
||||
clearFormComponents();
|
||||
|
||||
@ -16,12 +16,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { reactive } from 'vue';
|
||||
import Schema from 'async-validator';
|
||||
|
||||
import type { FormConfig, FormState, FormValue } from '../schema';
|
||||
|
||||
import { type CollectedField, collectValidatableFields } from './collectFields';
|
||||
import { applyMountValueEffects, type CollectedField, collectValidatableFields } from './collectFields';
|
||||
import { applyExtendState, createFormStateBase, initValue } from './form';
|
||||
import { formatValidateError } from './validateError';
|
||||
|
||||
@ -119,7 +119,7 @@ export interface ValidateValuesOptions extends HeadlessFormStateOptions {
|
||||
// #region ValidateValuesResult
|
||||
/** `validateValues` 结果 */
|
||||
export interface ValidateValuesResult {
|
||||
/** 经 `initValue` 初始化并复刻挂载副作用后的表单值 */
|
||||
/** 经 `initValue` 初始化并执行字段值初始化写入后的表单值 */
|
||||
values: FormValue;
|
||||
/** 汇总后的错误文案(多条以 `<br>` 拼接),校验通过为空字符串 */
|
||||
error: string;
|
||||
@ -135,8 +135,9 @@ export interface ValidateValuesResult {
|
||||
*
|
||||
* 1. 构造 headless `formState` 并合并 `extendState`;
|
||||
* 2. `initValue` 初始化表单值(默认值、嵌套结构、`onInitValue` 等);
|
||||
* 3. 遍历 config 树收集所有带规则的字段(等价于渲染出的 FormItem 集合);
|
||||
* 4. 逐字段交给 async-validator 执行,汇总错误文案。
|
||||
* 3. `applyMountValueEffects` 执行字段登记的值初始化写入(与渲染式共用同一份登记表);
|
||||
* 4. 遍历 config 树收集所有带规则的字段(等价于渲染出的 FormItem 集合);
|
||||
* 5. 逐字段交给 async-validator 执行,汇总错误文案。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
@ -165,13 +166,10 @@ export const validateValues = async (options: ValidateValuesOptions): Promise<Va
|
||||
|
||||
const values = await initValue(formState, { initValues, config });
|
||||
formState.values = values;
|
||||
// 与 Form.vue 一致:值挂到 formState 之后再执行字段的值初始化写入
|
||||
applyMountValueEffects(formState, config, values);
|
||||
|
||||
const fields = collectValidatableFields(
|
||||
formState,
|
||||
config,
|
||||
values,
|
||||
computed(() => Boolean(typeMatchValid)),
|
||||
);
|
||||
const fields = collectValidatableFields(formState, config, values, Boolean(typeMatchValid));
|
||||
|
||||
const invalidFields: Record<string, any> = {};
|
||||
for (const field of fields) {
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { builtInFields, clearFields, registerBuiltInFields, submitForm, validateForm } from '@form/headless';
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import MagicForm, { MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm } from '@form/index';
|
||||
|
||||
const mountForm = (props: Record<string, any> = {}, options: Record<string, any> = {}) =>
|
||||
mount(MForm, {
|
||||
global: {
|
||||
@ -701,6 +702,113 @@ describe('Form.vue —— config 变化', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 字段的值初始化写入(`date` 归一化、`display` 的 `initValue` 等)统一由 `applyMountValueEffects`
|
||||
* 在表单值初始化完成后执行一次,字段组件自身不再在 setup 里改写 model。
|
||||
*/
|
||||
describe('Form.vue —— 字段值初始化统一执行', () => {
|
||||
test('字段未渲染出来时值同样被规整', async () => {
|
||||
const wrapper = mountForm({
|
||||
config: [
|
||||
{
|
||||
type: 'fieldset',
|
||||
name: 'wrap',
|
||||
expand: true,
|
||||
checkbox: { name: 'value', trueValue: 1, falseValue: 0 },
|
||||
items: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }],
|
||||
},
|
||||
],
|
||||
initValues: { wrap: { value: 0, start: '2021/07/17 15:37:00' } },
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// 勾选框未勾选,内部字段没有渲染
|
||||
expect(wrapper.findComponent({ name: 'MFormDate' }).exists()).toBe(false);
|
||||
expect(wrapper.vm.values.wrap.start).toBe('2021-07-17');
|
||||
});
|
||||
|
||||
test('initValues 变化后重新初始化,值仍被规整', async () => {
|
||||
const config = [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }];
|
||||
const wrapper = mountForm({ config, initValues: { start: '2021/07/17 15:37:00' } });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.values.start).toBe('2021-07-17');
|
||||
|
||||
await wrapper.setProps({ initValues: { start: '2022/08/18 15:37:00' } });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.values.start).toBe('2022-08-18');
|
||||
});
|
||||
|
||||
test('对比模式下待对比的那份值同样被规整', async () => {
|
||||
const wrapper = mountForm({
|
||||
isCompare: true,
|
||||
config: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }],
|
||||
initValues: { start: '2021/07/17 15:37:00' },
|
||||
lastValues: { start: '2021/07/17 09:00:00' },
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.values.start).toBe('2021-07-17');
|
||||
// 两份值都归一化后才不会比出「只是格式不同」的假差异
|
||||
expect(wrapper.vm.lastValuesProcessed.start).toBe('2021-07-17');
|
||||
});
|
||||
|
||||
test('group-list 新增行的值被规整', async () => {
|
||||
const wrapper = mountForm({
|
||||
config: [
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'list',
|
||||
items: [
|
||||
{
|
||||
type: 'date',
|
||||
name: 'start',
|
||||
text: '开始',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
defaultValue: '2021/07/17 15:37:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
initValues: { list: [] },
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
|
||||
await addButton?.trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.values.list[0].start).toBe('2021-07-17');
|
||||
});
|
||||
|
||||
test('group-list 走 enum 新增时值也被规整', async () => {
|
||||
const wrapper = mountForm({
|
||||
config: [
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'list',
|
||||
enum: [{ id: 1, start: '2021/07/17 15:37:00' }],
|
||||
items: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }],
|
||||
},
|
||||
],
|
||||
initValues: { list: [] },
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
|
||||
await addButton?.trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.values.list[0].start).toBe('2021-07-17');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form.vue —— 配置变化是否触发重挂', () => {
|
||||
const makeConfig = () => [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { createForm, MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { createForm, MForm } from '@form/index';
|
||||
|
||||
describe('表单', () => {
|
||||
test('初始化', async () => {
|
||||
const initValues = {};
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MFormBox, MFormDialog, MFormDrawer } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MFormBox, MFormDialog, MFormDrawer } from '@form/index';
|
||||
|
||||
describe('FormDialog/FormDrawer/FormBox', () => {
|
||||
test('FormDialog 基础渲染', async () => {
|
||||
const wrapper = mount(MFormDialog, {
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
*/
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm } from '@form/index';
|
||||
|
||||
const mountForm = (config: any[], initValues: any = {}, extra: any = {}) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, MagicForm as any] },
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm } from '@form/index';
|
||||
|
||||
const mountForm = (config: any[], initValues: any = {}) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, MagicForm as any] },
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm } from '@form/index';
|
||||
|
||||
const mountForm = (config: any[], initValues: any = {}, props: any = {}) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, MagicForm as any] },
|
||||
|
||||
@ -5,11 +5,12 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import Table from '@form/containers/table/Table.vue';
|
||||
import MagicForm from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import Table from '@form/containers/table/Table.vue';
|
||||
import MagicForm from '@form/index';
|
||||
|
||||
// el-table 在 happy-dom 下的 MutationObserver 会报错,这里直接 stub 掉表格本体;
|
||||
// 导入 / 清空 / 新增按钮的显隐只取决于 importable & isCompare,与表格渲染无关。
|
||||
const mountTable = (props: any) =>
|
||||
|
||||
@ -17,9 +17,10 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { FormConfig, MForm, MTabs } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import ElementPlus, { ElTabs } from 'element-plus';
|
||||
|
||||
import MagicForm, { FormConfig, MForm, MTabs } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: FormConfig = [
|
||||
@ -83,4 +84,68 @@ describe('Tabs', () => {
|
||||
const item = wrapper.findAllComponents({ name: 'TMFormItem' }).find((w) => w.props('prop') === 'text');
|
||||
expect(item?.props('labelPosition')).toBe('left');
|
||||
});
|
||||
|
||||
test('dynamic 新增标签页的值被规整', async () => {
|
||||
const wrapper = getWrapper(
|
||||
[
|
||||
{
|
||||
type: 'tab',
|
||||
name: 'tabs',
|
||||
dynamic: true,
|
||||
editable: true,
|
||||
items: [
|
||||
{
|
||||
type: 'date',
|
||||
name: 'start',
|
||||
text: '开始',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
defaultValue: '2021/07/17 15:37:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any,
|
||||
{ tabs: [] },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
wrapper.findComponent(ElTabs).vm.$emit('tabAdd');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect((wrapper.vm as any).values.tabs[0].start).toBe('2021-07-17');
|
||||
});
|
||||
|
||||
test('自定义 onTabAdd 之后新增页的值也被规整', async () => {
|
||||
const wrapper = getWrapper(
|
||||
[
|
||||
{
|
||||
type: 'tab',
|
||||
name: 'tabs',
|
||||
dynamic: true,
|
||||
editable: true,
|
||||
onTabAdd: (_mForm: any, { model }: any) => {
|
||||
model.tabs.push({ start: '2021/07/17 15:37:00' });
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'date',
|
||||
name: 'start',
|
||||
text: '开始',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any,
|
||||
{ tabs: [] },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
wrapper.findComponent(ElTabs).vm.$emit('tabAdd');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect((wrapper.vm as any).values.tabs[0].start).toBe('2021-07-17');
|
||||
});
|
||||
});
|
||||
|
||||
150
packages/form/tests/unit/containers/useAdd.spec.ts
Normal file
150
packages/form/tests/unit/containers/useAdd.spec.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
@ -5,10 +5,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MCascader, MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MCascader, MForm } from '@form/index';
|
||||
|
||||
const mountForm = (config: any[], initValues: any = {}) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, MagicForm as any] },
|
||||
|
||||
@ -18,10 +18,11 @@
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MCheckbox, MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MCheckbox, MForm } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MCheckboxGroup, MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MCheckboxGroup, MForm } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MColorPicker, MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus, { ElColorPicker } from 'element-plus';
|
||||
|
||||
import MagicForm, { MColorPicker, MForm } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MDate, MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus, { ElInput } from 'element-plus';
|
||||
|
||||
import MagicForm, { MDate, MForm } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MDateTime, MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus, { ElInput } from 'element-plus';
|
||||
|
||||
import MagicForm, { MDateTime, MForm } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MDaterange, MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MDaterange, MForm } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MDisplay, MForm } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MDisplay, MForm } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MHidden } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MHidden } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { FormState, MForm, MFormDialog, MLink } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus, { ElButton } from 'element-plus';
|
||||
|
||||
import MagicForm, { FormState, MForm, MFormDialog, MLink } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MNumber } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MNumber } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MNumberRange } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MNumberRange } from '@form/index';
|
||||
|
||||
const getWrapper = (initValues: any = { range: [10, 20] }) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, MagicForm as any] },
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MRadioGroup } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MRadioGroup } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -5,11 +5,12 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MSelect } from '@form/index';
|
||||
import { setConfig } from '@form/utils/config';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MSelect } from '@form/index';
|
||||
import { setConfig } from '@form/utils/config';
|
||||
|
||||
const mountForm = (config: any[], initValues: any = {}) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, MagicForm as any] },
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MSwitch } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MSwitch } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MText } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus, { ElButton } from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MText } from '@form/index';
|
||||
|
||||
/**
|
||||
* 获取mock的Text实例
|
||||
* @param config 配置
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MTextarea } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MTextarea } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MTime } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MTime } from '@form/index';
|
||||
|
||||
const getWrapper = (
|
||||
config: any = [
|
||||
{
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import MagicForm, { MForm, MTimerange } from '@form/index';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm, { MForm, MTimerange } from '@form/index';
|
||||
|
||||
const mountForm = (config: any[], initValues: any) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, MagicForm as any] },
|
||||
|
||||
@ -24,9 +24,10 @@
|
||||
* 统一收口在此。
|
||||
*/
|
||||
import { type AppContext, createApp, defineComponent, h } from 'vue';
|
||||
import MagicForm from '@form/index';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
import MagicForm from '@form/index';
|
||||
|
||||
/** 必填规则 */
|
||||
export const required = (message = '必填') => [{ required: true, message }] as any;
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
*/
|
||||
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
|
||||
import { type AppContext, defineComponent, h, nextTick } from 'vue';
|
||||
|
||||
import { clearFields, registerFields, submitForm } from '@form/index';
|
||||
|
||||
import {
|
||||
|
||||
566
packages/form/tests/unit/utils/applyMountValueEffects.spec.ts
Normal file
566
packages/form/tests/unit/utils/applyMountValueEffects.spec.ts
Normal 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
|
||||
* 是否可能触发 effect。是否真的跳过,靠「walkNode 是否求值过函数型 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({});
|
||||
});
|
||||
});
|
||||
@ -16,6 +16,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { getConfig, setConfig } from '@form/utils/config';
|
||||
|
||||
describe('config.ts', () => {
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { FormState } from '@form/index';
|
||||
import {
|
||||
applyExtendState,
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { createApp, defineComponent } from 'vue';
|
||||
|
||||
import {
|
||||
builtInFields,
|
||||
clearFields,
|
||||
@ -168,21 +169,21 @@ describe('builtInFields', () => {
|
||||
});
|
||||
|
||||
test('mergeFieldOptions 后一份只覆盖自己带的 key', () => {
|
||||
const nested = () => undefined;
|
||||
const innerConfig = () => undefined;
|
||||
const typeMatch = () => undefined;
|
||||
const merged = mergeFieldOptions(
|
||||
{ 'code-select': { nested, typeMatch } },
|
||||
{ 'code-select': { innerConfig, typeMatch } },
|
||||
{ 'code-select': { component: FakeA } },
|
||||
{ 'code-select': { component: FakeB }, 'my-field': { component: FakeA } },
|
||||
);
|
||||
expect(merged['code-select'].component).toBe(FakeB);
|
||||
expect(merged['code-select'].nested).toBe(nested);
|
||||
expect(merged['code-select'].innerConfig).toBe(innerConfig);
|
||||
expect(merged['code-select'].typeMatch).toBe(typeMatch);
|
||||
expect(merged['my-field'].component).toBe(FakeA);
|
||||
});
|
||||
|
||||
test('多次 registerField 按字段合并,typeMatch 不会丢掉 nested', () => {
|
||||
registerField('my-composite', { nested: innerTextNested });
|
||||
test('多次 registerField 按字段合并,typeMatch 不会丢掉 innerConfig', () => {
|
||||
registerField('my-composite', { innerConfig: innerTextNested });
|
||||
registerField('my-composite', { typeMatch: () => undefined });
|
||||
|
||||
expect(getTypeMatchRule('my-composite')).toBeTypeOf('function');
|
||||
@ -201,8 +202,8 @@ describe('builtInFields', () => {
|
||||
expect(getTypeMatchRule('built-in-match')).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
test('registerBuiltInFields 的 nested 不会被 clearFields / unregisterField 清掉', () => {
|
||||
registerBuiltInFields({ 'built-in-nested': { nested: innerTextNested } });
|
||||
test('registerBuiltInFields 的 innerConfig 不会被 clearFields / unregisterField 清掉', () => {
|
||||
registerBuiltInFields({ 'built-in-nested': { innerConfig: innerTextNested } });
|
||||
|
||||
const collect = () =>
|
||||
collectValidatableFields(undefined, [{ type: 'built-in-nested', name: 'outer' }] as any, {
|
||||
@ -218,9 +219,9 @@ describe('builtInFields', () => {
|
||||
expect(collect()).toEqual(['outer.inner']);
|
||||
});
|
||||
|
||||
test('业务侧 nested 覆盖内置,unregisterField 后回落到内置', () => {
|
||||
registerBuiltInFields({ 'both-nested': { nested: innerTextNested } });
|
||||
registerField('both-nested', { nested: renameInnerNested });
|
||||
test('业务侧 innerConfig 覆盖内置,unregisterField 后回落到内置', () => {
|
||||
registerBuiltInFields({ 'both-nested': { innerConfig: innerTextNested } });
|
||||
registerField('both-nested', { innerConfig: renameInnerNested });
|
||||
|
||||
const collect = () =>
|
||||
collectValidatableFields(undefined, [{ type: 'both-nested', name: 'outer' }] as any, {
|
||||
@ -233,11 +234,11 @@ describe('builtInFields', () => {
|
||||
expect(collect()).toEqual(['outer.inner']);
|
||||
});
|
||||
|
||||
test('内置登记 nested 不会清掉业务侧已登记的叶子', () => {
|
||||
test('内置登记 innerConfig 不会清掉业务侧已登记的叶子', () => {
|
||||
registerField('leaf-then-built-in', {});
|
||||
expect(isLeafFieldType('leaf-then-built-in')).toBe(true);
|
||||
|
||||
registerBuiltInFields({ 'leaf-then-built-in': { nested: innerTextNested } });
|
||||
registerBuiltInFields({ 'leaf-then-built-in': { innerConfig: innerTextNested } });
|
||||
expect(isLeafFieldType('leaf-then-built-in')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { getGroupListRowConfig } from '@form/utils/tableGroupList';
|
||||
|
||||
describe('getGroupListRowConfig', () => {
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { setDesignConfig } from '@tmagic/design';
|
||||
import { getDesignConfig } from '@tmagic/design/headless';
|
||||
|
||||
import type { FormState } from '@form/index';
|
||||
import { getRules } from '@form/utils/form';
|
||||
import { clearFields } from '@form/utils/registerField';
|
||||
@ -30,9 +34,6 @@ import {
|
||||
validateTypeMatch,
|
||||
} from '@form/utils/typeMatch';
|
||||
|
||||
import { setDesignConfig } from '@tmagic/design';
|
||||
import { getDesignConfig } from '@tmagic/design/headless';
|
||||
|
||||
const mForm: FormState = {
|
||||
config: [],
|
||||
initValues: {},
|
||||
|
||||
@ -17,13 +17,14 @@
|
||||
*/
|
||||
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
|
||||
import { createApp, defineComponent } from 'vue';
|
||||
|
||||
import MagicForm, {
|
||||
builtInFields,
|
||||
clearFields,
|
||||
collectValidatableFields,
|
||||
createHeadlessFormState,
|
||||
getTypeMatchRule,
|
||||
isFieldNestedConfigError,
|
||||
isFieldInnerConfigError,
|
||||
isLeafFieldType,
|
||||
registerBuiltInFields,
|
||||
registerField,
|
||||
@ -679,9 +680,9 @@ describe('validateValues —— 未登记 type 与扩展登记', () => {
|
||||
expect(props).toEqual(['wrap.inner']);
|
||||
});
|
||||
|
||||
test('registerField nested 可遍历复合字段的内部配置', async () => {
|
||||
test('registerField innerConfig 可遍历复合字段的内部配置', async () => {
|
||||
registerField('my-composite', {
|
||||
nested: ({ config, model }) => ({
|
||||
innerConfig: ({ config, model }) => ({
|
||||
config: { type: 'text', name: 'inner', text: '内部', rules: required('内部必填') },
|
||||
model: model[(config as any).name],
|
||||
}),
|
||||
@ -692,30 +693,30 @@ describe('validateValues —— 未登记 type 与扩展登记', () => {
|
||||
initValues: { wrap: { inner: '' } },
|
||||
});
|
||||
|
||||
// 嵌套配置不在调用方传入的 config 树上,getTextByName 找不到 text,回退为 prop 路径
|
||||
// 内部配置不在调用方传入的 config 树上,getTextByName 找不到 text,回退为 prop 路径
|
||||
expect(error).toBe('wrap.inner -> 内部必填');
|
||||
});
|
||||
|
||||
test('nested 返回 null 表示该字段没有内部字段', () => {
|
||||
registerField('my-composite', { nested: () => null });
|
||||
test('innerConfig 返回 null 表示该字段没有内部字段', () => {
|
||||
registerField('my-composite', { innerConfig: () => null });
|
||||
expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).not.toThrow();
|
||||
});
|
||||
|
||||
test('nested 抛错时把失败原因带出去', () => {
|
||||
test('innerConfig 抛错时把失败原因带出去', () => {
|
||||
registerField('my-composite', {
|
||||
nested: () => {
|
||||
innerConfig: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).toThrow(
|
||||
/\[MForm\] nested config for "my-composite" at "a" failed: boom/,
|
||||
/\[MForm\] innerConfig for "my-composite" at "a" failed: boom/,
|
||||
);
|
||||
});
|
||||
|
||||
test('nested 抛错时抛出 FieldNestedConfigError,可按 code 判别', () => {
|
||||
test('innerConfig 抛错时抛出 FieldInnerConfigError,可按 code 判别', () => {
|
||||
registerField('my-composite', {
|
||||
nested: () => {
|
||||
innerConfig: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
@ -724,31 +725,30 @@ describe('validateValues —— 未登记 type 与扩展登记', () => {
|
||||
collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' });
|
||||
expect.unreachable('should throw');
|
||||
} catch (e) {
|
||||
expect(isFieldNestedConfigError(e)).toBe(true);
|
||||
expect((e as { code?: string }).code).toBe('FIELD_NESTED_CONFIG');
|
||||
expect(isFieldInnerConfigError(e)).toBe(true);
|
||||
expect((e as { code?: string }).code).toBe('FIELD_INNER_CONFIG');
|
||||
expect((e as { type?: string; prop?: string }).type).toBe('my-composite');
|
||||
expect((e as { type?: string; prop?: string }).prop).toBe('a');
|
||||
}
|
||||
});
|
||||
|
||||
test('nested 的 type 名支持驼峰与中划线互通', () => {
|
||||
registerField('myComposite', { nested: () => null });
|
||||
test('innerConfig 的 type 名支持驼峰与中划线互通', () => {
|
||||
registerField('myComposite', { innerConfig: () => null });
|
||||
expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).not.toThrow();
|
||||
});
|
||||
|
||||
test('同时传 nested 与 effect 时告警 effect 会被忽略', () => {
|
||||
test('同时传 innerConfig 与 effect 时不告警,两者并存', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
registerField('my-both', { nested: () => null, effect: () => undefined });
|
||||
registerField('my-both', { innerConfig: () => null, effect: () => undefined });
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[MForm] registerField("my-both")'));
|
||||
expect(spy.mock.calls[0][0]).toContain('mount value effect will be ignored');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('后一次 registerField 覆盖前一次,不告警', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
registerField('my-both', { effect: () => undefined });
|
||||
registerField('my-both', { nested: () => null });
|
||||
registerField('my-both', { innerConfig: () => null });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
*/
|
||||
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
|
||||
import { type AppContext, defineComponent, h, nextTick } from 'vue';
|
||||
|
||||
import { clearFields, registerFields, validateForm } from '@form/index';
|
||||
|
||||
import {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
},
|
||||
"exclude": [
|
||||
"**/dist/**/*"
|
||||
|
||||
@ -80,6 +80,7 @@ export default defineConfig({
|
||||
},
|
||||
{ find: /^@tmagic\/core/, replacement: path.join(__dirname, '../packages/core/src/index.ts') },
|
||||
{ find: /^@editor/, replacement: path.join(__dirname, '../packages/editor/src/') },
|
||||
{ find: /^@form/, replacement: path.join(__dirname, '../packages/form/src/') },
|
||||
// `/headless` 必须在下方通用的 `^@tmagic/<pkg>` 规则之前命中,否则会被改写成
|
||||
// `.../src/index.ts/headless` 导致 Vite 解析失败。
|
||||
{ find: /^@tmagic\/editor\/headless$/, replacement: path.join(__dirname, '../packages/editor/src/headless.ts') },
|
||||
|
||||
@ -159,6 +159,7 @@ async function build({ packageName, format, pkg, packagesDir, entry, name, fileN
|
||||
alias: [
|
||||
{ find: /^@data-source/, replacement: path.join(packagesDir, '/data-source/src') },
|
||||
{ find: /^@editor/, replacement: path.join(packagesDir, './editor/src') },
|
||||
{ find: /^@form/, replacement: path.join(packagesDir, './form/src') },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user