feat(editor): 抽离表单视图与对比逻辑

This commit is contained in:
roymondchen 2026-07-21 21:18:29 +08:00
parent 6cdf54a4a6
commit 6f3f321736
10 changed files with 783 additions and 202 deletions

View File

@ -19,107 +19,36 @@
</template>
<script lang="ts" setup>
import { computed, inject, provide, type Ref, ref, type ShallowRef, useTemplateRef, watch, watchEffect } from 'vue';
import { computed, type Ref, type ShallowRef } from 'vue';
import { isEqual } from 'lodash-es';
import { type CodeBlockContent, type DataSourceSchema, HookType, type MNode } from '@tmagic/core';
import { type FieldSize } from '@tmagic/design';
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
import { type CodeBlockContent, HookType } from '@tmagic/core';
import { type FormConfig, type FormValue, MForm } from '@tmagic/form';
import type { CompareCategory, CompareFormLoadConfig, Services } from '@editor/type';
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
import { useCompareForm } from '@editor/hooks/use-compare-form';
import type { CompareFormBaseProps } from '@editor/type';
defineOptions({
name: 'MEditorCompareForm',
});
const props = withDefaults(
defineProps<{
/** 当前值(修改后的值) */
value: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
/** 用于对比的旧值(修改前的值) */
lastValue?: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
/**
* 类型说明
* - `category` `node` `type` 为节点组件的类型例如 'text''button''page''container'
* - `category` `data-source` `type` 为数据源类型例如 'base''http'
* - `category` `code-block` `type` 可不传
*/
type?: string;
/** 表单配置类别,决定从哪里取 FormConfig */
category?: CompareCategory;
/** 数据源代码块场景下的数据源类型base/http用于代码块表单中"执行时机"展示 */
dataSourceType?: string;
labelWidth?: string;
/**
* 外层容器高度设置后表单内容超出时会在 CompareForm 内部出现滚动条
* 避免 dialog / 面板使用方需要自行处理滚动可传任意 CSS 长度例如 `60vh` / `400px` / `100%`
*/
height?: string;
/**
* 用户自定义注入到 MForm.formState 的扩展字段 Editor 顶层的 `extendFormState`
* PropsPanel `extend-state` 语义一致表单 item `display` / `disabled`
* filterFunction 经常依赖这里注入的字段 stage自定义业务上下文等
* 因此在差异对比场景下也需要透传避免出现 `formState.xxx is undefined` 的运行时错误
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* 外部透传的基础 formState通常来自 PropsPanel 主属性表单
* CompareForm 会提取其中的扩展字段覆盖到自己的 formState保证 filterFunction 上下文一致
*/
baseFormState?: FormState;
/** 需要走 self diff 的字段类型(例如 mod-cond。 */
selfDiffFieldTypes?: string[];
/**
* 表单内组件的尺寸透传给 MForm `size`可选 'large' | 'default' | 'small'
* 缺省时使用 MForm 内置默认尺寸
*/
size?: FieldSize;
/**
* 自定义 FormConfig 加载逻辑传入后将接管内置的按 `category`(node/data-source/code-block)
* 取配置逻辑调用方可根据业务自行返回或异步返回表单配置可通过
* `ctx.defaultLoadConfig()` 复用默认结果再做二次加工返回的 config 直接用于对比展示
*/
loadConfig?: CompareFormLoadConfig;
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
services?: Services;
}>(),
defineProps<
CompareFormBaseProps & {
/** 用于对比的旧值(修改前的值) */
lastValue?: CompareFormBaseProps['value'];
/** 需要走 self diff 的字段类型(例如 mod-cond。 */
selfDiffFieldTypes?: string[];
}
>(),
{
category: 'node',
labelWidth: '120px',
extendState: (state: FormState) => state,
},
);
provide('services', props.services);
const config = ref<FormConfig>([]);
/** vs-code 编辑器的 monaco 配置项,沿用 Editor 顶层 provide('codeOptions', ...) 的注入。 */
const codeOptions = inject<Record<string, any>>('codeOptions', {});
/** 将代码块的 content 字段统一成字符串,便于在表单/对比中展示 */
const normalizeCodeBlockValue = (
v: Partial<CodeBlockContent> | Record<string, any> | undefined,
): Record<string, any> => {
if (!v) return {};
const next: Record<string, any> = { ...v };
if (next.content && typeof next.content !== 'string') {
try {
next.content = next.content.toString();
} catch {
next.content = '';
}
}
return next;
};
const currentValues = computed<FormValue>(() => {
if (props.category === 'code-block') {
return normalizeCodeBlockValue(props.value as Partial<CodeBlockContent>);
}
return (props.value || {}) as FormValue;
});
const { config, currentValues, wrapperStyle, mergedExtendState, loadConfig, formRef, normalizeCodeBlockValue } =
useCompareForm(props);
const lastValuesProcessed = computed<FormValue>(() => {
if (props.category === 'code-block') {
@ -128,18 +57,6 @@ const lastValuesProcessed = computed<FormValue>(() => {
return (props.lastValue || {}) as FormValue;
});
/**
* 外层包裹层的样式当传入 `height` 时启用固定高度 + 内部滚动
* 这样滚动条会出现在 CompareForm 内部避免父容器 Dialog自身也产生滚动
*/
const wrapperStyle = computed(() => {
if (!props.height) return undefined;
return {
height: props.height,
overflow: 'auto',
} as Record<string, string>;
});
/**
* `code-select` 字段在历史数据中存在两种"语义为空"的形态
* - 字符串 `''`旧数据 / 用户从未配置过钩子
@ -168,110 +85,6 @@ const showDiff = ({ curValue, lastValue, config }: { curValue: any; lastValue: a
return !isEqual(curValue, lastValue);
};
const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
formConfig.map((item) => {
if (!('type' in item)) return item;
if (item?.type !== 'tab' || !Array.isArray(item.items)) return item;
return {
...item,
items: item.items.map((tabPane) => {
if (tabPane?.title !== '样式' || !Array.isArray(tabPane.items)) return tabPane;
return {
...tabPane,
display: true,
};
}),
};
});
const mergedExtendState = (state: FormState) => {
return props.extendState(props.baseFormState || state);
};
/**
* 内置的默认 FormConfig 加载逻辑 `category` 从对应 service / 工具取配置
* 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`方便复用与二次加工
*/
const defaultLoadConfig = async (): Promise<FormConfig> => {
if (!props.services) {
return [];
}
switch (props.category) {
case 'node': {
if (!props.type) {
return [];
}
return removeStyleDisplayConfig(
await props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
);
}
case 'data-source': {
const config = props.services.dataSourceService.getFormConfig(props.type || 'base');
// tab status 'fields'tab-pane name 'fields'
// active Tabs '0' 'fields'
// DataSourceConfigPanel tab
return config.map((item) => ('type' in item && item.type === 'tab' ? { ...item, active: 'fields' } : item));
}
case 'code-block': {
return getCodeBlockFormConfig({
paramColConfig: props.services.codeBlockService.getParamsColConfig(),
// dataSourceType "" props.dataSourceType
// step
isDataSource: () => Boolean(props.dataSourceType),
dataSourceType: () => props.dataSourceType,
codeOptions,
// /
editable: false,
});
}
default:
return [];
}
};
const loadConfig = async () => {
if (props.loadConfig) {
config.value = await props.loadConfig({
category: props.category,
type: props.type,
dataSourceType: props.dataSourceType,
defaultLoadConfig,
});
return;
}
config.value = await defaultLoadConfig();
};
watch(
[() => props.category, () => props.type, () => props.dataSourceType, () => props.loadConfig],
() => {
loadConfig();
},
{ immediate: true },
);
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
/**
* services / stage 注入 MForm formState避免 propsService 注入的表单配置中
* 形如 `display: ({ services }) => services.uiService.get(...)` filterFunction
* 在执行时拿不到 `formState.services` 而报错
*
* props-panel/FormPanel.vue 中的注入方式保持一致
* - services整个 useServices() 返回的服务集合
* - stage当前 editorService.get('stage') 的最新值
*/
watchEffect(() => {
if (formRef.value && props.services) {
formRef.value.formState.stage = props.services.editorService.get('stage');
formRef.value.formState.services = props.services;
}
});
defineExpose<{
form: ShallowRef<InstanceType<typeof MForm> | null>;
config: Ref<FormConfig>;

View File

@ -0,0 +1,55 @@
<template>
<div class="m-editor-view-form-wrapper" :style="wrapperStyle">
<MForm
v-if="config.length"
ref="form"
class="m-editor-view-form"
:config="config"
:init-values="currentValues"
:disabled="disabled"
:label-width="labelWidth"
:extend-state="mergedExtendState"
:size="size"
></MForm>
</div>
</template>
<script lang="ts" setup>
import { type Ref, type ShallowRef } from 'vue';
import { type FormConfig, MForm } from '@tmagic/form';
import { useCompareForm } from '@editor/hooks/use-compare-form';
import type { CompareFormBaseProps } from '@editor/type';
defineOptions({
name: 'MEditorViewForm',
});
const props = withDefaults(
defineProps<
CompareFormBaseProps & {
/** 是否禁用表单(默认只读展示)。 */
disabled?: boolean;
}
>(),
{
category: 'node',
labelWidth: '120px',
disabled: true,
// extendState useCompareForm props.extendState ?? ...
},
);
const { config, currentValues, wrapperStyle, mergedExtendState, loadConfig, formRef } = useCompareForm(props);
defineExpose<{
form: ShallowRef<InstanceType<typeof MForm> | null>;
config: Ref<FormConfig>;
reload: () => Promise<void>;
}>({
form: formRef,
config,
reload: loadConfig,
});
</script>

View File

@ -17,6 +17,7 @@
*/
export * from './use-code-block-edit';
export * from './use-compare-form';
export * from './use-stage';
export * from './use-float-box';
export * from './use-window-rect';

View File

@ -0,0 +1,187 @@
/*
* 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 { computed, type ComputedRef, inject, provide, type Ref, ref, useTemplateRef, watch, watchEffect } from 'vue';
import type { CodeBlockContent, MNode } from '@tmagic/core';
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
import type { CompareFormBaseProps } from '@editor/type';
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
import { removeStyleDisplayConfig } from '@editor/utils/props';
export interface UseCompareFormReturn {
config: Ref<FormConfig>;
currentValues: ComputedRef<FormValue>;
wrapperStyle: ComputedRef<Record<string, string> | undefined>;
mergedExtendState: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
loadConfig: () => Promise<void>;
formRef: Readonly<Ref<InstanceType<typeof MForm> | null>>;
normalizeCodeBlockValue: (v: Partial<CodeBlockContent> | Record<string, any> | undefined) => Record<string, any>;
}
/**
* CompareForm / ViewForm
* - `category`(node / data-source / code-block) FormConfig `loadConfig`
* - `content`
* - + `wrapperStyle`
* - services / stage MForm.formState filterFunction
*
*
*/
export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormReturn => {
provide('services', props.services);
const config = ref<FormConfig>([]);
/** vs-code 编辑器的 monaco 配置项,沿用 Editor 顶层 provide('codeOptions', ...) 的注入。 */
const codeOptions = inject<Record<string, any>>('codeOptions', {});
/** 将代码块的 content 字段统一成字符串,便于在表单 / 对比中展示 */
const normalizeCodeBlockValue = (
v: Partial<CodeBlockContent> | Record<string, any> | undefined,
): Record<string, any> => {
if (!v) return {};
const next: Record<string, any> = { ...v };
if (next.content && typeof next.content !== 'string') {
try {
next.content = next.content.toString();
} catch {
next.content = '';
}
}
return next;
};
const currentValues = computed<FormValue>(() => {
if (props.category === 'code-block') {
return normalizeCodeBlockValue(props.value as Partial<CodeBlockContent>);
}
return (props.value || {}) as FormValue;
});
/**
* `height` +
* Dialog
*/
const wrapperStyle = computed(() => {
if (!props.height) return undefined;
const style: Record<string, string> = {
height: props.height,
overflow: 'auto',
};
return style;
});
const mergedExtendState = (state: FormState) => {
const extendState = props.extendState ?? ((s: FormState) => s);
return extendState(props.baseFormState || state);
};
/**
* FormConfig `category` service /
* ctx.defaultLoadConfig `loadConfig`便
*/
const defaultLoadConfig = async (): Promise<FormConfig> => {
if (!props.services) {
return [];
}
switch (props.category) {
case 'node': {
if (!props.type) {
return [];
}
return removeStyleDisplayConfig(
await props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
);
}
case 'data-source': {
const config = props.services.dataSourceService.getFormConfig(props.type || 'base');
// 数据源表单外层 tab 的「数据定义」项 status 为 'fields'tab-pane name 随之为 'fields'。
// 未显式设置 active 时Tabs 默认取 '0',与 'fields' 不匹配会导致打开弹窗时无默认激活项,
// 这里与 DataSourceConfigPanel 保持一致默认激活「数据定义」tab。
return config.map((item) => ('type' in item && item.type === 'tab' ? { ...item, active: 'fields' } : item));
}
case 'code-block': {
return getCodeBlockFormConfig({
paramColConfig: props.services.codeBlockService.getParamsColConfig(),
// 通过传入 dataSourceType 间接表达"是数据源代码块"——在对比 / 展示场景下 props.dataSourceType
// 由调用方按 step 上下文显式传入,未传则视为普通代码块,「执行时机」字段隐藏。
isDataSource: () => Boolean(props.dataSourceType),
dataSourceType: () => props.dataSourceType,
codeOptions,
// 对比 / 展示模式只读,不需要校验/语法检查
editable: false,
});
}
default:
return [];
}
};
const loadConfig = async () => {
if (props.loadConfig) {
config.value = await props.loadConfig({
category: props.category as string,
type: props.type,
dataSourceType: props.dataSourceType,
defaultLoadConfig,
});
return;
}
config.value = await defaultLoadConfig();
};
watch(
[() => props.category, () => props.type, () => props.dataSourceType, () => props.loadConfig],
() => {
loadConfig();
},
{ immediate: true },
);
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
/**
* services / stage MForm formState propsService
* `display: ({ services }) => services.uiService.get(...)` filterFunction
* `formState.services`
*
* props-panel/FormPanel.vue
* - services useServices()
* - stage editorService.get('stage')
*/
watchEffect(() => {
if (formRef.value && props.services) {
formRef.value.formState.stage = props.services.editorService.get('stage');
formRef.value.formState.services = props.services;
}
});
return {
config,
currentValues,
wrapperStyle,
mergedExtendState,
loadConfig,
formRef,
normalizeCodeBlockValue,
};
};

View File

@ -74,6 +74,7 @@ export { default as SplitView } from './components/SplitView.vue';
export { default as Resizer } from './components/Resizer.vue';
export { default as CodeBlockEditor } from './components/CodeBlockEditor.vue';
export { default as CompareForm } from './components/CompareForm.vue';
export { default as ViewForm } from './components/ViewForm.vue';
export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue';
export { default as HistoryListBucketTab } from './layouts/history-list/BucketTab.vue';
export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue';

View File

@ -566,6 +566,58 @@ export interface CompareFormLoadConfigContext {
* `ctx.defaultLoadConfig()`
*/
export type CompareFormLoadConfig = (ctx: CompareFormLoadConfigContext) => FormConfig | Promise<FormConfig>;
/**
* CompareForm / ViewForm props
* category FormConfig + services/stage useCompareForm
*
*/
export interface CompareFormBaseProps {
/** 当前值(对比场景下为修改后的值) */
value: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
/**
*
* - `category` `node` `type` 'text''button''page''container'
* - `category` `data-source` `type` 'base''http'
* - `category` `code-block` `type`
*/
type?: string;
/** 表单配置类别,决定从哪里取 FormConfig */
category?: CompareCategory;
/** 数据源代码块场景下的数据源类型base/http用于代码块表单中"执行时机"展示 */
dataSourceType?: string;
labelWidth?: string;
/**
*
* dialog / 使 CSS `60vh` / `400px` / `100%`
*/
height?: string;
/**
* MForm.formState Editor `extendFormState`
* PropsPanel `extend-state` item `display` / `disabled`
* filterFunction stage
* / `formState.xxx is undefined`
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* formState PropsPanel
* formState filterFunction
*/
baseFormState?: FormState;
/**
* MForm `size` 'large' | 'default' | 'small'
* 使 MForm
*/
size?: FieldSize;
/**
* FormConfig `category`(node/data-source/code-block)
*
* `ctx.defaultLoadConfig()`
*/
loadConfig?: CompareFormLoadConfig;
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
services?: Services;
}
// #endregion CompareForm
// #region SideItemKey

View File

@ -362,6 +362,35 @@ export const fillConfig = (
return [tabConfig];
};
/**
* tab-pane `display` `true`
*
* `propsService.getPropsConfig` tab
* `display: ({ services }) => !(services?.uiService?.get('showStylePanel') ?? true)`
* / CompareForm / ViewForm uiService
* tab
*
* @param formConfig
* @returns
*/
export const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
formConfig.map((item) => {
if (!('type' in item)) return item;
if (item?.type !== 'tab' || !Array.isArray(item.items)) return item;
return {
...item,
items: item.items.map((tabPane) => {
if (tabPane?.title !== '样式' || !Array.isArray(tabPane.items)) return tabPane;
return {
...tabPane,
display: true,
};
}),
};
});
// #region ValidatePropsFormOptions
/**
* validatePropsForm

View File

@ -0,0 +1,172 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import ViewForm from '@editor/components/ViewForm.vue';
const propsService = {
getPropsConfig: vi.fn(async () => [{ type: 'text', name: 'name' }]),
};
const dataSourceService = {
getFormConfig: vi.fn(() => [{ type: 'text', name: 'title' }]),
};
const codeBlockService = {
getParamsColConfig: vi.fn(() => null),
};
const editorService = {
get: vi.fn(() => ({ select: vi.fn() })),
};
const services = {
propsService,
dataSourceService,
codeBlockService,
editorService,
} as any;
let capturedFormProps: Record<string, any> = {};
vi.mock('@editor/utils/code-block', () => ({
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
}));
vi.mock('@tmagic/form', () => ({
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'disabled', 'labelWidth', 'extendState', 'size'],
setup(props, { expose }) {
capturedFormProps = props as Record<string, any>;
expose({ formState: {} });
return () => h('div', { class: 'fake-mform' });
},
}),
}));
beforeEach(() => {
vi.clearAllMocks();
capturedFormProps = {};
});
describe('ViewForm.vue', () => {
test('node 类别按 type 加载配置并渲染 MForm', async () => {
const wrapper = mount(ViewForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1', name: 'a' },
services,
},
});
await nextTick();
await nextTick();
expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1', name: 'a' } });
expect(wrapper.find('.fake-mform').exists()).toBe(true);
expect(capturedFormProps.initValues).toEqual({ id: 'n1', name: 'a' });
});
test('默认 disabled 为 true', async () => {
mount(ViewForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
services,
},
});
await nextTick();
await nextTick();
expect(capturedFormProps.disabled).toBe(true);
});
test('可通过 disabled=false 覆盖为可编辑', async () => {
mount(ViewForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
disabled: false,
services,
},
});
await nextTick();
await nextTick();
expect(capturedFormProps.disabled).toBe(false);
});
test('size 透传给 MForm', async () => {
mount(ViewForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
size: 'small',
services,
},
});
await nextTick();
await nextTick();
expect(capturedFormProps.size).toBe('small');
});
test('code-block 类别会把 content 非字符串值 normalize 成字符串', async () => {
mount(ViewForm, {
props: {
category: 'code-block',
value: { id: 'cb_1', content: { toString: () => 'fn-body' } },
services,
},
});
await nextTick();
await nextTick();
expect(capturedFormProps.initValues.content).toBe('fn-body');
});
test('传入 height 时外层容器启用内部滚动样式', () => {
const wrapper = mount(ViewForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
height: '400px',
services,
},
});
const style = wrapper.find('.m-editor-view-form-wrapper').attributes('style') || '';
expect(style).toContain('height: 400px');
expect(style).toContain('overflow: auto');
});
test('node 类别缺少 type 时不渲染 MForm', async () => {
const wrapper = mount(ViewForm, {
props: {
category: 'node',
value: { id: 'n1' },
services,
},
});
await nextTick();
await nextTick();
expect(wrapper.find('.fake-mform').exists()).toBe(false);
});
test('reload 暴露方法会重新加载配置', async () => {
const wrapper = mount(ViewForm, {
props: {
category: 'data-source',
type: 'base',
value: { id: 'ds_1' },
services,
},
});
await nextTick();
await nextTick();
dataSourceService.getFormConfig.mockClear();
await (wrapper.vm as any).reload();
expect(dataSourceService.getFormConfig).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,222 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { MForm } from '@tmagic/form';
import { useCompareForm } from '@editor/hooks/use-compare-form';
const propsService = {
getPropsConfig: vi.fn(async () => [
{
type: 'tab',
items: [{ title: '样式', items: [{ type: 'text', name: 'color', display: false }] }],
},
{ type: 'text', name: 'name' },
]),
};
const dataSourceService = {
getFormConfig: vi.fn(() => [{ type: 'tab', items: [{ status: 'fields', items: [] }] }]),
};
const codeBlockService = {
getParamsColConfig: vi.fn(() => null),
};
const editorService = {
get: vi.fn(() => ({ select: vi.fn() })),
};
const services = {
propsService,
dataSourceService,
codeBlockService,
editorService,
} as any;
let capturedGetCodeBlockArgs: any;
vi.mock('@editor/utils/code-block', () => ({
getCodeBlockFormConfig: vi.fn((args: any) => {
capturedGetCodeBlockArgs = args;
return [{ type: 'text', name: 'content' }];
}),
}));
vi.mock('@tmagic/form', () => ({
MForm: defineComponent({
name: 'MForm',
setup(_, { expose }) {
expose({ formState: {} });
return () => h('div', { class: 'fake-mform' });
},
}),
}));
const mountHook = (props: any, provideOptions?: Record<string, any>) => {
let captured: any;
const comp = defineComponent({
setup() {
captured = useCompareForm(props);
return () => h(MForm as any, { ref: 'form' });
},
});
const wrapper = mount(comp, { global: { provide: provideOptions } });
return { captured, wrapper };
};
beforeEach(() => {
vi.clearAllMocks();
capturedGetCodeBlockArgs = undefined;
});
describe('useCompareForm', () => {
test('无 services 时 config 为空数组', async () => {
const { captured } = mountHook({ category: 'node', type: 'text', value: {} });
await nextTick();
await nextTick();
expect(captured.config.value).toEqual([]);
});
test('node 类别加载 props 配置并把「样式」tab display 置为 true', async () => {
const { captured } = mountHook({ category: 'node', type: 'text', value: { id: 'n1' }, services });
await nextTick();
await nextTick();
expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1' } });
const tab = captured.config.value.find((i: any) => i.type === 'tab');
const stylePane = tab.items.find((p: any) => p.title === '样式');
expect(stylePane.display).toBe(true);
});
test('node 类别缺少 type 时返回空配置', async () => {
const { captured } = mountHook({ category: 'node', value: { id: 'n1' }, services });
await nextTick();
await nextTick();
expect(propsService.getPropsConfig).not.toHaveBeenCalled();
expect(captured.config.value).toEqual([]);
});
test('data-source 类别默认激活 fields tab', async () => {
const { captured } = mountHook({ category: 'data-source', type: 'http', value: {}, services });
await nextTick();
await nextTick();
expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('http');
const tab = captured.config.value.find((i: any) => i.type === 'tab');
expect(tab.active).toBe('fields');
});
test('data-source 未传 type 时默认使用 base', async () => {
mountHook({ category: 'data-source', value: {}, services });
await nextTick();
await nextTick();
expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('base');
});
test('code-block 类别归一化 content 并按 dataSourceType 判定是否数据源代码块', async () => {
const { captured } = mountHook(
{ category: 'code-block', dataSourceType: 'http', value: { content: { toString: () => 'body' } }, services },
{ codeOptions: { theme: 'vs-dark' } },
);
await nextTick();
await nextTick();
expect(captured.currentValues.value.content).toBe('body');
expect(capturedGetCodeBlockArgs.editable).toBe(false);
expect(capturedGetCodeBlockArgs.isDataSource()).toBe(true);
expect(capturedGetCodeBlockArgs.dataSourceType()).toBe('http');
expect(capturedGetCodeBlockArgs.codeOptions).toEqual({ theme: 'vs-dark' });
});
test('未传 dataSourceType 时 isDataSource 为 false', async () => {
mountHook({ category: 'code-block', value: { content: 'x' }, services });
await nextTick();
await nextTick();
expect(capturedGetCodeBlockArgs.isDataSource()).toBe(false);
});
test('normalizeCodeBlockValue 处理各种输入', () => {
const { captured } = mountHook({ category: 'node', type: 'text', value: {}, services });
expect(captured.normalizeCodeBlockValue(undefined)).toEqual({});
expect(captured.normalizeCodeBlockValue({ content: 'x' })).toEqual({ content: 'x' });
expect(captured.normalizeCodeBlockValue({ content: { toString: () => 'y' } }).content).toBe('y');
const bad = {
content: {
toString: () => {
throw new Error('e');
},
},
};
expect(captured.normalizeCodeBlockValue(bad).content).toBe('');
});
test('currentValues 非 code-block 场景直接返回 value', async () => {
const { captured } = mountHook({ category: 'node', type: 'text', value: { id: 'n1', name: 'a' }, services });
await nextTick();
expect(captured.currentValues.value).toEqual({ id: 'n1', name: 'a' });
});
test('wrapperStyle 根据 height 生成', () => {
const { captured } = mountHook({ category: 'node', type: 'text', value: {}, height: '60vh', services });
expect(captured.wrapperStyle.value).toEqual({ height: '60vh', overflow: 'auto' });
const { captured: c2 } = mountHook({ category: 'node', type: 'text', value: {}, services });
expect(c2.wrapperStyle.value).toBeUndefined();
});
test('mergedExtendState 优先使用 baseFormState 并调用 extendState', () => {
const extendState = vi.fn((s: any) => ({ ...s, x: 1 }));
const base = { a: 1 } as any;
const { captured } = mountHook({
category: 'node',
type: 'text',
value: {},
services,
extendState,
baseFormState: base,
});
const result = captured.mergedExtendState({ b: 2 });
expect(extendState).toHaveBeenCalledWith(base);
expect(result).toEqual({ a: 1, x: 1 });
});
test('mergedExtendState 无 extendState 时原样返回 state', () => {
const { captured } = mountHook({ category: 'node', type: 'text', value: {}, services });
const state = { a: 1 } as any;
expect(captured.mergedExtendState(state)).toBe(state);
});
test('自定义 loadConfig 可接管配置加载并复用 defaultLoadConfig', async () => {
const loadConfig = vi.fn(async ({ defaultLoadConfig }: any) => {
await defaultLoadConfig();
return [{ type: 'text', name: 'custom' }];
});
const { captured } = mountHook({ category: 'node', type: 'text', value: { id: 'n1' }, services, loadConfig });
await nextTick();
await nextTick();
await nextTick();
expect(loadConfig).toHaveBeenCalled();
expect(propsService.getPropsConfig).toHaveBeenCalled();
expect(captured.config.value).toEqual([{ type: 'text', name: 'custom' }]);
});
test('reload 会重新加载配置', async () => {
const { captured } = mountHook({ category: 'data-source', type: 'base', value: {}, services });
await nextTick();
await nextTick();
dataSourceService.getFormConfig.mockClear();
await captured.loadConfig();
expect(dataSourceService.getFormConfig).toHaveBeenCalled();
});
test('formRef.formState 注入 stage / services', async () => {
const stage = { select: vi.fn() };
editorService.get.mockReturnValue(stage);
const { captured } = mountHook({ category: 'node', type: 'text', value: {}, services });
await nextTick();
await nextTick();
expect(editorService.get).toHaveBeenCalledWith('stage');
expect(captured.formRef.value.formState.services).toBe(services);
expect(captured.formRef.value.formState.stage).toBe(stage);
});
});

View File

@ -18,6 +18,7 @@ import {
fillConfig,
getCondOpOptionsByFieldType,
numberOptions,
removeStyleDisplayConfig,
styleTabConfig,
validatePropsForm,
} from '@editor/utils/props';
@ -223,3 +224,51 @@ describe('validatePropsForm', () => {
expect(state).toEqual({ stage, services });
});
});
describe('removeStyleDisplayConfig', () => {
test('将 tab 内「样式」pane 的 display 置为 true', () => {
const config = [
{
type: 'tab',
items: [
{ title: '属性', items: [{ name: 'a' }] },
{ title: '样式', display: false, items: [{ name: 'color' }] },
],
},
] as any;
const result = removeStyleDisplayConfig(config) as any;
const stylePane = result[0].items.find((i: any) => i.title === '样式');
expect(stylePane.display).toBe(true);
// 非样式 pane 保持不变
expect(result[0].items.find((i: any) => i.title === '属性').display).toBeUndefined();
});
test('不修改入参,返回浅拷贝', () => {
const stylePane = { title: '样式', display: false, items: [{ name: 'color' }] };
const config = [{ type: 'tab', items: [stylePane] }] as any;
const result = removeStyleDisplayConfig(config) as any;
expect(result).not.toBe(config);
expect(result[0]).not.toBe(config[0]);
expect(stylePane.display).toBe(false);
});
test('非 tab 项 / 无 items 的项原样返回', () => {
const plain = { name: 'x', type: 'text' };
const tabWithoutItems = { type: 'tab' };
const noType = { foo: 'bar' };
const config = [plain, tabWithoutItems, noType] as any;
const result = removeStyleDisplayConfig(config) as any;
expect(result[0]).toBe(plain);
expect(result[1]).toBe(tabWithoutItems);
expect(result[2]).toBe(noType);
});
test('样式 pane 无 items 数组时不处理', () => {
const config = [{ type: 'tab', items: [{ title: '样式' }] }] as any;
const result = removeStyleDisplayConfig(config) as any;
expect(result[0].items[0].display).toBeUndefined();
});
});