mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-10-28 21:40:39 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4cf3b9525 | ||
|
|
d01bc45b17 | ||
|
|
7e8cb1f9db | ||
|
|
d017f43573 | ||
|
|
cbd0442ffc | ||
|
|
8ac1a38601 | ||
|
|
7f0e78bf6a |
@ -503,6 +503,32 @@ const renderFunction = async (stage) => {
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## iframeSandbox
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
画布 iframe 的 sandbox 属性值,仅 `renderType` 为 `iframe` 时生效。不传或传 `false` 时不设置 sandbox 属性。
|
||||||
|
|
||||||
|
token 用空格分隔,缺哪个 token,画布内对应能力就会静默失效。编辑器自身依赖 `allow-scripts` + `allow-same-origin`(需要读写 iframe 内的 DOM);组件内需要下载、弹窗、表单提交时,分别要放开 `allow-downloads`、`allow-popups`、`allow-forms` 等。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 仅在编辑器初始化时读取,运行期修改不会生效。
|
||||||
|
- runtime 与编辑器不同域时,会通过 `srcdoc` 加载,同样受 sandbox 约束;缺少 `allow-same-origin` 会导致编辑器无法访问 iframe 内的 DOM。
|
||||||
|
- `allow-scripts` 与 `allow-same-origin` 同时存在且 iframe 同源时,iframe 内脚本可以移除自身的 sandbox 属性,因此该配置只用于限制 `allow-top-navigation`、`allow-popups` 等能力,不能作为安全隔离手段。
|
||||||
|
|
||||||
|
- **默认值:** `undefined`(不设置 sandbox 属性)
|
||||||
|
|
||||||
|
- **类型:** `string | false`
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template>
|
||||||
|
<m-editor iframe-sandbox="allow-scripts allow-same-origin allow-downloads"></m-editor>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
## autoScrollIntoView
|
## autoScrollIntoView
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|||||||
38
package.json
38
package.json
@ -41,12 +41,12 @@
|
|||||||
"url": "https://github.com/Tencent/tmagic-editor.git"
|
"url": "https://github.com/Tencent/tmagic-editor.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^21.2.3",
|
"@commitlint/cli": "^21.2.4",
|
||||||
"@commitlint/config-conventional": "^21.2.3",
|
"@commitlint/config-conventional": "^21.2.4",
|
||||||
"@tmagic/eslint-config": "workspace:*",
|
"@tmagic/eslint-config": "workspace:*",
|
||||||
"@types/node": "26.6.2",
|
"@types/node": "26.6.5",
|
||||||
"@vitejs/plugin-vue": "^6.0.9",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"@vitest/coverage-v8": "^5.0.1",
|
"@vitest/coverage-v8": "^5.0.5",
|
||||||
"@vue/compiler-sfc": "catalog:",
|
"@vue/compiler-sfc": "catalog:",
|
||||||
"c8": "^12.0.0",
|
"c8": "^12.0.0",
|
||||||
"commitizen": "^4.3.2",
|
"commitizen": "^4.3.2",
|
||||||
@ -55,30 +55,30 @@
|
|||||||
"cz-conventional-changelog": "^3.3.0",
|
"cz-conventional-changelog": "^3.3.0",
|
||||||
"element-plus": "catalog:",
|
"element-plus": "catalog:",
|
||||||
"enquirer": "^2.4.1",
|
"enquirer": "^2.4.1",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.13.0",
|
||||||
"execa": "^10.0.1",
|
"execa": "^10.2.0",
|
||||||
"happy-dom": "^20.14.5",
|
"happy-dom": "^21.0.3",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^30.1.0",
|
"jsdom": "^30.1.3",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.6.0",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"prettier": "^3.9.8",
|
"prettier": "^3.9.10",
|
||||||
"recast": "^0.23.20",
|
"recast": "^0.24.0",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"rolldown": "^1.2.9",
|
"rolldown": "^1.2.14",
|
||||||
"rolldown-plugin-dts": "^0.28.6",
|
"rolldown-plugin-dts": "^0.28.8",
|
||||||
"sass-embedded": "^1.104.1",
|
"sass-embedded": "^1.105.2",
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"serialize-javascript": "^7.1.1",
|
"serialize-javascript": "^7.1.2",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vitepress": "2.0.0-alpha.20",
|
"vitepress": "2.0.0-alpha.20",
|
||||||
"vitest": "^5.0.1",
|
"vitest": "^5.0.5",
|
||||||
"vue": "^3.5.43",
|
"vue": "^3.5.44",
|
||||||
"vue-tsc": "^3.3.11"
|
"vue-tsc": "^3.3.13"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
|
|||||||
@ -133,9 +133,13 @@ export default class Watcher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 整棵树只遍历一次、在每个属性上检查所有 target,把结构遍历开销从 ×targets 降到 ×1(详见 collectItems)
|
// 先清掉本次节点上的旧依赖,再统一收集。
|
||||||
|
// 复制出的页面片会共用节点 id。如果在收集每一棵树之前立刻删除,
|
||||||
|
// 后一棵树会按相同 id 把前一棵树刚写入的依赖清掉。
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
this.removeTargetsDep(targets, node);
|
this.removeTargetsDep(targets, node);
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
this.collectItems(node, targets, depExtendedData, deep);
|
this.collectItems(node, targets, depExtendedData, deep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -249,4 +249,86 @@ describe('Watcher', () => {
|
|||||||
expect(target1?.deps?.node_1).toBeUndefined();
|
expect(target1?.deps?.node_1).toBeUndefined();
|
||||||
expect(target1?.deps?.node_2).toBeUndefined();
|
expect(target1?.deps?.node_2).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collect keeps deps when roots share node ids', () => {
|
||||||
|
const watcher = new Watcher();
|
||||||
|
|
||||||
|
watcher.addTarget(
|
||||||
|
new Target({
|
||||||
|
type: 'target',
|
||||||
|
id: 'ds_a',
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === 'a',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
watcher.addTarget(
|
||||||
|
new Target({
|
||||||
|
type: 'target',
|
||||||
|
id: 'ds_b',
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === 'b',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
watcher.collect(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'fragment_a',
|
||||||
|
name: 'fragmentA',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
name: 'shared-a',
|
||||||
|
text: 'a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fragment_b',
|
||||||
|
name: 'fragmentB',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
name: 'shared-b',
|
||||||
|
text: 'b',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetA = watcher.getTarget('ds_a', 'target');
|
||||||
|
const targetB = watcher.getTarget('ds_b', 'target');
|
||||||
|
|
||||||
|
expect(targetA?.deps?.shared.keys).toEqual(['text']);
|
||||||
|
expect(targetA?.deps?.shared.name).toBe('shared-a');
|
||||||
|
expect(targetB?.deps?.shared.keys).toEqual(['text']);
|
||||||
|
expect(targetB?.deps?.shared.name).toBe('shared-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collect merges keys when one target matches shared-id nodes in several roots', () => {
|
||||||
|
const watcher = new Watcher();
|
||||||
|
|
||||||
|
watcher.addTarget(
|
||||||
|
new Target({
|
||||||
|
type: 'target',
|
||||||
|
id: 'ds',
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === 'x',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
watcher.collect(
|
||||||
|
[
|
||||||
|
{ id: 'root_a', items: [{ id: 'shared', name: 'shared-a', text: 'x' }] },
|
||||||
|
{ id: 'root_b', items: [{ id: 'shared', name: 'shared-b', title: 'y', text: 'x' }] },
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dep = watcher.getTarget('ds', 'target')?.deps?.shared;
|
||||||
|
expect(dep?.keys).toEqual(['text']);
|
||||||
|
// 同 id 的依赖只保留一条,name 取最后收集的节点
|
||||||
|
expect(dep?.name).toBe('shared-b');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -224,6 +224,7 @@ const stageOptions: StageOptions = {
|
|||||||
containerHighlightAddOnly: props.containerHighlightAddOnly,
|
containerHighlightAddOnly: props.containerHighlightAddOnly,
|
||||||
disabledDragStart: props.disabledDragStart,
|
disabledDragStart: props.disabledDragStart,
|
||||||
renderType: props.renderType,
|
renderType: props.renderType,
|
||||||
|
iframeSandbox: props.iframeSandbox,
|
||||||
guidesOptions: props.guidesOptions,
|
guidesOptions: props.guidesOptions,
|
||||||
disabledMultiSelect: props.disabledMultiSelect,
|
disabledMultiSelect: props.disabledMultiSelect,
|
||||||
alwaysMultiSelect: props.alwaysMultiSelect,
|
alwaysMultiSelect: props.alwaysMultiSelect,
|
||||||
|
|||||||
@ -62,6 +62,16 @@ export interface EditorProps {
|
|||||||
runtimeUrl?: string;
|
runtimeUrl?: string;
|
||||||
/** 是用iframe渲染还是直接渲染 */
|
/** 是用iframe渲染还是直接渲染 */
|
||||||
renderType?: RenderType;
|
renderType?: RenderType;
|
||||||
|
/**
|
||||||
|
* 画布 iframe 的 sandbox 属性值(仅 iframe 渲染时生效)
|
||||||
|
*
|
||||||
|
* - 不传 / `false`:不设置 sandbox 属性(默认)
|
||||||
|
* - 字符串:原样写入,token 空格分隔,如 `'allow-scripts allow-same-origin allow-downloads'`
|
||||||
|
*
|
||||||
|
* 需要注意编辑器自身依赖 `allow-scripts` + `allow-same-origin`(读写 iframe 内 DOM),
|
||||||
|
* 其余 token(`allow-downloads`、`allow-popups`、`allow-forms` 等)按业务需要放开。
|
||||||
|
*/
|
||||||
|
iframeSandbox?: string | false;
|
||||||
/** 选中时是否自动滚动到可视区域 */
|
/** 选中时是否自动滚动到可视区域 */
|
||||||
autoScrollIntoView?: boolean;
|
autoScrollIntoView?: boolean;
|
||||||
/** 组件的属性配置表单的dsl */
|
/** 组件的属性配置表单的dsl */
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export const useStage = (stageOptions: StageOptions) => {
|
|||||||
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
|
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
|
||||||
disabledDragStart: stageOptions.disabledDragStart,
|
disabledDragStart: stageOptions.disabledDragStart,
|
||||||
renderType: stageOptions.renderType,
|
renderType: stageOptions.renderType,
|
||||||
|
iframeSandbox: stageOptions.iframeSandbox,
|
||||||
canSelect: (el, event, stop) => {
|
canSelect: (el, event, stop) => {
|
||||||
if (!stageOptions.canSelect) return true;
|
if (!stageOptions.canSelect) return true;
|
||||||
|
|
||||||
|
|||||||
@ -62,13 +62,14 @@ const props = withDefaults(
|
|||||||
width?: number;
|
width?: number;
|
||||||
/** 子元素之间的间距,需与 SCSS gap 保持一致 */
|
/** 子元素之间的间距,需与 SCSS gap 保持一致 */
|
||||||
gap?: number;
|
gap?: number;
|
||||||
/** Popover 内容宽度 */
|
/**
|
||||||
popoverWidth?: number;
|
* Popover 内容宽度。不传时按内容撑开,避免下拉框超出菜单。
|
||||||
|
*/
|
||||||
|
popoverWidth?: number | string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
items: () => [],
|
items: () => [],
|
||||||
gap: 3,
|
gap: 3,
|
||||||
popoverWidth: 180,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -110,7 +111,18 @@ const containerWidth = ref(0);
|
|||||||
const hiddenIndexSet = ref<Set<number>>(new Set());
|
const hiddenIndexSet = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const hasOverflow = computed(() => hiddenIndexSet.value.size > 0);
|
const hasOverflow = computed(() => hiddenIndexSet.value.size > 0);
|
||||||
const overflowItems = computed(() => props.items.filter((_, index) => hiddenIndexSet.value.has(index)));
|
// 更多菜单是纵向列表:分割线改成横线;收进去的首尾分割线没有分组意义,直接去掉。
|
||||||
|
const overflowItems = computed(() => {
|
||||||
|
const items = props.items
|
||||||
|
.filter((_, index) => hiddenIndexSet.value.has(index))
|
||||||
|
.map((item) => (item.type === 'divider' ? { ...item, direction: 'horizontal' as const } : item));
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let end = items.length;
|
||||||
|
while (start < end && items[start].type === 'divider') start += 1;
|
||||||
|
while (end > start && items[end - 1].type === 'divider') end -= 1;
|
||||||
|
return items.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
const measureAndCompute = () => {
|
const measureAndCompute = () => {
|
||||||
if (!columnEl.value) return;
|
if (!columnEl.value) return;
|
||||||
|
|||||||
@ -13,18 +13,14 @@
|
|||||||
:data="{
|
:data="{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: '页面',
|
text: '页面',
|
||||||
handler: () => {
|
handler: addNormalPage,
|
||||||
addPage(NodeType.PAGE);
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
></ToolButton>
|
></ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
:data="{
|
:data="{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: '页面片',
|
text: '页面片',
|
||||||
handler: () => {
|
handler: addPageFragment,
|
||||||
addPage(NodeType.PAGE_FRAGMENT);
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
></ToolButton>
|
></ToolButton>
|
||||||
</TMagicPopover>
|
</TMagicPopover>
|
||||||
@ -62,4 +58,7 @@ const addPage = (type: NodeType.PAGE | NodeType.PAGE_FRAGMENT) => {
|
|||||||
};
|
};
|
||||||
editorService.add(pageConfig);
|
editorService.add(pageConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addNormalPage = () => addPage(NodeType.PAGE);
|
||||||
|
const addPageFragment = () => addPage(NodeType.PAGE_FRAGMENT);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -150,8 +150,10 @@ class Dep extends BaseService {
|
|||||||
const targets = this.watcher.getCollectableTargets(type);
|
const targets = this.watcher.getCollectableTargets(type);
|
||||||
|
|
||||||
if (targets.length) {
|
if (targets.length) {
|
||||||
|
// 先统一删除所有节点的旧依赖,再统一收集。
|
||||||
|
// 如果逐节点“删除 + 收集”,共用 id 的多棵树(如复制出的页面片),
|
||||||
|
// 后一棵树的删除会清掉前一棵树刚写入的依赖。
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
// 先删除原有依赖,再批量收集
|
|
||||||
if (isPage(node)) {
|
if (isPage(node)) {
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
this.removePageDep(target, depExtendedData);
|
this.removePageDep(target, depExtendedData);
|
||||||
@ -159,6 +161,8 @@ class Dep extends BaseService {
|
|||||||
} else {
|
} else {
|
||||||
this.watcher.removeTargetsDep(targets, node);
|
this.watcher.removeTargetsDep(targets, node);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
this.watcher.collectItems(node, targets, depExtendedData, deep);
|
this.watcher.collectItems(node, targets, depExtendedData, deep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,7 +120,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-editor-nav-menu-popover {
|
.tmagic-design-popper.m-editor-nav-menu-popover {
|
||||||
|
// 不固定宽度,按内容撑开,避免下拉框超出菜单;content-box 让 padding 不挤占内容宽度。
|
||||||
|
width: max-content;
|
||||||
|
min-width: 180px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
|
||||||
.m-editor-nav-menu-overflow-list {
|
.m-editor-nav-menu-overflow-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -139,8 +144,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.divider {
|
&.divider {
|
||||||
padding: 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 4px 12px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// 更多菜单是纵向列表,分割线必须盖过组件默认的竖线样式
|
||||||
|
.tmagic-design-divider,
|
||||||
|
.el-divider,
|
||||||
|
.t-divider {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--el-border-color, #dcdfe6);
|
||||||
|
border-left: 0;
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|||||||
@ -221,6 +221,8 @@ export interface StageOptions {
|
|||||||
*/
|
*/
|
||||||
disabledFlashTip?: boolean;
|
disabledFlashTip?: boolean;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
/** 画布 iframe 的 sandbox 属性值,不传 / false 表示不设置,见 @tmagic/stage 的 StageRenderConfig.iframeSandbox */
|
||||||
|
iframeSandbox?: string | false;
|
||||||
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||||
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,8 @@ vi.mock('@editor/components/ToolButton.vue', () => ({
|
|||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
ref: rootEl,
|
ref: rootEl,
|
||||||
class: ['tool-btn', (props.data as any)?.className],
|
class: ['tool-btn', (props.data as any)?.className, (props.data as any)?.type],
|
||||||
|
'data-direction': (props.data as any)?.direction || '',
|
||||||
},
|
},
|
||||||
(props.data as any)?.text || '',
|
(props.data as any)?.text || '',
|
||||||
);
|
);
|
||||||
@ -119,6 +120,15 @@ describe('NavMenuColumn', () => {
|
|||||||
expect(moreWrapper.classes()).toContain('m-editor-nav-menu-more-wrapper-hidden');
|
expect(moreWrapper.classes()).toContain('m-editor-nav-menu-more-wrapper-hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('未指定 popoverWidth 时不给更多菜单固定宽度', () => {
|
||||||
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
|
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
||||||
|
});
|
||||||
|
const popover = wrapper.findComponent({ name: 'TMagicPopover' });
|
||||||
|
expect(popover.exists()).toBe(true);
|
||||||
|
expect(popover.props('width')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test('点击 more 引用元素切换 popover 显示状态', async () => {
|
test('点击 more 引用元素切换 popover 显示状态', async () => {
|
||||||
const wrapper = mount(NavMenuColumn as any, {
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
||||||
@ -344,6 +354,127 @@ describe('NavMenuColumn', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('更多菜单中的分割线改为横向,工具栏中的分割线保持竖向', async () => {
|
||||||
|
const items = [
|
||||||
|
{ type: 'button', text: 'A' },
|
||||||
|
{ type: 'button', text: 'B' },
|
||||||
|
{ type: 'divider', className: 'sep' },
|
||||||
|
{ type: 'button', text: 'C' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
|
props: { columnKey: 'right', items, width: 80 },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnEl = wrapper.find('.m-editor-nav-menu-column').element as HTMLElement;
|
||||||
|
Object.defineProperty(columnEl, 'clientWidth', { configurable: true, get: () => 80 });
|
||||||
|
|
||||||
|
const moreEl = wrapper.find('.m-editor-nav-menu-more-wrapper').element as HTMLElement;
|
||||||
|
moreEl.getBoundingClientRect = () => ({
|
||||||
|
width: 30,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.findAll('.tool-btn').forEach((b) => {
|
||||||
|
const el = b.element as HTMLElement;
|
||||||
|
el.getBoundingClientRect = () => ({
|
||||||
|
width: 40,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roCallbacks.forEach((cb) => cb());
|
||||||
|
await flushRaf();
|
||||||
|
await flushRaf();
|
||||||
|
|
||||||
|
const overflowDividers = wrapper.findAll('.m-editor-nav-menu-overflow-list .sep');
|
||||||
|
expect(overflowDividers.length).toBeGreaterThan(0);
|
||||||
|
overflowDividers.forEach((divider) => {
|
||||||
|
expect(divider.attributes('data-direction')).toBe('horizontal');
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.findAll('.m-editor-nav-menu-column > .sep').forEach((divider) => {
|
||||||
|
expect(divider.attributes('data-direction')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('更多菜单首尾的分割线会被去掉,中间的分割线保留', async () => {
|
||||||
|
const items = [
|
||||||
|
{ type: 'button', text: 'A' },
|
||||||
|
{ type: 'divider', className: 'lead' },
|
||||||
|
{ type: 'button', text: 'B' },
|
||||||
|
{ type: 'divider', className: 'mid' },
|
||||||
|
{ type: 'button', text: 'C' },
|
||||||
|
{ type: 'divider', className: 'trail' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
|
props: { columnKey: 'right', items, width: 80 },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnEl = wrapper.find('.m-editor-nav-menu-column').element as HTMLElement;
|
||||||
|
Object.defineProperty(columnEl, 'clientWidth', { configurable: true, get: () => 80 });
|
||||||
|
|
||||||
|
const moreEl = wrapper.find('.m-editor-nav-menu-more-wrapper').element as HTMLElement;
|
||||||
|
moreEl.getBoundingClientRect = () => ({
|
||||||
|
width: 30,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.findAll('.tool-btn').forEach((b) => {
|
||||||
|
const el = b.element as HTMLElement;
|
||||||
|
el.getBoundingClientRect = () => ({
|
||||||
|
width: 40,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roCallbacks.forEach((cb) => cb());
|
||||||
|
await flushRaf();
|
||||||
|
await flushRaf();
|
||||||
|
|
||||||
|
const overflow = wrapper.find('.m-editor-nav-menu-overflow-list');
|
||||||
|
expect(overflow.find('.lead').exists()).toBe(false);
|
||||||
|
expect(overflow.find('.trail').exists()).toBe(false);
|
||||||
|
expect(
|
||||||
|
overflow.findAll('.tool-btn').map((button) => button.text() || button.classes().find((name) => name === 'mid')),
|
||||||
|
).toEqual(['B', 'mid', 'C']);
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
test('卸载时清理 ResizeObserver', () => {
|
test('卸载时清理 ResizeObserver', () => {
|
||||||
const wrapper = mount(NavMenuColumn as any, {
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
||||||
|
|||||||
@ -227,6 +227,49 @@ describe('Dep service', () => {
|
|||||||
expect(() => depService.collect([{ id: 'p1', type: 'page', items: [] }] as any, { pageId: 'p1' })).not.toThrow();
|
expect(() => depService.collect([{ id: 'p1', type: 'page', items: [] }] as any, { pageId: 'p1' })).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeTextTarget = (id: string, text: string) =>
|
||||||
|
new Target({
|
||||||
|
id,
|
||||||
|
type: DepTargetType.DEFAULT,
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === text,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeSharedIdFragments = () => [
|
||||||
|
{ id: 'fragment_a', type: 'container', items: [{ id: 'shared', type: 'text', text: 'a' }] },
|
||||||
|
{ id: 'fragment_b', type: 'container', items: [{ id: 'shared', type: 'text', text: 'b' }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('collect 多个根节点共用子节点 id 时不会互相清掉依赖', () => {
|
||||||
|
const targetA = makeTextTarget('ds_a', 'a');
|
||||||
|
const targetB = makeTextTarget('ds_b', 'b');
|
||||||
|
depService.addTarget(targetA);
|
||||||
|
depService.addTarget(targetB);
|
||||||
|
|
||||||
|
depService.collect(makeSharedIdFragments() as any, {}, true);
|
||||||
|
|
||||||
|
expect(targetA.deps.shared?.keys).toEqual(['text']);
|
||||||
|
expect(targetB.deps.shared?.keys).toEqual(['text']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collect 多个 page 节点同 pageId 时不会互相清掉依赖', () => {
|
||||||
|
const target = makeTextTarget('ds_a', 'a');
|
||||||
|
depService.addTarget(target);
|
||||||
|
target.deps.stale = { name: 'stale', keys: ['text'], data: { pageId: 'p' } };
|
||||||
|
|
||||||
|
depService.collect(
|
||||||
|
[
|
||||||
|
{ id: 'page_1', type: 'page', text: 'a', items: [] },
|
||||||
|
{ id: 'page_2', type: 'page', items: [{ id: 'child', type: 'text', text: 'a' }] },
|
||||||
|
] as any,
|
||||||
|
{ pageId: 'p' },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target.deps.stale).toBeUndefined();
|
||||||
|
expect(target.deps.page_1?.keys).toEqual(['text']);
|
||||||
|
expect(target.deps.child?.keys).toEqual(['text']);
|
||||||
|
});
|
||||||
|
|
||||||
test('collectNode 支持 page 与普通节点两条路径', () => {
|
test('collectNode 支持 page 与普通节点两条路径', () => {
|
||||||
const target = makeTarget('node-target');
|
const target = makeTarget('node-target');
|
||||||
depService.addTarget(target);
|
depService.addTarget(target);
|
||||||
|
|||||||
@ -832,6 +832,47 @@ export interface AddButtonConfig {
|
|||||||
}
|
}
|
||||||
// #endregion AddButtonConfig
|
// #endregion AddButtonConfig
|
||||||
|
|
||||||
|
// #region ActionButtonConfig
|
||||||
|
/**
|
||||||
|
* 行操作按钮。与 `addButtonConfig` 相同:`text` 是文案,`props` 透传给按钮。
|
||||||
|
* 不支持吸底。未配置 `text` 时使用内置中文。
|
||||||
|
*/
|
||||||
|
export type ActionButtonConfig = Omit<AddButtonConfig, 'sticky'>;
|
||||||
|
// #endregion ActionButtonConfig
|
||||||
|
|
||||||
|
// #region CustomActionButtonContext
|
||||||
|
/** 自定义行操作按钮回调收到的上下文 */
|
||||||
|
export interface CustomActionButtonContext {
|
||||||
|
/** 当前行数据 */
|
||||||
|
model: any;
|
||||||
|
/** 当前行在整个列表中的下标(table 分页时为全局下标) */
|
||||||
|
index: number;
|
||||||
|
/** 整个列表数据 */
|
||||||
|
groupModel: any[];
|
||||||
|
prop?: string;
|
||||||
|
config: any;
|
||||||
|
formValue: any;
|
||||||
|
}
|
||||||
|
// #endregion CustomActionButtonContext
|
||||||
|
|
||||||
|
// #region CustomActionButtonConfig
|
||||||
|
/** table / group-list 行操作中的自定义按钮,渲染在内置按钮之后 */
|
||||||
|
export interface CustomActionButtonConfig {
|
||||||
|
/** 按钮文案;table 非 flat 模式下仅作为 title 提示(有 icon 时) */
|
||||||
|
text?: string;
|
||||||
|
/** 透传给按钮的属性 */
|
||||||
|
props?: Record<string, any>;
|
||||||
|
/** 按钮图标组件 */
|
||||||
|
icon?: any;
|
||||||
|
/** 是否显示,默认显示 */
|
||||||
|
display?: boolean | ((mForm: FormState | undefined, ctx: CustomActionButtonContext) => boolean);
|
||||||
|
/** 是否禁用,表单禁用时始终禁用 */
|
||||||
|
disabled?: boolean | ((mForm: FormState | undefined, ctx: CustomActionButtonContext) => boolean);
|
||||||
|
/** 点击回调 */
|
||||||
|
handler: (mForm: FormState | undefined, ctx: CustomActionButtonContext) => void;
|
||||||
|
}
|
||||||
|
// #endregion CustomActionButtonConfig
|
||||||
|
|
||||||
// #region GroupListHeaderConfig
|
// #region GroupListHeaderConfig
|
||||||
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
|
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
|
||||||
export interface GroupListHeaderConfig {
|
export interface GroupListHeaderConfig {
|
||||||
@ -862,6 +903,18 @@ export interface TableGroupListCommonConfig extends FormItem {
|
|||||||
/** 新增后滚动到最后一项(group-list 形态,默认关闭) */
|
/** 新增后滚动到最后一项(group-list 形态,默认关闭) */
|
||||||
scrollLastItemIntoView?: boolean;
|
scrollLastItemIntoView?: boolean;
|
||||||
addButtonConfig?: AddButtonConfig;
|
addButtonConfig?: AddButtonConfig;
|
||||||
|
/** 删除按钮,text 默认「删除」 */
|
||||||
|
deleteButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 复制按钮,text 默认「复制」 */
|
||||||
|
copyButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 上移按钮,text 默认「上移」。仅 group-list 形态 */
|
||||||
|
moveUpButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 下移按钮,text 默认「下移」。仅 group-list 形态 */
|
||||||
|
moveDownButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 移动至按钮,text 默认「移动至」。仅 group-list 形态 */
|
||||||
|
moveToButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 自定义行操作按钮,渲染在内置按钮之后 */
|
||||||
|
actionButtons?: CustomActionButtonConfig[];
|
||||||
/** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */
|
/** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */
|
||||||
header?: GroupListHeaderConfig;
|
header?: GroupListHeaderConfig;
|
||||||
}
|
}
|
||||||
@ -898,6 +951,11 @@ export interface TableConfig<T = never> extends TableGroupListCommonConfig {
|
|||||||
showIndex?: boolean;
|
showIndex?: boolean;
|
||||||
/** 操作栏宽度 */
|
/** 操作栏宽度 */
|
||||||
operateColWidth?: number | string;
|
operateColWidth?: number | string;
|
||||||
|
/**
|
||||||
|
* 操作列。`text` 是列标题,默认「操作」。
|
||||||
|
* `props` 透传给列(如 width、align、fixed、minWidth),可覆盖 operateColWidth / fixed。
|
||||||
|
*/
|
||||||
|
operateColConfig?: ActionButtonConfig;
|
||||||
pagination?: boolean;
|
pagination?: boolean;
|
||||||
/** 表格标题前缀 ,新增xxx,暂无xxx数据,表明表格内容是什么 */
|
/** 表格标题前缀 ,新增xxx,暂无xxx数据,表明表格内容是什么 */
|
||||||
titlePrefix?: string;
|
titlePrefix?: string;
|
||||||
@ -919,6 +977,8 @@ export interface TableConfig<T = never> extends TableGroupListCommonConfig {
|
|||||||
dropActionButtonIcon?: any;
|
dropActionButtonIcon?: any;
|
||||||
copyActionButtonIcon?: any;
|
copyActionButtonIcon?: any;
|
||||||
deleteActionButtonIcon?: any;
|
deleteActionButtonIcon?: any;
|
||||||
|
/** 拖动排序按钮,text 作为提示,默认「拖动排序」 */
|
||||||
|
dropSortButtonConfig?: ActionButtonConfig;
|
||||||
/** 是否显示全屏按钮 */
|
/** 是否显示全屏按钮 */
|
||||||
enableFullscreen?: boolean;
|
enableFullscreen?: boolean;
|
||||||
fixed?: boolean | 'left' | 'right';
|
fixed?: boolean | 'left' | 'right';
|
||||||
|
|||||||
@ -19,8 +19,9 @@
|
|||||||
type="primary"
|
type="primary"
|
||||||
:icon="DocumentCopy"
|
:icon="DocumentCopy"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
v-bind="config.copyButtonConfig?.props"
|
||||||
@click="copyHandler"
|
@click="copyHandler"
|
||||||
>复制</TMagicButton
|
>{{ actionButtonText(config.copyButtonConfig, 'copy') }}</TMagicButton
|
||||||
>
|
>
|
||||||
|
|
||||||
<template v-if="movable && !isCompare">
|
<template v-if="movable && !isCompare">
|
||||||
@ -30,8 +31,9 @@
|
|||||||
size="default"
|
size="default"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:icon="Top"
|
:icon="Top"
|
||||||
|
v-bind="config.moveUpButtonConfig?.props"
|
||||||
@click="changeOrder(-1)"
|
@click="changeOrder(-1)"
|
||||||
>上移</TMagicButton
|
>{{ actionButtonText(config.moveUpButtonConfig, 'moveUp') }}</TMagicButton
|
||||||
>
|
>
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
v-show="index !== length - 1"
|
v-show="index !== length - 1"
|
||||||
@ -39,8 +41,9 @@
|
|||||||
size="default"
|
size="default"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:icon="Bottom"
|
:icon="Bottom"
|
||||||
|
v-bind="config.moveDownButtonConfig?.props"
|
||||||
@click="changeOrder(1)"
|
@click="changeOrder(1)"
|
||||||
>下移</TMagicButton
|
>{{ actionButtonText(config.moveDownButtonConfig, 'moveDown') }}</TMagicButton
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -58,8 +61,9 @@
|
|||||||
type="primary"
|
type="primary"
|
||||||
:icon="Position"
|
:icon="Position"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
v-bind="config.moveToButtonConfig?.props"
|
||||||
@click="moveSpecifyLocationVisible = true"
|
@click="moveSpecifyLocationVisible = true"
|
||||||
>移动至</TMagicButton
|
>{{ actionButtonText(config.moveToButtonConfig, 'moveTo') }}</TMagicButton
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<div>
|
||||||
@ -81,7 +85,10 @@
|
|||||||
</TMagicPopover>
|
</TMagicPopover>
|
||||||
|
|
||||||
<!-- v-if 必须挂在 Tooltip 上:子节点为 false 时 ElOnlyChild 会报 no valid child node found -->
|
<!-- v-if 必须挂在 Tooltip 上:子节点为 false 时 ElOnlyChild 会报 no valid child node found -->
|
||||||
<TMagicTooltip v-if="!isCompare" :content="`删除 ${title}`">
|
<TMagicTooltip
|
||||||
|
v-if="!isCompare"
|
||||||
|
:content="`${actionButtonText(config.deleteButtonConfig, 'delete')} ${title}`"
|
||||||
|
>
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
v-show="showDelete"
|
v-show="showDelete"
|
||||||
size="default"
|
size="default"
|
||||||
@ -89,10 +96,26 @@
|
|||||||
class="delete-button"
|
class="delete-button"
|
||||||
:icon="Delete"
|
:icon="Delete"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
v-bind="config.deleteButtonConfig?.props"
|
||||||
@click="removeHandler"
|
@click="removeHandler"
|
||||||
></TMagicButton>
|
></TMagicButton>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
|
|
||||||
|
<template v-if="!isCompare">
|
||||||
|
<TMagicButton
|
||||||
|
v-for="(item, i) in customButtons"
|
||||||
|
:key="i"
|
||||||
|
link
|
||||||
|
size="default"
|
||||||
|
type="primary"
|
||||||
|
:icon="item.button.icon"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
v-bind="item.button.props"
|
||||||
|
@click="item.onClick"
|
||||||
|
>{{ item.button.text }}</TMagicButton
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
|
||||||
<span v-if="itemExtra" v-html="itemExtra" class="m-form-tip"></span>
|
<span v-if="itemExtra" v-html="itemExtra" class="m-form-tip"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -122,6 +145,7 @@ import { ArrowDown, ArrowRight, Bottom, Delete, DocumentCopy, Position, Top } fr
|
|||||||
import { TMagicButton, TMagicCard, TMagicIcon, TMagicInputNumber, TMagicPopover, TMagicTooltip } from '@tmagic/design';
|
import { TMagicButton, TMagicCard, TMagicIcon, TMagicInputNumber, TMagicPopover, TMagicTooltip } from '@tmagic/design';
|
||||||
|
|
||||||
import type { ContainerChangeEventData, FormState, GroupListConfig } from '../schema';
|
import type { ContainerChangeEventData, FormState, GroupListConfig } from '../schema';
|
||||||
|
import { actionButtonText, resolveCustomActionButtons } from '../utils/actionButton';
|
||||||
import { appendProp, filterFunction } from '../utils/form';
|
import { appendProp, filterFunction } from '../utils/form';
|
||||||
import { getGroupListRowConfig } from '../utils/tableGroupList';
|
import { getGroupListRowConfig } from '../utils/tableGroupList';
|
||||||
|
|
||||||
@ -210,6 +234,22 @@ const movable = computed(() => {
|
|||||||
return movable;
|
return movable;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const customButtons = computed(() =>
|
||||||
|
resolveCustomActionButtons(
|
||||||
|
mForm,
|
||||||
|
props.config.actionButtons,
|
||||||
|
{
|
||||||
|
model: props.model,
|
||||||
|
index: props.index,
|
||||||
|
groupModel: props.groupModel,
|
||||||
|
prop: rowProp.value,
|
||||||
|
config: props.config,
|
||||||
|
formValue: mForm?.values || props.model,
|
||||||
|
},
|
||||||
|
props.disabled,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const copyable = computed(() => filterFunction<boolean>(mForm, props.config.copyable, props));
|
const copyable = computed(() => filterFunction<boolean>(mForm, props.config.copyable, props));
|
||||||
const onAddDiffCount = () => emit('addDiffCount');
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<slot name="operateCol" :scope="{ $index: index, row: row }"></slot>
|
<slot name="operateCol" :scope="{ $index: index, row: row }"></slot>
|
||||||
<TMagicTooltip v-if="config.dropSort && config.dropSortHandle" content="拖动排序" placement="left-start">
|
<TMagicTooltip
|
||||||
|
v-if="config.dropSort && config.dropSortHandle"
|
||||||
|
:content="actionButtonText(config.dropSortButtonConfig, 'dropSort')"
|
||||||
|
placement="left-start"
|
||||||
|
>
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
size="small"
|
size="small"
|
||||||
link
|
link
|
||||||
:class="{ 'tmagic-form-table-drag-target': config.dropSortHandle }"
|
:class="{ 'tmagic-form-table-drag-target': config.dropSortHandle }"
|
||||||
:icon="config.dropActionButtonIcon || Sort"
|
:icon="config.dropActionButtonIcon || Sort"
|
||||||
|
v-bind="config.dropSortButtonConfig?.props"
|
||||||
>
|
>
|
||||||
</TMagicButton>
|
</TMagicButton>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
@ -15,11 +20,12 @@
|
|||||||
size="small"
|
size="small"
|
||||||
type="danger"
|
type="danger"
|
||||||
link
|
link
|
||||||
title="删除"
|
:title="actionButtonText(config.deleteButtonConfig, 'delete')"
|
||||||
:icon="flat ? undefined : config.deleteActionButtonIcon || Delete"
|
:icon="flat ? undefined : config.deleteActionButtonIcon || Delete"
|
||||||
|
v-bind="config.deleteButtonConfig?.props"
|
||||||
@click="removeHandler(index + 1 + currentPage * pageSize - 1)"
|
@click="removeHandler(index + 1 + currentPage * pageSize - 1)"
|
||||||
>
|
>
|
||||||
<template v-if="flat">删除</template>
|
<template v-if="flat">{{ actionButtonText(config.deleteButtonConfig, 'delete') }}</template>
|
||||||
</TMagicButton>
|
</TMagicButton>
|
||||||
|
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
@ -27,23 +33,40 @@
|
|||||||
link
|
link
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
title="复制"
|
:title="actionButtonText(config.copyButtonConfig, 'copy')"
|
||||||
:icon="flat ? undefined : config.copyActionButtonIcon || DocumentCopy"
|
:icon="flat ? undefined : config.copyActionButtonIcon || DocumentCopy"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
v-bind="config.copyButtonConfig?.props"
|
||||||
@click="copyHandler(index + 1 + currentPage * pageSize - 1)"
|
@click="copyHandler(index + 1 + currentPage * pageSize - 1)"
|
||||||
>
|
>
|
||||||
<template v-if="flat">复制</template>
|
<template v-if="flat">{{ actionButtonText(config.copyButtonConfig, 'copy') }}</template>
|
||||||
|
</TMagicButton>
|
||||||
|
|
||||||
|
<TMagicButton
|
||||||
|
v-for="(item, i) in customButtons"
|
||||||
|
:key="i"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:title="item.button.text"
|
||||||
|
:icon="flat ? undefined : item.button.icon"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
v-bind="item.button.props"
|
||||||
|
@click="item.onClick"
|
||||||
|
>
|
||||||
|
<template v-if="flat || !item.button.icon">{{ item.button.text }}</template>
|
||||||
</TMagicButton>
|
</TMagicButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject } from 'vue';
|
import { computed, inject } from 'vue';
|
||||||
import { Delete, DocumentCopy, Sort } from '@element-plus/icons-vue';
|
import { Delete, DocumentCopy, Sort } from '@element-plus/icons-vue';
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import { TMagicButton, TMagicTooltip } from '@tmagic/design';
|
import { TMagicButton, TMagicTooltip } from '@tmagic/design';
|
||||||
|
|
||||||
import type { FormState, TableConfig } from '@form/schema';
|
import type { FormState, TableConfig } from '@form/schema';
|
||||||
|
import { actionButtonText, resolveCustomActionButtons } from '@form/utils/actionButton';
|
||||||
|
|
||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
@ -63,6 +86,22 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const mForm = inject<FormState | undefined>('mForm');
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
|
|
||||||
|
const customButtons = computed(() =>
|
||||||
|
resolveCustomActionButtons(
|
||||||
|
mForm,
|
||||||
|
props.config.actionButtons,
|
||||||
|
{
|
||||||
|
model: props.row,
|
||||||
|
index: props.index + 1 + props.currentPage * props.pageSize - 1,
|
||||||
|
groupModel: props.model[props.name],
|
||||||
|
prop: props.prop,
|
||||||
|
config: props.config,
|
||||||
|
formValue: mForm?.values || props.model,
|
||||||
|
},
|
||||||
|
props.disabled,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const removeHandler = (index: number) => {
|
const removeHandler = (index: number) => {
|
||||||
if (props.disabled) return;
|
if (props.disabled) return;
|
||||||
emit('change', props.model[props.name].toSpliced(index, 1));
|
emit('change', props.model[props.name].toSpliced(index, 1));
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { type TableColumnOptions, TMagicIcon, TMagicTooltip } from '@tmagic/desi
|
|||||||
import type { FormItemConfig, FormState } from '@tmagic/form-schema';
|
import type { FormItemConfig, FormState } from '@tmagic/form-schema';
|
||||||
|
|
||||||
import type { ContainerChangeEventData } from '@form/schema';
|
import type { ContainerChangeEventData } from '@form/schema';
|
||||||
|
import { actionButtonText } from '@form/utils/actionButton';
|
||||||
import { isGlobalFlat } from '@form/utils/config';
|
import { isGlobalFlat } from '@form/utils/config';
|
||||||
import { appendProp, display as displayFunc, getDataByPage, sortArray } from '@form/utils/form';
|
import { appendProp, display as displayFunc, getDataByPage, sortArray } from '@form/utils/form';
|
||||||
import { isTableColumnRendered, makeTableColumnConfig } from '@form/utils/tableGroupList';
|
import { isTableColumnRendered, makeTableColumnConfig } from '@form/utils/tableGroupList';
|
||||||
@ -112,11 +113,12 @@ export const useTableColumns = (
|
|||||||
|
|
||||||
const actionColumn = {
|
const actionColumn = {
|
||||||
props: {
|
props: {
|
||||||
label: '操作',
|
|
||||||
fixed: actionFixed,
|
|
||||||
|
|
||||||
width: props.config.operateColWidth ?? (props.config.dropSortHandle && props.config.dropSort ? 132 : 112),
|
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
fixed: actionFixed,
|
||||||
|
width: props.config.operateColWidth ?? (props.config.dropSortHandle && props.config.dropSort ? 132 : 112),
|
||||||
|
// props 覆盖列上的默认属性(宽度、对齐、fixed 等),标题仍以 text 为准
|
||||||
|
...props.config.operateColConfig?.props,
|
||||||
|
label: actionButtonText(props.config.operateColConfig, 'operate'),
|
||||||
},
|
},
|
||||||
cell: ({ row, $index }: any) =>
|
cell: ({ row, $index }: any) =>
|
||||||
h(ActionsColumn, {
|
h(ActionsColumn, {
|
||||||
|
|||||||
67
packages/form/src/utils/actionButton.ts
Normal file
67
packages/form/src/utils/actionButton.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ActionButtonConfig,
|
||||||
|
CustomActionButtonConfig,
|
||||||
|
CustomActionButtonContext,
|
||||||
|
FormState,
|
||||||
|
} from '@tmagic/form-schema';
|
||||||
|
|
||||||
|
/** table / group-list 行操作的内置文案。空 text 与未配置一样回退到这里,与 addButtonConfig 一致 */
|
||||||
|
export const DEFAULT_ACTION_BUTTON_TEXT = {
|
||||||
|
operate: '操作',
|
||||||
|
delete: '删除',
|
||||||
|
copy: '复制',
|
||||||
|
dropSort: '拖动排序',
|
||||||
|
moveUp: '上移',
|
||||||
|
moveDown: '下移',
|
||||||
|
moveTo: '移动至',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const actionButtonText = (
|
||||||
|
config: ActionButtonConfig | undefined,
|
||||||
|
key: keyof typeof DEFAULT_ACTION_BUTTON_TEXT,
|
||||||
|
): string => config?.text || DEFAULT_ACTION_BUTTON_TEXT[key];
|
||||||
|
|
||||||
|
export interface ResolvedCustomActionButton {
|
||||||
|
button: CustomActionButtonConfig;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析自定义行操作按钮:过滤掉不显示的,并计算禁用状态与点击回调 */
|
||||||
|
export const resolveCustomActionButtons = (
|
||||||
|
mForm: FormState | undefined,
|
||||||
|
buttons: CustomActionButtonConfig[] | undefined,
|
||||||
|
ctx: CustomActionButtonContext,
|
||||||
|
formDisabled?: boolean,
|
||||||
|
): ResolvedCustomActionButton[] => {
|
||||||
|
if (!Array.isArray(buttons)) return [];
|
||||||
|
|
||||||
|
return buttons
|
||||||
|
.filter(({ display }) => (typeof display === 'function' ? display(mForm, ctx) : (display ?? true)))
|
||||||
|
.map((button) => {
|
||||||
|
const { disabled } = button;
|
||||||
|
return {
|
||||||
|
button,
|
||||||
|
disabled: Boolean(formDisabled) || Boolean(typeof disabled === 'function' ? disabled(mForm, ctx) : disabled),
|
||||||
|
onClick: () => button.handler(mForm, ctx),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { nextTick, reactive } from 'vue';
|
import { nextTick, reactive } from 'vue';
|
||||||
|
import { Sort } from '@element-plus/icons-vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import ElementPlus from 'element-plus';
|
import ElementPlus from 'element-plus';
|
||||||
|
|
||||||
@ -29,14 +30,25 @@ const createMForm = (context: Record<string, any>): FormState =>
|
|||||||
() => context,
|
() => context,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mountColumn = (config: any, context: Record<string, any> = { env: 'prod' }) =>
|
const COMPONENT_PROP_KEYS = ['flat', 'disabled', 'sortKey', 'model'] as const;
|
||||||
mount(ActionsColumn as any, {
|
|
||||||
|
const mountColumn = (config: any, context: Record<string, any> = { env: 'prod' }) => {
|
||||||
|
const tableConfig = { ...config };
|
||||||
|
const extra: Record<string, any> = {};
|
||||||
|
for (const key of COMPONENT_PROP_KEYS) {
|
||||||
|
if (key in tableConfig) {
|
||||||
|
extra[key] = tableConfig[key];
|
||||||
|
delete tableConfig[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mount(ActionsColumn as any, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [ElementPlus as any, MagicForm as any],
|
plugins: [ElementPlus as any, MagicForm as any],
|
||||||
provide: { mForm: createMForm(context) },
|
provide: { mForm: createMForm(context) },
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
config: { type: 'table', name: 'list', ...config },
|
config: { type: 'table', name: 'list', ...tableConfig },
|
||||||
model: { list: [{ text: 'a' }] },
|
model: { list: [{ text: 'a' }] },
|
||||||
name: 'list',
|
name: 'list',
|
||||||
prop: 'list',
|
prop: 'list',
|
||||||
@ -44,8 +56,10 @@ const mountColumn = (config: any, context: Record<string, any> = { env: 'prod' }
|
|||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
index: 0,
|
index: 0,
|
||||||
row: { text: 'a' },
|
row: { text: 'a' },
|
||||||
|
...extra,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
describe('ActionsColumn', () => {
|
describe('ActionsColumn', () => {
|
||||||
test('copyable 函数收到 model / index / prop', async () => {
|
test('copyable 函数收到 model / index / prop', async () => {
|
||||||
@ -75,4 +89,122 @@ describe('ActionsColumn', () => {
|
|||||||
|
|
||||||
expect(wrapper.text()).not.toContain('复制');
|
expect(wrapper.text()).not.toContain('复制');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('未配置按钮文案时使用默认操作名称', async () => {
|
||||||
|
const wrapper = mountColumn({ flat: true, dropSort: true, dropSortHandle: true });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('删除');
|
||||||
|
expect(wrapper.text()).toContain('复制');
|
||||||
|
expect(wrapper.findComponent({ name: 'TMTooltip' }).props('content')).toBe('拖动排序');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('按钮配置与 addButtonConfig 一样,用 text 改名称、props 透传', async () => {
|
||||||
|
const wrapper = mountColumn({
|
||||||
|
flat: true,
|
||||||
|
dropSort: true,
|
||||||
|
dropSortHandle: true,
|
||||||
|
deleteButtonConfig: { text: '移除', props: { class: 'custom-delete', type: 'warning', size: 'large' } },
|
||||||
|
copyButtonConfig: { text: '克隆', props: { plain: true } },
|
||||||
|
dropSortButtonConfig: { text: '拖拽换序', props: { class: 'custom-drag' } },
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('移除');
|
||||||
|
expect(wrapper.text()).toContain('克隆');
|
||||||
|
expect(wrapper.text()).not.toContain('删除');
|
||||||
|
expect(wrapper.text()).not.toContain('复制');
|
||||||
|
expect(wrapper.find('.custom-delete').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.custom-drag').exists()).toBe(true);
|
||||||
|
const deleteButton = wrapper.findAllComponents({ name: 'TMButton' }).find((btn) => btn.props('type') === 'warning');
|
||||||
|
expect(deleteButton?.props('size')).toBe('large');
|
||||||
|
const copyButton = wrapper.findAllComponents({ name: 'TMButton' }).find((btn) => btn.text().includes('克隆'));
|
||||||
|
expect(copyButton?.find('button').classes()).toContain('is-plain');
|
||||||
|
expect(wrapper.findComponent({ name: 'TMTooltip' }).props('content')).toBe('拖拽换序');
|
||||||
|
expect(wrapper.findAll('button').map((btn) => btn.attributes('title'))).toEqual(
|
||||||
|
expect.arrayContaining(['移除', '克隆']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete 函数返回 false 时不展示删除,点击删除会移除当前行', async () => {
|
||||||
|
const deleteFn = vi.fn(() => false);
|
||||||
|
const hidden = mountColumn({ delete: deleteFn, flat: true });
|
||||||
|
await nextTick();
|
||||||
|
expect(deleteFn).toHaveBeenCalled();
|
||||||
|
expect((hidden.findAll('button')[0].element as HTMLElement).style.display).toBe('none');
|
||||||
|
|
||||||
|
const wrapper = mountColumn({ flat: true });
|
||||||
|
await nextTick();
|
||||||
|
await wrapper.findAll('button')[0].trigger('click');
|
||||||
|
expect(wrapper.emitted('change')?.[0][0]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled 时点击删除不改数据,复制会沿 sortKey 递减', async () => {
|
||||||
|
const blocked = mountColumn({ disabled: true, flat: true });
|
||||||
|
await nextTick();
|
||||||
|
await blocked.find('button').trigger('click');
|
||||||
|
expect(blocked.emitted('change')).toBeUndefined();
|
||||||
|
|
||||||
|
const wrapper = mountColumn({
|
||||||
|
flat: true,
|
||||||
|
sortKey: 'order',
|
||||||
|
copyable: true,
|
||||||
|
});
|
||||||
|
await wrapper.setProps({
|
||||||
|
model: {
|
||||||
|
list: [
|
||||||
|
{ text: 'a', order: 3 },
|
||||||
|
{ text: 'b', order: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
const copyButton = wrapper.findAll('button').find((btn) => btn.text().includes('复制'));
|
||||||
|
await copyButton?.trigger('click');
|
||||||
|
expect(wrapper.emitted('change')?.[0][0]).toEqual([
|
||||||
|
{ text: 'a', order: 3 },
|
||||||
|
{ text: 'b', order: 1 },
|
||||||
|
{ text: 'a', order: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actionButtons 渲染自定义按钮,点击收到行与下标,禁用时不触发', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrapper = mountColumn({
|
||||||
|
flat: true,
|
||||||
|
actionButtons: [
|
||||||
|
{ text: '详情', props: { class: 'custom-detail' }, handler },
|
||||||
|
{ text: '隐藏', display: false, handler },
|
||||||
|
{ text: '禁用', disabled: () => true, props: { class: 'custom-disabled' }, handler },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('详情');
|
||||||
|
expect(wrapper.text()).not.toContain('隐藏');
|
||||||
|
expect(wrapper.find('.custom-detail').exists()).toBe(true);
|
||||||
|
|
||||||
|
await wrapper.find('.custom-detail').trigger('click');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler.mock.calls[0][1]).toMatchObject({
|
||||||
|
model: { text: 'a' },
|
||||||
|
index: 0,
|
||||||
|
prop: 'list',
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.find('.custom-disabled').trigger('click');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非 flat 且有 icon 时自定义按钮只显示图标,text 作为 title', async () => {
|
||||||
|
const wrapper = mountColumn({
|
||||||
|
actionButtons: [{ text: '详情', icon: Sort, props: { class: 'custom-icon' }, handler: () => undefined }],
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const btn = wrapper.find('.custom-icon');
|
||||||
|
expect(btn.exists()).toBe(true);
|
||||||
|
expect(btn.attributes('title')).toBe('详情');
|
||||||
|
expect(btn.text()).not.toContain('详情');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -661,6 +661,112 @@ describe('GroupList container', () => {
|
|||||||
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
|
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('行操作按钮用 text 改名称,形态与 addButtonConfig 相同', async () => {
|
||||||
|
const wrapper = mountForm(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'group-list',
|
||||||
|
name: 'list',
|
||||||
|
copyable: true,
|
||||||
|
movable: true,
|
||||||
|
moveSpecifyLocation: true,
|
||||||
|
titleKey: 'text',
|
||||||
|
titlePrefix: '条目',
|
||||||
|
copyButtonConfig: { text: '克隆', props: { class: 'custom-copy', type: 'success' } },
|
||||||
|
moveUpButtonConfig: { text: '前移', props: { class: 'custom-up' } },
|
||||||
|
moveDownButtonConfig: { text: '后移', props: { class: 'custom-down' } },
|
||||||
|
moveToButtonConfig: { text: '挪到', props: { class: 'custom-move' } },
|
||||||
|
deleteButtonConfig: { text: '移除', props: { class: 'custom-remove' } },
|
||||||
|
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ list: [{ text: 'a' }, { text: 'b' }] },
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('克隆');
|
||||||
|
expect(wrapper.text()).toContain('前移');
|
||||||
|
expect(wrapper.text()).toContain('后移');
|
||||||
|
expect(wrapper.text()).toContain('挪到');
|
||||||
|
expect(wrapper.text()).not.toContain('复制');
|
||||||
|
expect(wrapper.find('.custom-copy').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.custom-up').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.custom-down').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.custom-move').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.custom-remove').exists()).toBe(true);
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.findAllComponents({ name: 'TMButton' })
|
||||||
|
.find((btn) => btn.text().includes('克隆'))
|
||||||
|
?.props('type'),
|
||||||
|
).toBe('success');
|
||||||
|
expect(wrapper.findComponent({ name: 'TMTooltip' }).props('content')).toBe('移除 条目 1: a');
|
||||||
|
|
||||||
|
await wrapper.find('.expand-button').trigger('click');
|
||||||
|
await wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((btn) => btn.text().includes('挪到'))
|
||||||
|
?.trigger('click');
|
||||||
|
await wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((btn) => btn.text().includes('取消'))
|
||||||
|
?.trigger('click');
|
||||||
|
await wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((btn) => btn.text().includes('确认'))
|
||||||
|
?.trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
expect((wrapper.vm as any).values.list.map((item: { text: string }) => item.text)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actionButtons 渲染自定义按钮并收到当前行上下文', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrapper = mountForm(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'group-list',
|
||||||
|
name: 'list',
|
||||||
|
actionButtons: [
|
||||||
|
{ text: '详情', props: { class: 'custom-detail' }, handler },
|
||||||
|
{ text: '隐藏', display: () => false, handler },
|
||||||
|
],
|
||||||
|
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ list: [{ text: 'a' }, { text: 'b' }] },
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('隐藏');
|
||||||
|
const buttons = wrapper.findAll('.custom-detail');
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
|
||||||
|
await buttons[1].trigger('click');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler.mock.calls[0][1]).toMatchObject({ index: 1, model: { text: 'b' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete 函数可隐藏单行删除,title 函数作为卡片标题', async () => {
|
||||||
|
const deleteFn = vi.fn(() => false);
|
||||||
|
const wrapper = mountForm(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'group-list',
|
||||||
|
name: 'list',
|
||||||
|
delete: deleteFn,
|
||||||
|
title: () => '自定义标题',
|
||||||
|
items: [{ name: 'text', type: 'text', text: 'text' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ list: [{ text: 'a' }] },
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(deleteFn).toHaveBeenCalled();
|
||||||
|
expect((wrapper.find('.delete-button').element as HTMLElement).style.display).toBe('none');
|
||||||
|
expect(wrapper.text()).toContain('自定义标题');
|
||||||
|
});
|
||||||
|
|
||||||
test('嵌套列表各自按 header.sticky 决定是否吸顶', async () => {
|
test('嵌套列表各自按 header.sticky 决定是否吸顶', async () => {
|
||||||
const wrapper = mountForm(
|
const wrapper = mountForm(
|
||||||
[
|
[
|
||||||
|
|||||||
218
packages/form/tests/unit/containers/useTableColumns.spec.ts
Normal file
218
packages/form/tests/unit/containers/useTableColumns.spec.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
import { defineComponent, h, nextTick, reactive } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import { useTableColumns } from '@form/containers/table/useTableColumns';
|
||||||
|
import { setConfig } from '@form/utils/config';
|
||||||
|
|
||||||
|
const Harness = defineComponent({
|
||||||
|
name: 'ColumnsHarness',
|
||||||
|
props: {
|
||||||
|
tableProps: { type: Object, required: true },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const events: any[] = [];
|
||||||
|
const { columns } = useTableColumns(
|
||||||
|
props.tableProps as any,
|
||||||
|
(...args: any[]) => {
|
||||||
|
events.push(args);
|
||||||
|
},
|
||||||
|
props.tableProps.currentPage,
|
||||||
|
props.tableProps.pageSize,
|
||||||
|
props.tableProps.modelName,
|
||||||
|
);
|
||||||
|
return { columns, events };
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createState = (overrides: Record<string, any> = {}) => {
|
||||||
|
const currentPage = overrides.currentPage ?? { value: 0 };
|
||||||
|
const pageSize = overrides.pageSize ?? { value: 10 };
|
||||||
|
const modelName = overrides.modelName ?? { value: 'list' };
|
||||||
|
return reactive({
|
||||||
|
model: { list: [{ text: 'a' }, { text: 'b' }] },
|
||||||
|
lastValues: { list: [{ text: 'old-a' }, { text: 'old-b' }] },
|
||||||
|
name: 'list',
|
||||||
|
prop: 'list',
|
||||||
|
showIndex: false,
|
||||||
|
sort: false,
|
||||||
|
config: {
|
||||||
|
type: 'table',
|
||||||
|
name: 'list',
|
||||||
|
items: [{ name: 'text', label: '文本', type: 'text' }],
|
||||||
|
},
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
modelName,
|
||||||
|
...overrides,
|
||||||
|
config: {
|
||||||
|
type: 'table',
|
||||||
|
name: 'list',
|
||||||
|
items: [{ name: 'text', label: '文本', type: 'text' }],
|
||||||
|
...(overrides.config || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountColumns = (state: ReturnType<typeof createState>, mForm?: any) =>
|
||||||
|
mount(Harness, {
|
||||||
|
props: { tableProps: state },
|
||||||
|
global: mForm ? { provide: { mForm } } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = (wrapper: ReturnType<typeof mountColumns>) =>
|
||||||
|
(wrapper.vm as any).columns.map((column: any) => column.props.label ?? column.props.type);
|
||||||
|
|
||||||
|
describe('useTableColumns', () => {
|
||||||
|
test('操作列标题默认为「操作」,operateColConfig.text 可替换', async () => {
|
||||||
|
setConfig({});
|
||||||
|
const defaults = mountColumns(createState());
|
||||||
|
await nextTick();
|
||||||
|
expect(labels(defaults)).toContain('操作');
|
||||||
|
|
||||||
|
const custom = mountColumns(
|
||||||
|
createState({
|
||||||
|
config: {
|
||||||
|
operateColWidth: 112,
|
||||||
|
fixed: 'left',
|
||||||
|
operateColConfig: { text: '动作', props: { width: 200, align: 'left', minWidth: 80, fixed: 'right' } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
const operateCol = (custom.vm as any).columns.find((column: any) => column.props.label === '动作');
|
||||||
|
expect(operateCol).toBeTruthy();
|
||||||
|
expect(labels(custom)).not.toContain('操作');
|
||||||
|
expect(operateCol.props).toMatchObject({ width: 200, align: 'left', minWidth: 80, fixed: 'right' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('展开列、选择列、排序列、序号列与数据列按配置拼出', async () => {
|
||||||
|
setConfig({ flat: true });
|
||||||
|
const itemExtra = vi.fn(() => '<em>extra</em>');
|
||||||
|
const titleTip = vi.fn(() => '提示');
|
||||||
|
const selection = vi.fn(() => true);
|
||||||
|
const mForm = { initValues: { list: [] }, values: { list: [{ text: 'form' }] }, $emit: vi.fn() };
|
||||||
|
const state = createState({
|
||||||
|
sort: true,
|
||||||
|
showIndex: true,
|
||||||
|
config: {
|
||||||
|
itemExtra,
|
||||||
|
selection,
|
||||||
|
showIndex: true,
|
||||||
|
flat: undefined,
|
||||||
|
fixed: 'right',
|
||||||
|
pagination: true,
|
||||||
|
items: [
|
||||||
|
{ name: 'hidden', type: 'hidden' },
|
||||||
|
{ name: 'skip', label: '跳过', display: false },
|
||||||
|
{ name: 'text', label: '文本', titleTip, itemsFunction: (row: any) => [{ name: 'inner', text: row.text }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = mountColumns(state, mForm);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const { columns } = wrapper.vm as any;
|
||||||
|
expect(labels(wrapper)).toEqual(['expand', 'selection', '排序', '序号', '文本', '操作']);
|
||||||
|
|
||||||
|
const extraVnode = columns[0].cell({ $index: 1 });
|
||||||
|
expect(itemExtra).toHaveBeenCalled();
|
||||||
|
expect(extraVnode.props.innerHTML).toBe('<em>extra</em>');
|
||||||
|
expect(extraVnode.props.class).toBe('m-form-tip');
|
||||||
|
|
||||||
|
const sortVnode = columns[2].cell({ $index: 1 });
|
||||||
|
sortVnode.props.onSwap(1, 0);
|
||||||
|
expect(mForm.$emit).toHaveBeenCalledWith('field-change', expect.any(Array));
|
||||||
|
expect((wrapper.vm as any).events.at(-1)[0]).toBe('change');
|
||||||
|
|
||||||
|
expect(String(columns[3].cell({ $index: 1 }).children)).toBe('2');
|
||||||
|
|
||||||
|
const dataVnode = columns[4].cell({ row: { text: 'a' }, $index: 0 });
|
||||||
|
expect(dataVnode.props.config.items).toEqual([{ name: 'inner', text: 'a' }]);
|
||||||
|
expect(dataVnode.props.lastValues).toEqual({ text: 'old-a' });
|
||||||
|
dataVnode.props.onChange({ text: 'next' }, { changeRecords: [] });
|
||||||
|
dataVnode.props.onAddDiffCount();
|
||||||
|
expect((wrapper.vm as any).events.map((event: any[]) => event[0])).toEqual(
|
||||||
|
expect.arrayContaining(['change', 'addDiffCount']),
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleVnode = columns[4].title();
|
||||||
|
expect(titleTip).toHaveBeenCalled();
|
||||||
|
expect(titleVnode).toBeTruthy();
|
||||||
|
|
||||||
|
const actionVnode = columns[5].cell({ row: { text: 'a' }, $index: 0 });
|
||||||
|
expect(actionVnode.props.flat).toBe(true);
|
||||||
|
actionVnode.props.onChange([{ text: 'only' }]);
|
||||||
|
expect((wrapper.vm as any).events.at(-1)).toEqual(['change', [{ text: 'only' }]]);
|
||||||
|
|
||||||
|
const dropped = mountColumns(
|
||||||
|
createState({
|
||||||
|
config: {
|
||||||
|
fixed: false,
|
||||||
|
dropSort: true,
|
||||||
|
itemExtra: 'plain',
|
||||||
|
selection: false,
|
||||||
|
pagination: false,
|
||||||
|
items: [{ name: 'text', label: '文本', titleTip: '静态提示' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mForm,
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const nextColumns = (dropped.vm as any).columns;
|
||||||
|
expect(nextColumns[0].props.label).toBe('操作');
|
||||||
|
expect(nextColumns[0].props.fixed).toBeUndefined();
|
||||||
|
expect(nextColumns.some((column: any) => column.props.type === 'expand')).toBe(false);
|
||||||
|
const plain = nextColumns.find((column: any) => column.props.prop === 'text');
|
||||||
|
expect(plain.props.class).toBe('el-table__column--dropable');
|
||||||
|
expect(plain.title()).toBeTruthy();
|
||||||
|
expect(plain.cell({ row: { text: 'a' }, $index: 0 }).props.lastValues).toEqual({ text: 'old-a' });
|
||||||
|
|
||||||
|
setConfig({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('选择函数返回 false、非法 fixed、全局非 flat 时不铺平操作列', async () => {
|
||||||
|
setConfig({});
|
||||||
|
const state = createState({
|
||||||
|
sort: true,
|
||||||
|
model: { list: [{ text: 'only' }] },
|
||||||
|
lastValues: {},
|
||||||
|
config: {
|
||||||
|
selection: () => false,
|
||||||
|
fixed: 'center',
|
||||||
|
items: [{ name: 'text', label: '文本', display: () => false }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = mountColumns(state);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const { columns } = wrapper.vm as any;
|
||||||
|
expect(columns.map((column: any) => column.props.label ?? column.props.type)).toEqual(['操作']);
|
||||||
|
expect(columns[0].props.fixed).toBe('left');
|
||||||
|
const actionVnode = columns[0].cell({ row: { text: 'only' }, $index: 0 });
|
||||||
|
expect(actionVnode.props.flat).toBeUndefined();
|
||||||
|
|
||||||
|
const plain = mountColumns(
|
||||||
|
createState({
|
||||||
|
lastValues: {},
|
||||||
|
config: {
|
||||||
|
fixed: 'left',
|
||||||
|
items: [{ name: 'text', label: '文本' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
const dataColumn = (plain.vm as any).columns.find((column: any) => column.props.prop === 'text');
|
||||||
|
expect(dataColumn.cell({ row: { text: 'only' }, $index: 0 }).props.lastValues).toBeUndefined();
|
||||||
|
expect(dataColumn.title).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
61
packages/form/tests/unit/utils/actionButton.spec.ts
Normal file
61
packages/form/tests/unit/utils/actionButton.spec.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { actionButtonText, DEFAULT_ACTION_BUTTON_TEXT, resolveCustomActionButtons } from '@form/utils/actionButton';
|
||||||
|
|
||||||
|
describe('actionButtonText', () => {
|
||||||
|
test('未配置时返回内置文案', () => {
|
||||||
|
expect(actionButtonText(undefined, 'operate')).toBe(DEFAULT_ACTION_BUTTON_TEXT.operate);
|
||||||
|
expect(actionButtonText({}, 'delete')).toBe('删除');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text 覆盖内置文案,空字符串回退', () => {
|
||||||
|
expect(actionButtonText({ text: '克隆' }, 'copy')).toBe('克隆');
|
||||||
|
expect(actionButtonText({ text: '' }, 'moveUp')).toBe('上移');
|
||||||
|
expect(actionButtonText({ text: '前移' }, 'moveDown')).toBe('前移');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveCustomActionButtons', () => {
|
||||||
|
const ctx = { model: { a: 1 }, index: 0, groupModel: [{ a: 1 }], config: {}, formValue: {} };
|
||||||
|
|
||||||
|
test('未配置返回空数组', () => {
|
||||||
|
expect(resolveCustomActionButtons(undefined, undefined, ctx)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display 为 false 或函数返回 false 时过滤', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const display = vi.fn(() => false);
|
||||||
|
const result = resolveCustomActionButtons(
|
||||||
|
undefined,
|
||||||
|
[{ handler }, { handler, display: false }, { handler, display }, { handler, display: true }],
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(display).toHaveBeenCalledWith(undefined, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled 支持静态值、函数与表单禁用', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const [a, b, c] = resolveCustomActionButtons(
|
||||||
|
undefined,
|
||||||
|
[{ handler }, { handler, disabled: () => true }, { handler, disabled: false }],
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
expect([a.disabled, b.disabled, c.disabled]).toEqual([false, true, false]);
|
||||||
|
|
||||||
|
const [d] = resolveCustomActionButtons(undefined, [{ handler }], ctx, true);
|
||||||
|
expect(d.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onClick 调用 handler 并传入上下文', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const [item] = resolveCustomActionButtons(undefined, [{ handler }], ctx);
|
||||||
|
item.onClick();
|
||||||
|
expect(handler).toHaveBeenCalledWith(undefined, ctx);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -71,6 +71,7 @@ export default class StageCore extends EventEmitter {
|
|||||||
runtimeUrl: config.runtimeUrl,
|
runtimeUrl: config.runtimeUrl,
|
||||||
zoom: config.zoom,
|
zoom: config.zoom,
|
||||||
renderType: config.renderType,
|
renderType: config.renderType,
|
||||||
|
iframeSandbox: config.iframeSandbox,
|
||||||
customizedRender: async (): Promise<HTMLElement | null | void> => {
|
customizedRender: async (): Promise<HTMLElement | null | void> => {
|
||||||
if (this?.customizedRender) {
|
if (this?.customizedRender) {
|
||||||
return await this.customizedRender(this);
|
return await this.customizedRender(this);
|
||||||
|
|||||||
@ -38,13 +38,21 @@ export default class StageRender extends EventEmitter {
|
|||||||
private runtimeUrl?: string;
|
private runtimeUrl?: string;
|
||||||
private zoom = DEFAULT_ZOOM;
|
private zoom = DEFAULT_ZOOM;
|
||||||
private renderType: RenderType;
|
private renderType: RenderType;
|
||||||
|
private iframeSandbox?: string | false;
|
||||||
private customizedRender?: () => Promise<HTMLElement | null | void>;
|
private customizedRender?: () => Promise<HTMLElement | null | void>;
|
||||||
|
|
||||||
constructor({ runtimeUrl, zoom, customizedRender, renderType = RenderType.IFRAME }: StageRenderConfig) {
|
constructor({
|
||||||
|
runtimeUrl,
|
||||||
|
zoom,
|
||||||
|
customizedRender,
|
||||||
|
renderType = RenderType.IFRAME,
|
||||||
|
iframeSandbox,
|
||||||
|
}: StageRenderConfig) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.renderType = renderType;
|
this.renderType = renderType;
|
||||||
this.runtimeUrl = runtimeUrl || '';
|
this.runtimeUrl = runtimeUrl || '';
|
||||||
|
this.iframeSandbox = iframeSandbox;
|
||||||
this.customizedRender = customizedRender;
|
this.customizedRender = customizedRender;
|
||||||
this.setZoom(zoom);
|
this.setZoom(zoom);
|
||||||
|
|
||||||
@ -194,7 +202,10 @@ export default class StageRender extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public postTmagicRuntimeReady() {
|
public postTmagicRuntimeReady() {
|
||||||
this.contentWindow = this.iframe?.contentWindow as RuntimeWindow;
|
this.contentWindow = (this.iframe?.contentWindow as RuntimeWindow | null) ?? null;
|
||||||
|
|
||||||
|
// iframe 尚未就绪时 contentWindow 为空,等 load 后再注入 magic
|
||||||
|
if (!this.contentWindow) return;
|
||||||
|
|
||||||
this.contentWindow.magic = this.getMagicApi();
|
this.contentWindow.magic = this.getMagicApi();
|
||||||
|
|
||||||
@ -247,6 +258,11 @@ export default class StageRender extends EventEmitter {
|
|||||||
|
|
||||||
private createIframe(): HTMLIFrameElement {
|
private createIframe(): HTMLIFrameElement {
|
||||||
this.iframe = globalThis.document.createElement('iframe');
|
this.iframe = globalThis.document.createElement('iframe');
|
||||||
|
// 沙箱由使用方配置(空字符串也是合法取值,表示放开全部限制),必须在设置 src 之前写入,
|
||||||
|
// 否则首次导航不受沙箱约束
|
||||||
|
if (typeof this.iframeSandbox === 'string') {
|
||||||
|
this.iframe.setAttribute('sandbox', this.iframeSandbox);
|
||||||
|
}
|
||||||
// 同源,直接加载
|
// 同源,直接加载
|
||||||
this.iframe.src = this.runtimeUrl && isSameDomain(this.runtimeUrl) ? this.runtimeUrl : '';
|
this.iframe.src = this.runtimeUrl && isSameDomain(this.runtimeUrl) ? this.runtimeUrl : '';
|
||||||
this.iframe.style.cssText = `
|
this.iframe.style.cssText = `
|
||||||
|
|||||||
@ -104,6 +104,8 @@ export interface StageCoreConfig {
|
|||||||
* 用于从图层树、面包屑等外部选中组件时,帮助用户快速定位组件在画布中的位置。
|
* 用于从图层树、面包屑等外部选中组件时,帮助用户快速定位组件在画布中的位置。
|
||||||
*/
|
*/
|
||||||
disabledFlashTip?: boolean;
|
disabledFlashTip?: boolean;
|
||||||
|
/** 画布 iframe 的 sandbox 属性值,见 StageRenderConfig.iframeSandbox */
|
||||||
|
iframeSandbox?: string | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionManagerConfig {
|
export interface ActionManagerConfig {
|
||||||
@ -153,6 +155,17 @@ export interface StageRenderConfig {
|
|||||||
runtimeUrl?: string;
|
runtimeUrl?: string;
|
||||||
zoom: number | undefined;
|
zoom: number | undefined;
|
||||||
renderType?: RenderType;
|
renderType?: RenderType;
|
||||||
|
/**
|
||||||
|
* iframe 渲染时给画布 iframe 设置的 sandbox 属性值
|
||||||
|
*
|
||||||
|
* - 不传 / `false`:不设置 sandbox 属性(默认,与未引入该配置前行为一致)
|
||||||
|
* - 字符串:原样写入 sandbox 属性,token 用空格分隔,如
|
||||||
|
* `'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads'`
|
||||||
|
*
|
||||||
|
* 注意:沙箱缺哪个 token,iframe 内对应能力就会静默失效(下载、弹窗、表单提交等),
|
||||||
|
* 编辑器自身需要 `allow-scripts` + `allow-same-origin`,否则画布不可用。
|
||||||
|
*/
|
||||||
|
iframeSandbox?: string | false;
|
||||||
customizedRender?: () => Promise<HTMLElement | null | void>;
|
customizedRender?: () => Promise<HTMLElement | null | void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -100,6 +100,21 @@ describe('StageCore', () => {
|
|||||||
stage.destroy();
|
stage.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('iframeSandbox 透传到 iframe 的 sandbox 属性', async () => {
|
||||||
|
const host = globalThis.document.createElement('div');
|
||||||
|
globalThis.document.body.appendChild(host);
|
||||||
|
const sandbox = 'allow-scripts allow-same-origin';
|
||||||
|
const stage = new StageCore({
|
||||||
|
renderType: RenderType.IFRAME,
|
||||||
|
runtimeUrl: '',
|
||||||
|
iframeSandbox: sandbox,
|
||||||
|
disabledRule: true,
|
||||||
|
});
|
||||||
|
await stage.mount(host);
|
||||||
|
expect(host.querySelector('iframe')!.getAttribute('sandbox')).toBe(sandbox);
|
||||||
|
stage.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test('select 选中节点并触发 flash', async () => {
|
test('select 选中节点并触发 flash', async () => {
|
||||||
const { host, page, stage } = createStage();
|
const { host, page, stage } = createStage();
|
||||||
await stage.mount(host);
|
await stage.mount(host);
|
||||||
|
|||||||
@ -133,6 +133,47 @@ describe('StageRender - NATIVE', () => {
|
|||||||
renderer.destroy();
|
renderer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('未配置 iframeSandbox 时不设置 sandbox 属性', async () => {
|
||||||
|
const host = globalThis.document.createElement('div');
|
||||||
|
globalThis.document.body.appendChild(host);
|
||||||
|
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '' });
|
||||||
|
await renderer.mount(host);
|
||||||
|
|
||||||
|
expect(host.querySelector('iframe')!.hasAttribute('sandbox')).toBe(false);
|
||||||
|
renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iframeSandbox 为 false 时不设置 sandbox 属性', async () => {
|
||||||
|
const host = globalThis.document.createElement('div');
|
||||||
|
globalThis.document.body.appendChild(host);
|
||||||
|
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '', iframeSandbox: false });
|
||||||
|
await renderer.mount(host);
|
||||||
|
|
||||||
|
expect(host.querySelector('iframe')!.hasAttribute('sandbox')).toBe(false);
|
||||||
|
renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iframeSandbox 配置的 token 原样写入 sandbox 属性', async () => {
|
||||||
|
const host = globalThis.document.createElement('div');
|
||||||
|
globalThis.document.body.appendChild(host);
|
||||||
|
const sandbox = 'allow-scripts allow-same-origin allow-downloads';
|
||||||
|
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '', iframeSandbox: sandbox });
|
||||||
|
await renderer.mount(host);
|
||||||
|
|
||||||
|
expect(host.querySelector('iframe')!.getAttribute('sandbox')).toBe(sandbox);
|
||||||
|
renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空字符串 iframeSandbox 也写入 sandbox 属性(放开全部限制)', async () => {
|
||||||
|
const host = globalThis.document.createElement('div');
|
||||||
|
globalThis.document.body.appendChild(host);
|
||||||
|
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '', iframeSandbox: '' });
|
||||||
|
await renderer.mount(host);
|
||||||
|
|
||||||
|
expect(host.querySelector('iframe')!.getAttribute('sandbox')).toBe('');
|
||||||
|
renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test('getElementImage 找到元素时调用 snapdom', async () => {
|
test('getElementImage 找到元素时调用 snapdom', async () => {
|
||||||
const page = globalThis.document.createElement('div');
|
const page = globalThis.document.createElement('div');
|
||||||
setIdToEl()(page, 'img-node');
|
setIdToEl()(page, 'img-node');
|
||||||
@ -154,6 +195,24 @@ describe('StageRender - NATIVE', () => {
|
|||||||
renderer.destroy();
|
renderer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('contentWindow 未就绪时 mount 不抛错,就绪后注入 magic', async () => {
|
||||||
|
const host = globalThis.document.createElement('div');
|
||||||
|
globalThis.document.body.appendChild(host);
|
||||||
|
const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '' });
|
||||||
|
await expect(renderer.mount(host)).resolves.toBeUndefined();
|
||||||
|
expect(renderer.contentWindow).toBeNull();
|
||||||
|
|
||||||
|
const postMessage = vi.fn();
|
||||||
|
Object.defineProperty(renderer.iframe!, 'contentWindow', {
|
||||||
|
configurable: true,
|
||||||
|
value: { postMessage, document: globalThis.document },
|
||||||
|
});
|
||||||
|
renderer.postTmagicRuntimeReady();
|
||||||
|
expect(postMessage).toHaveBeenCalledWith({ tmagicRuntimeReady: true }, '*');
|
||||||
|
expect(renderer.contentWindow?.magic).toBeDefined();
|
||||||
|
renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test('IFRAME getElementsFromPoint 考虑 iframe 偏移', async () => {
|
test('IFRAME getElementsFromPoint 考虑 iframe 偏移', async () => {
|
||||||
const host = globalThis.document.createElement('div');
|
const host = globalThis.document.createElement('div');
|
||||||
globalThis.document.body.appendChild(host);
|
globalThis.document.body.appendChild(host);
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
ref="editor"
|
ref="editor"
|
||||||
:menu="menu"
|
:menu="menu"
|
||||||
:runtime-url="runtimeUrl"
|
:runtime-url="runtimeUrl"
|
||||||
|
:iframe-sandbox="iframeSandbox"
|
||||||
:props-configs="propsConfigs"
|
:props-configs="propsConfigs"
|
||||||
:props-values="propsValues"
|
:props-values="propsValues"
|
||||||
:event-method-list="eventMethodList"
|
:event-method-list="eventMethodList"
|
||||||
@ -83,6 +84,22 @@ uiService.set('propsPanelSize', 'default');
|
|||||||
const datasourceList: DatasourceTypeOption[] = [];
|
const datasourceList: DatasourceTypeOption[] = [];
|
||||||
const runtimeUrl = `${VITE_RUNTIME_PATH}/playground/index.html`;
|
const runtimeUrl = `${VITE_RUNTIME_PATH}/playground/index.html`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布 iframe 的 sandbox 配置(较宽松的演示值,非推荐值),演示「是否加、加哪些 token」由使用方决定:
|
||||||
|
* - 改成 `false` 即不设置 sandbox 属性(与未配置时一致)
|
||||||
|
* - 画布需要 allow-scripts + allow-same-origin(编辑器要读写 iframe 内的 DOM),
|
||||||
|
* allow-downloads 用于画布内组件的下载,其余 token 保留表单提交、弹窗、新窗口打开能力
|
||||||
|
*/
|
||||||
|
const iframeSandbox = [
|
||||||
|
'allow-downloads',
|
||||||
|
'allow-forms',
|
||||||
|
'allow-modals',
|
||||||
|
'allow-popups',
|
||||||
|
'allow-popups-to-escape-sandbox',
|
||||||
|
'allow-same-origin',
|
||||||
|
'allow-scripts',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
const { propsValues, propsConfigs, eventMethodList, datasourceConfigs, datasourceValues, datasourceEventMethodList } =
|
const { propsValues, propsConfigs, eventMethodList, datasourceConfigs, datasourceValues, datasourceEventMethodList } =
|
||||||
useEditorRes();
|
useEditorRes();
|
||||||
const { contentMenuData } = useEditorContentMenuData();
|
const { contentMenuData } = useEditorContentMenuData();
|
||||||
|
|||||||
1860
pnpm-lock.yaml
generated
1860
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -18,12 +18,12 @@ allowBuilds:
|
|||||||
esbuild: true
|
esbuild: true
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
"@vue/compiler-sfc": ^3.5.43
|
"@vue/compiler-sfc": ^3.5.44
|
||||||
typescript: "^6.0.3"
|
typescript: "^6.0.3"
|
||||||
vite: ^8.3.0
|
vite: ^8.3.4
|
||||||
vue: ^3.5.43
|
vue: ^3.5.44
|
||||||
element-plus: ^2.14.6
|
element-plus: ^2.14.7
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
element-plus: ^2.14.6
|
element-plus: ^2.14.7
|
||||||
vue: ^3.5.43
|
vue: ^3.5.44
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user