Save and restore DocumentsTab state to local storage (#1919)

* Infrastructure to save app state

* Save filters

* Replace read/save methods with more generic ones

* Make datalist for filter unique per database/container combination

* Disable saving middle split position for now

* Fix unit tests

* Turn off confusing auto-complete from input box

* Disable tabStateData for now

* Save and restore split position

* Fix replace autocomplete="off" by removing id on Input tag

* Properly set allotment width

* Fix saved percentage

* Save splitter per collection

* Add error handling and telemetry

* Fix compiling issue

* Add ability to delete filter history. Bug fix when hitting Enter on filter input box.

* Replace delete filter modal with dropdown menu

* Add code to remove oldest record if max limit is reached in app state persistence

* Only save new splitter position on drag end (not onchange)

* Add unit tests

* Add Clear all in settings. Update snapshots

* Fix format

* Remove filter delete and keep filter history to a max. Reword clear button and message in settings pane.

* Fix setting button label

* Update test snapshots

* Reword Clear history button text

* Update unit test snapshot

* Enable Settings pane for Fabric, but turn off Rbac dial for Fabric.

* Change union type to enum

* Update src/Shared/AppStatePersistenceUtility.ts

Assert that path does not include slash char.

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Update src/Shared/AppStatePersistenceUtility.ts

Assert that path does not contain slash.

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Fix format

---------

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>
This commit is contained in:
Laurent Nguyen 2024-08-22 07:37:15 +02:00 committed by GitHub
parent 94d3fcb30f
commit 038142c180
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 723 additions and 141 deletions

View File

@ -167,15 +167,11 @@ export function createContextCommandBarButtons(
} }
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] { export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = const buttons: CommandButtonComponentProps[] = [
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
? []
: [
{ {
iconSrc: SettingsIcon, iconSrc: SettingsIcon,
iconAlt: "Settings", iconAlt: "Settings",
onCommandClick: () => onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
commandButtonLabel: undefined, commandButtonLabel: undefined,
ariaLabel: "Settings", ariaLabel: "Settings",
tooltipText: "Settings", tooltipText: "Settings",

View File

@ -1,6 +1,7 @@
import { import {
Checkbox, Checkbox,
ChoiceGroup, ChoiceGroup,
DefaultButton,
IChoiceGroupOption, IChoiceGroupOption,
ISpinButtonStyles, ISpinButtonStyles,
IToggleStyles, IToggleStyles,
@ -12,11 +13,15 @@ import {
Toggle, Toggle,
TooltipHost, TooltipHost,
} from "@fluentui/react"; } from "@fluentui/react";
import { makeStyles } from "@fluentui/react-components";
import { AuthType } from "AuthType";
import * as Constants from "Common/Constants"; import * as Constants from "Common/Constants";
import { SplitterDirection } from "Common/Splitter"; import { SplitterDirection } from "Common/Splitter";
import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { deleteAllStates } from "Shared/AppStatePersistenceUtility";
import { import {
DefaultRUThreshold, DefaultRUThreshold,
LocalStorageUtility, LocalStorageUtility,
@ -29,14 +34,13 @@ import * as StringUtility from "Shared/StringUtility";
import { updateUserContext, userContext } from "UserContext"; import { updateUserContext, userContext } from "UserContext";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import create, { UseStore } from "zustand";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; 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 { export interface DataPlaneRbacState {
dataPlaneRbacEnabled: boolean; dataPlaneRbacEnabled: boolean;
@ -50,6 +54,13 @@ export interface DataPlaneRbacState {
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>; type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
const useStyles = makeStyles({
bulletList: {
listStyleType: "disc",
paddingLeft: "20px",
},
});
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({ export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
dataPlaneRbacEnabled: false, dataPlaneRbacEnabled: false,
})); }));
@ -133,6 +144,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>( const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true", LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
); );
const styles = useStyles();
const explorerVersion = configContext.gitSha; const explorerVersion = configContext.gitSha;
const shouldShowQueryPageOptions = userContext.apiType === "SQL"; const shouldShowQueryPageOptions = userContext.apiType === "SQL";
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin"; const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
@ -153,6 +167,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage); LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
if (configContext.platform !== Platform.Fabric) {
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption); LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
if ( if (
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption || enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
@ -192,6 +207,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false }); useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
} }
} }
}
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled); LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled); LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
@ -476,7 +492,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</div> </div>
</div> </div>
)} )}
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && ( {userContext.apiType === "SQL" &&
userContext.authType === AuthType.AAD &&
configContext.platform !== Platform.Fabric && (
<> <>
<div className="settingsSection"> <div className="settingsSection">
<div className="settingsSectionPart"> <div className="settingsSectionPart">
@ -487,8 +505,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<TooltipHost <TooltipHost
content={ content={
<> <>
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable
ID RBAC. Entra ID RBAC.
<a <a
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer" href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
target="_blank" target="_blank"
@ -830,6 +848,34 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</div> </div>
</div> </div>
)} )}
<div className="settingsSection">
<div className="settingsSectionPart">
<DefaultButton
onClick={() => {
useDialog.getState().showOkCancelModalDialog(
"Clear History",
undefined,
"Are you sure you want to proceed?",
() => deleteAllStates(),
"Cancel",
undefined,
<>
<span>
This action will clear the all customizations for this account in this browser, including:
</span>
<ul className={styles.bulletList}>
<li>Reset your customized tab layout, including the splitter positions</li>
<li>Erase your table column preferences, including any custom columns</li>
<li>Clear your filter history</li>
</ul>
</>,
);
}}
>
Clear History
</DefaultButton>
</div>
</div>
<div className="settingsSection"> <div className="settingsSection">
<div className="settingsSectionPart"> <div className="settingsSectionPart">
<div className="settingsSectionLabel">Explorer Version</div> <div className="settingsSectionLabel">Explorer Version</div>

View File

@ -485,6 +485,19 @@ exports[`Settings Pane should render Default properly 1`] = `
/> />
</div> </div>
</div> </div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<CustomizedDefaultButton
onClick={[Function]}
>
Clear History
</CustomizedDefaultButton>
</div>
</div>
<div <div
className="settingsSection" className="settingsSection"
> >
@ -708,6 +721,19 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
/> />
</div> </div>
</div> </div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<CustomizedDefaultButton
onClick={[Function]}
>
Clear History
</CustomizedDefaultButton>
</div>
</div>
<div <div
className="settingsSection" className="settingsSection"
> >

View File

@ -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 = <T>(
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 = <T>(
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(),
});
};

View File

@ -13,6 +13,7 @@ import {
SAVE_BUTTON_ID, SAVE_BUTTON_ID,
UPDATE_BUTTON_ID, UPDATE_BUTTON_ID,
UPLOAD_BUTTON_ID, UPLOAD_BUTTON_ID,
addStringsNoDuplicate,
buildQuery, buildQuery,
getDiscardExistingDocumentChangesButtonState, getDiscardExistingDocumentChangesButtonState,
getDiscardNewDocumentChangesButtonState, getDiscardNewDocumentChangesButtonState,
@ -339,7 +340,10 @@ describe("Documents tab (noSql API)", () => {
const createMockProps = (): IDocumentsTabComponentProps => ({ const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false, isPreferredApiMongoDB: false,
documentIds: [], documentIds: [],
collection: undefined, collection: {
id: ko.observable<string>("collectionId"),
databaseId: "databaseId",
} as ViewModels.CollectionBase,
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 }, partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
onLoadStartKey: 0, onLoadStartKey: 0,
tabTitle: "", tabTitle: "",
@ -380,7 +384,7 @@ describe("Documents tab (noSql API)", () => {
.findWhere((node) => node.text() === "Edit Filter") .findWhere((node) => node.text() === "Edit Filter")
.at(0) .at(0)
.simulate("click"); .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"]);
});
});

View File

@ -20,6 +20,12 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import Explorer from "Explorer/Explorer"; import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; 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 { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
@ -51,6 +57,8 @@ import ObjectId from "../../Tree/ObjectId";
import TabsBase from "../TabsBase"; import TabsBase from "../TabsBase";
import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent"; 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; const loadMoreHeight = LayoutConstants.rowHeight;
export const useDocumentsTabStyles = makeStyles({ export const useDocumentsTabStyles = makeStyles({
container: { container: {
@ -474,6 +482,24 @@ export const buildQuery = (
return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey); 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 to expose to unit tests
export interface IDocumentsTabComponentProps { export interface IDocumentsTabComponentProps {
isPreferredApiMongoDB: boolean; isPreferredApiMongoDB: boolean;
@ -488,6 +514,11 @@ export interface IDocumentsTabComponentProps {
isTabActive: boolean; 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 to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
isPreferredApiMongoDB, isPreferredApiMongoDB,
@ -535,6 +566,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
ViewModels.DocumentExplorerState.noDocumentSelected, ViewModels.DocumentExplorerState.noDocumentSelected,
); );
// State
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
readSubComponentState(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35,
}),
);
const isQueryCopilotSampleContainer = const isQueryCopilotSampleContainer =
_collection?.isSampleCollection && _collection?.isSampleCollection &&
_collection?.databaseId === QueryCopilotSampleDatabaseId && _collection?.databaseId === QueryCopilotSampleDatabaseId &&
@ -543,6 +581,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// For Mongo only // For Mongo only
const [continuationToken, setContinuationToken] = useState<string>(undefined); const [continuationToken, setContinuationToken] = useState<string>(undefined);
// User's filter history
const [lastFilterContents, setLastFilterContents] = useState<string[]>(() =>
readSubComponentState(SubComponentName.FilterHistory, _collection, []),
);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
useEffect(() => { useEffect(() => {
@ -568,8 +611,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
}, [documentIds, clickedRowIndex, editorState]); }, [documentIds, clickedRowIndex, editorState]);
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
const applyFilterButton = { const applyFilterButton = {
enabled: true, enabled: true,
visible: true, visible: true,
@ -1239,7 +1280,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") { if (e.key === "Enter") {
refreshDocumentsGrid(true); onApplyFilterClick();
// Suppress the default behavior of the key // Suppress the default behavior of the key
e.preventDefault(); e.preventDefault();
@ -1442,7 +1483,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKey; return partitionKey;
}; };
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => { partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
@ -1663,6 +1703,24 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
// ***************** Mongo *************************** // ***************** Mongo ***************************
const onApplyFilterClick = (): void => {
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( const refreshDocumentsGrid = useCallback(
(applyFilterButtonPressed: boolean): void => { (applyFilterButtonPressed: boolean): void => {
// clear documents grid // clear documents grid
@ -1721,12 +1779,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<div className={styles.filterRow}> <div className={styles.filterRow}>
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>} {!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
<Input <Input
id="filterInput"
ref={filterInput} ref={filterInput}
type="text" type="text"
size="small" size="small"
list="filtersList" list={`filtersList-${getUniqueId(_collection)}`}
className={styles.filterInput} className={`filterInput ${styles.filterInput}`}
title="Type a query predicate or choose one from the list." title="Type a query predicate or choose one from the list."
placeholder={ placeholder={
isPreferredApiMongoDB isPreferredApiMongoDB
@ -1740,8 +1797,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
onBlur={() => setIsFilterFocused(false)} onBlur={() => setIsFilterFocused(false)}
/> />
<datalist id="filtersList"> <datalist id={`filtersList-${getUniqueId(_collection)}`}>
{lastFilterContents.map((filter) => ( {addStringsNoDuplicate(
lastFilterContents,
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
).map((filter) => (
<option key={filter} value={filter} /> <option key={filter} value={filter} />
))} ))}
</datalist> </datalist>
@ -1749,7 +1809,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<Button <Button
appearance="primary" appearance="primary"
size="small" size="small"
onClick={() => refreshDocumentsGrid(true)} onClick={onApplyFilterClick}
disabled={!applyFilterButton.enabled} disabled={!applyFilterButton.enabled}
aria-label="Apply filter" aria-label="Apply filter"
tabIndex={0} tabIndex={0}
@ -1780,11 +1840,16 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)} )}
</> </>
)} )}
{/* <Split> doesn't like to be a flex child */} {/* <Split> doesn't like to be a flex child */}
<div style={{ overflow: "hidden", height: "100%" }}> <div style={{ overflow: "hidden", height: "100%" }}>
<Allotment> <Allotment
<Allotment.Pane preferredSize="35%" minSize={175}> onDragEnd={(sizes: number[]) => {
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData);
setTabStateData(tabStateData);
}}
>
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}> <div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
<div className={styles.floatingControlsContainer}> <div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}> <div className={styles.floatingControls}>
@ -1813,6 +1878,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(partitionKey.systemKey && !isPreferredApiMongoDB) || (partitionKey.systemKey && !isPreferredApiMongoDB) ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
} }
collection={_collection}
/> />
</div> </div>
{tableItems.length > 0 && ( {tableItems.length > 0 && (
@ -1828,7 +1894,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)} )}
</div> </div>
</Allotment.Pane> </Allotment.Pane>
<Allotment.Pane preferredSize="65%" minSize={300}> <Allotment.Pane minSize={30}>
<div style={{ height: "100%", width: "100%" }}> <div style={{ height: "100%", width: "100%" }}>
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && ( {isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
<EditorReact <EditorReact

View File

@ -1,6 +1,7 @@
import { TableRowId } from "@fluentui/react-components"; import { TableRowId } from "@fluentui/react-components";
import { mount } from "enzyme"; import { mount } from "enzyme";
import React from "react"; import React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent"; import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
const PARTITION_KEY_HEADER = "partitionKey"; const PARTITION_KEY_HEADER = "partitionKey";
@ -25,6 +26,10 @@ describe("DocumentsTableComponent", () => {
partitionKeyHeaders: [PARTITION_KEY_HEADER], partitionKeyHeaders: [PARTITION_KEY_HEADER],
}, },
isSelectionDisabled: false, isSelectionDisabled: false,
collection: {
databaseId: "db",
id: ((): string => "coll") as ko.Observable<string>,
} as ViewModels.CollectionBase,
}); });
it("should render documents and partition keys in header", () => { it("should render documents and partition keys in header", () => {

View File

@ -1,4 +1,5 @@
import { import {
createTableColumn,
Menu, Menu,
MenuItem, MenuItem,
MenuList, MenuList,
@ -16,19 +17,26 @@ import {
TableRow, TableRow,
TableRowId, TableRowId,
TableSelectionCell, TableSelectionCell,
createTableColumn,
useArrowNavigationGroup, useArrowNavigationGroup,
useTableColumnSizing_unstable, useTableColumnSizing_unstable,
useTableFeatures, useTableFeatures,
useTableSelection, useTableSelection,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { NormalizedEventKey } from "Common/Constants"; 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 { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil"; import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils"; import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { FixedSizeList as List, ListChildComponentProps } from "react-window"; import { FixedSizeList as List, ListChildComponentProps } from "react-window";
import * as ViewModels from "../../../Contracts/ViewModels";
export type DocumentsTableComponentItem = { export type DocumentsTableComponentItem = {
id: string; id: string;
@ -47,6 +55,7 @@ export interface IDocumentsTableComponentProps {
columnHeaders: ColumnHeaders; columnHeaders: ColumnHeaders;
style?: React.CSSProperties; style?: React.CSSProperties;
isSelectionDisabled?: boolean; isSelectionDisabled?: boolean;
collection: ViewModels.CollectionBase;
} }
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> { interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
@ -59,6 +68,11 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
data: TableRowData[]; data: TableRowData[];
} }
const defaultSize: WidthDefinition = {
idealWidth: 200,
minWidth: 50,
};
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
items, items,
onSelectedRowsChange, onSelectedRowsChange,
@ -67,32 +81,34 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
size, size,
columnHeaders, columnHeaders,
isSelectionDisabled, isSelectionDisabled,
collection,
}: IDocumentsTableComponentProps) => { }: IDocumentsTableComponentProps) => {
const styles = useDocumentsTabStyles(); const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
const initialSizingOptions: TableColumnSizingOptions = { const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
id: { const columnSizesPx: ColumnSizesMap = {};
idealWidth: 280, columnIds.forEach((columnId) => {
minWidth: 50, columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize;
}, });
}; return columnSizesPx;
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
initialSizingOptions[pkHeader] = {
idealWidth: 200,
minWidth: 50,
};
}); });
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions); const styles = useDocumentsTabStyles();
const onColumnResize = React.useCallback((_, { columnId, width }) => { const onColumnResize = React.useCallback((_, { columnId, width }) => {
setColumnSizingOptions((state) => ({ setColumnSizingOptions((state) => {
const newSizingOptions = {
...state, ...state,
[columnId]: { [columnId]: {
...state[columnId], ...state[columnId],
idealWidth: width, 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 // Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes

View File

@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
} }
} }
> >
<Allotment> <Allotment
onDragEnd={[Function]}
>
<Allotment.Pane <Allotment.Pane
minSize={175} minSize={55}
preferredSize="35%" preferredSize="35%"
> >
<div <div
@ -77,6 +79,12 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
className="___9o87uj0_0000000 ffefeo0" className="___9o87uj0_0000000 ffefeo0"
> >
<DocumentsTableComponent <DocumentsTableComponent
collection={
{
"databaseId": "databaseId",
"id": [Function],
}
}
columnHeaders={ columnHeaders={
{ {
"idHeader": "id", "idHeader": "id",
@ -97,8 +105,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
</div> </div>
</Allotment.Pane> </Allotment.Pane>
<Allotment.Pane <Allotment.Pane
minSize={300} minSize={30}
preferredSize="65%"
> >
<div <div
style={ style={

View File

@ -2,6 +2,12 @@
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = ` exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
<DocumentsTableComponent <DocumentsTableComponent
collection={
{
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={ columnHeaders={
{ {
"idHeader": "id", "idHeader": "id",
@ -995,6 +1001,12 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = ` exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
<DocumentsTableComponent <DocumentsTableComponent
collection={
{
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={ columnHeaders={
{ {
"idHeader": "id", "idHeader": "id",

View File

@ -0,0 +1,170 @@
import { createKeyFromPath, deleteState, loadState, MAX_ENTRY_NB, saveState } from "Shared/AppStatePersistenceUtility";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
jest.mock("Shared/StorageUtility", () => ({
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);
});
});
});

View File

@ -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<ApplicationState>(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<ApplicationState>(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<ApplicationState>(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);
};

View File

@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
export const setEntryBoolean = (key: StorageKey, value: boolean): void => export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
localStorage.setItem(StorageKey[key], value.toString()); localStorage.setItem(StorageKey[key], value.toString());
export const setEntryObject = (key: StorageKey, value: unknown): void => {
localStorage.setItem(StorageKey[key], JSON.stringify(value));
};
export const getEntryObject = <T>(key: StorageKey): T | null => {
const item = localStorage.getItem(StorageKey[key]);
if (item) {
return JSON.parse(item) as T;
}
return null;
};

View File

@ -30,6 +30,7 @@ export enum StorageKey {
VisitedAccounts, VisitedAccounts,
PriorityLevel, PriorityLevel,
DefaultQueryResultsView, DefaultQueryResultsView,
AppState,
} }
export const hasRUThresholdBeenConfigured = (): boolean => { export const hasRUThresholdBeenConfigured = (): boolean => {

View File

@ -139,6 +139,9 @@ export enum Action {
QueryEdited, QueryEdited,
ExecuteQueryGeneratedFromQueryCopilot, ExecuteQueryGeneratedFromQueryCopilot,
DeleteDocuments, DeleteDocuments,
ReadPersistedTabState,
SavePersistedTabState,
DeletePersistedTabState,
} }
export const ActionModifiers = { export const ActionModifiers = {