fix(editor): 数据源与代码块删除确认改用 Popconfirm

This commit is contained in:
roymondchen 2026-11-05 13:53:38 +08:00
parent 8e4391972b
commit c2272b12d8
8 changed files with 341 additions and 54 deletions

View File

@ -20,12 +20,29 @@
</template>
<template #tree-node-tool="{ data }">
<TMagicTooltip v-if="data.type === 'code'" effect="dark" :content="editable ? '编辑' : '查看'" placement="bottom">
<Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editCode(`${data.key}`)"></Icon>
</TMagicTooltip>
<TMagicTooltip v-if="data.type === 'code' && editable" effect="dark" content="删除" placement="bottom">
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`, { historySource: 'tree' })"></Icon>
<TMagicTooltip v-if="data.type === 'code'" effect="dark" :content="editable ? '编辑' : '查看'" placement="left">
<TMagicButton
link
:icon="editable ? Edit : View"
class="edit-icon"
@click.stop="editCode(`${data.key}`)"
></TMagicButton>
</TMagicTooltip>
<TMagicPopconfirm
v-if="data.type === 'code' && editable"
title="确定删除该代码块吗?"
placement="right"
:teleported="true"
@confirm="deleteCode(`${data.key}`, { historySource: 'tree' })"
>
<template #reference>
<span class="delete-icon-wrapper" @click.stop="deleteClickHandler(`${data.key}`)">
<TMagicTooltip effect="dark" content="删除" placement="right">
<TMagicButton link :icon="Close" class="edit-icon"></TMagicButton>
</TMagicTooltip>
</span>
</template>
</TMagicPopconfirm>
<slot name="code-block-panel-tool" :id="data.key" :data="data"></slot>
</template>
</Tree>
@ -37,9 +54,8 @@ import { Close, Edit, View } from '@element-plus/icons-vue';
import type { Id, MNode } from '@tmagic/core';
import { DepTargetType } from '@tmagic/core';
import { tMagicMessage, tMagicMessageBox, TMagicTooltip } from '@tmagic/design';
import { TMagicButton, tMagicMessage, TMagicPopconfirm, TMagicTooltip } from '@tmagic/design';
import Icon from '@editor/components/Icon.vue';
import Tree from '@editor/components/Tree.vue';
import { useFilter } from '@editor/hooks/use-filter';
import { useNodeStatus } from '@editor/hooks/use-node-status';
@ -142,29 +158,38 @@ const editCode = (id: string) => {
emit('edit', id);
};
const deleteCode = async (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
/** 返回不能删除的原因,可删除时返回 undefined */
const getDeleteErrorType = (id: string): CodeDeleteErrorType | undefined => {
const currentCode = codeList.value.find((codeItem) => codeItem.id === id);
const existBinds = Boolean(currentCode?.items?.length);
const undeleteableList = codeBlockService.getUndeletableList() || [];
if (!existBinds && !undeleteableList.includes(id)) {
await tMagicMessageBox.confirm('确定删除该代码块吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
if (currentCode?.items?.length) {
return CodeDeleteErrorType.BIND;
}
// 无绑定关系,且不在不可删除列表中
emit('remove', id, { historySource });
} else {
return (codeBlockService.getUndeletableList() || []).includes(id) ? CodeDeleteErrorType.UNDELETEABLE : undefined;
};
/** 删除确认由 TMagicPopconfirm 完成,这里只做校验与删除 */
const deleteCode = (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
const errorType = getDeleteErrorType(id);
if (errorType) {
if (typeof props.customError === 'function') {
props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE);
props.customError(id, errorType);
} else if (errorType === CodeDeleteErrorType.BIND) {
tMagicMessage.error('代码块存在绑定关系,不可删除');
} else {
if (existBinds) {
tMagicMessage.error('代码块存在绑定关系,不可删除');
} else {
tMagicMessage.error('代码块不可删除');
}
tMagicMessage.error('代码块不可删除');
}
return;
}
emit('remove', id, { historySource });
};
/** 不可删除的代码块点击后直接说明原因,无需等待确认 */
const deleteClickHandler = (id: string) => {
if (getDeleteErrorType(id)) {
deleteCode(id, { historySource: 'tree' });
}
};

View File

@ -19,12 +19,29 @@
</div>
</template>
<template #tree-node-tool="{ data }">
<TMagicTooltip v-if="data.type === 'ds'" effect="dark" :content="editable ? '编辑' : '查看'" placement="bottom">
<Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editHandler(`${data.key}`)"></Icon>
</TMagicTooltip>
<TMagicTooltip v-if="data.type === 'ds' && editable" effect="dark" content="删除" placement="bottom">
<Icon :icon="Close" class="edit-icon" @click.stop="removeHandler(`${data.key}`)"></Icon>
<TMagicTooltip v-if="data.type === 'ds'" effect="dark" :content="editable ? '编辑' : '查看'" placement="left">
<TMagicButton
link
:icon="editable ? Edit : View"
class="edit-icon"
@click.stop="editHandler(`${data.key}`)"
></TMagicButton>
</TMagicTooltip>
<TMagicPopconfirm
v-if="data.type === 'ds' && editable"
title="确定删除?"
placement="right"
:teleported="true"
@confirm="removeHandler(`${data.key}`)"
>
<template #reference>
<span class="delete-icon-wrapper" @click.stop>
<TMagicTooltip effect="dark" content="删除" placement="right">
<TMagicButton link :icon="Close" class="edit-icon"></TMagicButton>
</TMagicTooltip>
</span>
</template>
</TMagicPopconfirm>
<slot name="data-source-panel-tool" :data="data"></slot>
</template>
</Tree>
@ -35,9 +52,8 @@ import { computed } from 'vue';
import { Close, Edit, View } from '@element-plus/icons-vue';
import { DepData, DepTargetType, Id, MNode } from '@tmagic/core';
import { TMagicTooltip } from '@tmagic/design';
import { TMagicButton, TMagicPopconfirm, TMagicTooltip } from '@tmagic/design';
import Icon from '@editor/components/Icon.vue';
import Tree from '@editor/components/Tree.vue';
import { useFilter } from '@editor/hooks/use-filter';
import { useNodeStatus } from '@editor/hooks/use-node-status';
@ -156,7 +172,7 @@ const editHandler = (id: string) => {
emit('edit', id);
};
const removeHandler = async (id: string) => {
const removeHandler = (id: string) => {
emit('remove', id);
};

View File

@ -50,7 +50,7 @@
import { computed, inject, ref, useTemplateRef, watch } from 'vue';
import { mergeWith } from 'lodash-es';
import { tMagicMessageBox, TMagicScrollbar } from '@tmagic/design';
import { TMagicScrollbar } from '@tmagic/design';
import ContentMenu from '@editor/components/ContentMenu.vue';
import SearchInput from '@editor/components/SearchInput.vue';
@ -133,13 +133,8 @@ const addHandler = (type: string) => {
editDialog.value.show();
};
const removeHandler = async (id: string) => {
await tMagicMessageBox.confirm('确定删除?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
/** 删除确认由 DataSourceList 中的 TMagicPopconfirm 完成,这里只负责执行删除 */
const removeHandler = (id: string) => {
dataSourceService.remove(id, { historySource: 'tree-contextmenu' });
};

View File

@ -76,9 +76,21 @@
display: flex;
align-items: center;
// Element Plus 的 link 按钮左右 padding 是 2px,TDesign 的 text 变体默认
// 为 calc(var(--td-comp-paddingLR-l) - 1px)(15px),会把工具栏图标按钮撑宽。
.t-button--variant-text {
padding-left: 2px;
padding-right: 2px;
}
.tmagic-design-icon {
margin-right: 10px;
}
.delete-icon-wrapper {
display: inline-flex;
align-items: center;
}
}
}
}

View File

@ -50,6 +50,21 @@ vi.mock('@tmagic/design', () => ({
return () => h('div', { class: 'fake-tooltip' }, slots.default?.());
},
}),
TMagicPopconfirm: defineComponent({
name: 'TMagicPopconfirm',
props: ['title', 'placement', 'width'],
emits: ['confirm', 'cancel'],
setup(_p, { slots }) {
return () => h('div', { class: 'fake-popconfirm' }, slots.reference?.());
},
}),
TMagicButton: defineComponent({
name: 'TMagicButton',
props: ['icon', 'link'],
setup(_p, { slots, attrs }) {
return () => h('button', { ...attrs, class: 'edit-icon' }, slots.default?.());
},
}),
}));
vi.mock('@editor/components/Icon.vue', () => ({
@ -90,6 +105,7 @@ beforeEach(() => {
codeBlockService.getCodeDsl.mockReturnValue({ c1: { name: 'C1' } });
codeBlockService.getEditStatus.mockReturnValue(true);
codeBlockService.getUndeletableList.mockReturnValue([]);
depService.getTarget.mockReturnValue(null);
editorService.get.mockImplementation((k: string) => {
if (k === 'root') return { items: [{ id: 'p1', name: 'page1' }] };
if (k === 'stage') return { select: vi.fn() };
@ -109,12 +125,20 @@ describe('CodeBlockList', () => {
expect(wrapper.emitted('edit')?.[0]?.[0]).toBe('c1');
});
test('删除按钮 弹窗确认后 emit remove', async () => {
test('删除图标由 Popconfirm 包裹,确认后 emit remove', async () => {
const wrapper = mount(CodeBlockList);
const popconfirm = wrapper.findComponent({ name: 'TMagicPopconfirm' });
expect(popconfirm.exists()).toBe(true);
expect(popconfirm.props('title')).toBe('确定删除该代码块吗?');
expect(popconfirm.props('placement')).toBe('right');
await wrapper.findAll('.edit-icon')[1].trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(messageBoxConfirm).toHaveBeenCalled();
expect(wrapper.emitted('remove')).toBeFalsy();
expect(messageBoxConfirm).not.toHaveBeenCalled();
popconfirm.vm.$emit('confirm');
expect(wrapper.emitted('remove')?.[0]?.[0]).toBe('c1');
expect(wrapper.emitted('remove')?.[0]?.[1]).toEqual({ historySource: 'tree' });
});
test('点击 Tree 节点选中组件', async () => {
@ -136,21 +160,53 @@ describe('CodeBlockList', () => {
expect(wrapper.emitted('node-contextmenu')).toBeTruthy();
});
test('删除按钮: 在不可删除列表 提示错误', async () => {
test('删除按钮: 在不可删除列表 点击时提示错误', async () => {
codeBlockService.getUndeletableList.mockReturnValue(['c1']);
const wrapper = mount(CodeBlockList);
await wrapper.findAll('.edit-icon')[1].trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(wrapper.emitted('remove')).toBeFalsy();
expect(messageError).toHaveBeenCalledWith('代码块不可删除');
});
test('Popconfirm 确认时仍校验不可删除列表', () => {
codeBlockService.getUndeletableList.mockReturnValue(['c1']);
const wrapper = mount(CodeBlockList);
wrapper.findComponent({ name: 'TMagicPopconfirm' }).vm.$emit('confirm');
expect(wrapper.emitted('remove')).toBeFalsy();
expect(messageError).toHaveBeenCalledWith('代码块不可删除');
});
test('删除按钮: 存在绑定关系 提示错误', async () => {
depService.getTarget.mockReturnValue({
id: 'c1',
deps: { n1: { name: 'N1', keys: ['k'], data: { pageId: 'p1' } } },
} as any);
const wrapper = mount(CodeBlockList);
await wrapper.findAll('.edit-icon')[1].trigger('click');
expect(wrapper.emitted('remove')).toBeFalsy();
expect(messageError).toHaveBeenCalledWith('代码块存在绑定关系,不可删除');
});
test('customError 函数被调用', async () => {
codeBlockService.getUndeletableList.mockReturnValue(['c1']);
const customError = vi.fn();
const wrapper = mount(CodeBlockList, { props: { customError } as any });
await wrapper.findAll('.edit-icon')[1].trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(customError).toHaveBeenCalledWith('c1', 'undeleteable');
});
test('customError 在 Popconfirm 确认时被调用', () => {
codeBlockService.getUndeletableList.mockReturnValue(['c1']);
const customError = vi.fn();
const wrapper = mount(CodeBlockList, { props: { customError } as any });
wrapper.findComponent({ name: 'TMagicPopconfirm' }).vm.$emit('confirm');
expect(customError).toHaveBeenCalledWith('c1', 'undeleteable');
});
test('editable=false 时不显示删除图标和 Popconfirm', () => {
codeBlockService.getEditStatus.mockReturnValue(false);
const wrapper = mount(CodeBlockList);
expect(wrapper.findAll('.edit-icon').length).toBe(1);
expect(wrapper.findComponent({ name: 'TMagicPopconfirm' }).exists()).toBe(false);
});
});

View File

@ -29,7 +29,9 @@ vi.mock('@editor/hooks/use-node-status', () => ({
}));
vi.mock('@editor/hooks/use-filter', () => ({
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
useFilter: (list: any, _nodeStatusMap: any, filterNode: any) => ({
filterTextChangeHandler: (value: string) => list.value.forEach((item: any) => filterNode(value, item)),
}),
}));
vi.mock('@editor/components/Tree.vue', () => ({
@ -74,10 +76,26 @@ vi.mock('@tmagic/design', () => ({
return () => h('div', { class: 'fake-tooltip' }, slots.default?.());
},
}),
TMagicPopconfirm: defineComponent({
name: 'TMagicPopconfirm',
props: ['title', 'placement', 'width'],
emits: ['confirm', 'cancel'],
setup(_p, { slots }) {
return () => h('div', { class: 'fake-popconfirm' }, slots.reference?.());
},
}),
TMagicButton: defineComponent({
name: 'TMagicButton',
props: ['icon', 'link'],
setup(_p, { slots, attrs }) {
return () => h('button', { ...attrs, class: 'edit-icon' }, slots.default?.());
},
}),
}));
beforeEach(() => {
vi.clearAllMocks();
depService.getTargets.mockReturnValue({});
dataSourceService.get.mockImplementation((k: string) => {
if (k === 'editable') return true;
if (k === 'dataSources') return [{ id: 'd1', title: 'D1', methods: [] }];
@ -123,14 +141,21 @@ describe('DataSourceList', () => {
expect(wrapper.emitted('edit')?.[0]?.[0]).toBe('d1');
});
test('点击删除图标 emit remove (editable=true)', async () => {
test('删除图标由 Popconfirm 包裹,确认后才 emit remove', async () => {
const wrapper = mount(DataSourceList);
const icons = wrapper.findAll('.edit-icon');
await icons[1].trigger('click');
const popconfirm = wrapper.findComponent({ name: 'TMagicPopconfirm' });
expect(popconfirm.exists()).toBe(true);
expect(popconfirm.props('title')).toBe('确定删除?');
expect(popconfirm.props('placement')).toBe('right');
await wrapper.findAll('.edit-icon')[1].trigger('click');
expect(wrapper.emitted('remove')).toBeFalsy();
popconfirm.vm.$emit('confirm');
expect(wrapper.emitted('remove')?.[0]?.[0]).toBe('d1');
});
test('editable=false 时不显示删除图标', () => {
test('editable=false 时不显示删除图标和 Popconfirm', () => {
dataSourceService.get.mockImplementation((k: string) => {
if (k === 'editable') return false;
if (k === 'dataSources') return [{ id: 'd1', title: 'D1' }];
@ -138,6 +163,7 @@ describe('DataSourceList', () => {
});
const wrapper = mount(DataSourceList);
expect(wrapper.findAll('.edit-icon').length).toBe(1);
expect(wrapper.findComponent({ name: 'TMagicPopconfirm' }).exists()).toBe(false);
});
test('list 计算: dataSources 为空时为空数组', () => {
@ -149,4 +175,32 @@ describe('DataSourceList', () => {
const wrapper = mount(DataSourceList);
expect(wrapper.findComponent({ name: 'TreeStub' }).props('data').length).toBe(0);
});
test('list 计算: 合并依赖节点并按页面分类', () => {
depService.getTargets.mockReturnValue({
d1: { deps: { n1: { name: 'N1', keys: ['k1'], data: { pageId: 'p1' } } } },
} as any);
editorService.get.mockImplementation((k: string) => {
if (k === 'root')
return {
items: [
{ id: 'p1', name: 'page1' },
{ id: 'p2', name: 'page2' },
],
};
return null;
});
const wrapper = mount(DataSourceList);
const list = wrapper.findComponent({ name: 'TreeStub' }).props('data') as any[];
expect(list[0].items).toHaveLength(1);
expect(list[0].items[0].items.length).toBeGreaterThan(0);
});
test('filter 空关键字与名称匹配', () => {
const wrapper = mount(DataSourceList);
(wrapper.vm as any).filter('');
(wrapper.vm as any).filter('D1');
expect(wrapper.findComponent({ name: 'TreeStub' }).props('data')).toHaveLength(1);
});
});

View File

@ -187,8 +187,8 @@ describe('DataSourceListPanel', () => {
await wrapper.find('.edit-btn').trigger('click');
expect(editHandler).toHaveBeenCalledWith('d1');
await wrapper.find('.remove-btn').trigger('click');
expect(messageBoxConfirm).toHaveBeenCalled();
await new Promise((r) => setTimeout(r, 0));
// 确认已下沉到 DataSourceList 的 Popconfirm,面板不再弹出 MessageBox
expect(messageBoxConfirm).not.toHaveBeenCalled();
expect(dataSourceService.remove).toHaveBeenCalledWith('d1', { historySource: 'tree-contextmenu' });
await wrapper.find('.ctx-btn').trigger('click');
expect(nodeContentMenuHandler).toHaveBeenCalled();
@ -217,4 +217,32 @@ describe('DataSourceListPanel', () => {
expect(events).toContain('edit-data-source');
expect(events).toContain('remove-data-source');
});
test('eventBus remove-data-source 执行删除', () => {
mountIt();
const handler = eventBusOn.mock.calls.find((c: any[]) => c[0] === 'remove-data-source')?.[1];
handler?.('d2');
expect(dataSourceService.remove).toHaveBeenCalledWith('d2', { historySource: 'tree-contextmenu' });
});
test('eventBus 编辑事件重置定位信息并打开编辑', () => {
mountIt();
const handlerOf = (name: string) => eventBusOn.mock.calls.find((c: any[]) => c[0] === name)?.[1];
handlerOf('edit-data-source')?.('d1');
handlerOf('edit-data-source-method')?.('d2', 'm1');
handlerOf('edit-data-source-field')?.('d3', ['f1']);
expect(editHandler).toHaveBeenCalledWith('d1');
expect(editHandler).toHaveBeenCalledWith('d2');
expect(editHandler).toHaveBeenCalledWith('d3');
});
test('addHandler 合并表单配置中的数组值', async () => {
editDialog.value = { show: editDialogShow };
dataSourceService.getFormValue.mockReturnValueOnce({ id: 'fv', type: 'http', fields: [{ name: 'a' }] });
const wrapper = mountIt();
await wrapper.find('.add-btn').trigger('click');
expect(dataSourceValues.value.fields).toEqual([{ name: 'a' }]);
});
});

View File

@ -0,0 +1,101 @@
/*
* 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 { mount } from '@vue/test-utils';
import { setDesignConfig } from '@tmagic/design';
import CodeBlockList from '@editor/layouts/sidebar/code-block/CodeBlockList.vue';
import DataSourceList from '@editor/layouts/sidebar/data-source/DataSourceList.vue';
import elementPlusAdapter from '../../../../../element-plus-adapter/src/index';
setDesignConfig(elementPlusAdapter);
const editorService = {
get: vi.fn((key: string) => (key === 'root' ? { items: [{ id: 'p1', name: 'page1' }] } : null)),
select: vi.fn(),
};
const depService = {
getTargets: vi.fn(() => ({})),
getTarget: vi.fn(() => null),
};
const dataSourceService = {
get: vi.fn((key: string) => {
if (key === 'editable') return true;
if (key === 'dataSources') return [{ id: 'd1', title: 'D1' }];
return null;
}),
};
const codeBlockService = {
getCodeDsl: vi.fn(() => ({ c1: { name: 'C1' } })),
getCodeContentById: vi.fn(),
getEditStatus: vi.fn(() => true),
getUndeletableList: vi.fn(() => []),
};
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ editorService, depService, dataSourceService, codeBlockService }),
}));
/** 点击 Popconfirm 的确认按钮(Element Plus 默认文案为 Yes) */
const clickConfirm = async () => {
await vi.waitFor(() => {
expect(document.body.textContent).toContain('Yes');
});
const confirmBtn = Array.from(document.querySelectorAll('button')).find(
(btn) => btn.textContent?.trim().toLowerCase() === 'yes',
);
confirmBtn?.click();
};
/** 浮层需挂载到 body,且 placement 真正作用到 popper 上 */
const expectRightPlacement = async (wrapper: { element: Element }) => {
await vi.waitFor(() => {
expect(document.querySelector('.el-popper')?.getAttribute('data-popper-placement')).toBe('right');
});
const popper = document.querySelector('.el-popper');
expect(popper).toBeTruthy();
expect(wrapper.element.contains(popper!)).toBe(false);
};
describe('侧栏删除 Popconfirm(真实 element-plus)', () => {
test('数据源:点击删除图标弹出 Popconfirm,确认后 emit remove', async () => {
const wrapper = mount(DataSourceList, { attachTo: document.body });
await wrapper.findAll('.edit-icon')[1].trigger('click');
await vi.waitFor(() => {
expect(document.body.textContent).toContain('确定删除?');
});
await expectRightPlacement(wrapper);
expect(wrapper.emitted('remove')).toBeFalsy();
await clickConfirm();
await vi.waitFor(() => {
expect(wrapper.emitted('remove')?.[0]?.[0]).toBe('d1');
});
wrapper.unmount();
});
test('代码块:点击删除图标弹出 Popconfirm,确认后 emit remove', async () => {
const wrapper = mount(CodeBlockList, { attachTo: document.body });
await wrapper.findAll('.edit-icon')[1].trigger('click');
await vi.waitFor(() => {
expect(document.body.textContent).toContain('确定删除该代码块吗?');
});
await expectRightPlacement(wrapper);
expect(wrapper.emitted('remove')).toBeFalsy();
await clickConfirm();
await vi.waitFor(() => {
expect(wrapper.emitted('remove')?.[0]?.[0]).toBe('c1');
});
wrapper.unmount();
});
});