mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-14 16:09:08 +00:00
Compare commits
13 Commits
v1.8.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39ef49db7d | ||
|
|
5a3edacb96 | ||
|
|
911cecfb1d | ||
|
|
2a4a54a495 | ||
|
|
7e4546899b | ||
|
|
66b54a52f8 | ||
|
|
7edb01d330 | ||
|
|
5fa9b37856 | ||
|
|
d1cb8c3853 | ||
|
|
092bc2914d | ||
|
|
959c0322b6 | ||
|
|
b0d43dc277 | ||
|
|
ad2f4e15cc |
24
CHANGELOG.md
24
CHANGELOG.md
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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[] }>`
|
||||
|
||||
@ -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}
|
||||
:::
|
||||
|
||||
@ -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="[{
|
||||
|
||||
@ -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
|
||||
|
||||
- **返回:**
|
||||
|
||||
@ -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)` |
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "tmagic",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/core",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/data-source",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/dep",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/design",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/editor",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'),
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@ -204,10 +204,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
|
||||
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
||||
* 用户取消时返回 false,调用方据此中止回滚。
|
||||
*/
|
||||
const confirmRevert = (): Promise<boolean> =>
|
||||
confirmHistoryAction(
|
||||
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
|
||||
);
|
||||
const confirmRevert = (): Promise<boolean> => confirmHistoryAction('确定回滚该步骤吗?');
|
||||
|
||||
/**
|
||||
* 「回滚」统一确认入口:可差异对比的步骤动态挂载 HistoryDiffDialog 走差异确认弹窗,
|
||||
|
||||
@ -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 的默认实现是 eval;HTML 拖放又允许其他源的页面在 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>
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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('新增动作');
|
||||
});
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
128
packages/editor/tests/unit/hooks/use-form-context.spec.ts
Normal file
128
packages/editor/tests/unit/hooks/use-form-context.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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 不是 reactive,stage 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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/form-schema",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/form",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -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]));
|
||||
|
||||
/** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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可以实现 */
|
||||
|
||||
@ -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 与宿主业务上下文关联:读取时优先 core,miss 再读穿到 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;
|
||||
};
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
311
packages/form/tests/unit/fields/Select.remote.spec.ts
Normal file
311
packages/form/tests/unit/fields/Select.remote.spec.ts
Normal 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 与 total(total=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']);
|
||||
});
|
||||
});
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/schema",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/stage",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/table",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.26",
|
||||
"version": "1.8.0-beta.28",
|
||||
"name": "@tmagic/utils",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -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
404
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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();
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
10
runtime/tmagic-form/src/mergeFormOptions.ts
Normal file
10
runtime/tmagic-form/src/mergeFormOptions.ts
Normal 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 });
|
||||
35
runtime/tmagic-form/tests/merge-form-options.test.ts
Normal file
35
runtime/tmagic-form/tests/merge-form-options.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
78
runtime/tmagic-form/tests/use-runtime.test.ts
Normal file
78
runtime/tmagic-form/tests/use-runtime.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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...');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user