roymondchen 02eeb2e87f feat(editor): 统一列表字段添加按钮样式并支持新增后自动滚动
CodeSelect、EventSelect、DisplayConds 使用吸底全宽添加按钮;
group-list 新增后滚到最后一项,避开吸顶标题与吸底按钮。
2026-09-01 12:02:09 +08:00

448 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import EventSelect from '@editor/fields/EventSelect.vue';
const { editorService, dataSourceService, eventsService, codeBlockService, propsService } = vi.hoisted(() => ({
editorService: {
get: vi.fn(),
getNodeById: vi.fn(),
},
dataSourceService: {
get: vi.fn(),
getDataSourceById: vi.fn(),
getFormEvent: vi.fn(() => []),
},
eventsService: {
getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]),
getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]),
},
codeBlockService: {
getCodeDsl: vi.fn(() => ({ c1: {} })),
getEditStatus: vi.fn(() => true),
},
propsService: {
getDisabledCodeBlock: vi.fn(() => false),
getDisabledDataSource: vi.fn(() => false),
},
}));
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }),
}));
vi.mock('@editor/services/editor', () => ({ default: editorService }));
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
vi.mock('@editor/services/events', () => ({ default: eventsService }));
vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService }));
vi.mock('@editor/services/props', () => ({ default: propsService }));
vi.mock('@editor/utils/data-source', async () => {
const actual = await vi.importActual<any>('@editor/utils/data-source');
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => []) };
});
let capturedConfig: any = null;
let capturedEventNameConfig: any = null;
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
...actual,
defineFormItem: (cfg: any) => cfg,
MTable: defineComponent({
name: 'MTable',
props: ['model', 'config', 'name', 'size', 'disabled'],
emits: ['change'],
setup() {
return () => h('div', { class: 'fake-table' });
},
}),
MContainer: defineComponent({
name: 'MFormContainer',
props: ['config', 'model', 'lastValues', 'isCompare', 'disabled', 'size', 'prop'],
emits: ['change'],
setup(props) {
capturedEventNameConfig = props.config;
return () => h('div', { class: 'fake-form-container' });
},
}),
MGroupList: defineComponent({
name: 'MGroupList',
props: ['config', 'name', 'disabled', 'model', 'lastValues', 'isCompare', 'prop', 'size'],
emits: ['change'],
setup(props, { emit, slots }) {
capturedConfig = props.config;
return () => {
const events = props.model?.[props.name] || [];
const first = events[0];
return h(
'div',
{ class: 'fake-group-list', onClick: () => emit('change', events) },
first && slots.title
? [
slots.title({
model: first,
lastValues: props.lastValues?.[props.name]?.[0],
prop: `${props.prop}.0`,
index: 0,
title: '事件 1',
}),
]
: [],
);
};
},
}),
};
});
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return {
...actual,
DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX: 'ds_change_',
};
});
const baseProps = (extra: any = {}) => ({
config: { type: 'event-select', src: 'component' },
name: 'events',
prop: 'events',
model: { events: [] },
size: 'default',
disabled: false,
...extra,
});
const eventNameCfg = () => capturedEventNameConfig;
const actionsCfg = () => capturedConfig.items[0];
const mountEvent = (extra: any = {}) => mount(EventSelect, { props: baseProps(extra) as any });
describe('EventSelect', () => {
test('events 为空 isOldVersion=false 渲染 group-list', () => {
const wrapper = mount(EventSelect, { props: baseProps() as any });
expect(wrapper.find('.event-select-container').exists()).toBe(true);
expect(wrapper.findComponent({ name: 'MGroupList' }).exists()).toBe(true);
expect(wrapper.find('.fake-table').exists()).toBe(false);
expect(capturedConfig.type).toBe('group-list');
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('添加事件');
expect(capturedConfig.defaultAdd).toEqual({ name: '', actions: [] });
expect(capturedConfig.movable).toBe(false);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作');
});
test('group-list change 向外 emit', async () => {
const wrapper = mount(EventSelect, { props: baseProps() as any });
await wrapper.findComponent({ name: 'MGroupList' }).vm.$emit('change', [], { modifyKey: 'foo' });
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual([]);
});
test('title 里改事件名仍抛出事件列表,而不是单条', async () => {
const events = [{ name: 'click', actions: [] }];
const wrapper = mountEvent({ model: { events } });
await wrapper.findComponent({ name: 'MFormContainer' }).vm.$emit('change', { name: 'click' }, {});
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(events);
});
test('events 含 actions 字段时不算 oldVersion渲染 group-list', () => {
const wrapper = mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
expect(wrapper.findComponent({ name: 'MGroupList' }).exists()).toBe(true);
});
test('events 不含 actions 字段时为 oldVersion渲染 table', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a' }] },
}) as any,
});
expect(wrapper.find('.fake-table').exists()).toBe(true);
});
test('Table change emit', async () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a' }] },
}) as any,
});
await wrapper.findComponent({ name: 'MTable' }).vm.$emit('change', null, { modifyKey: 'foo' });
expect(wrapper.emitted('change')).toBeTruthy();
});
test('eventNameConfig type/options src=component 返回 select', () => {
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
expect(cfg.type(undefined, { formValue: { type: 'btn' } })).toBe('select');
const opts = cfg.options(undefined, { formValue: { type: 'btn' } });
expect(Array.isArray(opts)).toBe(true);
expect(opts[0]).toMatchObject({ text: 'click', value: 'click' });
});
test('eventNameConfig.rules 仅校验事件名是否在可选项中', () => {
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const [rule] = cfg.rules;
const okCb = vi.fn();
rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } });
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } });
expect(errCb).toHaveBeenCalledWith('事件名(unknown)不存在');
// 空值不做枚举校验
const emptyCb = vi.fn();
rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } });
expect(emptyCb).toHaveBeenCalledWith();
});
test('eventNameConfig.rules 自定义 options 时跳过枚举', () => {
mountEvent({
config: { type: 'event-select', src: 'component', eventNameConfig: { options: () => [{ value: 'x' }] } },
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const [rule] = cfg.rules;
const cb = vi.fn();
rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } });
expect(cb).toHaveBeenCalledWith();
});
test('eventNameConfig type 当 page-fragment 且有 pageFragmentId 返回 cascader', () => {
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [] }] });
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
expect(cfg.type(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } })).toBe(
'cascader',
);
const opts = cfg.options(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } });
expect(Array.isArray(opts)).toBe(true);
});
test('eventNameConfig src=datasource 返回事件 + 数据变化字段', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
mountEvent({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
expect(opts).toEqual([{ label: '数据变化', value: 'ds_change_', children: [] }]);
});
test('eventNameConfig src=datasource 无 fields 时返回原始事件', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [] });
mountEvent({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
expect(opts).toEqual([]);
});
test('actionTypeConfig 含 组件/代码/数据源', () => {
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const actionType = actionsCfg().items[0];
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort());
});
test('actionTypeConfig disabledCodeBlock/disabledDataSource 时不包含选项', () => {
propsService.getDisabledCodeBlock.mockReturnValue(true);
propsService.getDisabledDataSource.mockReturnValue(true);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const actionType = actionsCfg().items[0];
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
expect(opts.map((o: any) => o.value)).toEqual(['comp']);
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
});
test('targetCompConfig display/onChange', () => {
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const target = actionsCfg().items[1];
expect(target.display(undefined, { model: { actionType: 'comp' } })).toBe(true);
const setModel = vi.fn();
target.onChange(undefined, undefined, { setModel });
expect(setModel).toHaveBeenCalledWith('method', '');
});
test('compActionConfig 解析 type/options', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('select');
expect(Array.isArray(compAction.options(undefined, { model: { to: '1' } }))).toBe(true);
});
test('compActionConfig type cascader 当 page-fragment-container', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('cascader');
const opts = compAction.options(undefined, { model: { to: '1' } });
expect(Array.isArray(opts)).toBe(true);
});
test('compActionConfig options 当 node 无 type 返回空数组', () => {
editorService.getNodeById.mockReturnValue(null);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
expect(compAction.options(undefined, { model: { to: 'unknown' } })).toEqual([]);
});
test('compActionConfig.rules 仅校验动作名是否在可选项中', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
const [rule] = compAction.rules;
const okCb = vi.fn();
rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } });
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } });
expect(errCb).toHaveBeenCalledWith('动作名(unknown)不存在');
const emptyCb = vi.fn();
rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } });
expect(emptyCb).toHaveBeenCalledWith();
});
test('compActionConfig.rules 自定义 options 时跳过枚举', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
mountEvent({
config: {
type: 'event-select',
src: 'component',
compActionConfig: { options: () => [{ text: 'x', value: 'x' }] },
},
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
const [rule] = compAction.rules;
const cb = vi.fn();
rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } });
expect(cb).toHaveBeenCalledWith();
});
test('compActionConfig.rules 页面片 cascader 支持数组 method', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
const [rule] = compAction.rules;
const okCb = vi.fn();
rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } });
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } });
expect(errCb).toHaveBeenCalledWith('动作名(c1.missing)不存在');
});
test('codeActionConfig display/notEditable', () => {
codeBlockService.getEditStatus.mockReturnValue(false);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const codeAction = actionsCfg().items[3];
expect(codeAction.display(undefined, { model: { actionType: 'code' } })).toBe(true);
expect(codeAction.notEditable()).toBe(true);
codeBlockService.getEditStatus.mockReturnValue(true);
});
test('dataSourceActionConfig display/notEditable', () => {
dataSourceService.get.mockReturnValue(false);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const dsAction = actionsCfg().items[4];
expect(dsAction.display(undefined, { model: { actionType: 'data-source' } })).toBe(true);
expect(dsAction.notEditable()).toBe(true);
});
test('table 配置中 method options', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn' });
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a' }] },
}) as any,
});
const tableCfg = wrapper.findComponent({ name: 'MTable' }).props('config') as any;
const methodCol = tableCfg.items.find((it: any) => it.name === 'method');
const opts = methodCol.options(undefined, { model: { to: '1' } });
expect(opts).toEqual([{ text: 'open', value: 'open' }]);
editorService.getNodeById.mockReturnValue(null);
expect(methodCol.options(undefined, { model: { to: '1' } })).toEqual([]);
});
describe('对比模式', () => {
test('isCompare 但无 lastValues 时不进入对比', () => {
const wrapper = mount(EventSelect, {
props: baseProps({ isCompare: true, model: { events: [] } }) as any,
});
expect(wrapper.findComponent({ name: 'MGroupList' }).props('isCompare')).toBe(false);
});
test('对比模式向内部透传 isCompare 与 lastValues', () => {
const lastValues = { events: [{ name: 'a', actions: [] }] };
const wrapper = mount(EventSelect, {
props: baseProps({
isCompare: true,
model: { events: [{ name: 'a', actions: [] }] },
lastValues,
}) as any,
});
const list = wrapper.findComponent({ name: 'MGroupList' });
expect(list.props('isCompare')).toBe(true);
expect(list.props('lastValues')).toEqual(lastValues);
});
});
});