mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-10 18:45:06 +08:00
feat(form): 用 FormContext 与 provide/inject 替代 extendState 钩子
统一表单业务上下文的注入方式,通过读穿 Proxy 保持 mForm 回调签名不变,并移除 extendFormState/extendState 等分散的扩展钩子。
This commit is contained in:
parent
f2ee7ae7b5
commit
a7999f50d5
@ -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
|
||||
|
||||
@ -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>>`
|
||||
|
||||
@ -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` 获取 |
|
||||
|
||||
@ -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) 自定义判断函数避免被误判为差异。
|
||||
|
||||
|
||||
@ -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');
|
||||
};
|
||||
|
||||
|
||||
@ -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>(() => {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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[];
|
||||
/** 页面顺序拖拽配置参数 */
|
||||
|
||||
@ -124,7 +124,7 @@ export const createDisplayCondsConfig = (
|
||||
copyable: true,
|
||||
movable: false,
|
||||
flat: true,
|
||||
labelWidth: 80,
|
||||
labelWidth: '80px',
|
||||
...stickyAddButton('新增条件'),
|
||||
items: [
|
||||
fieldItem,
|
||||
|
||||
@ -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,
|
||||
|
||||
40
packages/editor/src/hooks/use-form-context.ts
Normal file
40
packages/editor/src/hooks/use-form-context.ts
Normal 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'),
|
||||
};
|
||||
});
|
||||
@ -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',
|
||||
|
||||
@ -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 能拿到完整的业务上下文。
|
||||
* 未提供时为 undefined,CompareForm/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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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)。
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
},
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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` 是 ref,Vue 的 `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))) {
|
||||
|
||||
@ -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: () => [],
|
||||
|
||||
@ -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 后,内部子树
|
||||
|
||||
@ -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 后,内部子树
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
// 合并逻辑收口在 applyExtendState:props 派生的只读 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;
|
||||
|
||||
@ -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;
|
||||
|
||||
104
packages/form/src/utils/formStateProxy.ts
Normal file
104
packages/form/src/utils/formStateProxy.ts
Normal 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 与宿主业务上下文关联:读取时优先 core,miss 再读穿到 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;
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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 之后再执行字段的值初始化写入
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
78
packages/form/tests/unit/containers/ActionsColumn.spec.ts
Normal file
78
packages/form/tests/unit/containers/ActionsColumn.spec.ts
Normal 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('复制');
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
181
packages/form/tests/unit/utils/formStateProxy.spec.ts
Normal file
181
packages/form/tests/unit/utils/formStateProxy.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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 判定', () => {
|
||||
|
||||
@ -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('');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user