feat(editor): 统一列表字段添加按钮样式并支持新增后自动滚动

CodeSelect、EventSelect、DisplayConds 使用吸底全宽添加按钮;
group-list 新增后滚到最后一项,避开吸顶标题与吸底按钮。
This commit is contained in:
roymondchen 2026-09-01 12:02:09 +08:00
parent c34ed6aafc
commit 02eeb2e87f
36 changed files with 1526 additions and 769 deletions

View File

@ -15,7 +15,13 @@
<script lang="ts" setup>
import { computed, useTemplateRef } from 'vue';
import { type ContainerChangeEventData, type FormItemConfig, type FormValue, MForm } from '@tmagic/form';
import {
type ContainerChangeEventData,
defineFormItem,
type FormItemConfig,
type FormValue,
MForm,
} from '@tmagic/form';
import type { CodeParamStatement } from '@editor/type';
import { error } from '@editor/utils';
@ -41,13 +47,13 @@ const emit = defineEmits(['change']);
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
const getFormConfig = (items: FormItemConfig[] = []) => [
{
defineFormItem({
type: 'fieldset',
items,
legend: '参数',
labelWidth: '120px',
labelPosition: 'top',
name: props.name,
},
}),
];
const codeParamsConfig = computed(() =>

View File

@ -1,31 +1,25 @@
<template>
<div class="m-fields-code-select" :class="config.className">
<TMagicCard :flat="config.flat">
<MContainer
:config="codeConfig"
:size="size"
class="code-select-content"
:prop="prop"
:disabled="disabled"
:is-compare="isCompareMode"
:last-values="lastValues?.[name]"
:model="model[name]"
@change="changeHandler"
>
</MContainer>
<TMagicButton class="create-button fullWidth" :icon="Plus" :size="size" :disabled="disabled" @click="newHandler()"
>添加{{ config.text }}</TMagicButton
>
</TMagicCard>
<MContainer
:config="codeConfig"
:size="size"
class="code-select-content"
:prop="prop"
:disabled="disabled"
:is-compare="isCompareMode"
:last-values="lastValues?.[name]"
:model="model[name]"
:label-position="config.labelPosition"
:label-width="config.labelWidth"
@change="changeHandler"
>
</MContainer>
</div>
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import { HookCodeType } from '@tmagic/core';
import { TMagicButton, TMagicCard } from '@tmagic/design';
import type { CodeSelectConfig, ContainerChangeEventData, FieldProps } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
@ -55,17 +49,7 @@ const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
* 仅当存在历史值时才启用对比避免 lastValues 缺失时退化为全部新增的空对比
*/
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
const newHandler = () => {
const defaultCode = {
codeType: HookCodeType.CODE,
codeId: '',
};
const name = props.config.name || '';
const hookData = props.model[name]?.hookData || [];
emit('change', defaultCode, {
modifyKey: `hookData.${hookData.length}`,
});
};
const codeConfig = computed(() => createCodeSelectConfig(props.config));
watch(

View File

@ -1,15 +1,17 @@
<template>
<MGroupList
style="width: 100%"
:config="config"
:name="name"
:disabled="disabled"
:model="model"
:last-values="lastValues"
:prop="prop"
:size="size"
@change="changeHandler"
></MGroupList>
<div class="m-fields-display-conds">
<MGroupList
:config="config"
:name="name"
:disabled="disabled"
:model="model"
:last-values="lastValues"
:is-compare="isCompareMode"
:prop="prop"
:size="size"
@change="changeHandler"
></MGroupList>
</div>
</template>
<script lang="ts" setup>
@ -32,7 +34,7 @@ defineOptions({
});
const emit = defineEmits<{
change: [value: DisplayCond[], eventData?: ContainerChangeEventData];
change: [value: DisplayCond | DisplayCond[], eventData?: ContainerChangeEventData];
}>();
const props = withDefaults(defineProps<FieldProps<DisplayCondsConfig>>(), {
@ -45,11 +47,9 @@ const parentFields = computed(() => filterFunction<string[]>(mForm, props.config
const config = computed(() => createDisplayCondsConfig(props.config, props.name, parentFields.value));
const changeHandler = (v: DisplayCond[], eventData?: ContainerChangeEventData) => {
if (!Array.isArray(props.model[props.name])) {
props.model[props.name] = [];
}
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
const changeHandler = (v: DisplayCond[], eventData?: ContainerChangeEventData) => {
emit('change', v, eventData);
};
</script>

View File

@ -15,82 +15,47 @@
<div v-else class="fullWidth event-select-container">
<div class="event-select-header">
<div class="event-select-title">事件配置</div>
<TMagicButton
v-if="!isCompareMode && displayList.length > 0"
class="create-button"
text
type="primary"
:icon="Plus"
:size="size"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton
>
</div>
<MPanel
v-for="entry in displayList"
:key="entry.index"
<MGroupList
:config="eventConfig"
:name="name"
:disabled="disabled"
:size="size"
:prop="`${prop}.${entry.index}`"
:config="actionsConfig"
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:model="model"
:last-values="lastValues"
:is-compare="isCompareMode"
:hide-expand="false"
:label-width="config.labelWidth || '100px'"
:prop="prop"
:size="size"
@change="onChangeHandler"
>
<template #header>
<template #title="{ model: itemModel, lastValues: itemLastValues, prop: itemProp }">
<div class="event-item-header">
<MFormContainer
class="fullWidth"
:config="eventNameConfig"
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:model="itemModel"
:last-values="itemLastValues"
:is-compare="isCompareMode"
:disabled="disabled"
:size="size"
:prop="`${prop}.${entry.index}`"
@change="eventNameChangeHandler"
:prop="itemProp"
@change="onChangeHandler"
></MFormContainer>
<TMagicButton
class="event-item-delete-button"
v-if="!isCompareMode"
link
:icon="Delete"
:disabled="disabled"
:size="size"
@click="removeEvent(Number(entry.index))"
></TMagicButton>
</div>
</template>
</MPanel>
<TMagicButton
v-if="!isCompareMode"
class="create-button fullWidth"
:icon="Plus"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton
>
</MGroupList>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { Delete } from '@element-plus/icons-vue';
import { Plus } from '@element-plus/icons-vue';
import { TMagicButton } from '@tmagic/design';
import type { ContainerChangeEventData, EventSelectConfig, FieldProps } from '@tmagic/form';
import { MContainer as MFormContainer, MPanel, MTable } from '@tmagic/form';
import { MContainer as MFormContainer, MGroupList, MTable } from '@tmagic/form';
import {
createActionsConfig,
createEventNameConfig,
createEventSelectConfig,
createLegacyTableConfig,
isLegacyEventValue,
} from '@editor/fields/configs/eventSelect';
@ -105,81 +70,25 @@ const emit = defineEmits<{
change: [v: any, eventData?: ContainerChangeEventData];
}>();
//
const eventNameConfig = computed(() => createEventNameConfig(props.config));
//
const tableConfig = computed(() => createLegacyTableConfig(props.config));
//
const actionsConfig = computed(() => createActionsConfig(props.config));
const eventNameConfig = computed(() => createEventNameConfig(props.config));
const eventConfig = computed(() => createEventSelectConfig(props.config, props.name));
//
const isOldVersion = computed(() => isLegacyEventValue(props.model[props.name]));
/**
* 对比模式判定
*
* event-select 内部由事件列表 + 嵌套子表单组成属于复合字段父级 `MFormContainer` 已将其
* 归入自接管对比字段 Container.vue `SELF_DIFF_FIELD_TYPES`即对比时只渲染一次本组件
* 并把当前值 `model` 与历史值 `lastValues` 一并传入由本组件把 `is-compare`/`lastValues` 透传给
* 内部的 MPanel / MFormContainer逐项事件名动作展示前后差异
* event-select 内部是事件列表 group-list父级 `MFormContainer` 已将其归入自接管对比字段
* Container.vue `SELF_DIFF_FIELD_TYPES`对比时只渲染一次本组件并把 `is-compare` /
* `lastValues` 透传给内部 MGroupList title slot 里的事件名表单
*
* 仅当存在历史值时才启用对比避免 lastValues 缺失时退化为全部新增的空对比
*/
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
/**
* 待渲染的事件卡片列表
*
* - 非对比模式直接映射当前事件列表`lastCardItem` 为空
* - 对比模式按索引对齐当前值与历史值取两者长度的最大值使得新增仅当前有
* 删除仅历史有的事件都能被渲染出来缺失的一侧用空对象兜底从而让子级正确高亮差异
*/
const displayList = computed<{ cardItem: any; lastCardItem: any; index: number }[]>(() => {
const current = props.model[props.name] || [];
if (!isCompareMode.value) {
return current.map((cardItem: any, index: number) => ({ cardItem, lastCardItem: undefined, index }));
}
const last = props.lastValues?.[props.name] || [];
const length = Math.max(current.length, last.length);
return Array.from({ length }, (_, index) => ({
cardItem: current[index] ?? {},
lastCardItem: last[index] ?? {},
index,
}));
});
//
const addEvent = () => {
const defaultEvent = {
name: '',
actions: [],
};
if (!props.model[props.name]) {
props.model[props.name] = [];
}
emit('change', defaultEvent, {
modifyKey: props.model[props.name].length,
});
};
//
const removeEvent = (index: number) => {
if (!props.name) return;
props.model[props.name].splice(index, 1);
emit('change', props.model[props.name]);
};
const eventNameChangeHandler = (v: any, eventData: ContainerChangeEventData) => {
emit('change', props.model[props.name], eventData);
};
const onChangeHandler = (v: any, eventData: ContainerChangeEventData) =>
const onChangeHandler = (_v: any, eventData?: ContainerChangeEventData) =>
emit('change', props.model[props.name], eventData);
</script>

View File

@ -24,6 +24,8 @@ import type { CodeSelectConfig, FormValue, GroupListConfig } from '@tmagic/form/
import codeBlockService from '@editor/services/codeBlock';
import dataSourceService from '@editor/services/dataSource';
import { stickyAddButton } from './stickyAddButton';
/**
* `fields/CodeSelect.vue`
*
@ -35,7 +37,11 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
name: 'hookData',
enableToggleMode: false,
expandAll: true,
addable: () => false,
defaultAdd: () => ({
codeType: HookCodeType.CODE,
codeId: '',
}),
...stickyAddButton(`添加${config.text || ''}`),
title: (_mForm: any, { model, index }: any) => {
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
if (Array.isArray(model.codeId)) {
@ -64,7 +70,6 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
text: '代码类型',
type: 'select',
name: 'codeType',
labelPosition: 'right',
rules: [{ typeMatch: true, trigger: 'change' }],
options: [
{ value: HookCodeType.CODE, text: '代码块' },

View File

@ -20,18 +20,21 @@ import type { DisplayCondsConfig, FormState, GroupListConfig } from '@tmagic/for
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
import dataSourceService from '@editor/services/dataSource';
import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils';
import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils/data-source';
import { stickyAddButton } from './stickyAddButton';
/**
* `fields/DisplayConds.vue`
*
*
* `cond` groupList
*
*
* `parentFields` `filterFunction(mForm, config.parentFields, props)`
* cascader data-source-field-select
* `parentFields` cascader
* data-source-field-select
*/
export const createDisplayCondsConfig = (
config: DisplayCondsConfig,
config: Pick<DisplayCondsConfig, 'titlePrefix' | 'flat' | 'defaultValue' | 'rules'>,
name: string,
parentFields: string[],
): GroupListConfig => {
@ -57,69 +60,78 @@ export const createDisplayCondsConfig = (
return v;
};
const fieldItem = parentFields.length
? {
type: 'cascader',
options: () => {
const { ds, fieldNames } = resolveFieldPath(parentFields);
if (!ds) {
return [];
}
let fields = ds.fields || [];
fieldNames.forEach((key) => {
const field = fields.find((f) => f.name === key);
fields = field?.fields || [];
});
return getCascaderOptionsFromFields(fields, ['string', 'number', 'boolean', 'any']);
},
name: 'field',
value: 'key',
text: '字段',
checkStrictly: false,
onChange: fieldOnChange,
defaultValue: () => [],
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'change' },
],
}
: {
type: 'data-source-field-select',
name: 'field',
value: 'key',
text: '字段',
checkStrictly: false,
dataSourceFieldType: ['string', 'number', 'boolean', 'any'],
onChange: fieldOnChange,
defaultValue: () => [],
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'change' },
],
};
return {
type: 'groupList',
name,
titlePrefix: config.titlePrefix,
expandAll: true,
enableToggleMode: false,
defaultAdd: { cond: [] },
...stickyAddButton(`新增${config.titlePrefix || '条件组'}`),
flat: config.flat,
defaultValue: config.defaultValue ?? [],
rules: config.rules ?? [{ typeMatch: true }],
items: [
{
type: 'table',
type: 'groupList',
name: 'cond',
operateColWidth: config.operateColWidth,
titlePrefix: '条件',
expandAll: true,
enableToggleMode: false,
fixed: config.fixed,
flat: config.flat,
copyable: true,
movable: false,
flat: true,
labelWidth: 80,
...stickyAddButton('新增条件'),
items: [
parentFields.length
? {
type: 'cascader',
options: () => {
const { ds, fieldNames } = resolveFieldPath(parentFields);
if (!ds) {
return [];
}
let fields = ds.fields || [];
fieldNames.forEach((key) => {
const field = fields.find((f) => f.name === key);
fields = field?.fields || [];
});
return getCascaderOptionsFromFields(fields, ['string', 'number', 'boolean', 'any']);
},
name: 'field',
value: 'key',
label: '字段',
checkStrictly: false,
onChange: fieldOnChange,
defaultValue: () => [],
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'change' },
],
}
: {
type: 'data-source-field-select',
name: 'field',
value: 'key',
label: '字段',
checkStrictly: false,
dataSourceFieldType: ['string', 'number', 'boolean', 'any'],
onChange: fieldOnChange,
defaultValue: () => [],
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'change' },
],
},
fieldItem,
{
type: 'cond-op-select',
parentFields,
label: '条件',
width: 140,
text: '条件',
name: 'op',
rules: [
{ required: true, trigger: 'blur', message: '请选择条件' },
@ -127,49 +139,43 @@ export const createDisplayCondsConfig = (
],
},
{
label: '值',
width: 160,
items: [
{
name: 'value',
type: (_mForm: FormState | undefined, { model }: any) => {
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...(model.field || [])]);
const type = getFieldType(ds, fieldNames);
name: 'value',
text: '值',
type: (_mForm: FormState | undefined, { model }: any) => {
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...(model.field || [])]);
const type = getFieldType(ds, fieldNames);
if (type === 'number') {
return 'number';
}
if (type === 'number') {
return 'number';
}
if (type === 'boolean') {
return 'select';
}
if (type === 'boolean') {
return 'select';
}
if (type === 'null') {
return 'display';
}
if (type === 'null') {
return 'display';
}
return 'text';
},
options: [
{ text: 'true', value: true },
{ text: 'false', value: false },
],
display: (_mForm: FormState | undefined, { model }: any) =>
!['between', 'not_between'].includes(model.op),
displayText: (_mForm: FormState | undefined, { model }: any) => {
if (model.value === null) {
return 'null';
}
return model.value;
},
},
{
name: 'range',
type: 'number-range',
display: (_mForm: FormState | undefined, { model }: any) =>
['between', 'not_between'].includes(model.op),
},
return 'text';
},
options: [
{ text: 'true', value: true },
{ text: 'false', value: false },
],
display: (_mForm: FormState | undefined, { model }: any) => !['between', 'not_between'].includes(model.op),
displayText: (_mForm: FormState | undefined, { model }: any) => {
if (model.value === null) {
return 'null';
}
return model.value;
},
},
{
name: 'range',
text: '值',
type: 'number-range',
display: (_mForm: FormState | undefined, { model }: any) => ['between', 'not_between'].includes(model.op),
},
],
},

View File

@ -25,7 +25,7 @@ import type {
DynamicTypeConfig,
EventSelectConfig,
FormState,
PanelConfig,
GroupListConfig,
TableConfig,
UISelectConfig,
} from '@tmagic/form/headless';
@ -44,6 +44,8 @@ import {
normalizeCompActionValue,
} from '@editor/utils';
import { stickyAddButton } from './stickyAddButton';
/**
* `fields/EventSelect.vue`
*
@ -122,7 +124,6 @@ const createActionTypeConfig = (config: EventSelectConfig) => {
name: 'actionType',
text: '联动类型',
type: 'select',
labelPosition: 'left',
defaultValue: ActionType.COMP,
options: createActionTypeOptions(),
rules: [
@ -151,7 +152,6 @@ const createTargetCompConfig = (config: EventSelectConfig) => {
name: 'to',
text: '联动组件',
type: 'ui-select',
labelPosition: 'left',
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
onChange: (_mForm, _v, { setModel }) => {
setModel('method', '');
@ -171,7 +171,6 @@ const createCompActionConfig = (config: EventSelectConfig) => {
const defaultCompActionConfig: DynamicTypeConfig = {
name: 'method',
text: '动作',
labelPosition: 'left',
type: (_mForm: FormState | undefined, { model }: any) => {
const to = editorService.getNodeById(model.to);
@ -225,29 +224,43 @@ const createDataSourceActionConfig = (config: EventSelectConfig) => {
return { ...defaultDataSourceActionConfig, ...config.dataSourceActionConfig };
};
/** 单张事件卡片里的动作组配置 */
export const createActionsConfig = (config: EventSelectConfig): PanelConfig =>
/** 单张事件里的动作组 */
export const createActionsConfig = (config: EventSelectConfig): GroupListConfig =>
defineFormItem({
type: 'panel',
labelPosition: 'left',
type: 'group-list',
name: 'actions',
expandAll: true,
enableToggleMode: false,
titlePrefix: '动作',
labelPosition: 'top',
flat: true,
...stickyAddButton('新增动作'),
items: [
{
type: 'group-list',
name: 'actions',
expandAll: true,
enableToggleMode: false,
titlePrefix: '动作',
labelPosition: 'left',
items: [
createActionTypeConfig(config),
createTargetCompConfig(config),
createCompActionConfig(config),
createCodeActionConfig(config),
createDataSourceActionConfig(config),
],
},
createActionTypeConfig(config),
createTargetCompConfig(config),
createCompActionConfig(config),
createCodeActionConfig(config),
createDataSourceActionConfig(config),
],
}) as PanelConfig;
}) as GroupListConfig;
/** 事件列表(外层 group-list。事件名走 title slotbody 只放动作组。 */
export const createEventSelectConfig = (
config: EventSelectConfig,
name: string,
options?: { includeEventName?: boolean },
): GroupListConfig =>
defineFormItem({
type: 'group-list',
name,
titlePrefix: '事件',
expandAll: true,
enableToggleMode: false,
movable: false,
defaultAdd: { name: '', actions: [] },
...stickyAddButton('添加事件'),
items: [...(options?.includeEventName ? [createEventNameConfig(config)] : []), createActionsConfig(config)],
}) as GroupListConfig;
/** 兼容旧数据格式(事件列表里没有 actions时渲染的表格配置本身不带校验规则 */
export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig =>

View File

@ -0,0 +1,31 @@
/*
* 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.
*/
/**
* group-list
*
*
*/
export const stickyAddButton = (text: string) => ({
scrollLastItemIntoView: true as const,
addButtonConfig: {
sticky: true as const,
text,
props: { type: 'primary', plain: true, text: false },
},
});

View File

@ -30,7 +30,7 @@ import { editorTypeMatchRules, validateDataSourceFieldSelect } from '@editor/uti
import { createCodeSelectConfig, normalizeCodeSelectValue } from './configs/codeSelect';
import { createDisplayCondsConfig } from './configs/displayConds';
import { createActionsConfig, createEventNameConfig, isLegacyEventValue } from './configs/eventSelect';
import { createEventSelectConfig, isLegacyEventValue } from './configs/eventSelect';
import { createStyleSetterConfig } from './StyleSetter/configs';
const getName = (config: FormItemConfig): string => `${(config as any).name ?? ''}`;
@ -59,8 +59,7 @@ const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
/**
* `display-conds`
*
* `fields/DisplayConds.vue`
* `<MGroupList :config="config" :name="name" :model="model" :prop="prop">`
* `fields/DisplayConds.vue` groupList `MGroupList`
*
* @param ctx -
* @returns group-list prop parentProp name
@ -84,26 +83,24 @@ const displayCondsNestedConfig: FieldNestedConfig = ({ config, model, prop, pare
/**
* `event-select`
*
* `fields/EventSelect.vue` `v-for`
* `<MFormContainer>` `<MPanel>``:prop` `${prop}.${index}`
* group-list `v-for`
* `fields/EventSelect.vue` group-list title slot
* `<prop>.<index>.name`
*
* @param ctx -
* @returns group-list null
*/
const eventSelectNestedConfig: FieldNestedConfig = ({ config, model, parentProp }) => {
const name = getName(config);
if (model && !Array.isArray(model[name])) {
model[name] = [];
}
const events = model?.[name];
// 旧数据格式走的是另一套表格配置,其中不含任何 rules不参与校验
if (!Array.isArray(events) || isLegacyEventValue(events)) return null;
return {
config: {
type: 'group-list',
name,
items: [createEventNameConfig(config as EventSelectConfig), createActionsConfig(config as EventSelectConfig)],
} as any as FormItemConfig,
config: createEventSelectConfig(config as EventSelectConfig, name, { includeEventName: true }),
prop: parentProp,
};
};

View File

@ -1,40 +1,3 @@
.m-fields-code-select {
width: 100%;
> .el-card.tmagic-design-card--flat {
background-color: transparent;
margin-bottom: 10px;
> .el-card__body {
padding-left: 0;
.code-select-content {
> .m-fields-group-list {
> .tmagic-design-card--flat.el-card {
padding-left: 16px;
padding-right: 16px;
margin-bottom: 10px;
border: 0 !important;
}
}
}
}
}
.el-card__body {
.tmagic-design-form-item {
margin-bottom: 16px;
}
}
.code-select-content {
> .m-fields-group-list {
> .tmagic-design-card--flat {
> .el-card__header {
padding: 16px 0;
}
}
}
}
.create-button {
&.fullWidth {
width: 100%;
margin: 0 0 16px 0;
}
}
}

View File

@ -1,3 +1,7 @@
.m-fields-display-conds {
width: 100%;
}
.m-container-text.display-conds-title {
.tmagic-design-form-item {
.el-form-item__label {

View File

@ -1,49 +1,45 @@
.m-fields-event-select {
width: 100%;
.fullWidth {
width: 100%;
box-sizing: border-box;
&.create-button {
width: 100%;
}
&.m-container-ui-event {
width: calc(100% - 32px);
}
}
.event-select-container {
padding: 0 16px;
> .el-card.tmagic-design-card--flat {
> .el-card__header {
> .m-fields-group-list
> .m-fields-group-list-item.tmagic-design-card--flat {
> .el-card__header,
> .t-card__header {
border-bottom: 1px solid #ebeef5;
}
> .el-card__body {
> .el-card__body,
> .t-card__body {
padding-bottom: 16px;
.m-fields-group-list-footer {
div {
justify-content: flex-start !important;
}
}
.el-card.tmagic-design-card--flat {
> .el-card__header {
.el-card.tmagic-design-card--flat,
.t-card.tmagic-design-card--flat {
> .el-card__header,
> .t-card__header {
padding: 16px 0;
}
}
}
}
}
.event-select-code {
margin-left: 20px;
width: auto;
}
.m-form-panel {
margin: 10px 0px;
}
.el-card.is-always-shadow {
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.12);
}
}
.m-fields-code-select-col,
.m-fields-data-source-method-select {
width: 100%;
@ -63,10 +59,6 @@
}
}
.event-select-container {
padding: 0 16px;
}
.event-select-header {
display: flex;
align-items: center;
@ -79,28 +71,13 @@
line-height: 24px;
}
}
.event-item-header {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
.event-item-title {
color: #0f1113;
font-size: 16px;
font-weight: 500;
line-height: 24px;
margin-bottom: 8px;
}
.event-item-delete-button {
color: #0f1113;
}
.el-form-item {
.el-form-item {
&.is-error {
margin-bottom: 18px;
}
}
.event-item-header {
width: 100%;
.tmagic-design-form-item {
margin-bottom: 0;
}
.el-form-item .el-form-item.is-error {
margin-bottom: 18px;
}
}

View File

@ -125,14 +125,28 @@
.m-editor-props-form-panel-form {
padding-right: 10px;
padding-left: 10px;
// 吸顶标题避开绝对定位的 tabs header
--m-group-list-header-sticky-top: var(
--el-tabs-header-height,
var(--td-comp-size-xxl, 48px)
);
> .m-container-tab {
> .tmagic-design-tabs {
> .el-tabs__content {
margin-top: var(--el-tabs-header-height);
> .el-tabs__content,
> .t-tabs__content {
margin-top: var(--el-tabs-header-height, var(--td-comp-size-xxl, 48px));
padding-top: 15px;
// 允许吸底按钮相对属性面板滚动容器定位tabs 默认 overflow:hidden 会截断 sticky
overflow: visible;
> .el-tab-pane,
> .t-tab-panel {
overflow: visible;
}
}
> .el-tabs__header.is-top {
> .el-tabs__header.is-top,
> .t-tabs__header {
position: absolute;
top: 0;
width: 100%;

View File

@ -137,7 +137,8 @@
.m-editor-props-form-panel-form {
> .m-container-tab {
> .tmagic-design-tabs {
> .el-tabs__content {
> .el-tabs__content,
> .t-tabs__content {
background-color: #fafafa;
border-radius: 8px;
padding-left: 16px;
@ -146,6 +147,16 @@
}
}
}
.m-fields-group-list-footer.is-sticky-full {
--m-group-list-footer-bg: #fafafa;
background-color: #fafafa;
}
.m-fields-group-list-item .m-fields-group-list-footer.is-sticky-full {
--m-group-list-footer-bg: #fff;
background-color: #fff;
}
}
.m-editor.m-theme--magic-admin {

View File

@ -37,6 +37,7 @@ const dataSourceFormConfig: TabConfig = {
name: 'events',
src: 'datasource',
type: 'event-select',
defaultValue: [],
},
],
},

View File

@ -151,6 +151,7 @@ export const eventTabConfig: TabPaneConfig = {
src: 'component',
labelWidth: '100px',
type: 'event-select',
defaultValue: [],
rules: [{ typeMatch: true }],
},
],
@ -163,7 +164,6 @@ export const advancedTabConfig: TabPaneConfig = {
name: NODE_DISABLE_CODE_BLOCK_KEY,
text: '禁用代码块',
type: 'switch',
labelPosition: 'left',
defaultValue: false,
extra: '开启后,配置的代码块将不会被执行',
},
@ -171,7 +171,6 @@ export const advancedTabConfig: TabPaneConfig = {
name: NODE_DISABLE_DATA_SOURCE_KEY,
text: '禁用数据源',
type: 'switch',
labelPosition: 'left',
defaultValue: false,
extra: '开启后,组件内配置的数据源相关配置将不会被编译,显隐条件将失效',
},
@ -257,8 +256,6 @@ export const displayTabConfig: TabPaneConfig<DisplayCondsConfig> = {
type: 'display-conds',
name: NODE_CONDS_KEY,
titlePrefix: '条件组',
fixed: 'right',
operateColWidth: 112,
defaultValue: [],
rules: [{ typeMatch: true }],
},

View File

@ -15,6 +15,7 @@ let lastConfig: any;
let lastProps: any;
vi.mock('@tmagic/form', () => ({
defineFormItem: (cfg: any) => cfg,
MForm: defineComponent({
name: 'MFormStub',
props: ['config', 'initValues', 'disabled', 'size', 'watchProps', 'lastValues', 'isCompare'],

View File

@ -34,7 +34,10 @@ vi.mock('@tmagic/form', async (importOriginal) => {
props: ['config', 'size', 'prop', 'disabled', 'lastValues', 'isCompare', 'model'],
emits: ['change'],
setup() {
return () => h('div', { class: 'fake-container' });
return () =>
h('div', { class: 'fake-container' }, [
h('div', { class: 'm-fields-group-list' }, [h('div', { class: 'group-item' })]),
]);
},
}),
};
@ -47,12 +50,6 @@ vi.mock('@tmagic/design', () => ({
return () => h('div', { class: 'fake-card' }, slots.default?.());
},
}),
TMagicButton: defineComponent({
name: 'TMagicButton',
setup(_p, { slots }) {
return () => h('button', { class: 'fake-button' }, slots.default?.());
},
}),
}));
const baseProps = (extra: any = {}) => ({
@ -121,6 +118,9 @@ describe('CodeSelect', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
expect(config.scrollLastItemIntoView).toBe(true);
expect(config.addButtonConfig.sticky).toBe(true);
expect(config.addButtonConfig.text).toBe('添加');
const codeTypeSelect = config.items[0];
expect(codeTypeSelect.name).toBe('codeType');
const setModel = vi.fn();

View File

@ -27,8 +27,8 @@ const { fieldTypeMock } = vi.hoisted(() => ({
}),
}));
vi.mock('@editor/utils', async () => {
const actual = await vi.importActual<any>('@editor/utils');
vi.mock('@editor/utils/data-source', async () => {
const actual = await vi.importActual<any>('@editor/utils/data-source');
return {
...actual,
getCascaderOptionsFromFields: vi.fn(() => [{ label: 'f1', value: 'f1' }]),
@ -44,15 +44,19 @@ vi.mock('@tmagic/form', async () => {
filterFunction: vi.fn((_m: any, v: any) => (typeof v === 'function' ? v() : v)),
MGroupList: defineComponent({
name: 'MGroupList',
props: ['config', 'name', 'disabled', 'model', 'lastValues', 'prop', 'size'],
props: ['config', 'name', 'disabled', 'model', 'lastValues', 'isCompare', 'prop', 'size'],
emits: ['change'],
setup(props, { emit }) {
capturedConfig = props.config;
return () =>
h('div', {
class: 'fake-group-list',
onClick: () => emit('change', [{ field: ['fa'], op: 'eq', value: 'a' }]),
});
h(
'div',
{
class: 'fake-group-list m-fields-group-list',
onClick: () => emit('change', [{ field: ['fa'], op: 'eq', value: 'a' }]),
},
[h('div', { class: 'group-item m-fields-group-list-item' })],
);
},
}),
};
@ -65,14 +69,34 @@ beforeEach(() => {
});
describe('DisplayConds', () => {
test('change 事件初始化数组', async () => {
const model: any = {};
test('change 事件向上抛出', async () => {
const wrapper = mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: [] }, model, name: 'conds' } as any,
props: { config: { titlePrefix: 't', parentFields: [] }, model: { conds: [] }, name: 'conds' } as any,
});
await wrapper.find('.fake-group-list').trigger('click');
expect(model.conds).toEqual([]);
expect(wrapper.emitted('change')).toBeTruthy();
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual([{ field: ['fa'], op: 'eq', value: 'a' }]);
});
test('外层与内层 cond 都使用 groupList外层吸底新增由 group-list 接管', () => {
const wrapper = mount(DisplayConds, {
props: { config: { titlePrefix: '条件组', parentFields: [] }, model: { conds: [] }, name: 'conds' } as any,
});
expect(capturedConfig.type).toBe('groupList');
expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
expect(capturedConfig.items[0].type).toBe('groupList');
expect(capturedConfig.items[0].name).toBe('cond');
expect(capturedConfig.items[0].titlePrefix).toBe('条件');
expect(capturedConfig.items[0].copyable).toBe(true);
expect(capturedConfig.items[0].movable).toBe(false);
expect(capturedConfig.items[0].flat).toBe(true);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增条件');
expect(capturedConfig.items[0].items.every((item: any) => item.span === undefined)).toBe(true);
expect(wrapper.findComponent({ name: 'MGroupList' }).exists()).toBe(true);
});
test('parentFields 不为空时使用 cascader', () => {
@ -133,7 +157,7 @@ describe('DisplayConds', () => {
mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any,
});
const valueItem = capturedConfig.items[0].items[2].items[0];
const valueItem = capturedConfig.items[0].items[2];
expect(valueItem.type(undefined, { model: { field: ['numField'] } })).toBe('number');
expect(valueItem.type(undefined, { model: { field: ['boolField'] } })).toBe('select');
expect(valueItem.type(undefined, { model: { field: ['nullField'] } })).toBe('display');
@ -144,7 +168,7 @@ describe('DisplayConds', () => {
mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: [] }, model: {}, name: 'conds' } as any,
});
const valueItem = capturedConfig.items[0].items[2].items[0];
const valueItem = capturedConfig.items[0].items[2];
expect(valueItem.display(undefined, { model: { op: 'eq' } })).toBe(true);
expect(valueItem.display(undefined, { model: { op: 'between' } })).toBe(false);
expect(valueItem.displayText(undefined, { model: { value: null } })).toBe('null');
@ -155,7 +179,7 @@ describe('DisplayConds', () => {
mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: [] }, model: {}, name: 'conds' } as any,
});
const rangeItem = capturedConfig.items[0].items[2].items[1];
const rangeItem = capturedConfig.items[0].items[3];
expect(rangeItem.display(undefined, { model: { op: 'between' } })).toBe(true);
expect(rangeItem.display(undefined, { model: { op: 'eq' } })).toBe(false);
});
@ -194,4 +218,42 @@ describe('DisplayConds', () => {
const item = capturedConfig.items[0].items[0];
expect(item.options()).toEqual([]);
});
test('isCompare 但无 lastValues 时不进入对比', () => {
const wrapper = mount(DisplayConds, {
props: {
config: { titlePrefix: '条件组', parentFields: [] },
model: { conds: [] },
name: 'conds',
isCompare: true,
} as any,
});
expect(wrapper.findComponent({ name: 'MGroupList' }).props('isCompare')).toBe(false);
});
test('外层 defaultAdd 为默认条件组', () => {
mount(DisplayConds, {
props: {
config: { titlePrefix: '条件组', parentFields: [] },
model: { conds: [{ cond: [] }] },
name: 'conds',
} as any,
});
expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
});
test('对比模式向内部透传 isCompare', () => {
const lastValues = { conds: [{ cond: [] }] };
const wrapper = mount(DisplayConds, {
props: {
config: { titlePrefix: '条件组', parentFields: [] },
model: { conds: [{ cond: [] }] },
name: 'conds',
isCompare: true,
lastValues,
} as any,
});
expect(wrapper.findComponent({ name: 'MGroupList' }).props('isCompare')).toBe(true);
});
});

View File

@ -48,6 +48,9 @@ vi.mock('@editor/utils/data-source', async () => {
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => []) };
});
let capturedConfig: any = null;
let capturedEventNameConfig: any = null;
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
@ -61,36 +64,45 @@ vi.mock('@tmagic/form', async (importOriginal) => {
return () => h('div', { class: 'fake-table' });
},
}),
MPanel: defineComponent({
name: 'MPanel',
props: ['model', 'config', 'prop', 'disabled', 'size', 'labelWidth', 'lastValues', 'isCompare'],
emits: ['change'],
setup(_p, { slots }) {
return () => h('div', { class: 'fake-panel' }, slots.header?.());
},
}),
MContainer: defineComponent({
name: 'MFormContainer',
props: ['model', 'config', 'prop', 'disabled', 'size'],
props: ['config', 'model', 'lastValues', 'isCompare', 'disabled', 'size', 'prop'],
emits: ['change'],
setup() {
return () => h('div', { class: 'fake-container' });
setup(props) {
capturedEventNameConfig = props.config;
return () => h('div', { class: 'fake-form-container' });
},
}),
MGroupList: defineComponent({
name: 'MGroupList',
props: ['config', 'name', 'disabled', 'model', 'lastValues', 'isCompare', 'prop', 'size'],
emits: ['change'],
setup(props, { emit, slots }) {
capturedConfig = props.config;
return () => {
const events = props.model?.[props.name] || [];
const first = events[0];
return h(
'div',
{ class: 'fake-group-list', onClick: () => emit('change', events) },
first && slots.title
? [
slots.title({
model: first,
lastValues: props.lastValues?.[props.name]?.[0],
prop: `${props.prop}.0`,
index: 0,
title: '事件 1',
}),
]
: [],
);
};
},
}),
};
});
vi.mock('@tmagic/design', () => ({
TMagicButton: defineComponent({
name: 'TMagicButton',
props: ['type', 'size', 'disabled', 'icon', 'link'],
emits: ['click'],
setup(_p, { emit, slots }) {
return () => h('button', { onClick: () => emit('click') }, slots.default?.());
},
}),
}));
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return {
@ -109,40 +121,45 @@ const baseProps = (extra: any = {}) => ({
...extra,
});
const eventNameCfg = () => capturedEventNameConfig;
const actionsCfg = () => capturedConfig.items[0];
const mountEvent = (extra: any = {}) => mount(EventSelect, { props: baseProps(extra) as any });
describe('EventSelect', () => {
test('events 为空 isOldVersion=false 显示新版按钮', () => {
test('events 为空 isOldVersion=false 渲染 group-list', () => {
const wrapper = mount(EventSelect, { props: baseProps() as any });
expect(wrapper.find('.create-button').exists()).toBe(true);
expect(wrapper.find('.event-select-container').exists()).toBe(true);
expect(wrapper.findComponent({ name: 'MGroupList' }).exists()).toBe(true);
expect(wrapper.find('.fake-table').exists()).toBe(false);
expect(capturedConfig.type).toBe('group-list');
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('添加事件');
expect(capturedConfig.defaultAdd).toEqual({ name: '', actions: [] });
expect(capturedConfig.movable).toBe(false);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作');
});
test('addEvent emit 事件并携带 modifyKey', async () => {
test('group-list change 向外 emit', async () => {
const wrapper = mount(EventSelect, { props: baseProps() as any });
await wrapper.find('.create-button').trigger('click');
const evts = wrapper.emitted('change');
expect((evts?.[0]?.[0] as any).name).toBe('');
await wrapper.findComponent({ name: 'MGroupList' }).vm.$emit('change', [], { modifyKey: 'foo' });
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual([]);
});
test('removeEvent 删除指定 index', async () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const buttons = wrapper.findAll('button');
const lastBtn = buttons[buttons.length - 1];
await lastBtn.trigger('click');
const evts = wrapper.emitted('change');
expect(evts).toBeTruthy();
test('title 里改事件名仍抛出事件列表,而不是单条', async () => {
const events = [{ name: 'click', actions: [] }];
const wrapper = mountEvent({ model: { events } });
await wrapper.findComponent({ name: 'MFormContainer' }).vm.$emit('change', { name: 'click' }, {});
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(events);
});
test('events 含 actions 字段时不算 oldVersion渲染 panel', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
test('events 含 actions 字段时不算 oldVersion渲染 group-list', () => {
const wrapper = mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
expect(wrapper.findAll('.fake-panel').length).toBe(1);
expect(wrapper.findComponent({ name: 'MGroupList' }).exists()).toBe(true);
});
test('events 不含 actions 字段时为 oldVersion渲染 table', () => {
@ -164,32 +181,11 @@ describe('EventSelect', () => {
expect(wrapper.emitted('change')).toBeTruthy();
});
test('Panel header MFormContainer change emit', async () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
await wrapper.findComponent({ name: 'MFormContainer' }).vm.$emit('change', null, { modifyKey: 'name' });
expect(wrapper.emitted('change')).toBeTruthy();
});
test('addEvent 在 model[name] 为空时初始化', async () => {
const m: any = { events: [] };
const wrapper = mount(EventSelect, { props: baseProps({ model: m }) as any });
await wrapper.find('.create-button').trigger('click');
const evts = wrapper.emitted('change');
expect(evts).toBeTruthy();
expect((evts?.[0]?.[0] as any).actions).toEqual([]);
});
test('eventNameConfig type/options src=component 返回 select', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const cfg = eventNameCfg();
expect(cfg.type(undefined, { formValue: { type: 'btn' } })).toBe('select');
const opts = cfg.options(undefined, { formValue: { type: 'btn' } });
expect(Array.isArray(opts)).toBe(true);
@ -197,12 +193,10 @@ describe('EventSelect', () => {
});
test('eventNameConfig.rules 仅校验事件名是否在可选项中', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const cfg = eventNameCfg();
const [rule] = cfg.rules;
const okCb = vi.fn();
@ -220,13 +214,11 @@ describe('EventSelect', () => {
});
test('eventNameConfig.rules 自定义 options 时跳过枚举', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
config: { type: 'event-select', src: 'component', eventNameConfig: { options: () => [{ value: 'x' }] } },
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
config: { type: 'event-select', src: 'component', eventNameConfig: { options: () => [{ value: 'x' }] } },
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const cfg = eventNameCfg();
const [rule] = cfg.rules;
const cb = vi.fn();
@ -236,12 +228,10 @@ describe('EventSelect', () => {
test('eventNameConfig type 当 page-fragment 且有 pageFragmentId 返回 cascader', () => {
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [] }] });
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const cfg = eventNameCfg();
expect(cfg.type(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } })).toBe(
'cascader',
);
@ -251,26 +241,22 @@ describe('EventSelect', () => {
test('eventNameConfig src=datasource 返回事件 + 数据变化字段', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
const wrapper = mount(EventSelect, {
props: baseProps({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const cfg = eventNameCfg();
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
expect(opts).toEqual([{ label: '数据变化', value: 'ds_change_', children: [] }]);
});
test('eventNameConfig src=datasource 无 fields 时返回原始事件', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [] });
const wrapper = mount(EventSelect, {
props: baseProps({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const cfg = eventNameCfg();
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
expect(opts).toEqual([]);
});
@ -278,14 +264,10 @@ describe('EventSelect', () => {
test('actionTypeConfig 含 组件/代码/数据源', () => {
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const groupItems = panelCfg.items[0].items;
const actionType = groupItems[0];
const actionType = actionsCfg().items[0];
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort());
});
@ -293,13 +275,10 @@ describe('EventSelect', () => {
test('actionTypeConfig disabledCodeBlock/disabledDataSource 时不包含选项', () => {
propsService.getDisabledCodeBlock.mockReturnValue(true);
propsService.getDisabledDataSource.mockReturnValue(true);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const actionType = panelCfg.items[0].items[0];
const actionType = actionsCfg().items[0];
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
expect(opts.map((o: any) => o.value)).toEqual(['comp']);
propsService.getDisabledCodeBlock.mockReturnValue(false);
@ -307,13 +286,10 @@ describe('EventSelect', () => {
});
test('targetCompConfig display/onChange', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const target = panelCfg.items[0].items[1];
const target = actionsCfg().items[1];
expect(target.display(undefined, { model: { actionType: 'comp' } })).toBe(true);
const setModel = vi.fn();
target.onChange(undefined, undefined, { setModel });
@ -322,13 +298,10 @@ describe('EventSelect', () => {
test('compActionConfig 解析 type/options', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
const compAction = actionsCfg().items[2];
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('select');
expect(Array.isArray(compAction.options(undefined, { model: { to: '1' } }))).toBe(true);
});
@ -336,13 +309,10 @@ describe('EventSelect', () => {
test('compActionConfig type cascader 当 page-fragment-container', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
const compAction = actionsCfg().items[2];
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('cascader');
const opts = compAction.options(undefined, { model: { to: '1' } });
expect(Array.isArray(opts)).toBe(true);
@ -350,26 +320,20 @@ describe('EventSelect', () => {
test('compActionConfig options 当 node 无 type 返回空数组', () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
const compAction = actionsCfg().items[2];
expect(compAction.options(undefined, { model: { to: 'unknown' } })).toEqual([]);
});
test('compActionConfig.rules 仅校验动作名是否在可选项中', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
const compAction = actionsCfg().items[2];
const [rule] = compAction.rules;
const okCb = vi.fn();
@ -387,18 +351,15 @@ describe('EventSelect', () => {
test('compActionConfig.rules 自定义 options 时跳过枚举', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
const wrapper = mount(EventSelect, {
props: baseProps({
config: {
type: 'event-select',
src: 'component',
compActionConfig: { options: () => [{ text: 'x', value: 'x' }] },
},
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
config: {
type: 'event-select',
src: 'component',
compActionConfig: { options: () => [{ text: 'x', value: 'x' }] },
},
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
const compAction = actionsCfg().items[2];
const [rule] = compAction.rules;
const cb = vi.fn();
@ -410,13 +371,10 @@ describe('EventSelect', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
const compAction = actionsCfg().items[2];
const [rule] = compAction.rules;
const okCb = vi.fn();
@ -430,13 +388,10 @@ describe('EventSelect', () => {
test('codeActionConfig display/notEditable', () => {
codeBlockService.getEditStatus.mockReturnValue(false);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const codeAction = panelCfg.items[0].items[3];
const codeAction = actionsCfg().items[3];
expect(codeAction.display(undefined, { model: { actionType: 'code' } })).toBe(true);
expect(codeAction.notEditable()).toBe(true);
codeBlockService.getEditStatus.mockReturnValue(true);
@ -444,13 +399,10 @@ describe('EventSelect', () => {
test('dataSourceActionConfig display/notEditable', () => {
dataSourceService.get.mockReturnValue(false);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const dsAction = panelCfg.items[0].items[4];
const dsAction = actionsCfg().items[4];
expect(dsAction.display(undefined, { model: { actionType: 'data-source' } })).toBe(true);
expect(dsAction.notEditable()).toBe(true);
});
@ -471,62 +423,25 @@ describe('EventSelect', () => {
});
describe('对比模式', () => {
test('isCompare 但无 lastValues 时不进入对比,仍显示添加按钮', () => {
test('isCompare 但无 lastValues 时不进入对比', () => {
const wrapper = mount(EventSelect, {
props: baseProps({ isCompare: true, model: { events: [] } }) as any,
});
expect(wrapper.find('.create-button').exists()).toBe(true);
expect(wrapper.findComponent({ name: 'MGroupList' }).props('isCompare')).toBe(false);
});
test('对比模式隐藏「添加事件」与删除按钮', () => {
test('对比模式向内部透传 isCompare 与 lastValues', () => {
const lastValues = { events: [{ name: 'a', actions: [] }] };
const wrapper = mount(EventSelect, {
props: baseProps({
isCompare: true,
model: { events: [{ name: 'a', actions: [] }] },
lastValues: { events: [{ name: 'a', actions: [] }] },
lastValues,
}) as any,
});
expect(wrapper.find('.create-button').exists()).toBe(false);
// 对比模式 panel header 内不渲染删除按钮(仅 MFormContainer 占位)
expect(wrapper.findAll('button').length).toBe(0);
const list = wrapper.findComponent({ name: 'MGroupList' });
expect(list.props('isCompare')).toBe(true);
expect(list.props('lastValues')).toEqual(lastValues);
});
test('对比模式按索引对齐当前值与历史值,取最大长度渲染', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
isCompare: true,
model: { events: [{ name: 'a', actions: [] }] },
lastValues: {
events: [
{ name: 'a', actions: [] },
{ name: 'b', actions: [] },
],
},
}) as any,
});
// 当前 1 项 + 历史 2 项 → 取 max=2渲染 2 个 panel含被删除的事件
expect(wrapper.findAll('.fake-panel').length).toBe(2);
const panels = wrapper.findAllComponents({ name: 'MPanel' });
expect(panels[0].props('isCompare')).toBe(true);
// 缺失一侧用空对象兜底
expect(panels[1].props('model')).toEqual({});
expect(panels[1].props('lastValues')).toEqual({ name: 'b', actions: [] });
});
});
test('removeEvent 通过 panel header 删除按钮调用', async () => {
const m: any = {
events: [
{ name: 'a', actions: [] },
{ name: 'b', actions: [] },
],
};
const wrapper = mount(EventSelect, { props: baseProps({ model: m }) as any });
// 用 class 选择器直击 panel header 里的删除按钮:模板里同时存在顶部 / 底部「添加事件」按钮,
// 早期靠 `buttons[length - 1]` 取最后一个会误选到底部添加按钮,导致 events 没被删减。
const deleteBtns = wrapper.findAll('.event-item-delete-button');
expect(deleteBtns.length).toBe(m.events.length);
await deleteBtns[deleteBtns.length - 1].trigger('click');
expect(m.events.length).toBe(1);
});
});

View File

@ -304,4 +304,25 @@ describe('fillConfig 通用属性表单', () => {
),
).not.toThrow();
});
test('显示条件 tab 按 groupList 展开条件组与组内条件', () => {
const config = fillConfig([]);
const values = {
type: 'text',
id: '1',
name: '',
[NODE_CONDS_KEY]: [{ cond: [{ field: [], op: '', value: '' }] }],
};
const { props } = collect(config, values);
expect(props).toEqual(
expect.arrayContaining([
`${NODE_CONDS_KEY}.0.cond.0.field`,
`${NODE_CONDS_KEY}.0.cond.0.op`,
`${NODE_CONDS_KEY}.0.cond.0.value`,
]),
);
expect(props).not.toContain(`${NODE_CONDS_KEY}.${NODE_CONDS_KEY}`);
});
});

View File

@ -140,6 +140,7 @@ describe('plugin install', () => {
expect(formOpt.fields['code-select'].component).toEqual({ name: 'CustomCodeSelect' });
expect(formOpt.fields['code-select'].nested).toEqual(expect.any(Function));
expect(formOpt.fields['code-select'].typeMatch).toEqual(expect.any(Function));
expect(formOpt.fields['event-select'].effect).toEqual(expect.any(Function));
expect(formOpt.fields['my-field'].component).toEqual({ name: 'MyField' });
expect(formOpt.fields['ui-select'].component).toBeDefined();
});

View File

@ -5,7 +5,7 @@
*/
import { describe, expect, test, vi } from 'vitest';
import { NODE_CONDS_RESULT_KEY } from '@tmagic/core';
import { NODE_CONDS_KEY, NODE_CONDS_RESULT_KEY } from '@tmagic/core';
import {
advancedTabConfig,
@ -27,9 +27,13 @@ vi.mock('@tmagic/design', () => ({
},
}));
vi.mock('@tmagic/form', () => ({
validateForm: vi.fn(),
}));
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tmagic/form')>();
return {
...actual,
validateForm: vi.fn(),
};
});
describe('props 选项常量', () => {
test('eqOptions / arrayOptions / numberOptions / booleanOptions 内容稳定', () => {
@ -63,6 +67,7 @@ describe('props 选项常量', () => {
expect(styleTabConfig.title).toBe('样式');
expect(eventTabConfig.title).toBe('事件');
expect(displayTabConfig.title).toBe('显示条件');
expect((eventTabConfig.items as any[])[0].defaultValue).toEqual([]);
});
test('styleTabConfig / eventTabConfig / advancedTabConfig 不使用 lazy 懒加载', () => {
@ -128,6 +133,12 @@ describe('fillConfig', () => {
expect(displayTabConfig.display!({} as any, { model: { type: 'text' } } as any)).toBe(true);
});
test('displayTabConfig 条件组使用 display-conds', () => {
const conds = (displayTabConfig.items as any[]).find((i) => i.name === NODE_CONDS_KEY);
expect(conds.type).toBe('display-conds');
expect(conds.titlePrefix).toBe('条件组');
});
test('displayTabConfig select 项 extra 文案随 NODE_CONDS_RESULT_KEY 变化', () => {
const selectItem = (displayTabConfig.items as any[]).find((i) => i.type === 'select');
expect(typeof selectItem.extra).toBe('function');

View File

@ -799,6 +799,16 @@ export interface PanelConfig<T = never> extends FormItem, ContainerCommonConfig<
}
// #endregion PanelConfig
// #region AddButtonConfig
/** table / group-list 新增按钮的文案与形态 */
export interface AddButtonConfig {
props?: Record<string, any>;
text?: string;
/** 吸底全宽主按钮 */
sticky?: boolean;
}
// #endregion AddButtonConfig
// #region TableGroupListCommonConfig
export interface TableGroupListCommonConfig extends FormItem {
type: 'table' | 'groupList' | 'group-list';
@ -812,6 +822,9 @@ export interface TableGroupListCommonConfig extends FormItem {
defaultAdd?: ((mForm: FormState | undefined, data: any) => any) | Record<string, any>;
/** table 新增行时前置回调 */
beforeAddRow?: (mForm: FormState | undefined, data: any) => boolean | Promise<boolean>;
/** 新增后滚动到最后一项group-list 形态,默认关闭) */
scrollLastItemIntoView?: boolean;
addButtonConfig?: AddButtonConfig;
}
// #endregion TableGroupListCommonConfig
@ -826,10 +839,8 @@ export interface TableColumnConfig<T = never> extends FormItem {
itemsFunction?: (row: any) => FormConfig<T>;
titleTip?: FilterFunction<string>;
type?: string;
addButtonConfig?: {
props?: Record<string, any>;
text?: string;
};
/** 列内嵌套列表的新增按钮不支持吸底 */
addButtonConfig?: Omit<AddButtonConfig, 'sticky'>;
}
// #endregion TableColumnConfig
@ -904,14 +915,10 @@ export interface GroupListConfig<T = never> extends TableGroupListCommonConfig {
*
*/
defaultExpandQuantity?: number;
delete?: (model: any, index: number | string | symbol, values: any) => boolean | boolean;
copyable?: FilterFunction<boolean>;
movable?: (
mForm: FormState | undefined,
index: number | string | symbol,
model: any,
groupModel: any,
) => boolean | boolean;
delete?: boolean | ((model: any, index: number | string | symbol, values: any) => boolean);
copyable?: boolean | FilterFunction<boolean>;
movable?:
boolean | ((mForm: FormState | undefined, index: number | string | symbol, model: any, groupModel: any) => boolean);
moveSpecifyLocation?: boolean;
}
// #endregion GroupListConfig

View File

@ -50,7 +50,7 @@ import {
watch,
watchEffect,
} from 'vue';
import { cloneDeep, isEqual } from 'lodash-es';
import { cloneDeep, isEqualWith } from 'lodash-es';
import { M_THEME_KEY, TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
import { setValueByKeyPath } from '@tmagic/utils';
@ -321,12 +321,23 @@ provide(FORM_DIFF_CONFIG_KEY, {
const changeRecords = shallowRef<ChangeRecord[]>([]);
/**
* 两份配置的结构是否一致函数一律视为相等
*
* 宿主如编辑器属性面板往往在每次节点更新后整份重新生成配置其中的
* `display` / `options` / `onChange` 都是新闭包深比较必然判不等若据此把 `initialized`
* false整棵表单会卸载重挂滚动位置展开态输入焦点全部丢失
* 配置是响应式 prop闭包换了照样生效只有结构变化增删字段换组件类型才需要重挂
*/
const isSameConfigShape = (config: unknown, preConfig: unknown) =>
isEqualWith(config, preConfig, (a, b) => (typeof a === 'function' && typeof b === 'function' ? true : undefined));
watch(
[() => props.config, () => props.initValues],
([config], [preConfig]) => {
changeRecords.value = [];
if (!isEqual(toRaw(config), toRaw(preConfig))) {
if (!isSameConfigShape(toRaw(config), toRaw(preConfig))) {
initialized.value = false;
}

View File

@ -1,33 +1,41 @@
<template>
<div class="m-fields-group-list">
<div v-if="config.extra" v-html="config.extra" style="color: rgba(0, 0, 0, 0.45)"></div>
<div v-if="!model[name] || !model[name].length" class="el-table__empty-block">
<div v-if="!displayItems.length" class="el-table__empty-block">
<span class="el-table__empty-text t-table__empty">暂无{{ config.titlePrefix || '' }}数据</span>
</div>
<MFieldsGroupListItem
v-else
v-for="(item, index) in model[name]"
:key="index"
:model="item"
:lastValues="getLastValues(lastValues?.[name], Number(index))"
v-for="entry in displayItems"
:key="entry.index"
:model="entry.item"
:lastValues="entry.last"
:is-compare="isCompare"
:config="config"
:prop="prop"
:index="Number(index)"
:index="entry.index"
:label-width="labelWidth"
:label-position="labelPosition"
:size="size"
:disabled="disabled"
:group-model="model[name]"
:group-model="currentList"
@remove-item="removeHandler"
@copy-item="copyHandler"
@swap-item="swapHandler"
@change="changeHandler"
@addDiffCount="onAddDiffCount()"
></MFieldsGroupListItem>
>
<template #title="slotProps" v-if="$slots.title">
<slot name="title" v-bind="slotProps"></slot>
</template>
</MFieldsGroupListItem>
<div class="m-fields-group-list-footer" v-if="!isCompare">
<div
class="m-fields-group-list-footer"
:class="{ 'is-sticky-full': Boolean(config.addButtonConfig?.sticky) }"
v-if="!isCompare && ($slots['toggle-button'] || $slots['add-button'])"
>
<slot name="toggle-button"></slot>
<div style="display: flex; justify-content: flex-end; flex: 1">
<slot name="add-button"></slot>
@ -37,6 +45,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { ContainerChangeEventData, GroupListConfig } from '../schema';
@ -93,5 +102,24 @@ const swapHandler = (idx1: number, idx2: number) => {
const onAddDiffCount = () => emit('addDiffCount');
const getLastValues = (item: any, index: number) => item?.[index] || {};
const asList = (value: unknown): any[] => (Array.isArray(value) ? value : []);
const currentList = computed(() => asList(props.model[props.name]));
/** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */
const displayItems = computed(() => {
const current = currentList.value;
if (!props.isCompare) {
return current.map((item, index) => ({ item: item ?? {}, last: {}, index }));
}
const last = asList(props.lastValues?.[props.name]);
return Array.from({ length: Math.max(current.length, last.length) }, (_, index) => ({
item: current[index] ?? {},
last: last[index] ?? {},
index,
}));
});
</script>

View File

@ -6,89 +6,95 @@
<TMagicIcon><ArrowDown v-if="expand" /><ArrowRight v-else /></TMagicIcon>
</TMagicButton>
<span v-html="title"></span>
<TMagicTooltip :content="`删除 ${title}`">
<div class="m-fields-group-list-item-title">
<slot name="title" :model="model" :index="index" :last-values="lastValues" :prop="rowProp" :title="title">
<span v-html="title"></span>
</slot>
</div>
<div class="m-fields-group-list-item-actions">
<TMagicButton
v-if="!isCompare"
v-show="showDelete"
size="default"
link
class="delete-button"
:icon="Delete"
:disabled="disabled"
@click="removeHandler"
></TMagicButton>
</TMagicTooltip>
<TMagicButton
v-if="copyable && !isCompare"
link
size="default"
type="primary"
:icon="DocumentCopy"
:disabled="disabled"
@click="copyHandler"
>复制</TMagicButton
>
<template v-if="movable && !isCompare">
<TMagicButton
v-show="index !== 0"
v-if="copyable && !isCompare"
link
size="default"
type="primary"
:icon="DocumentCopy"
:disabled="disabled"
:icon="Top"
@click="changeOrder(-1)"
>上移</TMagicButton
@click="copyHandler"
>复制</TMagicButton
>
<TMagicButton
v-show="index !== length - 1"
link
size="default"
:disabled="disabled"
:icon="Bottom"
@click="changeOrder(1)"
>下移</TMagicButton
>
</template>
<TMagicPopover
v-if="config.moveSpecifyLocation && !isCompare"
trigger="click"
placement="top"
width="200"
:visible="moveSpecifyLocationVisible"
>
<template #reference>
<template v-if="movable && !isCompare">
<TMagicButton
v-show="index !== 0"
link
size="small"
type="primary"
:icon="Position"
size="default"
:disabled="disabled"
@click="moveSpecifyLocationVisible = true"
>移动至</TMagicButton
:icon="Top"
@click="changeOrder(-1)"
>上移</TMagicButton
>
<TMagicButton
v-show="index !== length - 1"
link
size="default"
:disabled="disabled"
:icon="Bottom"
@click="changeOrder(1)"
>下移</TMagicButton
>
</template>
<div>
<div>
<TMagicInputNumber
style="margin: 0 5px"
v-model="moveSpecifyLocationIndex"
size="small"
:min="1"
:disabled="disabled"
></TMagicInputNumber
>
</div>
<div style="text-align: right; margin-top: 20px">
<TMagicButton size="small" text @click="moveSpecifyLocationVisible = false">取消</TMagicButton>
<TMagicButton size="small" type="primary" @click="moveSpecifyLocationHandler">确认</TMagicButton>
</div>
</div>
</TMagicPopover>
<span v-if="itemExtra" v-html="itemExtra" class="m-form-tip"></span>
<TMagicPopover
v-if="config.moveSpecifyLocation && !isCompare"
trigger="click"
placement="top"
width="200"
:visible="moveSpecifyLocationVisible"
>
<template #reference>
<TMagicButton
link
size="small"
type="primary"
:icon="Position"
:disabled="disabled"
@click="moveSpecifyLocationVisible = true"
>移动至</TMagicButton
>
</template>
<div>
<div>
<TMagicInputNumber
style="margin: 0 5px"
v-model="moveSpecifyLocationIndex"
size="small"
:min="1"
:disabled="disabled"
></TMagicInputNumber
>
</div>
<div style="text-align: right; margin-top: 20px">
<TMagicButton size="small" text @click="moveSpecifyLocationVisible = false">取消</TMagicButton>
<TMagicButton size="small" type="primary" @click="moveSpecifyLocationHandler">确认</TMagicButton>
</div>
</div>
</TMagicPopover>
<TMagicTooltip :content="`删除 ${title}`">
<TMagicButton
v-if="!isCompare"
v-show="showDelete"
size="default"
link
class="delete-button"
:icon="Delete"
:disabled="disabled"
@click="removeHandler"
></TMagicButton>
</TMagicTooltip>
<span v-if="itemExtra" v-html="itemExtra" class="m-form-tip"></span>
</div>
</div>
</template>

View File

@ -21,13 +21,8 @@
@addDiffCount="onAddDiffCount"
@add="onAdd"
>
<template #toggle-button>
<TMagicButton
v-if="config.enableToggleMode || enableToggleMode"
:icon="Grid"
size="small"
@click="toggleDisplayMode"
>
<template #toggle-button v-if="config.enableToggleMode || enableToggleMode">
<TMagicButton :icon="Grid" size="small" @click="toggleDisplayMode">
{{ displayMode === 'table' ? '展开配置' : '切换为表格' }}
</TMagicButton>
</template>
@ -36,12 +31,10 @@
<TMagicButton
:class="displayMode === 'table' ? 'm-form-table-add-button' : ''"
:size="addButtonSize"
:plain="displayMode === 'table'"
:icon="Plus"
text
:disabled="disabled"
v-bind="currentConfig.addButtonConfig?.props || { type: 'primary' }"
@click="newHandler"
v-bind="addButtonProps"
@click="handleAdd"
>
{{
currentConfig.addButtonConfig?.text ||
@ -51,6 +44,10 @@
}}
</TMagicButton>
</template>
<template #title="slotProps" v-if="$slots.title">
<slot name="title" v-bind="slotProps"></slot>
</template>
</component>
</template>
@ -67,6 +64,7 @@ import MFormGroupList from '../GroupList.vue';
import MFormTable from '../table/Table.vue';
import { useAdd } from './useAdd';
import { useScrollLastItemIntoView } from './useScrollLastItemIntoView';
defineOptions({
name: 'MFormTableGroupList',
@ -107,25 +105,45 @@ const currentConfig = computed<any>(() => (displayMode.value === 'table' ? table
// Table/GroupList
const addButtonSize = computed(() => {
if (currentConfig.value.addButtonConfig?.sticky) return 'default';
if (displayMode.value === 'table') return 'small';
return props.config.enableToggleMode !== false ? 'small' : 'default';
});
const addButtonProps = computed(() => {
const custom = currentConfig.value.addButtonConfig?.props;
if (custom) return { type: 'primary', ...custom };
if (displayMode.value === 'table') return { type: 'primary', plain: true };
return { type: 'primary', text: true };
});
const toggleDisplayMode = () => {
displayMode.value = displayMode.value === 'table' ? 'groupList' : 'table';
};
const tableGroupListRef = useTemplateRef<InstanceType<typeof MFormTable>>('tableGroupList');
const { scrollLastItemIntoView } = useScrollLastItemIntoView(
tableGroupListRef,
() => Boolean(currentConfig.value.scrollLastItemIntoView) && displayMode.value === 'groupList',
);
const handleAdd = async () => {
const expectedCount = await newHandler();
if (expectedCount !== null) await scrollLastItemIntoView(expectedCount);
};
const onChange = (v: any, eventData?: ContainerChangeEventData) => emit('change', v, eventData);
const onSelect = (...args: any[]) => emit('select', ...args);
const onAddDiffCount = () => emit('addDiffCount');
const onAdd = (rows: any[]) => {
rows.forEach((row: any) => {
newHandler(row);
});
const onAdd = async (rows: any[]) => {
let expectedCount: number | null = null;
for (const row of rows) {
expectedCount = (await newHandler(row)) ?? expectedCount;
}
if (expectedCount !== null) await scrollLastItemIntoView(expectedCount);
};
const tableGroupListRef = useTemplateRef<InstanceType<typeof MFormTable>>('tableGroupList');
defineExpose({
toggleRowSelection: (row: any, selected: boolean) => tableGroupListRef.value?.toggleRowSelection?.(row, selected),
});

View File

@ -22,54 +22,63 @@ export const useAdd = (
if (!modelName) return false;
if (typeof props.config.addable === 'function') {
return Boolean(
props.config.addable(mForm, {
model: props.model[modelName],
formValue: mForm?.values,
prop: props.prop,
config: props.config,
}),
);
}
if (!props.model[modelName]?.length) {
return true;
}
if (typeof props.config.addable === 'function') {
return props.config.addable(mForm, {
model: props.model[modelName],
formValue: mForm?.values,
prop: props.prop,
config: props.config,
});
}
return typeof props.config.addable === 'undefined' ? true : props.config.addable;
return typeof props.config.addable === 'undefined' ? true : Boolean(props.config.addable);
});
const newHandler = async (row?: any) => {
const modelName = props.name || props.config.name || '';
/**
* / beforeAddRow / enum null
*
* change `props.model` `MForm` changeRecords propPath
* DOM
*/
const newHandler = async (row?: any): Promise<number | null> => {
const modelName = `${props.name || props.config.name || ''}`;
const list: any[] = Array.isArray(props.model[modelName]) ? props.model[modelName] : [];
if (props.config.max && props.model[modelName].length >= props.config.max) {
if (props.config.max && list.length >= props.config.max) {
tMagicMessage.error(`最多新增配置不能超过${props.config.max}`);
return;
return null;
}
if (typeof props.config.beforeAddRow === 'function') {
const beforeCheckRes = await props.config.beforeAddRow(mForm, {
model: props.model[modelName],
model: list,
formValue: mForm?.values,
prop: props.prop,
});
if (!beforeCheckRes) return;
if (!beforeCheckRes) return null;
}
const columns = props.config.items;
const enumValues = props.config.enum || [];
let enumV = [];
const { length } = props.model[modelName];
const { length } = list;
const key = props.config.key || 'id';
let inputs: any = {};
if (enumValues.length) {
if (length >= enumValues.length) {
return;
return null;
}
enumV = enumValues.filter((item) => {
let i = 0;
for (; i < length; i++) {
if (item[key] === props.model[modelName][i][key]) {
if (item[key] === list[i][key]) {
break;
}
}
@ -87,7 +96,7 @@ export const useAdd = (
} else {
if (typeof props.config.defaultAdd === 'function') {
inputs = await props.config.defaultAdd(mForm, {
model: props.model[modelName],
model: list,
prop: props.prop,
formValue: mForm?.values,
});
@ -102,17 +111,18 @@ export const useAdd = (
}
if (props.sortKey && length) {
inputs[props.sortKey] = props.model[modelName][length - 1][props.sortKey] - 1;
inputs[props.sortKey] = list[length - 1][props.sortKey] - 1;
}
emit('change', [...props.model[modelName], inputs], {
emit('change', [...list, inputs], {
changeRecords: [
{
propPath: `${props.prop}.${props.model[modelName].length}`,
propPath: `${props.prop}.${length}`,
value: inputs,
},
],
});
return length + 1;
};
return {

View File

@ -0,0 +1,94 @@
import { nextTick, type Ref } from 'vue';
type ListRef = Ref<{ $el?: unknown } | HTMLElement | null | undefined>;
interface ScrollTarget {
root: HTMLElement;
last: HTMLElement | null;
count: number;
}
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
/** 滚动要用的列表根与最后一项;组件已卸载(宿主重建了整棵表单)时返回 null */
const resolveScrollTarget = (listRef: ListRef): ScrollTarget | null => {
const inst = listRef.value;
const root = inst instanceof HTMLElement ? inst : inst?.$el;
if (!(root instanceof HTMLElement)) return null;
const items = root.querySelectorAll(':scope > .m-fields-group-list-item');
const last = items[items.length - 1];
return { root, last: last instanceof HTMLElement ? last : null, count: items.length };
};
/** 吸顶标题自身的高度:滚动后新项的标题不能被上一层标题压住 */
const getStickyHeaderOffset = (item: HTMLElement): number => {
const header = item.querySelector(':scope > .el-card__header, :scope > .t-card__header');
if (!(header instanceof HTMLElement)) return 0;
return Number.parseFloat(getComputedStyle(header).top) || 0;
};
/** 吸底「新增」按钮盖住的高度;嵌套列表的 footer 还用 bottom 给外层按钮留了位 */
const getStickyFooterOffset = (root: HTMLElement): number => {
const footer = root.querySelector(':scope > .m-fields-group-list-footer.is-sticky-full');
if (!(footer instanceof HTMLElement)) return 0;
return footer.getBoundingClientRect().height + (Number.parseFloat(getComputedStyle(footer).bottom) || 0);
};
/**
* `expectedCount` DOM
*
* change宿
* 宿
*
*/
const waitForStableTarget = async (
listRef: ListRef,
expectedCount: number,
timeout = 600,
): Promise<ScrollTarget | null> => {
const deadline = Date.now() + timeout;
let previous: HTMLElement | null = null;
while (Date.now() < deadline) {
const target = resolveScrollTarget(listRef);
if (!target) return null;
if (target.count >= expectedCount && target.last && target.last === previous) return target;
previous = target.last;
await nextFrame();
}
return resolveScrollTarget(listRef);
};
/**
*
*
* `enabled` false group-list
*/
export const useScrollLastItemIntoView = (listRef: ListRef, enabled: () => boolean) => {
const scrollLastItemIntoView = async (expectedCount: number) => {
if (!enabled()) return;
// 先让已经同步写回的那部分渲染出来,稳定判定的第一帧就能拿到新项,少等一帧
await nextTick();
const target = await waitForStableTarget(listRef, expectedCount);
if (!target?.last) return;
const { root, last } = target;
// 新项是追加在末尾的,下方往往没有多余内容可滚,用 `start` 会被浏览器夹住、
// 仍压在吸底按钮下面。`nearest` 只滚到刚好露出,配合两侧 scroll-margin 避开吸顶标题与吸底按钮。
const top = getStickyHeaderOffset(last);
const bottom = getStickyFooterOffset(root);
last.style.scrollMarginTop = top ? `${top}px` : '';
last.style.scrollMarginBottom = bottom ? `${bottom}px` : '';
// 用瞬时定位而不是 smooth平滑滚动要持续几百毫秒期间宿主的回写重绘会把滚动位置改掉
// 动画继续奔向旧目标,看起来就是「先弹回顶部再滚下来」。瞬时定位没有这个窗口。
last.scrollIntoView({ behavior: 'auto', block: 'nearest' });
};
return { scrollLastItemIntoView };
};

View File

@ -1,4 +1,20 @@
.m-fields-group-list {
// footer 高度由这三项派生 padding / 按钮尺寸时嵌套吸底按钮的偏移会跟着走
// TableGroupList 计算 scroll-margin 时量的是同一个 footer 的实际高度两边不会漂移
--m-group-list-footer-padding-top: 12px;
--m-group-list-footer-padding-bottom: 16px;
--m-group-list-footer-button-height: 32px;
--m-group-list-footer-height: calc(
var(--m-group-list-footer-padding-top) +
var(--m-group-list-footer-button-height) +
var(--m-group-list-footer-padding-bottom)
);
--m-group-list-footer-bg: var(--el-bg-color, #fff);
--m-group-list-header-height: 65px;
--m-group-list-header-bg: #fff;
width: 100%;
min-width: 0;
.m-fields-group-list-item.tmagic-design-card--flat:last-child {
border: 0;
}
@ -16,6 +32,7 @@
}
.m-fields-group-list-item {
overflow: visible;
border-bottom: 1px solid #ebeef5;
margin-bottom: 7px;
@ -23,18 +40,107 @@
border-bottom: 0;
}
.el-card__body,
.t-card__body {
overflow: visible;
> .m-container-row {
overflow: visible;
}
.tmagic-design-form-item {
margin-bottom: 16px;
}
}
.m-fields-group-list-item-header {
display: flex;
align-items: center;
gap: 8px;
}
.m-fields-group-list-item-title {
flex: 1;
min-width: 0;
}
.m-fields-group-list-item-actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
}
// 卡片标题吸顶补不透明背景避免滚动时正文透出
// z-index 必须低于吸底 footer否则标题会盖住新增按钮
> .el-card__header,
> .t-card__header {
position: sticky;
top: var(--m-group-list-header-sticky-top, 0px);
z-index: 7;
background-color: var(--m-group-list-header-bg, #fff);
}
}
.m-fields-group-list-footer {
// 嵌套标题叠在外层标题之下层级更低以免盖住外层底部分隔线
.m-fields-group-list-item .m-fields-group-list-item {
> .el-card__header,
> .t-card__header {
top: calc(
var(--m-group-list-header-sticky-top, 0px) +
var(--m-group-list-header-height)
);
z-index: 6;
}
}
.el-table__empty-block {
width: 100%;
min-height: 0;
}
.el-table__empty-text {
display: block;
box-sizing: border-box;
width: 100%;
}
> .m-fields-group-list-footer {
display: flex;
justify-content: space-between;
margin-top: 10px;
margin-bottom: 16px;
}
// 吸底全宽主按钮event-select / code-select / display-conds 外层列表
// z-index 高于 item header避免吸顶标题挡住新增按钮
> .m-fields-group-list-footer.is-sticky-full {
position: sticky;
bottom: 0;
z-index: 7;
margin-bottom: 0;
padding: var(--m-group-list-footer-padding-top) 0
var(--m-group-list-footer-padding-bottom);
background-color: var(--m-group-list-footer-bg);
> div {
justify-content: stretch;
}
.el-button,
.tmagic-design-button {
width: 100%;
height: var(--m-group-list-footer-button-height);
}
}
// 嵌套吸底按钮叠在外层 footer 之上新增条件叠在新增条件组上面
.m-fields-group-list-item
.m-fields-group-list
> .m-fields-group-list-footer.is-sticky-full {
bottom: var(--m-group-list-footer-height);
z-index: 7;
}
}
/** 最外层的groupList需要每个item需要增加空白区域界限时增加outer-gorup_list可以实现 */

View File

@ -16,17 +16,14 @@
.magic-form-dynamic-tab
) {
> .tmagic-design-tabs:not(.el-tabs--border-card) {
> .el-tabs__content {
> .el-tabs__content,
> .t-tabs__content {
background-color: #fff;
padding: 16px 16px 0 16px;
border-radius: 4px;
}
}
}
.m-container-tab.magic-form-dynamic-tab {
}
.m-form-tip {
color: rgba(0, 0, 0, 0.55);
}
@ -79,10 +76,7 @@
}
}
.m-fields-group-list-item-header {
position: relative;
.delete-button {
position: absolute;
right: 0;
color: #0f1113;
}
}

View File

@ -700,3 +700,50 @@ describe('Form.vue —— config 变化', () => {
expect(wrapper.vm.changeRecords).toEqual([]);
});
});
describe('Form.vue —— 配置变化是否触发重挂', () => {
const makeConfig = () => [
{
type: 'text',
name: 'title',
text: '标题',
// 宿主每次重新生成配置时都是新闭包
display: () => true,
onChange: (_mForm: any, v: any) => v,
},
];
test('结构不变、只有闭包换了新实例时不卸载重挂', async () => {
const wrapper = mountForm({ config: makeConfig(), initValues: { title: 'a' } });
await nextTick();
await nextTick();
const before = wrapper.find('input').element;
expect(before).toBeTruthy();
await wrapper.setProps({ config: makeConfig(), initValues: { title: 'b' } });
await nextTick();
await nextTick();
// 同一个 DOM 节点还在,说明表单没有被销毁重建,滚动位置/焦点才不会丢
expect(wrapper.find('input').element).toBe(before);
expect(wrapper.vm.initialized).toBe(true);
});
test('结构真的变了(换字段)时仍然重挂', async () => {
const wrapper = mountForm({ config: makeConfig(), initValues: { title: 'a' } });
await nextTick();
await nextTick();
const before = wrapper.find('input').element;
await wrapper.setProps({
config: [{ type: 'text', name: 'subtitle', text: '副标题' }],
initValues: { subtitle: 'b' },
});
await nextTick();
await nextTick();
expect(wrapper.find('input').element).not.toBe(before);
});
});

View File

@ -3,7 +3,7 @@
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
import MagicForm, { MForm } from '@form/index';
import { mount } from '@vue/test-utils';
@ -15,6 +15,23 @@ const mountForm = (config: any[], initValues: any = {}, extra: any = {}) =>
props: { config, initValues, ...extra },
});
/** 新增后的滚动要等列表 DOM 连续两帧不变,这里多等几帧让它走完 */
const settleScroll = async () => {
for (let i = 0; i < 6; i++) {
await nextTick();
await new Promise((resolve) => requestAnimationFrame(resolve));
}
};
const waitFor = async (predicate: () => boolean, timeout = 1000) => {
const deadline = Date.now() + timeout;
while (Date.now() < deadline && !predicate()) {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 16));
}
return predicate();
};
describe('GroupList container', () => {
test('空数据时显示暂无数据', async () => {
const wrapper = mountForm(
@ -31,6 +48,57 @@ describe('GroupList container', () => {
expect(wrapper.text()).toContain('暂无数据');
});
test('addable 为 false 但列表为空时仍展示「新增」按钮', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
titlePrefix: 'mounted',
addable: false,
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [] },
);
await nextTick();
expect(wrapper.text()).toContain('暂无mounted数据');
expect(wrapper.text()).toContain('新增mounted');
});
test('addable 函数返回 false 时空列表也不展示「新增」按钮', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
titlePrefix: 'mounted',
addable: () => false,
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [] },
);
await nextTick();
expect(wrapper.text()).not.toContain('新增mounted');
});
test('未配置 addable 时空列表仍展示「新增」按钮', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
titlePrefix: 'mounted',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [] },
);
await nextTick();
expect(wrapper.text()).toContain('新增mounted');
});
test('有数据时渲染列表项', async () => {
const wrapper = mountForm(
[
@ -44,6 +112,7 @@ describe('GroupList container', () => {
);
await nextTick();
expect(wrapper.findAllComponents({ name: 'MFormGroupList' })).toHaveLength(1);
expect(wrapper.findAll('.m-fields-group-list-item .el-card__header').length).toBeGreaterThan(0);
});
test('extra 字段渲染 HTML', async () => {
@ -81,6 +150,35 @@ describe('GroupList container', () => {
expect(wrapper.text()).toContain('上移');
});
test('每项只有一个删除按钮,且位于上移下移之后', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
const actions = wrapper.findAll('.m-fields-group-list-item-actions');
expect(actions).toHaveLength(2);
actions.forEach((action) => {
expect(action.findAll('.delete-button')).toHaveLength(1);
const html = action.html();
expect(html.indexOf('下移')).toBeLessThan(html.indexOf('delete-button'));
});
});
test('对比模式按较长一侧对齐,已删除的项仍渲染', async () => {
const wrapper = mountForm(
compareConfig,
{ list: [] },
{ isCompare: true, lastValues: { list: [{ text: 'old' }] } },
);
await nextTick();
await nextTick();
expect(wrapper.find('.el-table__empty-block').exists()).toBe(false);
expect(wrapper.findAll('.m-fields-group-list-item').length).toBe(1);
const item = wrapper.findComponent({ name: 'MFormGroupListItem' });
expect(item.props('lastValues')).toEqual({ text: 'old' });
expect(item.props('model')).toEqual({});
});
test('对比模式隐藏底部操作栏与复制/移动按钮', async () => {
const wrapper = mountForm(
compareConfig,
@ -144,4 +242,258 @@ describe('GroupList container', () => {
expect(getFormItemLabelPosition(wrapper, 'list.0.title')).toBe('top');
});
});
describe('新增后滚动', () => {
afterEach(() => {
vi.restoreAllMocks();
});
test('未开启 scrollLastItemIntoView 时点击新增不滚动', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
titlePrefix: '项',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
await addButton?.trigger('click');
await settleScroll();
expect(wrapper.findAll('.m-fields-group-list-item').length).toBeGreaterThanOrEqual(2);
expect(scrollIntoView).not.toHaveBeenCalled();
});
test('table 形态即使开启 scrollLastItemIntoView 也不滚动', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
const wrapper = mountForm(
[
{
type: 'table',
name: 'list',
titlePrefix: '项',
scrollLastItemIntoView: true,
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增一行'));
await addButton?.trigger('click');
await settleScroll();
expect((wrapper.vm as any).values.list).toHaveLength(2);
expect(scrollIntoView).not.toHaveBeenCalled();
});
test('点击新增后把最后一项顶部滚进视口', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
titlePrefix: '项',
scrollLastItemIntoView: true,
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
await addButton?.trigger('click');
await settleScroll();
const items = wrapper.findAll('.m-fields-group-list-item');
expect(items.length).toBeGreaterThanOrEqual(2);
const last = items[items.length - 1].element as HTMLElement;
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'nearest' });
expect(scrollIntoView.mock.instances[0]).toBe(last);
});
test('滚动时为吸底新增按钮预留 scroll-margin嵌套列表含外层按钮占位', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'groups',
titlePrefix: '事件',
addButtonConfig: { sticky: true, text: '添加事件' },
items: [
{
type: 'group-list',
name: 'actions',
titlePrefix: '动作',
scrollLastItemIntoView: true,
addButtonConfig: { sticky: true, text: '新增动作' },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
},
],
{ groups: [{ actions: [{ text: 'a' }] }] },
);
await nextTick();
const nestedFooter = wrapper.find(
'.m-fields-group-list-item .m-fields-group-list > .m-fields-group-list-footer.is-sticky-full',
).element as HTMLElement;
vi.spyOn(nestedFooter, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 0, 52));
// 嵌套 footer 的 bottom 是给外层吸底按钮留的位,样式表算不出来,这里直接给一个
const nestedFooterStyle = document.createElement('div').style;
nestedFooterStyle.bottom = '60px';
const originGetComputedStyle = window.getComputedStyle.bind(window);
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) =>
el === nestedFooter ? nestedFooterStyle : originGetComputedStyle(el as Element),
);
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增动作'));
await addButton?.trigger('click');
await settleScroll();
const last = wrapper.findAll('.m-fields-group-list-item .m-fields-group-list-item').at(-1)
?.element as HTMLElement;
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'nearest' });
expect(last.style.scrollMarginBottom).toBe('112px');
});
test('宿主异步写回时等新项渲染出来再滚,不滚到旧的最后一项', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
titlePrefix: '项',
scrollLastItemIntoView: true,
// 容器配置带 onChange 时 Container 会 await新项要过几十毫秒才写回渲染出来
onChange: async (_mForm: any, v: any) => {
await new Promise((resolve) => setTimeout(resolve, 30));
return v;
},
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const staleLast = wrapper.find('.m-fields-group-list-item').element;
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
await addButton?.trigger('click');
await waitFor(() => scrollIntoView.mock.calls.length > 0);
const items = wrapper.findAll('.m-fields-group-list-item');
expect(items).toHaveLength(2);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView.mock.instances[0]).toBe(items[1].element);
expect(scrollIntoView.mock.instances[0]).not.toBe(staleLast);
});
test('列表 DOM 还在重绘时不滚动,等稳定后再滚', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
titlePrefix: '项',
scrollLastItemIntoView: true,
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
await addButton?.trigger('click');
// 模拟宿主在 change 之后又追加一项(属性面板回写表单值会重绘列表)
await nextTick();
(wrapper.vm as any).values.list.push({ text: 'c' });
await settleScroll();
const items = wrapper.findAll('.m-fields-group-list-item');
const last = items[items.length - 1].element as HTMLElement;
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView.mock.instances[0]).toBe(last);
});
test('嵌套 sticky 的 footer 叠在外层吸底按钮之上', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'groups',
titlePrefix: '条件组',
addButtonConfig: { sticky: true, text: '新增条件组', props: { type: 'primary', plain: true, text: false } },
items: [
{
type: 'group-list',
name: 'cond',
titlePrefix: '条件',
addButtonConfig: {
sticky: true,
text: '新增条件',
props: { type: 'primary', plain: true, text: false },
},
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
},
],
{ groups: [{ cond: [{ text: 'a' }] }] },
);
await nextTick();
const footers = wrapper.findAll('.m-fields-group-list-footer.is-sticky-full');
expect(footers.length).toBeGreaterThanOrEqual(2);
expect(wrapper.text()).toContain('新增条件');
expect(wrapper.text()).toContain('新增条件组');
});
test('addButtonConfig.sticky 时 footer 带 is-sticky-full', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
addButtonConfig: { sticky: true, text: '添加', props: { type: 'primary', plain: true, text: false } },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer.is-sticky-full').exists()).toBe(true);
expect(wrapper.text()).toContain('添加');
});
});
});

View File

@ -0,0 +1,151 @@
/*
* 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 { afterEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { useScrollLastItemIntoView } from '@form/containers/table-group-list/useScrollLastItemIntoView';
import { mount } from '@vue/test-utils';
const settle = async () => {
for (let i = 0; i < 6; i++) {
await nextTick();
await new Promise((resolve) => requestAnimationFrame(resolve));
}
};
const mountHook = (itemCount: number, enabled = () => true) => {
let scroll: ((expectedCount: number) => Promise<void>) | undefined;
const wrapper = mount(
defineComponent({
setup() {
const listRef = ref<HTMLElement | null>(null);
const { scrollLastItemIntoView } = useScrollLastItemIntoView(listRef, enabled);
scroll = scrollLastItemIntoView;
return { listRef };
},
template: `
<div ref="listRef" class="m-fields-group-list">
<div v-for="i in ${itemCount}" :key="i" class="m-fields-group-list-item">
<div class="el-card__header"></div>
</div>
<div class="m-fields-group-list-footer is-sticky-full"></div>
</div>
`,
}),
);
return {
wrapper,
scroll: (expectedCount: number) => scroll?.(expectedCount),
lastItem: () => wrapper.findAll('.m-fields-group-list-item').at(-1)?.element as HTMLElement,
footer: () => wrapper.find('.m-fields-group-list-footer').element as HTMLElement,
lastHeader: () =>
wrapper.findAll('.m-fields-group-list-item').at(-1)?.find('.el-card__header').element as HTMLElement,
};
};
describe('useScrollLastItemIntoView', () => {
afterEach(() => {
vi.restoreAllMocks();
});
test('enabled 为 false 时不滚动', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
const { scroll } = mountHook(2, () => false);
await scroll(2);
await settle();
expect(scrollIntoView).not.toHaveBeenCalled();
});
test('列表根不存在时不滚动', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
let scroll: ((expectedCount: number) => Promise<void>) | undefined;
mount(
defineComponent({
setup() {
const listRef = ref<HTMLElement | null>(null);
({ scrollLastItemIntoView: scroll } = useScrollLastItemIntoView(listRef, () => true));
return {};
},
template: '<div />',
}),
);
await scroll?.(1);
await settle();
expect(scrollIntoView).not.toHaveBeenCalled();
});
test('把最后一项瞬时滚进视口,并为吸顶标题和吸底按钮预留 scroll-margin', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
const { scroll, lastItem, footer, lastHeader } = mountHook(2);
vi.spyOn(footer(), 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 0, 52));
const footerStyle = document.createElement('div').style;
footerStyle.bottom = '60px';
const headerStyle = document.createElement('div').style;
headerStyle.top = '65px';
const originGetComputedStyle = window.getComputedStyle.bind(window);
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
if (el === footer()) return footerStyle;
if (el === lastHeader()) return headerStyle;
return originGetComputedStyle(el as Element);
});
await scroll(2);
await settle();
const last = lastItem();
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'nearest' });
expect(scrollIntoView.mock.instances[0]).toBe(last);
expect(last.style.scrollMarginTop).toBe('65px');
expect(last.style.scrollMarginBottom).toBe('112px');
});
test('未达到 expectedCount 时不滚到旧的最后一项', async () => {
const scrollIntoView = vi.fn();
vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
const { wrapper, scroll, lastItem } = mountHook(1);
const staleLast = lastItem();
const pending = scroll(2);
await nextTick();
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(scrollIntoView).not.toHaveBeenCalled();
const extra = document.createElement('div');
extra.className = 'm-fields-group-list-item';
wrapper.element.insertBefore(extra, wrapper.find('.m-fields-group-list-footer').element);
await pending;
await settle();
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView.mock.instances[0]).toBe(extra);
expect(scrollIntoView.mock.instances[0]).not.toBe(staleLast);
});
});

View File

@ -120,6 +120,10 @@ export default defineConfig({
},
optimizeDeps: {
// 适配器是运行时按 sessionStorage 动态 import 的,默认只预构建当前页面扫到的包。
// 切到 tdesign 时 Vite 会临时重优化,浏览器仍请求旧 hash → 504 Outdated Optimize Dep
// 进而拖垮 `@tmagic/tdesign-vue-next-adapter` 的动态加载。两边都 include启动时一次打好。
include: ['element-plus', 'tdesign-vue-next'],
rolldownOptions: {
transform: {
define: {