mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-12 15:09:43 +00:00
feat(editor): 统一列表字段添加按钮样式并支持新增后自动滚动
CodeSelect、EventSelect、DisplayConds 使用吸底全宽添加按钮; group-list 新增后滚到最后一项,避开吸顶标题与吸底按钮。
This commit is contained in:
parent
c34ed6aafc
commit
02eeb2e87f
@ -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(() =>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: '代码块' },
|
||||
|
||||
@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -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 slot,body 只放动作组。 */
|
||||
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 =>
|
||||
|
||||
31
packages/editor/src/fields/configs/stickyAddButton.ts
Normal file
31
packages/editor/src/fields/configs/stickyAddButton.ts
Normal 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 },
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
.m-fields-display-conds {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m-container-text.display-conds-title {
|
||||
.tmagic-design-form-item {
|
||||
.el-form-item__label {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -37,6 +37,7 @@ const dataSourceFormConfig: TabConfig = {
|
||||
name: 'events',
|
||||
src: 'datasource',
|
||||
type: 'event-select',
|
||||
defaultValue: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -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 }],
|
||||
},
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
@ -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可以实现 */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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('添加');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user