feat(form): 用 FormContext 与 provide/inject 替代 extendState 钩子

统一表单业务上下文的注入方式,通过读穿 Proxy 保持 mForm 回调签名不变,并移除 extendFormState/extendState 等分散的扩展钩子。
This commit is contained in:
roymondchen 2026-09-01 14:48:42 +08:00
parent f2ee7ae7b5
commit a7999f50d5
44 changed files with 955 additions and 697 deletions

View File

@ -1558,52 +1558,71 @@ const onLayerNodeDblclick = (event, data) => {
- 返回 `false` 时,会同时阻断默认的"展开/收起"行为以及向上抛出的 [`layer-node-dblclick`](./events.md#layer-node-dblclick) 事件;返回其他值则继续触发默认行为并抛出事件。
:::
## extendFormState
## 表单业务上下文
- **详情:**
扩展表单状态
用于在属性表单中注入自定义的状态数据,这些数据可以在表单配置的各个字段为函数时的第一个参数中获取
编辑器会把 `services` 与当前 `stage` 通过 `FORM_CONTEXT_KEY` provide 下去,编辑器内所有 `MForm`(属性面板、历史差异对比表单、侧边栏、以及 Link / FormBox 里的嵌套子表单)都自动继承。
- **默认值:** `undefined`
- **类型:** `(state: FormState) => Record<string, any> | Promise<Record<string, any>>`
- **示例:**
业务方要往里追加自己的字段,在 `<m-editor>` 外层再 provide 一层即可,同名字段覆盖内置的:
```html
<template>
<m-editor :extend-form-state="extendFormState"></m-editor>
<m-editor></m-editor>
</template>
<script setup>
const extendFormState = async (state) => {
// 返回自定义的状态数据
return {
// 可以是同步数据
currentUser: {
name: 'Admin',
role: 'admin',
},
// 也可以是异步获取的数据
projectConfig: await fetchProjectConfig(),
};
};
import { computed, provide } from 'vue';
import { FORM_CONTEXT_KEY } from '@tmagic/form';
provide(
FORM_CONTEXT_KEY,
computed(() => ({
currentUser: store.currentUser,
env: store.env,
})),
);
</script>
```
:::tip
扩展的状态可以在表单配置中通过 `state` 访问,例如:
补类型用模块增强:
```ts
declare module '@tmagic/form-schema' {
interface FormContext {
currentUser?: { name: string; role: string };
}
}
```
表单配置里统一从第一个参数 `mForm`formState 是读穿 Proxy`mForm` 上没有的字段自动落到 context所以回调签名不变后端下发的存量配置无需改动。
```js
{
name: 'title',
text: '标题',
// 根据扩展的状态动态设置
disabled: (state) => state.currentUser.role !== 'admin',
disabled: (mForm) => mForm.currentUser?.role !== 'admin',
display: (mForm) => mForm.env === 'prod',
}
```
::: warning 从 extendFormState 迁移
`extendFormState` prop 已移除,同时移除的还有 `PropsPanel` / `FormPanel` / `HistoryDiffDialog` / `CompareForm` / `ViewForm``extendState``CompareForm``baseFormState``useHistoryRevert``extendState``getPropsPanelFormState`
改法是把「返回数据的函数」换成「数据本身」:
```ts
// before
const extendFormState = (state) => ({ env: store.env });
// <m-editor :extend-form-state="extendFormState" />
// after
provide(FORM_CONTEXT_KEY, computed(() => ({ env: store.env })));
```
原先返回 Promise 的写法,改由宿主自己决定挂载时机(`v-if="ready"`),或先 provide 一个空 context、数据到位后更新——后者不保证 `defaultValue` / `onInitValue` 首轮能读到。
配置里的 `mForm.xxx` 读法不受影响,读穿 Proxy 保留。
:::
## historyListExtraTabs

View File

@ -219,8 +219,25 @@
- **类型:** `boolean`
## extendState
## context
- **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上
- **详情:** 宿主业务上下文。可用本 prop 直接传,也可由祖先 `provide(FORM_CONTEXT_KEY)` 下发;同名字段本 prop 优先。
嵌套表单Link 的子表单、`MFormBox``MFormDialog`)会自动继承最近祖先的 context不需要层层透传。
配置回调统一通过第一个参数 `mForm` 读取formState 是一个读穿 Proxy`mForm` 上找不到的字段会自动落到 context。回调签名因此保持不变后端 eval 下发的存量配置无需改动。
- **类型:** `FormContext`
- **示例:**
```ts
// 模板:<m-form :context="formContext" />
const formContext = computed(() => ({ username: store.username }));
// 配置回调mForm.username 读穿到 context
{
display: (mForm) => mForm.username === 'admin',
}
```
- **类型:** `(state: FormState) => Record<string, any> | Promise<Record<string, any>>`

View File

@ -131,7 +131,7 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
| `popperClass` | `string` | — | 弹层 className |
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
| `useFieldTextInError` | `boolean` | `true` | 校验失败时错误提示前缀是否使用字段的 `text` 文案;`false` 时直接使用字段 `name` |
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
| `context` | `FormContext` | — | 宿主业务上下文,与 MForm 的 `context` 语义一致;配置回调通过 `mForm.xxx` 读穿取用 |
| `native` | `boolean` | `false` | 透传给 `Form.submitForm``true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。仅 `dialog: true` 时生效用于继承全局组件、指令、provide 等,常通过 `app._context``getCurrentInstance()?.appContext` 获取 |

View File

@ -101,7 +101,6 @@ onCodeBlockDiff(id, index);
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `appContext` | 否 | 父级应用上下文,用于让动态挂载的差异确认弹窗继承全局组件 / 指令 / provide / 插件Element Plus、`@tmagic/form` 字段组件等)。在组件 `setup` 中调用时会自动取当前组件的 `appContext`,无需手动传;仅当在组件 setup 之外调用时才需显式传入(如 `editorApp._context`)。 |
| `extendState` | 否 | 透传给差异确认弹窗的 `extendState`(同 Editor 的 [`extendFormState`](#自定义对比判断)),使对比表单中依赖业务上下文的 `display` / `disabled``filterFunction` 正常工作。 |
| `dialogWidth` | 否 | 内置页面 / 数据源 / 代码块的差异 / 回滚确认弹窗默认宽度(透传给 `TMagicDialog``width`),如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(`900px`)。业务自有历史可在 `viewDiff` / `confirmAndRevert` 调用时通过各自入参的 `width` 单独覆盖。 |
> 若只需要无确认、无校验的静默回滚,直接用上面的 `editorService.revertPageStep` 等即可,无需 `useHistoryRevert`
@ -164,7 +163,7 @@ const historyListExtraTabs = [
## 自定义对比判断
差异对话框中的「表单对比」最终透传到 `MForm`,你可以通过 Editor 顶层注入的 `extendFormState` 让对比表单拿到完整业务上下文,从而让依赖上下文的 `display` / `disabled``filterFunction` 正常工作
差异对话框中的「表单对比」最终透传到 `MForm`。Editor 会把 `services` / `stage` provide 为 `FORM_CONTEXT_KEY`,对比表单自动继承;业务字段在 `<m-editor>` 外层再 provide 一层即可,详见 [表单业务上下文](/api/editor/props.html#表单业务上下文)。配置回调通过 `mForm.xxx` 读穿取用
若某些字段语义上相等但结构不同(例如 `code-select` 字段中 `''``{ hookType: 'code', hookData: [] }` 应视为相等),可借助 `@tmagic/form` 的 [`showDiff`](/api/form/form-props.html#showdiff) 自定义判断函数避免被误判为差异。

View File

@ -101,7 +101,6 @@
<template #props-panel>
<slot name="props-panel">
<PropsPanel
:extend-state="extendFormState"
:disabled-show-src="disabledShowSrc"
@mounted="propsPanelMountedHandler"
@unmounted="propsPanelUnmountedHandler"
@ -136,11 +135,13 @@
<script lang="ts" setup>
import { EventEmitter } from 'events';
import { computed, provide, ref } from 'vue';
import { computed, provide } from 'vue';
import type { MApp } from '@tmagic/core';
import { M_THEME_KEY } from '@tmagic/design';
import { FORM_CONTEXT_KEY } from '@tmagic/form';
import { useEditorFormContext } from './hooks/use-form-context';
import Framework from './layouts/Framework.vue';
import TMagicNavMenu from './layouts/NavMenu.vue';
import FormPanel from './layouts/props-panel/FormPanel.vue';
@ -230,8 +231,6 @@ const stageOptions: StageOptions = {
stageOverlayService.set('stageOptions', stageOptions);
const propsPanelRef = ref<InstanceType<typeof FormPanel> | null>(null);
provide('services', services);
provide('codeOptions', props.codeOptions);
@ -239,11 +238,16 @@ provide('stageOptions', stageOptions);
/** 是否启用「属性配置表单校验」联动能力,供 PropsPanel / FormPanel 判断校验失败时是否仍更新节点并记录错误 */
provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false);
/**
* 把顶层 `extendFormState` 提供给非 PropsPanel 链路上的组件使用例如历史差异对话框 HistoryDiffDialog
* 内部的 CompareForm这样所有依赖业务上下文的表单 filterFunction 都能拿到一致的扩展状态
* PropsPanel 通过 `:extend-state` 显式传入的方式保持等价
* 编辑器注入给整棵表单树的业务上下文`services` / `stage`属性面板对比表单
* 侧边栏里的嵌套 MForm 都通过 inject 自动继承
*
* 业务方要追加自己的字段 `<MEditor>` 外层再 `provide(FORM_CONTEXT_KEY, computed(...))`
* 即可配置回调统一通过 `mForm.xxx` 读取
*/
provide('extendFormState', props.extendFormState);
provide(
FORM_CONTEXT_KEY,
useEditorFormContext(() => services),
);
// computed provide provide
// props.isLargeStageContainer @tmagic/design/ColorPicker
@ -253,12 +257,6 @@ provide(
'isLargeStageContainer',
computed(() => props.isLargeStageContainer),
);
/**
* 提供 PropsPanel 主属性表单的 formState getter供历史差异弹窗复用
* CompareForm PropsPanel filterFunction 上下文保持一致
*/
provide('getPropsPanelFormState', () => propsPanelRef.value?.configForm?.formState);
/**
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel它挂在 NavMenu
* markRaw component 形式渲染无法直接通过 props 透传业务方可借此在历史记录
@ -279,11 +277,9 @@ provide(
);
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
propsPanelRef.value = e;
emit('props-panel-mounted', e);
};
const propsPanelUnmountedHandler = () => {
propsPanelRef.value = null;
emit('props-panel-unmounted');
};

View File

@ -10,7 +10,7 @@
:is-compare="true"
:disabled="true"
:label-width="labelWidth"
:extend-state="mergedExtendState"
:context="formContext"
:show-diff="showDiff"
:self-diff-field-types="selfDiffFieldTypes"
:size="size"
@ -47,7 +47,7 @@ const props = withDefaults(
},
);
const { config, currentValues, wrapperStyle, mergedExtendState, loadConfig, formRef, normalizeCodeBlockValue } =
const { config, currentValues, wrapperStyle, formContext, loadConfig, formRef, normalizeCodeBlockValue } =
useCompareForm(props);
const lastValuesProcessed = computed<FormValue>(() => {

View File

@ -8,7 +8,7 @@
:init-values="currentValues"
:disabled="disabled"
:label-width="labelWidth"
:extend-state="mergedExtendState"
:context="formContext"
:size="size"
></MForm>
</div>
@ -37,11 +37,10 @@ const props = withDefaults(
category: 'node',
labelWidth: '120px',
disabled: true,
// extendState useCompareForm props.extendState ?? ...
},
);
const { config, currentValues, wrapperStyle, mergedExtendState, loadConfig, formRef } = useCompareForm(props);
const { config, currentValues, wrapperStyle, formContext, loadConfig, formRef } = useCompareForm(props);
defineExpose<{
form: ShallowRef<InstanceType<typeof MForm> | null>;

View File

@ -1,7 +1,7 @@
import type { InjectionKey } from 'vue';
import type { DataSourceSchema, EventOption, Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
import type { FormConfig, FormState } from '@tmagic/form';
import type { FormConfig } from '@tmagic/form';
import StageCore, {
CONTAINER_HIGHLIGHT_CLASS_NAME,
ContainerHighlightType,
@ -149,7 +149,6 @@ export interface EditorProps {
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 历史记录面板的自定义扩展 tab追加在内置的页面/数据源/代码块 tab 之后 */
historyListExtraTabs?: HistoryListExtraTab[];
/** 页面顺序拖拽配置参数 */

View File

@ -124,7 +124,7 @@ export const createDisplayCondsConfig = (
copyable: true,
movable: false,
flat: true,
labelWidth: 80,
labelWidth: '80px',
...stickyAddButton('新增条件'),
items: [
fieldItem,

View File

@ -16,11 +16,12 @@
* limitations under the License.
*/
import { computed, type ComputedRef, inject, provide, type Ref, ref, useTemplateRef, watch, watchEffect } from 'vue';
import { computed, type ComputedRef, inject, provide, type Ref, ref, useTemplateRef, watch } from 'vue';
import type { CodeBlockContent, MNode } from '@tmagic/core';
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
import { type FormConfig, type FormContext, type FormValue, MForm } from '@tmagic/form';
import { useEditorFormContext } from '@editor/hooks/use-form-context';
import type { CompareFormBaseProps } from '@editor/type';
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
@ -28,7 +29,7 @@ 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>>;
formContext: ComputedRef<FormContext>;
loadConfig: () => Promise<void>;
formRef: Readonly<Ref<InstanceType<typeof MForm> | null>>;
normalizeCodeBlockValue: (v: Partial<CodeBlockContent> | Record<string, any> | undefined) => Record<string, any>;
@ -39,7 +40,7 @@ export interface UseCompareFormReturn {
* - `category`(node / data-source / code-block) FormConfig `loadConfig`
* - `content`
* - + `wrapperStyle`
* - services / stage MForm.formState filterFunction
* - services / stage form context MForm filterFunction
*
*
*/
@ -87,10 +88,7 @@ export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormRetur
return style;
});
const mergedExtendState = (state: FormState) => {
const extendState = props.extendState ?? ((s: FormState) => s);
return extendState(props.baseFormState || state);
};
const formContext = useEditorFormContext(() => props.services);
/**
* FormConfig `category` service /
@ -156,27 +154,11 @@ export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormRetur
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,
formContext,
loadConfig,
formRef,
normalizeCodeBlockValue,

View File

@ -0,0 +1,40 @@
/*
* 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 } from 'vue';
import type { FormContext } from '@tmagic/form';
import type { Services } from '@editor/type';
/**
* `services` `stage`
*
* `Editor.vue`provide `FORM_CONTEXT_KEY``FormPanel.vue` `useCompareForm`
* `stage` computed
*
* `@editor/type` `@tmagic/form-schema` `FormContext`
*/
export const useEditorFormContext = (getServices: () => Services | undefined): ComputedRef<FormContext> =>
computed(() => {
const services = getServices();
return {
services,
stage: services?.editorService.get('stage'),
};
});

View File

@ -43,8 +43,6 @@
:data-source-type="payload.dataSourceType"
:value="rightValue"
:last-value="leftValue"
:base-form-state="compareFormState"
:extend-state="extendState"
:load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes"
:services="props.services"
@ -86,7 +84,6 @@ import {
TMagicRadioGroup,
TMagicTag,
} from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import CompareForm from '@editor/components/CompareForm.vue';
import CodeEditor from '@editor/layouts/CodeEditor.vue';
@ -100,12 +97,6 @@ const props = withDefaults(
defineProps<{
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
services?: Services;
/**
* 来自 Editor 顶层的 `extendFormState`用于扩展 MForm.formState
* 透传给 CompareForm从而让差异对比时表单 item 中依赖业务上下文的
* `display` / `disabled` filterFunction 正常工作
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* 自定义 FormConfig 加载逻辑透传给 CompareForm传入后将接管内置的按 `category`
* 取配置逻辑可通过 `ctx.defaultLoadConfig()` 复用默认结果再做二次加工
@ -117,7 +108,6 @@ const props = withDefaults(
isConfirm?: boolean;
onConfirm?: () => void;
selfDiffFieldTypes?: string[];
compareFormState?: FormState;
}>(),
{
width: '900px',

View File

@ -133,7 +133,6 @@ import { computed, inject, markRaw, ref, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue';
import { getDesignConfig, TMagicButton, tMagicMessage, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
@ -193,17 +192,6 @@ watch([disabledDataSource, disabledCodeBlock], ([dsDisabled, cbDisabled]) => {
}
});
/**
* 通过 inject 拿到 Editor 顶层注入的 `extendFormState`转交给 HistoryDiffDialog
* 内部的 CompareForm使差异对比表单的 filterFunction 能拿到完整的业务上下文
* 未提供时为 undefinedCompareForm/MForm 会跳过 extendState 处理
*/
const extendFormState = inject<((_state: FormState) => Record<string, any> | Promise<Record<string, any>>) | undefined>(
'extendFormState',
undefined,
);
const getPropsPanelFormState = inject<(() => FormState | undefined) | undefined>('getPropsPanelFormState', undefined);
const {
expanded,
toggleGroup,
@ -316,7 +304,7 @@ const onCodeBlockGotoInitial = (id: string | number) => {
* 业务方亦可直接 import useHistoryRevert(options, services) 调用无需自行挂载任何弹窗
*/
const { onPageRevert, onDataSourceRevert, onCodeBlockRevert, onPageDiff, onDataSourceDiff, onCodeBlockDiff } =
useHistoryRevert({ extendState: extendFormState, getPropsPanelFormState }, services);
useHistoryRevert({}, services);
/**
* 把内存中已清空对应类别后的历史状态重新写回 IndexedDB

View File

@ -74,7 +74,7 @@ interface MountedDiffDialog {
* import
*/
const mountHistoryDiffDialog = async (
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
options: Pick<UseHistoryRevertOptions, 'appContext'> &
CustomDiffFormOptions & {
services?: Services;
isConfirm?: boolean;
@ -90,10 +90,8 @@ const mountHistoryDiffDialog = async (
const app = createApp(historyDiffDialog, {
services: options.services,
isConfirm: options.isConfirm,
extendState: options.extendState,
loadConfig: options.loadConfig,
selfDiffFieldTypes: options.selfDiffFieldTypes,
compareFormState: options.compareFormState,
width: options.width,
size: options.size ?? options.services?.uiService?.get('propsPanelSize'),
onClose: options.onClose,
@ -125,7 +123,7 @@ const mountHistoryDiffDialog = async (
*/
const confirmRevertWithDiffDialog = async (
payload: DiffDialogPayload,
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
options: Pick<UseHistoryRevertOptions, 'appContext'> &
CustomDiffFormOptions & {
services?: Services;
},
@ -147,7 +145,7 @@ const confirmRevertWithDiffDialog = async (
*/
const viewHistoryDiffDialog = async (
payload: DiffDialogPayload,
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
options: Pick<UseHistoryRevertOptions, 'appContext'> &
CustomDiffFormOptions & {
services?: Services;
},
@ -194,7 +192,7 @@ const viewHistoryDiffDialog = async (
export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services?: Services) => {
// 自动捕获调用方所在组件的 appContext在 setup 中调用时),业务方亦可显式覆盖。
const appContext = options.appContext ?? getCurrentInstance()?.appContext ?? null;
const { extendState, getPropsPanelFormState, dialogWidth } = options;
const { dialogWidth } = options;
/** 目标数据已被删除、无法回滚时的统一提示。 */
const showRevertTargetMissing = () => {
@ -220,7 +218,6 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
if (payload) {
return confirmRevertWithDiffDialog(payload, {
appContext,
extendState,
services,
...extra,
width: extra?.width ?? dialogWidth,
@ -307,7 +304,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildPageDiffPayload(index), { compareFormState: getPropsPanelFormState?.() }).then((result) =>
return runRevert(buildPageDiffPayload(index), {}).then((result) =>
result ? services?.editorService.revertPageStep(index) : null,
);
};
@ -341,22 +338,20 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
if (payload) {
return viewHistoryDiffDialog(payload, {
appContext,
extendState,
services,
width: dialogWidth,
compareFormState: getPropsPanelFormState?.(),
});
}
};
const onDataSourceDiff = (id: string | number, index: number): Promise<void> | void => {
const payload = buildDataSourceDiffPayload(id, index);
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
if (payload) return viewHistoryDiffDialog(payload, { appContext, services, width: dialogWidth });
};
const onCodeBlockDiff = (id: string | number, index: number): Promise<void> | void => {
const payload = buildCodeBlockDiffPayload(id, index);
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
if (payload) return viewHistoryDiffDialog(payload, { appContext, services, width: dialogWidth });
};
/**
@ -398,7 +393,6 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
if (payload)
return viewHistoryDiffDialog(payload, {
appContext,
extendState,
services,
...extra,
width: extra?.width ?? dialogWidth,

View File

@ -13,7 +13,7 @@
:init-values="values"
:config="config"
:type-match-valid="true"
:extend-state="extendState"
:context="formContext"
:validate-on-init="true"
@change="submit"
@error="errorHandler"
@ -45,17 +45,18 @@
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, inject, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue';
import { computed, getCurrentInstance, inject, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import { Document as DocumentIcon } from '@element-plus/icons-vue';
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
import type { ContainerChangeEventData, FormConfig, FormValue } from '@tmagic/form';
import { MForm, validateForm } from '@tmagic/form';
import { filterXSS } from '@tmagic/utils';
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 { useEditorFormContext } from '@editor/hooks/use-form-context';
import { useServices } from '@editor/hooks/use-services';
import CodeEditor from '../CodeEditor.vue';
@ -75,7 +76,6 @@ const props = defineProps<{
labelWidth?: string;
codeValueKey?: string;
labelPosition?: 'top' | 'left' | 'right';
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
}>();
const emit = defineEmits<{
@ -89,24 +89,18 @@ const emit = defineEmits<{
const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false);
const services = useServices();
const { editorService, uiService } = services;
const { uiService } = services;
const codeOptions = inject('codeOptions', {});
const showSrc = ref(false);
const propsPanelSize = computed(() => uiService.get('propsPanelSize') || 'small');
const { height: editorContentHeight } = useEditorContentHeight();
const stage = computed(() => editorService.get('stage'));
const formContext = useEditorFormContext(() => services);
const configFormRef = useTemplateRef<InstanceType<typeof MForm>>('configForm');
watchEffect(() => {
if (configFormRef.value) {
configFormRef.value.formState.stage = stage.value;
configFormRef.value.formState.services = services;
}
});
const internalInstance = getCurrentInstance();
onMounted(() => {
emit('mounted', internalInstance?.proxy);
@ -162,18 +156,13 @@ const saveCode = async (values: any) => {
//
// 使
//
// display / rules formState services extendState
// display / rules formState services context
try {
const error = await validateForm({
config: props.config,
typeMatchValid: true,
initValues: newValues,
extendState: (state) => {
if (configFormRef.value?.formState) {
return { ...(configFormRef.value?.formState || {}) };
}
return props.extendState?.(state) || {};
},
context: formContext.value,
});
if (error) {

View File

@ -8,7 +8,6 @@
:config="curFormConfig"
:values="values"
:disabledShowSrc="disabledShowSrc"
:extendState="extendState"
@submit="(v, eventData, error) => submit(v, eventData, error, 'props')"
@submit-error="errorHandler"
@form-error="errorHandler"
@ -26,7 +25,6 @@
:config="styleFormConfig"
:values="values"
:disabledShowSrc="disabledShowSrc"
:extendState="extendState"
@submit="(v, eventData, error) => submit(v, eventData, error, 'style')"
@submit-error="errorHandler"
@form-error="errorHandler"
@ -61,7 +59,7 @@ import type { OnDrag } from 'gesto';
import { type MNode } from '@tmagic/core';
import { TMagicButton } from '@tmagic/design';
import type { ContainerChangeEventData, FormState, FormValue } from '@tmagic/form';
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
import { setValueByKeyPath } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue';
@ -84,7 +82,6 @@ defineOptions({
defineProps<{
disabledShowSrc?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
}>();
const emit = defineEmits<{

View File

@ -34,7 +34,7 @@ import type {
MPageFragment,
} from '@tmagic/core';
import type { FieldSize } from '@tmagic/design';
import type { ChangeRecord, FormConfig, FormState, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type StageCore from '@tmagic/stage';
import type {
CanDropIn,
@ -172,6 +172,15 @@ export interface Services {
}
// #endregion Services
declare module '@tmagic/form-schema' {
interface FormContext {
/** 编辑器服务集合(由 Editor / FormPanel provide */
services?: Services;
/** 当前画布 Stage 实例(由 Editor / FormPanel provide */
stage?: any;
}
}
export interface StageOptions {
runtimeUrl?: string;
autoScrollIntoView?: boolean;
@ -598,18 +607,6 @@ export interface CompareFormBaseProps {
* 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
@ -1558,17 +1555,6 @@ export interface UseHistoryRevertOptions {
* `getCurrentInstance()?.appContext` setup `editorApp._context`
*/
appContext?: AppContext | null;
/**
* `extendState` Editor `extendFormState`
* 使 `display` / `disabled` filterFunction
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* PropsPanel FormPanel -> MForm formState
* / 使 formState CompareForm
* filterFunction
*/
getPropsPanelFormState?: () => FormState | undefined;
/**
* / / / TMagicDialog `width`
* `'1200px'` / `'80%'`使900px
@ -1590,11 +1576,6 @@ export interface CustomDiffFormOptions {
loadConfig?: CompareFormLoadConfig;
/** 需要走 self diff 的字段类型(如模块的 mod-cond。 */
selfDiffFieldTypes?: string[];
/**
* formState PropsPanel
* CompareForm
*/
compareFormState?: FormState;
/**
* / HistoryDiffDialog TMagicDialog `width`
* `'1200px'` / `'80%'`使900px

View File

@ -4,11 +4,15 @@
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { type ComputedRef, defineComponent, h, inject, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_CONTEXT_KEY, type FormContext } from '@tmagic/form';
import Editor from '@editor/Editor.vue';
let injectedFormContext: ComputedRef<FormContext> | undefined;
const { initServiceEventsMock, initServiceStateMock } = vi.hoisted(() => ({
initServiceEventsMock: vi.fn(),
initServiceStateMock: vi.fn(),
@ -23,7 +27,21 @@ vi.mock('@editor/services/codeBlock', () => ({ default: {} }));
vi.mock('@editor/services/componentList', () => ({ default: {} }));
vi.mock('@editor/services/dataSource', () => ({ default: {} }));
vi.mock('@editor/services/dep', () => ({ default: {} }));
vi.mock('@editor/services/editor', () => ({ default: {} }));
const { stageStub } = vi.hoisted(() => ({ stageStub: { name: 'stage-instance' } }));
// 用响应式 ref 承载 stage模拟真实 editorService 的 reactive state
// 这样 formContext 的 computed 才会在 stage 变化时失效重算
vi.mock('@editor/services/editor', async () => {
const { shallowRef } = await import('vue');
const stage = shallowRef<any>(stageStub);
return {
default: {
get: vi.fn((key: string) => (key === 'stage' ? stage.value : undefined)),
__setStage: (value: any) => {
stage.value = value;
},
},
};
});
vi.mock('@editor/services/events', () => ({ default: {} }));
vi.mock('@editor/services/history', () => ({ default: {} }));
vi.mock('@editor/services/keybinding', () => ({
@ -88,6 +106,7 @@ vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({
name: 'PropsPanel',
emits: ['mounted', 'unmounted', 'submit-error', 'form-error'],
setup(_p, { emit }) {
injectedFormContext = inject(FORM_CONTEXT_KEY, undefined);
return () =>
h('div', { class: 'fake-props-panel' }, [
h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }),
@ -105,6 +124,7 @@ vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
beforeEach(() => {
vi.clearAllMocks();
injectedFormContext = undefined;
});
describe('Editor', () => {
@ -153,6 +173,30 @@ describe('Editor', () => {
expect(wrapper.emitted('layer-node-dblclick')).toBeTruthy();
});
test('provide FORM_CONTEXT_KEY子孙可拿到 services 与当前 stage', async () => {
mount(Editor, { props: {} as any });
await nextTick();
expect(injectedFormContext).toBeDefined();
const context = injectedFormContext!.value as any;
expect(context.services.editorService).toBeDefined();
expect(context.services.propsService).toBeDefined();
expect(context.stage).toBe(stageStub);
});
test('stage 走 computed 读时求值,切换画布后能读到新实例', async () => {
const editorServiceMod = (await import('@editor/services/editor')) as any;
mount(Editor, { props: {} as any });
await nextTick();
expect((injectedFormContext!.value as any).stage).toBe(stageStub);
const nextStage = { name: 'next-stage' };
editorServiceMod.default.__setStage(nextStage);
expect((injectedFormContext!.value as any).stage).toBe(nextStage);
editorServiceMod.default.__setStage(stageStub);
});
test('expose services', () => {
const wrapper = mount(Editor, { props: {} as any });
expect((wrapper.vm as any).editorService).toBeDefined();

View File

@ -47,7 +47,7 @@ vi.mock('@editor/utils/code-block', () => ({
vi.mock('@tmagic/form', () => ({
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'extendState', 'showDiff'],
props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'context', 'showDiff'],
setup(props, { expose }) {
capturedShowDiff = props.showDiff as (args: any) => boolean;
capturedFormProps = props as Record<string, any>;
@ -91,6 +91,8 @@ describe('CompareForm.vue', () => {
expect(wrapper.find('.fake-mform').exists()).toBe(true);
expect(capturedFormProps.initValues).toEqual({ id: 'n1', name: 'new' });
expect(capturedFormProps.lastValues).toEqual({ id: 'n1', name: 'old' });
expect(capturedFormProps.context?.services).toEqual(services);
expect(capturedFormProps.context).toHaveProperty('stage');
});
test('node 类别缺少 type 时不渲染 MForm', async () => {

View File

@ -38,7 +38,7 @@ vi.mock('@editor/utils/code-block', () => ({
vi.mock('@tmagic/form', () => ({
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'disabled', 'labelWidth', 'extendState', 'size'],
props: ['config', 'initValues', 'disabled', 'labelWidth', 'context', 'size'],
setup(props, { expose }) {
capturedFormProps = props as Record<string, any>;
expose({ formState: {} });
@ -72,6 +72,8 @@ describe('ViewForm.vue', () => {
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' });
expect(capturedFormProps.context?.services).toEqual(services);
expect(capturedFormProps.context).toHaveProperty('stage');
});
test('默认 disabled 为 true', async () => {

View File

@ -168,28 +168,6 @@ describe('useCompareForm', () => {
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();
@ -213,14 +191,14 @@ describe('useCompareForm', () => {
expect(dataSourceService.getFormConfig).toHaveBeenCalled();
});
test('formRef.formState 注入 stage / services', async () => {
test('formContext 含 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(captured.formContext.value.services).toBe(services);
expect(captured.formContext.value.stage).toBe(stage);
expect(editorService.get).toHaveBeenCalledWith('stage');
expect(captured.formRef.value.formState.services).toBe(services);
expect(captured.formRef.value.formState.stage).toBe(stage);
});
});

View File

@ -65,7 +65,7 @@ vi.mock('@tmagic/design', () => ({
vi.mock('@editor/components/CompareForm.vue', () => ({
default: defineComponent({
name: 'CompareForm',
props: ['category', 'type', 'dataSourceType', 'value', 'lastValue', 'extendState', 'height'],
props: ['category', 'type', 'dataSourceType', 'value', 'lastValue', 'height'],
setup() {
return () => h('div', { class: 'fake-compare-form' });
},

View File

@ -76,7 +76,7 @@ vi.mock('@tmagic/form', async () => {
validateForm: vi.fn((options?: any) => validateFormImpl(options)),
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'extendState'],
props: ['config', 'initValues', 'context'],
emits: ['change', 'error'],
setup(_p, { expose, emit }) {
const formState = { stage: null as any, services: null as any };
@ -234,7 +234,7 @@ describe('FormPanel', () => {
expect(validateSpy.mock.calls[0][0]).toMatchObject({ initValues: { style: { foo: 'bar' } } });
});
test('源码保存时传给 validateForm 的 extendState 注入 services 和 stage', async () => {
test('源码保存时传给 validateForm 的 context 含 services 和 stage', async () => {
const validateSpy = vi.fn(async () => '');
validateFormImpl = validateSpy;
const wrapper = mount(FormPanel, {
@ -245,11 +245,9 @@ describe('FormPanel', () => {
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');
const { context } = validateSpy.mock.calls[0][0];
expect(context).toHaveProperty('services');
expect(context).toHaveProperty('stage');
});
test('启用 enablePropsFormValidate 且源码保存静默校验通过时 submit 不携带 error', async () => {

View File

@ -71,7 +71,7 @@ const mountedHandlers: any[] = [];
vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
default: defineComponent({
name: 'FormPanel',
props: ['config', 'values', 'disabledShowSrc', 'extendState'],
props: ['config', 'values', 'disabledShowSrc'],
emits: ['submit', 'submit-error', 'form-error', 'mounted', 'unmounted'],
setup(_p, { emit, expose }) {
mountedHandlers.push(emit);

View File

@ -33,6 +33,27 @@ export interface OnChangeHandlerData {
export type FormValue = Record<string | number, any>;
// #endregion FormValue
// #region FormContext
/**
* 宿
*
* `declare module '@tmagic/form-schema'`
*
* ```ts
* declare module '@tmagic/form-schema' {
* interface FormContext {
* services?: Services;
* stage?: any;
* }
* }
* ```
*
*
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface FormContext {}
// #endregion FormContext
// #region OnChangeHandler
export type OnChangeHandler = (mForm: FormState | undefined, value: any, data: OnChangeHandlerData) => any;
// #endregion OnChangeHandler

View File

@ -37,19 +37,7 @@
</template>
<script setup lang="ts">
import {
computed,
inject,
nextTick,
provide,
reactive,
ref,
shallowRef,
toRaw,
useTemplateRef,
watch,
watchEffect,
} from 'vue';
import { computed, inject, nextTick, provide, reactive, ref, shallowRef, toRaw, useTemplateRef, watch } from 'vue';
import { cloneDeep, isEqualWith } from 'lodash-es';
import { M_THEME_KEY, TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
@ -57,10 +45,18 @@ import { setValueByKeyPath } from '@tmagic/utils';
import Container from './containers/Container.vue';
import { applyMountValueEffects } from './utils/collectFields';
import { applyExtendState, createFormStateBase, initValue } from './utils/form';
import { createFormStateBase, createFormStateProxy, initValue, mergeFormContexts } from './utils/form';
import { formatValidateError as formatError, getTextByName as findTextByName } from './utils/validateError';
import type { ChangeRecord, ContainerChangeEventData, FormConfig, FormSlots, FormState, FormValue } from './schema';
import { FORM_DIFF_CONFIG_KEY, FORM_TYPE_MATCH_VALID_KEY } from './schema';
import type {
ChangeRecord,
ContainerChangeEventData,
FormConfig,
FormContext,
FormSlots,
FormState,
FormValue,
} from './schema';
import { FORM_CONTEXT_KEY, FORM_DIFF_CONFIG_KEY, FORM_TYPE_MATCH_VALID_KEY } from './schema';
defineOptions({
name: 'MForm',
@ -105,7 +101,12 @@ const props = withDefaults(
* - `false`跳过查找直接使用字段 name 作为错误提示前缀形如 `字段name -> 错误信息`
*/
useFieldTextInError?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* 宿主业务上下文也可由祖先 `provide(FORM_CONTEXT_KEY)` 下发 prop 覆盖祖先的同名字段
*
* 配置回调通过 `mForm.xxx` 读取 formState 的读穿 Proxy 落到这里
*/
context?: FormContext;
/**
* 自定义"是否展示对比内容"的判断函数仅在 `isCompare === true` 时生效
*
@ -204,18 +205,14 @@ const themeClass = computed(() => (effectiveTheme.value ? `m-theme--${effectiveT
* 2. `values` / `lastValuesProcessed` refVue `reactive` 会自动解包因此每次
* 访问 `formState.values` / `formState.lastValuesProcessed` 也都是当前 ref
*
* 3. `extendState` 注入的字段在下方的 `watchEffect` 中合并到 `formState`
* - data 描述符普通字段通过 `formState[key] = value` 写入 reactive proxy
* set触发依赖通知`extendState` 同步段读到的响应式数据变化时会自动重跑
* 把最新值刷进 formState
* - accessor 描述符`{ get stage() { return ... } }`按原样写入调用方可以控制
* 读时求值每次读取都会重新执行 getter
* 3. 宿主业务上下文不再 merge coreState而是单独放在 `contextRef` 由读穿
* Proxy miss 时落到 context核心字段结构性优先`mForm.xxx` 永久兼容旧读法
*
* 4. `popperClass` 会自动拼接 `themeClass`调用方传入的 `popperClass` + 当前主题
* 修饰类含祖先 `<MEditor>` provide 的主题这样所有透传到 Element Plus 弹层
* `popper-class` 的字段Select / DateTime / Cascader 能自带主题作用域
*/
const formState: FormState = reactive<FormState>({
const coreState: FormState = reactive<FormState>({
get keyProp() {
return props.keyProp;
},
@ -247,60 +244,18 @@ const formState: FormState = reactive<FormState>({
...createFormStateBase({ $message: tMagicMessage, $messageBox: tMagicMessageBox }),
});
/**
* formState 的内置 key 快照keyProp / values / $emit / fields / post
*
* `extendState` 首次合并前捕获`applyExtendState` 会据此禁止 `extendState`
* 覆盖这些已有字段只能新增字段避免表单核心状态被外部意外改写
*
* 之所以在此处effect 之外捕获而不是在 `applyExtendState` 内动态取
* `watchEffect` 会在依赖变化时重跑若动态取`extendState` 自己新增的字段在第二次
* 合并时也会被当成已有 key而拒绝刷新这里只锁定内置字段即可规避该问题
*/
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(formState));
const ancestorContext = inject(FORM_CONTEXT_KEY, undefined);
/**
* `extendState` 的同步段直到第一个 `await` 之前所访问的任何响应式数据
* 都会被 `watchEffect` 自动跟踪这样可以兼容历史用法
*
* extendState: (formState) => ({
* username: store.username, // store
* env: store.env,
* })
*
* `store.username` 变化时整个 effect 重跑新值会被刷进 `formState`
*
* prop 派生字段initValues / config / ...已经在上方用 getter 定义
* 这里不再重复同步因此 `props.initValues` 这类高频变化也不会再触发
* `extendState` 重跑旧版的性能问题修复点
*
* 实现细节合并逻辑统一收口在 `applyExtendState`utils/form
* data 描述符走 reactive proxy set 触发依赖通知与旧版逐项赋值语义等价
* accessor 描述符按原样 defineProperty 支持读时求值
* props 派生的只读 getter 字段keyProp 以普通字段形式返回时会被跳过并告警
* 宿主业务上下文`props.context` 覆盖祖先注入的同名字段
* 嵌套表单Link / FormBox / FormDialog通过下面的 provide 自动继承
*/
watchEffect(async (onCleanup) => {
const { extendState } = props;
if (typeof extendState !== 'function') return;
const contextRef = computed<FormContext>(() => mergeFormContexts(ancestorContext?.value, props.context));
let stale = false;
onCleanup(() => {
stale = true;
});
let state: Record<string, any> = {};
try {
state = (await extendState(formState)) || {};
} catch (e) {
console.error('[MForm] extendState failed:', e);
return;
}
if (stale) return;
applyExtendState(formState, state, reservedStateKeys);
});
const formState: FormState = createFormStateProxy(coreState, () => contextRef.value);
provide('mForm', formState);
provide(FORM_CONTEXT_KEY, contextRef);
/**
* 把生效主题自身或祖先 provide 出去 form 子树内含 `Teleport` 的组件
@ -335,7 +290,7 @@ const isSameConfigShape = (config: unknown, preConfig: unknown) =>
watch(
[() => props.config, () => props.initValues],
([config], [preConfig]) => {
async ([config], [preConfig]) => {
changeRecords.value = [];
if (!isSameConfigShape(toRaw(config), toRaw(preConfig))) {

View File

@ -14,7 +14,7 @@
:inline="inline"
:prevent-submit-default="preventSubmitDefault"
:use-field-text-in-error="useFieldTextInError"
:extend-state="extendState"
:context="context"
:type-match-valid="typeMatchValid"
:validate-on-init="validateOnInit"
@change="changeHandler"
@ -44,7 +44,7 @@ import { computed, ref, watchEffect } from 'vue';
import { TMagicButton, TMagicScrollbar } from '@tmagic/design';
import Form from './Form.vue';
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from './schema';
import type { ContainerChangeEventData, FormConfig, FormContext, FormValue } from './schema';
defineOptions({
name: 'MFormBox',
@ -70,7 +70,8 @@ const props = withDefaults(
preventSubmitDefault?: boolean;
/** 透传给内部 `MForm`,控制表单校验失败时错误提示前缀是否使用字段的 text 文案 */
useFieldTextInError?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 透传给内部 `MForm` 的宿主业务上下文 */
context?: FormContext;
}>(),
{
config: () => [],

View File

@ -34,7 +34,7 @@
:use-field-text-in-error="useFieldTextInError"
:type-match-valid="typeMatchValid"
:validate-on-init="validateOnInit"
:extend-state="extendState"
:context="context"
:theme="effectiveTheme"
@change="changeHandler"
></Form>
@ -73,7 +73,7 @@ import { computed, inject, provide, ref } from 'vue';
import { M_THEME_KEY, TMagicButton, TMagicCol, TMagicDialog, TMagicRow } from '@tmagic/design';
import Form from './Form.vue';
import { ContainerChangeEventData, FormConfig, FormState, FormValue, StepConfig } from './schema';
import { ContainerChangeEventData, FormConfig, FormContext, FormValue, StepConfig } from './schema';
defineOptions({
name: 'MFormDialog',
@ -106,8 +106,8 @@ const props = withDefaults(
showCancel?: boolean;
/** 透传给内部 `MForm`,控制表单校验失败时错误提示前缀是否使用字段的 text 文案 */
useFieldTextInError?: boolean;
/** 透传给内部 `MForm`,用于扩展 `formState`(如注入 `$message` / `$store` 等) */
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 透传给内部 `MForm` 的宿主业务上下文 */
context?: FormContext;
/**
* 主题名优先级传入 `theme` prop > 祖先 `provide(M_THEME_KEY)` > 空串
* 计算结果会再次 `provide` 出去使得 Dialog Teleport body 内部子树

View File

@ -31,7 +31,7 @@
:use-field-text-in-error="useFieldTextInError"
:type-match-valid="typeMatchValid"
:validate-on-init="validateOnInit"
:extend-state="extendState"
:context="context"
:theme="effectiveTheme"
@change="changeHandler"
></Form>
@ -64,7 +64,7 @@ import { computed, inject, provide, ref, watchEffect } from 'vue';
import { M_THEME_KEY, TMagicButton, TMagicCol, TMagicDrawer, TMagicRow } from '@tmagic/design';
import Form from './Form.vue';
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from './schema';
import type { ContainerChangeEventData, FormConfig, FormContext, FormValue } from './schema';
defineOptions({
name: 'MFormDrawer',
@ -94,8 +94,8 @@ const props = withDefaults(
useFieldTextInError?: boolean;
/** 关闭前的回调,会暂停 Drawer 的关闭; done 是个 function type 接受一个 boolean 参数, 执行 done 使用 true 参数或不提供参数将会终止关闭 */
beforeClose?: (_done: (_cancel?: boolean) => void) => void;
/** 透传给内部 `MForm`,用于扩展 `formState`(如注入 `$message` / `$store` 等) */
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 透传给内部 `MForm` 的宿主业务上下文 */
context?: FormContext;
/**
* 主题名优先级传入 `theme` prop > 祖先 `provide(M_THEME_KEY)` > 空串
* 计算结果会再次 `provide` 出去使得 Drawer Teleport body 内部子树

View File

@ -1,6 +1,6 @@
import type { ComputedRef, InjectionKey } from 'vue';
import type { FormItemConfig } from '@tmagic/form-schema';
import type { FormContext, FormItemConfig } from '@tmagic/form-schema';
export * from '@tmagic/form-schema';
@ -29,6 +29,16 @@ export interface FormDiffConfig {
export const FORM_DIFF_CONFIG_KEY: InjectionKey<FormDiffConfig> = Symbol('mFormDiffConfig');
export const FORM_TYPE_MATCH_VALID_KEY: InjectionKey<ComputedRef<boolean>> = Symbol('mFormTypeMatchValid');
/**
* 宿 `MForm` Editor `provide`
*
* Link FormBox context
*
*
* `mForm.xxx` formState 穿 Proxy
*/
export const FORM_CONTEXT_KEY: InjectionKey<ComputedRef<FormContext>> = Symbol('mFormContext');
export interface ValidateError {
message: string;
field: string;

View File

@ -16,9 +16,8 @@
* limitations under the License.
*/
import { type AppContext, type Component, createApp, defineComponent, h, nextTick, type Ref, ref, watch } from 'vue';
import { type AppContext, type Component, createApp, defineComponent, h, nextTick, type Ref, ref } from 'vue';
import { applyExtendState } from './utils/form';
import {
submitForm as submitFormHeadless,
type SubmitFormOptions,
@ -119,50 +118,7 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
try {
const formRef = ref<any>(null);
// 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用,
// 而是在 wrapper 中通过 sync watch 在 formRef 就绪后直接写入 formState
// 避免 display 等 filterFunction 在首次渲染时读到 undefined。
// 与 CompareForm / FormPanel 中「formRef.value.formState.services = ...」的做法一致。
const { extendState, ...restFormProps } = formProps;
const userWrapper = createWrapper({ formRef, formProps: restFormProps, cleanup, resolve, reject });
const wrapperComponent =
typeof extendState === 'function'
? defineComponent({
name: 'MFormExtendStateInjector',
setup() {
watch(
() => formRef.value,
(form) => {
if (!form) return;
let result: any;
try {
result = extendState(form.formState);
} catch (e) {
console.error('[MForm] extendState failed:', e);
return;
}
// formState 的内置 key 快照:在 extendState 合并前捕获,
// 供 applyExtendState 禁止 extendState 覆盖这些已有字段(只能新增),
// 与 Form.vue 中 reservedStateKeys 的语义保持一致。
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(form.formState));
// 合并逻辑收口在 applyExtendStateprops 派生的只读 getter 字段
// keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
const apply = (state: Record<string, any> | null | undefined) =>
applyExtendState(form.formState, state, reservedStateKeys);
if (result && typeof result.then === 'function') {
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
} else {
apply(result);
}
},
{ flush: 'sync', immediate: true },
);
return () => h(userWrapper);
},
})
: userWrapper;
const wrapperComponent = createWrapper({ formRef, formProps, cleanup, resolve, reject });
const app = createApp(wrapperComponent);
instance.app = app;

View File

@ -44,6 +44,8 @@ import type {
import { getConfig } from './config';
import { createTypeMatchValidator } from './typeMatch';
export { createFormStateProxy, mergeFormContexts } from './formStateProxy';
type AsyncValidatorFn = (rule: any, value: any, callback: Function, source?: any, options?: any) => any;
const isTDesignAdapter = () => getDesignConfig('adapterType') === 'tdesign-vue-next';
@ -565,54 +567,6 @@ export const sortChange = (data: any[], { prop, order }: SortProp) => {
}
};
/**
* extendState formState
*
* - data `formState[key] = value` reactive proxy set
*
* - accessor `{ get stage() { return ... } }` defineProperty
* `configurable: true` 便 define
*
* extendState formState key
* `reservedKeys` key keyProp / popperClass /
* config / initValues / isCompare / lastValues / parentValues / values / $emit / fields /
* post key
*
* `reservedKeys` props getter setter
* proxy set trap
* `TypeError: 'set' on proxy: trap returned falsish`
*/
export const applyExtendState = (
formState: FormState,
state: Record<string, any> | null | undefined,
reservedKeys?: Set<string | symbol>,
): void => {
if (!state) return;
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
if (reservedKeys?.has(key)) {
continue;
}
if (!('value' in descriptor)) {
descriptor.configurable = true;
Object.defineProperty(formState, key, descriptor);
continue;
}
const targetDescriptor = Object.getOwnPropertyDescriptor(formState, key);
if (targetDescriptor && !('value' in targetDescriptor) && typeof targetDescriptor.set !== 'function') {
console.warn(
`[MForm] extendState: "${key}" is a read-only field derived from props and cannot be assigned a plain value. ` +
'Return it as a getter accessor if you really need to override it.',
);
continue;
}
(formState as any)[key] = (state as any)[key];
}
};
export const createObjectProp = (prop: string, key: string, name?: string | number) => {
if (prop === '') {
return key;

View File

@ -0,0 +1,104 @@
/*
* 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 { type Ref, unref } from 'vue';
import type { FormContext, FormState } from '../schema';
/** 无上下文时共用的空对象,避免每次读取都分配新对象 */
const EMPTY_CONTEXT: FormContext = Object.freeze({});
/**
* ****
*
* 穿 Proxy `{ ...a, ...b }` accessor
* 宿 `{ get stage() { ... } }`
*/
export const mergeFormContexts = (...layers: (FormContext | undefined | null)[]): FormContext => {
const stack = layers.filter((layer): layer is FormContext => Boolean(layer));
if (stack.length === 0) return EMPTY_CONTEXT;
if (stack.length === 1) return stack[0];
// 反转成「高优先级在前」,查找时取第一个命中的层
const ordered = stack.slice().reverse() as Record<string | symbol, any>[];
const owner = (k: string | symbol) => ordered.find((layer) => Reflect.has(layer, k));
const target: Record<string | symbol, any> = {};
return new Proxy(target, {
get: (_t, k) => owner(k)?.[k],
has: (_t, k) => Boolean(owner(k)),
ownKeys: () => [...new Set(ordered.flatMap((layer) => Reflect.ownKeys(layer)))],
getOwnPropertyDescriptor: (_t, k) => {
for (const layer of ordered) {
const descriptor = Object.getOwnPropertyDescriptor(layer, k);
// 目标是空对象,必须报告为 configurable否则违反 Proxy 不变式
if (descriptor) return { ...descriptor, configurable: true };
}
return undefined;
},
}) as FormContext;
};
/**
* coreState 宿 coremiss 穿 context
*
* - `get` 访 `Reflect.get(t, k, receiver)` Vue reactive `__v_raw`
* - symbol core trap context symbol
* Vue / symbol core `toRaw` / `isReactive`
* - `ownKeys` + `getOwnPropertyDescriptor` `Object.entries(formState)`
* admin-web-next `pickPanelFormStateExtendFields`
* - `set` coreState `formState.xxx = v` context
*
* `form.ts` `typeMatch.ts`
*/
export const createFormStateProxy = (
coreState: FormState,
getContext: (() => FormContext) | Ref<FormContext>,
): FormState => {
const resolve = (): Record<string | symbol, any> =>
(typeof getContext === 'function' ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
return new Proxy(coreState as object, {
get(t, k) {
if (typeof k === 'symbol') return Reflect.get(t, k);
const v = (t as any)[k];
if (v !== undefined || Reflect.has(t, k)) return v;
return resolve()[k];
},
set(t, k, value) {
(t as any)[k] = value;
return true;
},
has: (t, k) => Reflect.has(t, k) || (typeof k !== 'symbol' && k in resolve()),
ownKeys: (t) => [
...new Set([...Reflect.ownKeys(t), ...Reflect.ownKeys(resolve()).filter((k) => typeof k !== 'symbol')]),
],
getOwnPropertyDescriptor: (t, k) => {
const own = Reflect.getOwnPropertyDescriptor(t, k);
if (own) return own;
if (typeof k === 'symbol') return undefined;
const ctx = resolve();
if (!(k in ctx)) return undefined;
// core 上不存在该键,必须报告为 configurable否则违反 Proxy 不变式
return { configurable: true, enumerable: true, writable: true, value: ctx[k] };
},
}) as FormState;
};

View File

@ -19,7 +19,7 @@
import type { AppContext } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { ChangeRecord, FormConfig, FormState } from '../schema';
import type { ChangeRecord, FormConfig, FormContext } from '../schema';
import { validateValues, type ValidateValuesResult } from './validateValues';
@ -52,7 +52,8 @@ export interface SubmitFormOptions {
* `true` `false` 使 name
*/
useFieldTextInError?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 宿主业务上下文,与 MForm 的同名 prop 语义一致 */
context?: FormContext;
/** 透传给 Form.submitForm 的参数:是否直接返回原始响应式 values */
native?: boolean;
/**
@ -112,7 +113,8 @@ export interface ValidateFormOptions {
* `true` `false` 使 name
*/
useFieldTextInError?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 宿主业务上下文,与 MForm 的同名 prop 语义一致 */
context?: FormContext;
/**
* `dialog: true` `@tmagic/form/headless`
*/
@ -156,8 +158,7 @@ export const validateWithoutRender = async (
fnName: 'submitForm' | 'validateForm',
options: SubmitFormOptions | ValidateFormOptions,
): Promise<ValidateValuesResult> => {
const { signal, config, initValues, parentValues, keyProp, typeMatchValid, useFieldTextInError, extendState } =
options;
const { signal, config, initValues, parentValues, keyProp, typeMatchValid, useFieldTextInError, context } = options;
throwIfAborted(signal, fnName);
@ -169,7 +170,7 @@ export const validateWithoutRender = async (
popperClass: (options as SubmitFormOptions).popperClass,
typeMatchValid,
useFieldTextInError,
extendState,
context,
});
throwIfAborted(signal, fnName);

View File

@ -19,10 +19,10 @@
import { reactive } from 'vue';
import Schema from 'async-validator';
import type { FormConfig, FormState, FormValue } from '../schema';
import type { FormConfig, FormContext, FormState, FormValue } from '../schema';
import { applyMountValueEffects, type CollectedField, collectValidatableFields } from './collectFields';
import { applyExtendState, createFormStateBase, initValue } from './form';
import { createFormStateBase, createFormStateProxy, initValue } from './form';
import { formatValidateError } from './validateError';
// #region HeadlessFormStateOptions
@ -33,6 +33,8 @@ export interface HeadlessFormStateOptions {
parentValues?: FormValue;
keyProp?: string;
popperClass?: string;
/** 宿主业务上下文,与 MForm 的同名 prop 语义一致 */
context?: FormContext;
}
// #endregion HeadlessFormStateOptions
@ -46,6 +48,7 @@ export interface HeadlessFormStateOptions {
*
*/
export const createHeadlessFormState = (options: HeadlessFormStateOptions): FormState => {
const context = options.context ?? {};
const state: FormState = {
keyProp: options.keyProp ?? '__key',
popperClass: options.popperClass ?? '',
@ -60,7 +63,7 @@ export const createHeadlessFormState = (options: HeadlessFormStateOptions): Form
...createFormStateBase(),
};
return reactive(state);
return createFormStateProxy(reactive(state), () => context);
};
/**
@ -111,8 +114,6 @@ export interface ValidateValuesOptions extends HeadlessFormStateOptions {
* 使 text `true`
*/
useFieldTextInError?: boolean;
/** 扩展 formState与 MForm 的同名 prop 语义一致(只能新增字段,不能覆盖内置字段) */
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
}
// #endregion ValidateValuesOptions
@ -133,7 +134,7 @@ export interface ValidateValuesResult {
*
* DOM
*
* 1. headless `formState` `extendState`
* 1. headless `formState` `context` 穿
* 2. `initValue` `onInitValue`
* 3. `applyMountValueEffects`
* 4. config FormItem
@ -149,21 +150,10 @@ export interface ValidateValuesResult {
* ```
*/
export const validateValues = async (options: ValidateValuesOptions): Promise<ValidateValuesResult> => {
const { config, initValues = {}, typeMatchValid, useFieldTextInError = true, extendState } = options;
const { config, initValues = {}, typeMatchValid, useFieldTextInError = true } = options;
const formState = createHeadlessFormState(options);
// formState 的内置 key 快照extendState 只能新增字段,不能覆盖这些字段,与 Form.vue 语义一致
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(formState));
if (typeof extendState === 'function') {
try {
applyExtendState(formState, await extendState(formState), reservedStateKeys);
} catch (e) {
console.error('[MForm] extendState failed:', e);
}
}
const values = await initValue(formState, { initValues, config });
formState.values = values;
// 与 Form.vue 一致:值挂到 formState 之后再执行字段的值初始化写入

View File

@ -16,11 +16,11 @@
* limitations under the License.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { nextTick, ref } from 'vue';
import { computed, defineComponent, h, nextTick, provide, ref } from 'vue';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import MagicForm, { MForm } from '@form/index';
import MagicForm, { FORM_CONTEXT_KEY, MForm } from '@form/index';
const mountForm = (props: Record<string, any> = {}, options: Record<string, any> = {}) =>
mount(MForm, {
@ -143,141 +143,168 @@ describe('Form.vue —— formState getter 行为', () => {
});
});
describe('Form.vue —— extendState', () => {
test('extendState 抛错时被 catch不影响表单渲染', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const extendState = vi.fn(async () => {
throw new Error('boom');
});
describe('Form.vue —— context', () => {
test('context 字段可通过 formState 读穿,核心字段不被覆盖', async () => {
const wrapper = mountForm({
extendState,
config: [{ text: 'text', name: 'text', type: 'text' }],
keyProp: 'id',
context: { username: 'alice', keyProp: 'hacked' },
});
await nextTick();
await nextTick();
expect(extendState).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
expect(wrapper.find('.m-form').exists()).toBe(true);
errorSpy.mockRestore();
expect((wrapper.vm.formState as any).username).toBe('alice');
expect((wrapper.vm.formState as any).keyProp).toBe('id');
});
test('extendState 返回的普通字段被合并到 formState', async () => {
const wrapper = mountForm({
extendState: async () => ({ extra: 'hello', count: 42 }),
});
await nextTick();
await nextTick();
await nextTick();
expect((wrapper.vm.formState as any).extra).toBe('hello');
expect((wrapper.vm.formState as any).count).toBe(42);
});
test('extendState 返回的 accessor 描述符按原样定义并支持读时求值', async () => {
test('context 里的 accessor 保持读时求值', async () => {
let counter = 0;
const wrapper = mountForm({
extendState: () =>
Object.defineProperties(
{},
{
stage: {
enumerable: true,
get() {
counter += 1;
return `stage-${counter}`;
},
context: Object.defineProperties(
{},
{
stage: {
enumerable: true,
get() {
counter += 1;
return `stage-${counter}`;
},
},
),
},
),
});
await nextTick();
await nextTick();
await nextTick();
const v1 = (wrapper.vm.formState as any).stage;
const v2 = (wrapper.vm.formState as any).stage;
expect(v1).not.toEqual(v2);
expect(v1).toMatch(/^stage-/);
expect(v2).toMatch(/^stage-/);
// 每次读都重新求值,而不是挂载时快照一次
const first = (wrapper.vm.formState as any).stage;
const second = (wrapper.vm.formState as any).stage;
expect(first).toMatch(/^stage-\d+$/);
expect(second).not.toBe(first);
});
test('extendState 以普通字段返回内置保留字段时静默跳过,其余字段正常合并', async () => {
const wrapper = mountForm({
keyProp: 'id',
extendState: () => ({ keyProp: 'custom', config: [], extra: 'ok' }),
});
test('Object.entries(formState) 能枚举到 context 字段', async () => {
const wrapper = mountForm({ context: { username: 'alice', env: 'prod' } });
await nextTick();
await nextTick();
await nextTick();
// 内置保留字段keyProp / config未被 extendState 覆盖
expect((wrapper.vm.formState as any).keyProp).toBe('id');
expect(Array.isArray((wrapper.vm.formState as any).config)).toBe(true);
// 非保留字段正常合并
expect((wrapper.vm.formState as any).extra).toBe('ok');
expect(wrapper.find('.m-form').exists()).toBe(true);
const keys = Object.keys(wrapper.vm.formState as any);
expect(keys).toContain('username');
expect(keys).toContain('env');
expect(keys).toContain('values');
});
test('extendState 以 get 访问器返回内置保留字段同名 key 时仍被拦截,无法覆盖', async () => {
const wrapper = mountForm({
keyProp: 'id',
extendState: () =>
Object.defineProperties(
{},
{
keyProp: { enumerable: true, get: () => 'custom-key' },
},
),
});
test('context 变化后 formState 读到新值,且不残留旧 key', async () => {
const context = ref<Record<string, any>>({ username: 'alice', stale: 1 });
const wrapper = mountForm({ context: context.value });
await nextTick();
await nextTick();
await nextTick();
// keyProp 属于内置保留字段,即使以访问器形式返回也不允许覆盖
expect((wrapper.vm.formState as any).keyProp).toBe('id');
});
test('extendState 同步段读到的响应式数据变化时会重跑', async () => {
const username = ref('alice');
const calls: string[] = [];
const wrapper = mountForm({
// 同步读取 ref会被 watchEffect 跟踪
extendState: (_state: any) => {
calls.push(username.value);
return { username: username.value };
},
});
await nextTick();
await nextTick();
expect((wrapper.vm.formState as any).username).toBe('alice');
username.value = 'bob';
await nextTick();
await nextTick();
await wrapper.setProps({ context: { username: 'bob' } });
await nextTick();
expect((wrapper.vm.formState as any).username).toBe('bob');
// 至少跑了两次(初始 + 响应变化)
expect(calls.length).toBeGreaterThanOrEqual(2);
expect((wrapper.vm.formState as any).stale).toBeUndefined();
});
test('未传 extendState 时 watchEffect 早退,不抛错', async () => {
test('defaultValue 首轮就能读到 context 注入的字段', async () => {
const seen: any[] = [];
const wrapper = mountForm({
context: { username: 'alice' },
config: [
{
text: 'u',
name: 'u',
type: 'text',
defaultValue: (mForm: any) => {
seen.push(mForm?.username);
return mForm?.username;
},
},
],
});
await nextTick();
await nextTick();
await nextTick();
expect(seen[0]).toBe('alice');
expect(wrapper.vm.values.u).toBe('alice');
});
test('formState 直写扩展字段优先于 context', async () => {
const wrapper = mountForm({
context: { stage: 'from-context' },
});
await nextTick();
(wrapper.vm.formState as any).stage = 'assigned';
await nextTick();
expect((wrapper.vm.formState as any).stage).toBe('assigned');
});
test('嵌套 MForm 通过 FORM_CONTEXT_KEY 继承祖先 context', async () => {
const parentComponent = defineComponent({
setup() {
provide(
FORM_CONTEXT_KEY,
computed(() => ({ username: 'ancestor', env: 'prod' })),
);
return () => h(MForm, { initValues: {}, config: [] });
},
});
const wrapper = mount(parentComponent, {
global: {
plugins: [ElementPlus as any, MagicForm as any],
},
});
await nextTick();
await nextTick();
const formState = wrapper.findComponent(MForm).vm.formState as any;
expect(formState.username).toBe('ancestor');
expect(formState.env).toBe('prod');
expect(Object.keys(formState)).toEqual(expect.arrayContaining(['username', 'env', 'values']));
expect(Object.getOwnPropertyDescriptor(formState, 'username')?.enumerable).toBe(true);
});
test('子表单 props.context 覆盖祖先同名字段,未覆盖的仍可读', async () => {
const parentComponent = defineComponent({
setup() {
provide(
FORM_CONTEXT_KEY,
computed(() => ({ username: 'ancestor', env: 'prod' })),
);
return () =>
h(MForm, {
initValues: {},
config: [],
context: { username: 'child' },
});
},
});
const wrapper = mount(parentComponent, {
global: {
plugins: [ElementPlus as any, MagicForm as any],
},
});
await nextTick();
await nextTick();
const formState = wrapper.findComponent(MForm).vm.formState as any;
expect(formState.username).toBe('child');
expect(formState.env).toBe('prod');
});
test('未传 context 也未有祖先注入时,读扩展字段为 undefined 且不抛错', async () => {
const wrapper = mountForm({});
await nextTick();
expect(wrapper.find('.m-form').exists()).toBe(true);
expect((wrapper.vm.formState as any).whatever).toBeUndefined();
});
});

View File

@ -0,0 +1,78 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test, vi } from 'vitest';
import { nextTick, reactive } from 'vue';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
import ActionsColumn from '@form/containers/table/ActionsColumn.vue';
import MagicForm from '@form/index';
import type { FormState } from '@form/schema';
import { createFormStateProxy } from '@form/utils/formStateProxy';
const createMForm = (context: Record<string, any>): FormState =>
createFormStateProxy(
reactive({
keyProp: '__key',
config: [],
initValues: { list: [{ text: 'a' }] },
parentValues: { p: 1 },
values: { list: [{ text: 'a' }] },
lastValues: {},
lastValuesProcessed: {},
isCompare: false,
$emit: () => undefined,
}) as unknown as FormState,
() => context,
);
const mountColumn = (config: any, context: Record<string, any> = { env: 'prod' }) =>
mount(ActionsColumn as any, {
global: {
plugins: [ElementPlus as any, MagicForm as any],
provide: { mForm: createMForm(context) },
},
props: {
config: { type: 'table', name: 'list', ...config },
model: { list: [{ text: 'a' }] },
name: 'list',
prop: 'list',
currentPage: 0,
pageSize: 10,
index: 0,
row: { text: 'a' },
},
});
describe('ActionsColumn', () => {
test('copyable 函数收到 model / index / prop', async () => {
const copyable = vi.fn(() => true);
mountColumn({ copyable });
await nextTick();
expect(copyable).toHaveBeenCalled();
expect(copyable.mock.calls[0][1]).toMatchObject({ index: 0, prop: 'list' });
});
test('copyHandler 可从 mForm 读穿宿主 context返回值作为新增行', async () => {
const copyHandler = vi.fn((mForm: any, data: any) => ({ ...data.inputs, from: mForm.env }));
const wrapper = mountColumn({ copyable: true, copyHandler });
await nextTick();
await wrapper.findAll('button').at(-1)!.trigger('click');
expect(copyHandler).toHaveBeenCalled();
expect(copyHandler.mock.calls[0][1]).toMatchObject({ prop: 'list' });
expect(wrapper.emitted('change')?.[0][0]).toEqual([{ text: 'a' }, { text: 'a', from: 'prod' }]);
});
test('未配置函数时按静态值决定按钮显隐', async () => {
const wrapper = mountColumn({ copyable: false });
await nextTick();
expect(wrapper.text()).not.toContain('复制');
});
});

View File

@ -17,9 +17,10 @@
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { useScrollLastItemIntoView } from '@form/containers/table-group-list/useScrollLastItemIntoView';
import { mount } from '@vue/test-utils';
import { useScrollLastItemIntoView } from '@form/containers/table-group-list/useScrollLastItemIntoView';
const settle = async () => {
for (let i = 0; i < 6; i++) {
await nextTick();

View File

@ -173,4 +173,47 @@ describe('Link', () => {
expect(dialog.exists()).toBe(true);
dialog.vm.$emit('submit');
});
// Link 的 FormDialog 不透传 context靠 FORM_CONTEXT_KEY 的 provide/inject 继承
test('子表单自动继承宿主 context无需层层透传', async () => {
const seen: any[] = [];
const wrapper = mount(MForm, {
global: {
plugins: [ElementPlus as any, MagicForm as any],
},
props: {
initValues: { link: {} },
context: { env: 'prod' },
config: [
{
type: 'link',
text: 'link',
name: 'link',
href: '',
disabled: false,
form: [
{
type: 'text',
name: 'inner',
text: 'inner',
display: (mForm: any) => {
seen.push((mForm as any)?.env);
return true;
},
},
],
},
],
},
});
await nextTick();
await wrapper.findComponent(ElButton).trigger('click');
await nextTick();
await nextTick();
expect(seen.length).toBeGreaterThan(0);
// 子表单的 mForm.xxx 读穿到祖先注入的同一份 context
expect(seen[0]).toBe('prod');
});
});

View File

@ -119,26 +119,30 @@ describe('submitForm', () => {
expect(initValues.object.nested).toBe('b');
});
test('支持 extendState 扩展状态', async () => {
const extendState = vi.fn(async () => ({ extra: 'value' }));
await submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'foo' },
extendState,
test('context 可被 defaultValue 经 mForm 读到', async () => {
const values = await submitForm({
config: [
{
type: 'text',
name: 'u',
text: 'u',
defaultValue: (mForm: any) => mForm?.username ?? 'MISSING',
},
],
initValues: {},
context: { username: 'from-context' },
});
expect(extendState).toHaveBeenCalled();
expect(values).toEqual({ u: 'from-context' });
});
test('extendState 返回 keyProp 等内置保留字段时静默跳过且正常 resolve', async () => {
test('context 携带 keyProp 等内置保留字段时不污染最终 values', async () => {
const values = await submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'foo' },
extendState: () => ({ keyProp: 'custom', extra: 'value' }),
context: { keyProp: 'custom', extra: 'value' } as any,
});
// keyProp 属于内置保留字段,被静默跳过,不污染最终 values
expect(values).toEqual({ text: 'foo' });
});

View File

@ -16,11 +16,11 @@
* limitations under the License.
*/
import { describe, expect, test, vi } from 'vitest';
import { describe, expect, test } from 'vitest';
import type { FormState } from '@form/index';
import {
applyExtendState,
createFormStateProxy,
createObjectProp,
createValues,
datetimeFormatter,
@ -110,6 +110,20 @@ describe('filterFunction', () => {
expect(receivedArgs.config).toEqual({ type: 'text' });
});
test('config 函数通过 mForm 读穿到宿主 context', () => {
const proxied = createFormStateProxy(mForm, () => ({ username: 'alice' }));
let seen: any;
filterFunction(
proxied,
(form: any) => {
seen = form.username;
return true;
},
{ model: {} },
);
expect(seen).toBe('alice');
});
test('config 函数getFormValue正确获取值', () => {
const mockForm: FormState = {
...mForm,
@ -361,6 +375,32 @@ describe('initValue', () => {
expect(values).toHaveLength(0);
});
test('onInitValue / defaultValue 能从 mForm 读穿到宿主 context', async () => {
const proxied = createFormStateProxy(mForm, () => ({ username: 'alice' }));
let initSeen: any;
let defaultSeen: any;
const values = await initValue(proxied, {
initValues: {},
config: [
{
type: 'text',
name: 'u',
defaultValue: (form: any) => {
defaultSeen = form?.username;
return 'x';
},
onInitValue: (form: any, data: any) => {
initSeen = form?.username;
return data.formValue;
},
},
],
});
expect(defaultSeen).toBe('alice');
expect(initSeen).toBe('alice');
expect(values.u).toBe('x');
});
test('defaultValue', async () => {
const initValues = {
a: 1,
@ -1016,130 +1056,3 @@ describe('createObjectProp', () => {
expect(createObjectProp('a.b.c', 'newKey', 'c')).toBe('a.b.newKey');
});
});
describe('applyExtendState', () => {
test('state 为 null 或 undefined 时不修改 formState', () => {
const formState = { a: 1 } as any;
applyExtendState(formState, null);
applyExtendState(formState, undefined);
expect(formState).toEqual({ a: 1 });
});
test('普通字段data descriptor以新增字段形式合并进 formState', () => {
const formState = {} as any;
applyExtendState(formState, { foo: 'bar', num: 1 });
expect(formState.foo).toBe('bar');
expect(formState.num).toBe(1);
});
test('accessor descriptor 按原样 define 且读时求值', () => {
const formState = {} as any;
let count = 0;
const state = {};
Object.defineProperty(state, 'stage', {
get() {
count += 1;
return 'stageValue';
},
enumerable: true,
configurable: true,
});
applyExtendState(formState, state);
// 读时才求值
expect(count).toBe(0);
expect(formState.stage).toBe('stageValue');
expect(count).toBe(1);
});
test('accessor descriptor 强制 configurable=true可再次 define 不报错', () => {
const formState = {} as any;
const state = {};
// 原始描述符 configurable 为 false
Object.defineProperty(state, 'stage', {
get: () => 'v',
enumerable: true,
configurable: false,
});
applyExtendState(formState, state);
const descriptor = Object.getOwnPropertyDescriptor(formState, 'stage');
expect(descriptor?.configurable).toBe(true);
// 再次合并(重新 define不应抛出
expect(() => applyExtendState(formState, state)).not.toThrow();
});
test('reservedKeys 命中的 key 被跳过,不覆盖已有内置字段', () => {
const formState = { values: { origin: true } } as any;
const reservedKeys = new Set<string | symbol>(['values']);
applyExtendState(formState, { values: { changed: true }, extra: 1 }, reservedKeys);
// reserved key 未被覆盖
expect(formState.values).toEqual({ origin: true });
// 非 reserved 的新字段正常新增
expect(formState.extra).toBe(1);
});
test('reservedKeys 对 accessor 形式的同名 key 同样跳过', () => {
const formState = { keyProp: 'origin' } as any;
const reservedKeys = new Set<string | symbol>(['keyProp']);
const state = {};
Object.defineProperty(state, 'keyProp', {
get: () => 'override',
enumerable: true,
configurable: true,
});
applyExtendState(formState, state, reservedKeys);
expect(formState.keyProp).toBe('origin');
});
test('未传 reservedKeys 时,只读 getter 字段被跳过并告警', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const formState = {} as any;
Object.defineProperty(formState, 'keyProp', {
get: () => 'readonly',
enumerable: true,
configurable: true,
});
applyExtendState(formState, { keyProp: 'newValue' });
expect(formState.keyProp).toBe('readonly');
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
test('未传 reservedKeys 时,存在 setter 的字段可被赋值', () => {
let inner = 'old';
const formState = {} as any;
Object.defineProperty(formState, 'writable', {
get: () => inner,
set: (v: string) => {
inner = v;
},
enumerable: true,
configurable: true,
});
applyExtendState(formState, { writable: 'new' });
expect(formState.writable).toBe('new');
});
test('未传 reservedKeys 时,普通可写字段正常覆盖赋值', () => {
const formState = { editable: 'old' } as any;
applyExtendState(formState, { editable: 'new' });
expect(formState.editable).toBe('new');
});
});

View File

@ -0,0 +1,181 @@
/*
* 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 { describe, expect, test } from 'vitest';
import { reactive, ref } from 'vue';
import type { FormState } from '@form/index';
import { createFormStateProxy, mergeFormContexts } from '@form/utils/formStateProxy';
const makeCore = (extra: Record<string, any> = {}): FormState =>
reactive({
config: [],
initValues: {},
lastValues: {},
isCompare: false,
values: {},
lastValuesProcessed: {},
$emit: () => undefined,
keyProp: '__key',
setField: () => undefined,
getField: () => undefined,
deleteField: () => undefined,
$messageBox: {} as any,
$message: {} as any,
...extra,
}) as FormState;
describe('createFormStateProxy', () => {
test('核心字段优先于 context', () => {
const formState = createFormStateProxy(makeCore({ keyProp: 'id' }), () => ({ keyProp: 'hacked', extra: 1 }));
expect(formState.keyProp).toBe('id');
expect((formState as any).extra).toBe(1);
});
test('Object.entries 能枚举到扩展字段', () => {
const formState = createFormStateProxy(makeCore(), () => ({ username: 'alice' }));
const keys = Object.keys(formState);
expect(keys).toContain('username');
expect(keys).toContain('values');
expect(Object.entries(formState).some(([k, v]) => k === 'username' && v === 'alice')).toBe(true);
});
test('set 写入 core读取优先于 context', () => {
const core = makeCore();
const formState = createFormStateProxy(core, () => ({ stage: 'from-context' }));
(formState as any).stage = 'assigned';
expect((formState as any).stage).toBe('assigned');
expect((core as any).stage).toBe('assigned');
});
test('has 在 core 或 context 命中时为 true', () => {
const formState = createFormStateProxy(makeCore(), () => ({ username: 'alice' }));
expect('values' in formState).toBe(true);
expect('username' in formState).toBe(true);
expect('missing' in formState).toBe(false);
});
test('symbol key 只走 core四个 trap 语义一致', () => {
const key = Symbol('vue-raw');
const core = makeCore();
(core as any)[key] = 'from-core';
const onlyInContext = Symbol('ctx-only');
const formState = createFormStateProxy(core, () => ({ [key]: 'from-context', [onlyInContext]: 1 }));
expect((formState as any)[key]).toBe('from-core');
// context 上的 symbol 不应被 has / ownKeys / getOwnPropertyDescriptor 报告为存在,
// 否则会与 get 返回 undefined 自相矛盾
expect(onlyInContext in formState).toBe(false);
expect(Object.getOwnPropertySymbols(formState)).not.toContain(onlyInContext);
expect(Object.getOwnPropertyDescriptor(formState, onlyInContext)).toBeUndefined();
expect((formState as any)[onlyInContext]).toBeUndefined();
});
test('core 上存在但值为 undefined 的字段不读穿 context', () => {
const formState = createFormStateProxy(makeCore({ parentValues: undefined }), () => ({ parentValues: { a: 1 } }));
expect(formState.parentValues).toBeUndefined();
});
test('getContext 返回 undefined 时读扩展字段不抛错', () => {
const formState = createFormStateProxy(makeCore(), () => undefined as any);
expect((formState as any).username).toBeUndefined();
expect('username' in formState).toBe(false);
});
test('getOwnPropertyDescriptor 对 core/context 都不存在的 key 返回 undefined', () => {
const formState = createFormStateProxy(makeCore(), () => ({ username: 'alice' }));
expect(Object.getOwnPropertyDescriptor(formState, 'nope')).toBeUndefined();
expect(Object.getOwnPropertyDescriptor(formState, 'username')?.enumerable).toBe(true);
});
test('getContext 可以是 Ref', () => {
const ctx = ref({ username: 'alice' });
const formState = createFormStateProxy(makeCore(), ctx);
expect((formState as any).username).toBe('alice');
ctx.value = { username: 'bob' };
expect((formState as any).username).toBe('bob');
});
});
describe('mergeFormContexts', () => {
test('靠后的层优先', () => {
const merged = mergeFormContexts({ a: 1, b: 1 }, { b: 2, c: 2 }, { c: 3 }) as any;
expect(merged.a).toBe(1);
expect(merged.b).toBe(2);
expect(merged.c).toBe(3);
});
test('accessor 保持读时求值,不在合并时被展开', () => {
let count = 0;
const layer = Object.defineProperties(
{},
{
stage: {
enumerable: true,
get: () => {
count += 1;
return count;
},
},
},
);
const merged = mergeFormContexts(layer) as any;
expect(count).toBe(0);
const multi = mergeFormContexts({ other: 1 }, layer) as any;
expect(count).toBe(0);
expect(multi.stage).toBe(1);
expect(multi.stage).toBe(2);
expect(merged.stage).toBe(3);
});
test('枚举语义覆盖所有层,且不重复', () => {
const merged = mergeFormContexts({ a: 1, b: 1 }, { b: 2, c: 2 });
expect(Object.keys(merged).sort()).toEqual(['a', 'b', 'c']);
expect(Object.entries(merged).sort()).toEqual([
['a', 1],
['b', 2],
['c', 2],
]);
});
test('忽略 undefined / null 层;零层与单层不额外包 Proxy', () => {
const only = { a: 1 };
expect(mergeFormContexts(undefined, null)).toEqual({});
expect(mergeFormContexts(undefined, only, null)).toBe(only);
// 空层数组每次返回同一个共享空对象
expect(mergeFormContexts()).toBe(mergeFormContexts(undefined));
});
test('has 与 getOwnPropertyDescriptor 与取值一致', () => {
const merged = mergeFormContexts({ a: 1 }, { b: 2 });
expect('a' in merged).toBe(true);
expect('b' in merged).toBe(true);
expect('missing' in merged).toBe(false);
expect(Object.getOwnPropertyDescriptor(merged, 'b')?.value).toBe(2);
expect(Object.getOwnPropertyDescriptor(merged, 'missing')).toBeUndefined();
});
});

View File

@ -169,9 +169,9 @@ describe('validateValues —— 基础校验', () => {
expect(error).toContain('数字');
});
test('extendState 注入的字段可被 display 函数读到', async () => {
const { props } = await (async () => {
const config: any = [
test('context 注入的字段可被 mForm 读穿', async () => {
const { error } = await validateValues({
config: [
{
type: 'text',
name: 'a',
@ -179,43 +179,61 @@ describe('validateValues —— 基础校验', () => {
rules: required(),
display: (mForm: any) => mForm?.custom === 'on',
},
];
const { error } = await validateValues({
config,
initValues: { a: '' },
extendState: () => ({ custom: 'on' }),
});
return { props: error };
})();
expect(props).toBe('A -> 必填');
});
test('extendState 抛错时不影响校验流程', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { error } = await validateValues({
config: [{ type: 'text', name: 'a', text: 'A', rules: required() }] as any,
] as any,
initValues: { a: '' },
extendState: () => {
throw new Error('boom');
},
context: { custom: 'on' },
});
expect(error).toBe('A -> 必填');
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('extendState 不能覆盖 formState 内置字段', async () => {
test('context 不能覆盖 formState 内置字段', async () => {
const { values } = await validateValues({
config: [{ type: 'text', name: 'a', text: 'A' }] as any,
initValues: { a: 'origin' },
extendState: () => ({ keyProp: 'hacked', initValues: { a: 'hacked' } }),
context: { keyProp: 'hacked', initValues: { a: 'hacked' } } as any,
});
expect(values).toEqual({ a: 'origin' });
});
test('context 注入的字段可被 display 通过 mForm 读到', async () => {
const { error } = await validateValues({
config: [
{
type: 'text',
name: 'a',
text: 'A',
rules: required(),
display: (mForm: any) => mForm?.custom === 'on',
},
] as any,
initValues: { a: '' },
context: { custom: 'on' },
});
expect(error).toBe('A -> 必填');
});
test('同一次校验中各回调从 mForm 读到同一份 context', async () => {
const seen: any[] = [];
const display = (mForm: any) => {
seen.push({ username: mForm?.username, env: mForm?.env });
return true;
};
await validateValues({
config: [
{ type: 'text', name: 'a', text: 'A', display },
{ type: 'text', name: 'b', text: 'B', display },
] as any,
initValues: { a: '1', b: '2' },
context: { username: 'alice', env: 'prod' },
});
expect(seen.length).toBeGreaterThan(1);
expect(seen.every((c) => c.username === 'alice' && c.env === 'prod')).toBe(true);
});
});
describe('validateValues —— display 判定', () => {

View File

@ -79,20 +79,7 @@ describe('validateForm', () => {
expect(error).toBe('');
});
test('支持 extendState 扩展状态', async () => {
const extendState = vi.fn(async () => ({ extra: 'value' }));
await validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'foo' },
extendState,
});
expect(extendState).toHaveBeenCalled();
});
test('tab 的 display 函数读取 extendState 注入的值时不会因竞态崩溃', async () => {
// 无渲染实现下 extendState 一定先于遍历完成,不存在渲染式实现里的时序竞态
test('tab 的 display 函数可读取 context 注入的 services', async () => {
const error = await validateForm({
config: [
{
@ -111,7 +98,7 @@ describe('validateForm', () => {
},
],
initValues: { name: 'test' },
extendState: () => ({ services: { uiService: { get: () => false } } }),
context: { services: { uiService: { get: () => false } } } as any,
});
expect(error).toBe('');