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"; if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
private static itemsMaxNumber: number = 5; const oldDataSchemaVersion: string = "2";
private storedData: StoredData; const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity);
constructor() { if (rawData) {
// Retrieve from local storage const oldData = JSON.parse(rawData);
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) { if (oldData.schemaVersion === oldDataSchemaVersion) {
const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity); const itemsMap: Record<string, Item[]> = oldData.itemsMap;
Object.keys(itemsMap).forEach((accountId: string) => {
if (!rawData) { const accountName = accountId.split("/").pop();
this.storedData = MostRecentActivity.createEmptyData(); if (accountName) {
} else { saveState(
try { {
this.storedData = JSON.parse(rawData); componentName: AppStateComponentNames.MostRecentActivity,
} catch (e) { globalAccountName: accountName,
console.error("Unable to parse stored most recent activity. Use empty data:", rawData); },
this.storedData = MostRecentActivity.createEmptyData(); itemsMap[accountId].map((item) => {
} if ((item.type as unknown as number) === 0) {
item.type = Type.OpenCollection;
// If version doesn't match or schema broke, nuke it! } else if ((item.type as unknown as number) === 1) {
if ( item.type = Type.OpenNotebook;
!this.storedData.hasOwnProperty("schemaVersion") || }
this.storedData["schemaVersion"] !== MostRecentActivity.schemaVersion return item;
) { }),
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity); );
this.storedData = MostRecentActivity.createEmptyData(); }
} });
} }
} else {
this.storedData = MostRecentActivity.createEmptyData();
} }
for (let p in this.storedData.itemsMap) { // Remove old data
this.cleanupItems(p); LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
}
};
const addItem = (accountName: string, newItem: Item): void => {
// When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable.
// if (!accountId) {
// return;
// }
let items =
(loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || [];
// Remove duplicate
items = removeDuplicate(newItem, items);
items.unshift(newItem);
items = cleanupItems(items, accountName);
saveState(
{
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
},
items,
);
};
export const getItems = (accountName: string): Item[] => {
if (!accountName) {
return [];
}
return (
(loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || []
);
};
export const collectionWasOpened = (
accountName: string,
{ id, databaseId }: Pick<CollectionBase, "id" | "databaseId">,
) => {
if (accountName === undefined) {
return;
}
const collectionId = id();
addItem(accountName, {
type: Type.OpenCollection,
databaseId,
collectionId,
});
};
export const clear = (accountName: string): void => {
if (!accountName) {
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;
for (let i = 0; i < result.length; i++) {
const currentItem = result[i];
if (
JSON.stringify(sortObjectKeys(currentItem as unknown as Record<string, unknown>)) ===
JSON.stringify(sortObjectKeys(item as unknown as Record<string, unknown>))
) {
index = i;
break;
} }
this.saveToLocalStorage();
} }
private static createEmptyData(): StoredData { if (index !== -1) {
return { result.splice(index, 1);
schemaVersion: MostRecentActivity.schemaVersion,
itemsMap: {},
};
} }
private static isEmpty(object: any) { return result;
return Object.keys(object).length === 0 && object.constructor === Object; };
/**
* Remove unknown types
* Limit items to max number
* Modifies the array.
*/
const cleanupItems = (items: Item[], accountName: string): Item[] => {
if (accountName === undefined) {
return [];
} }
private saveToLocalStorage() { const itemsArray = items.filter((item) => item.type in Type).slice(0, itemsMaxNumber);
if (MostRecentActivity.isEmpty(this.storedData.itemsMap)) { if (itemsArray.length === 0) {
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) { deleteState({
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity); componentName: AppStateComponentNames.MostRecentActivity,
} globalAccountName: accountName,
// Don't save if empty
return;
}
LocalStorageUtility.setEntryString(StorageKey.MostRecentActivity, JSON.stringify(this.storedData));
}
private addItem(accountId: string, newItem: Item): void {
// When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable.
// if (!accountId) {
// return;
// }
// Remove duplicate
MostRecentActivity.removeDuplicate(newItem, this.storedData.itemsMap[accountId]);
this.storedData.itemsMap[accountId] = this.storedData.itemsMap[accountId] || [];
this.storedData.itemsMap[accountId].unshift(newItem);
this.cleanupItems(accountId);
this.saveToLocalStorage();
}
public getItems(accountId: string): Item[] {
return this.storedData.itemsMap[accountId] || [];
}
public collectionWasOpened(accountId: string, { id, databaseId }: Pick<CollectionBase, "id" | "databaseId">) {
const collectionId = id();
this.addItem(accountId, {
type: Type.OpenCollection,
databaseId,
collectionId,
}); });
} }
return itemsArray;
};
public notebookWasItemOpened(accountId: string, { name, path }: Pick<NotebookContentItem, "name" | "path">) { migrateOldData();
this.addItem(accountId, {
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;
}
let index = -1;
for (let i = 0; i < itemsArray.length; i++) {
const currentItem = itemsArray[i];
if (JSON.stringify(currentItem) === JSON.stringify(item)) {
index = i;
break;
}
}
if (index !== -1) {
itemsArray.splice(index, 1);
}
}
/**
* Remove unknown types
* Limit items to max number
*/
private cleanupItems(accountId: string): void {
if (!this.storedData.itemsMap.hasOwnProperty(accountId)) {
return;
}
const itemsArray = this.storedData.itemsMap[accountId]
.filter((item) => item.type in Type)
.slice(0, MostRecentActivity.itemsMaxNumber);
if (itemsArray.length === 0) {
delete this.storedData.itemsMap[accountId];
} else {
this.storedData.itemsMap[accountId] = itemsArray;
}
}
}
export const mostRecentActivity = new MostRecentActivity();

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,