diff --git a/src/Contracts/ActionContracts.ts b/src/Contracts/ActionContracts.ts index f8fc956e6..9d6c9f8a3 100644 --- a/src/Contracts/ActionContracts.ts +++ b/src/Contracts/ActionContracts.ts @@ -9,6 +9,7 @@ export enum TabKind { Graph, SQLQuery, ScaleSettings, + MongoQuery, } /** @@ -51,6 +52,8 @@ export interface OpenCollectionTab extends OpenTab { */ export interface OpenQueryTab extends OpenCollectionTab { query: QueryInfo; + splitterDirection?: "vertical" | "horizontal"; + queryViewSizePercent?: number; } /** diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index b30435dc6..0560c8931 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -115,7 +115,13 @@ export interface CollectionBase extends TreeNode { isSampleCollection?: boolean; 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; collapseCollection(): void; getDatabase(): Database; @@ -151,7 +157,13 @@ export interface Collection extends CollectionBase { onSettingsClick: () => Promise; 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; onNewStoredProcedureClick(source: Collection, event?: MouseEvent): void; onNewUserDefinedFunctionClick(source: Collection, event?: MouseEvent): void; @@ -311,6 +323,8 @@ export interface QueryTabOptions extends TabOptions { partitionKey?: DataModels.PartitionKey; queryText?: string; resourceTokenPartitionKey?: string; + splitterDirection?: "horizontal" | "vertical"; + queryViewSizePercent?: number; } export interface ScriptTabOption extends TabOptions { diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index c8c341e7e..dea7f7e95 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -1134,7 +1134,7 @@ export default class Explorer { if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") { userContext.authType === AuthType.ResourceToken ? 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(); diff --git a/src/Explorer/OpenActions/OpenActions.tsx b/src/Explorer/OpenActions/OpenActions.tsx index f3ef288c8..53fb896b5 100644 --- a/src/Explorer/OpenActions/OpenActions.tsx +++ b/src/Explorer/OpenActions/OpenActions.tsx @@ -1,4 +1,5 @@ // 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 React from "react"; import { ActionContracts } from "../../Contracts/ExplorerContracts"; @@ -56,6 +57,19 @@ function openCollectionTab( 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 if (!database.isDatabaseExpanded?.()) { database.expandDatabase?.(); @@ -121,10 +135,28 @@ function openCollectionTab( action.tabKind === ActionContracts.TabKind.SQLQuery || action.tabKind === ActionContracts.TabKind[ActionContracts.TabKind.SQLQuery] ) { + const openQueryTabAction = action as ActionContracts.OpenQueryTab; collection.onNewQueryClick( collection, 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; } diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts index f24f19eb4..d693a5ef3 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts @@ -3,17 +3,11 @@ import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent"; import { AppStateComponentNames, - deleteState, - loadState, - saveState, - saveStateDebounced, + deleteSubComponentState, + readSubComponentState, + saveSubComponentState, } from "Shared/AppStatePersistenceUtility"; -import { userContext } from "UserContext"; 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 { ColumnSizes = "ColumnSizes", @@ -21,6 +15,7 @@ export enum SubComponentName { MainTabDivider = "MainTabDivider", ColumnsSelection = "ColumnsSelection", ColumnSort = "ColumnSort", + CurrentFilter = "CurrentFilter", } export type ColumnSizesMap = { [columnId: string]: WidthDefinition }; @@ -30,84 +25,22 @@ export type TabDivider = { leftPaneWidthPercent: number }; export type ColumnsSelection = { selectedColumnIds: string[]; columnDefinitions: ColumnDefinition[] }; export type ColumnSort = { columnId: string; direction: "ascending" | "descending" }; -/** - * - * @param subComponentName - * @param collection - * @param defaultValue Will be returned if persisted state is not found - * @returns - */ -export const readSubComponentState = ( +// Wrap the ...SubComponentState functions for type safety + +export const readDocumentsTabSubComponentState = ( subComponentName: SubComponentName, collection: ViewModels.CollectionBase, 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; - } +): T => readSubComponentState(AppStateComponentNames.DocumentsTab, subComponentName, collection, defaultValue); - const state = loadState({ - 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 = ( +export const saveDocumentsTabSubComponentState = ( subComponentName: SubComponentName, collection: ViewModels.CollectionBase, 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; - } +): void => saveSubComponentState(AppStateComponentNames.DocumentsTab, subComponentName, collection, state, debounce); - (debounce ? saveStateDebounced : saveState)( - { - componentName: componentName, - subComponentName, - 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(), - }); -}; +export const deleteDocumentsTabSubComponentState = ( + subComponentName: SubComponentName, + collection: ViewModels.CollectionBase, +) => deleteSubComponentState(AppStateComponentNames.DocumentsTab, subComponentName, collection); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 83501336d..2548d582b 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -21,6 +21,7 @@ import { queryDocuments } from "Common/dataAccess/queryDocuments"; import { readDocument } from "Common/dataAccess/readDocument"; import { updateDocument } from "Common/dataAccess/updateDocument"; import { Platform, configContext } from "ConfigContext"; +import { ActionType, OpenCollectionTab, TabKind } from "Contracts/ActionContracts"; import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent"; import { useDialog } from "Explorer/Controls/Dialog"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; @@ -34,8 +35,9 @@ import { FilterHistory, SubComponentName, TabDivider, - readSubComponentState, - saveSubComponentState, + deleteDocumentsTabSubComponentState, + readDocumentsTabSubComponentState, + saveDocumentsTabSubComponentState, } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil"; import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil"; @@ -140,6 +142,8 @@ export class DocumentsTabV2 extends TabsBase { private title: string; private resourceTokenPartitionKey: string; + protected persistedState: OpenCollectionTab; + constructor(options: ViewModels.DocumentsTabOptions) { super(options); @@ -147,6 +151,13 @@ export class DocumentsTabV2 extends TabsBase { this.title = options.title; this.partitionKey = options.partitionKey; 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 { @@ -575,7 +586,10 @@ export const DocumentsTabComponent: React.FunctionComponent { - const [filterContent, setFilterContent] = useState(""); + const [filterContent, setFilterContent] = useState(() => + readDocumentsTabSubComponentState(SubComponentName.CurrentFilter, _collection, ""), + ); + const [documentIds, setDocumentIds] = useState([]); const [isExecuting, setIsExecuting] = useState(false); const styles = useDocumentsTabStyles(); @@ -606,7 +620,7 @@ export const DocumentsTabComponent: React.FunctionComponent(() => - readSubComponentState(SubComponentName.MainTabDivider, _collection, { + readDocumentsTabSubComponentState(SubComponentName.MainTabDivider, _collection, { leftPaneWidthPercent: 35, }), ); @@ -621,7 +635,7 @@ export const DocumentsTabComponent: React.FunctionComponent(() => - readSubComponentState(SubComponentName.FilterHistory, _collection, [] as FilterHistory), + readDocumentsTabSubComponentState(SubComponentName.FilterHistory, _collection, [] as FilterHistory), ); // For progress bar for bulk delete (noSql) @@ -763,7 +777,7 @@ export const DocumentsTabComponent: React.FunctionComponent(() => { - const persistedColumnsSelection = readSubComponentState( + const persistedColumnsSelection = readDocumentsTabSubComponentState( SubComponentName.ColumnsSelection, _collection, undefined, @@ -808,7 +822,7 @@ export const DocumentsTabComponent: React.FunctionComponent { setKeyboardActions({ [KeyboardAction.CLEAR_SEARCH]: () => { - setFilterContent(""); + updateFilterContent(""); refreshDocumentsGrid(true); return true; }, @@ -1645,7 +1659,7 @@ export const DocumentsTabComponent: React.FunctionComponent to garantee uniqueness const [columnDefinitions, setColumnDefinitions] = useState(() => { - const persistedColumnsSelection = readSubComponentState( + const persistedColumnsSelection = readDocumentsTabSubComponentState( SubComponentName.ColumnsSelection, _collection, undefined, @@ -1956,7 +1970,7 @@ export const DocumentsTabComponent: React.FunctionComponent(SubComponentName.FilterHistory, _collection, lastFilterContents); + saveDocumentsTabSubComponentState(SubComponentName.FilterHistory, _collection, lastFilterContents); }; const refreshDocumentsGrid = useCallback( @@ -2013,7 +2027,7 @@ export const DocumentsTabComponent: React.FunctionComponent(SubComponentName.ColumnsSelection, _collection, { + saveDocumentsTabSubComponentState(SubComponentName.ColumnsSelection, _collection, { selectedColumnIds: newSelectedColumnIds, columnDefinitions, }); @@ -2063,6 +2077,15 @@ export const DocumentsTabComponent: React.FunctionComponent { + if (filter === "" || filter === undefined) { + deleteDocumentsTabSubComponentState(SubComponentName.CurrentFilter, _collection); + } else { + saveDocumentsTabSubComponentState(SubComponentName.CurrentFilter, _collection, filter, true); + } + setFilterContent(filter); + }; + return (
@@ -2077,7 +2100,7 @@ export const DocumentsTabComponent: React.FunctionComponent setFilterContent(value)} + onChange={updateFilterContent} onKeyDown={onFilterKeyDown} bottomLink={{ text: "Learn more", url: DATA_EXPLORER_DOC_URL }} /> @@ -2103,7 +2126,7 @@ export const DocumentsTabComponent: React.FunctionComponent { tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]); - saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData); + saveDocumentsTabSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData); setTabStateData(tabStateData); }} > diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx index fc762dec4..b804ae60d 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx @@ -42,9 +42,9 @@ import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPan import { ColumnSizesMap, ColumnSort, - deleteSubComponentState, - readSubComponentState, - saveSubComponentState, + deleteDocumentsTabSubComponentState, + readDocumentsTabSubComponentState, + saveDocumentsTabSubComponentState, SubComponentName, } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil"; import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; @@ -118,7 +118,11 @@ export const DocumentsTableComponent: React.FC = const sortedRowsRef = React.useRef(null); const [columnSizingOptions, setColumnSizingOptions] = React.useState(() => { - const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {}); + const columnSizesMap: ColumnSizesMap = readDocumentsTabSubComponentState( + SubComponentName.ColumnSizes, + collection, + {}, + ); const columnSizesPx: TableColumnSizingOptions = {}; selectedColumnIds.forEach((columnId) => { if ( @@ -142,7 +146,7 @@ export const DocumentsTableComponent: React.FC = sortDirection: "ascending" | "descending"; sortColumn: TableColumnId | undefined; }>(() => { - const sort = readSubComponentState(SubComponentName.ColumnSort, collection, undefined); + const sort = readDocumentsTabSubComponentState(SubComponentName.ColumnSort, collection, undefined); if (!sort) { return { @@ -174,7 +178,12 @@ export const DocumentsTableComponent: React.FC = return acc; }, {} as ColumnSizesMap); - saveSubComponentState(SubComponentName.ColumnSizes, collection, persistentSizes, true); + saveDocumentsTabSubComponentState( + SubComponentName.ColumnSizes, + collection, + persistentSizes, + true, + ); return newSizingOptions; }); @@ -186,11 +195,14 @@ export const DocumentsTableComponent: React.FC = setColumnSort(event, columnId, direction); if (columnId === undefined || direction === undefined) { - deleteSubComponentState(SubComponentName.ColumnSort, collection); + deleteDocumentsTabSubComponentState(SubComponentName.ColumnSort, collection); return; } - saveSubComponentState(SubComponentName.ColumnSort, collection, { columnId, direction }); + saveDocumentsTabSubComponentState(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 diff --git a/src/Explorer/Tabs/MongoQueryTab/MongoQueryTab.tsx b/src/Explorer/Tabs/MongoQueryTab/MongoQueryTab.tsx index a533e9d28..22e50e1c8 100644 --- a/src/Explorer/Tabs/MongoQueryTab/MongoQueryTab.tsx +++ b/src/Explorer/Tabs/MongoQueryTab/MongoQueryTab.tsx @@ -1,3 +1,4 @@ +import { ActionType, TabKind } from "Contracts/ActionContracts"; import React from "react"; import MongoUtility from "../../../Common/MongoUtility"; import * as ViewModels from "../../../Contracts/ViewModels"; @@ -20,7 +21,7 @@ export class NewMongoQueryTab extends NewQueryTab { private mongoQueryTabProps: IMongoQueryTabProps, ) { super(options, mongoQueryTabProps); - this.queryText = ""; + this.queryText = options.queryText ?? ""; this.iMongoQueryTabComponentProps = { collection: options.collection, isExecutionError: this.isExecutionError(), @@ -28,6 +29,8 @@ export class NewMongoQueryTab extends NewQueryTab { tabsBaseInstance: this, queryText: this.queryText, partitionKey: this.partitionKey, + splitterDirection: options.splitterDirection, + queryViewSizePercent: options.queryViewSizePercent, container: this.mongoQueryTabProps.container, onTabAccessor: (instance: ITabAccessor): void => { this.iTabAccessor = instance; @@ -35,6 +38,26 @@ export class NewMongoQueryTab extends NewQueryTab { isPreferredApiMongoDB: true, monacoEditorSetting: "plaintext", 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(); + } + }, }; } diff --git a/src/Explorer/Tabs/QueryTab/QueryTab.tsx b/src/Explorer/Tabs/QueryTab/QueryTab.tsx index 09c4c959c..cb9864250 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTab.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTab.tsx @@ -1,4 +1,5 @@ import { sendMessage } from "Common/MessageHandler"; +import { ActionType, OpenQueryTab, TabKind } from "Contracts/ActionContracts"; import { MessageTypes } from "Contracts/MessageTypes"; import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext"; import { userContext } from "UserContext"; @@ -26,6 +27,8 @@ export class NewQueryTab extends TabsBase { public iQueryTabComponentProps: IQueryTabComponentProps; public iTabAccessor: ITabAccessor; + protected persistedState: OpenQueryTab; + constructor( options: QueryTabOptions, private props: IQueryTabProps, @@ -39,12 +42,41 @@ export class NewQueryTab extends TabsBase { tabsBaseInstance: this, queryText: options.queryText, partitionKey: this.partitionKey, + splitterDirection: options.splitterDirection, + queryViewSizePercent: options.queryViewSizePercent, container: this.props.container, onTabAccessor: (instance: ITabAccessor): void => { this.iTabAccessor = instance; }, 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 { diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx index f671ad628..3a81f76ab 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx @@ -34,6 +34,7 @@ jest.mock("Shared/AppStatePersistenceUtility", () => ({ AppStateComponentNames: { QueryCopilot: "QueryCopilot", }, + readSubComponentState: jest.fn(), })); describe("QueryTabComponent", () => { diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index 5f66836e4..2347e0822 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx @@ -18,13 +18,7 @@ import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil"; import { useSelectedNode } from "Explorer/useSelectedNode"; import { KeyboardAction } from "KeyboardShortcuts"; import { QueryConstants } from "Shared/Constants"; -import { - LocalStorageUtility, - StorageKey, - getDefaultQueryResultsView, - getRUThreshold, - ruThresholdEnabled, -} from "Shared/StorageUtility"; +import { LocalStorageUtility, StorageKey, getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility"; import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Allotment } from "allotment"; import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot"; @@ -99,6 +93,13 @@ export interface IQueryTabComponentProps { copilotEnabled?: boolean; isSampleCopilotActive?: boolean; copilotStore?: Partial; + splitterDirection?: "horizontal" | "vertical"; + queryViewSizePercent?: number; + onUpdatePersistedState: (state: { + queryText: string; + splitterDirection: "vertical" | "horizontal"; + queryViewSizePercent: number; + }) => void; } interface IQueryTabStates { @@ -118,11 +119,13 @@ interface IQueryTabStates { queryResultsView: SplitterDirection; errors?: QueryError[]; modelMarkers?: monaco.editor.IMarkerData[]; + queryViewSizePercent: number; } export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => { const styles = useQueryTabStyles(); const copilotStore = useCopilotStore(); + const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected(); const queryTabProps = { ...props, @@ -132,12 +135,12 @@ export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => isSampleCopilotActive: isSampleCopilotActive, copilotStore: copilotStore, }; - return ; + return ; }; export const QueryTabComponent = (props: IQueryTabComponentProps): any => { const styles = useQueryTabStyles(); - return ; + return ; }; 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). class QueryTabComponentImpl extends React.Component { + private static readonly DEBOUNCE_DELAY_MS = 1000; + public queryEditorId: string; public executeQueryButton: Button; public saveQueryButton: Button; @@ -157,10 +162,10 @@ class QueryTabComponentImpl extends React.Component; + private timeoutId: NodeJS.Timeout | undefined; constructor(props: QueryTabComponentImplProps) { super(props); - this.queryEditor = createRef(); this.state = { @@ -176,7 +181,9 @@ class QueryTabComponentImpl extends React.Component { + 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 { if (this.props.copilotEnabled) { return readCopilotToggleStatus(userContext.databaseAccount); @@ -567,7 +591,7 @@ class QueryTabComponentImpl extends React.Component this.saveQueryTabStateDebounced()); // We'll need to refresh the context buttons to update the selected state of the view buttons setTimeout(() => { @@ -599,13 +623,16 @@ class QueryTabComponentImpl extends React.Component this.saveQueryTabStateDebounced(), + ); if (this.isPreferredApiMongoDB) { if (newContent.length > 0) { this.executeQueryButton = { @@ -704,8 +731,20 @@ class QueryTabComponentImpl extends React.Component )} {/* Set 'key' to the value of vertical to force re-rendering when vertical changes, to work around https://github.com/johnwalley/allotment/issues/457 */} - - + { + const queryViewSizePercent = (100 * sizes[0]) / (sizes[0] + sizes[1]); + this.setState({ queryViewSizePercent }, () => this.saveQueryTabStateDebounced()); + }} + > + this.persistedState; + public triggerPersistState: () => void = undefined; + public onCloseTabButtonClick(): void { useTabs.getState().closeTab(this); TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, { diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index 8015f7643..2e08562e4 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -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 id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1; const title = "Query " + id; @@ -653,13 +659,21 @@ export default class Collection implements ViewModels.Collection { queryText: queryText, partitionKey: collection.partitionKey, onLoadStartKey: startKey, + splitterDirection, + queryViewSizePercent, }, { 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 id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1; @@ -681,6 +695,9 @@ export default class Collection implements ViewModels.Collection { node: this, partitionKey: collection.partitionKey, onLoadStartKey: startKey, + queryText, + splitterDirection, + queryViewSizePercent, }, { container: this.container, diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index 5bd84516e..e26cb9c12 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -38,6 +38,7 @@ export type Features = { readonly copilotChatFixedMonacoEditorHeight: boolean; readonly enablePriorityBasedExecution: boolean; readonly disableConnectionStringLogin: boolean; + readonly restoreTabs: boolean; // can be set via both flight and feature flag autoscaleDefault: boolean; @@ -108,6 +109,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"), enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"), disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"), + restoreTabs: "true" === get("restoretabs"), }; } diff --git a/src/Shared/AppStatePersistenceUtility.ts b/src/Shared/AppStatePersistenceUtility.ts index b48258d4f..58a02c3b3 100644 --- a/src/Shared/AppStatePersistenceUtility.ts +++ b/src/Shared/AppStatePersistenceUtility.ts @@ -1,12 +1,20 @@ 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. export enum AppStateComponentNames { DocumentsTab = "DocumentsTab", MostRecentActivity = "MostRecentActivity", QueryCopilot = "QueryCopilot", + DataExplorerAction = "DataExplorerAction", } +// Subcomponent for DataExplorerAction +export const OPEN_TABS_SUBCOMPONENT_NAME = "OpenTabs"; + export const PATH_SEPARATOR = "/"; // export for testing purposes const SCHEMA_VERSION = 1; @@ -72,12 +80,18 @@ export const hasState = (path: StorePath): boolean => { }; // This is for high-frequency state changes -let timeoutId: NodeJS.Timeout | undefined; +// Keep track of timeouts per path +const pathToTimeoutIdMap = new Map(); export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => { + const key = createKeyFromPath(path); + const timeoutId = pathToTimeoutIdMap.get(key); if (timeoutId) { clearTimeout(timeoutId); } - timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs); + pathToTimeoutIdMap.set( + key, + setTimeout(() => saveState(path, state), debounceDelayMs), + ); }; interface ApplicationState { @@ -112,3 +126,93 @@ export const createKeyFromPath = (path: StorePath): string => { export const deleteAllStates = (): void => { 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 = ( + 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 = ( + 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(), + }); +}; diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index eacc7f4c5..430cef277 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -7,6 +7,11 @@ import Explorer from "Explorer/Explorer"; import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane"; import { useSelectedNode } from "Explorer/useSelectedNode"; import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil"; +import { + AppStateComponentNames, + OPEN_TABS_SUBCOMPONENT_NAME, + readSubComponentState, +} from "Shared/AppStatePersistenceUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils"; import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility"; @@ -80,6 +85,11 @@ export function useKnockoutExplorer(platform: Platform): Explorer { await updateContextForCopilot(explorer); await updateContextForSampleData(explorer); } + + if (userContext.features.restoreTabs) { + restoreOpenTabs(); + } + setExplorer(explorer); } }; @@ -816,3 +826,17 @@ async function updateContextForSampleData(explorer: Explorer): Promise { interface SampledataconnectionResponse { 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); + } + }); +}; diff --git a/src/hooks/useTabs.ts b/src/hooks/useTabs.ts index ca6bc95cb..001dfcf87 100644 --- a/src/hooks/useTabs.ts +++ b/src/hooks/useTabs.ts @@ -1,5 +1,11 @@ import { clamp } from "@fluentui/react"; +import { OpenTab } from "Contracts/ActionContracts"; import { useSelectedNode } from "Explorer/useSelectedNode"; +import { + AppStateComponentNames, + OPEN_TABS_SUBCOMPONENT_NAME, + saveSubComponentState, +} from "Shared/AppStatePersistenceUtility"; import create, { UseStore } from "zustand"; import * as ViewModels from "../Contracts/ViewModels"; import { CollectionTabKind } from "../Contracts/ViewModels"; @@ -36,6 +42,7 @@ export interface TabsState { selectLeftTab: () => void; selectRightTab: () => void; closeActiveTab: () => void; + persistTabsState: () => void; } export enum ReactTabKind { @@ -73,7 +80,9 @@ export const useTabs: UseStore = create((set, get) => ({ }, activateNewTab: (tab: TabsBase): void => { set((state) => ({ openedTabs: [...state.openedTabs, tab], activeTab: tab, activeReactTab: undefined })); + tab.triggerPersistState = get().persistTabsState; tab.onActivate(); + get().persistTabsState(); }, activateReactTab: (tabKind: ReactTabKind): void => { // Clear the selected node when switching to a react tab. @@ -130,6 +139,8 @@ export const useTabs: UseStore = create((set, get) => ({ } set({ openedTabs: updatedTabs }); + + get().persistTabsState(); }, closeAllNotebookTabs: (hardClose): void => { const isNotebook = (tabKind: CollectionTabKind): boolean => { @@ -226,4 +237,15 @@ export const useTabs: UseStore = create((set, get) => ({ state.closeTab(state.activeTab); } }, + persistTabsState: () => { + const state = get(); + const openTabsStates = state.openedTabs.map((tab) => tab.getPersistedState()); + + saveSubComponentState( + AppStateComponentNames.DataExplorerAction, + OPEN_TABS_SUBCOMPONENT_NAME, + undefined, + openTabsStates, + ); + }, }));