diff --git a/docs/api/editor/codeBlockServiceMethods.md b/docs/api/editor/codeBlockServiceMethods.md index 26ef215c..8af093f4 100644 --- a/docs/api/editor/codeBlockServiceMethods.md +++ b/docs/api/editor/codeBlockServiceMethods.md @@ -70,6 +70,11 @@ 同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件 + ::: tip + 写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock` + 把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。 + ::: + ## getCodeDslByIds - **参数:** @@ -202,6 +207,11 @@ 在dsl数据源中删除指定id的代码块,每删除一个会触发一次 `remove` 事件 + ::: tip + 对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条 + `newContent=null` 的删除记录;不存在的 id 不会入历史。 + ::: + ## setParamsColConfig - **参数:** diff --git a/docs/api/editor/dataSourceServiceMethods.md b/docs/api/editor/dataSourceServiceMethods.md index 93cff280..d5f5359a 100644 --- a/docs/api/editor/dataSourceServiceMethods.md +++ b/docs/api/editor/dataSourceServiceMethods.md @@ -306,6 +306,11 @@ dataSourceService.setFormMethod("http", [ 添加一个数据源,如果配置中没有id或id已存在,会自动生成新的id + ::: tip + 添加成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema=null` 的新增记录, + 参见 [historyService.pushDataSource](./historyServiceMethods.md#pushdatasource)。 + ::: + - **示例:** ```js @@ -341,6 +346,11 @@ console.log(newDs.id); // 自动生成的id 更新数据源 + ::: tip + 更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema` + 均为对应 schema 的更新记录。 + ::: + - **示例:** ```js @@ -370,6 +380,11 @@ console.log(updatedDs); 删除指定id的数据源 + ::: tip + 对实际存在的数据源会自动调用 `historyService.pushDataSource` 入栈一条 `newSchema=null` + 的删除记录;不存在的 id 不会入历史。 + ::: + - **示例:** ```js diff --git a/docs/api/editor/historyServiceEvents.md b/docs/api/editor/historyServiceEvents.md index 52f67f71..87ce79ee 100644 --- a/docs/api/editor/historyServiceEvents.md +++ b/docs/api/editor/historyServiceEvents.md @@ -29,3 +29,41 @@ :::tip 当游标处于历史栈边界(已经无法继续撤销或重做)时,`UndoRedo.undo()` / `redo()` 返回 `null`,对应 `change` 回调收到的 `state` 为 `null` ::: + +## code-block-history-change + +- **详情:** 代码块历史记录发生变化(`pushCodeBlock` / `undoCodeBlock` / `redoCodeBlock` 成功时触发) + +- **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void` + + ::: details 查看 CodeBlockStepValue 及关联类型定义 + <<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts} + + <<< @/../packages/schema/src/index.ts#CodeBlockContent{ts} + + <<< @/../packages/schema/src/index.ts#Id{ts} + ::: + + :::tip + - 新增触发的 step 中 `oldContent` 为 `null` + - 删除触发的 step 中 `newContent` 为 `null` + - `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件 + ::: + +## data-source-history-change + +- **详情:** 数据源历史记录发生变化(`pushDataSource` / `undoDataSource` / `redoDataSource` 成功时触发) + +- **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void` + + ::: details 查看 DataSourceStepValue 及关联类型定义 + <<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts} + + <<< @/../packages/schema/src/index.ts#Id{ts} + ::: + + :::tip + - 新增触发的 step 中 `oldSchema` 为 `null` + - 删除触发的 step 中 `newSchema` 为 `null` + - `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件 + ::: diff --git a/docs/api/editor/historyServiceMethods.md b/docs/api/editor/historyServiceMethods.md index 846bf5d0..d61dae6a 100644 --- a/docs/api/editor/historyServiceMethods.md +++ b/docs/api/editor/historyServiceMethods.md @@ -4,7 +4,7 @@ - **详情:** - 重置记录 + 重置全部历史记录(包括页面节点栈、代码块栈、数据源栈),并重置当前页面 id / canRedo / canUndo ## resetPage @@ -16,7 +16,7 @@ - **详情:** - 重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo) + 重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo、codeBlockState、dataSourceState) ## changePage @@ -73,6 +73,159 @@ 恢复到下一步 +## pushCodeBlock + +- **参数:** + - `{Id} codeBlockId` 代码块 id + - `{Object} payload` + - `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null` + - `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null` + + ::: details 查看 CodeBlockStepValue 及关联类型定义 + <<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts} + + <<< @/../packages/schema/src/index.ts#CodeBlockContent{ts} + ::: + +- **返回:** + - `{CodeBlockStepValue | null}` 入栈失败(未传 id)时返回 `null` + +- **详情:** + + 推入一条代码块变更记录。与页面 / 节点完全无关,按 `codeBlockId` 维度独立一份 `UndoRedo` 栈, + 栈实例存放在 `historyService.state.codeBlockState[codeBlockId]`。 + + 入栈成功后会触发 `code-block-history-change` 事件。 + + ::: tip + `codeBlockService.setCodeDslByIdSync` 与 `codeBlockService.deleteCodeDslByIds` 内部已经 + 自动调用本方法,业务代码通常无需手动调用。 + ::: + +## undoCodeBlock + +- **参数:** + - `{Id} codeBlockId` + +- **返回:** + - `{CodeBlockStepValue | null}` 栈不存在或已无可撤销记录时返回 `null` + +- **详情:** + + 撤销指定代码块的最近一次变更。成功时会触发 `code-block-history-change` 事件。 + 拿到 step 后由调用方根据 `step.oldContent` 写回 `codeBlockService`(本方法不会自动回放)。 + +## redoCodeBlock + +- **参数:** + - `{Id} codeBlockId` + +- **返回:** + - `{CodeBlockStepValue | null}` 栈不存在或已无可重做记录时返回 `null` + +- **详情:** + + 重做指定代码块的下一次变更。成功时会触发 `code-block-history-change` 事件。 + +## canUndoCodeBlock + +- **参数:** + - `{Id} codeBlockId` + +- **返回:** + - `{boolean}` + +- **详情:** + + 指定代码块当前是否可撤销。栈不存在时返回 `false`。 + +## canRedoCodeBlock + +- **参数:** + - `{Id} codeBlockId` + +- **返回:** + - `{boolean}` + +- **详情:** + + 指定代码块当前是否可重做。栈不存在时返回 `false`。 + +## pushDataSource + +- **参数:** + - `{Id} dataSourceId` 数据源 id + - `{Object} payload` + - `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema;新增时为 `null` + - `{DataSourceSchema | null} newSchema` 变更后的数据源 schema;删除时为 `null` + + ::: details 查看 DataSourceStepValue 及关联类型定义 + <<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts} + ::: + +- **返回:** + - `{DataSourceStepValue | null}` 入栈失败(未传 id)时返回 `null` + +- **详情:** + + 推入一条数据源变更记录。与页面 / 节点完全无关,按 `dataSourceId` 维度独立一份 `UndoRedo` 栈, + 栈实例存放在 `historyService.state.dataSourceState[dataSourceId]`。 + + 入栈成功后会触发 `data-source-history-change` 事件。 + + ::: tip + `dataSourceService.add` / `update` / `remove` 内部已经自动调用本方法,业务代码通常无需手动调用。 + ::: + +## undoDataSource + +- **参数:** + - `{Id} dataSourceId` + +- **返回:** + - `{DataSourceStepValue | null}` + +- **详情:** + + 撤销指定数据源的最近一次变更。成功时会触发 `data-source-history-change` 事件。 + 拿到 step 后由调用方根据 `step.oldSchema` 写回 `dataSourceService`(本方法不会自动回放)。 + +## redoDataSource + +- **参数:** + - `{Id} dataSourceId` + +- **返回:** + - `{DataSourceStepValue | null}` + +- **详情:** + + 重做指定数据源的下一次变更。成功时会触发 `data-source-history-change` 事件。 + +## canUndoDataSource + +- **参数:** + - `{Id} dataSourceId` + +- **返回:** + - `{boolean}` + +- **详情:** + + 指定数据源当前是否可撤销。栈不存在时返回 `false`。 + +## canRedoDataSource + +- **参数:** + - `{Id} dataSourceId` + +- **返回:** + - `{boolean}` + +- **详情:** + + 指定数据源当前是否可重做。栈不存在时返回 `false`。 + ## destroy - **详情:** diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index b7ff4ee8..3fb88329 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -25,6 +25,7 @@ import { Target, Watcher } from '@tmagic/core'; import type { TableColumnConfig } from '@tmagic/form'; 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 { CODE_DRAFT_STORAGE_KEY } from '@editor/type'; @@ -123,6 +124,9 @@ class CodeBlock extends BaseService { } } + // 历史记录:在写入前快照旧内容,区分新增/更新 + const oldContent: CodeBlockContent | null = codeDsl[id] ? cloneDeep(codeDsl[id]) : null; + const existContent = codeDsl[id] || {}; codeDsl[id] = { @@ -130,6 +134,10 @@ class CodeBlock extends BaseService { ...codeConfigProcessed, }; + const newContent = cloneDeep(codeDsl[id]); + + historyService.pushCodeBlock(id, { oldContent, newContent }); + this.emit('addOrUpdate', id, codeDsl[id]); } @@ -225,8 +233,15 @@ class CodeBlock extends BaseService { if (!currentDsl) return; codeIds.forEach((id) => { + // 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入 + const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null; + delete currentDsl[id]; + if (oldContent) { + historyService.pushCodeBlock(id, { oldContent, newContent: null }); + } + this.emit('remove', id); }); } diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index 605fbcdf..4907d95e 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -8,6 +8,7 @@ import type { ChangeRecord, FormConfig } from '@tmagic/form'; import { guid, 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 { getFormConfig, getFormValue } from '@editor/utils/data-source'; @@ -110,6 +111,8 @@ class DataSource extends BaseService { this.get('dataSources').push(newConfig); + historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig }); + this.emit('add', newConfig); return newConfig; @@ -125,6 +128,11 @@ class DataSource extends BaseService { dataSources[index] = newConfig; + historyService.pushDataSource(newConfig.id, { + oldSchema: oldConfig ? cloneDeep(oldConfig) : null, + newSchema: newConfig, + }); + this.emit('update', newConfig, { oldConfig, changeRecords, @@ -136,8 +144,13 @@ class DataSource extends BaseService { public remove(id: string) { const dataSources = this.get('dataSources'); const index = dataSources.findIndex((ds) => ds.id === id); + const oldConfig = index !== -1 ? dataSources[index] : null; dataSources.splice(index, 1); + if (oldConfig) { + historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null }); + } + this.emit('remove', id); } diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index 514cd6a0..52671839 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -17,10 +17,11 @@ */ import { reactive } from 'vue'; +import { cloneDeep } from 'lodash-es'; -import type { Id, MPage, MPageFragment } from '@tmagic/core'; +import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core'; -import type { HistoryState, StepValue } from '@editor/type'; +import type { CodeBlockStepValue, DataSourceStepValue, HistoryState, StepValue } from '@editor/type'; import { UndoRedo } from '@editor/utils/undo-redo'; import BaseService from './BaseService'; @@ -31,6 +32,8 @@ class History extends BaseService { pageId: undefined, canRedo: false, canUndo: false, + codeBlockState: {}, + dataSourceState: {}, }); constructor() { @@ -41,6 +44,8 @@ class History extends BaseService { public reset() { this.state.pageSteps = {}; + this.state.codeBlockState = {}; + this.state.dataSourceState = {}; this.resetPage(); } @@ -69,6 +74,8 @@ class History extends BaseService { this.state.pageSteps = {}; this.state.canRedo = false; this.state.canUndo = false; + this.state.codeBlockState = {}; + this.state.dataSourceState = {}; } /** @@ -88,6 +95,114 @@ class History extends BaseService { return state; } + /** + * 推入一条代码块变更记录(与页面/节点完全无关),按 `codeBlockId` 维度独立一份 UndoRedo 栈。 + * + * - 新增:oldContent = null,newContent = 新内容 + * - 更新:oldContent / newContent 都为对应内容 + * - 删除:newContent = null,oldContent = 删除前内容 + * - 不直接驱动 codeBlockService,调用方负责实际写回。 + */ + public pushCodeBlock( + codeBlockId: Id, + payload: { + oldContent: CodeBlockContent | null; + newContent: CodeBlockContent | null; + }, + ): CodeBlockStepValue | null { + if (!codeBlockId) return null; + + const step: CodeBlockStepValue = { + id: codeBlockId, + oldContent: payload.oldContent ? cloneDeep(payload.oldContent) : null, + newContent: payload.newContent ? cloneDeep(payload.newContent) : null, + }; + + this.getCodeBlockUndoRedo(codeBlockId).pushElement(step); + this.emit('code-block-history-change', codeBlockId, step); + return step; + } + + /** + * 推入一条数据源变更记录(与页面/节点完全无关),按 `dataSourceId` 维度独立一份 UndoRedo 栈。 + * 行为同 pushCodeBlock(新增 oldSchema=null;删除 newSchema=null)。 + */ + public pushDataSource( + dataSourceId: Id, + payload: { + oldSchema: DataSourceSchema | null; + newSchema: DataSourceSchema | null; + }, + ): DataSourceStepValue | null { + if (!dataSourceId) return null; + + const step: DataSourceStepValue = { + id: dataSourceId, + oldSchema: payload.oldSchema ? cloneDeep(payload.oldSchema) : null, + newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null, + }; + + this.getDataSourceUndoRedo(dataSourceId).pushElement(step); + this.emit('data-source-history-change', dataSourceId, step); + return step; + } + + /** 撤销指定代码块的最近一次变更。 */ + public undoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null { + const undoRedo = this.state.codeBlockState[codeBlockId]; + if (!undoRedo) return null; + const step = undoRedo.undo(); + if (step) this.emit('code-block-history-change', codeBlockId, step); + return step; + } + + /** 重做指定代码块的下一次变更。 */ + public redoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null { + const undoRedo = this.state.codeBlockState[codeBlockId]; + if (!undoRedo) return null; + const step = undoRedo.redo(); + if (step) this.emit('code-block-history-change', codeBlockId, step); + return step; + } + + /** 是否可对指定代码块撤销。 */ + public canUndoCodeBlock(codeBlockId: Id): boolean { + return this.state.codeBlockState[codeBlockId]?.canUndo() ?? false; + } + + /** 是否可对指定代码块重做。 */ + public canRedoCodeBlock(codeBlockId: Id): boolean { + return this.state.codeBlockState[codeBlockId]?.canRedo() ?? false; + } + + /** 撤销指定数据源的最近一次变更。 */ + public undoDataSource(dataSourceId: Id): DataSourceStepValue | null { + const undoRedo = this.state.dataSourceState[dataSourceId]; + if (!undoRedo) return null; + const step = undoRedo.undo(); + if (step) this.emit('data-source-history-change', dataSourceId, step); + return step; + } + + /** 重做指定数据源的下一次变更。 */ + public redoDataSource(dataSourceId: Id): DataSourceStepValue | null { + const undoRedo = this.state.dataSourceState[dataSourceId]; + if (!undoRedo) return null; + const step = undoRedo.redo(); + if (step) this.emit('data-source-history-change', dataSourceId, step); + return step; + } + + /** 是否可对指定数据源撤销。 */ + public canUndoDataSource(dataSourceId: Id): boolean { + return this.state.dataSourceState[dataSourceId]?.canUndo() ?? false; + } + + /** 是否可对指定数据源重做。 */ + public canRedoDataSource(dataSourceId: Id): boolean { + return this.state.dataSourceState[dataSourceId]?.canRedo() ?? false; + } + public undo(): StepValue | null { const undoRedo = this.getUndoRedo(); if (!undoRedo) return null; @@ -130,6 +245,26 @@ class History extends BaseService { this.state.canRedo = undoRedo?.canRedo() || false; this.state.canUndo = undoRedo?.canUndo() || false; } + + /** + * 按 id 获取(或创建)指定代码块的 UndoRedo 栈。 + */ + private getCodeBlockUndoRedo(codeBlockId: Id): UndoRedo { + if (!this.state.codeBlockState[codeBlockId]) { + this.state.codeBlockState[codeBlockId] = new UndoRedo(); + } + return this.state.codeBlockState[codeBlockId]; + } + + /** + * 按 id 获取(或创建)指定数据源的 UndoRedo 栈。 + */ + private getDataSourceUndoRedo(dataSourceId: Id): UndoRedo { + if (!this.state.dataSourceState[dataSourceId]) { + this.state.dataSourceState[dataSourceId] = new UndoRedo(); + } + return this.state.dataSourceState[dataSourceId]; + } } export type HistoryService = History; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index c82dd1f3..133bd591 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -22,7 +22,17 @@ import type * as Monaco from 'monaco-editor'; import type { default as Sortable, Options, SortableEvent } from 'sortablejs'; import type { PascalCasedProperties, Writable } from 'type-fest'; -import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core'; +import type { + CodeBlockContent, + CodeBlockDSL, + DataSourceSchema, + Id, + MApp, + MContainer, + MNode, + MPage, + MPageFragment, +} from '@tmagic/core'; import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form'; import type StageCore from '@tmagic/stage'; import type { @@ -632,11 +642,55 @@ export interface StepValue { } // #endregion StepValue +// #region CodeBlockStepValue +/** + * 代码块历史记录条目。按 codeBlock.id 分组保存到 historyState.codeBlockState。 + * - 新增:oldContent = null,newContent = 新内容 + * - 更新:oldContent / newContent 都为对应内容 + * - 删除:newContent = null,oldContent = 删除前内容 + */ +export interface CodeBlockStepValue { + /** 关联的代码块 id */ + id: Id; + /** 变更前的代码块内容,新增时为 null */ + oldContent: CodeBlockContent | null; + /** 变更后的代码块内容,删除时为 null */ + newContent: CodeBlockContent | null; +} +// #endregion CodeBlockStepValue + +// #region DataSourceStepValue +/** + * 数据源历史记录条目。按 dataSource.id 分组保存到 historyState.dataSourceState。 + * - 新增:oldSchema = null,newSchema = 新 schema + * - 更新:oldSchema / newSchema 都为对应 schema + * - 删除:newSchema = null,oldSchema = 删除前 schema + */ +export interface DataSourceStepValue { + /** 关联的数据源 id */ + id: Id; + /** 变更前的数据源 schema,新增时为 null */ + oldSchema: DataSourceSchema | null; + /** 变更后的数据源 schema,删除时为 null */ + newSchema: DataSourceSchema | null; +} +// #endregion DataSourceStepValue + export interface HistoryState { pageId?: Id; pageSteps: Record>; canRedo: boolean; canUndo: boolean; + /** + * 代码块历史栈,按 codeBlock.id 分组(每个代码块独立一份 UndoRedo)。 + * 与页面/节点无关,支持独立 undo/redo。 + */ + codeBlockState: Record>; + /** + * 数据源历史栈,按 dataSource.id 分组(每个数据源独立一份 UndoRedo)。 + * 与页面/节点无关,支持独立 undo/redo。 + */ + dataSourceState: Record>; } export enum KeyBindingCommand { diff --git a/packages/editor/tests/unit/services/codeBlock.spec.ts b/packages/editor/tests/unit/services/codeBlock.spec.ts index 51b133a6..4e0e9133 100644 --- a/packages/editor/tests/unit/services/codeBlock.spec.ts +++ b/packages/editor/tests/unit/services/codeBlock.spec.ts @@ -6,6 +6,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import codeBlockService from '@editor/services/codeBlock'; +import historyService from '@editor/services/history'; import storageService, { Protocol } from '@editor/services/storage'; import { CODE_DRAFT_STORAGE_KEY } from '@editor/type'; import { setEditorConfig } from '@editor/utils/config'; @@ -26,6 +27,7 @@ beforeAll(() => { beforeEach(() => { codeBlockService.resetState(); + historyService.reset(); globalThis.localStorage.clear(); }); @@ -166,3 +168,45 @@ describe('CodeBlockService - 基础', () => { expect(fn).not.toHaveBeenCalled(); }); }); + +describe('CodeBlockService - 历史记录接入', () => { + test('setCodeDslByIdSync - 新增时入历史(oldContent=null)', async () => { + await codeBlockService.setCodeDsl({} as any); + codeBlockService.setCodeDslByIdSync('new_code', { name: 'A' } as any); + + expect(historyService.canUndoCodeBlock('new_code')).toBe(true); + const step = historyService.undoCodeBlock('new_code'); + expect(step?.oldContent).toBeNull(); + expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A' })); + }); + + test('setCodeDslByIdSync - 更新时入历史(oldContent / newContent 都非空)', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any); + codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any); + + const step = historyService.undoCodeBlock('a'); + expect(step?.oldContent).toEqual({ name: 'A' }); + expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A2' })); + }); + + test('setCodeDslByIdSync - force=false 已存在时不入历史', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any); + codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any, false); + expect(historyService.canUndoCodeBlock('a')).toBe(false); + }); + + test('deleteCodeDslByIds - 删除已存在的代码块入历史(newContent=null)', async () => { + await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any); + await codeBlockService.deleteCodeDslByIds(['a']); + + const step = historyService.undoCodeBlock('a'); + expect(step?.oldContent).toEqual({ name: 'A' }); + expect(step?.newContent).toBeNull(); + }); + + test('deleteCodeDslByIds - 删除不存在的 id 不入历史', async () => { + await codeBlockService.setCodeDsl({} as any); + await codeBlockService.deleteCodeDslByIds(['ghost']); + expect(historyService.canUndoCodeBlock('ghost')).toBe(false); + }); +}); diff --git a/packages/editor/tests/unit/services/dataSource.spec.ts b/packages/editor/tests/unit/services/dataSource.spec.ts index 71ff3fc8..edac2827 100644 --- a/packages/editor/tests/unit/services/dataSource.spec.ts +++ b/packages/editor/tests/unit/services/dataSource.spec.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import dataSource from '@editor/services/dataSource'; +import historyService from '@editor/services/history'; import storageService, { Protocol } from '@editor/services/storage'; import { setEditorConfig } from '@editor/utils/config'; import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor'; @@ -27,6 +28,7 @@ beforeEach(() => { dataSource.set('values', {}); dataSource.set('events', {}); dataSource.set('methods', {}); + historyService.reset(); }); afterEach(() => { @@ -127,3 +129,39 @@ describe('DataSource service', () => { expect(fn).not.toHaveBeenCalled(); }); }); + +describe('DataSource service - 历史记录接入', () => { + test('add - 入历史(oldSchema=null)', () => { + const ds = dataSource.add({ title: 'a', type: 'base' } as any); + expect(historyService.canUndoDataSource(ds.id!)).toBe(true); + const step = historyService.undoDataSource(ds.id!); + expect(step?.oldSchema).toBeNull(); + expect(step?.newSchema?.title).toBe('a'); + }); + + test('update - 入历史,oldSchema 是旧值,newSchema 是新值', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + // 清掉 add 推入的那条 + historyService.reset(); + + dataSource.update({ ...created, title: 'b' } as any); + const step = historyService.undoDataSource(created.id!); + expect(step?.oldSchema?.title).toBe('a'); + expect(step?.newSchema?.title).toBe('b'); + }); + + test('remove - 入历史(newSchema=null)', () => { + const created = dataSource.add({ title: 'a', type: 'base' } as any); + historyService.reset(); + + dataSource.remove(created.id!); + const step = historyService.undoDataSource(created.id!); + expect(step?.oldSchema?.title).toBe('a'); + expect(step?.newSchema).toBeNull(); + }); + + test('remove - 不存在的 id 不入历史', () => { + dataSource.remove('ghost'); + expect(historyService.canUndoDataSource('ghost')).toBe(false); + }); +}); diff --git a/packages/editor/tests/unit/services/history.spec.ts b/packages/editor/tests/unit/services/history.spec.ts index eeed378d..0a076157 100644 --- a/packages/editor/tests/unit/services/history.spec.ts +++ b/packages/editor/tests/unit/services/history.spec.ts @@ -3,7 +3,7 @@ * * Copyright (C) 2025 Tencent. */ -import { afterEach, describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import history from '@editor/services/history'; @@ -85,3 +85,144 @@ describe('history service', () => { expect(history.state.canUndo).toBe(true); }); }); + +describe('history service - codeBlock', () => { + test('pushCodeBlock 入栈并触发 code-block-history-change 事件', () => { + const fn = vi.fn(); + history.on('code-block-history-change', fn); + + const step = history.pushCodeBlock('code_1', { + oldContent: null, + newContent: { name: 'A', content: 'x' } as any, + }); + + expect(step).not.toBeNull(); + expect(step?.id).toBe('code_1'); + expect(step?.oldContent).toBeNull(); + expect(step?.newContent).toEqual({ name: 'A', content: 'x' }); + expect((history.state.codeBlockState as any).code_1).toBeDefined(); + expect(history.canUndoCodeBlock('code_1')).toBe(true); + expect(fn).toHaveBeenCalledWith('code_1', expect.objectContaining({ id: 'code_1' })); + + history.off('code-block-history-change', fn); + }); + + test('pushCodeBlock 不传 id 返回 null', () => { + expect(history.pushCodeBlock('', { oldContent: null, newContent: null })).toBeNull(); + }); + + test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => { + history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any }); + history.pushCodeBlock('code_1', { + oldContent: { name: 'A' } as any, + newContent: { name: 'B' } as any, + }); + + expect(history.canUndoCodeBlock('code_1')).toBe(true); + const undone = history.undoCodeBlock('code_1'); + expect(undone?.newContent).toEqual({ name: 'B' }); + expect(history.canRedoCodeBlock('code_1')).toBe(true); + + const redone = history.redoCodeBlock('code_1'); + expect(redone?.newContent).toEqual({ name: 'B' }); + }); + + test('undoCodeBlock 对不存在 id 返回 null', () => { + expect(history.undoCodeBlock('not-exist')).toBeNull(); + expect(history.redoCodeBlock('not-exist')).toBeNull(); + expect(history.canUndoCodeBlock('not-exist')).toBe(false); + expect(history.canRedoCodeBlock('not-exist')).toBe(false); + }); + + test('不同代码块 id 的栈相互隔离', () => { + history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any }); + history.pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any }); + + expect(history.canUndoCodeBlock('code_1')).toBe(true); + expect(history.canUndoCodeBlock('code_2')).toBe(true); + + history.undoCodeBlock('code_1'); + expect(history.canUndoCodeBlock('code_1')).toBe(false); + // code_2 的栈不受影响 + expect(history.canUndoCodeBlock('code_2')).toBe(true); + }); + + test('reset / resetState 清空 codeBlockState', () => { + history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any }); + history.reset(); + expect(Object.keys(history.state.codeBlockState)).toHaveLength(0); + + history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any }); + history.resetState(); + expect(Object.keys(history.state.codeBlockState)).toHaveLength(0); + }); +}); + +describe('history service - dataSource', () => { + test('pushDataSource 入栈并触发 data-source-history-change 事件', () => { + const fn = vi.fn(); + history.on('data-source-history-change', fn); + + const step = history.pushDataSource('ds_1', { + oldSchema: null, + newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any, + }); + + expect(step).not.toBeNull(); + expect(step?.id).toBe('ds_1'); + expect(step?.oldSchema).toBeNull(); + expect(step?.newSchema?.title).toBe('A'); + expect((history.state.dataSourceState as any).ds_1).toBeDefined(); + expect(history.canUndoDataSource('ds_1')).toBe(true); + expect(fn).toHaveBeenCalledWith('ds_1', expect.objectContaining({ id: 'ds_1' })); + + history.off('data-source-history-change', fn); + }); + + test('pushDataSource 不传 id 返回 null', () => { + expect(history.pushDataSource('', { oldSchema: null, newSchema: null })).toBeNull(); + }); + + test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => { + history.pushDataSource('ds_1', { + oldSchema: null, + newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any, + }); + history.pushDataSource('ds_1', { + oldSchema: { id: 'ds_1', type: 'base', title: 'A' } as any, + newSchema: { id: 'ds_1', type: 'base', title: 'B' } as any, + }); + + const undone = history.undoDataSource('ds_1'); + expect(undone?.newSchema?.title).toBe('B'); + + const redone = history.redoDataSource('ds_1'); + expect(redone?.newSchema?.title).toBe('B'); + }); + + test('undoDataSource 对不存在 id 返回 null', () => { + expect(history.undoDataSource('not-exist')).toBeNull(); + expect(history.redoDataSource('not-exist')).toBeNull(); + expect(history.canUndoDataSource('not-exist')).toBe(false); + expect(history.canRedoDataSource('not-exist')).toBe(false); + }); + + test('不同数据源 id 的栈相互隔离', () => { + history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any }); + history.pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any }); + + history.undoDataSource('ds_1'); + expect(history.canUndoDataSource('ds_1')).toBe(false); + expect(history.canUndoDataSource('ds_2')).toBe(true); + }); + + test('reset / resetState 清空 dataSourceState', () => { + history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any }); + history.reset(); + expect(Object.keys(history.state.dataSourceState)).toHaveLength(0); + + history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any }); + history.resetState(); + expect(Object.keys(history.state.dataSourceState)).toHaveLength(0); + }); +});