mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-09-24 16:04:36 +08:00
dbeaver/pro#7562 [CB] keep data editor filter state during session (#4261)
* dbeaver/pro#7562 feat: persist data editor filter state after reconnect
* dbeaver/pro#7562 feat: update data viewer to persist filter state
* dbeaver/pro#7562 feat: enhance constraint resolution in WebSQLDataFilter
allow using attrName without attrPosition
* dbeaver/pro#7562 refactor: don't use options
replaced private restorePendingState() with public restoreViewState(pinnedColumnNames, columnOrderNames?) that takes data directly as arguments
* dbeaver/pro#7562 fix: stale constraints in UI
bug with skipping constraints if any without a position.
if (!prevColumn) {
return;
}
* dbeaver/pro#7562 refactor: add method to retrieve pinned column names in GridViewAction
* dbeaver/pro#7562 refactor: simplify state validation using schema
* dbeaver/pro#7562 refactor: simplify state management in useDataViewerPanel
* dbeaver/pro#7562 refactor: move persisted state to IDataViewerPageState
* dbeaver/pro#7562 fix: initial pageState
* dbeaver/pro#7562 refactor: simplify getPinnedColumnNames method in GridViewAction
* dbeaver/pro#7562 refactor: shadowed column renamed to c for clarity
* dbeaver/pro#7562 refactor: change type of value in IPersistedConstraint to unknown
* dbeaver/pro#7562 refactor: remove manual state operations
* dbeaver/pro#7562 refactor: introduce DatabasePersistedStateAction for centralized state persistence
* dbeaver/pro#7562 fix
* dbeaver/pro#7562 refactor: replace reaction with when
* Revert "dbeaver/pro#7562 feat: enhance constraint resolution in WebSQLDataFilter"
This reverts commit 3e0e86f4b2.
Last test showed that we don't need any additional logic on BE. It handles all constraints properly.
* dbeaver/pro#7562 fix: make pinColumns optional
* dbeaver/pro#7562 refactor: simplify pinned column handling
* dbeaver/pro#7562 refactor: initialize store in DatabasePersistedStateAction constructor as observable
* dbeaver/pro#7562 refactor: rename getColumnNameByPosition to getColumnNameAt for clarity
* dbeaver/pro#7562 refactor: move DatabasePersistedStateAction to source level, simplify persisted state sync
- Make DatabasePersistedStateAction a standalone class on DatabaseDataSource instead of per-result action
- Convert options to getter
- Auto-sync constraints to persisted state via autorun + action batching
- Restore view state (pinned columns, column order) in GridViewAction constructor
* dbeaver/pro#7562 refactor: replace DatabasePersistedStateAction with DatabasePersistedStateStore and update related references
* dbeaver/pro#7562 refactor: simplify view-state persistence
- DatabasePersistedStateStore: pure KV (observable.ref + actions)
- DatabaseDataSource: add loadPersistedState() + onPersistedStateLoaded() hook
- DatabaseDataConstraintAction: own the constraint persist
- ResultSetDataSource: thin dispatcher
- GridViewAction: columnsOrder/pinnedColumns become computed getters
* dbeaver/pro#7562 refactor: create universal ColumnReference
* dbeaver/pro#7562 refactor: simplify persistence flow, remove extra guards, simplify persisted data filter state handling
* dbeaver/pro#7562 refactor: simplify GridViewAction column-ref handling and layout resolution
* dbeaver/pro#7562 refactor: per-key reactive persisted state store, drop constraint persistence hacks
* dbeaver/pro#7562 refactor: simplify setOptions method to use superclass implementation
* dbeaver/pro#7562 add missing fields
---------
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
Co-authored-by: naumov <iamemptyhuh@gmail.com>
Co-authored-by: alex <48489896+devnaumov@users.noreply.github.com>
This commit is contained in:
co-authored by
Daria Marutkina
naumov
alex
parent
a2ccd0b0f7
commit
9c6f301fa9
+3
-3
@@ -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<unknown>).getResult(resultIndex);
|
||||
const result = (source as ResultSetDataSource).getResult(resultIndex);
|
||||
return !result?.data?.hasDynamicTrace;
|
||||
},
|
||||
getPresentationComponent: () => DVResultTraceDetailsPresentation,
|
||||
|
||||
@@ -30,6 +30,7 @@ export const DataViewerPanel: ObjectPagePanelComponent<IDataViewerPageState> = o
|
||||
presentationId,
|
||||
resultIndex: 0,
|
||||
valuePresentationId: null,
|
||||
persistedState: {},
|
||||
});
|
||||
} else {
|
||||
pageState.presentationId = presentationId;
|
||||
@@ -47,6 +48,7 @@ export const DataViewerPanel: ObjectPagePanelComponent<IDataViewerPageState> = o
|
||||
presentationId: '',
|
||||
resultIndex: 0,
|
||||
valuePresentationId,
|
||||
persistedState: {},
|
||||
});
|
||||
} else {
|
||||
pageState.valuePresentationId = valuePresentationId;
|
||||
|
||||
@@ -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<IObjectViewerTabState>) {
|
||||
|
||||
model = dataViewerTableService.create(connectionInfo, node);
|
||||
tab.handlerState.tableId = model.id;
|
||||
|
||||
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);
|
||||
|
||||
const presentation = dataPresentationService.get(pageState.presentationId);
|
||||
|
||||
if (presentation?.dataFormat !== undefined) {
|
||||
model.setDataFormat(presentation.dataFormat);
|
||||
}
|
||||
|
||||
model.source.setOutdated();
|
||||
dataViewerDataChangeConfirmationService.trackTableDataUpdate(model.id);
|
||||
|
||||
const pageState = dataViewerTabService.page.getState(tab);
|
||||
|
||||
if (pageState) {
|
||||
const presentation = dataPresentationService.get(pageState.presentationId);
|
||||
|
||||
if (presentation?.dataFormat !== undefined) {
|
||||
model.setDataFormat(presentation.dataFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node?.name) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
+111
-22
@@ -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<TOptions extends IDatabaseDataOptions>(
|
||||
source: IDatabaseDataSource<TOptions, IDatabaseResultSet>,
|
||||
): 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<TOptions extends IDatabaseDataOptions>(
|
||||
source: IDatabaseDataSource<TOptions, IDatabaseResultSet>,
|
||||
): void {
|
||||
const options = source.options;
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const constraints = source.persistedState.get<SqlDataFilterConstraint[]>(CONSTRAINTS_KEY);
|
||||
const whereFilter = source.persistedState.get<string>(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<IDatabaseDataOptions, IDatabaseResultSet>
|
||||
implements IDatabaseDataConstraintAction<IDatabaseResultSet> {
|
||||
implements IDatabaseDataConstraintAction<IDatabaseResultSet>
|
||||
{
|
||||
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<IDatabaseDataOptions, IDatabaseResultSet>, result: IDatabaseResultSet) {
|
||||
@@ -264,31 +318,66 @@ function updateConstraintsForResult(source: IDatabaseDataSource<IDatabaseDataOpt
|
||||
return;
|
||||
}
|
||||
|
||||
for (const constraint of source.options.constraints) {
|
||||
const prevColumn = result.data?.columns?.find(column => 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<IDatabaseDataOptions, IDatabaseResultSet>): 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 {
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+174
-26
@@ -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<string>;
|
||||
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<string> {
|
||||
const resolved = this.resolveRefs(this.readRefs(PINNED_COLUMNS_KEY));
|
||||
const pinned = new Set<string>();
|
||||
if (resolved) {
|
||||
for (const index of resolved) {
|
||||
pinned.add(GridDataKeysUtils.serialize({ index }));
|
||||
}
|
||||
}
|
||||
return pinned;
|
||||
}
|
||||
|
||||
protected readonly data: GridDataResultAction<TColumn, TRow, TKey, TCell, TResult>;
|
||||
protected readonly editor?: GridEditAction<TColumn, TRow, TKey, TCell, TResult>;
|
||||
|
||||
@@ -65,12 +112,10 @@ export class GridViewAction<
|
||||
super(source, result);
|
||||
this.data = data as GridDataResultAction<TColumn, TRow, TKey, TCell, TResult>;
|
||||
this.editor = editor as GridEditAction<TColumn, TRow, TKey, TCell, TResult> | undefined;
|
||||
this.columnsOrder = this.data.columns.map((key, index) => index);
|
||||
this.pinnedColumns = observable.set<string>();
|
||||
|
||||
makeObservable<this, 'columnsOrder' | 'pinnedColumns'>(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);
|
||||
|
||||
if (columnIndex === -1) {
|
||||
const ref = this.getColumnRef(key);
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.columnsOrder.splice(this.columnsOrder.indexOf(columnIndex), 1);
|
||||
this.columnsOrder.splice(index, 0, columnIndex);
|
||||
const current = this.columnKeys.map(k => this.getColumnRef(k)).filter(isDefined);
|
||||
const from = current.findIndex(r => isSameRef(r, ref));
|
||||
if (from === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
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<unknown>(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<string, number[]>();
|
||||
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<number>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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<TOptions, TResult extends IDatabaseData
|
||||
offset: number;
|
||||
count: number;
|
||||
prevOptions: Readonly<TOptions> | null;
|
||||
options: TOptions | null;
|
||||
protected _options: TOptions | null;
|
||||
readonly persistedState: DatabasePersistedStateStore;
|
||||
requestInfo: IRequestInfo;
|
||||
error: Error | null;
|
||||
private readonly features: Set<DatabaseDataFeature | string>;
|
||||
@@ -53,6 +55,10 @@ export abstract class DatabaseDataSource<TOptions, TResult extends IDatabaseData
|
||||
return false;
|
||||
}
|
||||
|
||||
get options(): TOptions | null {
|
||||
return this._options;
|
||||
}
|
||||
|
||||
readonly serviceProvider: IServiceProvider;
|
||||
protected disabled: boolean;
|
||||
private lastAction: () => Promise<void>;
|
||||
@@ -78,7 +84,8 @@ export abstract class DatabaseDataSource<TOptions, TResult extends IDatabaseData
|
||||
this.offset = 0;
|
||||
this.count = 0;
|
||||
this.prevOptions = null;
|
||||
this.options = null;
|
||||
this._options = null;
|
||||
this.persistedState = new DatabasePersistedStateStore();
|
||||
this.disabled = false;
|
||||
this.outdated = true;
|
||||
this.constraintsAvailable = true;
|
||||
@@ -98,7 +105,7 @@ export abstract class DatabaseDataSource<TOptions, TResult extends IDatabaseData
|
||||
this.error = null;
|
||||
this.lastAction = this.requestData.bind(this);
|
||||
|
||||
makeObservable<DatabaseDataSource<TOptions, TResult>, 'disabled' | 'features' | 'activeOperationStack' | 'outdated'>(this, {
|
||||
makeObservable<DatabaseDataSource<TOptions, TResult>, 'disabled' | 'features' | 'activeOperationStack' | 'outdated' | '_options'>(this, {
|
||||
access: observable,
|
||||
dataFormat: observable,
|
||||
features: observable,
|
||||
@@ -107,7 +114,7 @@ export abstract class DatabaseDataSource<TOptions, TResult extends IDatabaseData
|
||||
offset: observable,
|
||||
count: observable,
|
||||
prevOptions: observable,
|
||||
options: observable,
|
||||
_options: observable,
|
||||
requestInfo: observable,
|
||||
error: observable.ref,
|
||||
disabled: observable,
|
||||
@@ -229,10 +236,18 @@ export abstract class DatabaseDataSource<TOptions, TResult extends IDatabaseData
|
||||
}
|
||||
|
||||
setOptions(options: TOptions): this {
|
||||
this.options = options;
|
||||
this._options = options;
|
||||
return this;
|
||||
}
|
||||
|
||||
loadPersistedState(state: Record<string, unknown>): 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<TOptions, TResult extends IDatabaseData
|
||||
this.lastAction = this.refreshData.bind(this);
|
||||
|
||||
if (this.prevOptions) {
|
||||
this.options = toJS(this.prevOptions);
|
||||
this._options = toJS(this.prevOptions);
|
||||
}
|
||||
|
||||
return this.requestDataAction();
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
import { observable } from 'mobx';
|
||||
|
||||
import type { IDatabasePersistedStateStore } from './IDatabasePersistedStateStore.js';
|
||||
|
||||
export class DatabasePersistedStateStore implements IDatabasePersistedStateStore {
|
||||
private readonly store = observable.map<string, unknown>();
|
||||
private external: Record<string, unknown> | null = null;
|
||||
|
||||
setStore(external: Record<string, unknown>): void {
|
||||
this.external = external;
|
||||
this.store.replace(Object.entries(external));
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.store.has(key);
|
||||
}
|
||||
|
||||
get<T>(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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TOptions = unknown, TResult extends IDataba
|
||||
/** Options of the previous request */
|
||||
readonly prevOptions: Readonly<TOptions> | 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<TOptions = unknown, TResult extends IDataba
|
||||
setAccess: (access: DatabaseDataAccessMode) => this;
|
||||
setSlice: (offset: number, count: number) => this;
|
||||
setOptions: (options: TOptions) => this;
|
||||
loadPersistedState: (state: Record<string, unknown>) => this;
|
||||
setDataFormat: (dataFormat: ResultDataFormat) => this;
|
||||
setSupportedDataFormats: (dataFormats: ResultDataFormat[]) => this;
|
||||
setFeature: (feature: DatabaseDataFeature) => this;
|
||||
|
||||
+15
@@ -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<T>(key: string): T | undefined;
|
||||
set(key: string, value: unknown): void;
|
||||
delete(key: string): void;
|
||||
has(key: string): boolean;
|
||||
setStore(store: Record<string, unknown>): void;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -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<TOptions = IDatabaseDataOptions> extends DatabaseDataSource<TOptions, IDatabaseResultSet> {
|
||||
export abstract class ResultSetDataSource<TOptions extends IDatabaseDataOptions = IDatabaseDataOptions>
|
||||
extends DatabaseDataSource<TOptions, IDatabaseResultSet>
|
||||
{
|
||||
executionContext: IConnectionExecutionContext | null;
|
||||
totalCountRequestTask: ITask<number> | null;
|
||||
private keepExecutionContextOnDispose: boolean;
|
||||
private readonly persistConstraintsDisposer: IReactionDisposer;
|
||||
|
||||
constructor(
|
||||
override readonly serviceProvider: IServiceProvider,
|
||||
@@ -71,6 +78,12 @@ export abstract class ResultSetDataSource<TOptions = IDatabaseDataOptions> 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<TOptions = IDatabaseDataOptions> exten
|
||||
}
|
||||
|
||||
override async dispose(): Promise<void> {
|
||||
this.persistConstraintsDisposer();
|
||||
await super.dispose();
|
||||
if (this.keepExecutionContextOnDispose) {
|
||||
await this.closeResults(this.results);
|
||||
@@ -226,6 +240,8 @@ export abstract class ResultSetDataSource<TOptions = IDatabaseDataOptions> exten
|
||||
}
|
||||
}
|
||||
|
||||
export function isResultSetDataSource<T = IDatabaseDataOptions>(dataSource: any): dataSource is ResultSetDataSource<T> {
|
||||
export function isResultSetDataSource<T extends IDatabaseDataOptions = IDatabaseDataOptions>(
|
||||
dataSource: unknown,
|
||||
): dataSource is ResultSetDataSource<T> {
|
||||
return dataSource instanceof ResultSetDataSource;
|
||||
}
|
||||
|
||||
@@ -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<T = IDatabaseDataOptions>(
|
||||
export function isResultSetDataModel<T extends IDatabaseDataOptions = IDatabaseDataOptions>(
|
||||
dataModel: IDatabaseDataModel<any> | undefined | null,
|
||||
): dataModel is IDatabaseDataModel<ResultSetDataSource<T>> {
|
||||
return dataModel instanceof DatabaseDataModel && dataModel.source instanceof ResultSetDataSource;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -210,8 +210,7 @@ export class QueryDataSource<TOptions extends IDataQueryOptions = IDataQueryOpti
|
||||
if (this.options?.query !== options.query) {
|
||||
this.resetQueryParameters();
|
||||
}
|
||||
this.options = options;
|
||||
return this;
|
||||
return super.setOptions(options);
|
||||
}
|
||||
|
||||
async request(prevResults: IDatabaseResultSet[]): Promise<IDatabaseResultSet[]> {
|
||||
|
||||
Reference in New Issue
Block a user