mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-10-28 05:20:43 +00:00
feat(form): 支持 table/group-list 行操作自定义按钮
新增 actionButtons 配置,渲染在内置按钮之后,支持 text/props/icon/display/disabled/handler, 回调可获得当前行数据、列表下标、整体数据等上下文。
This commit is contained in:
parent
7e8cb1f9db
commit
d01bc45b17
@ -840,6 +840,39 @@ export interface AddButtonConfig {
|
||||
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
|
||||
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
|
||||
export interface GroupListHeaderConfig {
|
||||
@ -880,6 +913,8 @@ export interface TableGroupListCommonConfig extends FormItem {
|
||||
moveDownButtonConfig?: ActionButtonConfig;
|
||||
/** 移动至按钮,text 默认「移动至」。仅 group-list 形态 */
|
||||
moveToButtonConfig?: ActionButtonConfig;
|
||||
/** 自定义行操作按钮,渲染在内置按钮之后 */
|
||||
actionButtons?: CustomActionButtonConfig[];
|
||||
/** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */
|
||||
header?: GroupListHeaderConfig;
|
||||
}
|
||||
|
||||
@ -101,6 +101,21 @@
|
||||
></TMagicButton>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@ -130,7 +145,7 @@ import { ArrowDown, ArrowRight, Bottom, Delete, DocumentCopy, Position, Top } fr
|
||||
import { TMagicButton, TMagicCard, TMagicIcon, TMagicInputNumber, TMagicPopover, TMagicTooltip } from '@tmagic/design';
|
||||
|
||||
import type { ContainerChangeEventData, FormState, GroupListConfig } from '../schema';
|
||||
import { actionButtonText } from '../utils/actionButton';
|
||||
import { actionButtonText, resolveCustomActionButtons } from '../utils/actionButton';
|
||||
import { appendProp, filterFunction } from '../utils/form';
|
||||
import { getGroupListRowConfig } from '../utils/tableGroupList';
|
||||
|
||||
@ -219,6 +234,22 @@ const movable = computed(() => {
|
||||
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 onAddDiffCount = () => emit('addDiffCount');
|
||||
|
||||
|
||||
@ -41,17 +41,32 @@
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue';
|
||||
import { computed, inject } from 'vue';
|
||||
import { Delete, DocumentCopy, Sort } from '@element-plus/icons-vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import { TMagicButton, TMagicTooltip } from '@tmagic/design';
|
||||
|
||||
import type { FormState, TableConfig } from '@form/schema';
|
||||
import { actionButtonText } from '@form/utils/actionButton';
|
||||
import { actionButtonText, resolveCustomActionButtons } from '@form/utils/actionButton';
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
@ -71,6 +86,22 @@ const props = defineProps<{
|
||||
|
||||
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) => {
|
||||
if (props.disabled) return;
|
||||
emit('change', props.model[props.name].toSpliced(index, 1));
|
||||
|
||||
@ -16,7 +16,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ActionButtonConfig } from '@tmagic/form-schema';
|
||||
import type {
|
||||
ActionButtonConfig,
|
||||
CustomActionButtonConfig,
|
||||
CustomActionButtonContext,
|
||||
FormState,
|
||||
} from '@tmagic/form-schema';
|
||||
|
||||
/** table / group-list 行操作的内置文案。空 text 与未配置一样回退到这里,与 addButtonConfig 一致 */
|
||||
export const DEFAULT_ACTION_BUTTON_TEXT = {
|
||||
@ -33,3 +38,30 @@ 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 { nextTick, reactive } from 'vue';
|
||||
import { Sort } from '@element-plus/icons-vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
@ -166,4 +167,44 @@ describe('ActionsColumn', () => {
|
||||
{ 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('详情');
|
||||
});
|
||||
});
|
||||
|
||||
@ -719,6 +719,33 @@ describe('GroupList container', () => {
|
||||
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(
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { actionButtonText, DEFAULT_ACTION_BUTTON_TEXT } from '@form/utils/actionButton';
|
||||
import { actionButtonText, DEFAULT_ACTION_BUTTON_TEXT, resolveCustomActionButtons } from '@form/utils/actionButton';
|
||||
|
||||
describe('actionButtonText', () => {
|
||||
test('未配置时返回内置文案', () => {
|
||||
@ -19,3 +19,43 @@ describe('actionButtonText', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user