mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 04:08:04 +00:00
feat(editor): 数据源与代码块 service 支持 undo/redo
- dataSourceService / codeBlockService 新增 undo / redo / canUndo / canRedo 方法 - undo/redo 内部复用 add / update / remove / setCodeDslByIdSync / deleteCodeDslByIds 写回, 并强制 doNotPushHistory,借此自动驱动 initService 中的依赖收集链路 (DepTargetType.DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD / CODE_BLOCK) - 更新场景下若 step 带 changeRecords,按 propPath 局部 patch,不冲掉同节点其它无关变更; 缺省退化为整 schema / 整内容替换 - 补充对应单测与 API 文档
This commit is contained in:
parent
09558fa027
commit
8dae67769c
@ -226,6 +226,72 @@
|
||||
`newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
|
||||
:::
|
||||
|
||||
## undo
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 代码块id
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<CodeBlockStepValue | null>}` 撤销的 step;栈不存在或已无可撤销时返回 `null`
|
||||
|
||||
- **详情:**
|
||||
|
||||
撤销指定代码块的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后,
|
||||
复用 [setCodeDslByIdSync](#setcodedslbyidsync) / [deleteCodeDslByIds](#deletecodedslbyids) 写回,
|
||||
并自动带上 `doNotPushHistory: true`,确保不会再次入栈。
|
||||
|
||||
写回会触发对应的 `addOrUpdate` / `remove` 事件,编辑器内部据此重新维护
|
||||
`DepTargetType.CODE_BLOCK` 的 dep target,无需调用方额外处理。
|
||||
|
||||
对于带有 `changeRecords` 的更新 step,会按 `propPath` 局部 patch 当前代码块内容;缺省才退化为整内容替换,
|
||||
避免冲掉同代码块上的其它无关变更。
|
||||
|
||||
- **示例:**
|
||||
|
||||
```js
|
||||
import { codeBlockService } from "@tmagic/editor";
|
||||
|
||||
if (codeBlockService.canUndo("code_1234")) {
|
||||
await codeBlockService.undo("code_1234");
|
||||
}
|
||||
```
|
||||
|
||||
## redo
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 代码块id
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<CodeBlockStepValue | null>}` 重做的 step;栈不存在或已无可重做时返回 `null`
|
||||
|
||||
- **详情:**
|
||||
|
||||
重做指定代码块的下一次变更。其它行为同 [undo](#undo)。
|
||||
|
||||
## canUndo
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 代码块id
|
||||
|
||||
- **返回:**
|
||||
- `{boolean}`
|
||||
|
||||
- **详情:**
|
||||
|
||||
当前指定代码块是否可撤销,等价于 `historyService.canUndoCodeBlock(id)`。
|
||||
|
||||
## canRedo
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 代码块id
|
||||
|
||||
- **返回:**
|
||||
- `{boolean}`
|
||||
|
||||
- **详情:**
|
||||
|
||||
当前指定代码块是否可重做,等价于 `historyService.canRedoCodeBlock(id)`。
|
||||
|
||||
## setParamsColConfig
|
||||
|
||||
- **参数:**
|
||||
|
||||
@ -443,6 +443,72 @@ const ds = dataSourceService.getDataSourceById("ds_123");
|
||||
console.log(ds);
|
||||
```
|
||||
|
||||
## undo
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 数据源id
|
||||
|
||||
- **返回:**
|
||||
- {`DataSourceStepValue` | null} 撤销的 step;栈不存在或已无可撤销时返回 `null`
|
||||
|
||||
- **详情:**
|
||||
|
||||
撤销指定数据源的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后,
|
||||
复用 [add](#add) / [update](#update) / [remove](#remove) 写回,并自动带上 `doNotPushHistory: true`,
|
||||
确保不会再次入栈。
|
||||
|
||||
写回会触发对应的 `add` / `update` / `remove` 事件,编辑器内部据此重新维护数据源相关的依赖收集
|
||||
(`DepTargetType.DATA_SOURCE` / `DATA_SOURCE_COND` / `DATA_SOURCE_METHOD`),无需调用方额外处理。
|
||||
|
||||
对于带有 `changeRecords` 的更新 step,会按 `propPath` 局部 patch 当前数据源;缺省才退化为整 schema 替换,
|
||||
避免冲掉同节点上的其它无关变更。
|
||||
|
||||
- **示例:**
|
||||
|
||||
```js
|
||||
import { dataSourceService } from "@tmagic/editor";
|
||||
|
||||
if (dataSourceService.canUndo("ds_123")) {
|
||||
dataSourceService.undo("ds_123");
|
||||
}
|
||||
```
|
||||
|
||||
## redo
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 数据源id
|
||||
|
||||
- **返回:**
|
||||
- {`DataSourceStepValue` | null} 重做的 step;栈不存在或已无可重做时返回 `null`
|
||||
|
||||
- **详情:**
|
||||
|
||||
重做指定数据源的下一次变更。其它行为同 [undo](#undo)。
|
||||
|
||||
## canUndo
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 数据源id
|
||||
|
||||
- **返回:**
|
||||
- `{boolean}`
|
||||
|
||||
- **详情:**
|
||||
|
||||
当前指定数据源是否可撤销,等价于 `historyService.canUndoDataSource(id)`。
|
||||
|
||||
## canRedo
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 数据源id
|
||||
|
||||
- **返回:**
|
||||
- `{boolean}`
|
||||
|
||||
- **详情:**
|
||||
|
||||
当前指定数据源是否可重做,等价于 `historyService.canRedoDataSource(id)`。
|
||||
|
||||
## copyWithRelated
|
||||
|
||||
- **参数:**
|
||||
|
||||
@ -23,11 +23,12 @@ import type { Writable } from 'type-fest';
|
||||
import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core';
|
||||
import { Target, Watcher } from '@tmagic/core';
|
||||
import type { ChangeRecord, TableColumnConfig } from '@tmagic/form';
|
||||
import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
|
||||
|
||||
import editorService from '@editor/services/editor';
|
||||
import historyService from '@editor/services/history';
|
||||
import storageService, { Protocol } from '@editor/services/storage';
|
||||
import type { AsyncHookPlugin, CodeState } from '@editor/type';
|
||||
import type { AsyncHookPlugin, CodeBlockStepValue, CodeState } from '@editor/type';
|
||||
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
||||
import { getEditorConfig } from '@editor/utils/config';
|
||||
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
||||
@ -276,6 +277,46 @@ class CodeBlock extends BaseService {
|
||||
return this.state.paramsColConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销指定代码块的最近一次变更。
|
||||
*
|
||||
* 内部走 setCodeDslByIdSync / deleteCodeDslByIds,因此会自动触发 codeBlockService 的
|
||||
* `addOrUpdate` / `remove` 事件,由 initService 中的 handler 重新维护 dep target
|
||||
* (DepTargetType.CODE_BLOCK 的 add / remove)。所有写回都带 `doNotPushHistory: true`,
|
||||
* 确保不会在历史栈里产生新的记录。
|
||||
*
|
||||
* @param id 代码块 id
|
||||
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||||
*/
|
||||
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
|
||||
const step = historyService.undoCodeBlock(id);
|
||||
if (!step) return null;
|
||||
await this.applyHistoryStep(step, true);
|
||||
return step;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做指定代码块的下一次变更。
|
||||
* @param id 代码块 id
|
||||
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||||
*/
|
||||
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
|
||||
const step = historyService.redoCodeBlock(id);
|
||||
if (!step) return null;
|
||||
await this.applyHistoryStep(step, false);
|
||||
return step;
|
||||
}
|
||||
|
||||
/** 是否可对指定代码块撤销。 */
|
||||
public canUndo(id: Id): boolean {
|
||||
return historyService.canUndoCodeBlock(id);
|
||||
}
|
||||
|
||||
/** 是否可对指定代码块重做。 */
|
||||
public canRedo(id: Id): boolean {
|
||||
return historyService.canRedoCodeBlock(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成代码块唯一id
|
||||
* @returns {Id} 代码块唯一id
|
||||
@ -357,6 +398,70 @@ class CodeBlock extends BaseService {
|
||||
public usePlugin(options: AsyncHookPlugin<AsyncMethodName, CodeBlock>): void {
|
||||
super.usePlugin(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条历史 step 应用到当前代码块服务上。
|
||||
*
|
||||
* 复用现有的 setCodeDslByIdSync / deleteCodeDslByIds,目的是借助它们发出的事件
|
||||
* 触发 initService 中的 dep target 维护(CODE_BLOCK 的 add / remove)。
|
||||
* 所有写回都带 `doNotPushHistory: true`,确保不会在历史栈里产生新的记录。
|
||||
*
|
||||
* - oldContent=null, newContent≠null:原始为新增 → undo 删除;redo 再次 setCodeDslByIdSync
|
||||
* - oldContent≠null, newContent=null:原始为删除 → undo 还原写入;redo 再次删除
|
||||
* - 两侧都有:原始为更新 → 按 changeRecords 局部 patch;缺省退化为整内容替换
|
||||
*
|
||||
* @param step 历史 step
|
||||
* @param reverse true=撤销,false=重做
|
||||
*/
|
||||
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
|
||||
const { id, oldContent, newContent, changeRecords } = step;
|
||||
|
||||
// 新增 / 删除:直接 set 或 delete,不走 patch 逻辑
|
||||
if (oldContent === null && newContent) {
|
||||
if (reverse) {
|
||||
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
|
||||
} else {
|
||||
this.setCodeDslByIdSync(id, cloneDeep(newContent), true, { doNotPushHistory: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldContent && newContent === null) {
|
||||
if (reverse) {
|
||||
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { doNotPushHistory: true });
|
||||
} else {
|
||||
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!oldContent || !newContent) return;
|
||||
|
||||
// 更新场景:优先按 changeRecords 局部 patch;缺省退化为整内容替换
|
||||
const sourceForValues = reverse ? oldContent : newContent;
|
||||
|
||||
if (changeRecords?.length) {
|
||||
const current = this.getCodeContentById(id);
|
||||
if (!current) return;
|
||||
const patched = cloneDeep(current) as CodeBlockContent;
|
||||
let fallbackToFullReplace = false;
|
||||
for (const record of changeRecords) {
|
||||
if (!record.propPath) {
|
||||
fallbackToFullReplace = true;
|
||||
break;
|
||||
}
|
||||
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
|
||||
setValueByKeyPath(record.propPath, value, patched);
|
||||
}
|
||||
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, true, {
|
||||
changeRecords,
|
||||
doNotPushHistory: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCodeDslByIdSync(id, cloneDeep(sourceForValues), true, { doNotPushHistory: true });
|
||||
}
|
||||
}
|
||||
|
||||
export type CodeBlockService = CodeBlock;
|
||||
|
||||
@ -5,12 +5,12 @@ import type { Writable } from 'type-fest';
|
||||
import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core';
|
||||
import { Target, Watcher } from '@tmagic/core';
|
||||
import type { ChangeRecord, FormConfig } from '@tmagic/form';
|
||||
import { guid, toLine } from '@tmagic/utils';
|
||||
import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
||||
|
||||
import editorService from '@editor/services/editor';
|
||||
import historyService from '@editor/services/history';
|
||||
import storageService, { Protocol } from '@editor/services/storage';
|
||||
import type { DatasourceTypeOption, SyncHookPlugin } from '@editor/type';
|
||||
import type { DataSourceStepValue, DatasourceTypeOption, SyncHookPlugin } from '@editor/type';
|
||||
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
|
||||
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
||||
|
||||
@ -184,6 +184,45 @@ class DataSource extends BaseService {
|
||||
this.emit('remove', id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销指定数据源的最近一次变更。
|
||||
*
|
||||
* 内部走 add / update / remove,因此会自动触发 dataSourceService 的事件,
|
||||
* 由 initService 中的对应 handler 重新收集 dep(DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD)。
|
||||
* 所有写回都带 `doNotPushHistory: true`,避免在历史栈里产生新的记录。
|
||||
*
|
||||
* @param id 数据源 id
|
||||
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||||
*/
|
||||
public undo(id: Id) {
|
||||
const step = historyService.undoDataSource(id);
|
||||
if (!step) return null;
|
||||
this.applyHistoryStep(step, true);
|
||||
return step;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做指定数据源的下一次变更。
|
||||
* @param id 数据源 id
|
||||
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||||
*/
|
||||
public redo(id: Id) {
|
||||
const step = historyService.redoDataSource(id);
|
||||
if (!step) return null;
|
||||
this.applyHistoryStep(step, false);
|
||||
return step;
|
||||
}
|
||||
|
||||
/** 是否可对指定数据源撤销。 */
|
||||
public canUndo(id: Id): boolean {
|
||||
return historyService.canUndoDataSource(id);
|
||||
}
|
||||
|
||||
/** 是否可对指定数据源重做。 */
|
||||
public canRedo(id: Id): boolean {
|
||||
return historyService.canRedoDataSource(id);
|
||||
}
|
||||
|
||||
public createId(): string {
|
||||
return `ds_${guid()}`;
|
||||
}
|
||||
@ -258,6 +297,70 @@ class DataSource extends BaseService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条历史 step 应用到当前数据源服务上。
|
||||
*
|
||||
* 复用现有的 add / update / remove,目的是借助它们发出的事件触发 initService 中
|
||||
* 的依赖收集逻辑(DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD)。
|
||||
* 所有写回都带 `doNotPushHistory: true`,确保不会在历史栈里产生新的记录。
|
||||
*
|
||||
* - oldSchema=null, newSchema≠null:原始为新增 → undo 删除;redo 再次 add
|
||||
* - oldSchema≠null, newSchema=null:原始为删除 → undo 还原 add;redo 再次删除
|
||||
* - 两侧都有:原始为更新 → 按 changeRecords 局部 patch;缺省退化为整 schema 替换
|
||||
*
|
||||
* @param step 历史 step
|
||||
* @param reverse true=撤销,false=重做
|
||||
*/
|
||||
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
|
||||
const { id, oldSchema, newSchema, changeRecords } = step;
|
||||
|
||||
// 新增 / 删除:直接 add 或 remove,不走 patch 逻辑
|
||||
if (oldSchema === null && newSchema) {
|
||||
if (reverse) {
|
||||
this.remove(`${id}`, { doNotPushHistory: true });
|
||||
} else {
|
||||
this.add(cloneDeep(newSchema), { doNotPushHistory: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldSchema && newSchema === null) {
|
||||
if (reverse) {
|
||||
this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
|
||||
} else {
|
||||
this.remove(`${id}`, { doNotPushHistory: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!oldSchema || !newSchema) return;
|
||||
|
||||
// 更新场景:优先按 changeRecords 局部 patch;缺省退化为整 schema 替换
|
||||
const sourceForValues = reverse ? oldSchema : newSchema;
|
||||
|
||||
if (changeRecords?.length) {
|
||||
const current = this.getDataSourceById(`${id}`);
|
||||
if (!current) return;
|
||||
const patched = cloneDeep(current) as DataSourceSchema;
|
||||
let fallbackToFullReplace = false;
|
||||
for (const record of changeRecords) {
|
||||
if (!record.propPath) {
|
||||
fallbackToFullReplace = true;
|
||||
break;
|
||||
}
|
||||
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
|
||||
setValueByKeyPath(record.propPath, value, patched);
|
||||
}
|
||||
this.update(fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, {
|
||||
changeRecords,
|
||||
doNotPushHistory: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.update(cloneDeep(sourceForValues), { doNotPushHistory: true });
|
||||
}
|
||||
}
|
||||
|
||||
export type DataSourceService = DataSource;
|
||||
|
||||
@ -230,3 +230,129 @@ describe('CodeBlockService - 历史记录接入', () => {
|
||||
expect(step?.changeRecords).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodeBlockService - undo / redo', () => {
|
||||
test('undo / redo - 新增场景:撤销=删除,重做=再写回', async () => {
|
||||
await codeBlockService.setCodeDsl({} as any);
|
||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A' } as any);
|
||||
|
||||
await codeBlockService.undo('a');
|
||||
expect(codeBlockService.getCodeContentById('a')).toBeNull();
|
||||
|
||||
await codeBlockService.redo('a');
|
||||
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
|
||||
});
|
||||
|
||||
test('undo / redo - 删除场景:撤销=还原内容,重做=再删除', async () => {
|
||||
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
||||
historyService.reset();
|
||||
|
||||
await codeBlockService.deleteCodeDslByIds(['a']);
|
||||
expect(codeBlockService.getCodeContentById('a')).toBeNull();
|
||||
|
||||
await codeBlockService.undo('a');
|
||||
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
|
||||
|
||||
await codeBlockService.redo('a');
|
||||
expect(codeBlockService.getCodeContentById('a')).toBeNull();
|
||||
});
|
||||
|
||||
test('undo / redo - 更新场景(无 changeRecords):整内容替换', async () => {
|
||||
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
||||
historyService.reset();
|
||||
|
||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
|
||||
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A2');
|
||||
|
||||
await codeBlockService.undo('a');
|
||||
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
|
||||
|
||||
await codeBlockService.redo('a');
|
||||
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A2');
|
||||
});
|
||||
|
||||
test('undo / redo - 更新场景(带 changeRecords):按 propPath 局部 patch,不冲掉同节点其它字段', async () => {
|
||||
await codeBlockService.setCodeDsl({
|
||||
a: { name: 'A', desc: 'origin' },
|
||||
} as any);
|
||||
historyService.reset();
|
||||
|
||||
// form 端仅改 name
|
||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A2', desc: 'origin' } as any, true, {
|
||||
changeRecords: [{ propPath: 'name', value: 'A2' }],
|
||||
});
|
||||
|
||||
// 中间外部同步改了 desc(不入历史)
|
||||
codeBlockService.setCodeDslByIdSync('a', { desc: 'changed-by-other' } as any, true, {
|
||||
doNotPushHistory: true,
|
||||
});
|
||||
|
||||
// undo 只回滚 name
|
||||
await codeBlockService.undo('a');
|
||||
const after = codeBlockService.getCodeContentById('a') as any;
|
||||
expect(after?.name).toBe('A');
|
||||
expect(after?.desc).toBe('changed-by-other');
|
||||
|
||||
// redo 只重做 name
|
||||
await codeBlockService.redo('a');
|
||||
const redo = codeBlockService.getCodeContentById('a') as any;
|
||||
expect(redo?.name).toBe('A2');
|
||||
expect(redo?.desc).toBe('changed-by-other');
|
||||
});
|
||||
|
||||
test('undo / redo - 通过 addOrUpdate / remove 事件触发,依赖收集链路保留', async () => {
|
||||
const addOrUpdateFn = vi.fn();
|
||||
const removeFn = vi.fn();
|
||||
codeBlockService.on('addOrUpdate', addOrUpdateFn);
|
||||
codeBlockService.on('remove', removeFn);
|
||||
|
||||
await codeBlockService.setCodeDsl({} as any);
|
||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A' } as any);
|
||||
addOrUpdateFn.mockClear();
|
||||
|
||||
// 撤销新增 → 触发 remove 事件,initService 中的 codeBlockRemoveHandler 会移除 dep target
|
||||
await codeBlockService.undo('a');
|
||||
expect(removeFn).toHaveBeenCalledWith('a');
|
||||
|
||||
// 重做新增 → 触发 addOrUpdate 事件,initService 会重新 addTarget
|
||||
await codeBlockService.redo('a');
|
||||
expect(addOrUpdateFn).toHaveBeenCalled();
|
||||
|
||||
codeBlockService.off('addOrUpdate', addOrUpdateFn);
|
||||
codeBlockService.off('remove', removeFn);
|
||||
});
|
||||
|
||||
test('undo / redo - 写回时不会再次入历史栈', async () => {
|
||||
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
||||
historyService.reset();
|
||||
|
||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
|
||||
expect(historyService.canUndoCodeBlock('a')).toBe(true);
|
||||
|
||||
await codeBlockService.undo('a');
|
||||
expect(historyService.canRedoCodeBlock('a')).toBe(true);
|
||||
|
||||
await codeBlockService.redo('a');
|
||||
expect(historyService.canRedoCodeBlock('a')).toBe(false);
|
||||
expect(historyService.canUndoCodeBlock('a')).toBe(true);
|
||||
});
|
||||
|
||||
test('canUndo / canRedo 委托给 historyService', async () => {
|
||||
expect(codeBlockService.canUndo('ghost')).toBe(false);
|
||||
expect(codeBlockService.canRedo('ghost')).toBe(false);
|
||||
|
||||
await codeBlockService.setCodeDsl({} as any);
|
||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A' } as any);
|
||||
expect(codeBlockService.canUndo('a')).toBe(true);
|
||||
expect(codeBlockService.canRedo('a')).toBe(false);
|
||||
|
||||
await codeBlockService.undo('a');
|
||||
expect(codeBlockService.canUndo('a')).toBe(false);
|
||||
expect(codeBlockService.canRedo('a')).toBe(true);
|
||||
});
|
||||
|
||||
test('undo / redo - 栈不存在或已无可操作时返回 null', async () => {
|
||||
await expect(codeBlockService.undo('ghost')).resolves.toBeNull();
|
||||
await expect(codeBlockService.redo('ghost')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -186,3 +186,140 @@ describe('DataSource service - 历史记录接入', () => {
|
||||
expect(step?.changeRecords).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSource service - undo / redo', () => {
|
||||
test('undo / redo - 新增场景:撤销=移除,重做=再添加', () => {
|
||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||
|
||||
// undo 后数据源应被移除
|
||||
const undoStep = dataSource.undo(created.id!);
|
||||
expect(undoStep).not.toBeNull();
|
||||
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
|
||||
|
||||
// redo 后数据源应被重新添加
|
||||
const redoStep = dataSource.redo(created.id!);
|
||||
expect(redoStep).not.toBeNull();
|
||||
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a');
|
||||
});
|
||||
|
||||
test('undo / redo - 删除场景:撤销=还原,重做=再删除', () => {
|
||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||
historyService.reset();
|
||||
|
||||
dataSource.remove(created.id!);
|
||||
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
|
||||
|
||||
dataSource.undo(created.id!);
|
||||
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a');
|
||||
|
||||
dataSource.redo(created.id!);
|
||||
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('undo / redo - 更新场景(无 changeRecords):整 schema 替换', () => {
|
||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||
historyService.reset();
|
||||
|
||||
dataSource.update({ ...created, title: 'b' } as any);
|
||||
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b');
|
||||
|
||||
dataSource.undo(created.id!);
|
||||
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a');
|
||||
|
||||
dataSource.redo(created.id!);
|
||||
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b');
|
||||
});
|
||||
|
||||
test('undo / redo - 更新场景(带 changeRecords):按 propPath 局部 patch,不冲掉同节点其它字段', () => {
|
||||
const created = dataSource.add({ title: 'a', type: 'base', description: 'origin' } as any);
|
||||
historyService.reset();
|
||||
|
||||
// 模拟 form 端只更新 title
|
||||
dataSource.update({ ...created, title: 'b', description: 'origin' } as any, {
|
||||
changeRecords: [{ propPath: 'title', value: 'b' }],
|
||||
});
|
||||
|
||||
// 在两次 update 之间用户又改了同节点的另一个字段(不入历史,模拟外部同步)
|
||||
dataSource.update({ ...dataSource.getDataSourceById(created.id!), description: 'changed-by-other' } as any, {
|
||||
doNotPushHistory: true,
|
||||
});
|
||||
|
||||
// undo 应只回滚 title,不影响 description
|
||||
dataSource.undo(created.id!);
|
||||
const after = dataSource.getDataSourceById(created.id!);
|
||||
expect(after?.title).toBe('a');
|
||||
expect(after?.description).toBe('changed-by-other');
|
||||
|
||||
// redo 应只重做 title
|
||||
dataSource.redo(created.id!);
|
||||
const redo = dataSource.getDataSourceById(created.id!);
|
||||
expect(redo?.title).toBe('b');
|
||||
expect(redo?.description).toBe('changed-by-other');
|
||||
});
|
||||
|
||||
test('undo / redo - 通过 add / update / remove 触发事件,依赖收集链路保留', () => {
|
||||
const addFn = vi.fn();
|
||||
const updateFn = vi.fn();
|
||||
const removeFn = vi.fn();
|
||||
dataSource.on('add', addFn);
|
||||
dataSource.on('update', updateFn);
|
||||
dataSource.on('remove', removeFn);
|
||||
|
||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||
addFn.mockClear();
|
||||
|
||||
// 撤销新增 → 触发 remove 事件,initService 中的 dataSourceRemoveHandler 会清依赖
|
||||
dataSource.undo(created.id!);
|
||||
expect(removeFn).toHaveBeenCalledWith(created.id);
|
||||
|
||||
// 重做新增 → 触发 add 事件,initService 会重新 collectIdle
|
||||
dataSource.redo(created.id!);
|
||||
expect(addFn).toHaveBeenCalled();
|
||||
|
||||
// 推入一次 update 历史,再 undo 触发 update 事件
|
||||
historyService.reset();
|
||||
dataSource.update({ ...created, title: 'b' } as any);
|
||||
updateFn.mockClear();
|
||||
dataSource.undo(created.id!);
|
||||
expect(updateFn).toHaveBeenCalled();
|
||||
|
||||
dataSource.off('add', addFn);
|
||||
dataSource.off('update', updateFn);
|
||||
dataSource.off('remove', removeFn);
|
||||
});
|
||||
|
||||
test('undo / redo - 写回时不会再次入历史栈', () => {
|
||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||
historyService.reset();
|
||||
|
||||
dataSource.update({ ...created, title: 'b' } as any);
|
||||
// 此时栈里只有一条 update
|
||||
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
|
||||
|
||||
dataSource.undo(created.id!);
|
||||
// undo 后栈应可 redo,并且 undo 不应再生新栈记录
|
||||
expect(historyService.canRedoDataSource(created.id!)).toBe(true);
|
||||
|
||||
dataSource.redo(created.id!);
|
||||
expect(historyService.canRedoDataSource(created.id!)).toBe(false);
|
||||
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
|
||||
});
|
||||
|
||||
test('canUndo / canRedo 委托给 historyService', () => {
|
||||
expect(dataSource.canUndo('ghost')).toBe(false);
|
||||
expect(dataSource.canRedo('ghost')).toBe(false);
|
||||
|
||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||
expect(dataSource.canUndo(created.id!)).toBe(true);
|
||||
expect(dataSource.canRedo(created.id!)).toBe(false);
|
||||
|
||||
dataSource.undo(created.id!);
|
||||
expect(dataSource.canUndo(created.id!)).toBe(false);
|
||||
expect(dataSource.canRedo(created.id!)).toBe(true);
|
||||
});
|
||||
|
||||
test('undo - 栈不存在或已无可撤销时返回 null', () => {
|
||||
expect(dataSource.undo('ghost')).toBeNull();
|
||||
expect(dataSource.redo('ghost')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user