Migrate Most Recent activity local storage to App State persistence (#1967)

* Rewrite MostRecentActivity to leverage AppStatePersistenceUtility.

* Fix format. Update type enum.

* Migrate Item enum to string enum

* Fix unit tests

* Fix build issue
This commit is contained in:
Laurent Nguyen 2024-09-20 08:26:58 +02:00 committed by GitHub
parent 869d81dfbc
commit 23b2e59560
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 197 additions and 209 deletions

View File

@ -1,13 +1,13 @@
import { clear, collectionWasOpened, getItems, Type } from "Explorer/MostRecentActivity/MostRecentActivity";
import { observable } from "knockout"; import { observable } from "knockout";
import { mostRecentActivity } from "./MostRecentActivity";
describe("MostRecentActivity", () => { describe("MostRecentActivity", () => {
const accountId = "some account"; const accountName = "some account";
beforeEach(() => mostRecentActivity.clear(accountId)); beforeEach(() => clear(accountName));
it("Has no items at first", () => { it("Has no items at first", () => {
expect(mostRecentActivity.getItems(accountId)).toStrictEqual([]); expect(getItems(accountName)).toStrictEqual([]);
}); });
it("Can record collections being opened", () => { it("Can record collections being opened", () => {
@ -18,9 +18,9 @@ describe("MostRecentActivity", () => {
databaseId, databaseId,
}; };
mostRecentActivity.collectionWasOpened(accountId, collection); collectionWasOpened(accountName, collection);
const activity = mostRecentActivity.getItems(accountId); const activity = getItems(accountName);
expect(activity).toEqual([ expect(activity).toEqual([
expect.objectContaining({ expect.objectContaining({
collectionId, collectionId,
@ -29,58 +29,24 @@ describe("MostRecentActivity", () => {
]); ]);
}); });
it("Can record notebooks being opened", () => { it("Does not store duplicate entries", () => {
const name = "some notebook"; const collectionId = "some collection";
const path = "some path"; const databaseId = "some database";
const notebook = { name, path }; const collection = {
id: observable(collectionId),
databaseId,
};
mostRecentActivity.notebookWasItemOpened(accountId, notebook); collectionWasOpened(accountName, collection);
collectionWasOpened(accountName, collection);
const activity = mostRecentActivity.getItems(accountId); const activity = getItems(accountName);
expect(activity).toEqual([expect.objectContaining(notebook)]); expect(activity).toEqual([
}); expect.objectContaining({
type: Type.OpenCollection,
it("Filters out duplicates", () => { collectionId,
const name = "some notebook"; databaseId,
const path = "some path"; }),
const notebook = { name, path }; ]);
const sameNotebook = { name, path };
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
mostRecentActivity.notebookWasItemOpened(accountId, sameNotebook);
const activity = mostRecentActivity.getItems(accountId);
expect(activity.length).toEqual(1);
expect(activity).toEqual([expect.objectContaining(notebook)]);
});
it("Allows for multiple accounts", () => {
const name = "some notebook";
const path = "some path";
const notebook = { name, path };
const anotherNotebook = { name: "Another " + name, path };
const anotherAccountId = "Another " + accountId;
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
mostRecentActivity.notebookWasItemOpened(anotherAccountId, anotherNotebook);
expect(mostRecentActivity.getItems(accountId)).toEqual([expect.objectContaining(notebook)]);
expect(mostRecentActivity.getItems(anotherAccountId)).toEqual([expect.objectContaining(anotherNotebook)]);
});
it("Can store multiple distinct elements, in FIFO order", () => {
const name = "some notebook";
const path = "some path";
const first = { name, path };
const second = { name: "Another " + name, path };
const third = { name, path: "Another " + path };
mostRecentActivity.notebookWasItemOpened(accountId, first);
mostRecentActivity.notebookWasItemOpened(accountId, second);
mostRecentActivity.notebookWasItemOpened(accountId, third);
const activity = mostRecentActivity.getItems(accountId);
expect(activity).toEqual([third, second, first].map(expect.objectContaining));
}); });
}); });

View File

@ -1,10 +1,10 @@
import { AppStateComponentNames, deleteState, loadState, saveState } from "Shared/AppStatePersistenceUtility";
import { CollectionBase } from "../../Contracts/ViewModels"; import { CollectionBase } from "../../Contracts/ViewModels";
import { StorageKey, LocalStorageUtility } from "../../Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
export enum Type { export enum Type {
OpenCollection, OpenCollection = "OpenCollection",
OpenNotebook, OpenNotebook = "OpenNotebook",
} }
export interface OpenNotebookItem { export interface OpenNotebookItem {
@ -21,158 +21,174 @@ export interface OpenCollectionItem {
type Item = OpenNotebookItem | OpenCollectionItem; type Item = OpenNotebookItem | OpenCollectionItem;
// Update schemaVersion if you are going to change this interface const itemsMaxNumber: number = 5;
interface StoredData {
schemaVersion: string;
itemsMap: { [accountId: string]: Item[] }; // FIFO
}
/** /**
* Stores most recent activity * Migrate old data to new AppState
*/ */
class MostRecentActivity { const migrateOldData = () => {
private static readonly schemaVersion: string = "2";
private static itemsMaxNumber: number = 5;
private storedData: StoredData;
constructor() {
// Retrieve from local storage
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) { if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
const oldDataSchemaVersion: string = "2";
const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity); const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity);
if (rawData) {
if (!rawData) { const oldData = JSON.parse(rawData);
this.storedData = MostRecentActivity.createEmptyData(); if (oldData.schemaVersion === oldDataSchemaVersion) {
} else { const itemsMap: Record<string, Item[]> = oldData.itemsMap;
try { Object.keys(itemsMap).forEach((accountId: string) => {
this.storedData = JSON.parse(rawData); const accountName = accountId.split("/").pop();
} catch (e) { if (accountName) {
console.error("Unable to parse stored most recent activity. Use empty data:", rawData); saveState(
this.storedData = MostRecentActivity.createEmptyData(); {
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
},
itemsMap[accountId].map((item) => {
if ((item.type as unknown as number) === 0) {
item.type = Type.OpenCollection;
} else if ((item.type as unknown as number) === 1) {
item.type = Type.OpenNotebook;
}
return item;
}),
);
}
});
}
} }
// If version doesn't match or schema broke, nuke it! // Remove old data
if (
!this.storedData.hasOwnProperty("schemaVersion") ||
this.storedData["schemaVersion"] !== MostRecentActivity.schemaVersion
) {
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
this.storedData = MostRecentActivity.createEmptyData();
}
}
} else {
this.storedData = MostRecentActivity.createEmptyData();
}
for (let p in this.storedData.itemsMap) {
this.cleanupItems(p);
}
this.saveToLocalStorage();
}
private static createEmptyData(): StoredData {
return {
schemaVersion: MostRecentActivity.schemaVersion,
itemsMap: {},
};
}
private static isEmpty(object: any) {
return Object.keys(object).length === 0 && object.constructor === Object;
}
private saveToLocalStorage() {
if (MostRecentActivity.isEmpty(this.storedData.itemsMap)) {
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity); LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
} }
// Don't save if empty };
return;
}
LocalStorageUtility.setEntryString(StorageKey.MostRecentActivity, JSON.stringify(this.storedData)); const addItem = (accountName: string, newItem: Item): void => {
}
private addItem(accountId: string, newItem: Item): void {
// When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable. // When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable.
// if (!accountId) { // if (!accountId) {
// return; // return;
// } // }
let items =
(loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || [];
// Remove duplicate // Remove duplicate
MostRecentActivity.removeDuplicate(newItem, this.storedData.itemsMap[accountId]); items = removeDuplicate(newItem, items);
this.storedData.itemsMap[accountId] = this.storedData.itemsMap[accountId] || []; items.unshift(newItem);
this.storedData.itemsMap[accountId].unshift(newItem); items = cleanupItems(items, accountName);
this.cleanupItems(accountId); saveState(
this.saveToLocalStorage(); {
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
},
items,
);
};
export const getItems = (accountName: string): Item[] => {
if (!accountName) {
return [];
} }
public getItems(accountId: string): Item[] { return (
return this.storedData.itemsMap[accountId] || []; (loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || []
);
};
export const collectionWasOpened = (
accountName: string,
{ id, databaseId }: Pick<CollectionBase, "id" | "databaseId">,
) => {
if (accountName === undefined) {
return;
} }
public collectionWasOpened(accountId: string, { id, databaseId }: Pick<CollectionBase, "id" | "databaseId">) {
const collectionId = id(); const collectionId = id();
this.addItem(accountId, { addItem(accountName, {
type: Type.OpenCollection, type: Type.OpenCollection,
databaseId, databaseId,
collectionId, collectionId,
}); });
} };
public notebookWasItemOpened(accountId: string, { name, path }: Pick<NotebookContentItem, "name" | "path">) { export const clear = (accountName: string): void => {
this.addItem(accountId, { if (!accountName) {
type: Type.OpenNotebook,
name,
path,
});
}
public clear(accountId: string): void {
delete this.storedData.itemsMap[accountId];
this.saveToLocalStorage();
}
/**
* Find items by doing strict comparison and remove from array if duplicate is found
* @param item
*/
private static removeDuplicate(item: Item, itemsArray: Item[]): void {
if (!itemsArray) {
return; return;
} }
deleteState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
});
};
// Sort object by key
const sortObjectKeys = (unordered: Record<string, unknown>): Record<string, unknown> => {
return Object.keys(unordered)
.sort()
.reduce((obj: Record<string, unknown>, key: string) => {
obj[key] = unordered[key];
return obj;
}, {});
};
/**
* Find items by doing strict comparison and remove from array if duplicate is found.
* Modifies the array.
* @param item
* @param itemsArray
* @returns new array
*/
const removeDuplicate = (item: Item, itemsArray: Item[]): Item[] => {
if (!itemsArray) {
return itemsArray;
}
const result: Item[] = [...itemsArray];
let index = -1; let index = -1;
for (let i = 0; i < itemsArray.length; i++) { for (let i = 0; i < result.length; i++) {
const currentItem = itemsArray[i]; const currentItem = result[i];
if (JSON.stringify(currentItem) === JSON.stringify(item)) {
if (
JSON.stringify(sortObjectKeys(currentItem as unknown as Record<string, unknown>)) ===
JSON.stringify(sortObjectKeys(item as unknown as Record<string, unknown>))
) {
index = i; index = i;
break; break;
} }
} }
if (index !== -1) { if (index !== -1) {
itemsArray.splice(index, 1); result.splice(index, 1);
}
} }
/** return result;
};
/**
* Remove unknown types * Remove unknown types
* Limit items to max number * Limit items to max number
* Modifies the array.
*/ */
private cleanupItems(accountId: string): void { const cleanupItems = (items: Item[], accountName: string): Item[] => {
if (!this.storedData.itemsMap.hasOwnProperty(accountId)) { if (accountName === undefined) {
return; return [];
} }
const itemsArray = this.storedData.itemsMap[accountId] const itemsArray = items.filter((item) => item.type in Type).slice(0, itemsMaxNumber);
.filter((item) => item.type in Type)
.slice(0, MostRecentActivity.itemsMaxNumber);
if (itemsArray.length === 0) { if (itemsArray.length === 0) {
delete this.storedData.itemsMap[accountId]; deleteState({
} else { componentName: AppStateComponentNames.MostRecentActivity,
this.storedData.itemsMap[accountId] = itemsArray; globalAccountName: accountName,
});
} }
} return itemsArray;
} };
export const mostRecentActivity = new MostRecentActivity(); migrateOldData();

View File

@ -114,7 +114,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
} }
private clearMostRecent = (): void => { private clearMostRecent = (): void => {
MostRecentActivity.mostRecentActivity.clear(userContext.databaseAccount?.id); MostRecentActivity.clear(userContext.databaseAccount?.name);
this.setState({}); this.setState({});
}; };
@ -498,7 +498,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
} }
private createRecentItems(): SplashScreenItem[] { private createRecentItems(): SplashScreenItem[] {
return MostRecentActivity.mostRecentActivity.getItems(userContext.databaseAccount?.id).map((activity) => { return MostRecentActivity.getItems(userContext.databaseAccount?.name).map((activity) => {
switch (activity.type) { switch (activity.type) {
default: { default: {
const unknownActivity: never = activity; const unknownActivity: never = activity;

View File

@ -1,4 +1,5 @@
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil"; import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil";
import { getItemName } from "Utils/APITypeUtils"; import { getItemName } from "Utils/APITypeUtils";
import * as ko from "knockout"; import * as ko from "knockout";
@ -28,7 +29,6 @@ import { useDialog } from "../Controls/Dialog";
import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent"; import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { NotebookUtil } from "../Notebook/NotebookUtil"; import { NotebookUtil } from "../Notebook/NotebookUtil";
import { useNotebook } from "../Notebook/useNotebook"; import { useNotebook } from "../Notebook/useNotebook";
@ -229,7 +229,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
onClick: () => { onClick: () => {
collection.openTab(); collection.openTab();
// push to most recent // push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection); collectionWasOpened(userContext.databaseAccount?.name, collection);
}, },
isSelected: () => isSelected: () =>
useSelectedNode useSelectedNode

View File

@ -1,5 +1,6 @@
import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons"; import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
import TabsBase from "Explorer/Tabs/TabsBase"; import TabsBase from "Explorer/Tabs/TabsBase";
import StoredProcedure from "Explorer/Tree/StoredProcedure"; import StoredProcedure from "Explorer/Tree/StoredProcedure";
import Trigger from "Explorer/Tree/Trigger"; import Trigger from "Explorer/Tree/Trigger";
@ -17,7 +18,6 @@ import { userContext } from "../../UserContext";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory"; import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { useNotebook } from "../Notebook/useNotebook"; import { useNotebook } from "../Notebook/useNotebook";
import { useSelectedNode } from "../useSelectedNode"; import { useSelectedNode } from "../useSelectedNode";
@ -98,7 +98,7 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
onClick: () => { onClick: () => {
collection.onDocumentDBDocumentsClick(); collection.onDocumentDBDocumentsClick();
// push to most recent // push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection); collectionWasOpened(userContext.databaseAccount?.name, collection);
}, },
isSelected: () => isSelected: () =>
useSelectedNode useSelectedNode
@ -234,7 +234,7 @@ export const buildCollectionNode = (
useSelectedNode.getState().setSelectedNode(collection); useSelectedNode.getState().setSelectedNode(collection);
collection.openTab(); collection.openTab();
// push to most recent // push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection); collectionWasOpened(userContext.databaseAccount?.name, collection);
}, },
onExpanded: async () => { onExpanded: async () => {
// Rewritten version of expandCollapseCollection // Rewritten version of expandCollapseCollection
@ -282,7 +282,7 @@ const buildCollectionNodeChildren = (
onClick: () => { onClick: () => {
collection.openTab(); collection.openTab();
// push to most recent // push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection); collectionWasOpened(userContext.databaseAccount?.name, collection);
}, },
isSelected: () => isSelected: () =>
useSelectedNode useSelectedNode

View File

@ -3,6 +3,7 @@ import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
// The component name whose state is being saved. Component name must not include special characters. // The component name whose state is being saved. Component name must not include special characters.
export enum AppStateComponentNames { export enum AppStateComponentNames {
DocumentsTab = "DocumentsTab", DocumentsTab = "DocumentsTab",
MostRecentActivity = "MostRecentActivity",
QueryCopilot = "QueryCopilot", QueryCopilot = "QueryCopilot",
} }
@ -34,6 +35,7 @@ export const loadState = (path: StorePath): unknown => {
const key = createKeyFromPath(path); const key = createKeyFromPath(path);
return appState[key]?.data; return appState[key]?.data;
}; };
export const saveState = (path: StorePath, state: unknown): void => { export const saveState = (path: StorePath, state: unknown): void => {
// Retrieve state object // Retrieve state object
const appState = const appState =
@ -65,6 +67,10 @@ export const deleteState = (path: StorePath): void => {
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState); LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
}; };
export const hasState = (path: StorePath): boolean => {
return loadState(path) !== undefined;
};
// This is for high-frequency state changes // This is for high-frequency state changes
let timeoutId: NodeJS.Timeout | undefined; let timeoutId: NodeJS.Timeout | undefined;
export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => { export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {

View File

@ -24,7 +24,7 @@ export enum StorageKey {
MaxDegreeOfParellism, MaxDegreeOfParellism,
IsGraphAutoVizDisabled, IsGraphAutoVizDisabled,
TenantId, TenantId,
MostRecentActivity, MostRecentActivity, // deprecated
SetPartitionKeyUndefined, SetPartitionKeyUndefined,
GalleryCalloutDismissed, GalleryCalloutDismissed,
VisitedAccounts, VisitedAccounts,