Restore open collection tabs: Query, Documents, Settings (#2004)

* Persist query multiple query texts

* Save multiple query tab histories

* Save and restore states for QueryTab and DocumentsTab for SQL and Mongo

* Enable Collection Scale/Settings restore

* Persist documents tab current filter

* Fix DocumentsTab conflict resolve mistake

* Remove unused variable

* Fix e2e test

* Fix e2e localStorage reference

* Try clearing local storage via playwright page

* Clear local storage after opening page

* Move restore flag behind feature flag. Whitelist restorable tabs in for Fabric. Restore e2e tests.

* Fix typo

* Fix: avoid setting undefined for preferredSize for the <Allotment.Pane>

* Add comments

* Move restore tabs after knockout configure step from Explorer constructor (which could be called multiple times)
This commit is contained in:
Laurent Nguyen 2024-11-28 11:18:55 +01:00 committed by GitHub
parent 80b926214b
commit 73d2686025
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 431 additions and 130 deletions

View File

@ -9,6 +9,7 @@ export enum TabKind {
Graph, Graph,
SQLQuery, SQLQuery,
ScaleSettings, ScaleSettings,
MongoQuery,
} }
/** /**
@ -51,6 +52,8 @@ export interface OpenCollectionTab extends OpenTab {
*/ */
export interface OpenQueryTab extends OpenCollectionTab { export interface OpenQueryTab extends OpenCollectionTab {
query: QueryInfo; query: QueryInfo;
splitterDirection?: "vertical" | "horizontal";
queryViewSizePercent?: number;
} }
/** /**

View File

@ -115,7 +115,13 @@ export interface CollectionBase extends TreeNode {
isSampleCollection?: boolean; isSampleCollection?: boolean;
onDocumentDBDocumentsClick(): void; onDocumentDBDocumentsClick(): void;
onNewQueryClick(source: any, event?: MouseEvent, queryText?: string): void; onNewQueryClick(
source: any,
event?: MouseEvent,
queryText?: string,
splitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
): void;
expandCollection(): void; expandCollection(): void;
collapseCollection(): void; collapseCollection(): void;
getDatabase(): Database; getDatabase(): Database;
@ -151,7 +157,13 @@ export interface Collection extends CollectionBase {
onSettingsClick: () => Promise<void>; onSettingsClick: () => Promise<void>;
onNewGraphClick(): void; onNewGraphClick(): void;
onNewMongoQueryClick(source: any, event?: MouseEvent, queryText?: string): void; onNewMongoQueryClick(
source: any,
event?: MouseEvent,
queryText?: string,
splitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
): void;
onNewMongoShellClick(): void; onNewMongoShellClick(): void;
onNewStoredProcedureClick(source: Collection, event?: MouseEvent): void; onNewStoredProcedureClick(source: Collection, event?: MouseEvent): void;
onNewUserDefinedFunctionClick(source: Collection, event?: MouseEvent): void; onNewUserDefinedFunctionClick(source: Collection, event?: MouseEvent): void;
@ -311,6 +323,8 @@ export interface QueryTabOptions extends TabOptions {
partitionKey?: DataModels.PartitionKey; partitionKey?: DataModels.PartitionKey;
queryText?: string; queryText?: string;
resourceTokenPartitionKey?: string; resourceTokenPartitionKey?: string;
splitterDirection?: "horizontal" | "vertical";
queryViewSizePercent?: number;
} }
export interface ScriptTabOption extends TabOptions { export interface ScriptTabOption extends TabOptions {

View File

@ -1134,7 +1134,7 @@ export default class Explorer {
if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") { if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") {
userContext.authType === AuthType.ResourceToken userContext.authType === AuthType.ResourceToken
? this.refreshDatabaseForResourceToken() ? this.refreshDatabaseForResourceToken()
: this.refreshAllDatabases(); : await this.refreshAllDatabases(); // await: we rely on the databases to be loaded before restoring the tabs further in the flow
} }
await useNotebook.getState().refreshNotebooksEnabledStateForAccount(); await useNotebook.getState().refreshNotebooksEnabledStateForAccount();

View File

@ -1,4 +1,5 @@
// TODO convert this file to an action registry in order to have actions and their handlers be more tightly coupled. // TODO convert this file to an action registry in order to have actions and their handlers be more tightly coupled.
import { configContext, Platform } from "ConfigContext";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import React from "react"; import React from "react";
import { ActionContracts } from "../../Contracts/ExplorerContracts"; import { ActionContracts } from "../../Contracts/ExplorerContracts";
@ -56,6 +57,19 @@ function openCollectionTab(
continue; continue;
} }
if (
configContext.platform === Platform.Fabric &&
!(
// whitelist the tab kinds that are allowed to be opened in Fabric
(
action.tabKind === ActionContracts.TabKind.SQLDocuments ||
action.tabKind === ActionContracts.TabKind.SQLQuery
)
)
) {
continue;
}
//expand database first if not expanded to load the collections //expand database first if not expanded to load the collections
if (!database.isDatabaseExpanded?.()) { if (!database.isDatabaseExpanded?.()) {
database.expandDatabase?.(); database.expandDatabase?.();
@ -121,10 +135,28 @@ function openCollectionTab(
action.tabKind === ActionContracts.TabKind.SQLQuery || action.tabKind === ActionContracts.TabKind.SQLQuery ||
action.tabKind === ActionContracts.TabKind[ActionContracts.TabKind.SQLQuery] action.tabKind === ActionContracts.TabKind[ActionContracts.TabKind.SQLQuery]
) { ) {
const openQueryTabAction = action as ActionContracts.OpenQueryTab;
collection.onNewQueryClick( collection.onNewQueryClick(
collection, collection,
undefined, undefined,
generateQueryText(action as ActionContracts.OpenQueryTab, collection.partitionKeyProperties), generateQueryText(openQueryTabAction, collection.partitionKeyProperties),
openQueryTabAction.splitterDirection,
openQueryTabAction.queryViewSizePercent,
);
break;
}
if (
action.tabKind === ActionContracts.TabKind.MongoQuery ||
action.tabKind === ActionContracts.TabKind[ActionContracts.TabKind.MongoQuery]
) {
const openQueryTabAction = action as ActionContracts.OpenQueryTab;
collection.onNewMongoQueryClick(
collection,
undefined,
generateQueryText(openQueryTabAction, collection.partitionKeyProperties),
openQueryTabAction.splitterDirection,
openQueryTabAction.queryViewSizePercent,
); );
break; break;
} }

View File

@ -3,17 +3,11 @@
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent"; import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
import { import {
AppStateComponentNames, AppStateComponentNames,
deleteState, deleteSubComponentState,
loadState, readSubComponentState,
saveState, saveSubComponentState,
saveStateDebounced,
} from "Shared/AppStatePersistenceUtility"; } from "Shared/AppStatePersistenceUtility";
import { userContext } from "UserContext";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
const componentName = AppStateComponentNames.DocumentsTab;
export enum SubComponentName { export enum SubComponentName {
ColumnSizes = "ColumnSizes", ColumnSizes = "ColumnSizes",
@ -21,6 +15,7 @@ export enum SubComponentName {
MainTabDivider = "MainTabDivider", MainTabDivider = "MainTabDivider",
ColumnsSelection = "ColumnsSelection", ColumnsSelection = "ColumnsSelection",
ColumnSort = "ColumnSort", ColumnSort = "ColumnSort",
CurrentFilter = "CurrentFilter",
} }
export type ColumnSizesMap = { [columnId: string]: WidthDefinition }; export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
@ -30,84 +25,22 @@ export type TabDivider = { leftPaneWidthPercent: number };
export type ColumnsSelection = { selectedColumnIds: string[]; columnDefinitions: ColumnDefinition[] }; export type ColumnsSelection = { selectedColumnIds: string[]; columnDefinitions: ColumnDefinition[] };
export type ColumnSort = { columnId: string; direction: "ascending" | "descending" }; export type ColumnSort = { columnId: string; direction: "ascending" | "descending" };
/** // Wrap the ...SubComponentState functions for type safety
*
* @param subComponentName export const readDocumentsTabSubComponentState = <T>(
* @param collection
* @param defaultValue Will be returned if persisted state is not found
* @returns
*/
export const readSubComponentState = <T>(
subComponentName: SubComponentName, subComponentName: SubComponentName,
collection: ViewModels.CollectionBase, collection: ViewModels.CollectionBase,
defaultValue: T, defaultValue: T,
): T => { ): T => readSubComponentState<T>(AppStateComponentNames.DocumentsTab, subComponentName, collection, defaultValue);
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.ReadPersistedTabState, { message, componentName });
return defaultValue;
}
const state = loadState({ export const saveDocumentsTabSubComponentState = <T>(
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
}) as T;
return state || defaultValue;
};
/**
*
* @param subComponentName
* @param collection
* @param state State to save
* @param debounce true for high-frequency calls (e.g mouse drag events)
*/
export const saveSubComponentState = <T>(
subComponentName: SubComponentName, subComponentName: SubComponentName,
collection: ViewModels.CollectionBase, collection: ViewModels.CollectionBase,
state: T, state: T,
debounce?: boolean, debounce?: boolean,
): void => { ): void => saveSubComponentState<T>(AppStateComponentNames.DocumentsTab, subComponentName, collection, state, debounce);
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.SavePersistedTabState, { message, componentName });
return;
}
(debounce ? saveStateDebounced : saveState)( export const deleteDocumentsTabSubComponentState = (
{ subComponentName: SubComponentName,
componentName: componentName, collection: ViewModels.CollectionBase,
subComponentName, ) => deleteSubComponentState(AppStateComponentNames.DocumentsTab, subComponentName, collection);
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
},
state,
);
};
export const deleteSubComponentState = (subComponentName: SubComponentName, collection: ViewModels.CollectionBase) => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.DeletePersistedTabState, { message, componentName });
return;
}
deleteState({
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
});
};

View File

@ -21,6 +21,7 @@ import { queryDocuments } from "Common/dataAccess/queryDocuments";
import { readDocument } from "Common/dataAccess/readDocument"; import { readDocument } from "Common/dataAccess/readDocument";
import { updateDocument } from "Common/dataAccess/updateDocument"; import { updateDocument } from "Common/dataAccess/updateDocument";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { ActionType, OpenCollectionTab, TabKind } from "Contracts/ActionContracts";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import { useDialog } from "Explorer/Controls/Dialog"; import { useDialog } from "Explorer/Controls/Dialog";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
@ -34,8 +35,9 @@ import {
FilterHistory, FilterHistory,
SubComponentName, SubComponentName,
TabDivider, TabDivider,
readSubComponentState, deleteDocumentsTabSubComponentState,
saveSubComponentState, readDocumentsTabSubComponentState,
saveDocumentsTabSubComponentState,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil"; } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil"; import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
@ -140,6 +142,8 @@ export class DocumentsTabV2 extends TabsBase {
private title: string; private title: string;
private resourceTokenPartitionKey: string; private resourceTokenPartitionKey: string;
protected persistedState: OpenCollectionTab;
constructor(options: ViewModels.DocumentsTabOptions) { constructor(options: ViewModels.DocumentsTabOptions) {
super(options); super(options);
@ -147,6 +151,13 @@ export class DocumentsTabV2 extends TabsBase {
this.title = options.title; this.title = options.title;
this.partitionKey = options.partitionKey; this.partitionKey = options.partitionKey;
this.resourceTokenPartitionKey = options.resourceTokenPartitionKey; this.resourceTokenPartitionKey = options.resourceTokenPartitionKey;
this.persistedState = {
actionType: ActionType.OpenCollectionTab,
tabKind: options.isPreferredApiMongoDB ? TabKind.MongoDocuments : TabKind.SQLDocuments,
databaseResourceId: options.collection.databaseId,
collectionResourceId: options.collection.id(),
};
} }
public render(): JSX.Element { public render(): JSX.Element {
@ -575,7 +586,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
onIsExecutingChange, onIsExecutingChange,
isTabActive, isTabActive,
}): JSX.Element => { }): JSX.Element => {
const [filterContent, setFilterContent] = useState<string>(""); const [filterContent, setFilterContent] = useState<string>(() =>
readDocumentsTabSubComponentState<string>(SubComponentName.CurrentFilter, _collection, ""),
);
const [documentIds, setDocumentIds] = useState<ExtendedDocumentId[]>([]); const [documentIds, setDocumentIds] = useState<ExtendedDocumentId[]>([]);
const [isExecuting, setIsExecuting] = useState<boolean>(false); const [isExecuting, setIsExecuting] = useState<boolean>(false);
const styles = useDocumentsTabStyles(); const styles = useDocumentsTabStyles();
@ -606,7 +620,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// State // State
const [tabStateData, setTabStateData] = useState<TabDivider>(() => const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
readSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, { readDocumentsTabSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35, leftPaneWidthPercent: 35,
}), }),
); );
@ -621,7 +635,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// User's filter history // User's filter history
const [lastFilterContents, setLastFilterContents] = useState<FilterHistory>(() => const [lastFilterContents, setLastFilterContents] = useState<FilterHistory>(() =>
readSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, [] as FilterHistory), readDocumentsTabSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, [] as FilterHistory),
); );
// For progress bar for bulk delete (noSql) // For progress bar for bulk delete (noSql)
@ -763,7 +777,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}; };
const [selectedColumnIds, setSelectedColumnIds] = useState<string[]>(() => { const [selectedColumnIds, setSelectedColumnIds] = useState<string[]>(() => {
const persistedColumnsSelection = readSubComponentState<ColumnsSelection>( const persistedColumnsSelection = readDocumentsTabSubComponentState<ColumnsSelection>(
SubComponentName.ColumnsSelection, SubComponentName.ColumnsSelection,
_collection, _collection,
undefined, undefined,
@ -808,7 +822,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
useEffect(() => { useEffect(() => {
setKeyboardActions({ setKeyboardActions({
[KeyboardAction.CLEAR_SEARCH]: () => { [KeyboardAction.CLEAR_SEARCH]: () => {
setFilterContent(""); updateFilterContent("");
refreshDocumentsGrid(true); refreshDocumentsGrid(true);
return true; return true;
}, },
@ -1645,7 +1659,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// Column definition is a map<id, ColumnDefinition> to garantee uniqueness // Column definition is a map<id, ColumnDefinition> to garantee uniqueness
const [columnDefinitions, setColumnDefinitions] = useState<ColumnDefinition[]>(() => { const [columnDefinitions, setColumnDefinitions] = useState<ColumnDefinition[]>(() => {
const persistedColumnsSelection = readSubComponentState<ColumnsSelection>( const persistedColumnsSelection = readDocumentsTabSubComponentState<ColumnsSelection>(
SubComponentName.ColumnsSelection, SubComponentName.ColumnsSelection,
_collection, _collection,
undefined, undefined,
@ -1956,7 +1970,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT); const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT);
setLastFilterContents(limitedLastFilterContents); setLastFilterContents(limitedLastFilterContents);
saveSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, lastFilterContents); saveDocumentsTabSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, lastFilterContents);
}; };
const refreshDocumentsGrid = useCallback( const refreshDocumentsGrid = useCallback(
@ -2013,7 +2027,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setSelectedColumnIds(newSelectedColumnIds); setSelectedColumnIds(newSelectedColumnIds);
saveSubComponentState<ColumnsSelection>(SubComponentName.ColumnsSelection, _collection, { saveDocumentsTabSubComponentState<ColumnsSelection>(SubComponentName.ColumnsSelection, _collection, {
selectedColumnIds: newSelectedColumnIds, selectedColumnIds: newSelectedColumnIds,
columnDefinitions, columnDefinitions,
}); });
@ -2063,6 +2077,15 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return options; return options;
}; };
const updateFilterContent = (filter: string): void => {
if (filter === "" || filter === undefined) {
deleteDocumentsTabSubComponentState(SubComponentName.CurrentFilter, _collection);
} else {
saveDocumentsTabSubComponentState<string>(SubComponentName.CurrentFilter, _collection, filter, true);
}
setFilterContent(filter);
};
return ( return (
<CosmosFluentProvider className={styles.container}> <CosmosFluentProvider className={styles.container}>
<div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}> <div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
@ -2077,7 +2100,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
title="Type a query predicate or choose one from the list." title="Type a query predicate or choose one from the list."
value={filterContent} value={filterContent}
onChange={(value) => setFilterContent(value)} onChange={updateFilterContent}
onKeyDown={onFilterKeyDown} onKeyDown={onFilterKeyDown}
bottomLink={{ text: "Learn more", url: DATA_EXPLORER_DOC_URL }} bottomLink={{ text: "Learn more", url: DATA_EXPLORER_DOC_URL }}
/> />
@ -2103,7 +2126,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<Allotment <Allotment
onDragEnd={(sizes: number[]) => { onDragEnd={(sizes: number[]) => {
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]); tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
saveSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, tabStateData); saveDocumentsTabSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, tabStateData);
setTabStateData(tabStateData); setTabStateData(tabStateData);
}} }}
> >

View File

@ -42,9 +42,9 @@ import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPan
import { import {
ColumnSizesMap, ColumnSizesMap,
ColumnSort, ColumnSort,
deleteSubComponentState, deleteDocumentsTabSubComponentState,
readSubComponentState, readDocumentsTabSubComponentState,
saveSubComponentState, saveDocumentsTabSubComponentState,
SubComponentName, SubComponentName,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil"; } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
@ -118,7 +118,11 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
const sortedRowsRef = React.useRef(null); const sortedRowsRef = React.useRef(null);
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => { const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {}); const columnSizesMap: ColumnSizesMap = readDocumentsTabSubComponentState(
SubComponentName.ColumnSizes,
collection,
{},
);
const columnSizesPx: TableColumnSizingOptions = {}; const columnSizesPx: TableColumnSizingOptions = {};
selectedColumnIds.forEach((columnId) => { selectedColumnIds.forEach((columnId) => {
if ( if (
@ -142,7 +146,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
sortDirection: "ascending" | "descending"; sortDirection: "ascending" | "descending";
sortColumn: TableColumnId | undefined; sortColumn: TableColumnId | undefined;
}>(() => { }>(() => {
const sort = readSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, undefined); const sort = readDocumentsTabSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, undefined);
if (!sort) { if (!sort) {
return { return {
@ -174,7 +178,12 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
return acc; return acc;
}, {} as ColumnSizesMap); }, {} as ColumnSizesMap);
saveSubComponentState<ColumnSizesMap>(SubComponentName.ColumnSizes, collection, persistentSizes, true); saveDocumentsTabSubComponentState<ColumnSizesMap>(
SubComponentName.ColumnSizes,
collection,
persistentSizes,
true,
);
return newSizingOptions; return newSizingOptions;
}); });
@ -186,11 +195,14 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
setColumnSort(event, columnId, direction); setColumnSort(event, columnId, direction);
if (columnId === undefined || direction === undefined) { if (columnId === undefined || direction === undefined) {
deleteSubComponentState(SubComponentName.ColumnSort, collection); deleteDocumentsTabSubComponentState(SubComponentName.ColumnSort, collection);
return; return;
} }
saveSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, { columnId, direction }); saveDocumentsTabSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, {
columnId,
direction,
});
}; };
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes // Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes

View File

@ -1,3 +1,4 @@
import { ActionType, TabKind } from "Contracts/ActionContracts";
import React from "react"; import React from "react";
import MongoUtility from "../../../Common/MongoUtility"; import MongoUtility from "../../../Common/MongoUtility";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
@ -20,7 +21,7 @@ export class NewMongoQueryTab extends NewQueryTab {
private mongoQueryTabProps: IMongoQueryTabProps, private mongoQueryTabProps: IMongoQueryTabProps,
) { ) {
super(options, mongoQueryTabProps); super(options, mongoQueryTabProps);
this.queryText = ""; this.queryText = options.queryText ?? "";
this.iMongoQueryTabComponentProps = { this.iMongoQueryTabComponentProps = {
collection: options.collection, collection: options.collection,
isExecutionError: this.isExecutionError(), isExecutionError: this.isExecutionError(),
@ -28,6 +29,8 @@ export class NewMongoQueryTab extends NewQueryTab {
tabsBaseInstance: this, tabsBaseInstance: this,
queryText: this.queryText, queryText: this.queryText,
partitionKey: this.partitionKey, partitionKey: this.partitionKey,
splitterDirection: options.splitterDirection,
queryViewSizePercent: options.queryViewSizePercent,
container: this.mongoQueryTabProps.container, container: this.mongoQueryTabProps.container,
onTabAccessor: (instance: ITabAccessor): void => { onTabAccessor: (instance: ITabAccessor): void => {
this.iTabAccessor = instance; this.iTabAccessor = instance;
@ -35,6 +38,26 @@ export class NewMongoQueryTab extends NewQueryTab {
isPreferredApiMongoDB: true, isPreferredApiMongoDB: true,
monacoEditorSetting: "plaintext", monacoEditorSetting: "plaintext",
viewModelcollection: this.mongoQueryTabProps.viewModelcollection, viewModelcollection: this.mongoQueryTabProps.viewModelcollection,
onUpdatePersistedState: (state: {
queryText: string;
splitterDirection: string;
queryViewSizePercent: number;
}): void => {
this.persistedState = {
actionType: ActionType.OpenCollectionTab,
tabKind: TabKind.SQLQuery,
databaseResourceId: options.collection.databaseId,
collectionResourceId: options.collection.id(),
query: {
text: state.queryText,
},
splitterDirection: state.splitterDirection as "vertical" | "horizontal",
queryViewSizePercent: state.queryViewSizePercent,
};
if (this.triggerPersistState) {
this.triggerPersistState();
}
},
}; };
} }

View File

@ -1,4 +1,5 @@
import { sendMessage } from "Common/MessageHandler"; import { sendMessage } from "Common/MessageHandler";
import { ActionType, OpenQueryTab, TabKind } from "Contracts/ActionContracts";
import { MessageTypes } from "Contracts/MessageTypes"; import { MessageTypes } from "Contracts/MessageTypes";
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext"; import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
@ -26,6 +27,8 @@ export class NewQueryTab extends TabsBase {
public iQueryTabComponentProps: IQueryTabComponentProps; public iQueryTabComponentProps: IQueryTabComponentProps;
public iTabAccessor: ITabAccessor; public iTabAccessor: ITabAccessor;
protected persistedState: OpenQueryTab;
constructor( constructor(
options: QueryTabOptions, options: QueryTabOptions,
private props: IQueryTabProps, private props: IQueryTabProps,
@ -39,12 +42,41 @@ export class NewQueryTab extends TabsBase {
tabsBaseInstance: this, tabsBaseInstance: this,
queryText: options.queryText, queryText: options.queryText,
partitionKey: this.partitionKey, partitionKey: this.partitionKey,
splitterDirection: options.splitterDirection,
queryViewSizePercent: options.queryViewSizePercent,
container: this.props.container, container: this.props.container,
onTabAccessor: (instance: ITabAccessor): void => { onTabAccessor: (instance: ITabAccessor): void => {
this.iTabAccessor = instance; this.iTabAccessor = instance;
}, },
isPreferredApiMongoDB: false, isPreferredApiMongoDB: false,
onUpdatePersistedState: (state: {
queryText: string;
splitterDirection: string;
queryViewSizePercent: number;
}): void => {
this.persistedState = {
actionType: ActionType.OpenCollectionTab,
tabKind: TabKind.SQLQuery,
databaseResourceId: options.collection.databaseId,
collectionResourceId: options.collection.id(),
query: {
text: state.queryText,
},
splitterDirection: state.splitterDirection as "vertical" | "horizontal",
queryViewSizePercent: state.queryViewSizePercent,
}; };
if (this.triggerPersistState) {
this.triggerPersistState();
}
},
};
// set initial state
this.iQueryTabComponentProps.onUpdatePersistedState({
queryText: options.queryText,
splitterDirection: options.splitterDirection,
queryViewSizePercent: options.queryViewSizePercent,
});
} }
public render(): JSX.Element { public render(): JSX.Element {

View File

@ -34,6 +34,7 @@ jest.mock("Shared/AppStatePersistenceUtility", () => ({
AppStateComponentNames: { AppStateComponentNames: {
QueryCopilot: "QueryCopilot", QueryCopilot: "QueryCopilot",
}, },
readSubComponentState: jest.fn(),
})); }));
describe("QueryTabComponent", () => { describe("QueryTabComponent", () => {

View File

@ -18,13 +18,7 @@ import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import { QueryConstants } from "Shared/Constants"; import { QueryConstants } from "Shared/Constants";
import { import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
LocalStorageUtility,
StorageKey,
getDefaultQueryResultsView,
getRUThreshold,
ruThresholdEnabled,
} from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { Allotment } from "allotment"; import { Allotment } from "allotment";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot"; import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
@ -99,6 +93,13 @@ export interface IQueryTabComponentProps {
copilotEnabled?: boolean; copilotEnabled?: boolean;
isSampleCopilotActive?: boolean; isSampleCopilotActive?: boolean;
copilotStore?: Partial<QueryCopilotState>; copilotStore?: Partial<QueryCopilotState>;
splitterDirection?: "horizontal" | "vertical";
queryViewSizePercent?: number;
onUpdatePersistedState: (state: {
queryText: string;
splitterDirection: "vertical" | "horizontal";
queryViewSizePercent: number;
}) => void;
} }
interface IQueryTabStates { interface IQueryTabStates {
@ -118,11 +119,13 @@ interface IQueryTabStates {
queryResultsView: SplitterDirection; queryResultsView: SplitterDirection;
errors?: QueryError[]; errors?: QueryError[];
modelMarkers?: monaco.editor.IMarkerData[]; modelMarkers?: monaco.editor.IMarkerData[];
queryViewSizePercent: number;
} }
export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => { export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => {
const styles = useQueryTabStyles(); const styles = useQueryTabStyles();
const copilotStore = useCopilotStore(); const copilotStore = useCopilotStore();
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected(); const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
const queryTabProps = { const queryTabProps = {
...props, ...props,
@ -132,12 +135,12 @@ export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any =>
isSampleCopilotActive: isSampleCopilotActive, isSampleCopilotActive: isSampleCopilotActive,
copilotStore: copilotStore, copilotStore: copilotStore,
}; };
return <QueryTabComponentImpl styles={styles} {...queryTabProps}></QueryTabComponentImpl>; return <QueryTabComponentImpl styles={styles} {...queryTabProps} />;
}; };
export const QueryTabComponent = (props: IQueryTabComponentProps): any => { export const QueryTabComponent = (props: IQueryTabComponentProps): any => {
const styles = useQueryTabStyles(); const styles = useQueryTabStyles();
return <QueryTabComponentImpl styles={styles} {...props}></QueryTabComponentImpl>; return <QueryTabComponentImpl styles={styles} {...{ ...props }} />;
}; };
type QueryTabComponentImplProps = IQueryTabComponentProps & { type QueryTabComponentImplProps = IQueryTabComponentProps & {
@ -146,6 +149,8 @@ type QueryTabComponentImplProps = IQueryTabComponentProps & {
// Inner (legacy) class component. We only use this component via one of the two functional components above (since we need to use the `useQueryTabStyles` hook). // Inner (legacy) class component. We only use this component via one of the two functional components above (since we need to use the `useQueryTabStyles` hook).
class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps, IQueryTabStates> { class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps, IQueryTabStates> {
private static readonly DEBOUNCE_DELAY_MS = 1000;
public queryEditorId: string; public queryEditorId: string;
public executeQueryButton: Button; public executeQueryButton: Button;
public saveQueryButton: Button; public saveQueryButton: Button;
@ -157,10 +162,10 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
private _iterator: MinimalQueryIterator; private _iterator: MinimalQueryIterator;
private queryAbortController: AbortController; private queryAbortController: AbortController;
queryEditor: React.RefObject<EditorReact>; queryEditor: React.RefObject<EditorReact>;
private timeoutId: NodeJS.Timeout | undefined;
constructor(props: QueryTabComponentImplProps) { constructor(props: QueryTabComponentImplProps) {
super(props); super(props);
this.queryEditor = createRef<EditorReact>(); this.queryEditor = createRef<EditorReact>();
this.state = { this.state = {
@ -176,7 +181,9 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
cancelQueryTimeoutID: undefined, cancelQueryTimeoutID: undefined,
copilotActive: this._queryCopilotActive(), copilotActive: this._queryCopilotActive(),
currentTabActive: true, currentTabActive: true,
queryResultsView: getDefaultQueryResultsView(), queryResultsView:
props.splitterDirection === "vertical" ? SplitterDirection.Vertical : SplitterDirection.Horizontal,
queryViewSizePercent: props.queryViewSizePercent,
}; };
this.isCloseClicked = false; this.isCloseClicked = false;
this.splitterId = this.props.tabId + "_splitter"; this.splitterId = this.props.tabId + "_splitter";
@ -207,6 +214,23 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
}); });
} }
/**
* Helper function to save the query text in the query tab state
* Since it reads and writes to the same state, it is debounced
*/
private saveQueryTabStateDebounced = () => {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.timeoutId = setTimeout(async () => {
this.props.onUpdatePersistedState({
queryText: this.state.sqlQueryEditorContent,
splitterDirection: this.state.queryResultsView,
queryViewSizePercent: this.state.queryViewSizePercent,
});
}, QueryTabComponentImpl.DEBOUNCE_DELAY_MS);
};
private _queryCopilotActive(): boolean { private _queryCopilotActive(): boolean {
if (this.props.copilotEnabled) { if (this.props.copilotEnabled) {
return readCopilotToggleStatus(userContext.databaseAccount); return readCopilotToggleStatus(userContext.databaseAccount);
@ -567,7 +591,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
}; };
} }
private _setViewLayout(direction: SplitterDirection): void { private _setViewLayout(direction: SplitterDirection): void {
this.setState({ queryResultsView: direction }); this.setState({ queryResultsView: direction }, () => this.saveQueryTabStateDebounced());
// We'll need to refresh the context buttons to update the selected state of the view buttons // We'll need to refresh the context buttons to update the selected state of the view buttons
setTimeout(() => { setTimeout(() => {
@ -599,13 +623,16 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
if (this.state.copilotActive) { if (this.state.copilotActive) {
this.props.copilotStore?.setQuery(newContent); this.props.copilotStore?.setQuery(newContent);
} }
this.setState({ this.setState(
{
sqlQueryEditorContent: newContent, sqlQueryEditorContent: newContent,
queryCopilotGeneratedQuery: "", queryCopilotGeneratedQuery: "",
// Clear the markers when the user edits the document. // Clear the markers when the user edits the document.
modelMarkers: [], modelMarkers: [],
}); },
() => this.saveQueryTabStateDebounced(),
);
if (this.isPreferredApiMongoDB) { if (this.isPreferredApiMongoDB) {
if (newContent.length > 0) { if (newContent.length > 0) {
this.executeQueryButton = { this.executeQueryButton = {
@ -704,8 +731,20 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
></QueryCopilotPromptbar> ></QueryCopilotPromptbar>
)} )}
{/* Set 'key' to the value of vertical to force re-rendering when vertical changes, to work around https://github.com/johnwalley/allotment/issues/457 */} {/* Set 'key' to the value of vertical to force re-rendering when vertical changes, to work around https://github.com/johnwalley/allotment/issues/457 */}
<Allotment key={vertical.toString()} vertical={vertical}> <Allotment
<Allotment.Pane data-test="QueryTab/EditorPane"> key={vertical.toString()}
vertical={vertical}
onDragEnd={(sizes: number[]) => {
const queryViewSizePercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
this.setState({ queryViewSizePercent }, () => this.saveQueryTabStateDebounced());
}}
>
<Allotment.Pane
data-test="QueryTab/EditorPane"
preferredSize={
this.state.queryViewSizePercent !== undefined ? `${this.state.queryViewSizePercent}%` : undefined
}
>
<EditorReact <EditorReact
ref={this.queryEditor} ref={this.queryEditor}
className={this.props.styles.queryEditor} className={this.props.styles.queryEditor}

View File

@ -1,3 +1,4 @@
import { ActionType, OpenCollectionTab, TabKind } from "Contracts/ActionContracts";
import React from "react"; import React from "react";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import { SettingsComponent } from "../Controls/Settings/SettingsComponent"; import { SettingsComponent } from "../Controls/Settings/SettingsComponent";
@ -10,6 +11,18 @@ export class SettingsTabV2 extends TabsBase {
} }
export class CollectionSettingsTabV2 extends SettingsTabV2 { export class CollectionSettingsTabV2 extends SettingsTabV2 {
protected persistedState: OpenCollectionTab;
constructor(options: ViewModels.TabOptions) {
super(options);
this.persistedState = {
actionType: ActionType.OpenCollectionTab,
tabKind: TabKind.ScaleSettings,
databaseResourceId: options.collection.databaseId,
collectionResourceId: options.collection.id(),
};
}
public onActivate(): void { public onActivate(): void {
super.onActivate(); super.onActivate();
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.CollectionSettingsV2); this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.CollectionSettingsV2);

View File

@ -1,3 +1,4 @@
import { OpenTab } from "Contracts/ActionContracts";
import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts";
import * as ko from "knockout"; import * as ko from "knockout";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
@ -30,6 +31,8 @@ export default class TabsBase extends WaitsForTemplateViewModel {
protected _theme: string; protected _theme: string;
public onLoadStartKey: number; public onLoadStartKey: number;
protected persistedState: OpenTab | undefined = undefined; // Used to store state of tab for persistence
constructor(options: ViewModels.TabOptions) { constructor(options: ViewModels.TabOptions) {
super(); super();
this.index = options.index; this.index = options.index;
@ -55,6 +58,10 @@ export default class TabsBase extends WaitsForTemplateViewModel {
}; };
} }
// Called by useTabs to persist
public getPersistedState = (): OpenTab | null => this.persistedState;
public triggerPersistState: () => void = undefined;
public onCloseTabButtonClick(): void { public onCloseTabButtonClick(): void {
useTabs.getState().closeTab(this); useTabs.getState().closeTab(this);
TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, { TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, {

View File

@ -630,7 +630,13 @@ export default class Collection implements ViewModels.Collection {
} }
}; };
public onNewQueryClick(source: any, event: MouseEvent, queryText?: string) { public onNewQueryClick(
source: any,
event: MouseEvent,
queryText?: string,
splitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
) {
const collection: ViewModels.Collection = source.collection || source; const collection: ViewModels.Collection = source.collection || source;
const id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1; const id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1;
const title = "Query " + id; const title = "Query " + id;
@ -653,13 +659,21 @@ export default class Collection implements ViewModels.Collection {
queryText: queryText, queryText: queryText,
partitionKey: collection.partitionKey, partitionKey: collection.partitionKey,
onLoadStartKey: startKey, onLoadStartKey: startKey,
splitterDirection,
queryViewSizePercent,
}, },
{ container: this.container }, { container: this.container },
), ),
); );
} }
public onNewMongoQueryClick(source: any, event: MouseEvent, queryText?: string) { public onNewMongoQueryClick(
source: any,
event: MouseEvent,
queryText?: string,
splitterDirection?: "horizontal" | "vertical",
queryViewSizePercent?: number,
) {
const collection: ViewModels.Collection = source.collection || source; const collection: ViewModels.Collection = source.collection || source;
const id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1; const id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1;
@ -681,6 +695,9 @@ export default class Collection implements ViewModels.Collection {
node: this, node: this,
partitionKey: collection.partitionKey, partitionKey: collection.partitionKey,
onLoadStartKey: startKey, onLoadStartKey: startKey,
queryText,
splitterDirection,
queryViewSizePercent,
}, },
{ {
container: this.container, container: this.container,

View File

@ -38,6 +38,7 @@ export type Features = {
readonly copilotChatFixedMonacoEditorHeight: boolean; readonly copilotChatFixedMonacoEditorHeight: boolean;
readonly enablePriorityBasedExecution: boolean; readonly enablePriorityBasedExecution: boolean;
readonly disableConnectionStringLogin: boolean; readonly disableConnectionStringLogin: boolean;
readonly restoreTabs: boolean;
// can be set via both flight and feature flag // can be set via both flight and feature flag
autoscaleDefault: boolean; autoscaleDefault: boolean;
@ -108,6 +109,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"), copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"),
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"), enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"), disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
restoreTabs: "true" === get("restoretabs"),
}; };
} }

View File

@ -1,12 +1,20 @@
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext";
import * as ViewModels from "../Contracts/ViewModels";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
// The component name whose state is being saved. Component name must not include special characters. // The component name whose state is being saved. Component name must not include special characters.
export enum AppStateComponentNames { export enum AppStateComponentNames {
DocumentsTab = "DocumentsTab", DocumentsTab = "DocumentsTab",
MostRecentActivity = "MostRecentActivity", MostRecentActivity = "MostRecentActivity",
QueryCopilot = "QueryCopilot", QueryCopilot = "QueryCopilot",
DataExplorerAction = "DataExplorerAction",
} }
// Subcomponent for DataExplorerAction
export const OPEN_TABS_SUBCOMPONENT_NAME = "OpenTabs";
export const PATH_SEPARATOR = "/"; // export for testing purposes export const PATH_SEPARATOR = "/"; // export for testing purposes
const SCHEMA_VERSION = 1; const SCHEMA_VERSION = 1;
@ -72,12 +80,18 @@ export const hasState = (path: StorePath): boolean => {
}; };
// This is for high-frequency state changes // This is for high-frequency state changes
let timeoutId: NodeJS.Timeout | undefined; // Keep track of timeouts per path
const pathToTimeoutIdMap = new Map<string, NodeJS.Timeout>();
export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => { export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {
const key = createKeyFromPath(path);
const timeoutId = pathToTimeoutIdMap.get(key);
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs); pathToTimeoutIdMap.set(
key,
setTimeout(() => saveState(path, state), debounceDelayMs),
);
}; };
interface ApplicationState { interface ApplicationState {
@ -112,3 +126,93 @@ export const createKeyFromPath = (path: StorePath): string => {
export const deleteAllStates = (): void => { export const deleteAllStates = (): void => {
LocalStorageUtility.removeEntry(StorageKey.AppState); LocalStorageUtility.removeEntry(StorageKey.AppState);
}; };
// Convenience functions
/**
*
* @param subComponentName
* @param collection
* @param defaultValue Will be returned if persisted state is not found
* @returns
*/
export const readSubComponentState = <T>(
componentName: AppStateComponentNames,
subComponentName: string,
collection: ViewModels.CollectionBase | undefined,
defaultValue: T,
): T => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.ReadPersistedTabState, { message, componentName });
return defaultValue;
}
const state = loadState({
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection ? collection.databaseId : "",
containerName: collection ? collection.id() : "",
}) as T;
return state || defaultValue;
};
/**
*
* @param subComponentName
* @param collection
* @param state State to save
* @param debounce true for high-frequency calls (e.g mouse drag events)
*/
export const saveSubComponentState = <T>(
componentName: AppStateComponentNames,
subComponentName: string,
collection: ViewModels.CollectionBase | undefined,
state: T,
debounce?: boolean,
): void => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.SavePersistedTabState, { message, componentName });
return;
}
(debounce ? saveStateDebounced : saveState)(
{
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection ? collection.databaseId : "",
containerName: collection ? collection.id() : "",
},
state,
);
};
export const deleteSubComponentState = (
componentName: AppStateComponentNames,
subComponentName: string,
collection: ViewModels.CollectionBase,
) => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.DeletePersistedTabState, { message, componentName });
return;
}
deleteState({
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
});
};

View File

@ -7,6 +7,11 @@ import Explorer from "Explorer/Explorer";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane"; import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil"; import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import {
AppStateComponentNames,
OPEN_TABS_SUBCOMPONENT_NAME,
readSubComponentState,
} from "Shared/AppStatePersistenceUtility";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils"; import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility"; import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility";
@ -80,6 +85,11 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
await updateContextForCopilot(explorer); await updateContextForCopilot(explorer);
await updateContextForSampleData(explorer); await updateContextForSampleData(explorer);
} }
if (userContext.features.restoreTabs) {
restoreOpenTabs();
}
setExplorer(explorer); setExplorer(explorer);
} }
}; };
@ -816,3 +826,17 @@ async function updateContextForSampleData(explorer: Explorer): Promise<void> {
interface SampledataconnectionResponse { interface SampledataconnectionResponse {
connectionString: string; connectionString: string;
} }
const restoreOpenTabs = () => {
const openTabsState = readSubComponentState<(DataExplorerAction | undefined)[]>(
AppStateComponentNames.DataExplorerAction,
OPEN_TABS_SUBCOMPONENT_NAME,
undefined,
[],
);
openTabsState.forEach((openTabState) => {
if (openTabState) {
handleOpenAction(openTabState, useDatabases.getState().databases, this);
}
});
};

View File

@ -1,5 +1,11 @@
import { clamp } from "@fluentui/react"; import { clamp } from "@fluentui/react";
import { OpenTab } from "Contracts/ActionContracts";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import {
AppStateComponentNames,
OPEN_TABS_SUBCOMPONENT_NAME,
saveSubComponentState,
} from "Shared/AppStatePersistenceUtility";
import create, { UseStore } from "zustand"; import create, { UseStore } from "zustand";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
import { CollectionTabKind } from "../Contracts/ViewModels"; import { CollectionTabKind } from "../Contracts/ViewModels";
@ -36,6 +42,7 @@ export interface TabsState {
selectLeftTab: () => void; selectLeftTab: () => void;
selectRightTab: () => void; selectRightTab: () => void;
closeActiveTab: () => void; closeActiveTab: () => void;
persistTabsState: () => void;
} }
export enum ReactTabKind { export enum ReactTabKind {
@ -73,7 +80,9 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
}, },
activateNewTab: (tab: TabsBase): void => { activateNewTab: (tab: TabsBase): void => {
set((state) => ({ openedTabs: [...state.openedTabs, tab], activeTab: tab, activeReactTab: undefined })); set((state) => ({ openedTabs: [...state.openedTabs, tab], activeTab: tab, activeReactTab: undefined }));
tab.triggerPersistState = get().persistTabsState;
tab.onActivate(); tab.onActivate();
get().persistTabsState();
}, },
activateReactTab: (tabKind: ReactTabKind): void => { activateReactTab: (tabKind: ReactTabKind): void => {
// Clear the selected node when switching to a react tab. // Clear the selected node when switching to a react tab.
@ -130,6 +139,8 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
} }
set({ openedTabs: updatedTabs }); set({ openedTabs: updatedTabs });
get().persistTabsState();
}, },
closeAllNotebookTabs: (hardClose): void => { closeAllNotebookTabs: (hardClose): void => {
const isNotebook = (tabKind: CollectionTabKind): boolean => { const isNotebook = (tabKind: CollectionTabKind): boolean => {
@ -226,4 +237,15 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
state.closeTab(state.activeTab); state.closeTab(state.activeTab);
} }
}, },
persistTabsState: () => {
const state = get();
const openTabsStates = state.openedTabs.map((tab) => tab.getPersistedState());
saveSubComponentState<OpenTab[]>(
AppStateComponentNames.DataExplorerAction,
OPEN_TABS_SUBCOMPONENT_NAME,
undefined,
openTabsStates,
);
},
})); }));