From f967a20edf849e929c926781d1c55c8cb5c5a00e Mon Sep 17 00:00:00 2001 From: Wroud Date: Thu, 29 May 2025 19:41:38 +0800 Subject: [PATCH 1/2] dbeaver/pro#5802 feat: table api --- common-react/yarn.lock | 30 ++++- .../@dbeaver/table-data/README.md | 1 + .../@dbeaver/table-data/package.json | 36 ++++++ .../table-data/src/TableDatasetManager.ts | 18 +++ .../@dbeaver/table-data/src/TableSource.ts | 110 ++++++++++++++++++ .../@dbeaver/table-data/src/index.ts | 10 ++ .../table-data/src/interfaces/ITableData.ts | 18 +++ .../src/interfaces/ITableDataset.ts | 5 + .../src/interfaces/ITableDatasetManager.ts | 7 ++ .../src/interfaces/ITableSorting.ts | 4 + .../table-data/src/interfaces/ITableSource.ts | 18 +++ .../src/interfaces/ITableSourceOptions.ts | 5 + .../table-data/src/interfaces/TableEvents.ts | 3 + .../src/interfaces/TableSourceEvents.ts | 7 ++ .../@dbeaver/table-data/tsconfig.json | 16 +++ common-typescript/yarn.lock | 30 ++++- webapp/yarn.lock | 28 +++++ 17 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 common-typescript/@dbeaver/table-data/README.md create mode 100644 common-typescript/@dbeaver/table-data/package.json create mode 100644 common-typescript/@dbeaver/table-data/src/TableDatasetManager.ts create mode 100644 common-typescript/@dbeaver/table-data/src/TableSource.ts create mode 100644 common-typescript/@dbeaver/table-data/src/index.ts create mode 100644 common-typescript/@dbeaver/table-data/src/interfaces/ITableData.ts create mode 100644 common-typescript/@dbeaver/table-data/src/interfaces/ITableDataset.ts create mode 100644 common-typescript/@dbeaver/table-data/src/interfaces/ITableDatasetManager.ts create mode 100644 common-typescript/@dbeaver/table-data/src/interfaces/ITableSorting.ts create mode 100644 common-typescript/@dbeaver/table-data/src/interfaces/ITableSource.ts create mode 100644 common-typescript/@dbeaver/table-data/src/interfaces/ITableSourceOptions.ts create mode 100644 common-typescript/@dbeaver/table-data/src/interfaces/TableEvents.ts create mode 100644 common-typescript/@dbeaver/table-data/src/interfaces/TableSourceEvents.ts create mode 100644 common-typescript/@dbeaver/table-data/tsconfig.json diff --git a/common-react/yarn.lock b/common-react/yarn.lock index 650056dbd13..538e01fda2a 100644 --- a/common-react/yarn.lock +++ b/common-react/yarn.lock @@ -384,6 +384,18 @@ __metadata: languageName: unknown linkType: soft +"@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data": + version: 0.0.0-use.local + resolution: "@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data" + dependencies: + "@dbeaver/tsconfig": "workspace:^" + async-mutex: "npm:^0" + nanoevents: "npm:^9" + rimraf: "npm:^6" + typescript: "npm:^5" + languageName: unknown + linkType: soft + "@dbeaver/tsconfig@workspace:../common-typescript/@dbeaver/tsconfig, @dbeaver/tsconfig@workspace:^": version: 0.0.0-use.local resolution: "@dbeaver/tsconfig@workspace:../common-typescript/@dbeaver/tsconfig" @@ -1988,6 +2000,15 @@ __metadata: languageName: node linkType: hard +"async-mutex@npm:^0": + version: 0.5.0 + resolution: "async-mutex@npm:0.5.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/9096e6ad6b674c894d8ddd5aa4c512b09bb05931b8746ebd634952b05685608b2b0820ed5c406e6569919ff5fe237ab3c491e6f2887d6da6b6ba906db3ee9c32 + languageName: node + linkType: hard + "axe-core@npm:^4.10.2": version: 4.10.3 resolution: "axe-core@npm:4.10.3" @@ -5127,6 +5148,13 @@ __metadata: languageName: node linkType: hard +"nanoevents@npm:^9": + version: 9.1.0 + resolution: "nanoevents@npm:9.1.0" + checksum: 10c0/5fb48e6fc1d3102daddaeffd6eada907c25b1c8554dcc648e9cb0a72979a1ab3ee56ffa2d2b3e566a8e561a9e2992a3783493c61cfaaa096b2986d56dbbc1ca5 + languageName: node + linkType: hard + "nanoid@npm:^3.3.8": version: 3.3.11 resolution: "nanoid@npm:3.3.11" @@ -6512,7 +6540,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2, tslib@npm:^2.3.0": +"tslib@npm:^2, tslib@npm:^2.3.0, tslib@npm:^2.4.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 diff --git a/common-typescript/@dbeaver/table-data/README.md b/common-typescript/@dbeaver/table-data/README.md new file mode 100644 index 00000000000..8af0a543d7d --- /dev/null +++ b/common-typescript/@dbeaver/table-data/README.md @@ -0,0 +1 @@ +# @dbeaver/table-data diff --git a/common-typescript/@dbeaver/table-data/package.json b/common-typescript/@dbeaver/table-data/package.json new file mode 100644 index 00000000000..f6e3b873699 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/package.json @@ -0,0 +1,36 @@ +{ + "name": "@dbeaver/table-data", + "type": "module", + "sideEffects": [], + "exports": { + ".": "./lib/index.js", + "./*": "./lib/*.js" + }, + "scripts": { + "clear": "rimraf lib" + }, + "files": [ + "package.json", + "LICENSE", + "README.md", + "CHANGELOG.md", + "lib", + "!lib/**/*.d.ts.map", + "!lib/**/*.test.js", + "!lib/**/*.test.d.ts", + "!lib/**/*.test.d.ts.map", + "!lib/**/*.test.js.map", + "!lib/tests", + "!.tsbuildinfo" + ], + "packageManager": "yarn@4.6.0", + "devDependencies": { + "@dbeaver/tsconfig": "workspace:^", + "rimraf": "^6", + "typescript": "^5" + }, + "dependencies": { + "async-mutex": "^0", + "nanoevents": "^9" + } +} diff --git a/common-typescript/@dbeaver/table-data/src/TableDatasetManager.ts b/common-typescript/@dbeaver/table-data/src/TableDatasetManager.ts new file mode 100644 index 00000000000..f3e7af9bfb1 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/TableDatasetManager.ts @@ -0,0 +1,18 @@ +import type { ITableDataset } from './interfaces/ITableDataset.js'; +import type { ITableDatasetManager } from './interfaces/ITableDatasetManager.js'; + +export class TableDatasetManager implements ITableDatasetManager { + get datasets(): ITableDataset[] { + return this.#datasets; + } + + #datasets: ITableDataset[] = []; + + constructor() { + this.#datasets = []; + } + + setDatasets(datasets: ITableDataset[]): void { + this.#datasets = datasets; + } +} diff --git a/common-typescript/@dbeaver/table-data/src/TableSource.ts b/common-typescript/@dbeaver/table-data/src/TableSource.ts new file mode 100644 index 00000000000..8205b74f503 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/TableSource.ts @@ -0,0 +1,110 @@ +import { Mutex } from 'async-mutex'; +import type { ITableSource } from './interfaces/ITableSource.js'; +import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents'; +import type { TableSourceEvents } from './interfaces/TableSourceEvents.js'; +import type { ITableDatasetManager } from './interfaces/ITableDatasetManager.js'; +import type { ITableSourceOptions } from './interfaces/ITableSourceOptions.js'; + +export abstract class TableSource + implements ITableSource +{ + get isLoading(): boolean { + return this.#loadingInProgress; + } + get isOutdated(): boolean { + return this.#outdated; + } + error: Error | null; + options: Partial; + + #outdated: boolean; + #loadingInProgress = false; + #loadingPending = false; + readonly #mutex: Mutex; + readonly #emitter: Emitter; + constructor(readonly datasetManager: ITableDatasetManager) { + this.options = {}; + this.error = null; + this.#outdated = false; + this.#mutex = new Mutex(); + this.#emitter = createNanoEvents(); + } + + setOptions(options: TOptions): void { + this.options = options; + this.setOutdated(); + } + + setOutdated(): void { + this.setOutdatedValue(true); + if (this.#mutex.isLocked()) { + this.#mutex.runExclusive(() => { + this.setOutdatedValue(true); + }); + } + } + + async save(): Promise { + const release = await this.#mutex.acquire(); + try { + this.setError(null); + await this.saveData(); + this.emit('saved'); + } catch (error) { + this.setError(error instanceof Error ? error : new Error(String(error))); + throw this.error; + } finally { + release(); + } + } + + async load(): Promise { + if (this.#loadingInProgress) { + this.#loadingPending = true; + return await this.#mutex.waitForUnlock(); + } + this.setLoadingInProgress(true); + try { + // this waits for any save to finish, then runs loadData + await this.#mutex.runExclusive(async () => { + do { + this.#loadingPending = false; + this.setError(null); + await this.loadData(); + this.emit('data'); + } while (this.#loadingPending); + }); + } catch (error) { + this.setError(error instanceof Error ? error : new Error(String(error))); + throw this.error; + } finally { + this.setOutdatedValue(false); + this.setLoadingInProgress(false); + } + } + + on(event: TEvent, listener: TableSourceEvents[TEvent]): Unsubscribe { + return this.#emitter.on(event, listener); + } + + protected setLoadingInProgress(state: boolean): void { + this.emit('loading', state); + this.#loadingInProgress = state; + } + + protected setError(error: Error | null): void { + this.error = error; + this.emit('error', error); + } + + protected setOutdatedValue(state: boolean): void { + this.#outdated = state; + this.emit('outdated', state); + } + + protected emit(event: TEvent, ...args: Parameters): void { + this.#emitter.emit(event, ...args); + } + protected abstract saveData(): Promise; + protected abstract loadData(): Promise; +} diff --git a/common-typescript/@dbeaver/table-data/src/index.ts b/common-typescript/@dbeaver/table-data/src/index.ts new file mode 100644 index 00000000000..87d0589e90d --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/index.ts @@ -0,0 +1,10 @@ +export * from './interfaces/ITableData.js'; +export * from './interfaces/ITableDataset.js'; +export * from './interfaces/ITableDatasetManager.js'; +export * from './interfaces/ITableSource.js'; +export * from './interfaces/ITableSourceOptions.js'; +export * from './interfaces/ITableSorting.js'; +export * from './interfaces/TableEvents.js'; +export * from './interfaces/TableSourceEvents.js'; +export * from './TableSource.js'; +export * from './TableDatasetManager.js'; diff --git a/common-typescript/@dbeaver/table-data/src/interfaces/ITableData.ts b/common-typescript/@dbeaver/table-data/src/interfaces/ITableData.ts new file mode 100644 index 00000000000..0805f796fe9 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/interfaces/ITableData.ts @@ -0,0 +1,18 @@ +import type { EmitterMixin } from 'nanoevents'; +import type { ITableDataset } from './ITableDataset.js'; +import type { ITableSource } from './ITableSource.js'; +import type { TableEvents } from './TableEvents.js'; +import type { ITableSourceOptions } from './ITableSourceOptions.js'; + +export interface ITableData extends EmitterMixin { + readonly id: string; + readonly isLoading: boolean; + readonly isOutdated: boolean; + readonly datasets: ITableDataset[]; + + readonly source: ITableSource; + + setSource(source: ITableSource): void; + + save(): Promise; +} diff --git a/common-typescript/@dbeaver/table-data/src/interfaces/ITableDataset.ts b/common-typescript/@dbeaver/table-data/src/interfaces/ITableDataset.ts new file mode 100644 index 00000000000..7e870f3f366 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/interfaces/ITableDataset.ts @@ -0,0 +1,5 @@ +export interface ITableDataset { + readonly id: string; + readonly columns: TColumn[]; + readonly rows: TValue[][]; +} diff --git a/common-typescript/@dbeaver/table-data/src/interfaces/ITableDatasetManager.ts b/common-typescript/@dbeaver/table-data/src/interfaces/ITableDatasetManager.ts new file mode 100644 index 00000000000..24190460614 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/interfaces/ITableDatasetManager.ts @@ -0,0 +1,7 @@ +import type { ITableDataset } from './ITableDataset.js'; + +export interface ITableDatasetManager { + readonly datasets: ITableDataset[]; + + setDatasets(datasets: ITableDataset[]): void; +} diff --git a/common-typescript/@dbeaver/table-data/src/interfaces/ITableSorting.ts b/common-typescript/@dbeaver/table-data/src/interfaces/ITableSorting.ts new file mode 100644 index 00000000000..d9cb6f98fad --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/interfaces/ITableSorting.ts @@ -0,0 +1,4 @@ +export interface ITableSorting { + column: number; + order: 'asc' | 'desc'; +} diff --git a/common-typescript/@dbeaver/table-data/src/interfaces/ITableSource.ts b/common-typescript/@dbeaver/table-data/src/interfaces/ITableSource.ts new file mode 100644 index 00000000000..9eaa4dc0032 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/interfaces/ITableSource.ts @@ -0,0 +1,18 @@ +import type { EmitterMixin } from 'nanoevents'; +import type { TableSourceEvents } from './TableSourceEvents.js'; +import type { ITableDatasetManager } from './ITableDatasetManager.js'; +import type { ITableSourceOptions } from './ITableSourceOptions.js'; + +export interface ITableSource extends EmitterMixin { + readonly datasetManager: ITableDatasetManager; + readonly options: Partial; + readonly isLoading: boolean; + readonly isOutdated: boolean; + readonly error: Error | null; + + setOptions(options: TOptions): void; + setOutdated(): void; + + save(): Promise; + load(): Promise; +} diff --git a/common-typescript/@dbeaver/table-data/src/interfaces/ITableSourceOptions.ts b/common-typescript/@dbeaver/table-data/src/interfaces/ITableSourceOptions.ts new file mode 100644 index 00000000000..238f5ebe419 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/interfaces/ITableSourceOptions.ts @@ -0,0 +1,5 @@ +import type { ITableSorting } from './ITableSorting.js'; + +export interface ITableSourceOptions { + sorting?: ITableSorting[]; +} diff --git a/common-typescript/@dbeaver/table-data/src/interfaces/TableEvents.ts b/common-typescript/@dbeaver/table-data/src/interfaces/TableEvents.ts new file mode 100644 index 00000000000..35607e8a368 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/interfaces/TableEvents.ts @@ -0,0 +1,3 @@ +import type { TableSourceEvents } from './TableSourceEvents.js'; + +export type TableEvents = TableSourceEvents & {}; diff --git a/common-typescript/@dbeaver/table-data/src/interfaces/TableSourceEvents.ts b/common-typescript/@dbeaver/table-data/src/interfaces/TableSourceEvents.ts new file mode 100644 index 00000000000..7a37d09f3d1 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/interfaces/TableSourceEvents.ts @@ -0,0 +1,7 @@ +export type TableSourceEvents = { + loading: (state: boolean) => void; + outdated: (state: boolean) => void; + saved: () => void; + data: () => void; + error: (error: Error | null) => void; +}; diff --git a/common-typescript/@dbeaver/table-data/tsconfig.json b/common-typescript/@dbeaver/table-data/tsconfig.json new file mode 100644 index 00000000000..b1c6cce93b8 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@dbeaver/tsconfig/tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./lib/.tsbuildinfo", + "rootDir": "src", + "rootDirs": [ + "src" + ], + "outDir": "lib", + "incremental": true, + "composite": true + }, + "include": [ + "src" + ] +} diff --git a/common-typescript/yarn.lock b/common-typescript/yarn.lock index bd51f9761ec..52fe94298c1 100644 --- a/common-typescript/yarn.lock +++ b/common-typescript/yarn.lock @@ -165,6 +165,18 @@ __metadata: languageName: unknown linkType: soft +"@dbeaver/table-data@workspace:@dbeaver/table-data": + version: 0.0.0-use.local + resolution: "@dbeaver/table-data@workspace:@dbeaver/table-data" + dependencies: + "@dbeaver/tsconfig": "workspace:^" + async-mutex: "npm:^0" + nanoevents: "npm:^9" + rimraf: "npm:^6" + typescript: "npm:^5" + languageName: unknown + linkType: soft + "@dbeaver/tsconfig@workspace:@dbeaver/tsconfig, @dbeaver/tsconfig@workspace:^": version: 0.0.0-use.local resolution: "@dbeaver/tsconfig@workspace:@dbeaver/tsconfig" @@ -949,6 +961,15 @@ __metadata: languageName: node linkType: hard +"async-mutex@npm:^0": + version: 0.5.0 + resolution: "async-mutex@npm:0.5.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/9096e6ad6b674c894d8ddd5aa4c512b09bb05931b8746ebd634952b05685608b2b0820ed5c406e6569919ff5fe237ab3c491e6f2887d6da6b6ba906db3ee9c32 + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -2002,6 +2023,13 @@ __metadata: languageName: node linkType: hard +"nanoevents@npm:^9": + version: 9.1.0 + resolution: "nanoevents@npm:9.1.0" + checksum: 10c0/5fb48e6fc1d3102daddaeffd6eada907c25b1c8554dcc648e9cb0a72979a1ab3ee56ffa2d2b3e566a8e561a9e2992a3783493c61cfaaa096b2986d56dbbc1ca5 + languageName: node + linkType: hard + "nanoid@npm:^3.3.8": version: 3.3.11 resolution: "nanoid@npm:3.3.11" @@ -2749,7 +2777,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2, tslib@npm:^2.3.0": +"tslib@npm:^2, tslib@npm:^2.3.0, tslib@npm:^2.4.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 9d53b9e37e3..ed468ea3dbb 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -4528,6 +4528,18 @@ __metadata: languageName: unknown linkType: soft +"@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data": + version: 0.0.0-use.local + resolution: "@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data" + dependencies: + "@dbeaver/tsconfig": "workspace:^" + async-mutex: "npm:^0" + nanoevents: "npm:^9" + rimraf: "npm:^6" + typescript: "npm:^5" + languageName: unknown + linkType: soft + "@dbeaver/tsconfig@workspace:*, @dbeaver/tsconfig@workspace:../common-typescript/@dbeaver/tsconfig, @dbeaver/tsconfig@workspace:^": version: 0.0.0-use.local resolution: "@dbeaver/tsconfig@workspace:../common-typescript/@dbeaver/tsconfig" @@ -8401,6 +8413,15 @@ __metadata: languageName: node linkType: hard +"async-mutex@npm:^0": + version: 0.5.0 + resolution: "async-mutex@npm:0.5.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/9096e6ad6b674c894d8ddd5aa4c512b09bb05931b8746ebd634952b05685608b2b0820ed5c406e6569919ff5fe237ab3c491e6f2887d6da6b6ba906db3ee9c32 + languageName: node + linkType: hard + "async@npm:^3.2.3": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -14598,6 +14619,13 @@ __metadata: languageName: node linkType: hard +"nanoevents@npm:^9": + version: 9.1.0 + resolution: "nanoevents@npm:9.1.0" + checksum: 10c0/5fb48e6fc1d3102daddaeffd6eada907c25b1c8554dcc648e9cb0a72979a1ab3ee56ffa2d2b3e566a8e561a9e2992a3783493c61cfaaa096b2986d56dbbc1ca5 + languageName: node + linkType: hard + "nanoid@npm:^3.3.8": version: 3.3.11 resolution: "nanoid@npm:3.3.11" From 438475a99c30593a022ff58f8b1b8057fb605359 Mon Sep 17 00:00:00 2001 From: Wroud Date: Fri, 30 May 2025 22:19:04 +0800 Subject: [PATCH 2/2] chore: editing draft --- common-react/yarn.lock | 2 + .../@dbeaver/table-data/package.json | 8 +- .../table-data/src/editor/ICellPosition.ts | 5 + .../table-data/src/editor/ITableEditor.ts | 27 ++ .../src/editor/ITableEditorFactory.ts | 5 + .../src/editor/ITableEditorHistory.ts | 38 +++ .../table-data/src/editor/TableEditor.test.ts | 193 +++++++++++++ .../table-data/src/editor/TableEditor.ts | 253 ++++++++++++++++++ .../src/editor/TableEditorEvents.ts | 9 + .../src/editor/TableEditorHistory.ts | 85 ++++++ .../@dbeaver/table-data/src/editor/index.ts | 25 ++ .../@dbeaver/table-data/src/index.ts | 2 + .../@dbeaver/table-data/tsconfig.json | 5 + common-typescript/yarn.lock | 2 + webapp/yarn.lock | 2 + 15 files changed, 659 insertions(+), 2 deletions(-) create mode 100644 common-typescript/@dbeaver/table-data/src/editor/ICellPosition.ts create mode 100644 common-typescript/@dbeaver/table-data/src/editor/ITableEditor.ts create mode 100644 common-typescript/@dbeaver/table-data/src/editor/ITableEditorFactory.ts create mode 100644 common-typescript/@dbeaver/table-data/src/editor/ITableEditorHistory.ts create mode 100644 common-typescript/@dbeaver/table-data/src/editor/TableEditor.test.ts create mode 100644 common-typescript/@dbeaver/table-data/src/editor/TableEditor.ts create mode 100644 common-typescript/@dbeaver/table-data/src/editor/TableEditorEvents.ts create mode 100644 common-typescript/@dbeaver/table-data/src/editor/TableEditorHistory.ts create mode 100644 common-typescript/@dbeaver/table-data/src/editor/index.ts diff --git a/common-react/yarn.lock b/common-react/yarn.lock index 538e01fda2a..bc556f875b2 100644 --- a/common-react/yarn.lock +++ b/common-react/yarn.lock @@ -388,11 +388,13 @@ __metadata: version: 0.0.0-use.local resolution: "@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data" dependencies: + "@dbeaver/cli": "workspace:^" "@dbeaver/tsconfig": "workspace:^" async-mutex: "npm:^0" nanoevents: "npm:^9" rimraf: "npm:^6" typescript: "npm:^5" + vitest: "npm:^3" languageName: unknown linkType: soft diff --git a/common-typescript/@dbeaver/table-data/package.json b/common-typescript/@dbeaver/table-data/package.json index f6e3b873699..6ee75536cd3 100644 --- a/common-typescript/@dbeaver/table-data/package.json +++ b/common-typescript/@dbeaver/table-data/package.json @@ -7,7 +7,9 @@ "./*": "./lib/*.js" }, "scripts": { - "clear": "rimraf lib" + "clear": "rimraf lib", + "build": "tsc -b", + "test": "dbeaver-test" }, "files": [ "package.json", @@ -25,9 +27,11 @@ ], "packageManager": "yarn@4.6.0", "devDependencies": { + "@dbeaver/cli": "workspace:^", "@dbeaver/tsconfig": "workspace:^", "rimraf": "^6", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3" }, "dependencies": { "async-mutex": "^0", diff --git a/common-typescript/@dbeaver/table-data/src/editor/ICellPosition.ts b/common-typescript/@dbeaver/table-data/src/editor/ICellPosition.ts new file mode 100644 index 00000000000..c232d8135a6 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/editor/ICellPosition.ts @@ -0,0 +1,5 @@ +// Cell position types +export interface ICellPosition { + readonly rowIdx: number; + readonly colIdx: number; +} diff --git a/common-typescript/@dbeaver/table-data/src/editor/ITableEditor.ts b/common-typescript/@dbeaver/table-data/src/editor/ITableEditor.ts new file mode 100644 index 00000000000..a36a84e660a --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/editor/ITableEditor.ts @@ -0,0 +1,27 @@ +import type { ICellPosition } from './ICellPosition.js'; +import type { ITableEditorHistory } from './ITableEditorHistory.js'; +import type { TableEditorEventEmitter } from './TableEditorEvents.js'; + +// Main table editor interface +export interface ITableEditor extends TableEditorEventEmitter { + // Data access + readonly data: readonly (readonly TValue[])[]; + readonly isEdited: boolean; + readonly rowCount: number; + + // Cell operations + getCellValue(position: ICellPosition): TValue | undefined; + setCellValue(position: ICellPosition, value: TValue): void; + + // Row operations + insertRow(rowIdx: number, rowData?: TValue[]): void; + deleteRow(rowIdx: number): void; + + // Data operations + resetData(newData: TValue[][]): void; + + // History operations + readonly history: ITableEditorHistory; + undo(): boolean; + redo(): boolean; +} diff --git a/common-typescript/@dbeaver/table-data/src/editor/ITableEditorFactory.ts b/common-typescript/@dbeaver/table-data/src/editor/ITableEditorFactory.ts new file mode 100644 index 00000000000..80523c37e08 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/editor/ITableEditorFactory.ts @@ -0,0 +1,5 @@ +import type { ITableEditor } from './ITableEditor.js'; + +export interface ITableEditorFactory { + create(initialData?: TValue[][]): ITableEditor; +} diff --git a/common-typescript/@dbeaver/table-data/src/editor/ITableEditorHistory.ts b/common-typescript/@dbeaver/table-data/src/editor/ITableEditorHistory.ts new file mode 100644 index 00000000000..1c3655fd2d0 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/editor/ITableEditorHistory.ts @@ -0,0 +1,38 @@ +import type { ICellPosition } from './ICellPosition.js'; + +// History entry types +export interface ITableEditorHistoryEntry { + readonly type: 'cell-edit' | 'row-insert' | 'row-delete'; + readonly timestamp: number; + readonly data: ICellEditEntry | IRowInsertEntry | IRowDeleteEntry; +} + +export interface ICellEditEntry { + readonly position: ICellPosition; + readonly oldValue: TValue; + readonly newValue: TValue; +} + +export interface IRowInsertEntry { + readonly rowIdx: number; + readonly rowData: TValue[]; +} + +export interface IRowDeleteEntry { + readonly rowIdx: number; + readonly rowData: TValue[]; +} + +// History management interface +export interface ITableEditorHistory { + readonly canUndo: boolean; + readonly canRedo: boolean; + readonly size: number; + readonly maxSize: number; + + push(entry: ITableEditorHistoryEntry): void; + undo(): ITableEditorHistoryEntry | null; + redo(): ITableEditorHistoryEntry | null; + clear(): void; + setMaxSize(size: number): void; +} diff --git a/common-typescript/@dbeaver/table-data/src/editor/TableEditor.test.ts b/common-typescript/@dbeaver/table-data/src/editor/TableEditor.test.ts new file mode 100644 index 00000000000..8e13c197fca --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/editor/TableEditor.test.ts @@ -0,0 +1,193 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { describe, expect, test, beforeEach } from 'vitest'; + +import { TableEditor } from './TableEditor.js'; +import type { ICellPosition } from './ICellPosition.js'; + +describe('TableEditor', () => { + let tableEditor: TableEditor; + const initialData = [ + ['A1', 'B1', 'C1'], + ['A2', 'B2', 'C2'], + ['A3', 'B3', 'C3'], + ]; + + beforeEach(() => { + tableEditor = new TableEditor(initialData); + }); + + describe('Basic functionality', () => { + test('should initialize with provided data', () => { + expect(tableEditor.rowCount).toBe(3); + expect(tableEditor.data).toEqual(initialData); + expect(tableEditor.isEdited).toBe(false); + }); + + test('should get cell values correctly', () => { + const position: ICellPosition = { rowIdx: 1, colIdx: 1 }; + expect(tableEditor.getCellValue(position)).toBe('B2'); + }); + + test('should return undefined for invalid positions', () => { + const invalidPosition: ICellPosition = { rowIdx: 10, colIdx: 10 }; + expect(tableEditor.getCellValue(invalidPosition)).toBeUndefined(); + }); + }); + + describe('Cell editing', () => { + test('should set cell values and track changes', () => { + const position: ICellPosition = { rowIdx: 1, colIdx: 1 }; + let changeCount = 0; + + tableEditor.on('data-changed', () => { + changeCount++; + }); + + tableEditor.setCellValue(position, 'NEW_VALUE'); + + expect(tableEditor.getCellValue(position)).toBe('NEW_VALUE'); + expect(tableEditor.isEdited).toBe(true); + expect(changeCount).toBe(1); + }); + + test('should not emit events when setting same value', () => { + const position: ICellPosition = { rowIdx: 1, colIdx: 1 }; + let changeCount = 0; + + tableEditor.on('data-changed', () => { + changeCount++; + }); + + tableEditor.setCellValue(position, 'B2'); // Same value + + expect(changeCount).toBe(0); + expect(tableEditor.isEdited).toBe(false); + }); + + test('should throw error for invalid positions', () => { + const invalidPosition: ICellPosition = { rowIdx: 10, colIdx: 10 }; + + expect(() => { + tableEditor.setCellValue(invalidPosition, 'value'); + }).toThrow(); + }); + }); + + describe('Row operations', () => { + test('should insert rows correctly', () => { + let changeCount = 0; + tableEditor.on('data-changed', () => { + changeCount++; + }); + + tableEditor.insertRow(1, ['X1', 'X2', 'X3']); + + expect(tableEditor.rowCount).toBe(4); + expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('X1'); + expect(tableEditor.getCellValue({ rowIdx: 2, colIdx: 0 })).toBe('A2'); // Shifted down + expect(tableEditor.isEdited).toBe(true); + expect(changeCount).toBe(1); + }); + + test('should delete rows correctly', () => { + let changeCount = 0; + tableEditor.on('data-changed', () => { + changeCount++; + }); + + tableEditor.deleteRow(1); + + expect(tableEditor.rowCount).toBe(2); + expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('A3'); + expect(tableEditor.isEdited).toBe(true); + expect(changeCount).toBe(1); + }); + }); + + describe('History management', () => { + test('should support undo/redo for cell edits', () => { + const position: ICellPosition = { rowIdx: 1, colIdx: 1 }; + + // Make a change + tableEditor.setCellValue(position, 'NEW_VALUE'); + expect(tableEditor.getCellValue(position)).toBe('NEW_VALUE'); + expect(tableEditor.history.canUndo).toBe(true); + expect(tableEditor.history.canRedo).toBe(false); + + // Undo + const undoResult = tableEditor.undo(); + expect(undoResult).toBe(true); + expect(tableEditor.getCellValue(position)).toBe('B2'); + expect(tableEditor.history.canUndo).toBe(false); + expect(tableEditor.history.canRedo).toBe(true); + + // Redo + const redoResult = tableEditor.redo(); + expect(redoResult).toBe(true); + expect(tableEditor.getCellValue(position)).toBe('NEW_VALUE'); + expect(tableEditor.history.canUndo).toBe(true); + expect(tableEditor.history.canRedo).toBe(false); + }); + + test('should support undo/redo for row operations', () => { + // Insert row + tableEditor.insertRow(1, ['X1', 'X2', 'X3']); + expect(tableEditor.rowCount).toBe(4); + + // Undo insert + tableEditor.undo(); + expect(tableEditor.rowCount).toBe(3); + expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('A2'); + + // Redo insert + tableEditor.redo(); + expect(tableEditor.rowCount).toBe(4); + expect(tableEditor.getCellValue({ rowIdx: 1, colIdx: 0 })).toBe('X1'); + }); + + test('should not fire change notifications for undo/redo operations', () => { + let changeCount = 0; + tableEditor.on('data-changed', () => { + changeCount++; + }); + + const position: ICellPosition = { rowIdx: 1, colIdx: 1 }; + + // Make a change - should fire change notification + tableEditor.setCellValue(position, 'NEW_VALUE'); + expect(changeCount).toBe(1); + + // Undo - should NOT fire change notification + tableEditor.undo(); + expect(changeCount).toBe(1); + + // Redo - should NOT fire change notification + tableEditor.redo(); + expect(changeCount).toBe(1); + }); + }); + + describe('Data reset', () => { + test('should reset data and clear history', () => { + // Make some changes + tableEditor.setCellValue({ rowIdx: 0, colIdx: 0 }, 'CHANGED'); + expect(tableEditor.isEdited).toBe(true); + expect(tableEditor.history.canUndo).toBe(true); + + // Reset data + const newData = [['Z1', 'Z2'], ['Z3', 'Z4']]; + tableEditor.resetData(newData); + + expect(tableEditor.data).toEqual(newData); + expect(tableEditor.rowCount).toBe(2); + expect(tableEditor.isEdited).toBe(false); + expect(tableEditor.history.canUndo).toBe(false); + }); + }); +}); diff --git a/common-typescript/@dbeaver/table-data/src/editor/TableEditor.ts b/common-typescript/@dbeaver/table-data/src/editor/TableEditor.ts new file mode 100644 index 00000000000..51cb7068566 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/editor/TableEditor.ts @@ -0,0 +1,253 @@ +import { createNanoEvents, type Emitter, type Unsubscribe } from 'nanoevents'; +import type { ITableEditor } from './ITableEditor.js'; +import type { ICellPosition } from './ICellPosition.js'; +import type { ITableEditorHistory, ITableEditorHistoryEntry } from './ITableEditorHistory.js'; +import type { TableEditorEvents } from './TableEditorEvents.js'; +import { TableEditorHistory } from './TableEditorHistory.js'; + +export class TableEditor implements ITableEditor { + private readonly _data: TValue[][]; + private readonly _history: ITableEditorHistory; + private readonly _emitter: Emitter>; + private _isEdited = false; + + constructor(initialData: TValue[][] = [], historyMaxSize = 100) { + this._data = initialData.map(row => [...row]); + this._history = new TableEditorHistory(historyMaxSize); + this._emitter = createNanoEvents(); + } + + get data(): readonly (readonly TValue[])[] { + return this._data.map(row => Object.freeze([...row])); + } + + get isEdited(): boolean { + return this._isEdited; + } + + get rowCount(): number { + return this._data.length; + } + + get history(): ITableEditorHistory { + return this._history; + } + + getCellValue(position: ICellPosition): TValue | undefined { + if (!this.isValidPosition(position)) { + return undefined; + } + return this._data[position.rowIdx]?.[position.colIdx]; + } + + setCellValue(position: ICellPosition, value: TValue): void { + if (!this.isValidPosition(position)) { + throw new Error(`Invalid cell position: row ${position.rowIdx}, column ${position.colIdx}`); + } + + const row = this._data[position.rowIdx]; + if (!row) { + throw new Error(`Row ${position.rowIdx} does not exist`); + } + + const oldValue = row[position.colIdx]; + + if (oldValue === value) { + return; + } + + const historyEntry: ITableEditorHistoryEntry = { + type: 'cell-edit', + timestamp: Date.now(), + data: { + position: { ...position }, + oldValue: oldValue as TValue, + newValue: value, + }, + }; + this._history.push(historyEntry); + + row[position.colIdx] = value; + + this._isEdited = true; + + this.emit('data-changed'); + this.emitHistoryChanged(); + } + + insertRow(rowIdx: number, rowData?: TValue[]): void { + if (rowIdx < 0 || rowIdx > this._data.length) { + throw new Error(`Invalid row index: ${rowIdx}. Must be between 0 and ${this._data.length}`); + } + + const newRowData = rowData ? [...rowData] : []; + + if (this._data.length > 0 && newRowData.length === 0) { + const columnCount = Math.max(...this._data.map(row => row.length)); + newRowData.length = columnCount; + newRowData.fill(undefined as any); + } + + const historyEntry: ITableEditorHistoryEntry = { + type: 'row-insert', + timestamp: Date.now(), + data: { + rowIdx, + rowData: [...newRowData], + }, + }; + this._history.push(historyEntry); + + this._data.splice(rowIdx, 0, newRowData); + + this._isEdited = true; + + this.emit('data-changed'); + this.emitHistoryChanged(); + } + + deleteRow(rowIdx: number): void { + if (rowIdx < 0 || rowIdx >= this._data.length) { + throw new Error(`Invalid row index: ${rowIdx}. Must be between 0 and ${this._data.length - 1}`); + } + + const row = this._data[rowIdx]; + if (!row) { + throw new Error(`Row ${rowIdx} does not exist`); + } + + const deletedRowData = [...row]; + + const historyEntry: ITableEditorHistoryEntry = { + type: 'row-delete', + timestamp: Date.now(), + data: { + rowIdx, + rowData: deletedRowData, + }, + }; + this._history.push(historyEntry); + + this._data.splice(rowIdx, 1); + + this._isEdited = true; + + this.emit('data-changed'); + this.emitHistoryChanged(); + } + + resetData(newData: TValue[][]): void { + this._data.length = 0; + + newData.forEach(row => { + this._data.push([...row]); + }); + + this._history.clear(); + this._isEdited = false; + + this.emit('data-reset', newData); + this.emitHistoryChanged(); + } + + undo(): boolean { + const entry = this._history.undo(); + if (!entry) { + return false; + } + + this.applyHistoryEntryReverse(entry); + this.emitHistoryChanged(); + return true; + } + + redo(): boolean { + const entry = this._history.redo(); + if (!entry) { + return false; + } + + this.applyHistoryEntry(entry); + this.emitHistoryChanged(); + return true; + } + + on>( + event: TEvent, + listener: TableEditorEvents[TEvent] + ): Unsubscribe { + return this._emitter.on(event, listener); + } + + emit>( + event: TEvent, + ...args: Parameters[TEvent]> + ): void { + this._emitter.emit(event, ...args); + } + + private isValidPosition(position: ICellPosition): boolean { + return ( + position.rowIdx >= 0 && + position.rowIdx < this._data.length && + position.colIdx >= 0 && + position.colIdx < (this._data[position.rowIdx]?.length || 0) + ); + } + + private emitHistoryChanged(): void { + this.emit('history-changed', this._history.canUndo, this._history.canRedo); + } + + private applyHistoryEntry(entry: ITableEditorHistoryEntry): void { + switch (entry.type) { + case 'cell-edit': { + const data = entry.data as any; + const { position, newValue } = data; + const row = this._data[position.rowIdx]; + if (row) { + row[position.colIdx] = newValue; + } + break; + } + case 'row-insert': { + const data = entry.data as any; + const { rowIdx, rowData } = data; + this._data.splice(rowIdx, 0, [...rowData]); + break; + } + case 'row-delete': { + const data = entry.data as any; + const { rowIdx } = data; + this._data.splice(rowIdx, 1); + break; + } + } + } + + private applyHistoryEntryReverse(entry: ITableEditorHistoryEntry): void { + switch (entry.type) { + case 'cell-edit': { + const data = entry.data as any; + const { position, oldValue } = data; + const row = this._data[position.rowIdx]; + if (row) { + row[position.colIdx] = oldValue; + } + break; + } + case 'row-insert': { + const data = entry.data as any; + const { rowIdx } = data; + this._data.splice(rowIdx, 1); + break; + } + case 'row-delete': { + const data = entry.data as any; + const { rowIdx, rowData } = data; + this._data.splice(rowIdx, 0, [...rowData]); + break; + } + } + } +} diff --git a/common-typescript/@dbeaver/table-data/src/editor/TableEditorEvents.ts b/common-typescript/@dbeaver/table-data/src/editor/TableEditorEvents.ts new file mode 100644 index 00000000000..8e9ef9f54d9 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/editor/TableEditorEvents.ts @@ -0,0 +1,9 @@ +import type { EmitterMixin } from 'nanoevents'; + +export interface TableEditorEvents { + 'data-changed': () => void; + 'data-reset': (newData: TValue[][]) => void; + 'history-changed': (canUndo: boolean, canRedo: boolean) => void; +} + +export type TableEditorEventEmitter = EmitterMixin>; diff --git a/common-typescript/@dbeaver/table-data/src/editor/TableEditorHistory.ts b/common-typescript/@dbeaver/table-data/src/editor/TableEditorHistory.ts new file mode 100644 index 00000000000..5f139a22086 --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/editor/TableEditorHistory.ts @@ -0,0 +1,85 @@ +import type { ITableEditorHistory, ITableEditorHistoryEntry } from './ITableEditorHistory.js'; + +/** + * Implementation of table editor history with undo/redo functionality. + * Maintains a circular buffer of history entries with a configurable maximum size. + */ +export class TableEditorHistory implements ITableEditorHistory { + private entries: ITableEditorHistoryEntry[] = []; + private currentIndex: number = -1; + private _maxSize: number; + + constructor(maxSize: number = 100) { + this._maxSize = Math.max(1, maxSize); + } + + get canUndo(): boolean { + return this.currentIndex >= 0; + } + + get canRedo(): boolean { + return this.currentIndex < this.entries.length - 1; + } + + get size(): number { + return this.entries.length; + } + + get maxSize(): number { + return this._maxSize; + } + + push(entry: ITableEditorHistoryEntry): void { + // Remove any entries after the current index (when pushing after undo) + if (this.currentIndex < this.entries.length - 1) { + this.entries = this.entries.slice(0, this.currentIndex + 1); + } + + // Add the new entry + this.entries.push(entry); + this.currentIndex = this.entries.length - 1; + + // Maintain max size by removing oldest entries + if (this.entries.length > this._maxSize) { + const removeCount = this.entries.length - this._maxSize; + this.entries = this.entries.slice(removeCount); + this.currentIndex -= removeCount; + } + } + + undo(): ITableEditorHistoryEntry | null { + if (!this.canUndo) { + return null; + } + + const entry = this.entries[this.currentIndex]; + this.currentIndex--; + return entry ?? null; + } + + redo(): ITableEditorHistoryEntry | null { + if (!this.canRedo) { + return null; + } + + this.currentIndex++; + const entry = this.entries[this.currentIndex]; + return entry ?? null; + } + + clear(): void { + this.entries = []; + this.currentIndex = -1; + } + + setMaxSize(size: number): void { + this._maxSize = Math.max(1, size); + + // Trim entries if the new max size is smaller + if (this.entries.length > this._maxSize) { + const removeCount = this.entries.length - this._maxSize; + this.entries = this.entries.slice(removeCount); + this.currentIndex = Math.max(-1, this.currentIndex - removeCount); + } + } +} diff --git a/common-typescript/@dbeaver/table-data/src/editor/index.ts b/common-typescript/@dbeaver/table-data/src/editor/index.ts new file mode 100644 index 00000000000..4bdb3a111da --- /dev/null +++ b/common-typescript/@dbeaver/table-data/src/editor/index.ts @@ -0,0 +1,25 @@ +// Main interfaces +export type { ITableEditor } from './ITableEditor.js'; +export type { ICellPosition } from './ICellPosition.js'; +export type { ITableEditorFactory } from './ITableEditorFactory.js'; + +// History interfaces +export type { + ITableEditorHistory, + ITableEditorHistoryEntry, + ICellEditEntry, + IRowInsertEntry, + IRowDeleteEntry, +} from './ITableEditorHistory.js'; + +// History implementation +export { TableEditorHistory } from './TableEditorHistory.js'; + +// Table editor implementation +export { TableEditor } from './TableEditor.js'; + +// Event interfaces +export type { + TableEditorEvents, + TableEditorEventEmitter, +} from './TableEditorEvents.js'; diff --git a/common-typescript/@dbeaver/table-data/src/index.ts b/common-typescript/@dbeaver/table-data/src/index.ts index 87d0589e90d..8b6a40dbf6d 100644 --- a/common-typescript/@dbeaver/table-data/src/index.ts +++ b/common-typescript/@dbeaver/table-data/src/index.ts @@ -1,3 +1,4 @@ +export * from './editor/TableEditor.js'; export * from './interfaces/ITableData.js'; export * from './interfaces/ITableDataset.js'; export * from './interfaces/ITableDatasetManager.js'; @@ -8,3 +9,4 @@ export * from './interfaces/TableEvents.js'; export * from './interfaces/TableSourceEvents.js'; export * from './TableSource.js'; export * from './TableDatasetManager.js'; +export * from './editor/index.js'; diff --git a/common-typescript/@dbeaver/table-data/tsconfig.json b/common-typescript/@dbeaver/table-data/tsconfig.json index b1c6cce93b8..569071c38b3 100644 --- a/common-typescript/@dbeaver/table-data/tsconfig.json +++ b/common-typescript/@dbeaver/table-data/tsconfig.json @@ -12,5 +12,10 @@ }, "include": [ "src" + ], + "references": [ + { + "path": "../cli" + } ] } diff --git a/common-typescript/yarn.lock b/common-typescript/yarn.lock index 52fe94298c1..69375137c11 100644 --- a/common-typescript/yarn.lock +++ b/common-typescript/yarn.lock @@ -169,11 +169,13 @@ __metadata: version: 0.0.0-use.local resolution: "@dbeaver/table-data@workspace:@dbeaver/table-data" dependencies: + "@dbeaver/cli": "workspace:^" "@dbeaver/tsconfig": "workspace:^" async-mutex: "npm:^0" nanoevents: "npm:^9" rimraf: "npm:^6" typescript: "npm:^5" + vitest: "npm:^3" languageName: unknown linkType: soft diff --git a/webapp/yarn.lock b/webapp/yarn.lock index ed468ea3dbb..519b0e5349e 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -4532,11 +4532,13 @@ __metadata: version: 0.0.0-use.local resolution: "@dbeaver/table-data@workspace:../common-typescript/@dbeaver/table-data" dependencies: + "@dbeaver/cli": "workspace:^" "@dbeaver/tsconfig": "workspace:^" async-mutex: "npm:^0" nanoevents: "npm:^9" rimraf: "npm:^6" typescript: "npm:^5" + vitest: "npm:^3" languageName: unknown linkType: soft