diff --git a/packages/editor/src/components/CodeParams.vue b/packages/editor/src/components/CodeParams.vue index caa73631..493e3643 100644 --- a/packages/editor/src/components/CodeParams.vue +++ b/packages/editor/src/components/CodeParams.vue @@ -15,7 +15,13 @@ diff --git a/packages/editor/src/fields/EventSelect.vue b/packages/editor/src/fields/EventSelect.vue index ebdd7dc5..93ee76f4 100644 --- a/packages/editor/src/fields/EventSelect.vue +++ b/packages/editor/src/fields/EventSelect.vue @@ -15,82 +15,47 @@
事件配置
- 添加事件
- - diff --git a/packages/editor/src/fields/configs/codeSelect.ts b/packages/editor/src/fields/configs/codeSelect.ts index d457cfc0..b318338d 100644 --- a/packages/editor/src/fields/configs/codeSelect.ts +++ b/packages/editor/src/fields/configs/codeSelect.ts @@ -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: '代码块' }, diff --git a/packages/editor/src/fields/configs/displayConds.ts b/packages/editor/src/fields/configs/displayConds.ts index d5eebe04..608bdc53 100644 --- a/packages/editor/src/fields/configs/displayConds.ts +++ b/packages/editor/src/fields/configs/displayConds.ts @@ -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, 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), }, ], }, diff --git a/packages/editor/src/fields/configs/eventSelect.ts b/packages/editor/src/fields/configs/eventSelect.ts index ab977ed8..60f69d74 100644 --- a/packages/editor/src/fields/configs/eventSelect.ts +++ b/packages/editor/src/fields/configs/eventSelect.ts @@ -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 => diff --git a/packages/editor/src/fields/configs/stickyAddButton.ts b/packages/editor/src/fields/configs/stickyAddButton.ts new file mode 100644 index 00000000..084a0d78 --- /dev/null +++ b/packages/editor/src/fields/configs/stickyAddButton.ts @@ -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 }, + }, +}); diff --git a/packages/editor/src/fields/headless-validation.ts b/packages/editor/src/fields/headless-validation.ts index 3ae26597..73eb7618 100644 --- a/packages/editor/src/fields/headless-validation.ts +++ b/packages/editor/src/fields/headless-validation.ts @@ -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` 内部 - * ``。 + * 对应 `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` 出的卡片:每张卡片渲染 - * `` 与 ``,`:prop` 为 `${prop}.${index}`。 - * 用合成的 group-list 表达这层 `v-for`。 + * 对应 `fields/EventSelect.vue`:列表走 group-list,事件名渲染在 title slot, + * 无渲染校验仍把事件名当作列表项字段,路径为 `..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, }; }; diff --git a/packages/editor/src/theme/code-block.scss b/packages/editor/src/theme/code-block.scss index fbb92d7e..116dd461 100644 --- a/packages/editor/src/theme/code-block.scss +++ b/packages/editor/src/theme/code-block.scss @@ -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; - } - } } diff --git a/packages/editor/src/theme/display-conds.scss b/packages/editor/src/theme/display-conds.scss index fae0c400..98ed56f6 100644 --- a/packages/editor/src/theme/display-conds.scss +++ b/packages/editor/src/theme/display-conds.scss @@ -1,3 +1,7 @@ +.m-fields-display-conds { + width: 100%; +} + .m-container-text.display-conds-title { .tmagic-design-form-item { .el-form-item__label { diff --git a/packages/editor/src/theme/event.scss b/packages/editor/src/theme/event.scss index e0d98c53..28123c97 100644 --- a/packages/editor/src/theme/event.scss +++ b/packages/editor/src/theme/event.scss @@ -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; } } diff --git a/packages/editor/src/theme/props-panel.scss b/packages/editor/src/theme/props-panel.scss index d4ba1847..316b7184 100644 --- a/packages/editor/src/theme/props-panel.scss +++ b/packages/editor/src/theme/props-panel.scss @@ -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%; diff --git a/packages/editor/src/theme/themes/magic-admin/index.scss b/packages/editor/src/theme/themes/magic-admin/index.scss index 37909e12..9eff0c6d 100644 --- a/packages/editor/src/theme/themes/magic-admin/index.scss +++ b/packages/editor/src/theme/themes/magic-admin/index.scss @@ -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 { diff --git a/packages/editor/src/utils/data-source/index.ts b/packages/editor/src/utils/data-source/index.ts index 78b0a983..16797d44 100644 --- a/packages/editor/src/utils/data-source/index.ts +++ b/packages/editor/src/utils/data-source/index.ts @@ -37,6 +37,7 @@ const dataSourceFormConfig: TabConfig = { name: 'events', src: 'datasource', type: 'event-select', + defaultValue: [], }, ], }, diff --git a/packages/editor/src/utils/props.ts b/packages/editor/src/utils/props.ts index 3a15e4d5..99b5a0ae 100644 --- a/packages/editor/src/utils/props.ts +++ b/packages/editor/src/utils/props.ts @@ -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 = { type: 'display-conds', name: NODE_CONDS_KEY, titlePrefix: '条件组', - fixed: 'right', - operateColWidth: 112, defaultValue: [], rules: [{ typeMatch: true }], }, diff --git a/packages/editor/tests/unit/components/CodeParams.spec.ts b/packages/editor/tests/unit/components/CodeParams.spec.ts index a5cc384b..08b35f33 100644 --- a/packages/editor/tests/unit/components/CodeParams.spec.ts +++ b/packages/editor/tests/unit/components/CodeParams.spec.ts @@ -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'], diff --git a/packages/editor/tests/unit/fields/CodeSelect.spec.ts b/packages/editor/tests/unit/fields/CodeSelect.spec.ts index 7b64c7dc..f41dd7c7 100644 --- a/packages/editor/tests/unit/fields/CodeSelect.spec.ts +++ b/packages/editor/tests/unit/fields/CodeSelect.spec.ts @@ -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(); diff --git a/packages/editor/tests/unit/fields/DisplayConds.spec.ts b/packages/editor/tests/unit/fields/DisplayConds.spec.ts index 9b497c5f..de007e69 100644 --- a/packages/editor/tests/unit/fields/DisplayConds.spec.ts +++ b/packages/editor/tests/unit/fields/DisplayConds.spec.ts @@ -27,8 +27,8 @@ const { fieldTypeMock } = vi.hoisted(() => ({ }), })); -vi.mock('@editor/utils', async () => { - const actual = await vi.importActual('@editor/utils'); +vi.mock('@editor/utils/data-source', async () => { + const actual = await vi.importActual('@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); + }); }); diff --git a/packages/editor/tests/unit/fields/EventSelect.spec.ts b/packages/editor/tests/unit/fields/EventSelect.spec.ts index 8dedef6a..7836847b 100644 --- a/packages/editor/tests/unit/fields/EventSelect.spec.ts +++ b/packages/editor/tests/unit/fields/EventSelect.spec.ts @@ -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(); 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('@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); }); }); diff --git a/packages/editor/tests/unit/fields/headless-validation.spec.ts b/packages/editor/tests/unit/fields/headless-validation.spec.ts index 610fe847..71c7f655 100644 --- a/packages/editor/tests/unit/fields/headless-validation.spec.ts +++ b/packages/editor/tests/unit/fields/headless-validation.spec.ts @@ -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}`); + }); }); diff --git a/packages/editor/tests/unit/plugin.spec.ts b/packages/editor/tests/unit/plugin.spec.ts index 0df5d81d..3c258f88 100644 --- a/packages/editor/tests/unit/plugin.spec.ts +++ b/packages/editor/tests/unit/plugin.spec.ts @@ -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(); }); diff --git a/packages/editor/tests/unit/utils/props-config.spec.ts b/packages/editor/tests/unit/utils/props-config.spec.ts index 9d073fe3..b5c377d4 100644 --- a/packages/editor/tests/unit/utils/props-config.spec.ts +++ b/packages/editor/tests/unit/utils/props-config.spec.ts @@ -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(); + 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'); diff --git a/packages/form-schema/src/base.ts b/packages/form-schema/src/base.ts index 70470437..a96c5245 100644 --- a/packages/form-schema/src/base.ts +++ b/packages/form-schema/src/base.ts @@ -799,6 +799,16 @@ export interface PanelConfig extends FormItem, ContainerCommonConfig< } // #endregion PanelConfig +// #region AddButtonConfig +/** table / group-list 新增按钮的文案与形态 */ +export interface AddButtonConfig { + props?: Record; + 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; /** table 新增行时前置回调 */ beforeAddRow?: (mForm: FormState | undefined, data: any) => boolean | Promise; + /** 新增后滚动到最后一项(group-list 形态,默认关闭) */ + scrollLastItemIntoView?: boolean; + addButtonConfig?: AddButtonConfig; } // #endregion TableGroupListCommonConfig @@ -826,10 +839,8 @@ export interface TableColumnConfig extends FormItem { itemsFunction?: (row: any) => FormConfig; titleTip?: FilterFunction; type?: string; - addButtonConfig?: { - props?: Record; - text?: string; - }; + /** 列内嵌套列表的新增按钮不支持吸底 */ + addButtonConfig?: Omit; } // #endregion TableColumnConfig @@ -904,14 +915,10 @@ export interface GroupListConfig extends TableGroupListCommonConfig { * 当未设置时,默认展开第一项 */ defaultExpandQuantity?: number; - delete?: (model: any, index: number | string | symbol, values: any) => boolean | boolean; - copyable?: FilterFunction; - 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; + movable?: + boolean | ((mForm: FormState | undefined, index: number | string | symbol, model: any, groupModel: any) => boolean); moveSpecifyLocation?: boolean; } // #endregion GroupListConfig diff --git a/packages/form/src/Form.vue b/packages/form/src/Form.vue index 65ea78d2..0ac70d20 100644 --- a/packages/form/src/Form.vue +++ b/packages/form/src/Form.vue @@ -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([]); +/** + * 两份配置的结构是否一致;函数一律视为相等。 + * + * 宿主(如编辑器属性面板)往往在每次节点更新后整份重新生成配置,其中的 + * `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; } diff --git a/packages/form/src/containers/GroupList.vue b/packages/form/src/containers/GroupList.vue index e43d1a5c..7d5910a1 100644 --- a/packages/form/src/containers/GroupList.vue +++ b/packages/form/src/containers/GroupList.vue @@ -1,33 +1,41 @@ diff --git a/packages/form/src/containers/GroupListItem.vue b/packages/form/src/containers/GroupListItem.vue index 4600c403..35582591 100644 --- a/packages/form/src/containers/GroupListItem.vue +++ b/packages/form/src/containers/GroupListItem.vue @@ -6,89 +6,95 @@ - - +
+ + + +
+
- - - 复制 - - - - diff --git a/packages/form/src/containers/table-group-list/TableGroupList.vue b/packages/form/src/containers/table-group-list/TableGroupList.vue index 66c473b1..e1c287c0 100644 --- a/packages/form/src/containers/table-group-list/TableGroupList.vue +++ b/packages/form/src/containers/table-group-list/TableGroupList.vue @@ -21,13 +21,8 @@ @addDiffCount="onAddDiffCount" @add="onAdd" > - + + @@ -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(() => (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>('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>('tableGroupList'); - defineExpose({ toggleRowSelection: (row: any, selected: boolean) => tableGroupListRef.value?.toggleRowSelection?.(row, selected), }); diff --git a/packages/form/src/containers/table-group-list/useAdd.ts b/packages/form/src/containers/table-group-list/useAdd.ts index 48b6d2f6..4913603f 100644 --- a/packages/form/src/containers/table-group-list/useAdd.ts +++ b/packages/form/src/containers/table-group-list/useAdd.ts @@ -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 => { + 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 { diff --git a/packages/form/src/containers/table-group-list/useScrollLastItemIntoView.ts b/packages/form/src/containers/table-group-list/useScrollLastItemIntoView.ts new file mode 100644 index 00000000..08610ade --- /dev/null +++ b/packages/form/src/containers/table-group-list/useScrollLastItemIntoView.ts @@ -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((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 => { + 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 }; +}; diff --git a/packages/form/src/theme/group-list.scss b/packages/form/src/theme/group-list.scss index 89e46825..eef38eca 100644 --- a/packages/form/src/theme/group-list.scss +++ b/packages/form/src/theme/group-list.scss @@ -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可以实现 */ diff --git a/packages/form/src/theme/themes/magic-admin/index.scss b/packages/form/src/theme/themes/magic-admin/index.scss index b1be37db..72836d27 100644 --- a/packages/form/src/theme/themes/magic-admin/index.scss +++ b/packages/form/src/theme/themes/magic-admin/index.scss @@ -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; } } diff --git a/packages/form/tests/unit/Form.extra.spec.ts b/packages/form/tests/unit/Form.extra.spec.ts index bf378d97..479d53b9 100644 --- a/packages/form/tests/unit/Form.extra.spec.ts +++ b/packages/form/tests/unit/Form.extra.spec.ts @@ -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); + }); +}); diff --git a/packages/form/tests/unit/containers/GroupList.spec.ts b/packages/form/tests/unit/containers/GroupList.spec.ts index 43f4f462..86635047 100644 --- a/packages/form/tests/unit/containers/GroupList.spec.ts +++ b/packages/form/tests/unit/containers/GroupList.spec.ts @@ -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('添加'); + }); + }); }); diff --git a/packages/form/tests/unit/containers/useScrollLastItemIntoView.spec.ts b/packages/form/tests/unit/containers/useScrollLastItemIntoView.spec.ts new file mode 100644 index 00000000..8f48b770 --- /dev/null +++ b/packages/form/tests/unit/containers/useScrollLastItemIntoView.spec.ts @@ -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) | undefined; + + const wrapper = mount( + defineComponent({ + setup() { + const listRef = ref(null); + const { scrollLastItemIntoView } = useScrollLastItemIntoView(listRef, enabled); + scroll = scrollLastItemIntoView; + return { listRef }; + }, + template: ` +
+
+
+
+ +
+ `, + }), + ); + + 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) | undefined; + mount( + defineComponent({ + setup() { + const listRef = ref(null); + ({ scrollLastItemIntoView: scroll } = useScrollLastItemIntoView(listRef, () => true)); + return {}; + }, + template: '
', + }), + ); + + 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); + }); +}); diff --git a/playground/vite.config.ts b/playground/vite.config.ts index 1a7d40f8..f487cf4b 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -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: {