Infrastructure to save app state
This commit is contained in:
parent
5871c1e2d0
commit
96b2ef728a
|
@ -0,0 +1,74 @@
|
|||
// Definitions of State data
|
||||
|
||||
import { TableColumnSizingOptions } from "@fluentui/react-components";
|
||||
import { loadState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
|
||||
import { userContext } from "UserContext";
|
||||
|
||||
// Component states
|
||||
export interface DocumentsTabStateData {
|
||||
leftPaneWidthPercent: number;
|
||||
}
|
||||
|
||||
const defaultState: DocumentsTabStateData = {
|
||||
leftPaneWidthPercent: 35,
|
||||
};
|
||||
|
||||
const ComponentName = "DocumentsTab";
|
||||
|
||||
export const readDocumentsTabState = (): DocumentsTabStateData => {
|
||||
const state = loadState({ componentName: ComponentName });
|
||||
return (state as DocumentsTabStateData) || defaultState;
|
||||
};
|
||||
|
||||
export const saveDocumentsTabState = (state: DocumentsTabStateData): void => {
|
||||
saveStateDebounced({ componentName: ComponentName }, state);
|
||||
};
|
||||
|
||||
type ColumnSizesMap = { [columnId: string]: WidthDefinition };
|
||||
type WidthDefinition = { idealWidth?: number; minWidth?: number };
|
||||
|
||||
const defaultSize: WidthDefinition = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
};
|
||||
|
||||
const ColumnSizesSubComponentName = "ColumnSizes";
|
||||
export const readColumnSizes = (
|
||||
databaseName: string,
|
||||
containerName: string,
|
||||
columnIds: string[],
|
||||
): TableColumnSizingOptions => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
// TODO what if databaseAccount doesn't exist?
|
||||
|
||||
const state = loadState({
|
||||
globalAccountName,
|
||||
databaseName,
|
||||
containerName,
|
||||
componentName: ComponentName,
|
||||
subComponentName: ColumnSizesSubComponentName,
|
||||
}) as ColumnSizesMap;
|
||||
|
||||
const columnSizesPx: ColumnSizesMap = {};
|
||||
columnIds.forEach((columnId) => {
|
||||
columnSizesPx[columnId] = (state && state[columnId]) || defaultSize;
|
||||
});
|
||||
|
||||
return columnSizesPx;
|
||||
};
|
||||
|
||||
export const saveColumnSizes = (databaseName: string, containerName: string, columnSizesMap: ColumnSizesMap): void => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
// TODO what if databaseAccount doesn't exist?
|
||||
|
||||
saveStateDebounced(
|
||||
{
|
||||
componentName: ComponentName,
|
||||
subComponentName: ColumnSizesSubComponentName,
|
||||
globalAccountName,
|
||||
databaseName,
|
||||
containerName,
|
||||
},
|
||||
columnSizesMap,
|
||||
);
|
||||
};
|
|
@ -21,6 +21,11 @@ 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 {
|
||||
DocumentsTabStateData,
|
||||
readDocumentsTabState,
|
||||
saveDocumentsTabState,
|
||||
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||
import { getPlatformTheme } from "Explorer/Theme/ThemeUtil";
|
||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
|
@ -480,6 +485,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
ViewModels.DocumentExplorerState.noDocumentSelected,
|
||||
);
|
||||
|
||||
// State
|
||||
const [tabStateData, setTabStateData] = useState<DocumentsTabStateData>(() => readDocumentsTabState());
|
||||
|
||||
const isQueryCopilotSampleContainer =
|
||||
_collection?.isSampleCollection &&
|
||||
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
|
||||
|
@ -1772,7 +1780,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
)}
|
||||
{/* <Split> doesn't like to be a flex child */}
|
||||
<div style={{ overflow: "hidden", height: "100%" }}>
|
||||
<Split>
|
||||
<Split
|
||||
onDragEnd={(preSize: number) => {
|
||||
tabStateData.leftPaneWidthPercent = Math.min(100, Math.max(0, Math.round(100 * preSize) / 100));
|
||||
saveDocumentsTabState(tabStateData);
|
||||
setTabStateData({ ...tabStateData });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ minWidth: 120, width: "35%", overflow: "hidden", position: "relative" }}
|
||||
ref={tableContainerRef}
|
||||
|
@ -1813,6 +1827,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
|
||||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
|
||||
}
|
||||
collection={_collection}
|
||||
/>
|
||||
{tableItems.length > 0 && (
|
||||
<a
|
||||
|
|
|
@ -23,10 +23,12 @@ import {
|
|||
useTableSelection,
|
||||
} from "@fluentui/react-components";
|
||||
import { NormalizedEventKey } from "Common/Constants";
|
||||
import { readColumnSizes, saveColumnSizes } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
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;
|
||||
|
@ -45,6 +47,7 @@ export interface IDocumentsTableComponentProps {
|
|||
columnHeaders: ColumnHeaders;
|
||||
style?: React.CSSProperties;
|
||||
isSelectionDisabled?: boolean;
|
||||
collection: ViewModels.CollectionBase;
|
||||
}
|
||||
|
||||
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
|
||||
|
@ -65,30 +68,26 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
|||
size,
|
||||
columnHeaders,
|
||||
isSelectionDisabled,
|
||||
collection,
|
||||
}: IDocumentsTableComponentProps) => {
|
||||
const initialSizingOptions: TableColumnSizingOptions = {
|
||||
id: {
|
||||
idealWidth: 280,
|
||||
minWidth: 50,
|
||||
},
|
||||
};
|
||||
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
|
||||
initialSizingOptions[pkHeader] = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
};
|
||||
});
|
||||
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() =>
|
||||
readColumnSizes(collection.databaseId, collection.id(), ["id"].concat(columnHeaders.partitionKeyHeaders)),
|
||||
);
|
||||
|
||||
const onColumnResize = React.useCallback((_, { columnId, width }) => {
|
||||
setColumnSizingOptions((state) => ({
|
||||
...state,
|
||||
[columnId]: {
|
||||
...state[columnId],
|
||||
idealWidth: width,
|
||||
},
|
||||
}));
|
||||
setColumnSizingOptions((state) => {
|
||||
const newSizingOptions = {
|
||||
...state,
|
||||
[columnId]: {
|
||||
...state[columnId],
|
||||
idealWidth: width,
|
||||
},
|
||||
};
|
||||
|
||||
saveColumnSizes(collection.databaseId, collection.id(), newSizingOptions);
|
||||
|
||||
return newSizingOptions;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
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" | "DocumentsTab.columnSizes";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
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,
|
||||
};
|
||||
|
||||
// TODO Add logic to clean up old state data based on timestamp
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// Internal stored state
|
||||
// interface ApplicationState {
|
||||
// data: GlobalStateData;
|
||||
// globalAccounts: {
|
||||
// [globalAccountName: string]: {
|
||||
// data: GlobalAccountStateData;
|
||||
// databases: {
|
||||
// [databaseName: string]: {
|
||||
// data: DatabaseStateData;
|
||||
// containers: {
|
||||
// data: ContainerStateData;
|
||||
// [containerName: string]: {
|
||||
// [componentName: string]: BaseStateData;
|
||||
// };
|
||||
// };
|
||||
// };
|
||||
// };
|
||||
// };
|
||||
// };
|
||||
// }
|
||||
|
||||
interface ApplicationState {
|
||||
[statePath: string]: StateData;
|
||||
}
|
||||
|
||||
/**
|
||||
* /componentName/globalAccountName/databaseName/containerName/
|
||||
* Any of the path segments can be "" except componentName
|
||||
* @param path
|
||||
*/
|
||||
const createKeyFromPath = (path: StorePath): string => {
|
||||
let key = `/${path.componentName}`;
|
||||
["subComponentName", "globalAccountName", "databaseName", "containerName"].forEach((segment) => {
|
||||
const segmentValue = (path as any)[segment];
|
||||
key += `/${segmentValue !== undefined ? segmentValue : ""}`;
|
||||
});
|
||||
return key;
|
||||
};
|
|
@ -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 = <T>(key: StorageKey): T | null => {
|
||||
const item = localStorage.getItem(StorageKey[key]);
|
||||
if (item) {
|
||||
return JSON.parse(item) as T;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
|
@ -30,6 +30,7 @@ export enum StorageKey {
|
|||
VisitedAccounts,
|
||||
PriorityLevel,
|
||||
DefaultQueryResultsView,
|
||||
AppState,
|
||||
}
|
||||
|
||||
export const hasRUThresholdBeenConfigured = (): boolean => {
|
||||
|
|
Loading…
Reference in New Issue