Compare commits

..

13 Commits

Author SHA1 Message Date
roymondchen
39ef49db7d fix(tmagic-form): 合并宿主 $MAGIC_FORM 与 formOptions,避免二次安装清空 request 2026-09-14 21:19:52 +08:00
roymondchen
5a3edacb96 fix(editor): 画布 drop 仅还原内部拖拽数据,避免外部源触发 parseDSL 执行
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 15:22:02 +08:00
roymondchen
911cecfb1d chore: release v1.8.0-beta.28 2026-09-09 11:25:18 +08:00
roymondchen
2a4a54a495 feat(form): group-list 标题吸顶改为按 header.sticky 显式开启
默认关闭避免长列表误吸顶,嵌套层按外层真实标题/吸底按钮高度让位;属性面板列表通过 stickyAddButton 保持原表现。
2026-09-09 11:19:54 +08:00
roymondchen
7e4546899b test(editor): 补充历史回滚单测并精简确认弹窗文案 2026-09-09 11:17:22 +08:00
roymondchen
66b54a52f8 fix(editor): 丢弃过期 root 快照,避免画布回退与 modelValue 回写死循环 2026-09-07 19:02:59 +08:00
roymondchen
7edb01d330 chore: update lockfile v1.8.0-beta.27 2026-09-04 11:56:13 +08:00
roymondchen
5fa9b37856 chore: release v1.8.0-beta.27 2026-09-04 11:34:03 +08:00
roymondchen
d1cb8c3853 fix(form): 避免 Select init 请求失败或路径缺失时抛出未处理异常 2026-09-04 11:27:06 +08:00
roymondchen
092bc2914d feat(data-source): 新增数据源 mounted 生命周期
页面渲染完成后由 runtime 触发 mounted 事件,统一管理数据源 mounted 及 timing=mounted 的方法执行;支持异步注册数据源在 init 后补充 mounted。
2026-09-03 20:27:18 +08:00
roymondchen
959c0322b6 fix(editor): 修复 eventsService resetState 响应式失效并在编辑器重置时清空
resetState 改为原地清空 reactive 对象以保留代理身份,避免 computed 不更新。
编辑器 reset 时同步调用 eventsService.resetState,并补充文档与单测。
2026-09-03 20:02:03 +08:00
roymondchen
b0d43dc277 fix(editor,form): 合并宿主 FORM_CONTEXT 并修复 formState mForm 自引用
编辑器不再遮蔽外层 provide 的业务上下文。
formState Proxy 支持 Vue2 时代 vm.mForm 跨字段通信,
并修复 context 自引用导致的栈溢出。
Select 对 valueKey 路径使用可选链。
2026-09-02 16:44:01 +08:00
roymondchen
ad2f4e15cc docs: 修正类型定义引用路径并为 editor 类型添加 region 标记 2026-09-01 20:01:04 +08:00
73 changed files with 3042 additions and 558 deletions

View File

@ -1,3 +1,27 @@
# [1.8.0-beta.28](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.27...v1.8.0-beta.28) (2026-09-09)
### Bug Fixes
* **editor:** 丢弃过期 root 快照,避免画布回退与 modelValue 回写死循环 ([66b54a5](https://github.com/Tencent/tmagic-editor/commit/66b54a52f8e9ff5041305ab7eb4057c820a4542a))
### Features
* **form:** group-list 标题吸顶改为按 header.sticky 显式开启 ([2a4a54a](https://github.com/Tencent/tmagic-editor/commit/2a4a54a49567007787295769e3f8d05e3e8a30e0))
# [1.8.0-beta.27](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.26...v1.8.0-beta.27) (2026-09-04)
### Bug Fixes
* **editor,form:** 合并宿主 FORM_CONTEXT 并修复 formState mForm 自引用 ([b0d43dc](https://github.com/Tencent/tmagic-editor/commit/b0d43dc2775a2241499de9ab1b165a5ad3febcf3))
* **editor:** 修复 eventsService resetState 响应式失效并在编辑器重置时清空 ([959c032](https://github.com/Tencent/tmagic-editor/commit/959c0322b6885ee00c38bc830f58841ccbb42a40))
* **form:** 避免 Select init 请求失败或路径缺失时抛出未处理异常 ([d1cb8c3](https://github.com/Tencent/tmagic-editor/commit/d1cb8c3853ac724b424dbe8de2d2a63bf6302324))
### Features
* **data-source:** 新增数据源 mounted 生命周期 ([092bc29](https://github.com/Tencent/tmagic-editor/commit/092bc2914d444c9527140a204ab8bc2776d31a1b))
# [1.8.0-beta.26](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.25...v1.8.0-beta.26) (2026-09-01)

View File

@ -16,7 +16,11 @@
- **详情:**
批量设置多个组件类型的事件列表
批量设置多个组件类型的事件列表。增量合并语义:只写入入参中出现的类型,已有的其他类型保持不变。
::: tip 切换 runtime 版本
不同版本的 runtime 事件表不同,直接调用 `setEvents` 会让上一个版本独有的类型残留。是否需要清掉上一个版本的数据由业务自行决定,需要清掉时先调用 [`resetState`](#resetstate) 再写入新表。
:::
- **示例:**
@ -47,7 +51,7 @@ eventsService.setEvents({
- **详情:**
设置指定组件类型的事件列表
设置指定组件类型的事件列表,只影响该类型
- **示例:**
@ -106,7 +110,7 @@ eventsService.usePlugin({
- **详情:**
批量设置多个组件类型的方法列表
批量设置多个组件类型的方法列表。语义与 `setEvents` 一致:增量合并,入参中不存在的类型保持不变。
- **示例:**
@ -138,7 +142,7 @@ eventsService.setMethods({
- **详情:**
设置指定组件类型的方法列表
设置指定组件类型的方法列表,只影响该类型
- **示例:**
@ -209,14 +213,17 @@ eventsService.usePlugin({
- **详情:**
重置事件服务状态,清空所有事件和方法配置
重置事件服务状态,清空所有事件和方法配置`setEvents` / `setMethods` 是增量合并,切换 runtime 版本这类需要丢弃上一份事件表的场景,由业务在写入新表前自行调用本方法。
- **示例:**
```js
import { eventsService } from '@tmagic/editor';
// 切到另一个版本的 runtime先清空再写入该版本的事件表
eventsService.resetState();
eventsService.setEvents(nextVersionEvents);
eventsService.setMethods(nextVersionMethods);
```
## destroy

View File

@ -630,6 +630,10 @@ const propsValues = {
该属性最终会设置到[eventsService](./eventsServiceMethods.md)中,所以也可直接调用[eventsService.setEvents()](./eventsServiceMethods.md#setEvents)与[eventsService.setMethods()](./eventsServiceMethods.md#setMethods)方法来配置
:::
:::warning
该属性每次变更都是增量合并,不会清掉上一次写入的类型。切换 runtime 版本时若需要丢弃旧版本的事件表,请自行调用 [eventsService.resetState()](./eventsServiceMethods.md#resetstate)
:::
- **默认值:** `{}`
- **类型:** `Record<string, { events: EventOption[]; methods: EventOption[] }>`

View File

@ -322,9 +322,9 @@ CJS 产物是两个各自独立的 bundle注册表不共享。所以同一进
## 类型定义
::: details 查看 `SubmitFormOptions` 类型定义
<<< @/../packages/form/src/submitForm.ts#SubmitFormOptions{ts}
<<< @/../packages/form/src/utils/submitHeadless.ts#SubmitFormOptions{ts}
:::
::: details 查看 `SubmitFormResult` 类型定义
<<< @/../packages/form/src/submitForm.ts#SubmitFormResult{ts}
<<< @/../packages/form/src/utils/submitHeadless.ts#SubmitFormResult{ts}
:::

View File

@ -2,7 +2,7 @@
## 配置类型
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig 配置类型定义
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig / GroupListHeaderConfig 配置类型定义
<<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts}
<<< @/../packages/form-schema/src/base.ts#RowConfig{ts}
@ -27,6 +27,8 @@
<<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts}
<<< @/../packages/form-schema/src/base.ts#GroupListHeaderConfig{ts}
<<< @/../packages/form-schema/src/base.ts#FormItem{ts}
:::
@ -211,6 +213,27 @@
}]
}]"></demo-block>
#### 标题吸顶
列表项较多、需要边滚动边看清当前编辑的是哪一项时,可以用 `header.sticky` 让卡片标题吸顶:
```ts
{
type: 'groupList',
name: 'group',
header: { sticky: true },
items: [/* ... */],
}
```
吸顶默认关闭,需要逐层显式开启:外层开了不会带动内层,嵌套列表想一起吸顶就各自配一次,内层标题会自动下移让开外层标题。
标题让位的高度默认按 65px 计算。如果自定义了卡片标题的样式导致实际高度不同、内层标题出现重叠或空隙,用 `header.height` 告知真实高度即可(它只影响内层的让位距离,不会改变标题本身的高度)。
::: warning 行为变更
1.8.0-beta.28 之前group-list 的卡片标题在任何情况下都会吸顶。现在改为默认关闭、由 `header.sticky` 显式开启。升级后如需保持原有表现,请在对应配置上补上 `header: { sticky: true }`
:::
### table
<demo-block type="form" :config="[{

View File

@ -43,6 +43,7 @@ new DataSource(options: DataSourceOptions)
| `methods` | `CodeBlockContent[]` | 自定义方法配置 |
| `data` | `any` | 当前数据 |
| `isInit` | `boolean` | 是否已初始化 |
| `isMounted` | `boolean` | 页面渲染后的 `mounted` 是否已执行 |
## 实例方法
@ -172,6 +173,28 @@ ds.onDataChange('user.name', (payload) => {
初始化数据源。
### mounted
- **返回:**
- `{Promise<void>}`
- **详情:**
页面渲染完成后执行,执行后 `isMounted``true`。由 `DataSourceManager` 监听到 `mounted` 事件后统一调用,自定义数据源可以重写该方法实现「页面渲染后」的逻辑。
- **示例:**
```typescript
class CustomDataSource extends DataSource {
public async mounted() {
// 页面渲染后再拉取数据,避免阻塞首屏
this.setData(await fetchSomething());
await super.mounted();
}
}
```
### destroy
- **返回:**

View File

@ -25,6 +25,7 @@ new DataSourceManager(options: DataSourceManagerOptions)
| `data` | `DataSourceManagerData` | 所有数据源的数据 |
| `initialData` | `DataSourceManagerData` | 初始化数据 |
| `useMock` | `boolean` | 是否使用 Mock 数据 |
| `isMounted` | `boolean` | 页面是否已渲染完成(收到过 `mounted` 事件) |
## 静态方法
@ -109,6 +110,27 @@ if (ds) {
}
```
### mounted
- **参数:**
- `{DataSource} ds` 单个数据源实例(必填)
- **返回:**
- `{Promise<void>}`
- **详情:**
页面渲染完成后执行单个数据源的 `mounted`:先调用 `ds.mounted()`,再依次执行 `methods``timing === 'mounted'` 的方法。当 `ds.isMounted``true`,或当前 `app.jsEngine` 命中 `ds.schema.disabledInitInJsEngine` 时直接跳过。
一般不需要手动调用runtime 在顶层组件渲染完成后触发 `mounted` 事件,由 manager 统一对所有数据源调用该方法。
- **示例:**
```typescript
// runtime 顶层组件渲染完成后
app.dataSourceManager?.emit('mounted');
```
### get
- **参数:**
@ -184,7 +206,7 @@ const ds = dataSourceManager.addDataSource({
- **详情:**
同步更新数据源 DSL 配置:先按 `id` 移除已有数据源,再以 `cloneDeep` 重新 `addDataSource`,并对新建实例触发 `init`(异步执行,不会被该方法 `await`)。一般在编辑器中修改配置后调用。
同步更新数据源 DSL 配置:先按 `id` 移除已有数据源,再以 `cloneDeep` 重新 `addDataSource`,并对新建实例触发 `init`(异步执行,不会被该方法 `await`;若此时 `isMounted` 已为 `true``init` 完成后还会补充执行 `mounted`。一般在编辑器中修改配置后调用。
### compiledNode
@ -325,6 +347,7 @@ DataSourceManager 继承自 EventEmitter支持以下事件
|--------|------|----------|
| `change` | 单个数据源数据变化 | `(dsId: string, changeEvent: ChangeEvent)` |
| `init` | 所有数据源初始化完成;现代分支携带 `(data, errors)`,旧 Promise.all 分支为 `(this.data)` | `(data, errors?)` |
| `mounted` | 由 runtime 在顶层组件渲染完成后触发manager 收到后统一执行各数据源的 `mounted` | 无 |
| `registered-all` | 所有数据源注册完成 | 无 |
| `update-data` | 由 `createDataSourceManager` 在数据变化后发出,用于通知节点重新渲染 | `(newNodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id)` |

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "tmagic",
"private": true,
"type": "module",

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/cli",
"type": "module",
"main": "lib/index.js",

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/core",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/data-source",
"type": "module",
"sideEffects": false,

View File

@ -57,7 +57,7 @@ class DataSourceManager extends EventEmitter {
for (let config = list.shift(); config; config = list.shift()) {
const ds = app.addDataSource(config);
if (ds) {
app.init(ds);
app.initAndMount(ds);
}
}
});
@ -84,6 +84,8 @@ class DataSourceManager extends EventEmitter {
public data: DataSourceManagerData = {};
public initialData: DataSourceManagerData = {};
public useMock?: boolean = false;
/** 页面是否已经渲染完成 */
public isMounted = false;
constructor({ app, useMock, initialData }: DataSourceManagerOptions) {
super();
@ -109,6 +111,12 @@ class DataSourceManager extends EventEmitter {
this.callDsInit();
});
}
// 由runtime在顶层组件渲染完成后触发
this.on('mounted', () => {
this.isMounted = true;
this.callDsMounted();
});
}
public async init(ds: DataSource) {
@ -137,6 +145,29 @@ class DataSourceManager extends EventEmitter {
}
}
/**
* mounted
* @param {DataSource} ds
*/
public async mounted(ds: DataSource) {
if (ds.isMounted) {
return;
}
if (this.app.jsEngine && ds.schema.disabledInitInJsEngine?.includes(this.app.jsEngine)) {
return;
}
await ds.mounted?.();
for (const method of ds.methods) {
if (typeof method.content !== 'function') return;
if (method.timing === 'mounted') {
await method.content({ params: {}, dataSource: ds, app: this.app });
}
}
}
public get(id: string) {
return this.dataSourceMap.get(id);
}
@ -219,7 +250,7 @@ class DataSourceManager extends EventEmitter {
this.addDataSource(cloneDeep(schema));
const newDs = this.get(schema.id);
if (newDs) {
this.init(newDs);
this.initAndMount(newDs);
}
}
}
@ -390,6 +421,28 @@ class DataSourceManager extends EventEmitter {
});
}
}
private callDsMounted() {
const promises = Array.from(this.dataSourceMap).map(([, ds]) => this.mounted(ds));
if (typeof Promise.allSettled === 'function') {
return Promise.allSettled(promises);
}
return Promise.all(promises.map((promise) => promise.catch(() => undefined)));
}
/**
* mounted
* @param {DataSource} ds
*/
private async initAndMount(ds: DataSource) {
await this.init(ds);
if (this.isMounted) {
await this.mounted(ds);
}
}
}
export default DataSourceManager;

View File

@ -31,6 +31,7 @@ import type { ChangeEvent, DataSourceOptions } from '@data-source/types';
*/
export default class DataSource<T extends DataSourceSchema = DataSourceSchema> extends EventEmitter {
public isInit = false;
public isMounted = false;
/** @tmagic/core 实例 */
public app: TMagicApp;
@ -152,6 +153,13 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
this.isInit = true;
}
/**
* DataSourceManager mounted
*/
public async mounted() {
this.isMounted = true;
}
public destroy() {
this.#fields = [];
this.removeAllListeners();

View File

@ -38,6 +38,25 @@ describe('DataSource', () => {
expect(ds.isInit).toBeTruthy();
});
test('mounted', async () => {
const ds = new DataSource({
schema: {
type: 'base',
id: '1',
fields: [{ name: 'name' }],
methods: [],
events: [],
},
app: new App({}),
});
expect(ds.isMounted).toBe(false);
await ds.mounted();
expect(ds.isMounted).toBe(true);
});
});
describe('DataSource setData', () => {

View File

@ -302,6 +302,227 @@ describe('DataSourceManager - init 生命周期', () => {
});
});
describe('DataSourceManager - mounted 生命周期', () => {
afterEach(() => {
DataSourceManager.clearDataSourceClass();
});
// 等待 mounted 事件触发的异步流程执行完
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const createApp = (id: string, dataSources: any[] = [], jsEngine?: any) =>
new TMagicApp({
...(jsEngine ? { jsEngine } : {}),
config: {
type: NodeType.ROOT,
id,
items: [],
dataSources,
},
} as any);
test('mounted 事件会统一执行所有数据源的 mounted', async () => {
const app = createApp('app_mounted', [
{ type: 'base', id: 'ds_m1', fields: [], methods: [], events: [] },
{ type: 'base', id: 'ds_m2', fields: [], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
expect(dsm.isMounted).toBe(false);
expect(dsm.get('ds_m1')?.isMounted).toBe(false);
dsm.emit('mounted');
await flush();
expect(dsm.isMounted).toBe(true);
expect(dsm.get('ds_m1')?.isMounted).toBe(true);
expect(dsm.get('ds_m2')?.isMounted).toBe(true);
});
test('methods 中 timing=mounted 的 content 会在 ds.mounted 之后调用', async () => {
const app = createApp('app_mounted_method');
const dsm = new DataSourceManager({ app });
const order: string[] = [];
const mountedContent = vi.fn(() => {
order.push('method');
});
const ds = new DataSource({
app,
schema: {
type: 'base',
id: 'ds_mounted_method',
fields: [],
events: [],
methods: [{ name: 'onMounted', content: mountedContent, timing: 'mounted', params: [] }],
} as any,
});
const origMounted = ds.mounted.bind(ds);
ds.mounted = async () => {
order.push('mounted');
await origMounted();
};
await dsm.mounted(ds);
expect(mountedContent).toHaveBeenCalledTimes(1);
const arg = mountedContent.mock.calls[0][0] as any;
expect(arg.dataSource).toBe(ds);
expect(arg.app).toBe(app);
expect(order).toEqual(['mounted', 'method']);
});
test('ds.isMounted 为 true 时直接跳过', async () => {
const app = createApp('app_mounted_skip');
const dsm = new DataSourceManager({ app });
const content = vi.fn();
const ds = new DataSource({
app,
schema: {
type: 'base',
id: 'ds_mounted_skip',
fields: [],
events: [],
methods: [{ name: 'onMounted', content, timing: 'mounted', params: [] }],
} as any,
});
await dsm.mounted(ds);
await dsm.mounted(ds);
expect(content).toHaveBeenCalledTimes(1);
});
test('jsEngine 命中 disabledInitInJsEngine 时跳过 mounted', async () => {
const app = createApp('app_mounted_disabled', [], 'nodejs');
const dsm = new DataSourceManager({ app });
const ds = new DataSource({
app,
schema: {
type: 'base',
id: 'ds_mounted_disabled',
fields: [],
methods: [],
events: [],
disabledInitInJsEngine: ['nodejs'],
} as any,
});
await dsm.mounted(ds);
expect(ds.isMounted).toBe(false);
});
test('method.content 非函数时提前返回', async () => {
const app = createApp('app_mounted_bad');
const dsm = new DataSourceManager({ app });
const content = vi.fn();
const ds = new DataSource({
app,
schema: {
type: 'base',
id: 'ds_mounted_bad',
fields: [],
events: [],
methods: [
{ name: 'bad', content: 'not-a-function', timing: 'mounted', params: [] } as any,
{ name: 'onMounted', content, timing: 'mounted', params: [] } as any,
],
} as any,
});
await dsm.mounted(ds);
expect(ds.isMounted).toBe(true);
expect(content).not.toHaveBeenCalled();
});
test('mounted 抛错时不会产生未处理的异常', async () => {
const mountedSpy = vi.spyOn(DataSource.prototype, 'mounted').mockRejectedValue(new Error('mounted-boom'));
try {
const app = createApp('app_mounted_err', [{ type: 'base', id: 'ds_me', fields: [], methods: [], events: [] }]);
const dsm = new DataSourceManager({ app });
dsm.emit('mounted');
await flush();
expect(dsm.isMounted).toBe(true);
} finally {
mountedSpy.mockRestore();
}
});
test('Promise.allSettled 不可用时走 Promise.all 兼容分支', async () => {
const original = Promise.allSettled;
(Promise as any).allSettled = undefined;
const mountedSpy = vi.spyOn(DataSource.prototype, 'mounted').mockRejectedValueOnce(new Error('compat-boom'));
try {
const app = createApp('app_mounted_compat', [
{ type: 'base', id: 'ds_mc1', fields: [], methods: [], events: [] },
{ type: 'base', id: 'ds_mc2', fields: [], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
dsm.emit('mounted');
await flush();
expect(mountedSpy).toHaveBeenCalledTimes(2);
expect(dsm.get('ds_mc2')?.isMounted).toBe(true);
} finally {
(Promise as any).allSettled = original;
mountedSpy.mockRestore();
}
});
test('页面渲染后注册的数据源类型会在 init 之后补充执行 mounted', async () => {
const app = createApp('app_mounted_register', [
{ type: 'mounted-late', id: 'ds_late', fields: [], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
expect(dsm.get('ds_late')).toBeUndefined();
dsm.emit('mounted');
await flush();
class LateDataSource extends DataSource {}
DataSourceManager.register('mounted-late', LateDataSource as any);
await flush();
expect(dsm.get('ds_late')?.isInit).toBe(true);
expect(dsm.get('ds_late')?.isMounted).toBe(true);
});
test('页面渲染后 updateSchema 重建的数据源会执行 mounted', async () => {
const app = createApp('app_mounted_update', [
{ type: 'base', id: 'ds_up', fields: [{ name: 'a' }], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
dsm.emit('mounted');
await flush();
dsm.updateSchema([{ type: 'base', id: 'ds_up', fields: [{ name: 'b' }], methods: [], events: [] }]);
await flush();
expect(dsm.get('ds_up')?.isMounted).toBe(true);
});
test('页面未渲染时 updateSchema 不会执行 mounted', async () => {
const app = createApp('app_update_not_mounted', [
{ type: 'base', id: 'ds_nm', fields: [{ name: 'a' }], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
dsm.updateSchema([{ type: 'base', id: 'ds_nm', fields: [{ name: 'b' }], methods: [], events: [] }]);
await flush();
expect(dsm.get('ds_nm')?.isInit).toBe(true);
expect(dsm.get('ds_nm')?.isMounted).toBe(false);
});
});
describe('DataSourceManager - addDataSource 边界', () => {
afterEach(() => {
DataSourceManager.clearDataSourceClass();

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/dep",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/design",
"type": "module",
"sideEffects": [

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/editor",
"type": "module",
"sideEffects": [

View File

@ -17,12 +17,13 @@
*/
/**
* group-list
* group-list
*
*
*
*/
export const stickyAddButton = (text: string) => ({
scrollLastItemIntoView: true as const,
header: { sticky: true as const },
addButtonConfig: {
sticky: true as const,
text,

View File

@ -16,9 +16,9 @@
* limitations under the License.
*/
import { computed, type ComputedRef } from 'vue';
import { computed, type ComputedRef, inject, unref } from 'vue';
import type { FormContext } from '@tmagic/form';
import { FORM_CONTEXT_KEY, type FormContext, mergeFormContexts } from '@tmagic/form';
import type { Services } from '@editor/type';
@ -28,13 +28,29 @@ import type { Services } from '@editor/type';
* `Editor.vue`provide `FORM_CONTEXT_KEY``FormPanel.vue` `useCompareForm`
* `stage` computed
*
* 宿 `<MEditor>` provide
* provide 宿
* 宿`services` / `stage` 宿
*
* `@editor/type` `@tmagic/form-schema` `FormContext`
*/
export const useEditorFormContext = (getServices: () => Services | undefined): ComputedRef<FormContext> =>
computed(() => {
export const useEditorFormContext = (getServices: () => Services | undefined): ComputedRef<FormContext> => {
// inject 只能在 setup 期取值,因此本 hook 必须在 setup 中调用(`use` 前缀即此约定)。
// 返回的 computed 可以随便传递,但 hook 本身不能延迟到事件回调或 onMounted 里再调。
const hostContext = inject(FORM_CONTEXT_KEY, undefined);
return computed(() => {
const services = getServices();
return {
const host = unref(hostContext);
// 上层(通常是 Editor.vue已经用同一份 services 合并过了。FormPanel / useCompareForm
// 都是它的后代,再合并一次只会多套一层 Proxy让每次属性 miss 多一轮线性查找。
// services 相同即可判定 stage 也相同——两边都是从同一个 editorService 读的。
if (host && host.services === services) return host;
return mergeFormContexts(host, {
services,
stage: services?.editorService.get('stage'),
};
});
});
};

View File

@ -254,6 +254,7 @@ export const initServiceState = (
uiService.resetState();
componentListService.resetState();
codeBlockService.resetState();
eventsService.resetState();
keybindingService.reset();
depService.reset();
});
@ -426,12 +427,26 @@ export const initServiceEvents = (
});
};
/**
* root root
*
* `editorService.set('root', v)` `root-change` `v`
* root `root-change` stage / runtime / root
* DSL root `v`
* root
*/
const isStaleRoot = (value: MApp | null) => toRaw(editorService.get('root')) !== toRaw(value);
const updateStageDsl = async (value: MApp | null) => {
const stage = await getStage();
const runtime = await stage.renderer?.getRuntime();
const app = await getTMagicApp();
// 等 stage / runtime 就绪期间 root 已被替换:新 root 的刷新可能已经完成,
// 再把旧 dsl 推给 runtime 会让画布回退到旧内容
if (isStaleRoot(value)) return;
if (!app?.dataSourceManager) {
runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!);
}
@ -448,6 +463,8 @@ export const initServiceEvents = (
await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value));
if (isStaleRoot(value)) return;
const dsl = cloneDeep(toRaw(value));
if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) {
for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) {
@ -465,7 +482,7 @@ export const initServiceEvents = (
depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
};
const rootChangeHandler = (value: MApp | null, preValue?: MApp | null) => {
const rootChangeHandler = (value: MApp | null) => {
if (!value) return;
value.codeBlocks = value.codeBlocks || {};
@ -508,7 +525,13 @@ export const initServiceEvents = (
editorService.set('page', null);
}
if (toRaw(value) !== toRaw(preValue)) {
// 上面的 select 是异步的,期间 root 可能已被替换,过期快照不能再回写给外部:
// 两次整体替换各自持有一个快照时,回写会把对方的 root 顶掉,外部 modelValue 变化又会
// 重新 set root两条链路无休止地交替下去表现为编辑器卡死
if (isStaleRoot(value)) return;
// 外部已经持有这个 root如本次变化就是 modelValue 传进来的)时无需回写
if (toRaw(props.modelValue) !== toRaw(value)) {
emit('update:modelValue', value);
}
})();

View File

@ -204,10 +204,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
*
* false
*/
const confirmRevert = (): Promise<boolean> =>
confirmHistoryAction(
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
);
const confirmRevert = (): Promise<boolean> => confirmHistoryAction('确定回滚该步骤吗?');
/**
* HistoryDiffDialog

View File

@ -56,7 +56,14 @@ import { calcValueByFontsize, getIdFromEl } from '@tmagic/utils';
import ScrollViewer from '@editor/components/ScrollViewer.vue';
import { useServices } from '@editor/hooks';
import { useStage } from '@editor/hooks/use-stage';
import type { CustomContentMenuFunction, MenuButton, MenuComponent, StageOptions, StageSlots } from '@editor/type';
import type {
AddMNode,
CustomContentMenuFunction,
MenuButton,
MenuComponent,
StageOptions,
StageSlots,
} from '@editor/type';
import { DragType, Layout } from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import { KeyBindingContainerKey } from '@editor/utils/keybinding-config';
@ -270,11 +277,56 @@ const resizeObserver = new globalThis.ResizeObserver((entries) => {
}
});
const parseDSL = getEditorConfig('parseDSL');
/**
* 本次拖拽是否由编辑器文档内部发起
*
* drop text/json 里可能带函数组件配置的事件钩子等还原只能交给 parseDSL
* parseDSL 的默认实现是 evalHTML 拖放又允许其他源的页面在 DataTransfer 中投递
* 自定义 MIME 数据跨源页面只要诱导用户拖拽一次就能让 eval 执行任意脚本
*
* 跨源页面既不会在本文档触发 dragstart也无法往本文档创建的 DataTransfer 中写数据
* 因此只有起源于本文档的拖拽才交给 parseDSL 还原
* 同源拖拽源写入的 text/json 由业务保证可信
*/
let isInternalDrag = false;
let internalDragSession = 0;
let clearInternalDragTimer: ReturnType<typeof setTimeout> | undefined;
const documentDragStartHandler = () => {
internalDragSession += 1;
isInternalDrag = true;
if (clearInternalDragTimer !== undefined) {
globalThis.clearTimeout(clearInternalDragTimer);
clearInternalDragTimer = undefined;
}
};
const documentDragEndHandler = () => {
// WebKit dragend drop
// drop session dragstart
const session = internalDragSession;
if (clearInternalDragTimer !== undefined) {
globalThis.clearTimeout(clearInternalDragTimer);
}
clearInternalDragTimer = globalThis.setTimeout(() => {
clearInternalDragTimer = undefined;
if (session === internalDragSession) {
isInternalDrag = false;
}
}, 0);
};
onMounted(() => {
if (stageWrapRef.value?.container) {
resizeObserver.observe(stageWrapRef.value.container);
keybindingService.registerEl(KeyBindingContainerKey.STAGE, stageWrapRef.value.container);
}
// dragstart
globalThis.document.addEventListener('dragstart', documentDragStartHandler, true);
globalThis.document.addEventListener('dragend', documentDragEndHandler, true);
});
onBeforeUnmount(() => {
@ -283,9 +335,15 @@ onBeforeUnmount(() => {
resizeObserver.disconnect();
editorService.set('stage', null);
keybindingService.unregisterEl('stage');
});
const parseDSL = getEditorConfig('parseDSL');
if (clearInternalDragTimer !== undefined) {
globalThis.clearTimeout(clearInternalDragTimer);
clearInternalDragTimer = undefined;
}
globalThis.document.removeEventListener('dragstart', documentDragStartHandler, true);
globalThis.document.removeEventListener('dragend', documentDragEndHandler, true);
});
const contextmenuHandler = (e: MouseEvent) => {
e.preventDefault();
@ -299,15 +357,27 @@ const dragoverHandler = (e: DragEvent) => {
};
const dropHandler = async (e: DragEvent) => {
// drop text/json drop
const allowed = isInternalDrag;
isInternalDrag = false;
if (!e.dataTransfer) return;
const data = e.dataTransfer.getData('text/json');
if (!data) return;
// parseDSL( eval)
if (!data || !allowed) return;
const config = parseDSL(`(${data})`);
let config: { dragType?: string; data?: AddMNode } | undefined;
try {
config = parseDSL(`(${data})`);
} catch {
return;
}
if (!config || config.dragType !== DragType.COMPONENT_LIST) return;
if (!config || config.dragType !== DragType.COMPONENT_LIST || !config.data) return;
const dragData = config.data;
e.preventDefault();
@ -349,7 +419,7 @@ const dropHandler = async (e: DragEvent) => {
const containerRect = stageContainerEl.value.getBoundingClientRect();
const { scrollTop, scrollLeft } = stage.mask!;
const { style = {} } = config.data;
const { style = {} } = dragData;
let top = 0;
let left = 0;
@ -371,16 +441,16 @@ const dropHandler = async (e: DragEvent) => {
}
}
config.data.style = {
dragData.style = {
...style,
position,
top: calcValueByFontsize(doc, top / zoom.value),
left: calcValueByFontsize(doc, left / zoom.value),
};
config.data.inputEvent = e;
dragData.inputEvent = e;
editorService.add(config.data, parent, { historySource: 'component-panel' });
editorService.add(dragData, parent, { historySource: 'component-panel' });
}
};
</script>

View File

@ -35,8 +35,8 @@ const canUsePluginMethods = {
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
let eventMap: Record<string, EventOption[]> = reactive({});
let methodMap: Record<string, EventOption[]> = reactive({});
const eventMap: Record<string, EventOption[]> = reactive({});
const methodMap: Record<string, EventOption[]> = reactive({});
class Events extends BaseService {
constructor() {
@ -75,8 +75,9 @@ class Events extends BaseService {
}
public resetState() {
eventMap = reactive({});
methodMap = reactive({});
// 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新
Object.keys(eventMap).forEach((type) => delete eventMap[type]);
Object.keys(methodMap).forEach((type) => delete methodMap[type]);
}
public destroy() {

View File

@ -225,6 +225,7 @@ export interface StageOptions {
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
}
// #region NodeInvalidInfo
/**
* /
* FormPanel
@ -237,9 +238,12 @@ export interface NodeInvalidInfo {
/** 样式表单校验错误文案(可能为包含 <br> 的 HTML */
style?: string;
}
// #endregion NodeInvalidInfo
// #region NodeInvalidSource
/** 节点校验错误来源 */
export type NodeInvalidSource = keyof NodeInvalidInfo;
// #endregion NodeInvalidSource
export interface StoreState {
root: MApp | null;
@ -312,6 +316,7 @@ export interface StageRect {
height: number | string;
}
// #region UiState
export interface UiState {
/** 当前点击画布是否触发选中true: 不触发false: 触发默认为false */
uiSelectMode: boolean;
@ -369,6 +374,7 @@ export interface UiState {
height: number;
};
}
// #endregion UiState
// #region EditorNodeInfo
export interface EditorNodeInfo {

View File

@ -103,6 +103,7 @@ export const getCodeBlockFormConfig = (options: GetCodeBlockFormConfigOptions =
const list = [
{ text: '初始化前', value: 'beforeInit' },
{ text: '初始化后', value: 'afterInit' },
{ text: '页面渲染后', value: 'mounted' },
];
if (dataSourceType?.() !== 'base') {
list.push({ text: '请求前', value: 'beforeRequest' });

View File

@ -4,7 +4,7 @@
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { type ComputedRef, defineComponent, h, inject, nextTick } from 'vue';
import { computed, type ComputedRef, defineComponent, h, inject, nextTick, provide, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_CONTEXT_KEY, type FormContext } from '@tmagic/form';
@ -184,6 +184,55 @@ describe('Editor', () => {
expect(context.stage).toBe(stageStub);
});
test('合并宿主在外层 provide 的 FORM_CONTEXT_KEY不遮蔽其业务字段', async () => {
const hostComponent = defineComponent({
setup: () => {
provide(
FORM_CONTEXT_KEY,
computed(() => ({ username: 'tester', editor: { editorCore: {} } }) as unknown as FormContext),
);
return () => h(Editor, {} as any);
},
});
mount(hostComponent);
await nextTick();
const context = injectedFormContext!.value as any;
expect(context.username).toBe('tester');
expect(context.editor.editorCore).toBeDefined();
// 编辑器自己的字段优先级更高,仍然兜底
expect(context.services.editorService).toBeDefined();
expect(context.stage).toBe(stageStub);
});
test('宿主上下文的 accessor 保持读时求值', async () => {
const counter = ref(0);
const hostComponent = defineComponent({
setup: () => {
provide(
FORM_CONTEXT_KEY,
computed(
() =>
({
get buildVersion() {
return counter.value;
},
}) as unknown as FormContext,
),
);
return () => h(Editor, {} as any);
},
});
mount(hostComponent);
await nextTick();
expect((injectedFormContext!.value as any).buildVersion).toBe(0);
counter.value = 7;
expect((injectedFormContext!.value as any).buildVersion).toBe(7);
});
test('stage 走 computed 读时求值,切换画布后能读到新实例', async () => {
const editorServiceMod = (await import('@editor/services/editor')) as any;
mount(Editor, { props: {} as any });

View File

@ -222,7 +222,13 @@ describe('CodeBlockEditor', () => {
});
const timingItem = capturedConfig.find((c: any) => c.name === 'timing');
const opts = timingItem.options();
expect(opts.length).toBe(4);
expect(opts.map((opt: any) => opt.value)).toEqual([
'beforeInit',
'afterInit',
'mounted',
'beforeRequest',
'afterRequest',
]);
});
test('timing options - base 类型', () => {
@ -231,7 +237,7 @@ describe('CodeBlockEditor', () => {
});
const timingItem = capturedConfig.find((c: any) => c.name === 'timing');
const opts = timingItem.options();
expect(opts.length).toBe(2);
expect(opts.map((opt: any) => opt.value)).toEqual(['beforeInit', 'afterInit', 'mounted']);
});
test('changeHandler 触发 changedValue', async () => {

View File

@ -4,10 +4,11 @@
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { computed, defineComponent, h, nextTick, ref } from 'vue';
import { mount, type VueWrapper } from '@vue/test-utils';
import { HookType } from '@tmagic/core';
import { FORM_CONTEXT_KEY } from '@tmagic/form';
import CompareForm from '@editor/components/CompareForm.vue';
@ -44,7 +45,8 @@ vi.mock('@editor/utils/code-block', () => ({
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
}));
vi.mock('@tmagic/form', () => ({
vi.mock('@tmagic/form', async (importOriginal) => ({
...(await importOriginal<typeof import('@tmagic/form')>()),
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'context', 'showDiff'],
@ -95,6 +97,28 @@ describe('CompareForm.vue', () => {
expect(capturedFormProps.context).toHaveProperty('stage');
});
test('宿主 provide 的上下文不被遮蔽,且 services 由编辑器兜底', async () => {
const wrapper = mount(CompareForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
lastValue: { id: 'n1' },
services,
},
global: {
provide: {
codeOptions: {},
[FORM_CONTEXT_KEY as symbol]: computed(() => ({ username: 'alice', services: 'host-services' })),
},
},
});
await waitForFormReady(wrapper);
expect(capturedFormProps.context.username).toBe('alice');
expect(capturedFormProps.context.services).toEqual(services);
});
test('node 类别缺少 type 时不渲染 MForm', async () => {
const wrapper = mount(CompareForm, {
props: {

View File

@ -35,7 +35,8 @@ vi.mock('@editor/utils/code-block', () => ({
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
}));
vi.mock('@tmagic/form', () => ({
vi.mock('@tmagic/form', async (importOriginal) => ({
...(await importOriginal<typeof import('@tmagic/form')>()),
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'disabled', 'labelWidth', 'context', 'size'],

View File

@ -122,6 +122,7 @@ describe('CodeSelect', () => {
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
expect(config.scrollLastItemIntoView).toBe(true);
expect(config.header.sticky).toBe(true);
expect(config.addButtonConfig.sticky).toBe(true);
expect(config.addButtonConfig.text).toBe('添加');
const codeTypeSelect = config.items[0];

View File

@ -84,6 +84,7 @@ describe('DisplayConds', () => {
expect(capturedConfig.type).toBe('groupList');
expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.header.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
expect(capturedConfig.items[0].type).toBe('groupList');
@ -93,6 +94,7 @@ describe('DisplayConds', () => {
expect(capturedConfig.items[0].movable).toBe(false);
expect(capturedConfig.items[0].flat).toBe(true);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].header.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增条件');
expect(capturedConfig.items[0].items.every((item: any) => item.span === undefined)).toBe(true);

View File

@ -133,11 +133,13 @@ describe('EventSelect', () => {
expect(wrapper.find('.fake-table').exists()).toBe(false);
expect(capturedConfig.type).toBe('group-list');
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.header.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('添加事件');
expect(capturedConfig.defaultAdd).toEqual({ name: '', actions: [] });
expect(capturedConfig.movable).toBe(false);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].header.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作');
});

View File

@ -46,7 +46,8 @@ vi.mock('@editor/utils/code-block', () => ({
}),
}));
vi.mock('@tmagic/form', () => ({
vi.mock('@tmagic/form', async (importOriginal) => ({
...(await importOriginal<typeof import('@tmagic/form')>()),
MForm: defineComponent({
name: 'MForm',
setup(_, { expose }) {

View File

@ -0,0 +1,128 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test } from 'vitest';
import { computed, type ComputedRef, defineComponent, h, provide, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_CONTEXT_KEY, type FormContext } from '@tmagic/form';
import { useEditorFormContext } from '@editor/hooks/use-form-context';
import type { Services } from '@editor/type';
const makeServices = (stage: any = { name: 'stage' }): Services =>
({
editorService: { get: (key: string) => (key === 'stage' ? stage : undefined) },
propsService: {},
}) as unknown as Services;
/**
* setup hook provide 宿
*/
const setup = (getServices: () => Services | undefined, hostContext?: ComputedRef<FormContext>) => {
let context: ComputedRef<FormContext> | undefined;
const inner = defineComponent({
setup() {
context = useEditorFormContext(getServices);
return () => h('div');
},
});
const outer = defineComponent({
setup() {
if (hostContext) provide(FORM_CONTEXT_KEY, hostContext);
return () => h(inner);
},
});
mount(outer);
return context!;
};
describe('useEditorFormContext', () => {
test('没有宿主上下文时只提供 services 与 stage', () => {
const stage = { name: 'stage-1' };
const context = setup(() => makeServices(stage)) as any;
expect(context.value.services.editorService).toBeDefined();
expect(context.value.stage).toBe(stage);
});
test('getServices 返回 undefined 时不抛错', () => {
const context = setup(() => undefined) as any;
expect(context.value.services).toBeUndefined();
expect(context.value.stage).toBeUndefined();
});
test('stage 读时求值,切换画布后拿到新实例', () => {
const stage = ref<any>({ name: 'stage-1' });
const context = setup(() => makeServices(stage.value)) as any;
expect(context.value.stage).toEqual({ name: 'stage-1' });
stage.value = { name: 'stage-2' };
expect(context.value.stage).toEqual({ name: 'stage-2' });
});
test('合并宿主在外层 provide 的上下文,宿主字段不被遮蔽', () => {
const host = computed(() => ({ username: 'alice', $store: { getters: {} } }) as unknown as FormContext);
const context = setup(() => makeServices(), host) as any;
expect(context.value.username).toBe('alice');
expect(context.value.$store.getters).toBeDefined();
expect(context.value.services).toBeDefined();
});
test('services / stage 由编辑器兜底,优先级高于宿主同名字段', () => {
const stage = { name: 'editor-stage' };
const host = computed(() => ({ stage: 'host-stage', services: 'host-services' }) as unknown as FormContext);
const context = setup(() => makeServices(stage), host) as any;
expect(context.value.stage).toBe(stage);
expect(context.value.services).not.toBe('host-services');
});
/**
* Editor.vue services FormPanel / useCompareForm
* Proxy miss 线
*/
test('上层已用同一份 services 合并过时直接复用,不再套一层 Proxy', () => {
const services = makeServices();
const host = computed(() => ({ services, username: 'alice' }) as unknown as FormContext);
const context = setup(() => services, host) as any;
expect(context.value).toBe(host.value);
expect(context.value.username).toBe('alice');
});
test('services 不同则仍然合并,编辑器的 services 优先', () => {
const hostServices = makeServices();
const ownServices = makeServices({ name: 'own-stage' });
const host = computed(() => ({ services: hostServices }) as unknown as FormContext);
const context = setup(() => ownServices, host) as any;
expect(context.value).not.toBe(host.value);
expect(context.value.services).toBe(ownServices);
});
test('宿主上下文里的 accessor 保持读时求值', () => {
const counter = ref(0);
const host = computed(
() =>
({
get buildVersion() {
return counter.value;
},
}) as unknown as FormContext,
);
const context = setup(() => makeServices(), host) as any;
expect(context.value.buildVersion).toBe(0);
counter.value = 3;
expect(context.value.buildVersion).toBe(3);
});
});

View File

@ -8,6 +8,7 @@ import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { DepTargetType } from '@tmagic/core';
import { getNodes } from '@tmagic/utils';
import { initServiceEvents, initServiceState } from '@editor/initService';
import * as logger from '@editor/utils/logger';
@ -60,6 +61,7 @@ const mkServices = () => {
...mkSvc('events'),
setEvents: vi.fn(),
setMethods: vi.fn(),
resetState: vi.fn(),
};
const uiService: any = {
...mkSvc('ui'),
@ -210,8 +212,8 @@ describe('initServiceState', () => {
eventMethodList: { typeA: { events: [{ name: 'click' }], methods: [{ name: 'm' }] } },
} as any;
mount(Wrap(props, services));
expect(services.eventsService.setEvents).toHaveBeenCalled();
expect(services.eventsService.setMethods).toHaveBeenCalled();
expect(services.eventsService.setEvents).toHaveBeenCalledWith({ typeA: [{ name: 'click' }] });
expect(services.eventsService.setMethods).toHaveBeenCalledWith({ typeA: [{ name: 'm' }] });
});
test('datasourceConfigs 设置 form config', () => {
@ -284,11 +286,44 @@ describe('initServiceState', () => {
expect(services.uiService.resetState).toHaveBeenCalled();
expect(services.componentListService.resetState).toHaveBeenCalled();
expect(services.codeBlockService.resetState).toHaveBeenCalled();
expect(services.eventsService.resetState).toHaveBeenCalled();
expect(services.keybindingService.reset).toHaveBeenCalled();
expect(services.depService.reset).toHaveBeenCalled();
});
});
const mkDataSourceManager = () => ({
addDataSource: vi.fn(),
removeDataSource: vi.fn(),
get: vi.fn(() => ({
setFields: vi.fn(),
setData: vi.fn(),
getDefaultData: vi.fn(() => ({})),
})),
init: vi.fn(),
compiledNode: vi.fn((node: any) => node),
});
/** renderer.runtime 直接就绪getTMagicApp 走同步分支 */
const mkReadyStage = (app: any) => {
const runtime = {
getApp: vi.fn(() => app),
updateRootConfig: vi.fn(),
updatePageId: vi.fn(),
};
return {
renderer: {
runtime,
getRuntime: vi.fn(async () => runtime),
once: vi.fn(),
},
select: vi.fn(),
reloadIframe: vi.fn(),
update: vi.fn(),
runtime,
};
};
describe('initServiceEvents', () => {
let services: ReturnType<typeof mkServices>;
let emit: any;
@ -322,7 +357,6 @@ describe('initServiceEvents', () => {
});
test('rootChange 处理代码块和数据源', async () => {
services.editorService.state.root = { id: 'r' };
mount(WrapEvents({} as any, emit, services));
const value: any = {
id: 'r',
@ -330,6 +364,8 @@ describe('initServiceEvents', () => {
dataSources: [{ id: 'd1', type: 'base' }],
items: [],
};
// set('root', v) 是先赋值再派发事件mock 中同样先对齐 state 再 emit
services.editorService.state.root = value;
services.editorService.emit('root-change', value, null);
await new Promise((r) => setTimeout(r, 0));
expect(services.codeBlockService.setCodeDsl).toHaveBeenCalled();
@ -499,4 +535,252 @@ describe('initServiceEvents', () => {
});
// 因 services 中 editor.state 不是 reactivestage watch 不会触发,跳过该测试场景
test('getTMagicApp 在 runtime 未就绪时等待 runtime-ready并复用同一个 promise', async () => {
const dataSourceManager = mkDataSourceManager();
const app = { dsl: {}, dataSourceManager };
let runtimeReady: any;
const renderer: any = {
runtime: null,
getRuntime: vi.fn(),
once: vi.fn((_event: string, cb: any) => {
runtimeReady = cb;
}),
};
services.editorService.state.stage = { renderer, select: vi.fn() };
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit('add', { id: 'd1', type: 'base' });
services.dataSourceService.emit('add', { id: 'd2', type: 'base' });
await nextTick();
// 两次调用只应注册一次 runtime-ready 监听
expect(renderer.once).toHaveBeenCalledTimes(1);
renderer.runtime = { getApp: () => app };
runtimeReady();
await new Promise((r) => setTimeout(r, 0));
expect(dataSourceManager.addDataSource).toHaveBeenCalledTimes(2);
expect(dataSourceManager.init).toHaveBeenCalled();
});
test('getTMagicApp 在 stage 没有 renderer 时返回 undefined', async () => {
services.editorService.state.stage = { select: vi.fn() };
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit('add', { id: 'd1', type: 'base' });
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.addTarget).toHaveBeenCalled();
});
test('ds-collected 事件把 dataSourceDeps 同步给 runtime app', async () => {
const app: any = { dsl: { dataSourceDeps: null }, dataSourceManager: mkDataSourceManager() };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = { id: 'r', dataSourceDeps: { d1: {} } };
mount(WrapEvents({} as any, emit, services));
services.depService.emit('ds-collected');
await new Promise((r) => setTimeout(r, 0));
expect(app.dsl.dataSourceDeps).toEqual({ d1: {} });
});
test('rootChange items 是数组时更新画布 dsl', async () => {
const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() };
const stage = mkReadyStage(app);
services.editorService.state.stage = stage;
services.editorService.state.page = { id: 'p1' };
services.editorService.state.node = { id: 'n1' };
mount(WrapEvents({} as any, emit, services));
const value: any = {
id: 'r',
items: [{ id: 'n1', type: 'text' }],
dataSources: [],
dataSourceDeps: { d1: {} },
codeBlocks: {},
};
services.editorService.state.root = value;
services.editorService.emit('root-change', value);
await new Promise((r) => setTimeout(r, 10));
expect(stage.runtime.updatePageId).toHaveBeenCalledWith('p1');
expect(stage.runtime.updateRootConfig).toHaveBeenCalled();
expect(stage.select).toHaveBeenCalledWith('n1');
});
test('rootChange items 不是数组时清空依赖', async () => {
mount(WrapEvents({} as any, emit, services));
const value: any = { id: 'r', dataSourceDeps: { a: {} }, dataSourceCondDeps: { b: {} } };
services.editorService.state.root = value;
services.editorService.emit('root-change', value);
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.clear).toHaveBeenCalled();
expect(value.dataSourceDeps).toBeUndefined();
expect(value.dataSourceCondDeps).toBeUndefined();
});
test('rootChange 没有可选中节点时回落到 root 自身', async () => {
services.editorService.getNodeById.mockReturnValue(null);
mount(WrapEvents({} as any, emit, services));
const value: any = { id: 'r', items: [] };
services.editorService.state.root = value;
services.editorService.emit('root-change', value, { id: 'prev' });
await new Promise((r) => setTimeout(r, 0));
expect(services.editorService.set).toHaveBeenCalledWith('nodes', [value]);
expect(services.editorService.set).toHaveBeenCalledWith('parent', null);
expect(services.editorService.set).toHaveBeenCalledWith('page', null);
expect(emit).toHaveBeenCalledWith('update:modelValue', value);
});
test('rootChange 有 items 时选中第一个节点', async () => {
services.editorService.getNodeById.mockReturnValue(null);
mount(WrapEvents({} as any, emit, services));
const value: any = { id: 'r', items: [{ id: 'first', type: 'page' }] };
services.editorService.state.root = value;
services.editorService.emit('root-change', value);
await new Promise((r) => setTimeout(r, 0));
expect(services.editorService.select).toHaveBeenCalledWith({ id: 'first', type: 'page' });
});
test('rootChange 外部已持有该 root 时不回写 modelValue', async () => {
services.editorService.getNodeById.mockReturnValue(null);
const value: any = { id: 'r', items: [] };
mount(WrapEvents({ modelValue: value } as any, emit, services));
services.editorService.state.root = value;
services.editorService.emit('root-change', value);
await new Promise((r) => setTimeout(r, 0));
expect(emit).not.toHaveBeenCalled();
});
test('rootChange 处理期间 root 被替换:不刷旧 dsl 也不回写过期快照', async () => {
const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() };
const stage = mkReadyStage(app);
services.editorService.state.stage = stage;
services.editorService.getNodeById.mockReturnValue(null);
mount(WrapEvents({} as any, emit, services));
const value: any = { id: 'r', items: [{ id: 'n1', type: 'text' }], dataSources: [], codeBlocks: {} };
services.editorService.state.root = value;
services.editorService.emit('root-change', value);
// 异步处理还没跑完root 已被新的一次整体替换顶掉
services.editorService.state.root = { id: 'r', items: [], dataSources: [], codeBlocks: {} };
await new Promise((r) => setTimeout(r, 10));
expect(stage.runtime.updateRootConfig).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
test('update 事件ROOT 节点、无 propPath、命中已收集依赖三种分支', async () => {
services.editorService.state.root = { id: 'r', items: [] };
services.depService.getTargets.mockReturnValue({
t1: { deps: { n3: { keys: ['props.text'] } } },
});
mount(WrapEvents({} as any, emit, services));
services.editorService.emit('update', [
{ newNode: { id: 'r', type: 'root' }, oldNode: { id: 'r', type: 'root' } },
{
newNode: { id: 'n2', type: 'text' },
oldNode: { id: 'n2', type: 'text' },
changeRecords: [{ value: 'x' }],
},
{
newNode: { id: 'n3', type: 'text' },
oldNode: { id: 'n3', type: 'text' },
changeRecords: [{ propPath: 'props.text', value: 'x' }],
},
]);
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.collectIdle).toHaveBeenCalled();
});
test('dataSource update 修改 mocks / methods 分别只重建对应 target', async () => {
const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = { id: 'r', items: [{ id: 'a', type: 'text' }] };
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit(
'update',
{ id: 'd1', type: 'base', fields: [], mocks: [{ useInEditor: true, data: { a: 1 } }] },
{ changeRecords: [{ propPath: 'mocks' }] },
);
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE);
services.depService.removeTarget.mockClear();
services.dataSourceService.emit(
'update',
{ id: 'd1', type: 'base', fields: [], methods: [] },
{ changeRecords: [{ propPath: 'methods.0.name' }] },
);
await new Promise((r) => setTimeout(r, 0));
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE_METHOD);
});
test('dataSource update 无依赖变更时只刷新数据', async () => {
const dataSourceManager = mkDataSourceManager();
const app: any = { dsl: {}, dataSourceManager };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = { id: 'r', items: [], dataSources: [{ id: 'd1' }] };
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit(
'update',
{ id: 'd1', type: 'base', fields: [] },
{ changeRecords: [{ propPath: 'title' }, {}] },
);
await new Promise((r) => setTimeout(r, 0));
expect(dataSourceManager.get).toHaveBeenCalledWith('d1');
expect(services.depService.clearIdleTasks).not.toHaveBeenCalled();
});
test('dataSource remove 重新收集依赖并从 app 中移除数据源', async () => {
const dataSourceManager = mkDataSourceManager();
const app: any = { dsl: {}, dataSourceManager };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = {
id: 'r',
items: [],
dataSources: [{ id: 'd1' }],
dataSourceDeps: { d1: { n1: {} } },
};
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit('remove', 'd1');
await new Promise((r) => setTimeout(r, 0));
expect(dataSourceManager.removeDataSource).toHaveBeenCalledWith('d1');
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE);
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE_COND);
expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE_METHOD);
expect(app.dsl.dataSources).toEqual([{ id: 'd1' }]);
});
test('dataSource remove 收集未完成时不移除数据源', async () => {
const dataSourceManager = mkDataSourceManager();
const app: any = { dsl: {}, dataSourceManager };
services.editorService.state.stage = mkReadyStage(app);
services.editorService.state.root = { id: 'r', items: [] };
vi.mocked(getNodes).mockReturnValueOnce([{ id: 'n1', type: 'text' }] as any);
services.depService.collectIdle.mockResolvedValue(false);
mount(WrapEvents({} as any, emit, services));
services.dataSourceService.emit('remove', 'd1');
await new Promise((r) => setTimeout(r, 0));
expect(dataSourceManager.removeDataSource).not.toHaveBeenCalled();
});
});

View File

@ -88,8 +88,14 @@ const diffableGroups = (id: string | number = 'p1') => [
},
];
/** 让 getHistoryGroups 只对指定类别返回可对比分组,避免三类历史互相串味。 */
const groupsFor = (category: string, id: string | number) => (type: string) =>
type === category ? diffableGroups(id) : [];
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
appMock._context = {};
});
describe('useHistoryRevert', () => {
@ -226,4 +232,255 @@ describe('useHistoryRevert', () => {
expect(result).toBe('done');
});
});
describe('数据源历史', () => {
test('可差异步骤走差异确认弹窗,确认后执行 revert', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{ step: { opType: 'update', diff: [{ newSchema: { id: 'ds_1' }, oldSchema: { id: 'ds_1' } }] } },
]);
services.historyService.getHistoryGroups.mockImplementation(groupsFor('dataSource', 'ds_1'));
services.dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds_1', title: '当前' });
const { onDataSourceRevert } = useHistoryRevert({}, services);
await onDataSourceRevert('ds_1', 0);
expect(lastDialogProps().isConfirm).toBe(true);
expect(services.dataSourceService.revert).toHaveBeenCalledWith('ds_1', 0);
});
test('buildDataSourceDiffPayload 取 title 作展示名、type 缺省为 base并带上当前值', () => {
const services = createServices();
services.historyService.getHistoryGroups.mockImplementation(() => [
{
id: 'ds_1',
steps: [
{
index: 0,
step: { diff: [{ oldSchema: { title: '旧' }, newSchema: { title: '新' } }] },
},
],
},
]);
services.dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds_1', title: '当前' });
const { buildDataSourceDiffPayload } = useHistoryRevert({}, services);
const payload = buildDataSourceDiffPayload('ds_1', 0);
expect(payload).toMatchObject({
category: 'data-source',
type: 'base',
targetLabel: '新',
id: 'ds_1',
currentValue: { id: 'ds_1', title: '当前' },
});
expect(services.dataSourceService.getDataSourceById).toHaveBeenCalledWith('ds_1');
});
test('onDataSourceDiff 打开只读弹窗', async () => {
const services = createServices();
services.historyService.getHistoryGroups.mockImplementation(groupsFor('dataSource', 'ds_1'));
const { onDataSourceDiff } = useHistoryRevert({ dialogWidth: '900px' }, services);
await onDataSourceDiff('ds_1', 0);
expect(lastDialogProps().isConfirm).toBe(false);
expect(lastDialogProps().width).toBe('900px');
expect(dialogInstance.open).toHaveBeenCalled();
});
test('无可对比内容时 onDataSourceDiff 不弹窗', async () => {
const services = createServices();
const { onDataSourceDiff } = useHistoryRevert({}, services);
await onDataSourceDiff('ds_1', 0);
expect(createAppMock).not.toHaveBeenCalled();
});
});
describe('代码块历史', () => {
test('update 记录对应代码块已删除时,提示错误且不执行回滚', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([{ step: { opType: 'update', diff: [] } }]);
services.codeBlockService.getCodeContentById.mockReturnValue(null);
const { onCodeBlockRevert } = useHistoryRevert({}, services);
await onCodeBlockRevert('code_1', 0);
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
expect(services.codeBlockService.revert).not.toHaveBeenCalled();
});
test('可差异步骤走差异确认弹窗,确认后执行 revert', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([{ step: { opType: 'update', diff: [] } }]);
services.historyService.getHistoryGroups.mockImplementation(groupsFor('codeBlock', 'code_1'));
services.codeBlockService.getCodeContentById.mockReturnValue({ name: '当前' });
const { onCodeBlockRevert } = useHistoryRevert({}, services);
await onCodeBlockRevert('code_1', 0);
expect(lastDialogProps().isConfirm).toBe(true);
expect(services.codeBlockService.revert).toHaveBeenCalledWith('code_1', 0);
});
test('用户取消确认时不执行 revert', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([{ step: { opType: 'add', diff: [] } }]);
vi.mocked(confirmHistoryAction).mockResolvedValueOnce(false);
const { onCodeBlockRevert } = useHistoryRevert({}, services);
await onCodeBlockRevert('code_1', 0);
expect(services.codeBlockService.revert).not.toHaveBeenCalled();
});
test('buildCodeBlockDiffPayload 不带 type展示名回退到 id', () => {
const services = createServices();
services.historyService.getHistoryGroups.mockImplementation(() => [
{
id: 'code_1',
steps: [{ index: 0, step: { diff: [{ oldSchema: {}, newSchema: {} }] } }],
},
]);
services.codeBlockService.getCodeContentById.mockReturnValue(undefined);
const { buildCodeBlockDiffPayload } = useHistoryRevert({}, services);
const payload = buildCodeBlockDiffPayload('code_1', 0);
expect(payload).toMatchObject({ category: 'code-block', targetLabel: 'code_1', currentValue: null });
expect(payload).not.toHaveProperty('type');
});
test('onCodeBlockDiff 打开只读弹窗', async () => {
const services = createServices();
services.historyService.getHistoryGroups.mockImplementation(groupsFor('codeBlock', 'code_1'));
const { onCodeBlockDiff } = useHistoryRevert({}, services);
await onCodeBlockDiff('code_1', 0);
expect(lastDialogProps().isConfirm).toBe(false);
expect(dialogInstance.open).toHaveBeenCalled();
});
});
describe('回滚前置校验', () => {
test('页面 remove 记录的原父容器已删除时判定为无法回滚', () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{ step: { opType: 'remove', diff: [{ parentId: 'parent_gone' }] } },
]);
services.editorService.getNodeById.mockReturnValue(null);
const { isPageRevertTargetMissing } = useHistoryRevert({}, services);
expect(isPageRevertTargetMissing(0)).toBe(true);
});
test('页面 remove 记录的原父容器仍在时可以回滚', () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{ step: { opType: 'remove', diff: [{ parentId: 'parent_1' }] } },
]);
services.editorService.getNodeById.mockReturnValue({ id: 'parent_1' });
const { isPageRevertTargetMissing } = useHistoryRevert({}, services);
expect(isPageRevertTargetMissing(0)).toBe(false);
});
test('步骤不存在时不判定为无法回滚', () => {
const services = createServices();
const { isPageRevertTargetMissing, isCodeBlockRevertTargetMissing } = useHistoryRevert({}, services);
expect(isPageRevertTargetMissing(0)).toBe(false);
expect(isCodeBlockRevertTargetMissing('code_1', 0)).toBe(false);
});
test('confirmAndRevert 在 isTargetMissing 命中时提示错误并返回 null', async () => {
const services = createServices();
const revert = vi.fn(async () => 'done');
const { confirmAndRevert } = useHistoryRevert({}, services);
const result = await confirmAndRevert({ isTargetMissing: () => true, diffPayload: null, revert });
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
expect(revert).not.toHaveBeenCalled();
expect(result).toBeNull();
});
test('confirmAndRevert 在用户取消时返回 null', async () => {
const services = createServices();
const revert = vi.fn(async () => 'done');
vi.mocked(confirmHistoryAction).mockResolvedValueOnce(false);
const { confirmAndRevert } = useHistoryRevert({}, services);
const result = await confirmAndRevert({ diffPayload: null, revert });
expect(revert).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('弹窗挂载与卸载', () => {
test('传入 appContext 时合并进弹窗 app 的上下文', async () => {
const services = createServices();
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
const appContext = { provides: { host: 1 } } as any;
const { onPageDiff } = useHistoryRevert({ appContext }, services);
await onPageDiff(0);
expect(appMock._context).toMatchObject({ provides: { host: 1 } });
});
test('未显式指定 size 时回落到属性面板尺寸', async () => {
const services = createServices();
services.uiService = { get: vi.fn(() => 'small') };
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
const { onPageDiff } = useHistoryRevert({}, services);
await onPageDiff(0);
expect(lastDialogProps().size).toBe('small');
});
test('确认弹窗结束后延迟卸载并移除容器', async () => {
vi.useFakeTimers();
const services = createServices();
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
const containerCount = document.body.childElementCount;
const { confirmAndRevert } = useHistoryRevert({}, services);
await confirmAndRevert({
diffPayload: { category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any,
revert: async () => 'done',
});
expect(document.body.childElementCount).toBe(containerCount + 1);
expect(appMock.unmount).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(appMock.unmount).toHaveBeenCalled();
expect(document.body.childElementCount).toBe(containerCount);
});
test('卸载抛错不影响容器清理', async () => {
vi.useFakeTimers();
const services = createServices();
appMock.unmount.mockImplementationOnce(() => {
throw new Error('unmount failed');
});
const containerCount = document.body.childElementCount;
const { viewDiff } = useHistoryRevert({}, services);
await viewDiff({ category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any);
// 只读弹窗要等用户关闭才卸载
lastDialogProps().onClose();
vi.advanceTimersByTime(300);
expect(document.body.childElementCount).toBe(containerCount);
});
});
});

View File

@ -4,9 +4,11 @@
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { computed, defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_CONTEXT_KEY } from '@tmagic/form';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
import FormPanel from '@editor/layouts/props-panel/FormPanel.vue';
@ -111,6 +113,26 @@ describe('FormPanel', () => {
expect(wrapper.findComponent({ name: 'MForm' }).exists()).toBe(true);
});
/**
* 宿 provide context
* 宿 <MEditor> provide
*/
test('宿主 provide 的上下文不被遮蔽,且 services / stage 由编辑器兜底', () => {
const wrapper = mount(FormPanel, {
props: { config: [], values: {} } as any,
global: {
provide: {
[FORM_CONTEXT_KEY as symbol]: computed(() => ({ username: 'alice', services: 'host-services' })),
},
},
});
const context = wrapper.findComponent({ name: 'MForm' }).props('context') as any;
expect(context.username).toBe('alice');
expect(context.services).not.toBe('host-services');
expect(context.stage).toEqual({ id: 'stage' });
});
test('mounted 事件 emit', async () => {
const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any });
await nextTick();

View File

@ -55,11 +55,14 @@ vi.mock('@editor/hooks', () => ({
useServices: () => ({ editorService, uiService, keybindingService, stageOverlayService }),
}));
// 与 plugin.ts 中 parseDSL 的默认实现eval保持一致用于验证守卫能否阻止其执行
const { parseDSL } = vi.hoisted(() => ({
// eslint-disable-next-line no-new-func
parseDSL: vi.fn((dsl: string) => new Function(`return ${dsl}`)()),
}));
vi.mock('@editor/utils/config', () => ({
getEditorConfig: vi.fn(() => (s: string) => {
if (s.startsWith('(')) return JSON.parse(s.slice(1, -1));
return JSON.parse(s);
}),
getEditorConfig: vi.fn(() => parseDSL),
}));
vi.mock('@editor/components/ScrollViewer.vue', () => ({
@ -146,6 +149,26 @@ const mountIt = (props: any = {}) =>
attachTo: document.body,
});
/** 模拟编辑器文档内部(如组件列表面板)发起拖拽 */
const startInternalDrag = () => {
document.dispatchEvent(new Event('dragstart'));
};
const endDrag = () => {
document.dispatchEvent(new Event('dragend'));
};
const createDropEvent = (raw: string) => {
const event: any = new Event('drop');
event.dataTransfer = { getData: vi.fn(() => raw) };
event.preventDefault = vi.fn();
return event;
};
const waitMacrotask = () => new Promise((r) => setTimeout(r, 10));
const COMPONENT_LIST_JSON = '{"dragType":"component-list","data":{"name":"text","style":{}}}';
describe('Stage', () => {
test('挂载并创建 stage', async () => {
const wrapper = mountIt();
@ -185,17 +208,28 @@ describe('Stage', () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
const event: any = new Event('drop');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
};
const event = createDropEvent(COMPONENT_LIST_JSON);
event.clientX = 100;
event.clientY = 100;
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10));
await waitMacrotask();
expect(editorService.add).toHaveBeenCalled();
endDrag();
});
test('drop 保留拖拽数据中的函数', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
const event = createDropEvent('{dragType:"component-list",data:{name:"text",style:{},created:() => "created"}}');
event.clientX = 10;
event.clientY = 10;
startInternalDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10));
expect(editorService.add.mock.calls[0][0].created()).toBe('created');
});
test('zoom 变化时 stage.setZoom 被调用', async () => {
@ -218,48 +252,163 @@ describe('Stage', () => {
expect(wrapper.find('.fake-overlay').exists()).toBe(false);
});
test('drop 数据为空时不处理', async () => {
test('drop 数据为空时消费内部拖拽标记,后续 drop 不再解析', async () => {
const wrapper = mountIt();
await nextTick();
const event: any = new Event('drop');
event.dataTransfer = { getData: vi.fn(() => '') };
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(createDropEvent(''));
expect(editorService.add).not.toHaveBeenCalled();
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(parseDSL).not.toHaveBeenCalled();
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 无 dataTransfer 时也消费内部拖拽标记', async () => {
const wrapper = mountIt();
await nextTick();
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
const event: any = new Event('drop');
event.dataTransfer = null;
stageContainer.dispatchEvent(event);
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(parseDSL).not.toHaveBeenCalled();
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 非 COMPONENT_LIST 时不处理', async () => {
const wrapper = mountIt();
await nextTick();
const event: any = new Event('drop');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"other","data":{"name":"text","style":{}}}'),
};
event.preventDefault = vi.fn();
const event = createDropEvent('{"dragType":"other","data":{"name":"text","style":{}}}');
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(event);
await waitMacrotask();
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 缺少 data 时不处理', async () => {
const wrapper = mountIt();
await nextTick();
startInternalDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent('{"dragType":"component-list"}'));
await waitMacrotask();
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 拖拽非本文档发起时不解析数据,不执行其中的脚本', async () => {
let executed = false;
Object.defineProperty(globalThis, '__PWNED__', {
configurable: true,
set() {
executed = true;
},
});
const wrapper = mountIt();
await nextTick();
// PoC 中攻击页面跨源投递的 payload
const event = createDropEvent("{a:(globalThis.__PWNED__='code execution',0), dragType:'component-list'}");
const stageContainer = wrapper.find('.m-editor-stage-container').element;
// 不触发 dragstart模拟拖拽起源于编辑器之外。
// 同源内部拖拽源写入的 text/json 由业务保证可信,本用例不覆盖。
expect(() => stageContainer.dispatchEvent(event)).not.toThrow();
await new Promise((r) => setTimeout(r, 10));
expect(parseDSL).not.toHaveBeenCalled();
expect(executed).toBe(false);
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 一次 dragstart 只消费一次', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(editorService.add).toHaveBeenCalledTimes(1);
stageContainer.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(editorService.add).toHaveBeenCalledTimes(1);
});
test('drop 在 dragend 之后仍处理(兼容 WebKit 先 dragend 再 drop', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
startInternalDrag();
endDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(editorService.add).toHaveBeenCalled();
});
test('dragend 宏任务之后取消的拖拽不再处理', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
startInternalDrag();
endDrag();
await waitMacrotask();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(parseDSL).not.toHaveBeenCalled();
expect(editorService.add).not.toHaveBeenCalled();
});
test('dragend 延迟清理不会清掉下一次 dragstart', async () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mountIt();
await nextTick();
startInternalDrag();
endDrag();
startInternalDrag();
await waitMacrotask();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent(COMPONENT_LIST_JSON));
await waitMacrotask();
expect(editorService.add).toHaveBeenCalled();
});
test('drop parseDSL 解析失败时不抛错', async () => {
const wrapper = mountIt();
await nextTick();
const event = createDropEvent('{"dragType":');
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
expect(() => stageContainer.dispatchEvent(event)).not.toThrow();
await new Promise((r) => setTimeout(r, 10));
expect(editorService.add).not.toHaveBeenCalled();
});
test('卸载时移除 document 上的拖拽监听', async () => {
const removeSpy = vi.spyOn(document, 'removeEventListener');
const wrapper = mountIt();
await nextTick();
wrapper.unmount();
expect(removeSpy).toHaveBeenCalledWith('dragstart', expect.any(Function), true);
expect(removeSpy).toHaveBeenCalledWith('dragend', expect.any(Function), true);
removeSpy.mockRestore();
});
test('drop position fixed 计算位置', async () => {
editorService.getNodeById.mockReturnValue(null);
editorService.getLayout.mockResolvedValue('relative');
const wrapper = mountIt();
await nextTick();
const event: any = new Event('drop');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}'),
};
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{"position":"fixed"}}}');
event.clientX = 80;
event.clientY = 60;
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element;
Object.defineProperty(stageContainer, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
configurable: true,
});
startInternalDrag();
stageContainer.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10));
const args = editorService.add.mock.calls[0][0];
@ -271,18 +420,15 @@ describe('Stage', () => {
editorService.getLayout.mockResolvedValue('absolute');
const wrapper = mountIt();
await nextTick();
const event: any = new Event('drop');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
};
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
event.clientX = 80;
event.clientY = 60;
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element;
Object.defineProperty(stageContainer, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: 800, height: 600 }),
configurable: true,
});
startInternalDrag();
stageContainer.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10));
const args = editorService.add.mock.calls[0][0];
@ -296,14 +442,11 @@ describe('Stage', () => {
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', canDropIn },
});
await nextTick();
const event: any = new Event('drop');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
};
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
event.clientX = 50;
event.clientY = 50;
event.preventDefault = vi.fn();
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10));
expect(canDropIn).toHaveBeenCalled();
@ -414,13 +557,10 @@ describe('Stage', () => {
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'h', canDropIn },
});
await nextTick();
const event: any = new Event('drop');
event.dataTransfer = {
getData: vi.fn(() => '{"dragType":"component-list","data":{"name":"text","style":{}}}'),
};
const event = createDropEvent('{"dragType":"component-list","data":{"name":"text","style":{}}}');
event.clientX = 1;
event.clientY = 1;
event.preventDefault = vi.fn();
startInternalDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 10));
expect(canDropIn).toHaveBeenCalled();

View File

@ -16,11 +16,16 @@
* limitations under the License.
*/
import { describe, expect, test } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { computed } from 'vue';
import events from '@editor/services/events';
describe('events', () => {
beforeEach(() => {
events.resetState();
});
test('setEvent', () => {
const event = [{ label: '点击', value: 'magic:common:events:click' }];
events.setEvent('button', event);
@ -42,6 +47,37 @@ describe('events', () => {
expect(events.getEvent('text')).toHaveLength(1);
});
test('setEvents 增量合并,不动入参中不存在的类型', () => {
events.setEvents({
Image: [{ label: 'click', value: 'click' }],
Text: [{ label: 'init', value: 'init' }],
} as any);
events.setEvents({
Image: [{ label: 'hover', value: 'hover' }],
} as any);
expect(events.getEvent('image')).toEqual([{ label: 'hover', value: 'hover' }]);
expect(events.getEvent('text')).toEqual([{ label: 'init', value: 'init' }]);
});
test('setEvents 空对象是空操作', () => {
events.setEvent('button', [{ label: 'click', value: 'click' }]);
events.setEvents({});
expect(events.getEvent('button')).toEqual([{ label: 'click', value: 'click' }]);
});
test('setEvents 入参类型名按 toLine 归一后写入', () => {
events.setEvent('foo-bar', [{ label: 'a', value: 'a' }]);
events.setEvents({
fooBar: [{ label: 'b', value: 'b' }],
} as any);
expect(events.getEvent('foo-bar')).toEqual([{ label: 'b', value: 'b' }]);
});
test('setEvents 缺省列表按空数组写入', () => {
events.setEvents({ button: undefined } as any);
expect(events.getEvent('button')).toEqual([]);
});
test('setMethods 批量设置', () => {
events.setMethods({
Image: [{ label: 'show', value: 'show' }],
@ -49,6 +85,29 @@ describe('events', () => {
expect(events.getMethod('image', {})).toHaveLength(1);
});
test('setMethods 增量合并,不动入参中不存在的类型', () => {
events.setMethods({
Image: [{ label: 'show', value: 'show' }],
Video: [{ label: 'play', value: 'play' }],
} as any);
events.setMethods({
Video: [{ label: 'pause', value: 'pause' }],
} as any);
expect(events.getMethod('video', {})).toEqual([{ label: 'pause', value: 'pause' }]);
expect(events.getMethod('image', {})).toEqual([{ label: 'show', value: 'show' }]);
});
test('setMethods 缺省列表按空数组写入', () => {
events.setMethods({ video: undefined } as any);
expect(events.getMethod('video')).toEqual([]);
});
test('setMethods 空对象是空操作', () => {
events.setMethod('button', [{ label: 'open', value: 'open' }]);
events.setMethods({});
expect(events.getMethod('button')).toEqual([{ label: 'open', value: 'open' }]);
});
test('getEvent / getMethod 支持节点上下文参数', () => {
const node = { id: 'n1', type: 'button' } as any;
events.setEvent('button', [{ label: '点击', value: 'click' }]);
@ -64,7 +123,58 @@ describe('events', () => {
test('resetState 清空所有事件 / 方法', () => {
events.setEvent('foo', [{ label: 'l', value: 'v' }]);
events.setMethod('foo', [{ label: 'm', value: 'm' }]);
events.resetState();
expect(events.getEvent('foo')).toEqual([]);
expect(events.getMethod('foo')).toEqual([]);
});
test('resetState 原地清空后仍可继续写入', () => {
events.setEvent('foo', [{ label: 'l', value: 'v' }]);
events.resetState();
events.setEvent('bar', [{ label: 'click', value: 'click' }]);
events.setMethod('bar', [{ label: 'open', value: 'open' }]);
expect(events.getEvent('bar')).toEqual([{ label: 'click', value: 'click' }]);
expect(events.getMethod('bar')).toEqual([{ label: 'open', value: 'open' }]);
});
test('resetState 原地清空会使已有 computed 失效', () => {
events.setEvent('foo', [{ label: 'l', value: 'v' }]);
events.setMethod('foo', [{ label: 'm', value: 'm' }]);
const eventList = computed(() => events.getEvent('foo'));
const methodList = computed(() => events.getMethod('foo'));
expect(eventList.value).toHaveLength(1);
expect(methodList.value).toHaveLength(1);
events.resetState();
expect(eventList.value).toEqual([]);
expect(methodList.value).toEqual([]);
});
test('切换 runtime 版本:宿主先 resetState 再写入新版本事件表', () => {
events.setEvents({
button: [{ label: 'click', value: 'click' }],
video: [{ label: 'play', value: 'play' }],
} as any);
// 是否清掉上一个版本的数据由业务决定,需要清掉时自行调用 resetState
events.resetState();
events.setEvents({ button: [{ label: 'click', value: 'click' }] } as any);
expect(events.getEvent('video')).toEqual([]);
expect(events.getEvent('button')).toHaveLength(1);
});
test('usePlugin 钩子生效destroy 后重置状态并移除插件', () => {
const afterGetEvent = vi.fn((result: any[]) => [...result, { label: 'extra', value: 'extra' }]);
events.usePlugin({ afterGetEvent } as any);
events.setEvent('button', [{ label: 'click', value: 'click' }]);
expect(events.getEvent('button')).toHaveLength(2);
events.destroy();
expect(events.getEvent('button')).toEqual([]);
events.setEvent('button', [{ label: 'click', value: 'click' }]);
expect(events.getEvent('button')).toHaveLength(1);
expect(afterGetEvent).toHaveBeenCalled();
});
});

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/element-plus-adapter",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/form-schema",
"type": "module",
"sideEffects": false,

View File

@ -830,6 +830,20 @@ export interface AddButtonConfig {
}
// #endregion AddButtonConfig
// #region GroupListHeaderConfig
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
export interface GroupListHeaderConfig {
/** 标题吸顶 */
sticky?: boolean;
/**
*
* **** 65px
* px CSS 使
*/
height?: number | string;
}
// #endregion GroupListHeaderConfig
// #region TableGroupListCommonConfig
export interface TableGroupListCommonConfig extends FormItem {
type: 'table' | 'groupList' | 'group-list';
@ -846,6 +860,8 @@ export interface TableGroupListCommonConfig extends FormItem {
/** 新增后滚动到最后一项group-list 形态,默认关闭) */
scrollLastItemIntoView?: boolean;
addButtonConfig?: AddButtonConfig;
/** group-list 形态的标题吸顶默认关闭table 形态忽略 */
header?: GroupListHeaderConfig;
}
// #endregion TableGroupListCommonConfig

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/form",
"type": "module",
"sideEffects": [

View File

@ -1,5 +1,12 @@
<template>
<div class="m-fields-group-list">
<div
class="m-fields-group-list"
:class="{
'is-header-sticky': Boolean(config.header?.sticky),
'is-footer-sticky': isFooterSticky($slots),
}"
:style="headerVars"
>
<div v-if="config.extra" v-html="config.extra" style="color: rgba(0, 0, 0, 0.45)"></div>
<div v-if="!displayItems.length" class="el-table__empty-block">
<span class="el-table__empty-text t-table__empty">暂无{{ config.titlePrefix || '' }}数据</span>
@ -34,7 +41,7 @@
<div
class="m-fields-group-list-footer"
:class="{ 'is-sticky-full': Boolean(config.addButtonConfig?.sticky) }"
v-if="!isCompare && ($slots['toggle-button'] || $slots['add-button'])"
v-if="hasFooter($slots)"
>
<slot name="toggle-button"></slot>
<div style="display: flex; justify-content: flex-end; flex: 1">
@ -49,6 +56,7 @@ import { computed } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { ContainerChangeEventData, GroupListConfig } from '../schema';
import { getGroupListHeaderVars } from '../utils/tableGroupList';
import MFieldsGroupListItem from './GroupListItem.vue';
@ -104,6 +112,19 @@ const onAddDiffCount = () => emit('addDiffCount');
const asList = (value: unknown): any[] => (Array.isArray(value) ? value : []);
const headerVars = computed(() => getGroupListHeaderVars(props.config.header));
/**
* `$slots` 而不是 `useSlots()`宿主切换 `addable` 时插槽会增删
* computed 缓存不随插槽变化失效只有在渲染期读才拿得到最新的
*/
const hasFooter = (slots: Record<string, unknown>) =>
!props.isCompare && Boolean(slots['toggle-button'] || slots['add-button']);
/** 只有真的渲染出吸底 footer内层列表才需要为它让位 */
const isFooterSticky = (slots: Record<string, unknown>) =>
hasFooter(slots) && Boolean(props.config.addButtonConfig?.sticky);
const currentList = computed(() => asList(props.model[props.name]));
/** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */

View File

@ -57,7 +57,7 @@
<component
v-for="option in options as SelectOption[]"
class="tmagic-design-option"
:key="config.valueKey ? option.value[config.valueKey] : option.value"
:key="config.valueKey ? option.value?.[config.valueKey] : option.value"
:is="optionComponent?.component || 'el-option'"
v-bind="
optionComponent?.props({
@ -372,7 +372,12 @@ const getInitOption = async () => {
});
}
let initData = getValueByKeyPath(initRoot || root, res);
let initData: any;
try {
initData = getValueByKeyPath(initRoot || root, res);
} catch {
// initRoot/root
}
if (initData) {
if (!Array.isArray(initData)) {
initData = [initData];
@ -417,8 +422,12 @@ if (typeof props.config.options === 'function') {
const v = props.model[props.name];
if (Array.isArray(v) ? !v.length : typeof v === 'undefined') return;
if (typeof v === 'undefined' || hasOption(v)) return;
const data = await getInitOption();
setOptions(data);
try {
const data = await getInitOption();
setOptions(data);
} catch {
setOptions([]);
}
};
watch(

View File

@ -70,27 +70,60 @@
flex-shrink: 0;
gap: 8px;
}
// 卡片标题吸顶补不透明背景避免滚动时正文透出
// z-index 必须低于吸底 footer否则标题会盖住新增按钮
> .el-card__header,
> .t-card__header {
position: sticky;
top: var(--m-group-list-header-sticky-top, 0px);
z-index: 7;
background-color: var(--m-group-list-header-bg, #fff);
}
}
// 嵌套标题叠在外层标题之下层级更低以免盖住外层底部分隔线
.m-fields-group-list-item .m-fields-group-list-item {
> .el-card__header,
> .t-card__header {
top: calc(
var(--m-group-list-header-sticky-top, 0px) +
var(--m-group-list-header-height)
// 嵌套吸底按钮叠在外层 footer 之上新增条件叠在新增条件组上面
// 每穿过一层自己渲染了吸底 footer的列表才多让出一个 footer 高度外层没有吸底按钮时
// 内层不能凭空抬高自定义属性不能自增所以用 item / list 两个变量交替接力
> .m-fields-group-list-item {
--m-group-list-item-footer-bottom: var(
--m-group-list-nested-footer-bottom,
0px
);
}
&.is-footer-sticky > .m-fields-group-list-item {
--m-group-list-item-footer-bottom: calc(
var(--m-group-list-nested-footer-bottom, 0px) +
var(--m-group-list-footer-height)
);
}
.m-fields-group-list-item .m-fields-group-list {
--m-group-list-nested-footer-bottom: var(
--m-group-list-item-footer-bottom,
0px
);
}
// header.sticky 时标题吸顶直接子 item避免外层吸顶带动未开启的内层
&.is-header-sticky {
// 从外层 item 接过已累加的偏移最外层没有该变量时走 fallback
--m-group-list-nested-sticky-top: var(--m-group-list-child-list-sticky-top);
--m-group-list-nested-header-z: var(--m-group-list-child-list-header-z);
> .m-fields-group-list-item {
--m-group-list-item-sticky-top: var(
--m-group-list-nested-sticky-top,
var(--m-group-list-header-sticky-top, 0px)
);
z-index: 6;
--m-group-list-item-header-z: var(--m-group-list-nested-header-z, 7);
--m-group-list-child-list-sticky-top: calc(
var(--m-group-list-item-sticky-top) + var(--m-group-list-header-height)
);
--m-group-list-child-list-header-z: calc(
var(--m-group-list-item-header-z) - 1
);
// 卡片标题吸顶补不透明背景避免滚动时正文透出
// z-index 必须低于吸底 footer否则标题会盖住新增按钮
> .el-card__header,
> .t-card__header {
position: sticky;
top: var(--m-group-list-item-sticky-top);
z-index: var(--m-group-list-item-header-z);
background-color: var(--m-group-list-header-bg, #fff);
}
}
}
@ -113,10 +146,10 @@
}
// 吸底全宽主按钮event-select / code-select / display-conds 外层列表
// z-index 高于 item header避免吸顶标题挡住新增按钮
// z-index 高于 item header避免吸顶标题挡住新增按钮多层时 bottom 由内层 list 累加
> .m-fields-group-list-footer.is-sticky-full {
position: sticky;
bottom: 0;
bottom: var(--m-group-list-nested-footer-bottom, 0px);
z-index: 7;
margin-bottom: 0;
padding: var(--m-group-list-footer-padding-top) 0
@ -133,14 +166,6 @@
height: var(--m-group-list-footer-button-height);
}
}
// 嵌套吸底按钮叠在外层 footer 之上新增条件叠在新增条件组上面
.m-fields-group-list-item
.m-fields-group-list
> .m-fields-group-list-footer.is-sticky-full {
bottom: var(--m-group-list-footer-height);
z-index: 7;
}
}
/** 最外层的groupList需要每个item需要增加空白区域界限时增加outer-gorup_list可以实现 */

View File

@ -56,6 +56,37 @@ export const mergeFormContexts = (...layers: (FormContext | undefined | null)[])
}) as FormContext;
};
/**
* 沿 Vue2 `vm.mForm.xxx`
*
*
* ```js
* vm.mForm.checkPropertyLimit = async (...) => { ... } // 一个字段的 validator 里挂
* await vm.mForm.checkPropertyLimit(...) // 另一处再取出来调用
* ```
*
* `mForm` formState core / context `set` trap
* coreState
*
* core > context >
*
* - **core**`formState.mForm = formState` FormPreview services
*
* - **context** formState context
* ComponentForm `mForm` context **** `extendState`
* formState formState
* Proxy context
* - **** `vm.mForm.xxx = fn` coreState
*
* ** context `mForm`**context computed
* core
*
* `'mForm' in state`
* `Object.keys(state)` `getOwnPropertyDescriptor` undefined
* formState AI
*/
const SELF_REF_KEY = 'mForm';
/**
* coreState 宿 coremiss 穿 context
*
@ -72,21 +103,33 @@ export const createFormStateProxy = (
coreState: FormState,
getContext: (() => FormContext) | Ref<FormContext>,
): FormState => {
const resolve = (): Record<string | symbol, any> =>
(typeof getContext === 'function' ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
const resolve = (): Record<string | symbol, any> => {
const ctx = (typeof getContext === 'function' ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
// 把 formState 自己当 context 传回来(`:context="formState"`)会让 get / has 无限递归,
// 直接爆栈。这种自引用本就提供不了任何额外字段,断掉即可。
return ctx === proxy ? EMPTY_CONTEXT : ctx;
};
return new Proxy(coreState as object, {
const proxy = 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];
const ctx = resolve();
if (k in ctx) return ctx[k];
// 自引用不进 ownKeys枚举 formState 的调用方(如按扩展字段打包发给 AI 的逻辑)
// 会因为循环引用炸掉,这里只在显式读取时才合成
return k === SELF_REF_KEY ? proxy : undefined;
},
set(t, k, value) {
(t as any)[k] = value;
return true;
},
has: (t, k) => Reflect.has(t, k) || (typeof k !== 'symbol' && k in resolve()),
// `mForm` 在这里为真,但不进 ownKeys、getOwnPropertyDescriptor 也返回 undefined
// (见 SELF_REF_KEY 注释)。因此展开 / Object.entries 不会循环引用,
// 但对 proxy 直接做递归遍历(`for...in`、深拷贝、直接 JSON.stringify(formState))仍会。
has: (t, k) => Reflect.has(t, k) || (typeof k !== 'symbol' && (k in resolve() || k === SELF_REF_KEY)),
ownKeys: (t) => [
...new Set([...Reflect.ownKeys(t), ...Reflect.ownKeys(resolve()).filter((k) => typeof k !== 'symbol')]),
],
@ -101,4 +144,6 @@ export const createFormStateProxy = (
return { configurable: true, enumerable: true, writable: true, value: ctx[k] };
},
}) as FormState;
return proxy;
};

View File

@ -18,7 +18,9 @@
import { cloneDeep } from 'lodash-es';
import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema';
import { isNumber } from '@tmagic/utils';
import type { GroupListConfig, GroupListHeaderConfig, TableColumnConfig, TableConfig } from '../schema';
/**
* table / group-list `TableGroupList.vue`
@ -31,6 +33,31 @@ import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema'
/** group-list 形态的 type兼容驼峰与中划线两种写法 */
export const isGroupListType = (type: unknown): boolean => type === 'groupList' || type === 'group-list';
/**
* CSS
*
* JSON `'48'` CSS
* `calc()` `px`
*/
const toCssLength = (height: number | string | undefined): string | undefined => {
const value = `${height ?? ''}`.trim();
if (!value) return undefined;
if (isNumber(value)) return `${value}px`;
// 走到这里还是 number 的只剩 NaN / Infinity不写变量交给样式默认值兜底
return typeof height === 'number' ? undefined : value;
};
/** header.sticky 开启时把 height 写成 CSS 变量,给嵌套吸顶偏移用 */
export const getGroupListHeaderVars = (
header: GroupListHeaderConfig | undefined,
): Record<string, string> | undefined => {
if (!header?.sticky) return undefined;
const height = toCssLength(header.height);
return height ? { '--m-group-list-header-height': height } : undefined;
};
/** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px最小 80px */
export const calcLabelWidth = (label: string): string => {
if (!label) return '0px';

View File

@ -202,6 +202,63 @@ describe('GroupList container', () => {
).toBe(false);
warn.mockRestore();
});
test('点击删除按钮移除对应项', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
await wrapper.findAll('.delete-button')[0].trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'b' }]);
expect(wrapper.findAll('.m-fields-group-list-item')).toHaveLength(1);
});
test('点击复制按钮在末尾追加一份副本', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
const copyButton = wrapper.findAll('button').find((btn) => btn.text().includes('复制'));
await copyButton?.trigger('click');
await nextTick();
const { list } = (wrapper.vm as any).values;
expect(list).toHaveLength(3);
expect(list[2]).toEqual({ text: 'a' });
// 深拷贝,改副本不应牵动原项
expect(list[2]).not.toBe(list[0]);
});
test('点击下移 / 上移交换相邻两项', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
// 首项的「上移」与末项的「下移」只是 v-show 隐藏,仍在 DOM 里,按 item 作用域取才不会点错
const buttonIn = (index: number, text: string) =>
wrapper
.findAll('.m-fields-group-list-item')
[index].findAll('button')
.find((btn) => btn.text().includes(text));
await buttonIn(0, '下移')?.trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'b' }, { text: 'a' }]);
await buttonIn(1, '上移')?.trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'a' }, { text: 'b' }]);
});
test('移动越界时夹到列表两端,不丢项', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
const item = wrapper.findAllComponents({ name: 'MFormGroupListItem' })[1];
item.vm.$emit('swap-item', 1, 5);
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'a' }, { text: 'b' }]);
});
});
describe('labelPosition 透传', () => {
@ -501,6 +558,131 @@ describe('GroupList container', () => {
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer.is-sticky-full').exists()).toBe(true);
expect(wrapper.text()).toContain('添加');
expect(wrapper.find('.m-fields-group-list').classes()).toContain('is-footer-sticky');
});
// 没有吸底 footer 的层不能让内层凭空抬高
test('未开 addButtonConfig.sticky 时根节点不带 is-footer-sticky', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list').classes()).not.toContain('is-footer-sticky');
});
test('对比模式下不渲染 footer也不带 is-footer-sticky', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
addButtonConfig: { sticky: true, text: '添加', props: { type: 'primary', plain: true, text: false } },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
{ isCompare: true, lastValues: { list: [{ text: 'a' }] } },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer').exists()).toBe(false);
expect(wrapper.find('.m-fields-group-list').classes()).not.toContain('is-footer-sticky');
});
test('header.sticky 时根节点带 is-header-sticky', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
header: { sticky: true },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(true);
});
test('未开 header.sticky 时不加吸顶 class', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
});
test('header.height 在 sticky 开启时写入 CSS 变量', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
header: { sticky: true, height: 48 },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const el = wrapper.find('.m-fields-group-list').element as HTMLElement;
expect(el.style.getPropertyValue('--m-group-list-header-height')).toBe('48px');
});
test('仅配置 header.height 未开 sticky 时不写 CSS 变量', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
header: { height: 48 },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const el = wrapper.find('.m-fields-group-list').element as HTMLElement;
expect(el.style.getPropertyValue('--m-group-list-header-height')).toBe('');
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
});
test('嵌套列表各自按 header.sticky 决定是否吸顶', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'groups',
header: { sticky: true },
items: [
{
type: 'group-list',
name: 'cond',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
},
],
{ groups: [{ cond: [{ text: 'a' }] }] },
);
await nextTick();
const lists = wrapper.findAll('.m-fields-group-list');
expect(lists[0].classes()).toContain('is-header-sticky');
expect(lists[1].classes()).not.toContain('is-header-sticky');
});
});
});

View File

@ -0,0 +1,311 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
/**
* `tMagicSelect.value.scrollbarWrap` ElSelect
* TMagicSelect
*
* el-option inject ElSelect select
*
*/
const selectStub = {
scrollbarWrap: undefined as HTMLElement | undefined,
setQuery: vi.fn(),
setPreviousQuery: vi.fn(),
setSelectedLabel: vi.fn(),
setSelected: vi.fn(),
};
vi.mock('@tmagic/design', async (importOriginal) => ({
...(await importOriginal<typeof import('@tmagic/design')>()),
TMagicSelect: defineComponent({
name: 'TMagicSelectStub',
props: { modelValue: null, remoteMethod: { type: Function, default: undefined } },
emits: ['update:modelValue', 'visible-change'],
setup(_props, { expose }) {
expose(selectStub);
return () => h('div', { class: 'stub-select' });
},
}),
}));
const MagicForm = (await import('@form/index')).default;
const { MForm, MSelect } = await import('@form/index');
const { setConfig } = await import('@form/utils/config');
let request: ReturnType<typeof vi.fn>;
const flushAsync = async () => {
await nextTick();
await new Promise((r) => setTimeout(r, 0));
await nextTick();
};
const mountSelect = (config: any, initValues: any = {}) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, [MagicForm as any, { request }]] },
props: { config: [{ name: 's', type: 'select', text: 's', ...config }], initValues },
});
const getStub = (wrapper: any) => wrapper.findComponent({ name: 'TMagicSelectStub' });
/**
* initUrl
*
* MForm '' undefined getInitOption initUrl
* getInitLocalOption localOptions getOptions
* initUrl init
* localOptions / /
*/
const remoteOption = (extra: any = {}) => ({
url: 'https://example.com/list',
initUrl: 'https://example.com/init',
root: 'data.list',
totalKey: 'total',
...extra,
});
beforeEach(() => {
const wrap = document.createElement('div');
selectStub.scrollbarWrap = wrap;
selectStub.setQuery.mockClear();
selectStub.setPreviousQuery.mockClear();
selectStub.setSelectedLabel.mockClear();
selectStub.setSelected.mockClear();
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] }, total: 50 }));
setConfig({ request });
});
afterEach(() => {
setConfig({});
});
describe('Select - visibleHandler', () => {
test('下拉收起时不触发请求', async () => {
const wrapper = mountSelect({
remote: true,
option: remoteOption(),
});
await flushAsync();
request.mockClear();
getStub(wrapper).vm.$emit('visible-change', false);
await flushAsync();
expect(request).not.toHaveBeenCalled();
});
test('非 remote 配置展开下拉时不触发请求', async () => {
const wrapper = mountSelect({ option: remoteOption() });
await flushAsync();
request.mockClear();
getStub(wrapper).vm.$emit('visible-change', true);
await flushAsync();
expect(request).not.toHaveBeenCalled();
});
test('remote 展开且选项不足时拉取选项', async () => {
const wrapper = mountSelect({
remote: true,
option: remoteOption(),
});
await flushAsync();
request.mockClear();
getStub(wrapper).vm.$emit('visible-change', true);
await flushAsync();
expect(request).toHaveBeenCalledTimes(1);
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]);
});
test('已有搜索词时展开下拉,回填搜索状态而不重新拉取', async () => {
const wrapper = mountSelect({
remote: true,
option: remoteOption(),
});
await flushAsync();
// 先通过远程搜索写入 query
await getStub(wrapper).props('remoteMethod')('kw');
await flushAsync();
request.mockClear();
getStub(wrapper).vm.$emit('visible-change', true);
await flushAsync();
expect(selectStub.setQuery).toHaveBeenCalledWith('kw');
expect(selectStub.setPreviousQuery).toHaveBeenCalledWith('kw');
expect(selectStub.setSelectedLabel).toHaveBeenCalledWith('kw');
expect(request).not.toHaveBeenCalled();
});
});
describe('Select - remoteMethod', () => {
test('远程搜索会重置分页并按关键词请求', async () => {
const wrapper = mountSelect({
remote: true,
option: remoteOption(),
});
await flushAsync();
request.mockClear();
await getStub(wrapper).props('remoteMethod')('kw');
await flushAsync();
expect(request).toHaveBeenCalledTimes(1);
expect(request.mock.calls[0][0].data).toMatchObject({ query: 'kw', pgIndex: 0 });
});
test('多选远程搜索后刷新已选状态', async () => {
const wrapper = mountSelect(
{
remote: true,
multiple: true,
option: remoteOption(),
},
{ s: [] },
);
await flushAsync();
await getStub(wrapper).props('remoteMethod')('kw');
await flushAsync();
expect(selectStub.setSelected).toHaveBeenCalled();
});
test('已有本地选项时远程搜索不再请求', async () => {
// 无 initUrl初始化会走 getInitLocalOption把结果缓存进 localOptions
const wrapper = mountSelect(
{
remote: true,
option: { url: 'https://example.com/list', root: 'data.list' },
},
{ s: 'a' },
);
await flushAsync();
request.mockClear();
await getStub(wrapper).props('remoteMethod')('kw');
await flushAsync();
expect(request).not.toHaveBeenCalled();
});
});
describe('Select - 滚动分页', () => {
const scrollToBottom = async (wrapper: any) => {
selectStub.scrollbarWrap!.dispatchEvent(new Event('scroll'));
await flushAsync();
return wrapper;
};
const remoteConfig = { remote: true, option: remoteOption() };
test('触底且仍有剩余数据时加载下一页', async () => {
const wrapper = mountSelect(remoteConfig);
await flushAsync();
// 先展开一次填充 options 与 totaltotal=50 > 已加载 1 条)
getStub(wrapper).vm.$emit('visible-change', true);
await flushAsync();
request.mockClear();
await scrollToBottom(wrapper);
expect(request).toHaveBeenCalledTimes(1);
expect(request.mock.calls[0][0].data).toMatchObject({ pgIndex: 1 });
});
test('已加载条数达到总数时触底不再请求', async () => {
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] }, total: 1 }));
setConfig({ request });
const wrapper = mountSelect(remoteConfig);
await flushAsync();
getStub(wrapper).vm.$emit('visible-change', true);
await flushAsync();
request.mockClear();
await scrollToBottom(wrapper);
expect(request).not.toHaveBeenCalled();
});
test('未触底时不加载下一页', async () => {
const wrapper = mountSelect(remoteConfig);
await flushAsync();
getStub(wrapper).vm.$emit('visible-change', true);
await flushAsync();
request.mockClear();
// 距底部还有距离
Object.defineProperty(selectStub.scrollbarWrap!, 'scrollHeight', { value: 500, configurable: true });
Object.defineProperty(selectStub.scrollbarWrap!, 'clientHeight', { value: 100, configurable: true });
await scrollToBottom(wrapper);
expect(request).not.toHaveBeenCalled();
});
});
describe('Select - 多选保留已选项', () => {
test('新一页结果不含已选项时,已选项仍保留在选项列表里', async () => {
// initUrl 让初始化走 init 接口localOptions 保持为空,
// 这样后续展开下拉才会真正调用列表接口
request = vi.fn(async (postOptions: Record<string, any>) => {
if (postOptions.url.includes('init')) {
return { data: { list: [{ text: 'A', value: 'a' }] } };
}
return { data: { list: [{ text: 'B', value: 'b' }] }, total: 50 };
});
setConfig({ request });
const wrapper = mountSelect(
{
remote: true,
multiple: true,
option: {
url: 'https://example.com/list',
initUrl: 'https://example.com/init',
initRoot: 'data.list',
root: 'data.list',
totalKey: 'total',
},
},
{ s: ['a'] },
);
await flushAsync();
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]);
getStub(wrapper).vm.$emit('visible-change', true);
await flushAsync();
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b' },
]);
});
});
describe('Select - changeHandler', () => {
test('选中值变化时向上抛 change', async () => {
const wrapper = mountSelect({ options: [{ text: 'A', value: 'a' }] }, { s: '' });
await flushAsync();
getStub(wrapper).vm.$emit('update:modelValue', 'a');
await flushAsync();
expect(wrapper.findComponent(MSelect).emitted('change')?.[0]).toEqual(['a']);
});
});

View File

@ -76,6 +76,81 @@ describe('Select', () => {
expect(wrapper.findComponent(MSelect).exists()).toBe(true);
});
/**
* key valueKey `valueKey && option.value?.[valueKey] ? ... : option.value`
* `{ id: 0 }` 退 key
* valueKey key
* Vue key id 0
*/
test('valueKey 指向假值时key 仍取该值而非整个对象', async () => {
const wrapper = mountForm(
[
{
name: 's',
type: 'select',
text: 's',
valueKey: 'id',
options: [
{ text: 'A', value: { id: 0 } },
{ text: 'B', value: { id: 1 } },
{ text: 'C', value: { id: '' } },
],
},
],
{ s: { id: 1 } },
);
await nextTick();
await nextTick();
const keys = wrapper.findAllComponents({ name: 'ElOption' }).map((o) => (o.vm as any).$.vnode.key);
expect(keys).toEqual([0, 1, '']);
});
test('valueKey 配置下 option.value 为 null 不抛错', async () => {
const wrapper = mountForm(
[
{
name: 's',
type: 'select',
text: 's',
valueKey: 'id',
options: [
{ text: 'A', value: { id: 1 } },
{ text: 'B', value: null },
],
},
],
{},
);
await nextTick();
await nextTick();
const keys = wrapper.findAllComponents({ name: 'ElOption' }).map((o) => (o.vm as any).$.vnode.key);
expect(keys).toEqual([1, null]);
});
test('未配置 valueKey 时 key 取 option.value 本身', async () => {
const wrapper = mountForm(
[
{
name: 's',
type: 'select',
text: 's',
options: [
{ text: 'A', value: 0 },
{ text: 'B', value: 'b' },
],
},
],
{},
);
await nextTick();
await nextTick();
const keys = wrapper.findAllComponents({ name: 'ElOption' }).map((o) => (o.vm as any).$.vnode.key);
expect(keys).toEqual([0, 'b']);
});
test('multiple 多选', async () => {
const wrapper = mountForm(
[
@ -333,3 +408,299 @@ describe('Select - config.option model value watch', () => {
);
});
});
/**
* initUrl getInitLocalOption -> getOptions
* option
*/
describe('Select - getOptions 列表接口分支', () => {
let request: ReturnType<typeof vi.fn>;
const mountFormWithRequest = (config: any[], initValues: any = {}) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, [MagicForm as any, { request }]] },
props: { config, initValues },
});
const flushAsync = async () => {
await nextTick();
await new Promise((r) => setTimeout(r, 0));
await nextTick();
};
const buildConfig = (option: any, extra: any = {}) => [{ name: 's', type: 'select', text: 's', ...extra, option }];
beforeEach(() => {
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] }, total: 50 }));
setConfig({ request });
});
afterEach(() => {
setConfig({});
vi.restoreAllMocks();
});
test('url 与 body 为函数时先求值再请求,并带上分页参数', async () => {
const url = vi.fn(async () => 'https://example.com/dyn');
const body = vi.fn(() => ({ extra: 1 }));
mountFormWithRequest(buildConfig({ url, body, root: 'data.list' }), { s: 'a' });
await flushAsync();
expect(url).toHaveBeenCalled();
expect(body).toHaveBeenCalled();
const arg = request.mock.calls[0][0];
expect(arg.url).toBe('https://example.com/dyn');
expect(arg.data).toMatchObject({ extra: 1, query: '', pgSize: 20, pgIndex: 0 });
});
test('beforeRequest / afterRequest 钩子可改写请求与响应', async () => {
const beforeRequest = vi.fn(async (_mForm: any, postOptions: any) => ({
...postOptions,
headers: { token: 't' },
}));
const afterRequest = vi.fn(async () => ({ data: { list: [{ text: 'AR', value: 'a' }] } }));
const wrapper = mountFormWithRequest(
buildConfig({ url: 'https://example.com/list', root: 'data.list', beforeRequest, afterRequest }),
{ s: 'a' },
);
await flushAsync();
expect(beforeRequest).toHaveBeenCalled();
expect(afterRequest).toHaveBeenCalled();
expect(request.mock.calls[0][0].headers).toEqual({ token: 't' });
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'AR', value: 'a' }]);
});
test('method 为 jsonp 时补 jsonpCallback', async () => {
mountFormWithRequest(buildConfig({ url: 'https://example.com/list', root: 'data.list', method: 'jsonp' }), {
s: 'a',
});
await flushAsync();
expect(request.mock.calls[0][0]).toMatchObject({ method: 'jsonp', jsonpCallback: 'callback' });
});
test('自定义 jsonpCallback 优先', async () => {
mountFormWithRequest(
buildConfig({ url: 'https://example.com/list', root: 'data.list', method: 'JSONP', jsonpCallback: 'cb' }),
{ s: 'a' },
);
await flushAsync();
expect(request.mock.calls[0][0].jsonpCallback).toBe('cb');
});
test('option.item 自定义映射结果totalKey 命中时记录总数', async () => {
request = vi.fn(async () => ({ data: { list: [{ n: 'A', v: 'a' }] }, total: 50 }));
setConfig({ request });
const item = vi.fn((data: any[]) => data.map((d) => ({ text: d.n, value: d.v })));
const wrapper = mountFormWithRequest(
buildConfig({ url: 'https://example.com/list', root: 'data.list', totalKey: 'total', item }),
{ s: 'a' },
);
await flushAsync();
expect(item).toHaveBeenCalled();
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]);
});
test('option.text / option.value 为函数时按函数取值', async () => {
request = vi.fn(async () => ({ data: { list: [{ n: 'A', v: 'a' }] } }));
setConfig({ request });
const wrapper = mountFormWithRequest(
buildConfig({
url: 'https://example.com/list',
root: 'data.list',
text: (i: any) => `T-${i.n}`,
value: (i: any) => i.v,
}),
{ s: 'a' },
);
await flushAsync();
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'T-A', value: 'a' }]);
});
test('valueKey 下用对象值比对,命中后不再重复请求', async () => {
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: { id: 'a' } }] } }));
setConfig({ request });
const wrapper = mountFormWithRequest(
buildConfig({ url: 'https://example.com/list', root: 'data.list' }, { valueKey: 'id' }),
{ s: { id: 'a' } },
);
await flushAsync();
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: { id: 'a' } }]);
// 已有匹配项hasOption 命中后不应再打接口
const count = request.mock.calls.length;
(wrapper.vm as any).values.s = { id: 'a' };
await flushAsync();
expect(request.mock.calls.length).toBe(count);
});
test('group 单选:按组过滤出命中项所在的组', async () => {
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] } }));
setConfig({ request });
const item = vi.fn(() => [
{ label: 'G1', options: [{ text: 'A', value: 'a' }] },
{ label: 'G2', options: [{ text: 'B', value: 'b' }] },
]);
const wrapper = mountFormWithRequest(
buildConfig({ url: 'https://example.com/list', root: 'data.list', item }, { group: true }),
{ s: 'a' },
);
await flushAsync();
const opts = (wrapper.findComponent(MSelect).vm as any).options;
expect(opts).toHaveLength(1);
expect(opts[0].label).toBe('G1');
});
test('group 多选:按组过滤出命中项所在的组', async () => {
request = vi.fn(async () => ({ data: { list: [] } }));
setConfig({ request });
const item = vi.fn(() => [
{ label: 'G1', options: [{ text: 'A', value: 1 }] },
{ label: 'G2', options: [{ text: 'B', value: 2 }] },
]);
const wrapper = mountFormWithRequest(
buildConfig({ url: 'https://example.com/list', root: 'data.list', item }, { group: true, multiple: true }),
{ s: [2] },
);
await flushAsync();
const opts = (wrapper.findComponent(MSelect).vm as any).options;
expect(opts).toHaveLength(1);
expect(opts[0].label).toBe('G2');
});
test('本地选项已加载后再次触发不重复请求', async () => {
const wrapper = mountFormWithRequest(buildConfig({ url: 'https://example.com/list', root: 'data.list' }), {
s: 'a',
});
await flushAsync();
const count = request.mock.calls.length;
// 换一个不在 options 里的值:会再次走 getInitLocalOption
// 但 localOptions 已有缓存getOptions 直接返回不再打接口
(wrapper.vm as any).values.s = 'zzz';
await flushAsync();
expect(request.mock.calls.length).toBe(count);
});
});
/**
* initUrl getInitOption init
*/
describe('Select - getInitOption 初始化接口分支', () => {
let request: ReturnType<typeof vi.fn>;
const mountFormWithRequest = (option: any, initValues: any = {}) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, [MagicForm as any, { request }]] },
props: { config: [{ name: 's', type: 'select', text: 's', option }], initValues },
});
const flushAsync = async () => {
await nextTick();
await new Promise((r) => setTimeout(r, 0));
await nextTick();
};
beforeEach(() => {
request = vi.fn(async () => ({ data: { obj: { text: 'A', value: 'a' } } }));
setConfig({ request });
});
afterEach(() => {
setConfig({});
vi.restoreAllMocks();
});
test('initUrl / initBody 为函数,且 beforeInitRequest 可改写请求', async () => {
const initUrl = vi.fn(async () => 'https://example.com/init-dyn');
const initBody = vi.fn(() => ({ b: 1 }));
const beforeInitRequest = vi.fn(async (_mForm: any, postOptions: any) => ({
...postOptions,
headers: { token: 't' },
}));
mountFormWithRequest({ initUrl, initBody, beforeInitRequest, initRoot: 'data.obj' }, { s: 'a' });
await flushAsync();
expect(initUrl).toHaveBeenCalled();
expect(initBody).toHaveBeenCalled();
expect(beforeInitRequest).toHaveBeenCalled();
const arg = request.mock.calls[0][0];
expect(arg.url).toBe('https://example.com/init-dyn');
expect(arg.data).toMatchObject({ id: 'a', b: 1 });
expect(arg.headers).toEqual({ token: 't' });
});
test('init 请求为 jsonp 时补 jsonpCallback', async () => {
mountFormWithRequest({ initUrl: 'https://example.com/init', initRoot: 'data.obj', method: 'jsonp' }, { s: 'a' });
await flushAsync();
expect(request.mock.calls[0][0]).toMatchObject({ method: 'jsonp', jsonpCallback: 'callback' });
});
test('afterRequest 可改写 init 响应;非数组结果会被包成数组', async () => {
const afterRequest = vi.fn(async () => ({ data: { obj: { text: 'ONE', value: 'a' } } }));
const wrapper = mountFormWithRequest(
{ initUrl: 'https://example.com/init', initRoot: 'data.obj', afterRequest },
{ s: 'a' },
);
await flushAsync();
expect(afterRequest).toHaveBeenCalled();
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'ONE', value: 'a' }]);
});
test('init 结果走 option.item 自定义映射', async () => {
request = vi.fn(async () => ({ data: { obj: [{ n: 'A', v: 'a' }] } }));
setConfig({ request });
const item = vi.fn((data: any[]) => data.map((d) => ({ text: d.n, value: d.v })));
const wrapper = mountFormWithRequest(
{ initUrl: 'https://example.com/init', initRoot: 'data.obj', item },
{ s: 'a' },
);
await flushAsync();
expect(item).toHaveBeenCalled();
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]);
});
test('init 响应取不到数据时 options 为空', async () => {
request = vi.fn(async () => ({}));
setConfig({ request });
const wrapper = mountFormWithRequest({ initUrl: 'https://example.com/init', initRoot: 'data.obj' }, { s: 'a' });
await flushAsync();
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([]);
});
test('init 请求失败时 options 为空且不抛未处理异常', async () => {
request = vi.fn(async () => {
throw new Error('init-fail');
});
setConfig({ request });
const wrapper = mountFormWithRequest({ initUrl: 'https://example.com/init', initRoot: 'data.obj' }, { s: 'a' });
await flushAsync();
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([]);
});
});

View File

@ -106,6 +106,77 @@ describe('createFormStateProxy', () => {
expect(Object.getOwnPropertyDescriptor(formState, 'username')?.enumerable).toBe(true);
});
test('mForm 自引用回 formState读能穿到 core 与 context', () => {
const formState = createFormStateProxy(makeCore({ keyProp: 'id' }), () => ({ username: 'alice' }));
const vm = formState as any;
expect(vm.mForm).toBe(formState);
expect(vm.mForm.keyProp).toBe('id');
expect(vm.mForm.username).toBe('alice');
expect('mForm' in formState).toBe(true);
});
test('往 mForm 上挂的方法落到 core 并持久可读,跨回调可取回', () => {
const core = makeCore();
const formState = createFormStateProxy(core, () => ({ username: 'alice' }));
const vm = formState as any;
// 存量配置的跨字段通信写法:一个 validator 里挂,另一处取出来调用
const check = () => 'checked';
vm.mForm.checkPropertyLimit = check;
expect((core as any).checkPropertyLimit).toBe(check);
expect(vm.mForm.checkPropertyLimit()).toBe('checked');
expect(vm.checkPropertyLimit).toBe(check);
});
test('mForm 不进入枚举,避免循环引用', () => {
const formState = createFormStateProxy(makeCore(), () => ({ username: 'alice' }));
expect(Object.keys(formState)).not.toContain('mForm');
expect(() => JSON.stringify({ ...formState })).not.toThrow();
});
test('core 或 context 显式提供 mForm 时不被自引用覆盖', () => {
const host = { name: 'host-mForm' };
const fromContext = createFormStateProxy(makeCore(), () => ({ mForm: host }) as any);
expect((fromContext as any).mForm).toBe(host);
const own = { name: 'core-mForm' };
const fromCore = createFormStateProxy(makeCore({ mForm: own }), () => ({ mForm: host }) as any);
expect((fromCore as any).mForm).toEqual(own);
});
/**
* ComponentForm formState context
* mForm context extendState
*
*/
test('父 formState 作为 context 时mForm 指向父表单而非自引用', () => {
const parent = createFormStateProxy(makeCore({ owner: 'parent' }), () => ({}) as any);
const child = createFormStateProxy(makeCore({ owner: 'child' }), () => parent as any);
expect((child as any).mForm).toBe(parent);
// 子表单自己的 core 字段仍优先,不被父级覆盖
expect((child as any).owner).toBe('child');
// 跨字段通信:一个回调往 mForm 上挂,另一个回调取回,落在同一份对象上
(child as any).mForm.checkLimit = () => 'ok';
expect((parent as any).checkLimit()).toBe('ok');
});
test('把 formState 自己当 context 传回来不爆栈', () => {
// 闭包只在 trap 触发时求值,构造期不会读到未初始化的 formState
const formState: any = createFormStateProxy(makeCore({ a: 1 }), () => formState);
expect(formState.a).toBe(1);
expect(formState.missing).toBeUndefined();
expect('missing' in formState).toBe(false);
// 自引用不提供额外字段mForm 仍由兜底合成
expect(formState.mForm).toBe(formState);
expect(Object.keys(formState)).toContain('a');
});
test('getContext 可以是 Ref', () => {
const ctx = ref({ username: 'alice' });
const formState = createFormStateProxy(makeCore(), ctx);

View File

@ -5,7 +5,7 @@
*/
import { describe, expect, test } from 'vitest';
import { getGroupListRowConfig } from '@form/utils/tableGroupList';
import { getGroupListHeaderVars, getGroupListRowConfig } from '@form/utils/tableGroupList';
describe('getGroupListRowConfig', () => {
test('把 group-list 的 labelWidth / labelPosition 复制到 row 配置', () => {
@ -26,3 +26,47 @@ describe('getGroupListRowConfig', () => {
expect(row.items).toHaveLength(1);
});
});
describe('getGroupListHeaderVars', () => {
test('未开 sticky 时不写样式', () => {
expect(getGroupListHeaderVars(undefined)).toBeUndefined();
expect(getGroupListHeaderVars({})).toBeUndefined();
expect(getGroupListHeaderVars({ height: 48 })).toBeUndefined();
});
test('sticky 但 height 为空时不写变量,走样式默认值', () => {
expect(getGroupListHeaderVars({ sticky: true })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: '' })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: ' ' })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: Number.NaN })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: Number.POSITIVE_INFINITY })).toBeUndefined();
});
test('数字 height 转成 px', () => {
expect(getGroupListHeaderVars({ sticky: true, height: 48 })).toEqual({
'--m-group-list-header-height': '48px',
});
expect(getGroupListHeaderVars({ sticky: true, height: 0 })).toEqual({
'--m-group-list-header-height': '0px',
});
});
// 无单位的值会让嵌套层的 calc() 整体失效、吸顶静默失灵
test('无单位数字字符串补 px', () => {
expect(getGroupListHeaderVars({ sticky: true, height: '48' })).toEqual({
'--m-group-list-header-height': '48px',
});
expect(getGroupListHeaderVars({ sticky: true, height: ' 47.5 ' })).toEqual({
'--m-group-list-header-height': '47.5px',
});
});
test('带单位的字符串 height 原样写入', () => {
expect(getGroupListHeaderVars({ sticky: true, height: '4em' })).toEqual({
'--m-group-list-header-height': '4em',
});
expect(getGroupListHeaderVars({ sticky: true, height: 'var(--x)' })).toEqual({
'--m-group-list-header-height': 'var(--x)',
});
});
});

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/schema",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/stage",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/table",
"type": "module",
"sideEffects": [

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/tdesign-vue-next-adapter",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"name": "@tmagic/utils",
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "tmagic-playground",
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"type": "module",
"private": true,
"scripts": {
@ -12,11 +12,11 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@tmagic/core": "1.8.0-beta.26",
"@tmagic/design": "1.8.0-beta.26",
"@tmagic/editor": "1.8.0-beta.26",
"@tmagic/element-plus-adapter": "1.8.0-beta.26",
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.26",
"@tmagic/core": "1.8.0-beta.28",
"@tmagic/design": "1.8.0-beta.28",
"@tmagic/editor": "1.8.0-beta.28",
"@tmagic/element-plus-adapter": "1.8.0-beta.28",
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.28",
"@tmagic/tmagic-form-runtime": "1.1.6",
"element-plus": "catalog:",
"lodash-es": "^4.18.1",

404
pnpm-lock.yaml generated
View File

@ -567,23 +567,23 @@ importers:
specifier: ^2.3.2
version: 2.3.2(vue@3.5.41(typescript@6.0.3))
'@tmagic/core':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26(typescript@6.0.3)
specifier: 1.8.0-beta.28
version: link:../packages/core
'@tmagic/design':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
specifier: 1.8.0-beta.28
version: link:../packages/design
'@tmagic/editor':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(monaco-editor@0.56.0)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
specifier: 1.8.0-beta.28
version: link:../packages/editor
'@tmagic/element-plus-adapter':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(element-plus@2.14.4(vue@3.5.41(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
specifier: 1.8.0-beta.28
version: link:../packages/element-plus-adapter
'@tmagic/tdesign-vue-next-adapter':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(tdesign-vue-next@1.20.5(vue@3.5.41(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
specifier: 1.8.0-beta.28
version: link:../packages/tdesign-vue-next-adapter
'@tmagic/tmagic-form-runtime':
specifier: 1.1.6
version: 1.1.6(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(monaco-editor@0.56.0)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(element-plus@2.14.4(vue@3.5.41(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
version: 1.1.6(@tmagic/core@packages+core)(@tmagic/editor@packages+editor)(element-plus@2.14.4(vue@3.5.41(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
element-plus:
specifier: ^2.14.4
version: 2.14.4(vue@3.5.41(typescript@6.0.3))
@ -923,14 +923,14 @@ importers:
runtime/react:
dependencies:
'@tmagic/core':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26(typescript@6.0.3)
specifier: 1.8.0-beta.28
version: link:../../packages/core
'@tmagic/react-runtime-help':
specifier: 0.2.2
version: 0.2.2(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.18.1)(react@19.2.8)(typescript@6.0.3)
version: link:../react-runtime-help
'@tmagic/stage':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
specifier: 1.8.0-beta.28
version: link:../../packages/stage
axios:
specifier: ^1.19.0
version: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
@ -945,8 +945,8 @@ importers:
version: 19.2.8(react@19.2.8)
devDependencies:
'@tmagic/cli':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26
specifier: 1.8.0-beta.28
version: link:../../packages/cli
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@ -1021,14 +1021,14 @@ importers:
runtime/vue:
dependencies:
'@tmagic/core':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26(typescript@6.0.3)
specifier: 1.8.0-beta.28
version: link:../../packages/core
'@tmagic/stage':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
specifier: 1.8.0-beta.28
version: link:../../packages/stage
'@tmagic/vue-runtime-help':
specifier: ^2.0.4
version: 2.0.4(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
version: link:../vue-runtime-help
axios:
specifier: ^1.19.0
version: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
@ -1037,8 +1037,8 @@ importers:
version: 3.5.41(typescript@6.0.3)
devDependencies:
'@tmagic/cli':
specifier: 1.8.0-beta.26
version: 1.8.0-beta.26
specifier: 1.8.0-beta.28
version: link:../../packages/cli
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@ -2748,11 +2748,6 @@ packages:
'@sxzz/popperjs-es@2.11.8':
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
'@tmagic/cli@1.8.0-beta.26':
resolution: {integrity: sha512-lsNAbON1lSh603P3Z+xKOvkFLxeUSnsLWzqP4Sl6mCY/ptyFjhRxd3Uq0qK5mpvRWDVKs51TSmwhFaLkW+7KLw==}
engines: {node: '>=18'}
hasBin: true
'@tmagic/core@1.7.0':
resolution: {integrity: sha512-n3IRm71D0/BZodzywBPRN6h0TWlpdZol1kbW7b3H+V8oRj4ZXdcWKOpo7+vGsRuR0DdzJtfgo7o4hGj7EVLyTA==}
engines: {node: '>=18'}
@ -2771,15 +2766,6 @@ packages:
typescript:
optional: true
'@tmagic/core@1.8.0-beta.26':
resolution: {integrity: sha512-+IeByZLALvy1i0apTid+id0LF9sFOm8yXImzYv4z/ifEs8HwjaVR0NDOsGphZqrevsg/nYEvpcA9LoCFus6eeA==}
engines: {node: '>=18'}
peerDependencies:
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/data-source@1.7.0':
resolution: {integrity: sha512-wN97pQiNXQXAkKNZyV8MADEBDEfS2l+uZnbqmdny4vAaTuwIB6DoZ8wpGx0oe3h9icj6f7O8fwyoNFoW+zTW4w==}
engines: {node: '>=18'}
@ -2800,16 +2786,6 @@ packages:
typescript:
optional: true
'@tmagic/data-source@1.8.0-beta.26':
resolution: {integrity: sha512-xbmUt1GSQDPzGoCpVZY5bSgHmio/DJGAFmbySXSeHAH6m1CfrLw8RUayGrjNMFNfxHADO9x5HtIL/L6beBwxYQ==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/core': 1.8.0-beta.26
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/dep@1.7.0':
resolution: {integrity: sha512-zR3yyAzevTkyfZEvftKD7jWlpEhR3fkTvJS78JC7dEEpKCsI5uvK43cKaH29ZspmR9fWlkP1HmNdB6GxiI5I2A==}
engines: {node: '>=18'}
@ -2832,17 +2808,6 @@ packages:
typescript:
optional: true
'@tmagic/dep@1.8.0-beta.26':
resolution: {integrity: sha512-9FQ0KLDMFpYqzuxCUP2vtjENB+L2QV4jC+2SiXkiqSKkQ1s2FxUjxoIjm+lkTW92PwreQ5+KpkJs6f5CzpQxrg==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/schema': 1.8.0-beta.26
'@tmagic/utils': 1.8.0-beta.26
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/design@1.7.2':
resolution: {integrity: sha512-BJdy/WswcQ2icyxRhUE1PMbPw81rtDcVsmYpZGmgqwDLKg+f2ByFHUxTBjMilH7iz4WlSDxuv1ZkH+dHYGG5GA==}
engines: {node: '>=18'}
@ -2853,16 +2818,6 @@ packages:
typescript:
optional: true
'@tmagic/design@1.8.0-beta.26':
resolution: {integrity: sha512-NpagIRcD9q+mQND20Gn7hNpMpls2dcYpDuVX/Y3ADPVdLOU7i9sjnaIAHqYEMthDO7rkBsXZ2AZ0tKqLO/twPA==}
engines: {node: '>=18'}
peerDependencies:
typescript: ^6.0.3
vue: ^3.5.41
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/editor@1.7.2':
resolution: {integrity: sha512-cLv/WvheIjxRealxuN0XFoZaMrj79kPUGUBQTqjBVYWHcv6oSVRV+N9fPX3DoHfrkWCG+21NW4fItvOAdpKLPw==}
engines: {node: '>=18'}
@ -2875,33 +2830,6 @@ packages:
typescript:
optional: true
'@tmagic/editor@1.8.0-beta.26':
resolution: {integrity: sha512-eZlbecsHwgAJ5+p9X5nkJxtMtXGhDGs6+3UJMUNQ8UNfZY+ymxRAm1yY84MpJZxIvwvqTkrdBfTfWhAMRennfw==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/core': 1.8.0-beta.26
monaco-editor: '^0.55.1 '
type-fest: ^5.2.0
typescript: ^6.0.3
vue: ^3.5.41
peerDependenciesMeta:
type-fest:
optional: true
typescript:
optional: true
'@tmagic/element-plus-adapter@1.8.0-beta.26':
resolution: {integrity: sha512-6UQ84kQGg3/xqhWpCZOYZFVlM5ZixS4E5b1kfR41zVMqLPwN1Qr9geZEph55uIOHHcULZ6AcZXg8mHhNvAFuVA==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/design': 1.8.0-beta.26
element-plus: ^2.14.4
typescript: ^6.0.3
vue: ^3.5.41
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/form-schema@1.7.2':
resolution: {integrity: sha512-UYsEXWUgGGCJ1CCVrlRyrksr4arMB1esOkdiekWro2nOdcQZYGY3+svSgKPQqFYmKZBBGIfrdS3NiCNMs7djKA==}
engines: {node: '>=18'}
@ -2911,15 +2839,6 @@ packages:
typescript:
optional: true
'@tmagic/form-schema@1.8.0-beta.26':
resolution: {integrity: sha512-EZBosF2PUEY+IJD8XlSsuorrZUxLtvjf6UY56/FQ5bxV1RutgfzdSLOepmu1Xa1RKr7EJlfqodD1xfZm+U44tA==}
engines: {node: '>=18'}
peerDependencies:
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/form@1.7.2':
resolution: {integrity: sha512-y44f5vrfWZhncd+D9NDsNMimIZZ8mvBlzCgIV1jzzmzIi3EMAuiN0URRuF9QZdyD3JXy2ADm3J2nsshagMYVVQ==}
engines: {node: '>=18'}
@ -2933,36 +2852,6 @@ packages:
typescript:
optional: true
'@tmagic/form@1.8.0-beta.26':
resolution: {integrity: sha512-IW3fmNJTpRW72BkYMsWITP+gOdxj82Zxw6ydbprnZRsrSGK/eGnfSTWTtg6i+9NI7VGYmSJKbBhkFAgihBEwpA==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/design': 1.8.0-beta.26
'@tmagic/form-schema': 1.8.0-beta.26
'@tmagic/utils': 1.8.0-beta.26
typescript: ^6.0.3
vue: ^3.5.41
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/react-runtime-help@0.2.2':
resolution: {integrity: sha512-Ob4lKgEpehcJ678JciJOT+aMW+91PNkB2Zb1Qp425PS4Y6K6TdEnrGtvqO0Z9IxcXwlCAWT9CK205ZxpTNlwvg==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/core': '>=1.7.0'
'@tmagic/stage': '>=1.7.0'
lodash-es: ^4.17.21
react: '>=18.3.1'
typescript: ^5.9.3
peerDependenciesMeta:
'@tmagic/core':
optional: true
'@tmagic/stage':
optional: true
typescript:
optional: true
'@tmagic/schema@1.7.0':
resolution: {integrity: sha512-YHFEv/BBKFHJ8qeY6lSlAWwcn1PjHKx16OfevttXN9uis0Bybw4UThjl6BgMERnH2mFOp0zPX0y1iqE2EjAiNg==}
engines: {node: '>=18'}
@ -2981,15 +2870,6 @@ packages:
typescript:
optional: true
'@tmagic/schema@1.8.0-beta.26':
resolution: {integrity: sha512-28TNx0GEU5iAWmJidcNrUlvoYfOEqljbJYWVvUiReIRFYbuLM2T/6RrgEzzqJz/6dXYnxstuu9tQamBmzt7lhQ==}
engines: {node: '>=18'}
peerDependencies:
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/stage@1.7.0':
resolution: {integrity: sha512-JU8WxqKjyiLbE0FccYY6yrfKlxTZ2bumoZHJBueVIV67W0FnrxLi32C6k6/qIYV/8w/Iy1qiuupLp3TTjSB67g==}
engines: {node: '>=18'}
@ -3010,16 +2890,6 @@ packages:
typescript:
optional: true
'@tmagic/stage@1.8.0-beta.26':
resolution: {integrity: sha512-Nop2OrUnwWUZDigPmxtWN2YzQiX8g6GIDTjP4Q/OARr1a0oDjxAd0SIyZm52XJ9jQTV4oHwT+0/xzmhZV8/aug==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/core': 1.8.0-beta.26
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/table@1.7.2':
resolution: {integrity: sha512-TL13j92ChY0SUbknRhq43n5N6YBbT7wboEYS8k5e4BI9r3flG1qq+m03e3kVMo6Xu5UKuURM+pbhr7Wl8krJdg==}
engines: {node: '>=18'}
@ -3032,31 +2902,6 @@ packages:
typescript:
optional: true
'@tmagic/table@1.8.0-beta.26':
resolution: {integrity: sha512-3troqYXuh6z6TYvMUGgC6qX2m+vvnMQ5mtQIeLjhRr7Jd/0A30DQbSyuXmngoxv0hvIK2sGoVLsQZ5a/C3/RuA==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/design': 1.8.0-beta.26
'@tmagic/form': 1.8.0-beta.26
'@tmagic/utils': 1.8.0-beta.26
typescript: ^6.0.3
vue: ^3.5.41
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/tdesign-vue-next-adapter@1.8.0-beta.26':
resolution: {integrity: sha512-490AHGyrKkB/tnbWzEGZvReKrfMSjQyRAR6gscRQJXRxS/UvzIUM1svzuVUeV/kr6LEK3cbe5v+66vhzC1K48w==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/design': 1.8.0-beta.26
tdesign-vue-next: ^1.17.1
typescript: ^6.0.3
vue: ^3.5.41
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/tmagic-form-runtime@1.1.6':
resolution: {integrity: sha512-iRvsrNxrY2KsUh5qiDo+v0bvNzfk41+viQGvuTCdF3lbD4J1ylZ/PNbjAnkFWLMx7Rc6UHcbvRfyx6r22TYHiQ==}
engines: {node: '>=18'}
@ -3092,32 +2937,6 @@ packages:
typescript:
optional: true
'@tmagic/utils@1.8.0-beta.26':
resolution: {integrity: sha512-AcYC/a8OoTLRGYGSMX4LbbUJE23/bQqkkQThCvv9+xpyi24GmUqENeZzA2FlcaV8iMIO+h6Om1WeUq2Hrm970g==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/schema': 1.8.0-beta.26
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/vue-runtime-help@2.0.4':
resolution: {integrity: sha512-7fZ0IIVicepgUkjq+1eiDGdfPA/IS0NX7/xGoBa+QEINt/nGEOTMvYcIAeYd4UoxYWF0Wo9Qe4acAj6lESUr2g==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/core': '>=1.7.0'
'@tmagic/stage': '>=1.7.0'
typescript: ^6.0.3
vue: ^3.5.41
peerDependenciesMeta:
'@tmagic/core':
optional: true
'@tmagic/stage':
optional: true
typescript:
optional: true
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -8616,16 +8435,6 @@ snapshots:
'@sxzz/popperjs-es@2.11.8': {}
'@tmagic/cli@1.8.0-beta.26':
dependencies:
cac: 7.0.0
chokidar: 5.0.0
fs-extra: 11.4.0
merge-options: 3.0.4
picocolors: 1.1.1
recast: 0.23.20
typescript: 6.0.3
'@tmagic/core@1.7.0(typescript@6.0.3)':
dependencies:
'@tmagic/data-source': 1.7.0(@tmagic/core@1.7.0(typescript@6.0.3))(typescript@6.0.3)
@ -8648,17 +8457,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/core@1.8.0-beta.26(typescript@6.0.3)':
dependencies:
'@tmagic/data-source': 1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/dep': 1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/schema': 1.8.0-beta.26(typescript@6.0.3)
'@tmagic/utils': 1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
events: 3.3.0
lodash-es: 4.18.1
optionalDependencies:
typescript: 6.0.3
'@tmagic/data-source@1.7.0(@tmagic/core@1.7.0(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@tmagic/core': 1.7.0(typescript@6.0.3)
@ -8677,15 +8475,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/data-source@1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@tmagic/core': 1.8.0-beta.26(typescript@6.0.3)
deep-state-observer: 5.5.17
events: 3.3.0
lodash-es: 4.18.1
optionalDependencies:
typescript: 6.0.3
'@tmagic/dep@1.7.0(@tmagic/schema@1.7.0(typescript@6.0.3))(@tmagic/utils@1.7.0(@tmagic/schema@1.7.0(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@tmagic/schema': 1.7.0(typescript@6.0.3)
@ -8700,13 +8489,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/dep@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@tmagic/schema': 1.8.0-beta.26(typescript@6.0.3)
'@tmagic/utils': 1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/design@1.7.2(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@popperjs/core': 2.11.8
@ -8714,13 +8496,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@popperjs/core': 2.11.8
vue: 3.5.41(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/editor@1.7.2(@tmagic/core@1.7.2(typescript@6.0.3))(@tmagic/form-schema@1.7.2(typescript@6.0.3))(@tmagic/schema@1.7.2(typescript@6.0.3))(monaco-editor@0.48.0)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.41(typescript@6.0.3))
@ -8748,54 +8523,12 @@ snapshots:
- '@tmagic/form-schema'
- '@tmagic/schema'
'@tmagic/editor@1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(monaco-editor@0.56.0)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.41(typescript@6.0.3))
'@tmagic/core': 1.8.0-beta.26(typescript@6.0.3)
'@tmagic/design': 1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
'@tmagic/form': 1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
'@tmagic/stage': 1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/table': 1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form@1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
'@tmagic/utils': 1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
buffer: 6.0.3
deep-object-diff: 1.1.9
emmet-monaco-es: 5.7.0(monaco-editor@0.56.0)
events: 3.3.0
gesto: 1.19.4
keycon: 1.4.0
lodash-es: 4.18.1
monaco-editor: 0.56.0
moveable: 0.53.0
serialize-javascript: 7.1.0
sortablejs: 1.15.7
vue: 3.5.41(typescript@6.0.3)
optionalDependencies:
type-fest: 5.2.0
typescript: 6.0.3
transitivePeerDependencies:
- '@tmagic/form-schema'
- '@tmagic/schema'
'@tmagic/element-plus-adapter@1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(element-plus@2.14.4(vue@3.5.41(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@tmagic/design': 1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
element-plus: 2.14.4(vue@3.5.41(typescript@6.0.3))
vue: 3.5.41(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/form-schema@1.7.2(typescript@6.0.3)':
dependencies:
'@tmagic/schema': 1.7.2(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3)':
dependencies:
'@tmagic/schema': 1.8.0-beta.26(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/form@1.7.2(@tmagic/design@1.7.2(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form-schema@1.7.2(typescript@6.0.3))(@tmagic/utils@1.7.2(@tmagic/schema@1.7.2(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.41(typescript@6.0.3))
@ -8810,30 +8543,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/form@1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.41(typescript@6.0.3))
'@popperjs/core': 2.11.8
'@tmagic/design': 1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
'@tmagic/form-schema': 1.8.0-beta.26(typescript@6.0.3)
'@tmagic/utils': 1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
async-validator: 4.2.5
dayjs: 1.11.21
lodash-es: 4.18.1
sortablejs: 1.15.7
vue: 3.5.41(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/react-runtime-help@0.2.2(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.18.1)(react@19.2.8)(typescript@6.0.3)':
dependencies:
lodash-es: 4.18.1
react: 19.2.8
optionalDependencies:
'@tmagic/core': 1.8.0-beta.26(typescript@6.0.3)
'@tmagic/stage': 1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
typescript: 6.0.3
'@tmagic/schema@1.7.0(typescript@6.0.3)':
optionalDependencies:
typescript: 6.0.3
@ -8842,10 +8551,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/schema@1.8.0-beta.26(typescript@6.0.3)':
optionalDependencies:
typescript: 6.0.3
'@tmagic/stage@1.7.0(@tmagic/core@1.7.0(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@scena/guides': 0.29.2
@ -8872,20 +8577,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/stage@1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@scena/guides': 0.29.2
'@tmagic/core': 1.8.0-beta.26(typescript@6.0.3)
'@zumer/snapdom': 2.23.2
events: 3.3.0
keycon: 1.4.0
lodash-es: 4.18.1
moveable: 0.53.0
moveable-helper: 0.4.0(scenejs@1.10.3)
scenejs: 1.10.3
optionalDependencies:
typescript: 6.0.3
'@tmagic/table@1.7.2(@tmagic/design@1.7.2(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form@1.7.2(@tmagic/design@1.7.2(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form-schema@1.7.2(typescript@6.0.3))(@tmagic/utils@1.7.2(@tmagic/schema@1.7.2(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@tmagic/design': 1.7.2(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
@ -8895,32 +8586,13 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/table@1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form@1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
'@tmagic/tmagic-form-runtime@1.1.6(@tmagic/core@packages+core)(@tmagic/editor@packages+editor)(element-plus@2.14.4(vue@3.5.41(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.41(typescript@6.0.3))
'@tmagic/design': 1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
'@tmagic/form': 1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
'@tmagic/utils': 1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
lodash-es: 4.18.1
vue: 3.5.41(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/tdesign-vue-next-adapter@1.8.0-beta.26(@tmagic/design@1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(tdesign-vue-next@1.20.5(vue@3.5.41(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@tmagic/design': 1.8.0-beta.26(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
tdesign-vue-next: 1.20.5(vue@3.5.41(typescript@6.0.3))
vue: 3.5.41(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/tmagic-form-runtime@1.1.6(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(monaco-editor@0.56.0)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(element-plus@2.14.4(vue@3.5.41(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
'@tmagic/editor': 1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.26(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(monaco-editor@0.56.0)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
'@tmagic/editor': link:packages/editor
element-plus: 2.14.4(vue@3.5.41(typescript@6.0.3))
vue: 3.5.41(typescript@6.0.3)
optionalDependencies:
'@tmagic/core': 1.8.0-beta.26(typescript@6.0.3)
'@tmagic/core': link:packages/core
typescript: 6.0.3
'@tmagic/utils@1.7.0(@tmagic/schema@1.7.0(typescript@6.0.3))(typescript@6.0.3)':
@ -8937,21 +8609,6 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/utils@1.8.0-beta.26(@tmagic/schema@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@tmagic/schema': 1.8.0-beta.26(typescript@6.0.3)
lodash-es: 4.18.1
optionalDependencies:
typescript: 6.0.3
'@tmagic/vue-runtime-help@2.0.4(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))':
dependencies:
vue: 3.5.41(typescript@6.0.3)
optionalDependencies:
'@tmagic/core': 1.8.0-beta.26(typescript@6.0.3)
'@tmagic/stage': 1.8.0-beta.26(@tmagic/core@1.8.0-beta.26(typescript@6.0.3))(typescript@6.0.3)
typescript: 6.0.3
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -10383,11 +10040,6 @@ snapshots:
emmet: 2.4.11
monaco-editor: 0.55.1
emmet-monaco-es@5.7.0(monaco-editor@0.56.0):
dependencies:
emmet: 2.4.11
monaco-editor: 0.56.0
emmet@2.4.11:
dependencies:
'@emmetio/abbreviation': 2.3.3

View File

@ -6,6 +6,10 @@ packages:
- "react-components/*"
- "eslint-config"
# Link matching workspace packages so release lockfile updates do not
# fetch a just-bumped @tmagic/* version from the registry.
linkWorkspacePackages: true
minimumReleaseAge: 0
allowBuilds:

View File

@ -1,6 +1,6 @@
{
"name": "runtime-react",
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"type": "module",
"private": true,
"engines": {
@ -16,16 +16,16 @@
"build:playground": "node scripts/build.mjs --type=playground"
},
"dependencies": {
"@tmagic/core": "1.8.0-beta.26",
"@tmagic/core": "1.8.0-beta.28",
"@tmagic/react-runtime-help": "0.2.2",
"@tmagic/stage": "1.8.0-beta.26",
"@tmagic/stage": "1.8.0-beta.28",
"axios": "^1.19.0",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tmagic/cli": "1.8.0-beta.26",
"@tmagic/cli": "1.8.0-beta.28",
"@types/fs-extra": "^11.0.4",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import React, { useContext } from 'react';
import React, { useContext, useEffect } from 'react';
import type TMagicApp from '@tmagic/core';
import { AppContent, useDsl } from '@tmagic/react-runtime-help';
@ -26,6 +26,10 @@ function App() {
const { pageConfig } = useDsl(app);
useEffect(() => {
app?.dataSourceManager?.emit('mounted');
}, [app]);
const MagicUiPage = app?.resolveComponent('page');
return <MagicUiPage config={pageConfig}></MagicUiPage>;

View File

@ -35,4 +35,29 @@ import {
} from '@tmagic/tmagic-form-runtime';
const { render } = useRuntime();
```
```
## 表单配置需要发请求时
`@tmagic/form``request` 是模块级配置,`setConfig` 会整对象替换。请在宿主 Vue
组件的 setup 中调用 `useRuntime()`:此时会读取宿主 `$MAGIC_FORM`,与 `formOptions`
浅合并后再安装 formPlugin默认不会清空宿主已有的 `request` / `flat` 等。
若宿主未配 `request`,或画布侧需要覆盖,通过 `formOptions` 传入。`request` 的语义与
宿主 `editorPlugin` 一致:入参为 `{ method, url, data, ... }`**resolve 出接口 body**
(不要返回整个 Response / axios response
```ts
const { render } = useRuntime({
formOptions: {
request: async ({ url, method = 'GET', data }) => {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'GET' ? undefined : JSON.stringify(data),
});
return response.json();
},
},
});
```

View File

@ -1,5 +1,5 @@
{
"version": "1.1.6",
"version": "1.1.8",
"name": "@tmagic/tmagic-form-runtime",
"type": "module",
"main": "dist/tmagic-tmagic-form.umd.cjs",

View File

@ -1,12 +1,13 @@
import { createApp, onBeforeUnmount, Plugin } from 'vue';
import { createApp, getCurrentInstance, onBeforeUnmount, Plugin } from 'vue';
import cssStyle from 'element-plus/dist/index.css?raw';
import type { FormConfig, StageCore } from '@tmagic/editor';
import type { FormConfig, FormInstallOptions, StageCore } from '@tmagic/editor';
import { editorService, formPlugin, injectStyle, Layout, propsService, uiService } from '@tmagic/editor';
import commonConfig from './form-config/common';
import App from './App.vue';
import formConfigs from './form-config';
import { mergeFormInstallOptions } from './mergeFormOptions';
export * from './component-group-list';
@ -15,10 +16,22 @@ export const propsConfigs = formConfigs;
export const useRuntime = ({
plugins = [],
fillConfig = (config) => config,
formOptions = {},
}: {
plugins?: Plugin[];
fillConfig?: (config: FormConfig, mForm: any) => FormConfig;
/**
* `@tmagic/form` `request`
*
* formPlugin `setConfig` `useRuntime` 宿
* `$MAGIC_FORM` `formOptions`
* 宿 `request` / `flat`
*/
formOptions?: FormInstallOptions;
} = {}) => {
const hostFormOptions = getCurrentInstance()?.appContext.config.globalProperties.$MAGIC_FORM as
FormInstallOptions | undefined;
const render = (stage: StageCore) => {
const doc = stage.renderer?.getDocument();
@ -48,7 +61,7 @@ export const useRuntime = ({
stage,
fillConfig,
});
vueApp.use(formPlugin);
vueApp.use(formPlugin, mergeFormInstallOptions(hostFormOptions, formOptions));
plugins.forEach((plugin) => vueApp.use(plugin));
vueApp.mount(el);

View File

@ -0,0 +1,10 @@
import type { FormInstallOptions } from '@tmagic/editor';
/**
* formPlugin.install setConfig
* 宿 `$MAGIC_FORM` `formOptions` request / flat
*/
export const mergeFormInstallOptions = (
host: FormInstallOptions | undefined,
formOptions: FormInstallOptions = {},
): FormInstallOptions => ({ ...(host ?? {}), ...formOptions });

View File

@ -0,0 +1,35 @@
/**
* formPlugin.install setConfig
* mergeFormInstallOptions 宿 $MAGIC_FORM formOptions
*/
import { describe, expect, it, vi } from 'vitest';
import { mergeFormInstallOptions } from '../src/mergeFormOptions';
describe('mergeFormInstallOptions', () => {
const hostRequest = vi.fn();
const hostFields = { select: { component: { name: 'HostSelect' } } };
const host = { request: hostRequest, flat: true, fields: hostFields };
it('无 formOptions 时原样带回宿主配置,避免二次安装清空', () => {
expect(mergeFormInstallOptions(host)).toEqual(host);
expect(mergeFormInstallOptions(host, {})).toEqual(host);
});
it('formOptions 只覆盖传入的键,保留宿主其余配置', () => {
const request = vi.fn();
expect(mergeFormInstallOptions(host, { request })).toEqual({
request,
flat: true,
fields: hostFields,
});
});
it('没有宿主配置时只使用 formOptions', () => {
const request = vi.fn();
expect(mergeFormInstallOptions(undefined, { request })).toEqual({ request });
expect(mergeFormInstallOptions(undefined)).toEqual({});
});
});

View File

@ -0,0 +1,78 @@
/**
* `useRuntime` 宿 `$MAGIC_FORM` formPlugin
*/
import { describe, expect, it, vi } from 'vitest';
import { useRuntime } from '../src/index';
const { formPluginMock, createAppMock, appUseMock, getCurrentInstanceMock } = vi.hoisted(() => {
const appUseMock = vi.fn();
return {
formPluginMock: { install: vi.fn() },
createAppMock: vi.fn(() => ({ use: appUseMock, mount: vi.fn() })),
appUseMock,
getCurrentInstanceMock: vi.fn(),
};
});
vi.mock('vue', async (importOriginal) => ({
...(await importOriginal<typeof import('vue')>()),
createApp: createAppMock,
onBeforeUnmount: vi.fn(),
getCurrentInstance: getCurrentInstanceMock,
}));
vi.mock('@tmagic/editor', () => ({
formPlugin: formPluginMock,
createForm: (config: unknown) => config,
editorService: { usePlugin: vi.fn(), removeAllPlugins: vi.fn() },
propsService: { usePlugin: vi.fn(), removeAllPlugins: vi.fn() },
uiService: { set: vi.fn(), get: vi.fn() },
injectStyle: vi.fn(),
Layout: { RELATIVE: 'relative' },
}));
vi.mock('../src/App.vue', () => ({ default: { name: 'App' } }));
const stage = { renderer: { getDocument: () => undefined } } as any;
const hostRequest = vi.fn();
const hostFormOptions = { request: hostRequest, flat: true, extra: 'keep' };
const withHostForm = () => {
getCurrentInstanceMock.mockReturnValue({
appContext: { config: { globalProperties: { $MAGIC_FORM: hostFormOptions } } },
});
};
describe('useRuntime — formOptions', () => {
it('setup 中能读到宿主 $MAGIC_FORM 时,默认安装不会清空 request / flat', () => {
withHostForm();
useRuntime().render(stage);
expect(appUseMock).toHaveBeenCalledWith(formPluginMock, hostFormOptions);
});
it('formOptions 覆盖 request保留宿主 flat 等其余键', () => {
withHostForm();
const request = vi.fn();
useRuntime({ formOptions: { request } }).render(stage);
expect(appUseMock).toHaveBeenCalledWith(formPluginMock, {
request,
flat: true,
extra: 'keep',
});
});
it('不在 setup 中调用时,只透传 formOptions', () => {
getCurrentInstanceMock.mockReturnValue(null);
const request = vi.fn();
useRuntime({ formOptions: { request } }).render(stage);
expect(appUseMock).toHaveBeenCalledWith(formPluginMock, { request });
});
});

View File

@ -1,6 +1,6 @@
{
"name": "runtime-vue",
"version": "1.8.0-beta.26",
"version": "1.8.0-beta.28",
"type": "module",
"private": true,
"engines": {
@ -16,14 +16,14 @@
"build:playground": "node scripts/build.mjs --type=playground"
},
"dependencies": {
"@tmagic/core": "1.8.0-beta.26",
"@tmagic/stage": "1.8.0-beta.26",
"@tmagic/core": "1.8.0-beta.28",
"@tmagic/stage": "1.8.0-beta.28",
"@tmagic/vue-runtime-help": "^2.0.4",
"axios": "^1.19.0",
"vue": "catalog:"
},
"devDependencies": {
"@tmagic/cli": "1.8.0-beta.26",
"@tmagic/cli": "1.8.0-beta.28",
"@types/fs-extra": "^11.0.4",
"@types/node": "^26.1.2",
"@vitejs/plugin-legacy": "^8.2.2",

View File

@ -3,7 +3,7 @@
</template>
<script lang="ts" setup>
import { reactive } from 'vue';
import { onMounted, reactive } from 'vue';
import type { Id, MPage } from '@tmagic/core';
import { cloneDeep, DevtoolApi, getNodeInfo, replaceChildNode, setValueByKeyPath } from '@tmagic/core';
@ -12,6 +12,10 @@ import { useComponent, useDsl } from '@tmagic/vue-runtime-help';
const { pageConfig, app } = useDsl();
const pageComponent = useComponent('page');
onMounted(() => {
app.dataSourceManager?.emit('mounted');
});
if (import.meta.env.DEV) {
app.devtools = new (class extends DevtoolApi {
public updateDsl(nodeId: Id, data: any, path: string) {

View File

@ -165,6 +165,11 @@ async function main() {
updateVersions(targetVersion);
versionUpdated = true;
// Refresh the lockfile after version bumps. Matching @tmagic/* pins are
// linked locally via linkWorkspacePackages (see pnpm-workspace.yaml).
step('\nUpdating lockfile...');
await run('pnpm', ['install', '--prefer-offline']);
// build all packages with types
step('\nBuilding all packages...');
if (!skipBuild && !isDryRun) {
@ -222,21 +227,6 @@ async function main() {
await publishPackage(pkg, targetVersion, additionalPublishFlags);
}
// update pnpm-lock.yaml
step('\nUpdating lockfile...');
await run('pnpm', ['install', '--prefer-offline']);
if (!skipGit) {
const { stdout } = await run('git', ['diff'], { stdio: 'pipe' });
if (stdout) {
step('\nCommitting changes...');
await runIfNotDry('git', ['add', '-A']);
await runIfNotDry('git', ['commit', '-m', `chore: update lockfile v${targetVersion}`, '--verify']);
} else {
console.log('No changes to commit.');
}
}
// push to GitHub
if (!skipGit) {
step('\nPushing to GitHub...');