diff --git a/webapp/packages/plugin-data-viewer-result-trace-details/src/DVResultTraceDetailsBootstrap.ts b/webapp/packages/plugin-data-viewer-result-trace-details/src/DVResultTraceDetailsBootstrap.ts index c90a984159e..645a562efa2 100644 --- a/webapp/packages/plugin-data-viewer-result-trace-details/src/DVResultTraceDetailsBootstrap.ts +++ b/webapp/packages/plugin-data-viewer-result-trace-details/src/DVResultTraceDetailsBootstrap.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ export class DVResultTraceDetailsBootstrap extends Bootstrap { super(); } - override register() { + override register(): void { this.dataPresentationService.add({ id: 'result-trace-details-presentation', type: DataPresentationType.toolsPanel, @@ -32,7 +32,7 @@ export class DVResultTraceDetailsBootstrap extends Bootstrap { if (!isResultSetDataSource(source)) { return true; } - const result = (source as ResultSetDataSource).getResult(resultIndex); + const result = (source as ResultSetDataSource).getResult(resultIndex); return !result?.data?.hasDynamicTrace; }, getPresentationComponent: () => DVResultTraceDetailsPresentation, diff --git a/webapp/packages/plugin-data-viewer/src/DataViewerPage/DataViewerPanel.tsx b/webapp/packages/plugin-data-viewer/src/DataViewerPage/DataViewerPanel.tsx index 29496f0829e..e1a7f07f3ad 100644 --- a/webapp/packages/plugin-data-viewer/src/DataViewerPage/DataViewerPanel.tsx +++ b/webapp/packages/plugin-data-viewer/src/DataViewerPage/DataViewerPanel.tsx @@ -30,6 +30,7 @@ export const DataViewerPanel: ObjectPagePanelComponent = o presentationId, resultIndex: 0, valuePresentationId: null, + persistedState: {}, }); } else { pageState.presentationId = presentationId; @@ -47,6 +48,7 @@ export const DataViewerPanel: ObjectPagePanelComponent = o presentationId: '', resultIndex: 0, valuePresentationId, + persistedState: {}, }); } else { pageState.valuePresentationId = valuePresentationId; diff --git a/webapp/packages/plugin-data-viewer/src/DataViewerPage/useDataViewerPanel.ts b/webapp/packages/plugin-data-viewer/src/DataViewerPage/useDataViewerPanel.ts index 21991950f36..ffb443fd956 100644 --- a/webapp/packages/plugin-data-viewer/src/DataViewerPage/useDataViewerPanel.ts +++ b/webapp/packages/plugin-data-viewer/src/DataViewerPage/useDataViewerPanel.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -57,18 +57,29 @@ export function useDataViewerPanel(tab: ITab) { model = dataViewerTableService.create(connectionInfo, node); tab.handlerState.tableId = model.id; - model.source.setOutdated(); - dataViewerDataChangeConfirmationService.trackTableDataUpdate(model.id); - const pageState = dataViewerTabService.page.getState(tab); + let pageState = dataViewerTabService.page.getState(tab); + + if (!pageState) { + dataViewerTabService.page.setState(tab, { + resultIndex: 0, + presentationId: '', + valuePresentationId: null, + persistedState: {}, + }); + pageState = dataViewerTabService.page.getState(tab)!; + } + + model.source.loadPersistedState(pageState.persistedState); - if (pageState) { - const presentation = dataPresentationService.get(pageState.presentationId); + const presentation = dataPresentationService.get(pageState.presentationId); - if (presentation?.dataFormat !== undefined) { - model.setDataFormat(presentation.dataFormat); - } + if (presentation?.dataFormat !== undefined) { + model.setDataFormat(presentation.dataFormat); } + + model.source.setOutdated(); + dataViewerDataChangeConfirmationService.trackTableDataUpdate(model.id); } if (node?.name) { diff --git a/webapp/packages/plugin-data-viewer/src/DataViewerTabService.ts b/webapp/packages/plugin-data-viewer/src/DataViewerTabService.ts index 7ae1f4304d3..37c8821ca3f 100644 --- a/webapp/packages/plugin-data-viewer/src/DataViewerTabService.ts +++ b/webapp/packages/plugin-data-viewer/src/DataViewerTabService.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -111,7 +111,12 @@ export class DataViewerTabService { await initTab(); if (tabInfo.isNewlyCreated) { - trySwitchPage(this.page); + trySwitchPage(this.page, { + resultIndex: 0, + presentationId: '', + valuePresentationId: null, + persistedState: {}, + }); } } catch (exception: any) { this.notificationService.logException(exception, 'Data Editor Error', 'Error in Data Editor while processing action with database node'); diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/DatabaseDataConstraintAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/DatabaseDataConstraintAction.ts index 3ea37e12d36..9008430c16c 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/DatabaseDataConstraintAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/DatabaseDataConstraintAction.ts @@ -5,9 +5,9 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { computed, makeObservable } from 'mobx'; +import { action, computed, makeObservable, runInAction } from 'mobx'; -import { type DataTypeLogicalOperation, ResultDataFormat, type SqlDataFilterConstraint } from '@cloudbeaver/core-sdk'; +import { type DataTypeLogicalOperation, ResultDataFormat, type SqlDataFilterConstraint, type SqlResultColumn } from '@cloudbeaver/core-sdk'; import { DatabaseDataAction } from '../DatabaseDataAction.js'; import type { IDatabaseDataOptions } from '../IDatabaseDataOptions.js'; @@ -21,10 +21,47 @@ import { IDatabaseDataResult } from '../IDatabaseDataResult.js'; export const IS_NULL_ID = 'IS_NULL'; export const IS_NOT_NULL_ID = 'IS_NOT_NULL'; +const CONSTRAINTS_KEY = 'constraints'; +const WHERE_FILTER_KEY = 'whereFilter'; + +export function persistDataFilterConstraints( + source: IDatabaseDataSource, +): void { + const options = source.options; + if (!options) { + return; + } + + source.persistedState.set(CONSTRAINTS_KEY, options.constraints.filter(hasConstraintIdentity)); + source.persistedState.set(WHERE_FILTER_KEY, options.whereFilter || ''); +} + +export function applyPersistedDataFilterConstraints( + source: IDatabaseDataSource, +): void { + const options = source.options; + if (!options) { + return; + } + + const constraints = source.persistedState.get(CONSTRAINTS_KEY); + const whereFilter = source.persistedState.get(WHERE_FILTER_KEY); + + if (!Array.isArray(constraints) || typeof whereFilter !== 'string') { + return; + } + + runInAction(() => { + options.constraints = constraints.map(constraint => ({ ...constraint })); + options.whereFilter = whereFilter; + }); +} + @injectable(() => [IDatabaseDataSource, IDatabaseDataResult]) export class DatabaseDataConstraintAction extends DatabaseDataAction - implements IDatabaseDataConstraintAction { + implements IDatabaseDataConstraintAction +{ static dataFormat = [ResultDataFormat.Resultset, ResultDataFormat.Document]; get supported(): boolean { @@ -54,6 +91,16 @@ export class DatabaseDataConstraintAction makeObservable(this, { orderConstraints: computed, filterConstraints: computed, + deleteAll: action, + deleteFilter: action, + deleteFilters: action, + deleteOrders: action, + deleteOrder: action, + deleteDataFilters: action, + deleteData: action, + setWhereFilter: action, + setFilter: action, + setOrder: action, }); } @@ -161,7 +208,7 @@ export class DatabaseDataConstraintAction this.resetWhereFilter(); } - setWhereFilter(value: string) { + setWhereFilter(value: string): void { if (!this.source.options) { throw new Error('Options must be provided'); } @@ -169,11 +216,11 @@ export class DatabaseDataConstraintAction this.source.options.whereFilter = value; } - resetWhereFilter() { + resetWhereFilter(): void { this.setWhereFilter(''); } - setFilter(attributePosition: number, operator: string, value?: any): void { + setFilter(attributePosition: number, operator: string, value?: unknown): void { if (!this.source.options) { throw new Error('Options must be provided'); } @@ -182,6 +229,7 @@ export class DatabaseDataConstraintAction if (currentConstraint) { currentConstraint.operator = operator; + currentConstraint.attributeName = this.getColumnNameAt(attributePosition); if (value !== undefined) { currentConstraint.value = value; } else if (currentConstraint.value !== undefined) { @@ -192,6 +240,7 @@ export class DatabaseDataConstraintAction const constraint: SqlDataFilterConstraint = { attributePosition, + attributeName: this.getColumnNameAt(attributePosition), operator, }; @@ -219,6 +268,7 @@ export class DatabaseDataConstraintAction if (!resetOrder) { this.source.options.constraints.push({ attributePosition, + attributeName: this.getColumnNameAt(attributePosition), orderPosition: this.getMaxOrderPosition(), orderAsc: order === EOrder.asc, }); @@ -257,6 +307,10 @@ export class DatabaseDataConstraintAction override updateResult(result: IDatabaseResultSet): void { updateConstraintsForResult(this.source, result); } + + private getColumnNameAt(colIdx: number): string | undefined { + return this.result.data?.columns?.find(c => c.position === colIdx)?.name; + } } function updateConstraintsForResult(source: IDatabaseDataSource, result: IDatabaseResultSet) { @@ -264,31 +318,66 @@ function updateConstraintsForResult(source: IDatabaseDataSource column.position === constraint.attributePosition); + const columns = result.data?.columns ?? []; - if (!prevColumn) { - return; - } + if (columns.length === 0) { + return; + } - let column = result.data?.columns?.find(column => column.position === prevColumn.position); + runInAction(() => { + for (const constraint of source.options!.constraints) { + if (!hasConstraintIdentity(constraint)) { + resetDataFilterState(source); + return; + } - if (!column || column.label !== prevColumn.label) { - column = result.data?.columns?.find(column => column.label === prevColumn.label); - } + const initialPosition = constraint.attributePosition; + const initialName = constraint.attributeName; + + const resolvedColumn = resolveConstraintColumn(columns, initialName, initialPosition); + + if (!resolvedColumn) { + resetDataFilterState(source); + return; + } + + constraint.attributeName = resolvedColumn.name; + constraint.attributePosition = resolvedColumn.position; - if (column && prevColumn.position !== column.position) { const prevConstraint = source.prevOptions?.constraints.find( - prevConstraint => prevConstraint.attributePosition === constraint.attributePosition, + prevConstraint => prevConstraint.attributePosition === initialPosition && prevConstraint.attributeName === initialName, ); - constraint.attributePosition = column.position; - if (prevConstraint) { + prevConstraint.attributeName = constraint.attributeName; prevConstraint.attributePosition = constraint.attributePosition; } } - } + }); +} + +function resetDataFilterState(source: IDatabaseDataSource): void { + source.options!.constraints = []; + source.options!.whereFilter = ''; + source.persistedState.delete(CONSTRAINTS_KEY); + source.persistedState.delete(WHERE_FILTER_KEY); +} + +function resolveConstraintColumn( + columns: SqlResultColumn[], + attributeName: string, + attributePosition: number, +): (SqlResultColumn & { name: string; position: number }) | undefined { + return columns.find( + (column): column is SqlResultColumn & { name: string; position: number } => + typeof column.name === 'string' && typeof column.position === 'number' && column.position === attributePosition && column.name === attributeName, + ); +} + +function hasConstraintIdentity( + constraint: SqlDataFilterConstraint, +): constraint is SqlDataFilterConstraint & { attributeName: string; attributePosition: number } { + return typeof constraint.attributeName === 'string' && constraint.attributeName.length > 0 && typeof constraint.attributePosition === 'number'; } export function nullOperationsFilter(operation: DataTypeLogicalOperation): boolean { @@ -306,12 +395,12 @@ export function getNextOrder(order: Order): Order { } } -export function wrapOperationArgument(operationId: string, argument: any): string { +export function wrapOperationArgument(operationId: string, argument: unknown): string { if (operationId === 'LIKE') { return `%${argument}%`; } - return argument; + return String(argument); } export function isFilterConstraint(constraint: SqlDataFilterConstraint): boolean { diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridDataResultAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridDataResultAction.ts index 258e366147e..1ec4b0722e6 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridDataResultAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridDataResultAction.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridViewAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridViewAction.ts index 444ef53c9b3..437e9e6eca8 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridViewAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/Grid/GridViewAction.ts @@ -5,8 +5,10 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { action, computed, makeObservable, observable, ObservableSet } from 'mobx'; +import { action, computed, makeObservable } from 'mobx'; +import { isArraysEqual } from '@cloudbeaver/core-utils'; +import { isDefined } from '@dbeaver/js-helpers'; import { DatabaseDataAction } from '../../DatabaseDataAction.js'; import { IDatabaseDataSource } from '../../IDatabaseDataSource.js'; import type { IGridColumnKey, IGridDataKey, IGridRowKey } from './IGridDataKey.js'; @@ -22,6 +24,34 @@ import { IDatabaseDataResultAction } from '../IDatabaseDataResultAction.js'; import { IDatabaseDataEditAction } from '../IDatabaseDataEditAction.js'; import type { IDatabaseValueHolder } from '../IDatabaseValueHolder.js'; +const PINNED_COLUMNS_KEY = 'pinnedColumns'; +const COLUMN_ORDER_KEY = 'columnOrder'; + +interface IColumnRef { + name: string; + position: number; +} + +function isColumnRef(value: unknown): value is IColumnRef { + return ( + typeof value === 'object' && + value !== null && + 'name' in value && + 'position' in value && + typeof value.name === 'string' && + value.name.length > 0 && + typeof value.position === 'number' + ); +} + +function isSameRef(left: IColumnRef, right: IColumnRef): boolean { + return left.name === right.name && left.position === right.position; +} + +function refKey(ref: IColumnRef): string { + return `${ref.name}\0${ref.position}`; +} + @injectable(() => [IDatabaseDataSource, IDatabaseDataResult, IDatabaseDataResultAction, IDatabaseDataEditAction]) export class GridViewAction< TColumn = unknown, @@ -51,8 +81,25 @@ export class GridViewAction< return this.columnKeys.map(this.mapColumn, this); } - private columnsOrder: number[]; - readonly pinnedColumns: ObservableSet; + get columnsOrder(): number[] { + const stored = this.readRefs(COLUMN_ORDER_KEY); + if (stored.length === 0) { + return this.defaultOrder; + } + return this.resolveRefs(stored) ?? this.defaultOrder; + } + + get pinnedColumns(): ReadonlySet { + const resolved = this.resolveRefs(this.readRefs(PINNED_COLUMNS_KEY)); + const pinned = new Set(); + if (resolved) { + for (const index of resolved) { + pinned.add(GridDataKeysUtils.serialize({ index })); + } + } + return pinned; + } + protected readonly data: GridDataResultAction; protected readonly editor?: GridEditAction; @@ -65,12 +112,10 @@ export class GridViewAction< super(source, result); this.data = data as GridDataResultAction; this.editor = editor as GridEditAction | undefined; - this.columnsOrder = this.data.columns.map((key, index) => index); - this.pinnedColumns = observable.set(); - makeObservable(this, { - columnsOrder: observable, - pinnedColumns: observable, + makeObservable(this, { + columnsOrder: computed, + pinnedColumns: computed, setColumnOrder: action, pinColumns: action, unpinColumns: action, @@ -103,14 +148,24 @@ export class GridViewAction< } setColumnOrder(key: IGridColumnKey, index: number): void { - const columnIndex = this.columnDataIndex(key); + const ref = this.getColumnRef(key); + if (!ref) { + return; + } - if (columnIndex === -1) { + const current = this.columnKeys.map(k => this.getColumnRef(k)).filter(isDefined); + const from = current.findIndex(r => isSameRef(r, ref)); + if (from === -1) { return; } - this.columnsOrder.splice(this.columnsOrder.indexOf(columnIndex), 1); - this.columnsOrder.splice(index, 0, columnIndex); + current.splice(from, 1); + current.splice(index, 0, ref); + + const defaults = this.data.columns.map((_, i) => this.getColumnRef({ index: i })).filter(isDefined); + const isDefault = isArraysEqual(current, defaults, isSameRef, true); + + this.source.persistedState.set(COLUMN_ORDER_KEY, isDefault ? [] : current); } columnIndex(key: IGridColumnKey): number { @@ -179,32 +234,50 @@ export class GridViewAction< } pinColumns(keys: IGridColumnKey[]): void { - for (const key of keys) { - const serializedKey = GridDataKeysUtils.serialize(key); - this.pinnedColumns.add(serializedKey); - } + this.mutatePinned(columns => { + for (const key of keys) { + const ref = this.getColumnRef(key); + if (ref && !columns.some(r => isSameRef(r, ref))) { + columns.push(ref); + } + } + }); } unpinColumns(keys: IGridColumnKey[]): void { - for (const key of keys) { - const serializedKey = GridDataKeysUtils.serialize(key); - this.pinnedColumns.delete(serializedKey); - } + this.mutatePinned(columns => { + for (const key of keys) { + const ref = this.getColumnRef(key); + if (!ref) { + continue; + } + const idx = columns.findIndex(r => isSameRef(r, ref)); + if (idx !== -1) { + columns.splice(idx, 1); + } + } + }); } unpinAllColumns(): void { - this.pinnedColumns.clear(); + this.source.persistedState.set(PINNED_COLUMNS_KEY, []); } isColumnPinned(key: IGridColumnKey): boolean { - const serializedKey = GridDataKeysUtils.serialize(key); - return this.pinnedColumns.has(serializedKey); + return this.pinnedColumns.has(GridDataKeysUtils.serialize(key)); } hasPinnedColumns(): boolean { return this.pinnedColumns.size > 0; } + getPinnedColumnNames(): string[] { + return this.columnKeys + .filter(key => this.isColumnPinned(key)) + .map(key => this.getColumnName(key)) + .filter(isDefined); + } + protected mapRow(row: IGridRowKey): TCell[] { const edited = this.editor?.getRow(row); @@ -228,8 +301,83 @@ export class GridViewAction< override updateResult(result: TResult, index: number): void { super.updateResult(result, index); - if (this.columnsOrder.length !== this.data.columns.length) { - this.columnsOrder = this.data.columns.map((key, index) => index); + + for (const key of [COLUMN_ORDER_KEY, PINNED_COLUMNS_KEY]) { + const stored = this.readRefs(key); + if (stored.length > 0 && this.resolveRefs(stored) === null) { + this.source.persistedState.set(key, []); + } + } + } + + private get defaultOrder(): number[] { + return this.data.columns.map((_, i) => i); + } + + private getColumnRef(key: IGridColumnKey): IColumnRef | undefined { + const name = this.getColumnName(key); + const column = this.getColumn(key); + + if (!name || typeof column !== 'object' || column === null || !('position' in column) || typeof column.position !== 'number') { + return undefined; + } + + return { name, position: column.position }; + } + + private readRefs(key: string): IColumnRef[] { + const stored = this.source.persistedState.get(key); + if (!Array.isArray(stored)) { + return []; + } + return stored.filter(isColumnRef); + } + + private mutatePinned(mutate: (columns: IColumnRef[]) => void): void { + const columns = this.columnKeys + .filter(key => this.isColumnPinned(key)) + .map(key => this.getColumnRef(key)) + .filter(isDefined); + mutate(columns); + this.source.persistedState.set(PINNED_COLUMNS_KEY, columns); + } + + private resolveRefs(refs: IColumnRef[]): number[] | null { + if (refs.length === 0) { + return []; + } + + const byKey = new Map(); + for (let i = 0; i < this.data.columns.length; i++) { + const current = this.getColumnRef({ index: i }); + if (!current) { + continue; + } + const k = refKey(current); + const list = byKey.get(k); + if (list) { + list.push(i); + } else { + byKey.set(k, [i]); + } } + + const resolved: number[] = []; + const used = new Set(); + + for (const ref of refs) { + const candidates = byKey.get(refKey(ref)); + if (!candidates) { + return null; + } + const available = candidates.filter(i => !used.has(i)); + if (available.length !== 1) { + return null; + } + resolved.push(available[0]!); + used.add(available[0]!); + } + + return resolved; } } diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataAction.ts index 0b861cc3793..4e690df297b 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataAction.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataSource.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataSource.ts index af9b9ddb83c..8c49420192f 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataSource.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataSource.ts @@ -11,6 +11,7 @@ import { withExternal, type IServiceProvider, type IServiceScope, type SingleSer import { Executor, ExecutorInterrupter, type IExecutor, type ISyncExecutor, type ITask, SyncExecutor, Task } from '@cloudbeaver/core-executor'; import { ResultDataFormat } from '@cloudbeaver/core-sdk'; +import { DatabasePersistedStateStore } from './DatabasePersistedStateStore.js'; import { IDatabaseDataActions } from './IDatabaseDataActions.js'; import type { IDatabaseDataResult } from './IDatabaseDataResult.js'; import { @@ -32,7 +33,8 @@ export abstract class DatabaseDataSource | null; - options: TOptions | null; + protected _options: TOptions | null; + readonly persistedState: DatabasePersistedStateStore; requestInfo: IRequestInfo; error: Error | null; private readonly features: Set; @@ -53,6 +55,10 @@ export abstract class DatabaseDataSource Promise; @@ -78,7 +84,8 @@ export abstract class DatabaseDataSource, 'disabled' | 'features' | 'activeOperationStack' | 'outdated'>(this, { + makeObservable, 'disabled' | 'features' | 'activeOperationStack' | 'outdated' | '_options'>(this, { access: observable, dataFormat: observable, features: observable, @@ -107,7 +114,7 @@ export abstract class DatabaseDataSource): this { + this.persistedState.setStore(state); + this.onPersistedStateLoaded(); return this; } + protected onPersistedStateLoaded(): void {} + setDataFormat(dataFormat: ResultDataFormat): this { this.dataFormat = dataFormat; return this; @@ -300,7 +315,7 @@ export abstract class DatabaseDataSource(); + private external: Record | null = null; + + setStore(external: Record): void { + this.external = external; + this.store.replace(Object.entries(external)); + } + + has(key: string): boolean { + return this.store.has(key); + } + + get(key: string): T | undefined { + return this.store.get(key) as T | undefined; + } + + set(key: string, value: unknown): void { + this.store.set(key, value); + // Mirror into the external tab-state object so it persists on serialization. + if (this.external) { + this.external[key] = value; + } + } + + delete(key: string): void { + this.store.delete(key); + if (this.external) { + delete this.external[key]; + } + } +} diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabaseDataSource.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabaseDataSource.ts index 60b1bb6f684..0e9366d0186 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabaseDataSource.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabaseDataSource.ts @@ -10,6 +10,7 @@ import type { IExecutor, ISyncExecutor } from '@cloudbeaver/core-executor'; import { type TLocalizationToken } from '@cloudbeaver/core-localization'; import type { ResultDataFormat } from '@cloudbeaver/core-sdk'; +import type { IDatabasePersistedStateStore } from './IDatabasePersistedStateStore.js'; import type { IDatabaseDataActions } from './IDatabaseDataActions.js'; import type { IDatabaseDataResult } from './IDatabaseDataResult.js'; @@ -80,6 +81,7 @@ export interface IDatabaseDataSource | null; readonly options: TOptions | null; + readonly persistedState: IDatabasePersistedStateStore; readonly requestInfo: IRequestInfo; readonly error: Error | null; readonly canCancel: boolean; @@ -116,6 +118,7 @@ export interface IDatabaseDataSource this; setSlice: (offset: number, count: number) => this; setOptions: (options: TOptions) => this; + loadPersistedState: (state: Record) => this; setDataFormat: (dataFormat: ResultDataFormat) => this; setSupportedDataFormats: (dataFormats: ResultDataFormat[]) => this; setFeature: (feature: DatabaseDataFeature) => this; diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabasePersistedStateStore.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabasePersistedStateStore.ts new file mode 100644 index 00000000000..18792c08ee4 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabasePersistedStateStore.ts @@ -0,0 +1,15 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export interface IDatabasePersistedStateStore { + get(key: string): T | undefined; + set(key: string, value: unknown): void; + delete(key: string): void; + has(key: string): boolean; + setStore(store: Record): void; +} diff --git a/webapp/packages/plugin-data-viewer/src/IDataViewerPageState.ts b/webapp/packages/plugin-data-viewer/src/IDataViewerPageState.ts index c1d13ee974c..b1fb6b1750e 100644 --- a/webapp/packages/plugin-data-viewer/src/IDataViewerPageState.ts +++ b/webapp/packages/plugin-data-viewer/src/IDataViewerPageState.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -10,4 +10,5 @@ export interface IDataViewerPageState { resultIndex: number; presentationId: string; valuePresentationId: string | null; + persistedState: Record; } diff --git a/webapp/packages/plugin-data-viewer/src/ResultSet/ResultSetDataSource.ts b/webapp/packages/plugin-data-viewer/src/ResultSet/ResultSetDataSource.ts index 9f2bd9666a7..d78b52961c0 100644 --- a/webapp/packages/plugin-data-viewer/src/ResultSet/ResultSetDataSource.ts +++ b/webapp/packages/plugin-data-viewer/src/ResultSet/ResultSetDataSource.ts @@ -5,7 +5,7 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { makeObservable, observable } from 'mobx'; +import { autorun, type IReactionDisposer, makeObservable, observable } from 'mobx'; import type { IConnectionExecutionContext, IConnectionExecutionContextInfo } from '@cloudbeaver/core-connections'; import type { IServiceProvider } from '@cloudbeaver/core-di'; @@ -16,7 +16,11 @@ import type { GraphQLService, SqlRowIdentifier, SqlRowIdentifierState } from '@c import { DatabaseDataSource } from '../DatabaseDataModel/DatabaseDataSource.js'; import { type IDatabaseDataOptions } from '../DatabaseDataModel/IDatabaseDataOptions.js'; import type { IDatabaseResultSet } from '../DatabaseDataModel/IDatabaseResultSet.js'; -import { DatabaseDataConstraintAction } from '../DatabaseDataModel/Actions/DatabaseDataConstraintAction.js'; +import { + applyPersistedDataFilterConstraints, + DatabaseDataConstraintAction, + persistDataFilterConstraints, +} from '../DatabaseDataModel/Actions/DatabaseDataConstraintAction.js'; import { DocumentDataAction } from '../DatabaseDataModel/Actions/Document/DocumentDataAction.js'; import { DocumentEditAction } from '../DatabaseDataModel/Actions/Document/DocumentEditAction.js'; import { IDatabaseDataCacheAction } from '../DatabaseDataModel/Actions/IDatabaseDataCacheAction.js'; @@ -39,10 +43,13 @@ export interface IRowIdentifierInfo { identifier: SqlRowIdentifier | null; } -export abstract class ResultSetDataSource extends DatabaseDataSource { +export abstract class ResultSetDataSource + extends DatabaseDataSource +{ executionContext: IConnectionExecutionContext | null; totalCountRequestTask: ITask | null; private keepExecutionContextOnDispose: boolean; + private readonly persistConstraintsDisposer: IReactionDisposer; constructor( override readonly serviceProvider: IServiceProvider, @@ -71,6 +78,12 @@ export abstract class ResultSetDataSource exten totalCountRequestTask: observable.ref, executionContext: observable, }); + + this.persistConstraintsDisposer = autorun(() => persistDataFilterConstraints(this)); + } + + protected override onPersistedStateLoaded(): void { + applyPersistedDataFilterConstraints(this); } override isReadonly(resultIndex: number): boolean { @@ -147,6 +160,7 @@ export abstract class ResultSetDataSource exten } override async dispose(): Promise { + this.persistConstraintsDisposer(); await super.dispose(); if (this.keepExecutionContextOnDispose) { await this.closeResults(this.results); @@ -226,6 +240,8 @@ export abstract class ResultSetDataSource exten } } -export function isResultSetDataSource(dataSource: any): dataSource is ResultSetDataSource { +export function isResultSetDataSource( + dataSource: unknown, +): dataSource is ResultSetDataSource { return dataSource instanceof ResultSetDataSource; } diff --git a/webapp/packages/plugin-data-viewer/src/ResultSet/isResultSetDataModel.ts b/webapp/packages/plugin-data-viewer/src/ResultSet/isResultSetDataModel.ts index ff75d44860d..e10472f1cb8 100644 --- a/webapp/packages/plugin-data-viewer/src/ResultSet/isResultSetDataModel.ts +++ b/webapp/packages/plugin-data-viewer/src/ResultSet/isResultSetDataModel.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -10,7 +10,7 @@ import { type IDatabaseDataModel } from '../DatabaseDataModel/IDatabaseDataModel import { type IDatabaseDataOptions } from '../DatabaseDataModel/IDatabaseDataOptions.js'; import { ResultSetDataSource } from './ResultSetDataSource.js'; -export function isResultSetDataModel( +export function isResultSetDataModel( dataModel: IDatabaseDataModel | undefined | null, ): dataModel is IDatabaseDataModel> { return dataModel instanceof DatabaseDataModel && dataModel.source instanceof ResultSetDataSource; diff --git a/webapp/packages/plugin-data-viewer/src/index.ts b/webapp/packages/plugin-data-viewer/src/index.ts index 4d25f5a4c0d..24f6548da5c 100644 --- a/webapp/packages/plugin-data-viewer/src/index.ts +++ b/webapp/packages/plugin-data-viewer/src/index.ts @@ -49,6 +49,8 @@ export * from './DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction. export * from './DatabaseDataModel/Actions/DatabaseDataResultAction.js'; export * from './DatabaseDataModel/Actions/DatabaseEditAction.js'; export * from './DatabaseDataModel/Actions/General/DatabaseMetadataAction.js'; +export * from './DatabaseDataModel/DatabasePersistedStateStore.js'; +export * from './DatabaseDataModel/IDatabasePersistedStateStore.js'; export * from './DatabaseDataModel/Actions/DatabaseSelectAction.js'; export * from './DatabaseDataModel/Actions/IDatabaseDataCacheAction.js'; export * from './DatabaseDataModel/Actions/IDatabaseDataViewAction.js'; diff --git a/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts b/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts index a9a59b599f7..afd14ba5e3b 100644 --- a/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts +++ b/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts @@ -210,8 +210,7 @@ export class QueryDataSource {