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 @@
-
+
暂无{{ config.titlePrefix || '' }}数据
+ >
+
+
+
+
-