From 6f3f321736a40b242e7b556b0bea456484f161ea Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 21 Jul 2026 21:18:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=8A=BD=E7=A6=BB=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E8=A7=86=E5=9B=BE=E4=B8=8E=E5=AF=B9=E6=AF=94=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/src/components/CompareForm.vue | 217 ++--------------- packages/editor/src/components/ViewForm.vue | 55 +++++ packages/editor/src/hooks/index.ts | 1 + packages/editor/src/hooks/use-compare-form.ts | 187 +++++++++++++++ packages/editor/src/index.ts | 1 + packages/editor/src/type.ts | 52 ++++ packages/editor/src/utils/props.ts | 29 +++ .../tests/unit/components/ViewForm.spec.ts | 172 ++++++++++++++ .../tests/unit/hooks/use-compare-form.spec.ts | 222 ++++++++++++++++++ .../tests/unit/utils/props-config.spec.ts | 49 ++++ 10 files changed, 783 insertions(+), 202 deletions(-) create mode 100644 packages/editor/src/components/ViewForm.vue create mode 100644 packages/editor/src/hooks/use-compare-form.ts create mode 100644 packages/editor/tests/unit/components/ViewForm.spec.ts create mode 100644 packages/editor/tests/unit/hooks/use-compare-form.spec.ts diff --git a/packages/editor/src/components/CompareForm.vue b/packages/editor/src/components/CompareForm.vue index d825c014..a0bb4ff9 100644 --- a/packages/editor/src/components/CompareForm.vue +++ b/packages/editor/src/components/CompareForm.vue @@ -19,107 +19,36 @@ diff --git a/packages/editor/src/hooks/index.ts b/packages/editor/src/hooks/index.ts index cbc1690d..c2cea8ce 100644 --- a/packages/editor/src/hooks/index.ts +++ b/packages/editor/src/hooks/index.ts @@ -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'; diff --git a/packages/editor/src/hooks/use-compare-form.ts b/packages/editor/src/hooks/use-compare-form.ts new file mode 100644 index 00000000..1884f49d --- /dev/null +++ b/packages/editor/src/hooks/use-compare-form.ts @@ -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; + currentValues: ComputedRef; + wrapperStyle: ComputedRef | undefined>; + mergedExtendState: (state: FormState) => Record | Promise>; + loadConfig: () => Promise; + formRef: Readonly | null>>; + normalizeCodeBlockValue: (v: Partial | Record | undefined) => Record; +} + +/** + * 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([]); + + /** vs-code 编辑器的 monaco 配置项,沿用 Editor 顶层 provide('codeOptions', ...) 的注入。 */ + const codeOptions = inject>('codeOptions', {}); + + /** 将代码块的 content 字段统一成字符串,便于在表单 / 对比中展示 */ + const normalizeCodeBlockValue = ( + v: Partial | Record | undefined, + ): Record => { + if (!v) return {}; + const next: Record = { ...v }; + if (next.content && typeof next.content !== 'string') { + try { + next.content = next.content.toString(); + } catch { + next.content = ''; + } + } + return next; + }; + + const currentValues = computed(() => { + if (props.category === 'code-block') { + return normalizeCodeBlockValue(props.value as Partial); + } + return (props.value || {}) as FormValue; + }); + + /** + * 外层包裹层的样式:当传入 `height` 时启用固定高度 + 内部滚动, + * 这样滚动条会出现在组件内部,避免父容器(如 Dialog)自身也产生滚动。 + */ + const wrapperStyle = computed(() => { + if (!props.height) return undefined; + const style: Record = { + 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 => { + 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>('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, + }; +}; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index dbf543b0..6e20e8e5 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -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'; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 926df798..2805630a 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -566,6 +566,58 @@ export interface CompareFormLoadConfigContext { * 可通过 `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。 */ export type CompareFormLoadConfig = (ctx: CompareFormLoadConfigContext) => FormConfig | Promise; + +/** + * CompareForm / ViewForm 共用的基础 props。 + * 两者都基于同一套「按 category 加载 FormConfig + 注入 services/stage」的逻辑(见 useCompareForm), + * 差异仅在于是否做新旧值对比。这里抽出公共字段避免重复定义。 + */ +export interface CompareFormBaseProps { + /** 当前值(对比场景下为修改后的值) */ + value: Partial | Partial | Partial | Record; + /** + * 类型说明: + * - `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 | Promise>; + /** + * 外部透传的基础 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 diff --git a/packages/editor/src/utils/props.ts b/packages/editor/src/utils/props.ts index 9109892c..dbef4557 100644 --- a/packages/editor/src/utils/props.ts +++ b/packages/editor/src/utils/props.ts @@ -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 参数 diff --git a/packages/editor/tests/unit/components/ViewForm.spec.ts b/packages/editor/tests/unit/components/ViewForm.spec.ts new file mode 100644 index 00000000..a0643931 --- /dev/null +++ b/packages/editor/tests/unit/components/ViewForm.spec.ts @@ -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 = {}; + +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; + 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(); + }); +}); diff --git a/packages/editor/tests/unit/hooks/use-compare-form.spec.ts b/packages/editor/tests/unit/hooks/use-compare-form.spec.ts new file mode 100644 index 00000000..8c3c7ea9 --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-compare-form.spec.ts @@ -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) => { + 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); + }); +}); diff --git a/packages/editor/tests/unit/utils/props-config.spec.ts b/packages/editor/tests/unit/utils/props-config.spec.ts index d41572d3..5de30fa4 100644 --- a/packages/editor/tests/unit/utils/props-config.spec.ts +++ b/packages/editor/tests/unit/utils/props-config.spec.ts @@ -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(); + }); +});