mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-08 02:21:11 +08:00
feat(table): 操作列支持 sub-actions 更多菜单并抽取 ActionButton 组件
This commit is contained in:
parent
4a893d35df
commit
78a1259665
45
packages/table/src/ActionButton.vue
Normal file
45
packages/table/src/ActionButton.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<TMagicButton
|
||||
v-show="visible"
|
||||
:class="btnClass"
|
||||
link
|
||||
size="small"
|
||||
:type="action.buttonType || 'primary'"
|
||||
:icon="action.icon"
|
||||
:disabled="disabled(action.disabled, row)"
|
||||
@click="onClick"
|
||||
>
|
||||
<span v-html="formatter(action.text, row)"></span>
|
||||
</TMagicButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TMagicButton } from '@tmagic/design';
|
||||
|
||||
import { disabled, formatActionText as formatter } from './actionHelpers';
|
||||
import { ColumnActionConfig } from './schema';
|
||||
|
||||
defineOptions({
|
||||
name: 'MTableActionButton',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: ColumnActionConfig;
|
||||
row: any;
|
||||
index: number;
|
||||
btnClass?: string;
|
||||
visible?: boolean;
|
||||
}>(),
|
||||
{
|
||||
btnClass: 'action-btn',
|
||||
visible: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [action: ColumnActionConfig, row: any, index: number];
|
||||
}>();
|
||||
|
||||
const onClick = () => emit('click', props.action, props.row, props.index);
|
||||
</script>
|
||||
@ -8,37 +8,83 @@
|
||||
@confirm="actionHandler(action, row, index)"
|
||||
>
|
||||
<template #reference>
|
||||
<TMagicButton
|
||||
v-show="display(action.display, row) && !editState[index]"
|
||||
class="action-btn"
|
||||
link
|
||||
size="small"
|
||||
:type="action.buttonType || 'primary'"
|
||||
:icon="action.icon"
|
||||
:disabled="disabled(action.disabled, row)"
|
||||
>
|
||||
<span v-html="formatter(action.text, row)"></span>
|
||||
</TMagicButton>
|
||||
<ActionButton
|
||||
:action="action"
|
||||
:row="row"
|
||||
:index="index"
|
||||
:visible="display(action.display, row) && !editState[index]"
|
||||
/>
|
||||
</template>
|
||||
</TMagicPopconfirm>
|
||||
|
||||
<TMagicPopover
|
||||
v-else-if="action.type === 'sub-actions'"
|
||||
trigger="click"
|
||||
:placement="action.subActionConfig?.placement || 'bottom'"
|
||||
:width="action.subActionConfig?.popoverWidth"
|
||||
:popper-class="action.subActionConfig?.popoverClass"
|
||||
:destroy-on-close="action.subActionConfig?.popoverDestroyOnClose"
|
||||
>
|
||||
<template #reference>
|
||||
<ActionButton
|
||||
:action="action"
|
||||
:row="row"
|
||||
:index="index"
|
||||
:visible="display(action.display, row) && !editState[index]"
|
||||
/>
|
||||
</template>
|
||||
<div class="sub-actions">
|
||||
<template v-for="(subAction, subIndex) in action.subActionConfig?.items" :key="subIndex">
|
||||
<TMagicPopconfirm
|
||||
v-if="subAction.popconfirm"
|
||||
placement="top"
|
||||
:width="subAction.popconfirmWidth"
|
||||
:title="formatter(subAction.confirmText, row) || '确定执行此操作?'"
|
||||
@confirm="actionHandler(subAction, row, index)"
|
||||
>
|
||||
<template #reference>
|
||||
<ActionButton
|
||||
:action="subAction"
|
||||
:row="row"
|
||||
:index="index"
|
||||
btn-class="sub-action-btn"
|
||||
:visible="display(subAction.display, row)"
|
||||
/>
|
||||
</template>
|
||||
</TMagicPopconfirm>
|
||||
|
||||
<TMagicTooltip
|
||||
v-else
|
||||
:placement="subAction.tooltipPlacement || 'top'"
|
||||
:disabled="!Boolean(subAction.tooltip)"
|
||||
:content="subAction.tooltip"
|
||||
>
|
||||
<ActionButton
|
||||
:action="subAction"
|
||||
:row="row"
|
||||
:index="index"
|
||||
btn-class="sub-action-btn"
|
||||
:visible="display(subAction.display, row)"
|
||||
@click="actionHandler"
|
||||
/>
|
||||
</TMagicTooltip>
|
||||
</template>
|
||||
</div>
|
||||
</TMagicPopover>
|
||||
|
||||
<TMagicTooltip
|
||||
v-else
|
||||
:placement="action.tooltipPlacement || 'top'"
|
||||
:disabled="!Boolean(action.tooltip)"
|
||||
:content="action.tooltip"
|
||||
>
|
||||
<TMagicButton
|
||||
v-show="display(action.display, row) && !editState[index]"
|
||||
class="action-btn"
|
||||
link
|
||||
size="small"
|
||||
:type="action.buttonType || 'primary'"
|
||||
:icon="action.icon"
|
||||
:disabled="disabled(action.disabled, row)"
|
||||
@click="actionHandler(action, row, index)"
|
||||
><span v-html="formatter(action.text, row)"></span
|
||||
></TMagicButton>
|
||||
<ActionButton
|
||||
:action="action"
|
||||
:row="row"
|
||||
:index="index"
|
||||
:visible="display(action.display, row) && !editState[index]"
|
||||
@click="actionHandler"
|
||||
/>
|
||||
</TMagicTooltip>
|
||||
</template>
|
||||
|
||||
@ -65,8 +111,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import { TMagicButton, tMagicMessage, TMagicPopconfirm, TMagicTooltip } from '@tmagic/design';
|
||||
import { TMagicButton, tMagicMessage, TMagicPopconfirm, TMagicPopover, TMagicTooltip } from '@tmagic/design';
|
||||
|
||||
import ActionButton from './ActionButton.vue';
|
||||
import { display, formatActionText as formatter } from './actionHelpers';
|
||||
import { ColumnActionConfig, ColumnConfig } from './schema';
|
||||
|
||||
defineOptions({
|
||||
@ -95,33 +143,6 @@ const emit = defineEmits<{
|
||||
'after-action-cancel': [{ index: number }];
|
||||
}>();
|
||||
|
||||
const display = (fuc: boolean | Function | undefined, row: any) => {
|
||||
if (typeof fuc === 'function') {
|
||||
return fuc(row);
|
||||
}
|
||||
if (typeof fuc === 'boolean') {
|
||||
return fuc;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const disabled = (fuc: boolean | Function | undefined, row: any) => {
|
||||
if (typeof fuc === 'function') {
|
||||
return fuc(row);
|
||||
}
|
||||
if (typeof fuc === 'boolean') {
|
||||
return fuc;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const formatter = (fuc: string | Function | undefined, row: any) => {
|
||||
if (typeof fuc === 'function') {
|
||||
return fuc(row);
|
||||
}
|
||||
return fuc;
|
||||
};
|
||||
|
||||
const actionHandler = async (action: ColumnActionConfig, row: any, index: number) => {
|
||||
await action.before?.(row, index);
|
||||
if (action.type === 'edit') {
|
||||
@ -163,4 +184,23 @@ const cancel = async (index: number, config: ColumnConfig) => {
|
||||
}
|
||||
emit('after-action-cancel', { index });
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
actionHandler,
|
||||
save,
|
||||
cancel,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sub-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sub-actions .sub-action-btn {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,19 +1,20 @@
|
||||
<template>
|
||||
<component
|
||||
:is="config.component"
|
||||
v-bind="componentProps(row, index)"
|
||||
v-on="componentListeners(row, index)"
|
||||
v-bind="resolveComponentProps(config, row, index)"
|
||||
v-on="resolveComponentListeners(config, row, index)"
|
||||
></component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { resolveComponentListeners, resolveComponentProps } from './componentHelpers';
|
||||
import { ColumnConfig } from './schema';
|
||||
|
||||
defineOptions({
|
||||
name: 'MTableColumn',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
config: ColumnConfig;
|
||||
row: any;
|
||||
@ -23,18 +24,4 @@ const props = withDefaults(
|
||||
config: () => ({}),
|
||||
},
|
||||
);
|
||||
|
||||
const componentProps = (row: any, index: number) => {
|
||||
if (typeof props.config.props === 'function') {
|
||||
return props.config.props(row, index) || {};
|
||||
}
|
||||
return props.config.props || {};
|
||||
};
|
||||
|
||||
const componentListeners = (row: any, index: number) => {
|
||||
if (typeof props.config.listeners === 'function') {
|
||||
return props.config.listeners(row, index) || {};
|
||||
}
|
||||
return props.config.listeners || {};
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -47,8 +47,8 @@
|
||||
import { TMagicButton, TMagicTag, TMagicTooltip } from '@tmagic/design';
|
||||
import { type ContainerChangeEventData, MForm } from '@tmagic/form';
|
||||
import type { FormItemConfig, FormValue } from '@tmagic/form-schema';
|
||||
import { setValueByKeyPath } from '@tmagic/utils';
|
||||
|
||||
import { applyInlineEditChange } from './formHelpers';
|
||||
import { ColumnConfig } from './schema';
|
||||
import { formatter } from './utils';
|
||||
|
||||
@ -70,12 +70,6 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
const formChangeHandler = (v: FormValue, eventData: ContainerChangeEventData) => {
|
||||
if (eventData.changeRecords?.length) {
|
||||
for (const record of eventData.changeRecords) {
|
||||
if (record.propPath) {
|
||||
setValueByKeyPath(record.propPath, record.value, props.editState[props.index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyInlineEditChange(props.editState[props.index], eventData);
|
||||
};
|
||||
</script>
|
||||
|
||||
26
packages/table/src/actionHelpers.ts
Normal file
26
packages/table/src/actionHelpers.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export const display = (fuc: boolean | Function | undefined, row: any) => {
|
||||
if (typeof fuc === 'function') {
|
||||
return fuc(row);
|
||||
}
|
||||
if (typeof fuc === 'boolean') {
|
||||
return fuc;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const disabled = (fuc: boolean | Function | undefined, row: any) => {
|
||||
if (typeof fuc === 'function') {
|
||||
return fuc(row);
|
||||
}
|
||||
if (typeof fuc === 'boolean') {
|
||||
return fuc;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const formatActionText = (fuc: string | Function | undefined, row: any) => {
|
||||
if (typeof fuc === 'function') {
|
||||
return fuc(row);
|
||||
}
|
||||
return fuc;
|
||||
};
|
||||
15
packages/table/src/componentHelpers.ts
Normal file
15
packages/table/src/componentHelpers.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { ColumnConfig } from './schema';
|
||||
|
||||
export const resolveComponentProps = (config: ColumnConfig, row: any, index: number) => {
|
||||
if (typeof config.props === 'function') {
|
||||
return config.props(row, index) || {};
|
||||
}
|
||||
return config.props || {};
|
||||
};
|
||||
|
||||
export const resolveComponentListeners = (config: ColumnConfig, row: any, index: number) => {
|
||||
if (typeof config.listeners === 'function') {
|
||||
return config.listeners(row, index) || {};
|
||||
}
|
||||
return config.listeners || {};
|
||||
};
|
||||
12
packages/table/src/formHelpers.ts
Normal file
12
packages/table/src/formHelpers.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { ContainerChangeEventData } from '@tmagic/form';
|
||||
import { setValueByKeyPath } from '@tmagic/utils';
|
||||
|
||||
export const applyInlineEditChange = (target: any, eventData: ContainerChangeEventData) => {
|
||||
if (eventData.changeRecords?.length) {
|
||||
for (const record of eventData.changeRecords) {
|
||||
if (record.propPath) {
|
||||
setValueByKeyPath(record.propPath, record.value, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -18,8 +18,44 @@
|
||||
|
||||
import { FormConfig, FormValue } from '@tmagic/form';
|
||||
|
||||
export type ColumnActionPlacement =
|
||||
| 'auto'
|
||||
| 'auto-start'
|
||||
| 'auto-end'
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'left-start'
|
||||
| 'left-end'
|
||||
| 'right-start'
|
||||
| 'right-end';
|
||||
|
||||
/** 当 type 为 sub-actions 时,更多菜单(Popover)的配置 */
|
||||
export interface ColumnSubActionConfig {
|
||||
/** Popover 的弹出位置,默认 bottom */
|
||||
placement?: ColumnActionPlacement;
|
||||
/** Popover 浮层宽度,数字按 px 处理 */
|
||||
popoverWidth?: string | number;
|
||||
/** 附加到 Popover 浮层上的自定义 class */
|
||||
popoverClass?: string;
|
||||
/** Popover 关闭后是否销毁内容,默认 false */
|
||||
popoverDestroyOnClose?: boolean;
|
||||
/** 更多菜单中的子动作配置 */
|
||||
items?: ColumnActionConfig[];
|
||||
}
|
||||
|
||||
export interface ColumnActionConfig {
|
||||
type?: 'delete' | 'copy' | 'edit' | string;
|
||||
/**
|
||||
* 动作类型:
|
||||
* - `delete` / `copy` / `edit`:内置语义,`edit` 会进入行内编辑态。
|
||||
* - `sub-actions`:点击后以 Popover 形式展开更多菜单,菜单配置通过 `subActionConfig` 提供。
|
||||
*/
|
||||
type?: 'delete' | 'copy' | 'edit' | 'sub-actions' | string;
|
||||
buttonType?: string;
|
||||
display?: boolean | ((row: any) => boolean);
|
||||
disabled?: boolean | ((row: any) => boolean);
|
||||
@ -34,10 +70,15 @@ export interface ColumnActionConfig {
|
||||
confirmText?: string | ((row: any) => string);
|
||||
/** Popconfirm 浮层宽度,数字按 px 处理 */
|
||||
popconfirmWidth?: string | number;
|
||||
/** 当 type 为 sub-actions 时,更多菜单(Popover)的配置 */
|
||||
subActionConfig?: ColumnSubActionConfig;
|
||||
handler?: (row: any, index: number) => Promise<any> | any;
|
||||
before?: (row: any, index: number) => Promise<void> | void;
|
||||
after?: (row: any, index: number) => Promise<void> | void;
|
||||
action?: (data: { data: any; index: number }) => Promise<void> | void;
|
||||
action?: (data: {
|
||||
data: any;
|
||||
index: number;
|
||||
}) => Promise<{ ret: number; msg?: string } | void> | { ret: number; msg?: string } | void;
|
||||
cancel?: (data: { index: number }) => Promise<void> | void;
|
||||
}
|
||||
|
||||
|
||||
@ -7,9 +7,13 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import ActionButton from '../src/ActionButton.vue';
|
||||
import { disabled, display, formatActionText } from '../src/actionHelpers';
|
||||
import ActionsColumn from '../src/ActionsColumn.vue';
|
||||
import ComponentColumn from '../src/ComponentColumn.vue';
|
||||
import { resolveComponentListeners, resolveComponentProps } from '../src/componentHelpers';
|
||||
import ExpandColumn from '../src/ExpandColumn.vue';
|
||||
import { applyInlineEditChange } from '../src/formHelpers';
|
||||
import PopoverColumn from '../src/PopoverColumn.vue';
|
||||
import { ColumnActionConfig } from '../src/schema';
|
||||
import TextColumn from '../src/TextColumn.vue';
|
||||
@ -96,7 +100,7 @@ describe('TextColumn.vue', () => {
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.mform-stub').exists()).toBe(true);
|
||||
wrapper.vm.formChangeHandler({}, { changeRecords: [{ propPath: 'name', value: 'new' }] });
|
||||
applyInlineEditChange(editState[0], { changeRecords: [{ propPath: 'name', value: 'new' }] });
|
||||
expect(editState[0].name).toBe('new');
|
||||
});
|
||||
|
||||
@ -108,6 +112,31 @@ describe('TextColumn.vue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionHelpers', () => {
|
||||
test('display / disabled / formatActionText 辅助函数', () => {
|
||||
expect(display(() => false, {})).toBe(false);
|
||||
expect(display(true, {})).toBe(true);
|
||||
expect(display(undefined, {})).toBe(true);
|
||||
expect(disabled(() => true, {})).toBe(true);
|
||||
expect(disabled(false, {})).toBe(false);
|
||||
expect(formatActionText((row: any) => row.id, { id: 2 })).toBe(2);
|
||||
expect(formatActionText('静态', {})).toBe('静态');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActionButton.vue', () => {
|
||||
test('渲染操作按钮', () => {
|
||||
const wrapper = mount(ActionButton, {
|
||||
props: {
|
||||
action: { text: '操作' },
|
||||
row: { id: 1 },
|
||||
index: 0,
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain('操作');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActionsColumn.vue', () => {
|
||||
const baseProps = () => ({
|
||||
columns: [],
|
||||
@ -134,17 +163,6 @@ describe('ActionsColumn.vue', () => {
|
||||
editState: [] as any[],
|
||||
});
|
||||
|
||||
test('display / disabled / formatter 辅助函数', () => {
|
||||
const wrapper = mount(ActionsColumn, { props: baseProps() });
|
||||
expect(wrapper.vm.display(() => false, {})).toBe(false);
|
||||
expect(wrapper.vm.display(true, {})).toBe(true);
|
||||
expect(wrapper.vm.display(undefined, {})).toBe(true);
|
||||
expect(wrapper.vm.disabled(() => true, {})).toBe(true);
|
||||
expect(wrapper.vm.disabled(false, {})).toBe(false);
|
||||
expect(wrapper.vm.formatter((row: any) => row.id, { id: 2 })).toBe(2);
|
||||
expect(wrapper.vm.formatter('静态', {})).toBe('静态');
|
||||
});
|
||||
|
||||
test('编辑 / 保存 / 取消流程', async () => {
|
||||
const props = baseProps();
|
||||
const wrapper = mount(ActionsColumn, { props });
|
||||
@ -233,18 +251,12 @@ describe('ComponentColumn.vue', () => {
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.inner').text()).toBe('Cell');
|
||||
wrapper.vm.componentListeners({ name: 'Cell' }, 3).click();
|
||||
resolveComponentListeners(wrapper.props('config'), { name: 'Cell' }, 3).click();
|
||||
expect(onClick).toHaveBeenCalledWith(3);
|
||||
|
||||
const wrapper2 = mount(ComponentColumn, {
|
||||
props: {
|
||||
config: { component: innerComp, props: { label: '静态' }, listeners: {} },
|
||||
row: {},
|
||||
index: 0,
|
||||
},
|
||||
});
|
||||
expect(wrapper2.vm.componentProps({}, 0)).toEqual({ label: '静态' });
|
||||
expect(wrapper2.vm.componentListeners({}, 0)).toEqual({});
|
||||
const staticConfig = { component: innerComp, props: { label: '静态' }, listeners: {} };
|
||||
expect(resolveComponentProps(staticConfig, {}, 0)).toEqual({ label: '静态' });
|
||||
expect(resolveComponentListeners(staticConfig, {}, 0)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user