mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-22 02:11:29 +00:00
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:
170
src/Shared/AppStatePersistenceUtility.test.ts
Normal file
170
src/Shared/AppStatePersistenceUtility.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
src/Shared/AppStatePersistenceUtility.ts
Normal file
109
src/Shared/AppStatePersistenceUtility.ts
Normal 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);
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
@@ -139,6 +139,9 @@ export enum Action {
|
||||
QueryEdited,
|
||||
ExecuteQueryGeneratedFromQueryCopilot,
|
||||
DeleteDocuments,
|
||||
ReadPersistedTabState,
|
||||
SavePersistedTabState,
|
||||
DeletePersistedTabState,
|
||||
}
|
||||
|
||||
export const ActionModifiers = {
|
||||
|
||||
Reference in New Issue
Block a user