diff --git a/packages/design/src/Form.vue b/packages/design/src/Form.vue index 18b32cd3..52e32182 100644 --- a/packages/design/src/Form.vue +++ b/packages/design/src/Form.vue @@ -35,6 +35,16 @@ defineExpose({ return form.value?.validate(); }, + clearValidate(props?: string | string[]) { + if (typeof form.value?.clearValidate === 'function') { + return form.value?.clearValidate(props); + } + // tdesign 使用 clearValidate,element-plus 也是 clearValidate;此处兜底其它可能的命名 + if (typeof form.value?.clearValidateState === 'function') { + return form.value?.clearValidateState(); + } + }, + resetFields() { if (typeof form.value?.resetFields === 'function') { return form.value?.resetFields(); diff --git a/packages/editor/src/fields/EventSelect.vue b/packages/editor/src/fields/EventSelect.vue index 57454217..a2cac2e3 100644 --- a/packages/editor/src/fields/EventSelect.vue +++ b/packages/editor/src/fields/EventSelect.vue @@ -131,6 +131,7 @@ const eventNameConfig = computed(() => { } callback(); }, + trigger: 'blur', }, ], }; diff --git a/packages/editor/src/layouts/props-panel/FormPanel.vue b/packages/editor/src/layouts/props-panel/FormPanel.vue index cb98c7dd..9ae614dd 100644 --- a/packages/editor/src/layouts/props-panel/FormPanel.vue +++ b/packages/editor/src/layouts/props-panel/FormPanel.vue @@ -54,6 +54,7 @@ import MIcon from '@editor/components/Icon.vue'; import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps'; import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height'; import { useServices } from '@editor/hooks/use-services'; +import { validatePropsForm } from '@editor/utils/props'; import CodeEditor from '../CodeEditor.vue'; @@ -133,8 +134,33 @@ const errorHandler = (e: any) => { emit('form-error', e); }; -const saveCode = (values: any) => { - emit('submit', props.codeValueKey ? { [props.codeValueKey]: values } : values); +const saveCode = async (values: any) => { + const newValues = props.codeValueKey ? { [props.codeValueKey]: values } : values; + + if (!enablePropsFormValidate) { + // 未启用校验联动:保持原行为,直接提交源码保存的值 + emit('submit', newValues); + return; + } + + // 启用校验联动:源码编辑器保存的值未经过表单交互,这里另建一个独立的 MForm 实例对最新配置 + // 做一次静默校验(不复用、也不污染页面上正在展示的表单),并将校验结果(错误信息)随提交 + // 一并抛给上层记录,使源码保存的错误状态与表单编辑保持一致。 + try { + const error = await validatePropsForm({ + config: props.config, + values: newValues, + appContext: internalInstance?.appContext ?? null, + services, + stage: stage.value, + extendState: props.extendState, + }); + emit('submit', newValues, undefined, error ? new Error(error) : undefined); + } catch (e: any) { + console.log('validateForm error', e); + // 静默校验本身出现异常(如初始化超时)时,退回到不携带错误信息的提交,避免阻塞源码保存 + emit('submit', newValues); + } }; defineExpose({ configForm: configFormRef, submit }); diff --git a/packages/editor/src/utils/props.ts b/packages/editor/src/utils/props.ts index 5915f75a..a856d278 100644 --- a/packages/editor/src/utils/props.ts +++ b/packages/editor/src/utils/props.ts @@ -17,6 +17,8 @@ * limitations under the License. */ +import type { AppContext } from 'vue'; + import { HookType, NODE_CONDS_KEY, @@ -25,7 +27,18 @@ import { NODE_DISABLE_DATA_SOURCE_KEY, } from '@tmagic/core'; import { tMagicMessage } from '@tmagic/design'; -import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form'; +import type { + ChildConfig, + DisplayCondsConfig, + FormConfig, + FormState, + FormValue, + TabConfig, + TabPaneConfig, +} from '@tmagic/form'; +import { validateForm } from '@tmagic/form'; + +import type { Services } from '@editor/type'; export const arrayOptions = [ { text: '包含', value: 'include' }, @@ -75,7 +88,7 @@ export const getCondOpOptionsByFieldType = (type: string) => { export const styleTabConfig: TabPaneConfig = { title: '样式', lazy: true, - display: ({ services }: any) => !(services.uiService.get('showStylePanel') ?? true), + display: ({ services }: any) => !(services?.uiService?.get('showStylePanel') ?? true), items: [ { name: 'style', @@ -348,3 +361,83 @@ export const fillConfig = ( return [tabConfig]; }; + +// #region ValidatePropsFormOptions +/** + * validatePropsForm 参数 + */ +export interface ValidatePropsFormOptions { + /** 组件属性表单配置 */ + config: FormConfig; + /** 待校验的表单值 */ + values: FormValue; + /** + * 当前组件实例的 appContext(通常为 `getCurrentInstance()?.appContext`)。 + * 会与 services 一并合入临时 MForm 的 appContext,使编辑器字段组件(DataSourceInput 等)能正常 inject。 + */ + appContext?: AppContext | null; + /** 编辑器服务集合,注入到临时表单的 formState */ + services?: Services; + /** stage 实例,注入到临时表单的 formState */ + stage?: any; + /** 外部扩展的 formState */ + extendState?: (_state: FormState) => Record | Promise>; + /** + * 调试模式,默认 `true`:以弹层形式可见地渲染表单,点击「确定」才触发校验。 + * 置为 `false` 时静默挂载后自动校验。 + */ + debug?: boolean; +} +// #endregion ValidatePropsFormOptions + +/** + * 对一份「组件属性表单配置 + 值」做一次独立的校验,**不复用也不污染页面上正在展示的表单**。 + * + * 内部基于 `@tmagic/form` 的 `validateForm` 另建一个独立的 MForm 实例完成校验,并统一处理 + * 编辑器场景所需的上下文注入:将当前组件实例的 provides 合入 appContext,并向 formState 注入 + * stage / services 及外部扩展状态,保证校验规则依赖的上下文可用。 + * + * 常用于源码编辑器保存后,对最新配置做一次校验,并将校验结果(错误信息)随提交一并抛给上层记录, + * 使源码保存的错误状态与表单编辑保持一致。 + * + * @returns 校验通过返回空字符串 `''`,否则返回以 `
` 拼接的错误文案。 + * 仅在初始化超时或挂载失败等异常情况下才会 reject。 + * + * @example + * ```ts + * const error = await validatePropsForm({ + * config, + * values, + * appContext: getCurrentInstance()?.appContext, + * services, + * stage: editorService.get('stage'), + * extendState, + * }); + * if (error) { + * // 配置不合法,error 为错误文案 + * } + * ``` + */ +export const validatePropsForm = ({ + config, + values, + appContext = null, + services, + stage, + extendState, + debug, +}: ValidatePropsFormOptions): Promise => + validateForm({ + config, + debug, + initValues: values, + // 将当前组件实例的 provides(含 Editor 顶层的 services / codeOptions 等组件级 provide) + // 合入 appContext,使临时 MForm 中的编辑器字段组件(DataSourceInput 等)能正常 inject + appContext: appContext ? { ...appContext, provides: { services } } : null, + // 与页面表单保持一致:注入 stage/services 及外部扩展状态,保证校验规则依赖的上下文可用 + extendState: async (state) => ({ + ...((await extendState?.(state)) || {}), + stage, + services, + }), + }); 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 9c28566c..c258314b 100644 --- a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts @@ -64,11 +64,15 @@ vi.mock('@tmagic/design', () => ({ // 可控的 submitForm 实现:默认校验成功,测试可将其改为 reject 以模拟校验失败 let submitFormImpl: () => Promise = async () => ({ a: 1 }); +// 可控的静默校验 validateForm 实现:默认返回空字符串(校验通过) +let validateFormImpl: (_options?: any) => Promise = async () => ''; vi.mock('@tmagic/form', async () => { const actual = await vi.importActual('@tmagic/form'); return { ...actual, + // 源码保存后的静默校验走独立的 validateForm(内部新建 MForm 实例),此处 mock 便于断言 + validateForm: vi.fn((options?: any) => validateFormImpl(options)), MForm: defineComponent({ name: 'MForm', props: ['config', 'initValues', 'extendState'], @@ -95,6 +99,7 @@ vi.mock('@tmagic/form', async () => { beforeEach(() => { vi.clearAllMocks(); submitFormImpl = async () => ({ a: 1 }); + validateFormImpl = async () => ''; uiService.get.mockImplementation((k: string) => (k === 'propsPanelSize' ? 'small' : null)); editorService.get.mockImplementation((k: string) => (k === 'stage' ? { id: 'stage' } : null)); }); @@ -198,4 +203,102 @@ describe('FormPanel', () => { await wrapper.find('.fake-code-editor').trigger('click'); expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ foo: 'bar' }); }); + + test('未启用 enablePropsFormValidate 时源码保存不触发静默校验', async () => { + const validateSpy = vi.fn(async () => ''); + validateFormImpl = validateSpy; + const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any }); + await wrapper.find('.fake-btn').trigger('click'); + await wrapper.find('.fake-code-editor').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + expect(validateSpy).not.toHaveBeenCalled(); + // 仅携带值,不携带 error + expect(wrapper.emitted('submit')?.[0]).toEqual([{ foo: 'bar' }]); + }); + + test('启用 enablePropsFormValidate 时源码保存通过新建的 MForm 做静默校验(携带 config/initValues)', async () => { + const validateSpy = vi.fn(async () => ''); + validateFormImpl = validateSpy; + const wrapper = mount(FormPanel, { + props: { config: [], values: {}, codeValueKey: 'style' } as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await wrapper.find('.fake-btn').trigger('click'); + await wrapper.find('.fake-code-editor').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + expect(validateSpy).toHaveBeenCalledTimes(1); + // 以源码保存后的最新值作为待校验的 initValues + expect(validateSpy.mock.calls[0][0]).toMatchObject({ initValues: { style: { foo: 'bar' } } }); + }); + + test('源码保存时传给 validateForm 的 extendState 注入 services 和 stage', async () => { + const validateSpy = vi.fn(async () => ''); + validateFormImpl = validateSpy; + const wrapper = mount(FormPanel, { + props: { config: [], values: {} } as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await wrapper.find('.fake-btn').trigger('click'); + await wrapper.find('.fake-code-editor').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + const { extendState } = validateSpy.mock.calls[0][0]; + expect(typeof extendState).toBe('function'); + const result = await extendState({}); + expect(result).toHaveProperty('services'); + expect(result).toHaveProperty('stage'); + }); + + test('启用 enablePropsFormValidate 且源码保存静默校验通过时 submit 不携带 error', async () => { + validateFormImpl = async () => ''; + const wrapper = mount(FormPanel, { + props: { config: [], values: {} } as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await wrapper.find('.fake-btn').trigger('click'); + await wrapper.find('.fake-code-editor').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + const submitEvents = wrapper.emitted('submit'); + expect(submitEvents?.[0]?.[0]).toEqual({ foo: 'bar' }); + // eventData 为 undefined(源码保存),error 为 undefined(校验通过) + expect(submitEvents?.[0]?.[1]).toBeUndefined(); + expect(submitEvents?.[0]?.[2]).toBeUndefined(); + }); + + test('启用 enablePropsFormValidate 且源码保存静默校验失败时 submit 携带 error', async () => { + validateFormImpl = async () => '字段A -> 必填'; + const wrapper = mount(FormPanel, { + props: { config: [], values: {} } as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await wrapper.find('.fake-btn').trigger('click'); + await wrapper.find('.fake-code-editor').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + const submitEvents = wrapper.emitted('submit'); + expect(submitEvents?.[0]?.[0]).toEqual({ foo: 'bar' }); + expect(submitEvents?.[0]?.[1]).toBeUndefined(); + expect(submitEvents?.[0]?.[2]).toBeInstanceOf(Error); + expect((submitEvents?.[0]?.[2] as Error).message).toBe('字段A -> 必填'); + }); + + test('启用 enablePropsFormValidate 时静默校验抛异常则退回普通提交', async () => { + validateFormImpl = async () => { + throw new Error('validate 异常'); + }; + const wrapper = mount(FormPanel, { + props: { config: [], values: {} } as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await wrapper.find('.fake-btn').trigger('click'); + await wrapper.find('.fake-code-editor').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + const submitEvents = wrapper.emitted('submit'); + // 退回到仅携带值的提交 + expect(submitEvents?.[0]).toEqual([{ foo: 'bar' }]); + }); }); diff --git a/packages/editor/tests/unit/utils/props-config.spec.ts b/packages/editor/tests/unit/utils/props-config.spec.ts index 4cdd5ab3..a4114f3a 100644 --- a/packages/editor/tests/unit/utils/props-config.spec.ts +++ b/packages/editor/tests/unit/utils/props-config.spec.ts @@ -3,9 +3,10 @@ * * Copyright (C) 2025 Tencent. */ -import { describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { NODE_CONDS_RESULT_KEY } from '@tmagic/core'; +import { validateForm } from '@tmagic/form'; import { advancedTabConfig, @@ -18,6 +19,7 @@ import { getCondOpOptionsByFieldType, numberOptions, styleTabConfig, + validatePropsForm, } from '@editor/utils/props'; vi.mock('@tmagic/design', () => ({ @@ -27,6 +29,10 @@ vi.mock('@tmagic/design', () => ({ }, })); +vi.mock('@tmagic/form', () => ({ + validateForm: vi.fn(), +})); + describe('props 选项常量', () => { test('eqOptions / arrayOptions / numberOptions / booleanOptions 内容稳定', () => { expect(eqOptions.map((o) => o.value)).toEqual(['=', '!=']); @@ -164,3 +170,56 @@ describe('fillConfig', () => { }); }); }); + +describe('validatePropsForm', () => { + beforeEach(() => { + vi.mocked(validateForm).mockReset(); + }); + + test('合入 appContext.provides、注入 stage/services 并合并外部 extendState,debug 默认 true', async () => { + vi.mocked(validateForm).mockResolvedValue('err-text'); + + const services = { uiService: {} } as any; + const stage = { id: 'stage' }; + const appContext = { app: {}, provides: { foo: 1 } } as any; + + const result = await validatePropsForm({ + config: [], + values: { a: 1 }, + appContext, + services, + stage, + extendState: async () => ({ extra: true }), + }); + + // 直接返回 validateForm 的结果 + expect(result).toBe('err-text'); + + const arg = vi.mocked(validateForm).mock.calls[0][0]; + expect(arg.config).toEqual([]); + expect(arg.debug).toBe(true); + expect(arg.initValues).toEqual({ a: 1 }); + // appContext 保留原字段,但 provides 被替换为 { services } + expect(arg.appContext).toEqual({ app: {}, provides: { services } }); + + // extendState 合并外部返回并注入 stage/services + const state = await arg.extendState!({} as any); + expect(state).toEqual({ extra: true, stage, services }); + }); + + test('appContext 缺省为 null,extendState 缺省时仍注入 stage/services,可覆盖 debug', async () => { + vi.mocked(validateForm).mockResolvedValue(''); + + const services = {} as any; + const stage = {}; + + await validatePropsForm({ config: [], values: {}, services, stage, debug: false }); + + const arg = vi.mocked(validateForm).mock.calls[0][0]; + expect(arg.appContext).toBeNull(); + expect(arg.debug).toBe(false); + + const state = await arg.extendState!({} as any); + expect(state).toEqual({ stage, services }); + }); +}); diff --git a/packages/form/src/Form.vue b/packages/form/src/Form.vue index 89a1193e..2d94f893 100644 --- a/packages/form/src/Form.vue +++ b/packages/form/src/Form.vue @@ -32,7 +32,7 @@