mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-13 12:15:42 +08:00
将字段挂载时的 model 写入迁移至 registerField effect, 渲染与无渲染校验共用 applyMountValueEffects; nested 重命名为 innerConfig 以区分配置派生与值写入。
567 lines
18 KiB
TypeScript
567 lines
18 KiB
TypeScript
/*
|
||
* 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({});
|
||
});
|
||
});
|