mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-14 07:59:46 +00:00
Compare commits
5 Commits
v1.8.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a3edacb96 | ||
|
|
911cecfb1d | ||
|
|
2a4a54a495 | ||
|
|
7e4546899b | ||
|
|
66b54a52f8 |
11
CHANGELOG.md
11
CHANGELOG.md
@ -1,3 +1,14 @@
|
|||||||
|
# [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)
|
# [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 配置类型定义
|
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig / GroupListHeaderConfig 配置类型定义
|
||||||
<<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts}
|
<<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts}
|
||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#RowConfig{ts}
|
<<< @/../packages/form-schema/src/base.ts#RowConfig{ts}
|
||||||
@ -27,6 +27,8 @@
|
|||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts}
|
<<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/form-schema/src/base.ts#GroupListHeaderConfig{ts}
|
||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#FormItem{ts}
|
<<< @/../packages/form-schema/src/base.ts#FormItem{ts}
|
||||||
|
|
||||||
:::
|
:::
|
||||||
@ -211,6 +213,27 @@
|
|||||||
}]
|
}]
|
||||||
}]"></demo-block>
|
}]"></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
|
### table
|
||||||
|
|
||||||
<demo-block type="form" :config="[{
|
<demo-block type="form" :config="[{
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "tmagic",
|
"name": "tmagic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/cli",
|
"name": "@tmagic/cli",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/core",
|
"name": "@tmagic/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/data-source",
|
"name": "@tmagic/data-source",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/dep",
|
"name": "@tmagic/dep",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/design",
|
"name": "@tmagic/design",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/editor",
|
"name": "@tmagic/editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -17,12 +17,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 属性面板列表字段共用的吸底全宽「添加」按钮,展开到 group-list 配置上。
|
* 属性面板列表字段共用的吸底全宽「添加」按钮与吸顶标题,展开到 group-list 配置上。
|
||||||
*
|
*
|
||||||
* 吸底按钮会盖住列表底部,新增的项必须同时滚进视口才看得见,两者一起给出避免漏配。
|
* 吸底按钮会盖住列表底部,吸顶标题会盖住列表顶部,新增的项必须同时滚进视口才看得见。
|
||||||
*/
|
*/
|
||||||
export const stickyAddButton = (text: string) => ({
|
export const stickyAddButton = (text: string) => ({
|
||||||
scrollLastItemIntoView: true as const,
|
scrollLastItemIntoView: true as const,
|
||||||
|
header: { sticky: true as const },
|
||||||
addButtonConfig: {
|
addButtonConfig: {
|
||||||
sticky: true as const,
|
sticky: true as const,
|
||||||
text,
|
text,
|
||||||
|
|||||||
@ -427,12 +427,26 @@ 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 updateStageDsl = async (value: MApp | null) => {
|
||||||
const stage = await getStage();
|
const stage = await getStage();
|
||||||
|
|
||||||
const runtime = await stage.renderer?.getRuntime();
|
const runtime = await stage.renderer?.getRuntime();
|
||||||
const app = await getTMagicApp();
|
const app = await getTMagicApp();
|
||||||
|
|
||||||
|
// 等 stage / runtime 就绪期间 root 已被替换:新 root 的刷新可能已经完成,
|
||||||
|
// 再把旧 dsl 推给 runtime 会让画布回退到旧内容
|
||||||
|
if (isStaleRoot(value)) return;
|
||||||
|
|
||||||
if (!app?.dataSourceManager) {
|
if (!app?.dataSourceManager) {
|
||||||
runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!);
|
runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!);
|
||||||
}
|
}
|
||||||
@ -449,6 +463,8 @@ export const initServiceEvents = (
|
|||||||
|
|
||||||
await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value));
|
await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value));
|
||||||
|
|
||||||
|
if (isStaleRoot(value)) return;
|
||||||
|
|
||||||
const dsl = cloneDeep(toRaw(value));
|
const dsl = cloneDeep(toRaw(value));
|
||||||
if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) {
|
if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) {
|
||||||
for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) {
|
for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) {
|
||||||
@ -466,7 +482,7 @@ export const initServiceEvents = (
|
|||||||
depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
|
depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
|
||||||
};
|
};
|
||||||
|
|
||||||
const rootChangeHandler = (value: MApp | null, preValue?: MApp | null) => {
|
const rootChangeHandler = (value: MApp | null) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|
||||||
value.codeBlocks = value.codeBlocks || {};
|
value.codeBlocks = value.codeBlocks || {};
|
||||||
@ -509,7 +525,13 @@ export const initServiceEvents = (
|
|||||||
editorService.set('page', null);
|
editorService.set('page', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toRaw(value) !== toRaw(preValue)) {
|
// 上面的 select 是异步的,期间 root 可能已被替换,过期快照不能再回写给外部:
|
||||||
|
// 两次整体替换各自持有一个快照时,回写会把对方的 root 顶掉,外部 modelValue 变化又会
|
||||||
|
// 重新 set root,两条链路无休止地交替下去(表现为编辑器卡死)
|
||||||
|
if (isStaleRoot(value)) return;
|
||||||
|
|
||||||
|
// 外部已经持有这个 root(如本次变化就是 modelValue 传进来的)时无需回写
|
||||||
|
if (toRaw(props.modelValue) !== toRaw(value)) {
|
||||||
emit('update:modelValue', value);
|
emit('update:modelValue', value);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -204,10 +204,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
|
|||||||
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
||||||
* 用户取消时返回 false,调用方据此中止回滚。
|
* 用户取消时返回 false,调用方据此中止回滚。
|
||||||
*/
|
*/
|
||||||
const confirmRevert = (): Promise<boolean> =>
|
const confirmRevert = (): Promise<boolean> => confirmHistoryAction('确定回滚该步骤吗?');
|
||||||
confirmHistoryAction(
|
|
||||||
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「回滚」统一确认入口:可差异对比的步骤动态挂载 HistoryDiffDialog 走差异确认弹窗,
|
* 「回滚」统一确认入口:可差异对比的步骤动态挂载 HistoryDiffDialog 走差异确认弹窗,
|
||||||
|
|||||||
@ -56,7 +56,14 @@ import { calcValueByFontsize, getIdFromEl } from '@tmagic/utils';
|
|||||||
import ScrollViewer from '@editor/components/ScrollViewer.vue';
|
import ScrollViewer from '@editor/components/ScrollViewer.vue';
|
||||||
import { useServices } from '@editor/hooks';
|
import { useServices } from '@editor/hooks';
|
||||||
import { useStage } from '@editor/hooks/use-stage';
|
import { useStage } from '@editor/hooks/use-stage';
|
||||||
import type { CustomContentMenuFunction, MenuButton, MenuComponent, StageOptions, StageSlots } from '@editor/type';
|
import type {
|
||||||
|
AddMNode,
|
||||||
|
CustomContentMenuFunction,
|
||||||
|
MenuButton,
|
||||||
|
MenuComponent,
|
||||||
|
StageOptions,
|
||||||
|
StageSlots,
|
||||||
|
} from '@editor/type';
|
||||||
import { DragType, Layout } from '@editor/type';
|
import { DragType, Layout } from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
import { KeyBindingContainerKey } from '@editor/utils/keybinding-config';
|
import { KeyBindingContainerKey } from '@editor/utils/keybinding-config';
|
||||||
@ -270,11 +277,56 @@ 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(() => {
|
onMounted(() => {
|
||||||
if (stageWrapRef.value?.container) {
|
if (stageWrapRef.value?.container) {
|
||||||
resizeObserver.observe(stageWrapRef.value.container);
|
resizeObserver.observe(stageWrapRef.value.container);
|
||||||
keybindingService.registerEl(KeyBindingContainerKey.STAGE, stageWrapRef.value.container);
|
keybindingService.registerEl(KeyBindingContainerKey.STAGE, stageWrapRef.value.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用捕获阶段监听,确保拖拽源自身的 dragstart 无论是否阻止冒泡都能被记录
|
||||||
|
globalThis.document.addEventListener('dragstart', documentDragStartHandler, true);
|
||||||
|
globalThis.document.addEventListener('dragend', documentDragEndHandler, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@ -283,9 +335,15 @@ onBeforeUnmount(() => {
|
|||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
editorService.set('stage', null);
|
editorService.set('stage', null);
|
||||||
keybindingService.unregisterEl('stage');
|
keybindingService.unregisterEl('stage');
|
||||||
});
|
|
||||||
|
|
||||||
const parseDSL = getEditorConfig('parseDSL');
|
if (clearInternalDragTimer !== undefined) {
|
||||||
|
globalThis.clearTimeout(clearInternalDragTimer);
|
||||||
|
clearInternalDragTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.document.removeEventListener('dragstart', documentDragStartHandler, true);
|
||||||
|
globalThis.document.removeEventListener('dragend', documentDragEndHandler, true);
|
||||||
|
});
|
||||||
|
|
||||||
const contextmenuHandler = (e: MouseEvent) => {
|
const contextmenuHandler = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -299,15 +357,27 @@ const dragoverHandler = (e: DragEvent) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dropHandler = async (e: DragEvent) => {
|
const dropHandler = async (e: DragEvent) => {
|
||||||
|
// 画布上的任意 drop 都消费本次内部拖拽标记(含空 text/json),避免残留到下一次 drop
|
||||||
|
const allowed = isInternalDrag;
|
||||||
|
isInternalDrag = false;
|
||||||
|
|
||||||
if (!e.dataTransfer) return;
|
if (!e.dataTransfer) return;
|
||||||
|
|
||||||
const data = e.dataTransfer.getData('text/json');
|
const data = e.dataTransfer.getData('text/json');
|
||||||
|
|
||||||
if (!data) return;
|
// 外部源投递的拖拽数据不可信,不能交给 parseDSL(默认实现为 eval)
|
||||||
|
if (!data || !allowed) return;
|
||||||
|
|
||||||
const config = parseDSL(`(${data})`);
|
let config: { dragType?: string; data?: AddMNode } | undefined;
|
||||||
|
try {
|
||||||
|
config = parseDSL(`(${data})`);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!config || config.dragType !== DragType.COMPONENT_LIST) return;
|
if (!config || config.dragType !== DragType.COMPONENT_LIST || !config.data) return;
|
||||||
|
|
||||||
|
const dragData = config.data;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@ -349,7 +419,7 @@ const dropHandler = async (e: DragEvent) => {
|
|||||||
|
|
||||||
const containerRect = stageContainerEl.value.getBoundingClientRect();
|
const containerRect = stageContainerEl.value.getBoundingClientRect();
|
||||||
const { scrollTop, scrollLeft } = stage.mask!;
|
const { scrollTop, scrollLeft } = stage.mask!;
|
||||||
const { style = {} } = config.data;
|
const { style = {} } = dragData;
|
||||||
|
|
||||||
let top = 0;
|
let top = 0;
|
||||||
let left = 0;
|
let left = 0;
|
||||||
@ -371,16 +441,16 @@ const dropHandler = async (e: DragEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.data.style = {
|
dragData.style = {
|
||||||
...style,
|
...style,
|
||||||
position,
|
position,
|
||||||
top: calcValueByFontsize(doc, top / zoom.value),
|
top: calcValueByFontsize(doc, top / zoom.value),
|
||||||
left: calcValueByFontsize(doc, left / zoom.value),
|
left: calcValueByFontsize(doc, left / zoom.value),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.data.inputEvent = e;
|
dragData.inputEvent = e;
|
||||||
|
|
||||||
editorService.add(config.data, parent, { historySource: 'component-panel' });
|
editorService.add(dragData, parent, { historySource: 'component-panel' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -122,6 +122,7 @@ describe('CodeSelect', () => {
|
|||||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||||
const config = container.props('config') as any;
|
const config = container.props('config') as any;
|
||||||
expect(config.scrollLastItemIntoView).toBe(true);
|
expect(config.scrollLastItemIntoView).toBe(true);
|
||||||
|
expect(config.header.sticky).toBe(true);
|
||||||
expect(config.addButtonConfig.sticky).toBe(true);
|
expect(config.addButtonConfig.sticky).toBe(true);
|
||||||
expect(config.addButtonConfig.text).toBe('添加');
|
expect(config.addButtonConfig.text).toBe('添加');
|
||||||
const codeTypeSelect = config.items[0];
|
const codeTypeSelect = config.items[0];
|
||||||
|
|||||||
@ -84,6 +84,7 @@ describe('DisplayConds', () => {
|
|||||||
expect(capturedConfig.type).toBe('groupList');
|
expect(capturedConfig.type).toBe('groupList');
|
||||||
expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
|
expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
|
||||||
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
|
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
|
||||||
|
expect(capturedConfig.header.sticky).toBe(true);
|
||||||
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
|
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
|
||||||
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
|
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
|
||||||
expect(capturedConfig.items[0].type).toBe('groupList');
|
expect(capturedConfig.items[0].type).toBe('groupList');
|
||||||
@ -93,6 +94,7 @@ describe('DisplayConds', () => {
|
|||||||
expect(capturedConfig.items[0].movable).toBe(false);
|
expect(capturedConfig.items[0].movable).toBe(false);
|
||||||
expect(capturedConfig.items[0].flat).toBe(true);
|
expect(capturedConfig.items[0].flat).toBe(true);
|
||||||
expect(capturedConfig.items[0].scrollLastItemIntoView).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.sticky).toBe(true);
|
||||||
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增条件');
|
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增条件');
|
||||||
expect(capturedConfig.items[0].items.every((item: any) => item.span === undefined)).toBe(true);
|
expect(capturedConfig.items[0].items.every((item: any) => item.span === undefined)).toBe(true);
|
||||||
|
|||||||
@ -133,11 +133,13 @@ describe('EventSelect', () => {
|
|||||||
expect(wrapper.find('.fake-table').exists()).toBe(false);
|
expect(wrapper.find('.fake-table').exists()).toBe(false);
|
||||||
expect(capturedConfig.type).toBe('group-list');
|
expect(capturedConfig.type).toBe('group-list');
|
||||||
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
|
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
|
||||||
|
expect(capturedConfig.header.sticky).toBe(true);
|
||||||
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
|
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
|
||||||
expect(capturedConfig.addButtonConfig.text).toBe('添加事件');
|
expect(capturedConfig.addButtonConfig.text).toBe('添加事件');
|
||||||
expect(capturedConfig.defaultAdd).toEqual({ name: '', actions: [] });
|
expect(capturedConfig.defaultAdd).toEqual({ name: '', actions: [] });
|
||||||
expect(capturedConfig.movable).toBe(false);
|
expect(capturedConfig.movable).toBe(false);
|
||||||
expect(capturedConfig.items[0].scrollLastItemIntoView).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.sticky).toBe(true);
|
||||||
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作');
|
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -357,7 +357,6 @@ describe('initServiceEvents', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('rootChange 处理代码块和数据源', async () => {
|
test('rootChange 处理代码块和数据源', async () => {
|
||||||
services.editorService.state.root = { id: 'r' };
|
|
||||||
mount(WrapEvents({} as any, emit, services));
|
mount(WrapEvents({} as any, emit, services));
|
||||||
const value: any = {
|
const value: any = {
|
||||||
id: 'r',
|
id: 'r',
|
||||||
@ -365,6 +364,8 @@ describe('initServiceEvents', () => {
|
|||||||
dataSources: [{ id: 'd1', type: 'base' }],
|
dataSources: [{ id: 'd1', type: 'base' }],
|
||||||
items: [],
|
items: [],
|
||||||
};
|
};
|
||||||
|
// set('root', v) 是先赋值再派发事件,mock 中同样先对齐 state 再 emit
|
||||||
|
services.editorService.state.root = value;
|
||||||
services.editorService.emit('root-change', value, null);
|
services.editorService.emit('root-change', value, null);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
expect(services.codeBlockService.setCodeDsl).toHaveBeenCalled();
|
expect(services.codeBlockService.setCodeDsl).toHaveBeenCalled();
|
||||||
@ -593,13 +594,15 @@ describe('initServiceEvents', () => {
|
|||||||
services.editorService.state.node = { id: 'n1' };
|
services.editorService.state.node = { id: 'n1' };
|
||||||
mount(WrapEvents({} as any, emit, services));
|
mount(WrapEvents({} as any, emit, services));
|
||||||
|
|
||||||
services.editorService.emit('root-change', {
|
const value: any = {
|
||||||
id: 'r',
|
id: 'r',
|
||||||
items: [{ id: 'n1', type: 'text' }],
|
items: [{ id: 'n1', type: 'text' }],
|
||||||
dataSources: [],
|
dataSources: [],
|
||||||
dataSourceDeps: { d1: {} },
|
dataSourceDeps: { d1: {} },
|
||||||
codeBlocks: {},
|
codeBlocks: {},
|
||||||
});
|
};
|
||||||
|
services.editorService.state.root = value;
|
||||||
|
services.editorService.emit('root-change', value);
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
expect(stage.runtime.updatePageId).toHaveBeenCalledWith('p1');
|
expect(stage.runtime.updatePageId).toHaveBeenCalledWith('p1');
|
||||||
@ -608,10 +611,10 @@ describe('initServiceEvents', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('rootChange items 不是数组时清空依赖', async () => {
|
test('rootChange items 不是数组时清空依赖', async () => {
|
||||||
services.editorService.state.root = { id: 'r' };
|
|
||||||
mount(WrapEvents({} as any, emit, services));
|
mount(WrapEvents({} as any, emit, services));
|
||||||
|
|
||||||
const value: any = { id: 'r', dataSourceDeps: { a: {} }, dataSourceCondDeps: { b: {} } };
|
const value: any = { id: 'r', dataSourceDeps: { a: {} }, dataSourceCondDeps: { b: {} } };
|
||||||
|
services.editorService.state.root = value;
|
||||||
services.editorService.emit('root-change', value);
|
services.editorService.emit('root-change', value);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
@ -625,6 +628,7 @@ describe('initServiceEvents', () => {
|
|||||||
mount(WrapEvents({} as any, emit, services));
|
mount(WrapEvents({} as any, emit, services));
|
||||||
|
|
||||||
const value: any = { id: 'r', items: [] };
|
const value: any = { id: 'r', items: [] };
|
||||||
|
services.editorService.state.root = value;
|
||||||
services.editorService.emit('root-change', value, { id: 'prev' });
|
services.editorService.emit('root-change', value, { id: 'prev' });
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
@ -638,12 +642,44 @@ describe('initServiceEvents', () => {
|
|||||||
services.editorService.getNodeById.mockReturnValue(null);
|
services.editorService.getNodeById.mockReturnValue(null);
|
||||||
mount(WrapEvents({} as any, emit, services));
|
mount(WrapEvents({} as any, emit, services));
|
||||||
|
|
||||||
services.editorService.emit('root-change', { id: 'r', items: [{ id: 'first', type: 'page' }] });
|
const value: any = { id: 'r', items: [{ id: 'first', type: 'page' }] };
|
||||||
|
services.editorService.state.root = value;
|
||||||
|
services.editorService.emit('root-change', value);
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
expect(services.editorService.select).toHaveBeenCalledWith({ id: 'first', type: 'page' });
|
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 () => {
|
test('update 事件:ROOT 节点、无 propPath、命中已收集依赖三种分支', async () => {
|
||||||
services.editorService.state.root = { id: 'r', items: [] };
|
services.editorService.state.root = { id: 'r', items: [] };
|
||||||
services.depService.getTargets.mockReturnValue({
|
services.depService.getTargets.mockReturnValue({
|
||||||
|
|||||||
@ -88,8 +88,14 @@ const diffableGroups = (id: string | number = 'p1') => [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 让 getHistoryGroups 只对指定类别返回可对比分组,避免三类历史互相串味。 */
|
||||||
|
const groupsFor = (category: string, id: string | number) => (type: string) =>
|
||||||
|
type === category ? diffableGroups(id) : [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
appMock._context = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useHistoryRevert', () => {
|
describe('useHistoryRevert', () => {
|
||||||
@ -226,4 +232,255 @@ describe('useHistoryRevert', () => {
|
|||||||
expect(result).toBe('done');
|
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,11 +55,14 @@ vi.mock('@editor/hooks', () => ({
|
|||||||
useServices: () => ({ editorService, uiService, keybindingService, stageOverlayService }),
|
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', () => ({
|
vi.mock('@editor/utils/config', () => ({
|
||||||
getEditorConfig: vi.fn(() => (s: string) => {
|
getEditorConfig: vi.fn(() => parseDSL),
|
||||||
if (s.startsWith('(')) return JSON.parse(s.slice(1, -1));
|
|
||||||
return JSON.parse(s);
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/components/ScrollViewer.vue', () => ({
|
vi.mock('@editor/components/ScrollViewer.vue', () => ({
|
||||||
@ -146,6 +149,26 @@ const mountIt = (props: any = {}) =>
|
|||||||
attachTo: document.body,
|
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', () => {
|
describe('Stage', () => {
|
||||||
test('挂载并创建 stage', async () => {
|
test('挂载并创建 stage', async () => {
|
||||||
const wrapper = mountIt();
|
const wrapper = mountIt();
|
||||||
@ -185,17 +208,28 @@ describe('Stage', () => {
|
|||||||
editorService.getNodeById.mockReturnValue(null);
|
editorService.getNodeById.mockReturnValue(null);
|
||||||
const wrapper = mountIt();
|
const wrapper = mountIt();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const event: any = new Event('drop');
|
const event = createDropEvent(COMPONENT_LIST_JSON);
|
||||||
event.dataTransfer = {
|
|
||||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
|
|
||||||
};
|
|
||||||
event.clientX = 100;
|
event.clientX = 100;
|
||||||
event.clientY = 100;
|
event.clientY = 100;
|
||||||
event.preventDefault = vi.fn();
|
|
||||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||||
|
startInternalDrag();
|
||||||
stageContainer.dispatchEvent(event);
|
stageContainer.dispatchEvent(event);
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await waitMacrotask();
|
||||||
expect(editorService.add).toHaveBeenCalled();
|
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('zoom 变化时 stage.setZoom 被调用', async () => {
|
test('zoom 变化时 stage.setZoom 被调用', async () => {
|
||||||
@ -218,48 +252,163 @@ describe('Stage', () => {
|
|||||||
expect(wrapper.find('.fake-overlay').exists()).toBe(false);
|
expect(wrapper.find('.fake-overlay').exists()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('drop 数据为空时不处理', async () => {
|
test('drop 数据为空时消费内部拖拽标记,后续 drop 不再解析', async () => {
|
||||||
const wrapper = mountIt();
|
const wrapper = mountIt();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const event: any = new Event('drop');
|
|
||||||
event.dataTransfer = { getData: vi.fn(() => '') };
|
|
||||||
event.preventDefault = vi.fn();
|
|
||||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
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;
|
||||||
stageContainer.dispatchEvent(event);
|
stageContainer.dispatchEvent(event);
|
||||||
|
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
|
||||||
|
await waitMacrotask();
|
||||||
|
expect(parseDSL).not.toHaveBeenCalled();
|
||||||
expect(editorService.add).not.toHaveBeenCalled();
|
expect(editorService.add).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('drop 非 COMPONENT_LIST 时不处理', async () => {
|
test('drop 非 COMPONENT_LIST 时不处理', async () => {
|
||||||
const wrapper = mountIt();
|
const wrapper = mountIt();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const event: any = new Event('drop');
|
const event = createDropEvent('{"dragType":"other","data":{"name":"text","style":{}}}');
|
||||||
event.dataTransfer = {
|
|
||||||
getData: vi.fn(() => '{"dragType":"other","data":{"name":"text","style":{}}}'),
|
|
||||||
};
|
|
||||||
event.preventDefault = vi.fn();
|
|
||||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||||
|
startInternalDrag();
|
||||||
stageContainer.dispatchEvent(event);
|
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));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
expect(editorService.add).not.toHaveBeenCalled();
|
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 () => {
|
test('drop position fixed 计算位置', async () => {
|
||||||
editorService.getNodeById.mockReturnValue(null);
|
editorService.getNodeById.mockReturnValue(null);
|
||||||
editorService.getLayout.mockResolvedValue('relative');
|
editorService.getLayout.mockResolvedValue('relative');
|
||||||
const wrapper = mountIt();
|
const wrapper = mountIt();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const event: any = new Event('drop');
|
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}');
|
||||||
event.dataTransfer = {
|
|
||||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}'),
|
|
||||||
};
|
|
||||||
event.clientX = 80;
|
event.clientX = 80;
|
||||||
event.clientY = 60;
|
event.clientY = 60;
|
||||||
event.preventDefault = vi.fn();
|
|
||||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||||
Object.defineProperty(stageContainer, 'getBoundingClientRect', {
|
Object.defineProperty(stageContainer, 'getBoundingClientRect', {
|
||||||
value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
startInternalDrag();
|
||||||
stageContainer.dispatchEvent(event);
|
stageContainer.dispatchEvent(event);
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
const args = editorService.add.mock.calls[0][0];
|
const args = editorService.add.mock.calls[0][0];
|
||||||
@ -271,18 +420,15 @@ describe('Stage', () => {
|
|||||||
editorService.getLayout.mockResolvedValue('absolute');
|
editorService.getLayout.mockResolvedValue('absolute');
|
||||||
const wrapper = mountIt();
|
const wrapper = mountIt();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const event: any = new Event('drop');
|
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
|
||||||
event.dataTransfer = {
|
|
||||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
|
|
||||||
};
|
|
||||||
event.clientX = 80;
|
event.clientX = 80;
|
||||||
event.clientY = 60;
|
event.clientY = 60;
|
||||||
event.preventDefault = vi.fn();
|
|
||||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||||
Object.defineProperty(stageContainer, 'getBoundingClientRect', {
|
Object.defineProperty(stageContainer, 'getBoundingClientRect', {
|
||||||
value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
startInternalDrag();
|
||||||
stageContainer.dispatchEvent(event);
|
stageContainer.dispatchEvent(event);
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
const args = editorService.add.mock.calls[0][0];
|
const args = editorService.add.mock.calls[0][0];
|
||||||
@ -296,14 +442,11 @@ describe('Stage', () => {
|
|||||||
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', canDropIn },
|
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', canDropIn },
|
||||||
});
|
});
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const event: any = new Event('drop');
|
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
|
||||||
event.dataTransfer = {
|
|
||||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
|
|
||||||
};
|
|
||||||
event.clientX = 50;
|
event.clientX = 50;
|
||||||
event.clientY = 50;
|
event.clientY = 50;
|
||||||
event.preventDefault = vi.fn();
|
|
||||||
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
const stageContainer = wrapper.find('.m-editor-stage-container').element;
|
||||||
|
startInternalDrag();
|
||||||
stageContainer.dispatchEvent(event);
|
stageContainer.dispatchEvent(event);
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
expect(canDropIn).toHaveBeenCalled();
|
expect(canDropIn).toHaveBeenCalled();
|
||||||
@ -414,13 +557,10 @@ describe('Stage', () => {
|
|||||||
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'h', canDropIn },
|
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'h', canDropIn },
|
||||||
});
|
});
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const event: any = new Event('drop');
|
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
|
||||||
event.dataTransfer = {
|
|
||||||
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
|
|
||||||
};
|
|
||||||
event.clientX = 1;
|
event.clientX = 1;
|
||||||
event.clientY = 1;
|
event.clientY = 1;
|
||||||
event.preventDefault = vi.fn();
|
startInternalDrag();
|
||||||
wrapper.find('.m-editor-stage-container').element.dispatchEvent(event);
|
wrapper.find('.m-editor-stage-container').element.dispatchEvent(event);
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
expect(canDropIn).toHaveBeenCalled();
|
expect(canDropIn).toHaveBeenCalled();
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/element-plus-adapter",
|
"name": "@tmagic/element-plus-adapter",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/form-schema",
|
"name": "@tmagic/form-schema",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -830,6 +830,20 @@ export interface AddButtonConfig {
|
|||||||
}
|
}
|
||||||
// #endregion AddButtonConfig
|
// #endregion AddButtonConfig
|
||||||
|
|
||||||
|
// #region GroupListHeaderConfig
|
||||||
|
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
|
||||||
|
export interface GroupListHeaderConfig {
|
||||||
|
/** 标题吸顶 */
|
||||||
|
sticky?: boolean;
|
||||||
|
/**
|
||||||
|
* 告诉嵌套列表「本层吸顶标题占了多高」,内层标题据此下移让位。
|
||||||
|
* **不会改变标题自身的高度**,只在实际标题高度偏离默认值 65px 时才需要配。
|
||||||
|
* 数字与纯数字字符串按 px,其余按 CSS 长度原样使用。
|
||||||
|
*/
|
||||||
|
height?: number | string;
|
||||||
|
}
|
||||||
|
// #endregion GroupListHeaderConfig
|
||||||
|
|
||||||
// #region TableGroupListCommonConfig
|
// #region TableGroupListCommonConfig
|
||||||
export interface TableGroupListCommonConfig extends FormItem {
|
export interface TableGroupListCommonConfig extends FormItem {
|
||||||
type: 'table' | 'groupList' | 'group-list';
|
type: 'table' | 'groupList' | 'group-list';
|
||||||
@ -846,6 +860,8 @@ export interface TableGroupListCommonConfig extends FormItem {
|
|||||||
/** 新增后滚动到最后一项(group-list 形态,默认关闭) */
|
/** 新增后滚动到最后一项(group-list 形态,默认关闭) */
|
||||||
scrollLastItemIntoView?: boolean;
|
scrollLastItemIntoView?: boolean;
|
||||||
addButtonConfig?: AddButtonConfig;
|
addButtonConfig?: AddButtonConfig;
|
||||||
|
/** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */
|
||||||
|
header?: GroupListHeaderConfig;
|
||||||
}
|
}
|
||||||
// #endregion TableGroupListCommonConfig
|
// #endregion TableGroupListCommonConfig
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/form",
|
"name": "@tmagic/form",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="m-fields-group-list">
|
<div
|
||||||
|
class="m-fields-group-list"
|
||||||
|
:class="{
|
||||||
|
'is-header-sticky': Boolean(config.header?.sticky),
|
||||||
|
'is-footer-sticky': isFooterSticky($slots),
|
||||||
|
}"
|
||||||
|
:style="headerVars"
|
||||||
|
>
|
||||||
<div v-if="config.extra" v-html="config.extra" style="color: rgba(0, 0, 0, 0.45)"></div>
|
<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">
|
<div v-if="!displayItems.length" class="el-table__empty-block">
|
||||||
<span class="el-table__empty-text t-table__empty">暂无{{ config.titlePrefix || '' }}数据</span>
|
<span class="el-table__empty-text t-table__empty">暂无{{ config.titlePrefix || '' }}数据</span>
|
||||||
@ -34,7 +41,7 @@
|
|||||||
<div
|
<div
|
||||||
class="m-fields-group-list-footer"
|
class="m-fields-group-list-footer"
|
||||||
:class="{ 'is-sticky-full': Boolean(config.addButtonConfig?.sticky) }"
|
:class="{ 'is-sticky-full': Boolean(config.addButtonConfig?.sticky) }"
|
||||||
v-if="!isCompare && ($slots['toggle-button'] || $slots['add-button'])"
|
v-if="hasFooter($slots)"
|
||||||
>
|
>
|
||||||
<slot name="toggle-button"></slot>
|
<slot name="toggle-button"></slot>
|
||||||
<div style="display: flex; justify-content: flex-end; flex: 1">
|
<div style="display: flex; justify-content: flex-end; flex: 1">
|
||||||
@ -49,6 +56,7 @@ import { computed } from 'vue';
|
|||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import type { ContainerChangeEventData, GroupListConfig } from '../schema';
|
import type { ContainerChangeEventData, GroupListConfig } from '../schema';
|
||||||
|
import { getGroupListHeaderVars } from '../utils/tableGroupList';
|
||||||
|
|
||||||
import MFieldsGroupListItem from './GroupListItem.vue';
|
import MFieldsGroupListItem from './GroupListItem.vue';
|
||||||
|
|
||||||
@ -104,6 +112,19 @@ const onAddDiffCount = () => emit('addDiffCount');
|
|||||||
|
|
||||||
const asList = (value: unknown): any[] => (Array.isArray(value) ? value : []);
|
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]));
|
const currentList = computed(() => asList(props.model[props.name]));
|
||||||
|
|
||||||
/** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */
|
/** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */
|
||||||
|
|||||||
@ -70,27 +70,60 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 8px;
|
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 之上(如「新增条件」叠在「新增条件组」上面)。
|
||||||
.m-fields-group-list-item .m-fields-group-list-item {
|
// 每穿过一层「自己渲染了吸底 footer」的列表才多让出一个 footer 高度,外层没有吸底按钮时
|
||||||
> .el-card__header,
|
// 内层不能凭空抬高。自定义属性不能自增,所以用 item / list 两个变量交替接力。
|
||||||
> .t-card__header {
|
> .m-fields-group-list-item {
|
||||||
top: calc(
|
--m-group-list-item-footer-bottom: var(
|
||||||
var(--m-group-list-header-sticky-top, 0px) +
|
--m-group-list-nested-footer-bottom,
|
||||||
var(--m-group-list-header-height)
|
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)
|
||||||
);
|
);
|
||||||
z-index: 6;
|
--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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,10 +146,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 吸底全宽主按钮(event-select / code-select / display-conds 外层列表)
|
// 吸底全宽主按钮(event-select / code-select / display-conds 外层列表)
|
||||||
// z-index 高于 item header,避免吸顶标题挡住新增按钮
|
// z-index 高于 item header,避免吸顶标题挡住新增按钮;多层时 bottom 由内层 list 累加
|
||||||
> .m-fields-group-list-footer.is-sticky-full {
|
> .m-fields-group-list-footer.is-sticky-full {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: var(--m-group-list-nested-footer-bottom, 0px);
|
||||||
z-index: 7;
|
z-index: 7;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding: var(--m-group-list-footer-padding-top) 0
|
padding: var(--m-group-list-footer-padding-top) 0
|
||||||
@ -133,14 +166,6 @@
|
|||||||
height: var(--m-group-list-footer-button-height);
|
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可以实现 */
|
/** 最外层的groupList需要每个item需要增加空白区域界限时,增加outer-gorup_list可以实现 */
|
||||||
|
|||||||
@ -18,7 +18,9 @@
|
|||||||
|
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema';
|
import { isNumber } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import type { GroupListConfig, GroupListHeaderConfig, TableColumnConfig, TableConfig } from '../schema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* table / group-list 是同一份配置的两种展示形态,可以互相切换(`TableGroupList.vue`)。
|
* table / group-list 是同一份配置的两种展示形态,可以互相切换(`TableGroupList.vue`)。
|
||||||
@ -31,6 +33,31 @@ import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema'
|
|||||||
/** group-list 形态的 type(兼容驼峰与中划线两种写法) */
|
/** group-list 形态的 type(兼容驼峰与中划线两种写法) */
|
||||||
export const isGroupListType = (type: unknown): boolean => type === 'groupList' || type === 'group-list';
|
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) */
|
/** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px,最小 80px) */
|
||||||
export const calcLabelWidth = (label: string): string => {
|
export const calcLabelWidth = (label: string): string => {
|
||||||
if (!label) return '0px';
|
if (!label) return '0px';
|
||||||
|
|||||||
@ -202,6 +202,63 @@ describe('GroupList container', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
warn.mockRestore();
|
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 透传', () => {
|
describe('labelPosition 透传', () => {
|
||||||
@ -501,6 +558,131 @@ describe('GroupList container', () => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
expect(wrapper.find('.m-fields-group-list-footer.is-sticky-full').exists()).toBe(true);
|
expect(wrapper.find('.m-fields-group-list-footer.is-sticky-full').exists()).toBe(true);
|
||||||
expect(wrapper.text()).toContain('添加');
|
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 { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { getGroupListRowConfig } from '@form/utils/tableGroupList';
|
import { getGroupListHeaderVars, getGroupListRowConfig } from '@form/utils/tableGroupList';
|
||||||
|
|
||||||
describe('getGroupListRowConfig', () => {
|
describe('getGroupListRowConfig', () => {
|
||||||
test('把 group-list 的 labelWidth / labelPosition 复制到 row 配置', () => {
|
test('把 group-list 的 labelWidth / labelPosition 复制到 row 配置', () => {
|
||||||
@ -26,3 +26,47 @@ describe('getGroupListRowConfig', () => {
|
|||||||
expect(row.items).toHaveLength(1);
|
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.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/schema",
|
"name": "@tmagic/schema",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/stage",
|
"name": "@tmagic/stage",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/table",
|
"name": "@tmagic/table",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/tdesign-vue-next-adapter",
|
"name": "@tmagic/tdesign-vue-next-adapter",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"name": "@tmagic/utils",
|
"name": "@tmagic/utils",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tmagic-playground",
|
"name": "tmagic-playground",
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -12,11 +12,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@tmagic/core": "1.8.0-beta.27",
|
"@tmagic/core": "1.8.0-beta.28",
|
||||||
"@tmagic/design": "1.8.0-beta.27",
|
"@tmagic/design": "1.8.0-beta.28",
|
||||||
"@tmagic/editor": "1.8.0-beta.27",
|
"@tmagic/editor": "1.8.0-beta.28",
|
||||||
"@tmagic/element-plus-adapter": "1.8.0-beta.27",
|
"@tmagic/element-plus-adapter": "1.8.0-beta.28",
|
||||||
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.27",
|
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.28",
|
||||||
"@tmagic/tmagic-form-runtime": "1.1.6",
|
"@tmagic/tmagic-form-runtime": "1.1.6",
|
||||||
"element-plus": "catalog:",
|
"element-plus": "catalog:",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
|
|||||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@ -567,19 +567,19 @@ importers:
|
|||||||
specifier: ^2.3.2
|
specifier: ^2.3.2
|
||||||
version: 2.3.2(vue@3.5.41(typescript@6.0.3))
|
version: 2.3.2(vue@3.5.41(typescript@6.0.3))
|
||||||
'@tmagic/core':
|
'@tmagic/core':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../packages/core
|
version: link:../packages/core
|
||||||
'@tmagic/design':
|
'@tmagic/design':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../packages/design
|
version: link:../packages/design
|
||||||
'@tmagic/editor':
|
'@tmagic/editor':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../packages/editor
|
version: link:../packages/editor
|
||||||
'@tmagic/element-plus-adapter':
|
'@tmagic/element-plus-adapter':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../packages/element-plus-adapter
|
version: link:../packages/element-plus-adapter
|
||||||
'@tmagic/tdesign-vue-next-adapter':
|
'@tmagic/tdesign-vue-next-adapter':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../packages/tdesign-vue-next-adapter
|
version: link:../packages/tdesign-vue-next-adapter
|
||||||
'@tmagic/tmagic-form-runtime':
|
'@tmagic/tmagic-form-runtime':
|
||||||
specifier: 1.1.6
|
specifier: 1.1.6
|
||||||
@ -923,13 +923,13 @@ importers:
|
|||||||
runtime/react:
|
runtime/react:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tmagic/core':
|
'@tmagic/core':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../../packages/core
|
version: link:../../packages/core
|
||||||
'@tmagic/react-runtime-help':
|
'@tmagic/react-runtime-help':
|
||||||
specifier: 0.2.2
|
specifier: 0.2.2
|
||||||
version: link:../react-runtime-help
|
version: link:../react-runtime-help
|
||||||
'@tmagic/stage':
|
'@tmagic/stage':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../../packages/stage
|
version: link:../../packages/stage
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.19.0
|
specifier: ^1.19.0
|
||||||
@ -945,7 +945,7 @@ importers:
|
|||||||
version: 19.2.8(react@19.2.8)
|
version: 19.2.8(react@19.2.8)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tmagic/cli':
|
'@tmagic/cli':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../../packages/cli
|
version: link:../../packages/cli
|
||||||
'@types/fs-extra':
|
'@types/fs-extra':
|
||||||
specifier: ^11.0.4
|
specifier: ^11.0.4
|
||||||
@ -1021,10 +1021,10 @@ importers:
|
|||||||
runtime/vue:
|
runtime/vue:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tmagic/core':
|
'@tmagic/core':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../../packages/core
|
version: link:../../packages/core
|
||||||
'@tmagic/stage':
|
'@tmagic/stage':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../../packages/stage
|
version: link:../../packages/stage
|
||||||
'@tmagic/vue-runtime-help':
|
'@tmagic/vue-runtime-help':
|
||||||
specifier: ^2.0.4
|
specifier: ^2.0.4
|
||||||
@ -1037,7 +1037,7 @@ importers:
|
|||||||
version: 3.5.41(typescript@6.0.3)
|
version: 3.5.41(typescript@6.0.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tmagic/cli':
|
'@tmagic/cli':
|
||||||
specifier: 1.8.0-beta.27
|
specifier: 1.8.0-beta.28
|
||||||
version: link:../../packages/cli
|
version: link:../../packages/cli
|
||||||
'@types/fs-extra':
|
'@types/fs-extra':
|
||||||
specifier: ^11.0.4
|
specifier: ^11.0.4
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "runtime-react",
|
"name": "runtime-react",
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -16,16 +16,16 @@
|
|||||||
"build:playground": "node scripts/build.mjs --type=playground"
|
"build:playground": "node scripts/build.mjs --type=playground"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tmagic/core": "1.8.0-beta.27",
|
"@tmagic/core": "1.8.0-beta.28",
|
||||||
"@tmagic/react-runtime-help": "0.2.2",
|
"@tmagic/react-runtime-help": "0.2.2",
|
||||||
"@tmagic/stage": "1.8.0-beta.27",
|
"@tmagic/stage": "1.8.0-beta.28",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tmagic/cli": "1.8.0-beta.27",
|
"@tmagic/cli": "1.8.0-beta.28",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "runtime-vue",
|
"name": "runtime-vue",
|
||||||
"version": "1.8.0-beta.27",
|
"version": "1.8.0-beta.28",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -16,14 +16,14 @@
|
|||||||
"build:playground": "node scripts/build.mjs --type=playground"
|
"build:playground": "node scripts/build.mjs --type=playground"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tmagic/core": "1.8.0-beta.27",
|
"@tmagic/core": "1.8.0-beta.28",
|
||||||
"@tmagic/stage": "1.8.0-beta.27",
|
"@tmagic/stage": "1.8.0-beta.28",
|
||||||
"@tmagic/vue-runtime-help": "^2.0.4",
|
"@tmagic/vue-runtime-help": "^2.0.4",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"vue": "catalog:"
|
"vue": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tmagic/cli": "1.8.0-beta.27",
|
"@tmagic/cli": "1.8.0-beta.28",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@vitejs/plugin-legacy": "^8.2.2",
|
"@vitejs/plugin-legacy": "^8.2.2",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user