diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index a55b0ca68..8c374a4c1 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -167,22 +167,18 @@ export function createContextCommandBarButtons( } export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] { - const buttons: CommandButtonComponentProps[] = - configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly - ? [] - : [ - { - iconSrc: SettingsIcon, - iconAlt: "Settings", - onCommandClick: () => - useSidePanel.getState().openSidePanel("Settings", ), - commandButtonLabel: undefined, - ariaLabel: "Settings", - tooltipText: "Settings", - hasPopup: true, - disabled: false, - }, - ]; + const buttons: CommandButtonComponentProps[] = [ + { + iconSrc: SettingsIcon, + iconAlt: "Settings", + onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", ), + commandButtonLabel: undefined, + ariaLabel: "Settings", + tooltipText: "Settings", + hasPopup: true, + disabled: false, + }, + ]; const showOpenFullScreen = configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin"; diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index 8d07fdad7..a8e6c17f5 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -1,6 +1,7 @@ import { Checkbox, ChoiceGroup, + DefaultButton, IChoiceGroupOption, ISpinButtonStyles, IToggleStyles, @@ -12,11 +13,15 @@ import { Toggle, TooltipHost, } from "@fluentui/react"; +import { makeStyles } from "@fluentui/react-components"; +import { AuthType } from "AuthType"; import * as Constants from "Common/Constants"; import { SplitterDirection } from "Common/Splitter"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { Platform, configContext } from "ConfigContext"; +import { useDialog } from "Explorer/Controls/Dialog"; import { useDatabases } from "Explorer/useDatabases"; +import { deleteAllStates } from "Shared/AppStatePersistenceUtility"; import { DefaultRUThreshold, LocalStorageUtility, @@ -29,14 +34,13 @@ import * as StringUtility from "Shared/StringUtility"; import { updateUserContext, userContext } from "UserContext"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; +import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useSidePanel } from "hooks/useSidePanel"; import React, { FunctionComponent, useState } from "react"; +import create, { UseStore } from "zustand"; import Explorer from "../../Explorer"; import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; -import { AuthType } from "AuthType"; -import create, { UseStore } from "zustand"; -import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; export interface DataPlaneRbacState { dataPlaneRbacEnabled: boolean; @@ -50,6 +54,13 @@ export interface DataPlaneRbacState { type DataPlaneRbacStore = UseStore>; +const useStyles = makeStyles({ + bulletList: { + listStyleType: "disc", + paddingLeft: "20px", + }, +}); + export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({ dataPlaneRbacEnabled: false, })); @@ -133,6 +144,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState( LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true", ); + + const styles = useStyles(); + const explorerVersion = configContext.gitSha; const shouldShowQueryPageOptions = userContext.apiType === "SQL"; const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin"; @@ -153,43 +167,45 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage); - LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption); - if ( - enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption || - (enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption && - userContext.databaseAccount.properties.disableLocalAuth) - ) { - updateUserContext({ - dataPlaneRbacEnabled: true, - hasDataPlaneRbacSettingChanged: true, - }); - useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true }); - } else { - updateUserContext({ - dataPlaneRbacEnabled: false, - hasDataPlaneRbacSettingChanged: true, - }); - const { databaseAccount: account, subscriptionId, resourceGroup } = userContext; - if (!userContext.features.enableAadDataPlane && !userContext.masterKey) { - let keys; - try { - keys = await listKeys(subscriptionId, resourceGroup, account.name); - updateUserContext({ - masterKey: keys.primaryMasterKey, - }); - } catch (error) { - // if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys - if (error.code === "AuthorizationFailed") { - keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name); + if (configContext.platform !== Platform.Fabric) { + LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption); + if ( + enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption || + (enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption && + userContext.databaseAccount.properties.disableLocalAuth) + ) { + updateUserContext({ + dataPlaneRbacEnabled: true, + hasDataPlaneRbacSettingChanged: true, + }); + useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true }); + } else { + updateUserContext({ + dataPlaneRbacEnabled: false, + hasDataPlaneRbacSettingChanged: true, + }); + const { databaseAccount: account, subscriptionId, resourceGroup } = userContext; + if (!userContext.features.enableAadDataPlane && !userContext.masterKey) { + let keys; + try { + keys = await listKeys(subscriptionId, resourceGroup, account.name); updateUserContext({ - masterKey: keys.primaryReadonlyMasterKey, + masterKey: keys.primaryMasterKey, }); - } else { - logConsoleError(`Error occurred fetching keys for the account." ${error.message}`); - throw error; + } catch (error) { + // if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys + if (error.code === "AuthorizationFailed") { + keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name); + updateUserContext({ + masterKey: keys.primaryReadonlyMasterKey, + }); + } else { + logConsoleError(`Error occurred fetching keys for the account." ${error.message}`); + throw error; + } } + useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false }); } - useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false }); } } @@ -476,55 +492,57 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ )} - {userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && ( - <> -
-
-
- - Enable Entra ID RBAC - - - Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra - ID RBAC. - - {" "} - Learn more{" "} - - - } - > - - - {showDataPlaneRBACWarning && configContext.platform === Platform.Portal && ( - setShowDataPlaneRBACWarning(false)} - dismissButtonAriaLabel="Close" + {userContext.apiType === "SQL" && + userContext.authType === AuthType.AAD && + configContext.platform !== Platform.Fabric && ( + <> +
+
+
+ + Enable Entra ID RBAC + + + Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable + Entra ID RBAC. + + {" "} + Learn more{" "} + + + } > - Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC - operations - - )} - -
+ + + {showDataPlaneRBACWarning && configContext.platform === Platform.Portal && ( + setShowDataPlaneRBACWarning(false)} + dismissButtonAriaLabel="Close" + > + Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC + operations + + )} + +
+
- - - )} + + )} {userContext.apiType === "SQL" && ( <>
@@ -830,6 +848,34 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
)} +
+
+ { + useDialog.getState().showOkCancelModalDialog( + "Clear History", + undefined, + "Are you sure you want to proceed?", + () => deleteAllStates(), + "Cancel", + undefined, + <> + + This action will clear the all customizations for this account in this browser, including: + +
    +
  • Reset your customized tab layout, including the splitter positions
  • +
  • Erase your table column preferences, including any custom columns
  • +
  • Clear your filter history
  • +
+ , + ); + }} + > + Clear History +
+
+
Explorer Version
diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index 6e2f9e001..633afd20e 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -485,6 +485,19 @@ exports[`Settings Pane should render Default properly 1`] = ` />
+
+
+ + Clear History + +
+
@@ -708,6 +721,19 @@ exports[`Settings Pane should render Gremlin properly 1`] = ` />
+
+
+ + Clear History + +
+
diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts new file mode 100644 index 000000000..fb01bdc54 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts @@ -0,0 +1,100 @@ +// Definitions of State data + +import { deleteState, loadState, saveState, saveStateDebounced } 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 = "DocumentsTab"; +export enum SubComponentName { + ColumnSizes = "ColumnSizes", + FilterHistory = "FilterHistory", + MainTabDivider = "MainTabDivider", +} + +export type ColumnSizesMap = { [columnId: string]: WidthDefinition }; +export type WidthDefinition = { idealWidth?: number; minWidth?: number }; +export type TabDivider = { leftPaneWidthPercent: number }; + +/** + * + * @param subComponentName + * @param collection + * @param defaultValue Will be returned if persisted state is not found + * @returns + */ +export const readSubComponentState = ( + 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; + } + + 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 = ( + 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; + } + + (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(), + }); +}; diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx index 267db89b6..59ae9052a 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx @@ -13,6 +13,7 @@ import { SAVE_BUTTON_ID, UPDATE_BUTTON_ID, UPLOAD_BUTTON_ID, + addStringsNoDuplicate, buildQuery, getDiscardExistingDocumentChangesButtonState, getDiscardNewDocumentChangesButtonState, @@ -339,7 +340,10 @@ describe("Documents tab (noSql API)", () => { const createMockProps = (): IDocumentsTabComponentProps => ({ isPreferredApiMongoDB: false, documentIds: [], - collection: undefined, + collection: { + id: ko.observable("collectionId"), + databaseId: "databaseId", + } as ViewModels.CollectionBase, partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 }, onLoadStartKey: 0, tabTitle: "", @@ -380,7 +384,7 @@ describe("Documents tab (noSql API)", () => { .findWhere((node) => node.text() === "Edit Filter") .at(0) .simulate("click"); - expect(wrapper.find("#filterInput").exists()).toBeTruthy(); + expect(wrapper.find("Input.filterInput").exists()).toBeTruthy(); }); }); @@ -474,3 +478,13 @@ describe("Documents tab (noSql API)", () => { }); }); }); + +describe("Documents tab", () => { + it("should add strings to array without duplicate", () => { + const array1 = ["a", "b", "c"]; + const array2 = ["b", "c", "d"]; + + const array3 = addStringsNoDuplicate(array1, array2); + expect(array3).toEqual(["a", "b", "c", "d"]); + }); +}); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 25e52bd2c..7f20dbce4 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -20,6 +20,12 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; import Explorer from "Explorer/Explorer"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; +import { + SubComponentName, + TabDivider, + readSubComponentState, + saveSubComponentState, +} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil"; import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil"; import { useSelectedNode } from "Explorer/useSelectedNode"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; @@ -51,6 +57,8 @@ import ObjectId from "../../Tree/ObjectId"; import TabsBase from "../TabsBase"; import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent"; +const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen + const loadMoreHeight = LayoutConstants.rowHeight; export const useDocumentsTabStyles = makeStyles({ container: { @@ -474,6 +482,24 @@ export const buildQuery = ( return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey); }; +/** + * Export to expose to unit tests + * + * Add array2 to array1 without duplicates + * @param array1 + * @param array2 + * @return array1 with array2 added without duplicates + */ +export const addStringsNoDuplicate = (array1: string[], array2: string[]): string[] => { + const result = [...array1]; + array2.forEach((item) => { + if (!result.includes(item)) { + result.push(item); + } + }); + return result; +}; + // Export to expose to unit tests export interface IDocumentsTabComponentProps { isPreferredApiMongoDB: boolean; @@ -488,6 +514,11 @@ export interface IDocumentsTabComponentProps { isTabActive: boolean; } +const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`; + +const defaultSqlFilters = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC']; +const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"]; + // Export to expose to unit tests export const DocumentsTabComponent: React.FunctionComponent = ({ isPreferredApiMongoDB, @@ -535,6 +566,13 @@ export const DocumentsTabComponent: React.FunctionComponent(() => + readSubComponentState(SubComponentName.MainTabDivider, _collection, { + leftPaneWidthPercent: 35, + }), + ); + const isQueryCopilotSampleContainer = _collection?.isSampleCollection && _collection?.databaseId === QueryCopilotSampleDatabaseId && @@ -543,6 +581,11 @@ export const DocumentsTabComponent: React.FunctionComponent(undefined); + // User's filter history + const [lastFilterContents, setLastFilterContents] = useState(() => + readSubComponentState(SubComponentName.FilterHistory, _collection, []), + ); + const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); useEffect(() => { @@ -568,8 +611,6 @@ export const DocumentsTabComponent: React.FunctionComponent): void => { if (e.key === "Enter") { - refreshDocumentsGrid(true); + onApplyFilterClick(); // Suppress the default behavior of the key e.preventDefault(); @@ -1442,7 +1483,6 @@ export const DocumentsTabComponent: React.FunctionComponent { if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); @@ -1663,6 +1703,24 @@ export const DocumentsTabComponent: React.FunctionComponent { + refreshDocumentsGrid(true); + + // Remove duplicates, but keep order + if (lastFilterContents.includes(filterContent)) { + lastFilterContents.splice(lastFilterContents.indexOf(filterContent), 1); + } + + // Save filter content to local storage + lastFilterContents.unshift(filterContent); + + // Keep the list size under MAX_FILTER_HISTORY_COUNT. Drop last element if needed. + const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT); + + setLastFilterContents(limitedLastFilterContents); + saveSubComponentState(SubComponentName.FilterHistory, _collection, lastFilterContents); + }; + const refreshDocumentsGrid = useCallback( (applyFilterButtonPressed: boolean): void => { // clear documents grid @@ -1721,12 +1779,11 @@ export const DocumentsTabComponent: React.FunctionComponent {!isPreferredApiMongoDB && SELECT * FROM c } setIsFilterFocused(false)} /> - - {lastFilterContents.map((filter) => ( + + {addStringsNoDuplicate( + lastFilterContents, + isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters, + ).map((filter) => ( @@ -1749,7 +1809,7 @@ export const DocumentsTabComponent: React.FunctionComponent refreshDocumentsGrid(true)} + onClick={onApplyFilterClick} disabled={!applyFilterButton.enabled} aria-label="Apply filter" tabIndex={0} @@ -1780,11 +1840,16 @@ export const DocumentsTabComponent: React.FunctionComponent )} - {/* doesn't like to be a flex child */}
- - + { + tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]); + saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData); + setTabStateData(tabStateData); + }} + > +
@@ -1813,6 +1878,7 @@ export const DocumentsTabComponent: React.FunctionComponent
{tableItems.length > 0 && ( @@ -1828,7 +1894,7 @@ export const DocumentsTabComponent: React.FunctionComponent - +
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && ( { partitionKeyHeaders: [PARTITION_KEY_HEADER], }, isSelectionDisabled: false, + collection: { + databaseId: "db", + id: ((): string => "coll") as ko.Observable, + } as ViewModels.CollectionBase, }); it("should render documents and partition keys in header", () => { diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx index b6cc25355..7d785f6b1 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx @@ -1,4 +1,5 @@ import { + createTableColumn, Menu, MenuItem, MenuList, @@ -16,19 +17,26 @@ import { TableRow, TableRowId, TableSelectionCell, - createTableColumn, useArrowNavigationGroup, useTableColumnSizing_unstable, useTableFeatures, useTableSelection, } from "@fluentui/react-components"; import { NormalizedEventKey } from "Common/Constants"; +import { + ColumnSizesMap, + readSubComponentState, + saveSubComponentState, + SubComponentName, + WidthDefinition, +} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil"; import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; import { LayoutConstants } from "Explorer/Theme/ThemeUtil"; import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils"; import React, { useCallback, useMemo } from "react"; import { FixedSizeList as List, ListChildComponentProps } from "react-window"; +import * as ViewModels from "../../../Contracts/ViewModels"; export type DocumentsTableComponentItem = { id: string; @@ -47,6 +55,7 @@ export interface IDocumentsTableComponentProps { columnHeaders: ColumnHeaders; style?: React.CSSProperties; isSelectionDisabled?: boolean; + collection: ViewModels.CollectionBase; } interface TableRowData extends RowStateBase { @@ -59,6 +68,11 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps { data: TableRowData[]; } +const defaultSize: WidthDefinition = { + idealWidth: 200, + minWidth: 50, +}; + export const DocumentsTableComponent: React.FC = ({ items, onSelectedRowsChange, @@ -67,32 +81,34 @@ export const DocumentsTableComponent: React.FC = size, columnHeaders, isSelectionDisabled, + collection, }: IDocumentsTableComponentProps) => { - const styles = useDocumentsTabStyles(); - - const initialSizingOptions: TableColumnSizingOptions = { - id: { - idealWidth: 280, - minWidth: 50, - }, - }; - columnHeaders.partitionKeyHeaders.forEach((pkHeader) => { - initialSizingOptions[pkHeader] = { - idealWidth: 200, - minWidth: 50, - }; + const [columnSizingOptions, setColumnSizingOptions] = React.useState(() => { + const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders); + const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {}); + const columnSizesPx: ColumnSizesMap = {}; + columnIds.forEach((columnId) => { + columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize; + }); + return columnSizesPx; }); - const [columnSizingOptions, setColumnSizingOptions] = React.useState(initialSizingOptions); + const styles = useDocumentsTabStyles(); const onColumnResize = React.useCallback((_, { columnId, width }) => { - setColumnSizingOptions((state) => ({ - ...state, - [columnId]: { - ...state[columnId], - idealWidth: width, - }, - })); + setColumnSizingOptions((state) => { + const newSizingOptions = { + ...state, + [columnId]: { + ...state[columnId], + idealWidth: width, + }, + }; + + saveSubComponentState(SubComponentName.ColumnSizes, collection, newSizingOptions, true); + + return newSizingOptions; + }); }, []); // 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/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap index 7b9bae63c..93719e55c 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap @@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = ` } } > - +
({ + LocalStorageUtility: { + getEntryObject: jest.fn(), + setEntryObject: jest.fn(), + }, + StorageKey: { + AppState: "AppState", + }, +})); + +describe("AppStatePersistenceUtility", () => { + const storePath = { + componentName: "a", + subComponentName: "b", + globalAccountName: "c", + databaseName: "d", + containerName: "e", + }; + const key = createKeyFromPath(storePath); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({ + key0: { + schemaVersion: 1, + timestamp: 0, + data: {}, + }, + }); + }); + + describe("saveState()", () => { + const testState = { aa: 1, bb: "2", cc: [3, 4] }; + + it("should save state", () => { + saveState(storePath, testState); + expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1); + expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledWith(StorageKey.AppState, expect.any(Object)); + + const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1]; + expect(passedState[key].data).toHaveProperty("aa", 1); + }); + + it("should save state with timestamp", () => { + saveState(storePath, testState); + const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1]; + expect(passedState[key]).toHaveProperty("timestamp"); + expect(passedState[key].timestamp).toBeGreaterThan(0); + }); + + it("should add state to existing state", () => { + (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({ + key0: { + schemaVersion: 1, + timestamp: 0, + data: { dd: 5 }, + }, + }); + + saveState(storePath, testState); + const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1]; + expect(passedState["key0"].data).toHaveProperty("dd", 5); + }); + + it("should remove the oldest entry when the number of entries exceeds the limit", () => { + // Fill up storage with MAX entries + const currentAppState = {}; + for (let i = 0; i < MAX_ENTRY_NB; i++) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (currentAppState as any)[`key${i}`] = { + schemaVersion: 1, + timestamp: i, + data: {}, + }; + } + (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(currentAppState); + + saveState(storePath, testState); + + // Verify that the new entry is saved + const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1]; + expect(passedState[key].data).toHaveProperty("aa", 1); + + // Verify that the oldest entry is removed (smallest timestamp) + const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1]; + expect(Object.keys(passedAppState).length).toBe(MAX_ENTRY_NB); + expect(passedAppState).not.toHaveProperty("key0"); + }); + + it("should not remove the oldest entry when the number of entries does not exceed the limit", () => { + (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({ + key0: { + schemaVersion: 1, + timestamp: 0, + data: {}, + }, + key1: { + schemaVersion: 1, + timestamp: 1, + data: {}, + }, + }); + saveState(storePath, testState); + const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1]; + expect(Object.keys(passedAppState).length).toBe(3); + }); + }); + + describe("loadState()", () => { + it("should load state", () => { + const data = { aa: 1, bb: "2", cc: [3, 4] }; + const testState = { + [key]: { + schemaVersion: 1, + timestamp: 0, + data, + }, + }; + (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(testState); + const state = loadState(storePath); + expect(state).toEqual(data); + }); + + it("should return undefined if the state is not found", () => { + (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(null); + const state = loadState(storePath); + expect(state).toBeUndefined(); + }); + }); + + describe("deleteState()", () => { + it("should delete state", () => { + const key = createKeyFromPath(storePath); + (LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({ + [key]: { + schemaVersion: 1, + timestamp: 0, + data: {}, + }, + otherKey: { + schemaVersion: 2, + timestamp: 0, + data: {}, + }, + }); + + deleteState(storePath); + expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1); + const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1]; + expect(passedAppState).not.toHaveProperty(key); + expect(passedAppState).toHaveProperty("otherKey"); + }); + }); + describe("createKeyFromPath()", () => { + it("should create path that contains all components", () => { + const key = createKeyFromPath(storePath); + expect(key).toContain(storePath.componentName); + expect(key).toContain(storePath.subComponentName); + expect(key).toContain(storePath.globalAccountName); + expect(key).toContain(storePath.databaseName); + expect(key).toContain(storePath.containerName); + }); + }); +}); diff --git a/src/Shared/AppStatePersistenceUtility.ts b/src/Shared/AppStatePersistenceUtility.ts new file mode 100644 index 000000000..bcf5ad7f3 --- /dev/null +++ b/src/Shared/AppStatePersistenceUtility.ts @@ -0,0 +1,109 @@ +import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; + +// The component name whose state is being saved. Component name must not include special characters. +export type ComponentName = "DocumentsTab"; + +const SCHEMA_VERSION = 1; + +// Export for testing purposes +export const MAX_ENTRY_NB = 100_000; // Limit number of entries to 100k + +export interface StateData { + schemaVersion: number; + timestamp: number; + data: unknown; +} + +type StorePath = { + componentName: string; + subComponentName?: string; + globalAccountName?: string; + databaseName?: string; + containerName?: string; +}; + +// Load and save state data +export const loadState = (path: StorePath): unknown => { + const appState = + LocalStorageUtility.getEntryObject(StorageKey.AppState) || ({} as ApplicationState); + const key = createKeyFromPath(path); + return appState[key]?.data; +}; +export const saveState = (path: StorePath, state: unknown): void => { + // Retrieve state object + const appState = + LocalStorageUtility.getEntryObject(StorageKey.AppState) || ({} as ApplicationState); + const key = createKeyFromPath(path); + appState[key] = { + schemaVersion: SCHEMA_VERSION, + timestamp: Date.now(), + data: state, + }; + + if (Object.keys(appState).length > MAX_ENTRY_NB) { + // Remove the oldest entry + const oldestKey = Object.keys(appState).reduce((oldest, current) => + appState[current].timestamp < appState[oldest].timestamp ? current : oldest, + ); + delete appState[oldestKey]; + } + + LocalStorageUtility.setEntryObject(StorageKey.AppState, appState); +}; + +export const deleteState = (path: StorePath): void => { + // Retrieve state object + const appState = + LocalStorageUtility.getEntryObject(StorageKey.AppState) || ({} as ApplicationState); + const key = createKeyFromPath(path); + delete appState[key]; + LocalStorageUtility.setEntryObject(StorageKey.AppState, appState); +}; + +// This is for high-frequency state changes +let timeoutId: NodeJS.Timeout | undefined; +export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs); +}; + +interface ApplicationState { + [statePath: string]: StateData; +} + +const orderedPathSegments: (keyof StorePath)[] = [ + "subComponentName", + "globalAccountName", + "databaseName", + "containerName", +]; + +/** + * /componentName/subComponentName/globalAccountName/databaseName/containerName/ + * Any of the path segments can be "" except componentName + * Export for testing purposes + * @param path + */ +export const createKeyFromPath = (path: StorePath): string => { + if (path.componentName.includes("/")) { + throw new Error(`Invalid component name: ${path.componentName}`); + } + let key = `/${path.componentName}`; // ComponentName is always there + orderedPathSegments.forEach((segment) => { + const segmentValue = path[segment as keyof StorePath]; + if (segmentValue.includes("/")) { + throw new Error(`Invalid setting path segment: ${segment}`); + } + key += `/${segmentValue !== undefined ? segmentValue : ""}`; + }); + return key; +}; + +/** + * Remove the entire app state key from local storage + */ +export const deleteAllStates = (): void => { + LocalStorageUtility.removeEntry(StorageKey.AppState); +}; diff --git a/src/Shared/LocalStorageUtility.ts b/src/Shared/LocalStorageUtility.ts index 9fc2f4f7c..097f45877 100644 --- a/src/Shared/LocalStorageUtility.ts +++ b/src/Shared/LocalStorageUtility.ts @@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void => export const setEntryBoolean = (key: StorageKey, value: boolean): void => localStorage.setItem(StorageKey[key], value.toString()); + +export const setEntryObject = (key: StorageKey, value: unknown): void => { + localStorage.setItem(StorageKey[key], JSON.stringify(value)); +}; +export const getEntryObject = (key: StorageKey): T | null => { + const item = localStorage.getItem(StorageKey[key]); + if (item) { + return JSON.parse(item) as T; + } + return null; +}; diff --git a/src/Shared/StorageUtility.ts b/src/Shared/StorageUtility.ts index f2ca1f20b..952bcd9ac 100644 --- a/src/Shared/StorageUtility.ts +++ b/src/Shared/StorageUtility.ts @@ -30,6 +30,7 @@ export enum StorageKey { VisitedAccounts, PriorityLevel, DefaultQueryResultsView, + AppState, } export const hasRUThresholdBeenConfigured = (): boolean => { diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index 3b4892990..1fae132ad 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -139,6 +139,9 @@ export enum Action { QueryEdited, ExecuteQueryGeneratedFromQueryCopilot, DeleteDocuments, + ReadPersistedTabState, + SavePersistedTabState, + DeletePersistedTabState, } export const ActionModifiers = {