diff --git a/docs/api/editor/props.md b/docs/api/editor/props.md index 4ee4c93b..6e682296 100644 --- a/docs/api/editor/props.md +++ b/docs/api/editor/props.md @@ -1311,6 +1311,8 @@ const guidesOptions = { 关闭时保持原行为:属性 / 样式表单校验失败则丢弃本次改动,不写入节点。 + 该开关同时决定属性 / 样式表单内部 `MForm` 的 `typeMatchValid`(按字段 type 校验值形态)与 `validateOnInit`(初始化后立即校验一次):开启时才一并开启,关闭时两者均为 `false`。 + :::tip 校验错误以「来源」为维度分别记录 —— 属性表单来源记为 `props`,样式表单来源记为 `style`;两者指向同一节点,互不覆盖。节点只要任一来源存在错误即视为出错。 diff --git a/docs/guide/advanced/prop-form-validate.md b/docs/guide/advanced/prop-form-validate.md index e656a7df..46721fa0 100644 --- a/docs/guide/advanced/prop-form-validate.md +++ b/docs/guide/advanced/prop-form-validate.md @@ -10,6 +10,8 @@ > 该能力默认关闭,需通过 `` 显式开启。 +开启后,属性 / 样式表单内部 `MForm` 的 `typeMatchValid`(按字段 type 校验值形态)与 `validateOnInit`(初始化后立即校验一次)也会一并开启;关闭时两者同样关闭。开关可动态变化,表单会同步生效。 + ## 工作原理 ### 错误来源维度 diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue index 79d6f423..3d0ea4fd 100644 --- a/packages/editor/src/Editor.vue +++ b/packages/editor/src/Editor.vue @@ -236,7 +236,12 @@ provide('services', services); provide('codeOptions', props.codeOptions); provide('stageOptions', stageOptions); /** 是否启用「属性配置表单校验」联动能力,供 PropsPanel / FormPanel 判断校验失败时是否仍更新节点并记录错误 */ -provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false); +// 同 isLargeStageContainer:用 computed 包一层,否则传下去的是 provide 那一刻的值快照, +// props.enablePropsFormValidate 后续变化不会同步到子孙。 +provide( + ENABLE_PROPS_FORM_VALIDATE, + computed(() => props.enablePropsFormValidate ?? false), +); /** * 编辑器注入给整棵表单树的业务上下文(`services` / `stage`),属性面板、对比表单、 * 侧边栏里的嵌套 MForm 都通过 inject 自动继承。 diff --git a/packages/editor/src/editorProps.ts b/packages/editor/src/editorProps.ts index 9e376b72..fa4b9595 100644 --- a/packages/editor/src/editorProps.ts +++ b/packages/editor/src/editorProps.ts @@ -1,4 +1,4 @@ -import type { InjectionKey } from 'vue'; +import type { ComputedRef, InjectionKey } from 'vue'; import type { DataSourceSchema, EventOption, Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core'; import type { FormConfig } from '@tmagic/form'; @@ -31,8 +31,11 @@ import type { /** * 「属性配置表单校验」联动能力的 provide/inject 注入键。 * 使用 Symbol 避免与其它字符串键冲突,供 PropsPanel / FormPanel 注入判断校验失败时是否仍更新节点并记录错误。 + * + * 注入值是 ComputedRef:Editor 用 `computed(() => props.enablePropsFormValidate ?? false)` 提供, + * 保证外部动态切换该 prop 时子孙组件能同步拿到最新值;子孙侧用 `.value` 读取。 */ -export const ENABLE_PROPS_FORM_VALIDATE: InjectionKey = Symbol('enablePropsFormValidate'); +export const ENABLE_PROPS_FORM_VALIDATE: InjectionKey> = Symbol('enablePropsFormValidate'); export interface EditorProps { /** 是否是大屏模拟器容器,大屏容器时,左侧属性面板会扩展为两列(正常3列),颜色表单不扩展两列等 */ diff --git a/packages/editor/src/layouts/props-panel/FormPanel.vue b/packages/editor/src/layouts/props-panel/FormPanel.vue index 0aab2ee8..4878ef12 100644 --- a/packages/editor/src/layouts/props-panel/FormPanel.vue +++ b/packages/editor/src/layouts/props-panel/FormPanel.vue @@ -12,9 +12,9 @@ :size="propsPanelSize" :init-values="values" :config="config" - :type-match-valid="true" + :type-match-valid="enablePropsFormValidate" :context="formContext" - :validate-on-init="true" + :validate-on-init="enablePropsFormValidate" @change="submit" @error="errorHandler" > @@ -86,7 +86,10 @@ const emit = defineEmits<{ unmounted: []; }>(); -const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false); +const enablePropsFormValidate = inject( + ENABLE_PROPS_FORM_VALIDATE, + computed(() => false), +); const services = useServices(); const { uiService } = services; @@ -116,7 +119,7 @@ const submit = async (v: FormValue, eventData: ContainerChangeEventData) => { // 校验成功:正常更新节点(第三个参数 error 为空,表示清除该来源的错误记录) emit('submit', values, eventData); } catch (e: any) { - if (enablePropsFormValidate) { + if (enablePropsFormValidate.value) { // 启用校验联动:校验失败时仍以当前表单值更新节点,并把错误信息一并抛给上层记录 emit('submit', v, eventData, e); } else { @@ -146,7 +149,7 @@ const formatValidateErrorHtml = (error: string): string => const saveCode = async (values: any) => { const newValues = props.codeValueKey ? { [props.codeValueKey]: values } : values; - if (!enablePropsFormValidate) { + if (!enablePropsFormValidate.value) { // 未启用校验联动:保持原行为,直接提交源码保存的值 emit('submit', newValues); return; diff --git a/packages/editor/src/layouts/props-panel/PropsPanel.vue b/packages/editor/src/layouts/props-panel/PropsPanel.vue index 8d4491e0..474236a6 100644 --- a/packages/editor/src/layouts/props-panel/PropsPanel.vue +++ b/packages/editor/src/layouts/props-panel/PropsPanel.vue @@ -93,7 +93,10 @@ const emit = defineEmits<{ const { editorService, uiService, propsService, storageService } = useServices(); -const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false); +const enablePropsFormValidate = inject( + ENABLE_PROPS_FORM_VALIDATE, + computed(() => false), +); const values = ref({}); // ts类型应该是FormConfig, 但是打包时会出错,所以暂时用any @@ -205,7 +208,9 @@ const submit = async ( replace, // 启用校验联动时,仅校验失败(error 存在)才把错误信息随更新传入 editorService 记录; // 其余情况(含表单校验成功、CodeEditor 源码保存)不携带 invalidInfo,由 editorService 在执行 update 时统一清除该节点错误。 - ...(enablePropsFormValidate && error ? { invalidInfo: { id: newValue.id, source, error: error?.message } } : {}), + ...(enablePropsFormValidate.value && error + ? { invalidInfo: { id: newValue.id, source, error: error?.message } } + : {}), }); } catch (e: any) { emit('submit-error', e); diff --git a/packages/editor/tests/unit/Editor.spec.ts b/packages/editor/tests/unit/Editor.spec.ts index b30cb0a1..9b6d8af3 100644 --- a/packages/editor/tests/unit/Editor.spec.ts +++ b/packages/editor/tests/unit/Editor.spec.ts @@ -10,8 +10,10 @@ import { mount } from '@vue/test-utils'; import { FORM_CONTEXT_KEY, type FormContext } from '@tmagic/form'; import Editor from '@editor/Editor.vue'; +import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps'; let injectedFormContext: ComputedRef | undefined; +let injectedEnablePropsFormValidate: ComputedRef | undefined; const { initServiceEventsMock, initServiceStateMock } = vi.hoisted(() => ({ initServiceEventsMock: vi.fn(), @@ -107,6 +109,10 @@ vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({ emits: ['mounted', 'unmounted', 'submit-error', 'form-error'], setup(_p, { emit }) { injectedFormContext = inject(FORM_CONTEXT_KEY, undefined); + injectedEnablePropsFormValidate = inject( + ENABLE_PROPS_FORM_VALIDATE, + computed(() => false), + ); return () => h('div', { class: 'fake-props-panel' }, [ h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }), @@ -125,6 +131,7 @@ vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({ beforeEach(() => { vi.clearAllMocks(); injectedFormContext = undefined; + injectedEnablePropsFormValidate = undefined; }); describe('Editor', () => { @@ -246,6 +253,24 @@ describe('Editor', () => { editorServiceMod.default.__setStage(stageStub); }); + test('ENABLE_PROPS_FORM_VALIDATE 默认注入 false', async () => { + mount(Editor, { props: {} as any }); + await nextTick(); + expect(injectedEnablePropsFormValidate?.value).toBe(false); + }); + + test('ENABLE_PROPS_FORM_VALIDATE 走 computed 读时求值,prop 变化后子孙能读到最新值', async () => { + const wrapper = mount(Editor, { props: { enablePropsFormValidate: false } as any }); + await nextTick(); + expect(injectedEnablePropsFormValidate?.value).toBe(false); + + await wrapper.setProps({ enablePropsFormValidate: true }); + expect(injectedEnablePropsFormValidate?.value).toBe(true); + + await wrapper.setProps({ enablePropsFormValidate: false }); + expect(injectedEnablePropsFormValidate?.value).toBe(false); + }); + test('expose services', () => { const wrapper = mount(Editor, { props: {} as any }); expect((wrapper.vm as any).editorService).toBeDefined(); diff --git a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts index 57bae94f..11881aa0 100644 --- a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts @@ -4,7 +4,7 @@ * Copyright (C) 2025 Tencent. */ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { computed, defineComponent, h, nextTick } from 'vue'; +import { computed, defineComponent, h, nextTick, ref } from 'vue'; import { mount } from '@vue/test-utils'; import { FORM_CONTEXT_KEY } from '@tmagic/form'; @@ -78,7 +78,7 @@ vi.mock('@tmagic/form', async () => { validateForm: vi.fn((options?: any) => validateFormImpl(options)), MForm: defineComponent({ name: 'MForm', - props: ['config', 'initValues', 'context'], + props: ['config', 'initValues', 'context', 'typeMatchValid', 'validateOnInit'], emits: ['change', 'error'], setup(_p, { expose, emit }) { const formState = { stage: null as any, services: null as any }; @@ -171,7 +171,7 @@ describe('FormPanel', () => { }; const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await wrapper.find('.change-btn').trigger('click'); await new Promise((r) => setTimeout(r, 0)); @@ -188,7 +188,7 @@ describe('FormPanel', () => { test('校验成功时 emit submit 且不携带 error', async () => { const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await wrapper.find('.change-btn').trigger('click'); await new Promise((r) => setTimeout(r, 0)); @@ -245,7 +245,7 @@ describe('FormPanel', () => { validateFormImpl = validateSpy; const wrapper = mount(FormPanel, { props: { config: [], values: {}, codeValueKey: 'style' } as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await wrapper.find('.fake-btn').trigger('click'); await wrapper.find('.fake-code-editor').trigger('click'); @@ -261,7 +261,7 @@ describe('FormPanel', () => { validateFormImpl = validateSpy; const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await wrapper.find('.fake-btn').trigger('click'); await wrapper.find('.fake-code-editor').trigger('click'); @@ -276,7 +276,7 @@ describe('FormPanel', () => { validateFormImpl = async () => ''; const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await wrapper.find('.fake-btn').trigger('click'); await wrapper.find('.fake-code-editor').trigger('click'); @@ -293,7 +293,7 @@ describe('FormPanel', () => { validateFormImpl = async () => '字段A -> 必填'; const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await wrapper.find('.fake-btn').trigger('click'); await wrapper.find('.fake-code-editor').trigger('click'); @@ -314,7 +314,7 @@ describe('FormPanel', () => { }; const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await wrapper.find('.fake-btn').trigger('click'); await wrapper.find('.fake-code-editor').trigger('click'); @@ -329,4 +329,41 @@ describe('FormPanel', () => { expect(tMagicMessage).not.toHaveBeenCalled(); consoleError.mockRestore(); }); + + test('未启用 enablePropsFormValidate 时 MForm 不开启 typeMatchValid / validateOnInit', () => { + const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any }); + + const mForm = wrapper.findComponent({ name: 'MForm' }); + expect(mForm.props('typeMatchValid')).toBe(false); + expect(mForm.props('validateOnInit')).toBe(false); + }); + + test('启用 enablePropsFormValidate 时 MForm 开启 typeMatchValid / validateOnInit', () => { + const wrapper = mount(FormPanel, { + props: { config: [], values: {} } as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, + }); + + const mForm = wrapper.findComponent({ name: 'MForm' }); + expect(mForm.props('typeMatchValid')).toBe(true); + expect(mForm.props('validateOnInit')).toBe(true); + }); + + test('enablePropsFormValidate 变化时 MForm 的 typeMatchValid / validateOnInit 同步更新', async () => { + const enabled = ref(false); + const wrapper = mount(FormPanel, { + props: { config: [], values: {} } as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => enabled.value) } }, + }); + + const mForm = wrapper.findComponent({ name: 'MForm' }); + expect(mForm.props('typeMatchValid')).toBe(false); + expect(mForm.props('validateOnInit')).toBe(false); + + enabled.value = true; + await nextTick(); + + expect(mForm.props('typeMatchValid')).toBe(true); + expect(mForm.props('validateOnInit')).toBe(true); + }); }); diff --git a/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts b/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts index 075602c8..85060177 100644 --- a/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts @@ -4,7 +4,7 @@ * Copyright (C) 2025 Tencent. */ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { defineComponent, h, nextTick, ref } from 'vue'; +import { computed, defineComponent, h, nextTick, ref } from 'vue'; import { mount } from '@vue/test-utils'; import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps'; @@ -193,7 +193,7 @@ describe('PropsPanel', () => { test('启用 enablePropsFormValidate 时属性表单校验失败携带 invalidInfo(source=props)', async () => { const wrapper = mount(PropsPanel, { props: {} as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await new Promise((r) => setTimeout(r, 0)); await wrapper.find('.submit-with-err-btn').trigger('click'); @@ -205,7 +205,7 @@ describe('PropsPanel', () => { test('启用 enablePropsFormValidate 且校验成功时不携带 invalidInfo(保持错误状态不变)', async () => { const wrapper = mount(PropsPanel, { props: {} as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await new Promise((r) => setTimeout(r, 0)); await wrapper.find('.submit-btn').trigger('click'); @@ -218,7 +218,7 @@ describe('PropsPanel', () => { showStylePanel.value = true; const wrapper = mount(PropsPanel, { props: {} as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await new Promise((r) => setTimeout(r, 0)); // 第二个 FormPanel 为样式面板 @@ -233,7 +233,7 @@ describe('PropsPanel', () => { test('CodeEditor 源码保存不携带 invalidInfo(未经表单校验,不应改动错误状态)', async () => { const wrapper = mount(PropsPanel, { props: {} as any, - global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: computed(() => true) } }, }); await new Promise((r) => setTimeout(r, 0)); await wrapper.find('.code-save-btn').trigger('click');