mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-14 07:59:46 +00:00
Compare commits
No commits in common. "master" and "v1.8.0-beta.27" have entirely different histories.
master
...
v1.8.0-bet
11
CHANGELOG.md
11
CHANGELOG.md
@ -1,14 +1,3 @@
|
||||
# [1.8.0-beta.28](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.27...v1.8.0-beta.28) (2026-09-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** 丢弃过期 root 快照,避免画布回退与 modelValue 回写死循环 ([66b54a5](https://github.com/Tencent/tmagic-editor/commit/66b54a52f8e9ff5041305ab7eb4057c820a4542a))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **form:** group-list 标题吸顶改为按 header.sticky 显式开启 ([2a4a54a](https://github.com/Tencent/tmagic-editor/commit/2a4a54a49567007787295769e3f8d05e3e8a30e0))
|
||||
# [1.8.0-beta.27](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.26...v1.8.0-beta.27) (2026-09-04)
|
||||
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 配置类型
|
||||
|
||||
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig / GroupListHeaderConfig 配置类型定义
|
||||
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig 配置类型定义
|
||||
<<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts}
|
||||
|
||||
<<< @/../packages/form-schema/src/base.ts#RowConfig{ts}
|
||||
@ -27,8 +27,6 @@
|
||||
|
||||
<<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts}
|
||||
|
||||
<<< @/../packages/form-schema/src/base.ts#GroupListHeaderConfig{ts}
|
||||
|
||||
<<< @/../packages/form-schema/src/base.ts#FormItem{ts}
|
||||
|
||||
:::
|
||||
@ -213,27 +211,6 @@
|
||||
}]
|
||||
}]"></demo-block>
|
||||
|
||||
#### 标题吸顶
|
||||
|
||||
列表项较多、需要边滚动边看清当前编辑的是哪一项时,可以用 `header.sticky` 让卡片标题吸顶:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: 'groupList',
|
||||
name: 'group',
|
||||
header: { sticky: true },
|
||||
items: [/* ... */],
|
||||
}
|
||||
```
|
||||
|
||||
吸顶默认关闭,需要逐层显式开启:外层开了不会带动内层,嵌套列表想一起吸顶就各自配一次,内层标题会自动下移让开外层标题。
|
||||
|
||||
标题让位的高度默认按 65px 计算。如果自定义了卡片标题的样式导致实际高度不同、内层标题出现重叠或空隙,用 `header.height` 告知真实高度即可(它只影响内层的让位距离,不会改变标题本身的高度)。
|
||||
|
||||
::: warning 行为变更
|
||||
1.8.0-beta.28 之前,group-list 的卡片标题在任何情况下都会吸顶。现在改为默认关闭、由 `header.sticky` 显式开启。升级后如需保持原有表现,请在对应配置上补上 `header: { sticky: true }`。
|
||||
:::
|
||||
|
||||
### table
|
||||
|
||||
<demo-block type="form" :config="[{
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "tmagic",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/cli",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/core",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/data-source",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/dep",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/design",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/editor",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -17,13 +17,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* 属性面板列表字段共用的吸底全宽「添加」按钮与吸顶标题,展开到 group-list 配置上。
|
||||
* 属性面板列表字段共用的吸底全宽「添加」按钮,展开到 group-list 配置上。
|
||||
*
|
||||
* 吸底按钮会盖住列表底部,吸顶标题会盖住列表顶部,新增的项必须同时滚进视口才看得见。
|
||||
* 吸底按钮会盖住列表底部,新增的项必须同时滚进视口才看得见,两者一起给出避免漏配。
|
||||
*/
|
||||
export const stickyAddButton = (text: string) => ({
|
||||
scrollLastItemIntoView: true as const,
|
||||
header: { sticky: true as const },
|
||||
addButtonConfig: {
|
||||
sticky: true as const,
|
||||
text,
|
||||
|
||||
@ -427,26 +427,12 @@ export const initServiceEvents = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* root 是否已经不是编辑器当前的 root。
|
||||
*
|
||||
* `editorService.set('root', v)` 是同步赋值后再派发 `root-change`,因此派发那一刻 `v` 一定
|
||||
* 就是当前 root;但处理 `root-change` 要等 stage / runtime / 依赖收集等异步过程,期间 root
|
||||
* 可能被新的一次整体替换(外部重设 DSL、更新 root 节点、源码保存等)顶掉,此时手上的 `v`
|
||||
* 已是过期快照,继续用它刷画布或回写给外部都会与新 root 互相覆盖。
|
||||
*/
|
||||
const isStaleRoot = (value: MApp | null) => toRaw(editorService.get('root')) !== toRaw(value);
|
||||
|
||||
const updateStageDsl = async (value: MApp | null) => {
|
||||
const stage = await getStage();
|
||||
|
||||
const runtime = await stage.renderer?.getRuntime();
|
||||
const app = await getTMagicApp();
|
||||
|
||||
// 等 stage / runtime 就绪期间 root 已被替换:新 root 的刷新可能已经完成,
|
||||
// 再把旧 dsl 推给 runtime 会让画布回退到旧内容
|
||||
if (isStaleRoot(value)) return;
|
||||
|
||||
if (!app?.dataSourceManager) {
|
||||
runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!);
|
||||
}
|
||||
@ -463,8 +449,6 @@ export const initServiceEvents = (
|
||||
|
||||
await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value));
|
||||
|
||||
if (isStaleRoot(value)) return;
|
||||
|
||||
const dsl = cloneDeep(toRaw(value));
|
||||
if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) {
|
||||
for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) {
|
||||
@ -482,7 +466,7 @@ export const initServiceEvents = (
|
||||
depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
|
||||
};
|
||||
|
||||
const rootChangeHandler = (value: MApp | null) => {
|
||||
const rootChangeHandler = (value: MApp | null, preValue?: MApp | null) => {
|
||||
if (!value) return;
|
||||
|
||||
value.codeBlocks = value.codeBlocks || {};
|
||||
@ -525,13 +509,7 @@ export const initServiceEvents = (
|
||||
editorService.set('page', null);
|
||||
}
|
||||
|
||||
// 上面的 select 是异步的,期间 root 可能已被替换,过期快照不能再回写给外部:
|
||||
// 两次整体替换各自持有一个快照时,回写会把对方的 root 顶掉,外部 modelValue 变化又会
|
||||
// 重新 set root,两条链路无休止地交替下去(表现为编辑器卡死)
|
||||
if (isStaleRoot(value)) return;
|
||||
|
||||
// 外部已经持有这个 root(如本次变化就是 modelValue 传进来的)时无需回写
|
||||
if (toRaw(props.modelValue) !== toRaw(value)) {
|
||||
if (toRaw(value) !== toRaw(preValue)) {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
})();
|
||||
|
||||
@ -204,7 +204,10 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
|
||||
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
||||
* 用户取消时返回 false,调用方据此中止回滚。
|
||||
*/
|
||||
const confirmRevert = (): Promise<boolean> => confirmHistoryAction('确定回滚该步骤吗?');
|
||||
const confirmRevert = (): Promise<boolean> =>
|
||||
confirmHistoryAction(
|
||||
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
|
||||
);
|
||||
|
||||
/**
|
||||
* 「回滚」统一确认入口:可差异对比的步骤动态挂载 HistoryDiffDialog 走差异确认弹窗,
|
||||
|
||||
@ -56,14 +56,7 @@ import { calcValueByFontsize, getIdFromEl } from '@tmagic/utils';
|
||||
import ScrollViewer from '@editor/components/ScrollViewer.vue';
|
||||
import { useServices } from '@editor/hooks';
|
||||
import { useStage } from '@editor/hooks/use-stage';
|
||||
import type {
|
||||
AddMNode,
|
||||
CustomContentMenuFunction,
|
||||
MenuButton,
|
||||
MenuComponent,
|
||||
StageOptions,
|
||||
StageSlots,
|
||||
} from '@editor/type';
|
||||
import type { CustomContentMenuFunction, MenuButton, MenuComponent, StageOptions, StageSlots } from '@editor/type';
|
||||
import { DragType, Layout } from '@editor/type';
|
||||
import { getEditorConfig } from '@editor/utils/config';
|
||||
import { KeyBindingContainerKey } from '@editor/utils/keybinding-config';
|
||||
@ -277,56 +270,11 @@ const resizeObserver = new globalThis.ResizeObserver((entries) => {
|
||||
}
|
||||
});
|
||||
|
||||
const parseDSL = getEditorConfig('parseDSL');
|
||||
|
||||
/**
|
||||
* 本次拖拽是否由编辑器文档内部发起
|
||||
*
|
||||
* drop 的 text/json 里可能带函数(组件配置的事件、钩子等),还原只能交给 parseDSL,
|
||||
* 而 parseDSL 的默认实现是 eval;HTML 拖放又允许其他源的页面在 DataTransfer 中投递
|
||||
* 自定义 MIME 数据,跨源页面只要诱导用户拖拽一次,就能让 eval 执行任意脚本。
|
||||
*
|
||||
* 跨源页面既不会在本文档触发 dragstart,也无法往本文档创建的 DataTransfer 中写数据,
|
||||
* 因此只有起源于本文档的拖拽才交给 parseDSL 还原。
|
||||
* 同源拖拽源写入的 text/json 由业务保证可信。
|
||||
*/
|
||||
let isInternalDrag = false;
|
||||
let internalDragSession = 0;
|
||||
let clearInternalDragTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const documentDragStartHandler = () => {
|
||||
internalDragSession += 1;
|
||||
isInternalDrag = true;
|
||||
if (clearInternalDragTimer !== undefined) {
|
||||
globalThis.clearTimeout(clearInternalDragTimer);
|
||||
clearInternalDragTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const documentDragEndHandler = () => {
|
||||
// WebKit 可能先 dragend 再 drop;推迟到下一个宏任务再清标志,
|
||||
// 让同一次拖拽的 drop 仍能消费。session 防止误清掉下一次 dragstart。
|
||||
const session = internalDragSession;
|
||||
if (clearInternalDragTimer !== undefined) {
|
||||
globalThis.clearTimeout(clearInternalDragTimer);
|
||||
}
|
||||
clearInternalDragTimer = globalThis.setTimeout(() => {
|
||||
clearInternalDragTimer = undefined;
|
||||
if (session === internalDragSession) {
|
||||
isInternalDrag = false;
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (stageWrapRef.value?.container) {
|
||||
resizeObserver.observe(stageWrapRef.value.container);
|
||||
keybindingService.registerEl(KeyBindingContainerKey.STAGE, stageWrapRef.value.container);
|
||||
}
|
||||
|
||||
// 用捕获阶段监听,确保拖拽源自身的 dragstart 无论是否阻止冒泡都能被记录
|
||||
globalThis.document.addEventListener('dragstart', documentDragStartHandler, true);
|
||||
globalThis.document.addEventListener('dragend', documentDragEndHandler, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@ -335,16 +283,10 @@ onBeforeUnmount(() => {
|
||||
resizeObserver.disconnect();
|
||||
editorService.set('stage', null);
|
||||
keybindingService.unregisterEl('stage');
|
||||
|
||||
if (clearInternalDragTimer !== undefined) {
|
||||
globalThis.clearTimeout(clearInternalDragTimer);
|
||||
clearInternalDragTimer = undefined;
|
||||
}
|
||||
|
||||
globalThis.document.removeEventListener('dragstart', documentDragStartHandler, true);
|
||||
globalThis.document.removeEventListener('dragend', documentDragEndHandler, true);
|
||||
});
|
||||
|
||||
const parseDSL = getEditorConfig('parseDSL');
|
||||
|
||||
const contextmenuHandler = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
menuRef.value?.show(e);
|
||||
@ -357,27 +299,15 @@ const dragoverHandler = (e: DragEvent) => {
|
||||
};
|
||||
|
||||
const dropHandler = async (e: DragEvent) => {
|
||||
// 画布上的任意 drop 都消费本次内部拖拽标记(含空 text/json),避免残留到下一次 drop
|
||||
const allowed = isInternalDrag;
|
||||
isInternalDrag = false;
|
||||
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
const data = e.dataTransfer.getData('text/json');
|
||||
|
||||
// 外部源投递的拖拽数据不可信,不能交给 parseDSL(默认实现为 eval)
|
||||
if (!data || !allowed) return;
|
||||
if (!data) return;
|
||||
|
||||
let config: { dragType?: string; data?: AddMNode } | undefined;
|
||||
try {
|
||||
config = parseDSL(`(${data})`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const config = parseDSL(`(${data})`);
|
||||
|
||||
if (!config || config.dragType !== DragType.COMPONENT_LIST || !config.data) return;
|
||||
|
||||
const dragData = config.data;
|
||||
if (!config || config.dragType !== DragType.COMPONENT_LIST) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
@ -419,7 +349,7 @@ const dropHandler = async (e: DragEvent) => {
|
||||
|
||||
const containerRect = stageContainerEl.value.getBoundingClientRect();
|
||||
const { scrollTop, scrollLeft } = stage.mask!;
|
||||
const { style = {} } = dragData;
|
||||
const { style = {} } = config.data;
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
@ -441,16 +371,16 @@ const dropHandler = async (e: DragEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
dragData.style = {
|
||||
config.data.style = {
|
||||
...style,
|
||||
position,
|
||||
top: calcValueByFontsize(doc, top / zoom.value),
|
||||
left: calcValueByFontsize(doc, left / zoom.value),
|
||||
};
|
||||
|
||||
dragData.inputEvent = e;
|
||||
config.data.inputEvent = e;
|
||||
|
||||
editorService.add(dragData, parent, { historySource: 'component-panel' });
|
||||
editorService.add(config.data, parent, { historySource: 'component-panel' });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -122,7 +122,6 @@ describe('CodeSelect', () => {
|
||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||
const config = container.props('config') as any;
|
||||
expect(config.scrollLastItemIntoView).toBe(true);
|
||||
expect(config.header.sticky).toBe(true);
|
||||
expect(config.addButtonConfig.sticky).toBe(true);
|
||||
expect(config.addButtonConfig.text).toBe('添加');
|
||||
const codeTypeSelect = config.items[0];
|
||||
|
||||
@ -84,7 +84,6 @@ describe('DisplayConds', () => {
|
||||
expect(capturedConfig.type).toBe('groupList');
|
||||
expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
|
||||
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
|
||||
expect(capturedConfig.header.sticky).toBe(true);
|
||||
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
|
||||
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
|
||||
expect(capturedConfig.items[0].type).toBe('groupList');
|
||||
@ -94,7 +93,6 @@ describe('DisplayConds', () => {
|
||||
expect(capturedConfig.items[0].movable).toBe(false);
|
||||
expect(capturedConfig.items[0].flat).toBe(true);
|
||||
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
|
||||
expect(capturedConfig.items[0].header.sticky).toBe(true);
|
||||
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
|
||||
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增条件');
|
||||
expect(capturedConfig.items[0].items.every((item: any) => item.span === undefined)).toBe(true);
|
||||
|
||||
@ -133,13 +133,11 @@ describe('EventSelect', () => {
|
||||
expect(wrapper.find('.fake-table').exists()).toBe(false);
|
||||
expect(capturedConfig.type).toBe('group-list');
|
||||
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
|
||||
expect(capturedConfig.header.sticky).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].header.sticky).toBe(true);
|
||||
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
|
||||
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作');
|
||||
});
|
||||
|
||||
@ -357,6 +357,7 @@ describe('initServiceEvents', () => {
|
||||
});
|
||||
|
||||
test('rootChange 处理代码块和数据源', async () => {
|
||||
services.editorService.state.root = { id: 'r' };
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
const value: any = {
|
||||
id: 'r',
|
||||
@ -364,8 +365,6 @@ describe('initServiceEvents', () => {
|
||||
dataSources: [{ id: 'd1', type: 'base' }],
|
||||
items: [],
|
||||
};
|
||||
// set('root', v) 是先赋值再派发事件,mock 中同样先对齐 state 再 emit
|
||||
services.editorService.state.root = value;
|
||||
services.editorService.emit('root-change', value, null);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(services.codeBlockService.setCodeDsl).toHaveBeenCalled();
|
||||
@ -594,15 +593,13 @@ describe('initServiceEvents', () => {
|
||||
services.editorService.state.node = { id: 'n1' };
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
|
||||
const value: any = {
|
||||
services.editorService.emit('root-change', {
|
||||
id: 'r',
|
||||
items: [{ id: 'n1', type: 'text' }],
|
||||
dataSources: [],
|
||||
dataSourceDeps: { d1: {} },
|
||||
codeBlocks: {},
|
||||
};
|
||||
services.editorService.state.root = value;
|
||||
services.editorService.emit('root-change', value);
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(stage.runtime.updatePageId).toHaveBeenCalledWith('p1');
|
||||
@ -611,10 +608,10 @@ describe('initServiceEvents', () => {
|
||||
});
|
||||
|
||||
test('rootChange items 不是数组时清空依赖', async () => {
|
||||
services.editorService.state.root = { id: 'r' };
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
|
||||
const value: any = { id: 'r', dataSourceDeps: { a: {} }, dataSourceCondDeps: { b: {} } };
|
||||
services.editorService.state.root = value;
|
||||
services.editorService.emit('root-change', value);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@ -628,7 +625,6 @@ describe('initServiceEvents', () => {
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
|
||||
const value: any = { id: 'r', items: [] };
|
||||
services.editorService.state.root = value;
|
||||
services.editorService.emit('root-change', value, { id: 'prev' });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@ -642,44 +638,12 @@ describe('initServiceEvents', () => {
|
||||
services.editorService.getNodeById.mockReturnValue(null);
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
|
||||
const value: any = { id: 'r', items: [{ id: 'first', type: 'page' }] };
|
||||
services.editorService.state.root = value;
|
||||
services.editorService.emit('root-change', value);
|
||||
services.editorService.emit('root-change', { id: 'r', items: [{ id: 'first', type: 'page' }] });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(services.editorService.select).toHaveBeenCalledWith({ id: 'first', type: 'page' });
|
||||
});
|
||||
|
||||
test('rootChange 外部已持有该 root 时不回写 modelValue', async () => {
|
||||
services.editorService.getNodeById.mockReturnValue(null);
|
||||
const value: any = { id: 'r', items: [] };
|
||||
mount(WrapEvents({ modelValue: value } as any, emit, services));
|
||||
|
||||
services.editorService.state.root = value;
|
||||
services.editorService.emit('root-change', value);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rootChange 处理期间 root 被替换:不刷旧 dsl 也不回写过期快照', async () => {
|
||||
const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() };
|
||||
const stage = mkReadyStage(app);
|
||||
services.editorService.state.stage = stage;
|
||||
services.editorService.getNodeById.mockReturnValue(null);
|
||||
mount(WrapEvents({} as any, emit, services));
|
||||
|
||||
const value: any = { id: 'r', items: [{ id: 'n1', type: 'text' }], dataSources: [], codeBlocks: {} };
|
||||
services.editorService.state.root = value;
|
||||
services.editorService.emit('root-change', value);
|
||||
// 异步处理还没跑完,root 已被新的一次整体替换顶掉
|
||||
services.editorService.state.root = { id: 'r', items: [], dataSources: [], codeBlocks: {} };
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(stage.runtime.updateRootConfig).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('update 事件:ROOT 节点、无 propPath、命中已收集依赖三种分支', async () => {
|
||||
services.editorService.state.root = { id: 'r', items: [] };
|
||||
services.depService.getTargets.mockReturnValue({
|
||||
|
||||
@ -88,14 +88,8 @@ const diffableGroups = (id: string | number = 'p1') => [
|
||||
},
|
||||
];
|
||||
|
||||
/** 让 getHistoryGroups 只对指定类别返回可对比分组,避免三类历史互相串味。 */
|
||||
const groupsFor = (category: string, id: string | number) => (type: string) =>
|
||||
type === category ? diffableGroups(id) : [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
appMock._context = {};
|
||||
});
|
||||
|
||||
describe('useHistoryRevert', () => {
|
||||
@ -232,255 +226,4 @@ describe('useHistoryRevert', () => {
|
||||
expect(result).toBe('done');
|
||||
});
|
||||
});
|
||||
|
||||
describe('数据源历史', () => {
|
||||
test('可差异步骤走差异确认弹窗,确认后执行 revert', async () => {
|
||||
const services = createServices();
|
||||
services.historyService.getStepList.mockReturnValue([
|
||||
{ step: { opType: 'update', diff: [{ newSchema: { id: 'ds_1' }, oldSchema: { id: 'ds_1' } }] } },
|
||||
]);
|
||||
services.historyService.getHistoryGroups.mockImplementation(groupsFor('dataSource', 'ds_1'));
|
||||
services.dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds_1', title: '当前' });
|
||||
|
||||
const { onDataSourceRevert } = useHistoryRevert({}, services);
|
||||
await onDataSourceRevert('ds_1', 0);
|
||||
|
||||
expect(lastDialogProps().isConfirm).toBe(true);
|
||||
expect(services.dataSourceService.revert).toHaveBeenCalledWith('ds_1', 0);
|
||||
});
|
||||
|
||||
test('buildDataSourceDiffPayload 取 title 作展示名、type 缺省为 base,并带上当前值', () => {
|
||||
const services = createServices();
|
||||
services.historyService.getHistoryGroups.mockImplementation(() => [
|
||||
{
|
||||
id: 'ds_1',
|
||||
steps: [
|
||||
{
|
||||
index: 0,
|
||||
step: { diff: [{ oldSchema: { title: '旧' }, newSchema: { title: '新' } }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
services.dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds_1', title: '当前' });
|
||||
|
||||
const { buildDataSourceDiffPayload } = useHistoryRevert({}, services);
|
||||
const payload = buildDataSourceDiffPayload('ds_1', 0);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
category: 'data-source',
|
||||
type: 'base',
|
||||
targetLabel: '新',
|
||||
id: 'ds_1',
|
||||
currentValue: { id: 'ds_1', title: '当前' },
|
||||
});
|
||||
expect(services.dataSourceService.getDataSourceById).toHaveBeenCalledWith('ds_1');
|
||||
});
|
||||
|
||||
test('onDataSourceDiff 打开只读弹窗', async () => {
|
||||
const services = createServices();
|
||||
services.historyService.getHistoryGroups.mockImplementation(groupsFor('dataSource', 'ds_1'));
|
||||
|
||||
const { onDataSourceDiff } = useHistoryRevert({ dialogWidth: '900px' }, services);
|
||||
await onDataSourceDiff('ds_1', 0);
|
||||
|
||||
expect(lastDialogProps().isConfirm).toBe(false);
|
||||
expect(lastDialogProps().width).toBe('900px');
|
||||
expect(dialogInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('无可对比内容时 onDataSourceDiff 不弹窗', async () => {
|
||||
const services = createServices();
|
||||
|
||||
const { onDataSourceDiff } = useHistoryRevert({}, services);
|
||||
await onDataSourceDiff('ds_1', 0);
|
||||
|
||||
expect(createAppMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('代码块历史', () => {
|
||||
test('update 记录对应代码块已删除时,提示错误且不执行回滚', async () => {
|
||||
const services = createServices();
|
||||
services.historyService.getStepList.mockReturnValue([{ step: { opType: 'update', diff: [] } }]);
|
||||
services.codeBlockService.getCodeContentById.mockReturnValue(null);
|
||||
|
||||
const { onCodeBlockRevert } = useHistoryRevert({}, services);
|
||||
await onCodeBlockRevert('code_1', 0);
|
||||
|
||||
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
|
||||
expect(services.codeBlockService.revert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('可差异步骤走差异确认弹窗,确认后执行 revert', async () => {
|
||||
const services = createServices();
|
||||
services.historyService.getStepList.mockReturnValue([{ step: { opType: 'update', diff: [] } }]);
|
||||
services.historyService.getHistoryGroups.mockImplementation(groupsFor('codeBlock', 'code_1'));
|
||||
services.codeBlockService.getCodeContentById.mockReturnValue({ name: '当前' });
|
||||
|
||||
const { onCodeBlockRevert } = useHistoryRevert({}, services);
|
||||
await onCodeBlockRevert('code_1', 0);
|
||||
|
||||
expect(lastDialogProps().isConfirm).toBe(true);
|
||||
expect(services.codeBlockService.revert).toHaveBeenCalledWith('code_1', 0);
|
||||
});
|
||||
|
||||
test('用户取消确认时不执行 revert', async () => {
|
||||
const services = createServices();
|
||||
services.historyService.getStepList.mockReturnValue([{ step: { opType: 'add', diff: [] } }]);
|
||||
vi.mocked(confirmHistoryAction).mockResolvedValueOnce(false);
|
||||
|
||||
const { onCodeBlockRevert } = useHistoryRevert({}, services);
|
||||
await onCodeBlockRevert('code_1', 0);
|
||||
|
||||
expect(services.codeBlockService.revert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('buildCodeBlockDiffPayload 不带 type,展示名回退到 id', () => {
|
||||
const services = createServices();
|
||||
services.historyService.getHistoryGroups.mockImplementation(() => [
|
||||
{
|
||||
id: 'code_1',
|
||||
steps: [{ index: 0, step: { diff: [{ oldSchema: {}, newSchema: {} }] } }],
|
||||
},
|
||||
]);
|
||||
services.codeBlockService.getCodeContentById.mockReturnValue(undefined);
|
||||
|
||||
const { buildCodeBlockDiffPayload } = useHistoryRevert({}, services);
|
||||
const payload = buildCodeBlockDiffPayload('code_1', 0);
|
||||
|
||||
expect(payload).toMatchObject({ category: 'code-block', targetLabel: 'code_1', currentValue: null });
|
||||
expect(payload).not.toHaveProperty('type');
|
||||
});
|
||||
|
||||
test('onCodeBlockDiff 打开只读弹窗', async () => {
|
||||
const services = createServices();
|
||||
services.historyService.getHistoryGroups.mockImplementation(groupsFor('codeBlock', 'code_1'));
|
||||
|
||||
const { onCodeBlockDiff } = useHistoryRevert({}, services);
|
||||
await onCodeBlockDiff('code_1', 0);
|
||||
|
||||
expect(lastDialogProps().isConfirm).toBe(false);
|
||||
expect(dialogInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('回滚前置校验', () => {
|
||||
test('页面 remove 记录的原父容器已删除时判定为无法回滚', () => {
|
||||
const services = createServices();
|
||||
services.historyService.getStepList.mockReturnValue([
|
||||
{ step: { opType: 'remove', diff: [{ parentId: 'parent_gone' }] } },
|
||||
]);
|
||||
services.editorService.getNodeById.mockReturnValue(null);
|
||||
|
||||
const { isPageRevertTargetMissing } = useHistoryRevert({}, services);
|
||||
expect(isPageRevertTargetMissing(0)).toBe(true);
|
||||
});
|
||||
|
||||
test('页面 remove 记录的原父容器仍在时可以回滚', () => {
|
||||
const services = createServices();
|
||||
services.historyService.getStepList.mockReturnValue([
|
||||
{ step: { opType: 'remove', diff: [{ parentId: 'parent_1' }] } },
|
||||
]);
|
||||
services.editorService.getNodeById.mockReturnValue({ id: 'parent_1' });
|
||||
|
||||
const { isPageRevertTargetMissing } = useHistoryRevert({}, services);
|
||||
expect(isPageRevertTargetMissing(0)).toBe(false);
|
||||
});
|
||||
|
||||
test('步骤不存在时不判定为无法回滚', () => {
|
||||
const services = createServices();
|
||||
|
||||
const { isPageRevertTargetMissing, isCodeBlockRevertTargetMissing } = useHistoryRevert({}, services);
|
||||
expect(isPageRevertTargetMissing(0)).toBe(false);
|
||||
expect(isCodeBlockRevertTargetMissing('code_1', 0)).toBe(false);
|
||||
});
|
||||
|
||||
test('confirmAndRevert 在 isTargetMissing 命中时提示错误并返回 null', async () => {
|
||||
const services = createServices();
|
||||
const revert = vi.fn(async () => 'done');
|
||||
|
||||
const { confirmAndRevert } = useHistoryRevert({}, services);
|
||||
const result = await confirmAndRevert({ isTargetMissing: () => true, diffPayload: null, revert });
|
||||
|
||||
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
|
||||
expect(revert).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('confirmAndRevert 在用户取消时返回 null', async () => {
|
||||
const services = createServices();
|
||||
const revert = vi.fn(async () => 'done');
|
||||
vi.mocked(confirmHistoryAction).mockResolvedValueOnce(false);
|
||||
|
||||
const { confirmAndRevert } = useHistoryRevert({}, services);
|
||||
const result = await confirmAndRevert({ diffPayload: null, revert });
|
||||
|
||||
expect(revert).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('弹窗挂载与卸载', () => {
|
||||
test('传入 appContext 时合并进弹窗 app 的上下文', async () => {
|
||||
const services = createServices();
|
||||
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
|
||||
const appContext = { provides: { host: 1 } } as any;
|
||||
|
||||
const { onPageDiff } = useHistoryRevert({ appContext }, services);
|
||||
await onPageDiff(0);
|
||||
|
||||
expect(appMock._context).toMatchObject({ provides: { host: 1 } });
|
||||
});
|
||||
|
||||
test('未显式指定 size 时回落到属性面板尺寸', async () => {
|
||||
const services = createServices();
|
||||
services.uiService = { get: vi.fn(() => 'small') };
|
||||
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
|
||||
|
||||
const { onPageDiff } = useHistoryRevert({}, services);
|
||||
await onPageDiff(0);
|
||||
|
||||
expect(lastDialogProps().size).toBe('small');
|
||||
});
|
||||
|
||||
test('确认弹窗结束后延迟卸载并移除容器', async () => {
|
||||
vi.useFakeTimers();
|
||||
const services = createServices();
|
||||
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
|
||||
const containerCount = document.body.childElementCount;
|
||||
|
||||
const { confirmAndRevert } = useHistoryRevert({}, services);
|
||||
await confirmAndRevert({
|
||||
diffPayload: { category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any,
|
||||
revert: async () => 'done',
|
||||
});
|
||||
|
||||
expect(document.body.childElementCount).toBe(containerCount + 1);
|
||||
expect(appMock.unmount).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(appMock.unmount).toHaveBeenCalled();
|
||||
expect(document.body.childElementCount).toBe(containerCount);
|
||||
});
|
||||
|
||||
test('卸载抛错不影响容器清理', async () => {
|
||||
vi.useFakeTimers();
|
||||
const services = createServices();
|
||||
appMock.unmount.mockImplementationOnce(() => {
|
||||
throw new Error('unmount failed');
|
||||
});
|
||||
const containerCount = document.body.childElementCount;
|
||||
|
||||
const { viewDiff } = useHistoryRevert({}, services);
|
||||
await viewDiff({ category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any);
|
||||
|
||||
// 只读弹窗要等用户关闭才卸载
|
||||
lastDialogProps().onClose();
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(document.body.childElementCount).toBe(containerCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -55,14 +55,11 @@ vi.mock('@editor/hooks', () => ({
|
||||
useServices: () => ({ editorService, uiService, keybindingService, stageOverlayService }),
|
||||
}));
|
||||
|
||||
// 与 plugin.ts 中 parseDSL 的默认实现(eval)保持一致,用于验证守卫能否阻止其执行
|
||||
const { parseDSL } = vi.hoisted(() => ({
|
||||
// eslint-disable-next-line no-new-func
|
||||
parseDSL: vi.fn((dsl: string) => new Function(`return ${dsl}`)()),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/config', () => ({
|
||||
getEditorConfig: vi.fn(() => parseDSL),
|
||||
getEditorConfig: vi.fn(() => (s: string) => {
|
||||
if (s.startsWith('(')) return JSON.parse(s.slice(1, -1));
|
||||
return JSON.parse(s);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/components/ScrollViewer.vue', () => ({
|
||||
@ -149,26 +146,6 @@ const mountIt = (props: any = {}) =>
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
/** 模拟编辑器文档内部(如组件列表面板)发起拖拽 */
|
||||
const startInternalDrag = () => {
|
||||
document.dispatchEvent(new Event('dragstart'));
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
document.dispatchEvent(new Event('dragend'));
|
||||
};
|
||||
|
||||
const createDropEvent = (raw: string) => {
|
||||
const event: any = new Event('drop');
|
||||
event.dataTransfer = { getData: vi.fn(() => raw) };
|
||||
event.preventDefault = vi.fn();
|
||||
return event;
|
||||
};
|
||||
|
||||
const waitMacrotask = () => new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const COMPONENT_LIST_JSON = '{"dragType":"component-list","data":{"name":"text","style":{}}}';
|
||||
|
||||
describe('Stage', () => {
|
||||
test('挂载并创建 stage', async () => {
|
||||
const wrapper = mountIt();
|
||||
@ -208,28 +185,17 @@ describe('Stage', () => {
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
const event = createDropEvent(COMPONENT_LIST_JSON);
|
||||
const event: any = new Event('drop');
|
||||
event.dataTransfer = {
|
||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
|
||||
};
|
||||
event.clientX = 100;
|
||||
event.clientY = 100;
|
||||
event.preventDefault = vi.fn();
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
startInternalDrag();
|
||||
stageContainer.dispatchEvent(event);
|
||||
await waitMacrotask();
|
||||
expect(editorService.add).toHaveBeenCalled();
|
||||
endDrag();
|
||||
});
|
||||
|
||||
test('drop 保留拖拽数据中的函数', async () => {
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
const event = createDropEvent('{dragType:"component-list",data:{name:"text",style:{},created:() => "created"}}');
|
||||
event.clientX = 10;
|
||||
event.clientY = 10;
|
||||
startInternalDrag();
|
||||
wrapper.find('.m-editor-stage-container').element.dispatchEvent(event);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(editorService.add.mock.calls[0][0].created()).toBe('created');
|
||||
expect(editorService.add).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('zoom 变化时 stage.setZoom 被调用', async () => {
|
||||
@ -252,163 +218,48 @@ describe('Stage', () => {
|
||||
expect(wrapper.find('.fake-overlay').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('drop 数据为空时消费内部拖拽标记,后续 drop 不再解析', async () => {
|
||||
test('drop 数据为空时不处理', async () => {
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
startInternalDrag();
|
||||
stageContainer.dispatchEvent(createDropEvent(''));
|
||||
expect(editorService.add).not.toHaveBeenCalled();
|
||||
|
||||
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
|
||||
await waitMacrotask();
|
||||
expect(parseDSL).not.toHaveBeenCalled();
|
||||
expect(editorService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('drop 无 dataTransfer 时也消费内部拖拽标记', async () => {
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
startInternalDrag();
|
||||
const event: any = new Event('drop');
|
||||
event.dataTransfer = null;
|
||||
event.dataTransfer = { getData: vi.fn(() => '') };
|
||||
event.preventDefault = vi.fn();
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
stageContainer.dispatchEvent(event);
|
||||
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
|
||||
await waitMacrotask();
|
||||
expect(parseDSL).not.toHaveBeenCalled();
|
||||
expect(editorService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('drop 非 COMPONENT_LIST 时不处理', async () => {
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
const event = createDropEvent('{"dragType":"other","data":{"name":"text","style":{}}}');
|
||||
const event: any = new Event('drop');
|
||||
event.dataTransfer = {
|
||||
getData: vi.fn(() => '{"dragType":"other","data":{"name":"text","style":{}}}'),
|
||||
};
|
||||
event.preventDefault = vi.fn();
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
startInternalDrag();
|
||||
stageContainer.dispatchEvent(event);
|
||||
await waitMacrotask();
|
||||
expect(editorService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('drop 缺少 data 时不处理', async () => {
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
startInternalDrag();
|
||||
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent('{"dragType":"component-list"}'));
|
||||
await waitMacrotask();
|
||||
expect(editorService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('drop 拖拽非本文档发起时不解析数据,不执行其中的脚本', async () => {
|
||||
let executed = false;
|
||||
Object.defineProperty(globalThis, '__PWNED__', {
|
||||
configurable: true,
|
||||
set() {
|
||||
executed = true;
|
||||
},
|
||||
});
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
// PoC 中攻击页面跨源投递的 payload
|
||||
const event = createDropEvent("{a:(globalThis.__PWNED__='code execution',0), dragType:'component-list'}");
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
// 不触发 dragstart,模拟拖拽起源于编辑器之外。
|
||||
// 同源内部拖拽源写入的 text/json 由业务保证可信,本用例不覆盖。
|
||||
expect(() => stageContainer.dispatchEvent(event)).not.toThrow();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(parseDSL).not.toHaveBeenCalled();
|
||||
expect(executed).toBe(false);
|
||||
expect(editorService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('drop 一次 dragstart 只消费一次', async () => {
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
startInternalDrag();
|
||||
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
|
||||
await waitMacrotask();
|
||||
expect(editorService.add).toHaveBeenCalledTimes(1);
|
||||
|
||||
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
|
||||
await waitMacrotask();
|
||||
expect(editorService.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('drop 在 dragend 之后仍处理(兼容 WebKit 先 dragend 再 drop)', async () => {
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
startInternalDrag();
|
||||
endDrag();
|
||||
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
|
||||
await waitMacrotask();
|
||||
expect(editorService.add).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dragend 宏任务之后取消的拖拽不再处理', async () => {
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
startInternalDrag();
|
||||
endDrag();
|
||||
await waitMacrotask();
|
||||
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
|
||||
await waitMacrotask();
|
||||
expect(parseDSL).not.toHaveBeenCalled();
|
||||
expect(editorService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dragend 延迟清理不会清掉下一次 dragstart', async () => {
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
startInternalDrag();
|
||||
endDrag();
|
||||
startInternalDrag();
|
||||
await waitMacrotask();
|
||||
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
|
||||
await waitMacrotask();
|
||||
expect(editorService.add).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('drop parseDSL 解析失败时不抛错', async () => {
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
const event = createDropEvent('{"dragType":');
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
startInternalDrag();
|
||||
expect(() => stageContainer.dispatchEvent(event)).not.toThrow();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(editorService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('卸载时移除 document 上的拖拽监听', async () => {
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
wrapper.unmount();
|
||||
expect(removeSpy).toHaveBeenCalledWith('dragstart', expect.any(Function), true);
|
||||
expect(removeSpy).toHaveBeenCalledWith('dragend', expect.any(Function), true);
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('drop position fixed 计算位置', async () => {
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
editorService.getLayout.mockResolvedValue('relative');
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}');
|
||||
const event: any = new Event('drop');
|
||||
event.dataTransfer = {
|
||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}'),
|
||||
};
|
||||
event.clientX = 80;
|
||||
event.clientY = 60;
|
||||
event.preventDefault = vi.fn();
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
Object.defineProperty(stageContainer, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||
configurable: true,
|
||||
});
|
||||
startInternalDrag();
|
||||
stageContainer.dispatchEvent(event);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const args = editorService.add.mock.calls[0][0];
|
||||
@ -420,15 +271,18 @@ describe('Stage', () => {
|
||||
editorService.getLayout.mockResolvedValue('absolute');
|
||||
const wrapper = mountIt();
|
||||
await nextTick();
|
||||
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
|
||||
const event: any = new Event('drop');
|
||||
event.dataTransfer = {
|
||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
|
||||
};
|
||||
event.clientX = 80;
|
||||
event.clientY = 60;
|
||||
event.preventDefault = vi.fn();
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
Object.defineProperty(stageContainer, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||
configurable: true,
|
||||
});
|
||||
startInternalDrag();
|
||||
stageContainer.dispatchEvent(event);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const args = editorService.add.mock.calls[0][0];
|
||||
@ -442,11 +296,14 @@ describe('Stage', () => {
|
||||
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', canDropIn },
|
||||
});
|
||||
await nextTick();
|
||||
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
|
||||
const event: any = new Event('drop');
|
||||
event.dataTransfer = {
|
||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
|
||||
};
|
||||
event.clientX = 50;
|
||||
event.clientY = 50;
|
||||
event.preventDefault = vi.fn();
|
||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||
startInternalDrag();
|
||||
stageContainer.dispatchEvent(event);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(canDropIn).toHaveBeenCalled();
|
||||
@ -557,10 +414,13 @@ describe('Stage', () => {
|
||||
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'h', canDropIn },
|
||||
});
|
||||
await nextTick();
|
||||
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
|
||||
const event: any = new Event('drop');
|
||||
event.dataTransfer = {
|
||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
|
||||
};
|
||||
event.clientX = 1;
|
||||
event.clientY = 1;
|
||||
startInternalDrag();
|
||||
event.preventDefault = vi.fn();
|
||||
wrapper.find('.m-editor-stage-container').element.dispatchEvent(event);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(canDropIn).toHaveBeenCalled();
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/element-plus-adapter",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/form-schema",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -830,20 +830,6 @@ export interface AddButtonConfig {
|
||||
}
|
||||
// #endregion AddButtonConfig
|
||||
|
||||
// #region GroupListHeaderConfig
|
||||
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
|
||||
export interface GroupListHeaderConfig {
|
||||
/** 标题吸顶 */
|
||||
sticky?: boolean;
|
||||
/**
|
||||
* 告诉嵌套列表「本层吸顶标题占了多高」,内层标题据此下移让位。
|
||||
* **不会改变标题自身的高度**,只在实际标题高度偏离默认值 65px 时才需要配。
|
||||
* 数字与纯数字字符串按 px,其余按 CSS 长度原样使用。
|
||||
*/
|
||||
height?: number | string;
|
||||
}
|
||||
// #endregion GroupListHeaderConfig
|
||||
|
||||
// #region TableGroupListCommonConfig
|
||||
export interface TableGroupListCommonConfig extends FormItem {
|
||||
type: 'table' | 'groupList' | 'group-list';
|
||||
@ -860,8 +846,6 @@ export interface TableGroupListCommonConfig extends FormItem {
|
||||
/** 新增后滚动到最后一项(group-list 形态,默认关闭) */
|
||||
scrollLastItemIntoView?: boolean;
|
||||
addButtonConfig?: AddButtonConfig;
|
||||
/** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */
|
||||
header?: GroupListHeaderConfig;
|
||||
}
|
||||
// #endregion TableGroupListCommonConfig
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/form",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -1,12 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="m-fields-group-list"
|
||||
:class="{
|
||||
'is-header-sticky': Boolean(config.header?.sticky),
|
||||
'is-footer-sticky': isFooterSticky($slots),
|
||||
}"
|
||||
:style="headerVars"
|
||||
>
|
||||
<div class="m-fields-group-list">
|
||||
<div v-if="config.extra" v-html="config.extra" style="color: rgba(0, 0, 0, 0.45)"></div>
|
||||
<div v-if="!displayItems.length" class="el-table__empty-block">
|
||||
<span class="el-table__empty-text t-table__empty">暂无{{ config.titlePrefix || '' }}数据</span>
|
||||
@ -41,7 +34,7 @@
|
||||
<div
|
||||
class="m-fields-group-list-footer"
|
||||
:class="{ 'is-sticky-full': Boolean(config.addButtonConfig?.sticky) }"
|
||||
v-if="hasFooter($slots)"
|
||||
v-if="!isCompare && ($slots['toggle-button'] || $slots['add-button'])"
|
||||
>
|
||||
<slot name="toggle-button"></slot>
|
||||
<div style="display: flex; justify-content: flex-end; flex: 1">
|
||||
@ -56,7 +49,6 @@ import { computed } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import type { ContainerChangeEventData, GroupListConfig } from '../schema';
|
||||
import { getGroupListHeaderVars } from '../utils/tableGroupList';
|
||||
|
||||
import MFieldsGroupListItem from './GroupListItem.vue';
|
||||
|
||||
@ -112,19 +104,6 @@ const onAddDiffCount = () => emit('addDiffCount');
|
||||
|
||||
const asList = (value: unknown): any[] => (Array.isArray(value) ? value : []);
|
||||
|
||||
const headerVars = computed(() => getGroupListHeaderVars(props.config.header));
|
||||
|
||||
/**
|
||||
* 取 `$slots` 而不是 `useSlots()`:宿主切换 `addable` 时插槽会增删,
|
||||
* computed 缓存不随插槽变化失效,只有在渲染期读才拿得到最新的。
|
||||
*/
|
||||
const hasFooter = (slots: Record<string, unknown>) =>
|
||||
!props.isCompare && Boolean(slots['toggle-button'] || slots['add-button']);
|
||||
|
||||
/** 只有真的渲染出吸底 footer,内层列表才需要为它让位 */
|
||||
const isFooterSticky = (slots: Record<string, unknown>) =>
|
||||
hasFooter(slots) && Boolean(props.config.addButtonConfig?.sticky);
|
||||
|
||||
const currentList = computed(() => asList(props.model[props.name]));
|
||||
|
||||
/** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */
|
||||
|
||||
@ -70,60 +70,27 @@
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 卡片标题吸顶;补不透明背景,避免滚动时正文透出。
|
||||
// z-index 必须低于吸底 footer,否则标题会盖住「新增」按钮。
|
||||
> .el-card__header,
|
||||
> .t-card__header {
|
||||
position: sticky;
|
||||
top: var(--m-group-list-header-sticky-top, 0px);
|
||||
z-index: 7;
|
||||
background-color: var(--m-group-list-header-bg, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
// 嵌套吸底按钮叠在外层 footer 之上(如「新增条件」叠在「新增条件组」上面)。
|
||||
// 每穿过一层「自己渲染了吸底 footer」的列表才多让出一个 footer 高度,外层没有吸底按钮时
|
||||
// 内层不能凭空抬高。自定义属性不能自增,所以用 item / list 两个变量交替接力。
|
||||
> .m-fields-group-list-item {
|
||||
--m-group-list-item-footer-bottom: var(
|
||||
--m-group-list-nested-footer-bottom,
|
||||
0px
|
||||
);
|
||||
}
|
||||
|
||||
&.is-footer-sticky > .m-fields-group-list-item {
|
||||
--m-group-list-item-footer-bottom: calc(
|
||||
var(--m-group-list-nested-footer-bottom, 0px) +
|
||||
var(--m-group-list-footer-height)
|
||||
);
|
||||
}
|
||||
|
||||
.m-fields-group-list-item .m-fields-group-list {
|
||||
--m-group-list-nested-footer-bottom: var(
|
||||
--m-group-list-item-footer-bottom,
|
||||
0px
|
||||
);
|
||||
}
|
||||
|
||||
// 仅 header.sticky 时标题吸顶;直接子 item,避免外层吸顶带动未开启的内层
|
||||
&.is-header-sticky {
|
||||
// 从外层 item 接过已累加的偏移;最外层没有该变量时走 fallback
|
||||
--m-group-list-nested-sticky-top: var(--m-group-list-child-list-sticky-top);
|
||||
--m-group-list-nested-header-z: var(--m-group-list-child-list-header-z);
|
||||
|
||||
> .m-fields-group-list-item {
|
||||
--m-group-list-item-sticky-top: var(
|
||||
--m-group-list-nested-sticky-top,
|
||||
var(--m-group-list-header-sticky-top, 0px)
|
||||
// 嵌套标题叠在外层标题之下,层级更低以免盖住外层底部分隔线
|
||||
.m-fields-group-list-item .m-fields-group-list-item {
|
||||
> .el-card__header,
|
||||
> .t-card__header {
|
||||
top: calc(
|
||||
var(--m-group-list-header-sticky-top, 0px) +
|
||||
var(--m-group-list-header-height)
|
||||
);
|
||||
--m-group-list-item-header-z: var(--m-group-list-nested-header-z, 7);
|
||||
--m-group-list-child-list-sticky-top: calc(
|
||||
var(--m-group-list-item-sticky-top) + var(--m-group-list-header-height)
|
||||
);
|
||||
--m-group-list-child-list-header-z: calc(
|
||||
var(--m-group-list-item-header-z) - 1
|
||||
);
|
||||
|
||||
// 卡片标题吸顶;补不透明背景,避免滚动时正文透出。
|
||||
// z-index 必须低于吸底 footer,否则标题会盖住「新增」按钮。
|
||||
> .el-card__header,
|
||||
> .t-card__header {
|
||||
position: sticky;
|
||||
top: var(--m-group-list-item-sticky-top);
|
||||
z-index: var(--m-group-list-item-header-z);
|
||||
background-color: var(--m-group-list-header-bg, #fff);
|
||||
}
|
||||
z-index: 6;
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,10 +113,10 @@
|
||||
}
|
||||
|
||||
// 吸底全宽主按钮(event-select / code-select / display-conds 外层列表)
|
||||
// z-index 高于 item header,避免吸顶标题挡住新增按钮;多层时 bottom 由内层 list 累加
|
||||
// z-index 高于 item header,避免吸顶标题挡住新增按钮
|
||||
> .m-fields-group-list-footer.is-sticky-full {
|
||||
position: sticky;
|
||||
bottom: var(--m-group-list-nested-footer-bottom, 0px);
|
||||
bottom: 0;
|
||||
z-index: 7;
|
||||
margin-bottom: 0;
|
||||
padding: var(--m-group-list-footer-padding-top) 0
|
||||
@ -166,6 +133,14 @@
|
||||
height: var(--m-group-list-footer-button-height);
|
||||
}
|
||||
}
|
||||
|
||||
// 嵌套吸底按钮叠在外层 footer 之上(如「新增条件」叠在「新增条件组」上面)
|
||||
.m-fields-group-list-item
|
||||
.m-fields-group-list
|
||||
> .m-fields-group-list-footer.is-sticky-full {
|
||||
bottom: var(--m-group-list-footer-height);
|
||||
z-index: 7;
|
||||
}
|
||||
}
|
||||
|
||||
/** 最外层的groupList需要每个item需要增加空白区域界限时,增加outer-gorup_list可以实现 */
|
||||
|
||||
@ -18,9 +18,7 @@
|
||||
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import { isNumber } from '@tmagic/utils';
|
||||
|
||||
import type { GroupListConfig, GroupListHeaderConfig, TableColumnConfig, TableConfig } from '../schema';
|
||||
import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema';
|
||||
|
||||
/**
|
||||
* table / group-list 是同一份配置的两种展示形态,可以互相切换(`TableGroupList.vue`)。
|
||||
@ -33,31 +31,6 @@ import type { GroupListConfig, GroupListHeaderConfig, TableColumnConfig, TableCo
|
||||
/** group-list 形态的 type(兼容驼峰与中划线两种写法) */
|
||||
export const isGroupListType = (type: unknown): boolean => type === 'groupList' || type === 'group-list';
|
||||
|
||||
/**
|
||||
* 归一成带单位的 CSS 长度。
|
||||
*
|
||||
* 配置常常来自 JSON 或表单输入,`'48'` 这类无单位数字串很常见;直接写进 CSS 变量会让
|
||||
* 嵌套层的 `calc()` 整体失效、吸顶静默失灵,所以纯数字一律补 `px`。
|
||||
*/
|
||||
const toCssLength = (height: number | string | undefined): string | undefined => {
|
||||
const value = `${height ?? ''}`.trim();
|
||||
if (!value) return undefined;
|
||||
|
||||
if (isNumber(value)) return `${value}px`;
|
||||
// 走到这里还是 number 的只剩 NaN / Infinity,不写变量,交给样式默认值兜底
|
||||
return typeof height === 'number' ? undefined : value;
|
||||
};
|
||||
|
||||
/** header.sticky 开启时把 height 写成 CSS 变量,给嵌套吸顶偏移用 */
|
||||
export const getGroupListHeaderVars = (
|
||||
header: GroupListHeaderConfig | undefined,
|
||||
): Record<string, string> | undefined => {
|
||||
if (!header?.sticky) return undefined;
|
||||
|
||||
const height = toCssLength(header.height);
|
||||
return height ? { '--m-group-list-header-height': height } : undefined;
|
||||
};
|
||||
|
||||
/** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px,最小 80px) */
|
||||
export const calcLabelWidth = (label: string): string => {
|
||||
if (!label) return '0px';
|
||||
|
||||
@ -202,63 +202,6 @@ describe('GroupList container', () => {
|
||||
).toBe(false);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('点击删除按钮移除对应项', async () => {
|
||||
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
|
||||
await nextTick();
|
||||
|
||||
await wrapper.findAll('.delete-button')[0].trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect((wrapper.vm as any).values.list).toEqual([{ text: 'b' }]);
|
||||
expect(wrapper.findAll('.m-fields-group-list-item')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('点击复制按钮在末尾追加一份副本', async () => {
|
||||
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
|
||||
await nextTick();
|
||||
|
||||
const copyButton = wrapper.findAll('button').find((btn) => btn.text().includes('复制'));
|
||||
await copyButton?.trigger('click');
|
||||
await nextTick();
|
||||
|
||||
const { list } = (wrapper.vm as any).values;
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[2]).toEqual({ text: 'a' });
|
||||
// 深拷贝,改副本不应牵动原项
|
||||
expect(list[2]).not.toBe(list[0]);
|
||||
});
|
||||
|
||||
test('点击下移 / 上移交换相邻两项', async () => {
|
||||
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
|
||||
await nextTick();
|
||||
|
||||
// 首项的「上移」与末项的「下移」只是 v-show 隐藏,仍在 DOM 里,按 item 作用域取才不会点错
|
||||
const buttonIn = (index: number, text: string) =>
|
||||
wrapper
|
||||
.findAll('.m-fields-group-list-item')
|
||||
[index].findAll('button')
|
||||
.find((btn) => btn.text().includes(text));
|
||||
|
||||
await buttonIn(0, '下移')?.trigger('click');
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).values.list).toEqual([{ text: 'b' }, { text: 'a' }]);
|
||||
|
||||
await buttonIn(1, '上移')?.trigger('click');
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).values.list).toEqual([{ text: 'a' }, { text: 'b' }]);
|
||||
});
|
||||
|
||||
test('移动越界时夹到列表两端,不丢项', async () => {
|
||||
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
|
||||
await nextTick();
|
||||
|
||||
const item = wrapper.findAllComponents({ name: 'MFormGroupListItem' })[1];
|
||||
item.vm.$emit('swap-item', 1, 5);
|
||||
await nextTick();
|
||||
|
||||
expect((wrapper.vm as any).values.list).toEqual([{ text: 'a' }, { text: 'b' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('labelPosition 透传', () => {
|
||||
@ -558,131 +501,6 @@ describe('GroupList container', () => {
|
||||
await nextTick();
|
||||
expect(wrapper.find('.m-fields-group-list-footer.is-sticky-full').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('添加');
|
||||
expect(wrapper.find('.m-fields-group-list').classes()).toContain('is-footer-sticky');
|
||||
});
|
||||
|
||||
// 没有吸底 footer 的层不能让内层凭空抬高
|
||||
test('未开 addButtonConfig.sticky 时根节点不带 is-footer-sticky', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'list',
|
||||
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||
},
|
||||
],
|
||||
{ list: [{ text: 'a' }] },
|
||||
);
|
||||
await nextTick();
|
||||
expect(wrapper.find('.m-fields-group-list').classes()).not.toContain('is-footer-sticky');
|
||||
});
|
||||
|
||||
test('对比模式下不渲染 footer,也不带 is-footer-sticky', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'list',
|
||||
addButtonConfig: { sticky: true, text: '添加', props: { type: 'primary', plain: true, text: false } },
|
||||
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||
},
|
||||
],
|
||||
{ list: [{ text: 'a' }] },
|
||||
{ isCompare: true, lastValues: { list: [{ text: 'a' }] } },
|
||||
);
|
||||
await nextTick();
|
||||
expect(wrapper.find('.m-fields-group-list-footer').exists()).toBe(false);
|
||||
expect(wrapper.find('.m-fields-group-list').classes()).not.toContain('is-footer-sticky');
|
||||
});
|
||||
|
||||
test('header.sticky 时根节点带 is-header-sticky', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'list',
|
||||
header: { sticky: true },
|
||||
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||
},
|
||||
],
|
||||
{ list: [{ text: 'a' }] },
|
||||
);
|
||||
await nextTick();
|
||||
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('未开 header.sticky 时不加吸顶 class', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'list',
|
||||
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||
},
|
||||
],
|
||||
{ list: [{ text: 'a' }] },
|
||||
);
|
||||
await nextTick();
|
||||
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('header.height 在 sticky 开启时写入 CSS 变量', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'list',
|
||||
header: { sticky: true, height: 48 },
|
||||
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||
},
|
||||
],
|
||||
{ list: [{ text: 'a' }] },
|
||||
);
|
||||
await nextTick();
|
||||
const el = wrapper.find('.m-fields-group-list').element as HTMLElement;
|
||||
expect(el.style.getPropertyValue('--m-group-list-header-height')).toBe('48px');
|
||||
});
|
||||
|
||||
test('仅配置 header.height 未开 sticky 时不写 CSS 变量', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'list',
|
||||
header: { height: 48 },
|
||||
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||
},
|
||||
],
|
||||
{ list: [{ text: 'a' }] },
|
||||
);
|
||||
await nextTick();
|
||||
const el = wrapper.find('.m-fields-group-list').element as HTMLElement;
|
||||
expect(el.style.getPropertyValue('--m-group-list-header-height')).toBe('');
|
||||
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('嵌套列表各自按 header.sticky 决定是否吸顶', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'groups',
|
||||
header: { sticky: true },
|
||||
items: [
|
||||
{
|
||||
type: 'group-list',
|
||||
name: 'cond',
|
||||
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{ groups: [{ cond: [{ text: 'a' }] }] },
|
||||
);
|
||||
await nextTick();
|
||||
const lists = wrapper.findAll('.m-fields-group-list');
|
||||
expect(lists[0].classes()).toContain('is-header-sticky');
|
||||
expect(lists[1].classes()).not.toContain('is-header-sticky');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { getGroupListHeaderVars, getGroupListRowConfig } from '@form/utils/tableGroupList';
|
||||
import { getGroupListRowConfig } from '@form/utils/tableGroupList';
|
||||
|
||||
describe('getGroupListRowConfig', () => {
|
||||
test('把 group-list 的 labelWidth / labelPosition 复制到 row 配置', () => {
|
||||
@ -26,47 +26,3 @@ describe('getGroupListRowConfig', () => {
|
||||
expect(row.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupListHeaderVars', () => {
|
||||
test('未开 sticky 时不写样式', () => {
|
||||
expect(getGroupListHeaderVars(undefined)).toBeUndefined();
|
||||
expect(getGroupListHeaderVars({})).toBeUndefined();
|
||||
expect(getGroupListHeaderVars({ height: 48 })).toBeUndefined();
|
||||
});
|
||||
|
||||
test('sticky 但 height 为空时不写变量,走样式默认值', () => {
|
||||
expect(getGroupListHeaderVars({ sticky: true })).toBeUndefined();
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: '' })).toBeUndefined();
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: ' ' })).toBeUndefined();
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: Number.NaN })).toBeUndefined();
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: Number.POSITIVE_INFINITY })).toBeUndefined();
|
||||
});
|
||||
|
||||
test('数字 height 转成 px', () => {
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: 48 })).toEqual({
|
||||
'--m-group-list-header-height': '48px',
|
||||
});
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: 0 })).toEqual({
|
||||
'--m-group-list-header-height': '0px',
|
||||
});
|
||||
});
|
||||
|
||||
// 无单位的值会让嵌套层的 calc() 整体失效、吸顶静默失灵
|
||||
test('无单位数字字符串补 px', () => {
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: '48' })).toEqual({
|
||||
'--m-group-list-header-height': '48px',
|
||||
});
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: ' 47.5 ' })).toEqual({
|
||||
'--m-group-list-header-height': '47.5px',
|
||||
});
|
||||
});
|
||||
|
||||
test('带单位的字符串 height 原样写入', () => {
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: '4em' })).toEqual({
|
||||
'--m-group-list-header-height': '4em',
|
||||
});
|
||||
expect(getGroupListHeaderVars({ sticky: true, height: 'var(--x)' })).toEqual({
|
||||
'--m-group-list-header-height': 'var(--x)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/schema",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/stage",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/table",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/tdesign-vue-next-adapter",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"name": "@tmagic/utils",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tmagic-playground",
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@ -12,11 +12,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@tmagic/core": "1.8.0-beta.28",
|
||||
"@tmagic/design": "1.8.0-beta.28",
|
||||
"@tmagic/editor": "1.8.0-beta.28",
|
||||
"@tmagic/element-plus-adapter": "1.8.0-beta.28",
|
||||
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.28",
|
||||
"@tmagic/core": "1.8.0-beta.27",
|
||||
"@tmagic/design": "1.8.0-beta.27",
|
||||
"@tmagic/editor": "1.8.0-beta.27",
|
||||
"@tmagic/element-plus-adapter": "1.8.0-beta.27",
|
||||
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.27",
|
||||
"@tmagic/tmagic-form-runtime": "1.1.6",
|
||||
"element-plus": "catalog:",
|
||||
"lodash-es": "^4.18.1",
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@ -567,19 +567,19 @@ importers:
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2(vue@3.5.41(typescript@6.0.3))
|
||||
'@tmagic/core':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../packages/core
|
||||
'@tmagic/design':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../packages/design
|
||||
'@tmagic/editor':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../packages/editor
|
||||
'@tmagic/element-plus-adapter':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../packages/element-plus-adapter
|
||||
'@tmagic/tdesign-vue-next-adapter':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../packages/tdesign-vue-next-adapter
|
||||
'@tmagic/tmagic-form-runtime':
|
||||
specifier: 1.1.6
|
||||
@ -923,13 +923,13 @@ importers:
|
||||
runtime/react:
|
||||
dependencies:
|
||||
'@tmagic/core':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../../packages/core
|
||||
'@tmagic/react-runtime-help':
|
||||
specifier: 0.2.2
|
||||
version: link:../react-runtime-help
|
||||
'@tmagic/stage':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../../packages/stage
|
||||
axios:
|
||||
specifier: ^1.19.0
|
||||
@ -945,7 +945,7 @@ importers:
|
||||
version: 19.2.8(react@19.2.8)
|
||||
devDependencies:
|
||||
'@tmagic/cli':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../../packages/cli
|
||||
'@types/fs-extra':
|
||||
specifier: ^11.0.4
|
||||
@ -1021,10 +1021,10 @@ importers:
|
||||
runtime/vue:
|
||||
dependencies:
|
||||
'@tmagic/core':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../../packages/core
|
||||
'@tmagic/stage':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../../packages/stage
|
||||
'@tmagic/vue-runtime-help':
|
||||
specifier: ^2.0.4
|
||||
@ -1037,7 +1037,7 @@ importers:
|
||||
version: 3.5.41(typescript@6.0.3)
|
||||
devDependencies:
|
||||
'@tmagic/cli':
|
||||
specifier: 1.8.0-beta.28
|
||||
specifier: 1.8.0-beta.27
|
||||
version: link:../../packages/cli
|
||||
'@types/fs-extra':
|
||||
specifier: ^11.0.4
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "runtime-react",
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"engines": {
|
||||
@ -16,16 +16,16 @@
|
||||
"build:playground": "node scripts/build.mjs --type=playground"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tmagic/core": "1.8.0-beta.28",
|
||||
"@tmagic/core": "1.8.0-beta.27",
|
||||
"@tmagic/react-runtime-help": "0.2.2",
|
||||
"@tmagic/stage": "1.8.0-beta.28",
|
||||
"@tmagic/stage": "1.8.0-beta.27",
|
||||
"axios": "^1.19.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tmagic/cli": "1.8.0-beta.28",
|
||||
"@tmagic/cli": "1.8.0-beta.27",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "runtime-vue",
|
||||
"version": "1.8.0-beta.28",
|
||||
"version": "1.8.0-beta.27",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"engines": {
|
||||
@ -16,14 +16,14 @@
|
||||
"build:playground": "node scripts/build.mjs --type=playground"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tmagic/core": "1.8.0-beta.28",
|
||||
"@tmagic/stage": "1.8.0-beta.28",
|
||||
"@tmagic/core": "1.8.0-beta.27",
|
||||
"@tmagic/stage": "1.8.0-beta.27",
|
||||
"@tmagic/vue-runtime-help": "^2.0.4",
|
||||
"axios": "^1.19.0",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tmagic/cli": "1.8.0-beta.28",
|
||||
"@tmagic/cli": "1.8.0-beta.27",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^26.1.2",
|
||||
"@vitejs/plugin-legacy": "^8.2.2",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user