From aedcc6d70e17612c631350f34f8bb2677be0fd43 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 18 Mar 2021 14:36:34 +0200 Subject: [PATCH] Persist local-storage in external json-file (#2279) --- integration/__tests__/cluster-pages.tests.ts | 49 ++-- package.json | 1 + .../+cluster/cluster-overview.store.ts | 56 ++-- .../components/+custom-resources/crd-list.tsx | 29 ++- .../components/+namespaces/namespace.store.ts | 12 +- .../dock/__test__/dock-tabs.test.tsx | 9 +- .../components/dock/create-resource.store.ts | 2 +- .../components/dock/dock-tab.store.ts | 66 +++-- src/renderer/components/dock/dock.store.ts | 120 +++++---- src/renderer/components/dock/dock.tsx | 2 +- .../components/dock/edit-resource.store.ts | 41 ++- .../components/dock/edit-resource.tsx | 44 ++-- .../components/dock/install-chart.store.ts | 2 +- src/renderer/components/dock/log-tab.store.ts | 2 +- .../components/dock/upgrade-chart.store.ts | 2 +- .../item-object-list/item-list-layout.tsx | 36 +-- .../components/layout/main-layout.scss | 10 +- .../components/layout/main-layout.tsx | 68 ++--- .../components/layout/sidebar-context.ts | 7 - .../components/layout/sidebar-item.scss | 63 +++++ .../components/layout/sidebar-item.tsx | 98 +++++++ .../components/layout/sidebar-nav-item.scss | 76 ------ .../components/layout/sidebar-nav-item.tsx | 83 ------ .../components/layout/sidebar-storage.ts | 15 ++ src/renderer/components/layout/sidebar.scss | 25 +- src/renderer/components/layout/sidebar.tsx | 245 +++++++++--------- src/renderer/hooks/useStorage.ts | 5 +- .../utils/__tests__/storageHelper.test.ts | 190 ++++++++++++++ src/renderer/utils/createStorage.ts | 117 +++++---- src/renderer/utils/index.ts | 1 + src/renderer/utils/storageHelper.ts | 162 ++++++++++++ yarn.lock | 5 + 32 files changed, 1025 insertions(+), 618 deletions(-) delete mode 100644 src/renderer/components/layout/sidebar-context.ts create mode 100644 src/renderer/components/layout/sidebar-item.scss create mode 100644 src/renderer/components/layout/sidebar-item.tsx delete mode 100644 src/renderer/components/layout/sidebar-nav-item.scss delete mode 100644 src/renderer/components/layout/sidebar-nav-item.tsx create mode 100644 src/renderer/components/layout/sidebar-storage.ts create mode 100644 src/renderer/utils/__tests__/storageHelper.test.ts create mode 100755 src/renderer/utils/storageHelper.ts diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 1662f33f4a..510110707e 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -54,6 +54,15 @@ describe("Lens cluster pages", () => { } }; + function getSidebarSelectors(itemId: string) { + const root = `.SidebarItem[data-test-id="${itemId}"]`; + + return { + expandSubMenu: `${root} .nav-item`, + subMenuLink: (href: string) => `.Sidebar .sub-menu a[href^="/${href}"]`, + }; + } + describe("cluster pages", () => { beforeAll(appStartAddCluster, 40000); @@ -311,28 +320,36 @@ describe("Lens cluster pages", () => { }]; tests.forEach(({ drawer = "", drawerId = "", pages }) => { + const selectors = getSidebarSelectors(drawerId); + if (drawer !== "") { it(`shows ${drawer} drawer`, async () => { expect(clusterAdded).toBe(true); - await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`); - await app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name); + await app.client.click(selectors.expandSubMenu); + await app.client.waitUntilTextExists(selectors.subMenuLink(pages[0].href), pages[0].name); }); - } - pages.forEach(({ name, href, expectedSelector, expectedText }) => { - it(`shows ${drawer}->${name} page`, async () => { + + pages.forEach(({ name, href, expectedSelector, expectedText }) => { + it(`shows ${drawer}->${name} page`, async () => { + expect(clusterAdded).toBe(true); + await app.client.click(selectors.subMenuLink(href)); + await app.client.waitUntilTextExists(expectedSelector, expectedText); + }); + }); + + it(`hides ${drawer} drawer`, async () => { + expect(clusterAdded).toBe(true); + await app.client.click(selectors.expandSubMenu); + await expect(app.client.waitUntilTextExists(selectors.subMenuLink(pages[0].href), pages[0].name, 100)).rejects.toThrow(); + }); + } else { + const { href, name, expectedText, expectedSelector } = pages[0]; + + it(`shows page ${name}`, async () => { expect(clusterAdded).toBe(true); await app.client.click(`a[href^="/${href}"]`); await app.client.waitUntilTextExists(expectedSelector, expectedText); }); - }); - - if (drawer !== "") { - // hide the drawer - it(`hides ${drawer} drawer`, async () => { - expect(clusterAdded).toBe(true); - await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`); - await expect(app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow(); - }); } }); }); @@ -349,7 +366,7 @@ describe("Lens cluster pages", () => { it(`shows a log for a pod`, async () => { expect(clusterAdded).toBe(true); // Go to Pods page - await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); + await app.client.click(getSidebarSelectors("workloads").expandSubMenu); await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); await app.client.click('a[href^="/pods"]'); await app.client.click(".NamespaceSelect"); @@ -416,7 +433,7 @@ describe("Lens cluster pages", () => { it(`creates a pod in ${TEST_NAMESPACE} namespace`, async () => { expect(clusterAdded).toBe(true); - await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); + await app.client.click(getSidebarSelectors("workloads").expandSubMenu); await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); await app.client.click('a[href^="/pods"]'); diff --git a/package.json b/package.json index 450def5778..ace4b6fdba 100644 --- a/package.json +++ b/package.json @@ -211,6 +211,7 @@ "fs-extra": "^9.0.1", "handlebars": "^4.7.6", "http-proxy": "^1.18.1", + "immer": "^8.0.1", "js-yaml": "^3.14.0", "jsdom": "^16.4.0", "jsonpath": "^1.0.2", diff --git a/src/renderer/components/+cluster/cluster-overview.store.ts b/src/renderer/components/+cluster/cluster-overview.store.ts index 64faa2394c..0158b405d0 100644 --- a/src/renderer/components/+cluster/cluster-overview.store.ts +++ b/src/renderer/components/+cluster/cluster-overview.store.ts @@ -16,36 +16,50 @@ export enum MetricNodeRole { WORKER = "worker" } +export interface ClusterOverviewStorageState { + metricType: MetricType; + metricNodeRole: MetricNodeRole, +} + @autobind() -export class ClusterOverviewStore extends KubeObjectStore { +export class ClusterOverviewStore extends KubeObjectStore implements ClusterOverviewStorageState { api = clusterApi; @observable metrics: Partial = {}; @observable metricsLoaded = false; - @observable metricType: MetricType; - @observable metricNodeRole: MetricNodeRole; + + private storage = createStorage("cluster_overview", { + metricType: MetricType.CPU, // setup defaults + metricNodeRole: MetricNodeRole.WORKER, + }); + + get metricType(): MetricType { + return this.storage.get().metricType; + } + + set metricType(value: MetricType) { + this.storage.merge({ metricType: value }); + } + + get metricNodeRole(): MetricNodeRole { + return this.storage.get().metricNodeRole; + } + + set metricNodeRole(value: MetricNodeRole) { + this.storage.merge({ metricNodeRole: value }); + } constructor() { super(); - this.resetMetrics(); + this.init(); + } - // sync user setting with local storage - const storage = createStorage("cluster_metric_switchers", {}); - - Object.assign(this, storage.get()); - reaction(() => { - const { metricType, metricNodeRole } = this; - - return { metricType, metricNodeRole }; - }, - settings => storage.set(settings) - ); - - // auto-update metrics + private init() { + // TODO: refactor, seems not a correct place to be + // auto-refresh metrics on user-action reaction(() => this.metricNodeRole, () => { if (!this.metricsLoaded) return; - this.metrics = {}; - this.metricsLoaded = false; + this.resetMetrics(); this.loadMetrics(); }); @@ -79,16 +93,16 @@ export class ClusterOverviewStore extends KubeObjectStore { } } + @action resetMetrics() { this.metrics = {}; this.metricsLoaded = false; - this.metricType = MetricType.CPU; - this.metricNodeRole = MetricNodeRole.WORKER; } reset() { super.reset(); this.resetMetrics(); + this.storage?.reset(); } } diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index f8b77c09a9..57959fa1d0 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -29,23 +29,31 @@ enum columnId { @observer export class CrdList extends React.Component { - @computed get groups(): string[] { + get selectedGroups(): string[] { return crdGroupsUrlParam.get(); } - onSelectGroup(group: string) { - const groups = new Set(this.groups); + @computed get items() { + if (this.selectedGroups.length) { + return crdStore.items.filter(item => this.selectedGroups.includes(item.getGroup())); + } + + return crdStore.items; // show all by default + } + + toggleSelection(group: string) { + const groups = new Set(crdGroupsUrlParam.get()); if (groups.has(group)) { - groups.delete(group); // toggle selection + groups.delete(group); } else { groups.add(group); } - crdGroupsUrlParam.set(Array.from(groups)); + crdGroupsUrlParam.set([...groups]); } render() { - const selectedGroups = this.groups; + const { items, selectedGroups } = this; const sortingCallbacks = { [columnId.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(), [columnId.group]: (crd: CustomResourceDefinition) => crd.getGroup(), @@ -60,13 +68,9 @@ export class CrdList extends React.Component { className="CrdList" isClusterScoped={true} store={crdStore} + items={items} sortingCallbacks={sortingCallbacks} searchFilters={Object.values(sortingCallbacks)} - filterItems={[ - (items: CustomResourceDefinition[]) => { - return selectedGroups.length ? items.filter(item => selectedGroups.includes(item.getGroup())) : items; - } - ]} renderHeaderTitle="Custom Resources" customizeHeader={() => { let placeholder = <>All groups; @@ -81,7 +85,8 @@ export class CrdList extends React.Component { className="group-select" placeholder={placeholder} options={Object.keys(crdStore.groups)} - onChange={({ value: group }: SelectOption) => this.onSelectGroup(group)} + onChange={({ value: group }: SelectOption) => this.toggleSelection(group)} + closeMenuOnSelect={false} controlShouldRenderValue={false} formatOptionLabel={({ value: group }: SelectOption) => { const isSelected = selectedGroups.includes(group); diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 9995fbb7e5..f81fee1ece 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -5,15 +5,13 @@ import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api"; import { createPageParam } from "../../navigation"; import { apiManager } from "../../api/api-manager"; -const storage = createStorage("context_namespaces"); +const selectedNamespaces = createStorage("selected_namespaces"); export const namespaceUrlParam = createPageParam({ name: "namespaces", isSystem: true, multiValues: true, - get defaultValue() { - return storage.get() ?? []; // initial namespaces coming from URL or local-storage (default) - } + defaultValue: [], }); export function getDummyNamespace(name: string) { @@ -42,6 +40,7 @@ export class NamespaceStore extends KubeObjectStore { private async init() { await this.contextReady; + await selectedNamespaces.whenReady; this.setContext(this.initialNamespaces); this.autoLoadAllowedNamespaces(); @@ -57,7 +56,7 @@ export class NamespaceStore extends KubeObjectStore { private autoUpdateUrlAndLocalStorage(): IReactionDisposer { return this.onContextChange(namespaces => { - storage.set(namespaces); // save to local-storage + selectedNamespaces.set(namespaces); // save to local-storage namespaceUrlParam.set(namespaces, { replaceHistory: true }); // update url }, { fireImmediately: true, @@ -71,10 +70,9 @@ export class NamespaceStore extends KubeObjectStore { }); } - @computed private get initialNamespaces(): string[] { const namespaces = new Set(this.allowedNamespaces); - const prevSelectedNamespaces = storage.get(); + const prevSelectedNamespaces = selectedNamespaces.get(); // return previously saved namespaces from local-storage (if any) if (prevSelectedNamespaces) { diff --git a/src/renderer/components/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx index f893e06540..1e8a18c6b5 100644 --- a/src/renderer/components/dock/__test__/dock-tabs.test.tsx +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -4,7 +4,6 @@ import "@testing-library/jest-dom/extend-expect"; import { DockTabs } from "../dock-tabs"; import { dockStore, IDockTab, TabKind } from "../dock.store"; -import { observable } from "mobx"; const onChangeTab = jest.fn(); @@ -134,9 +133,9 @@ describe("", () => { }); it("disables 'Close All' & 'Close Other' items if only 1 tab available", () => { - dockStore.tabs = observable.array([{ + dockStore.tabs = [{ id: "terminal", kind: TabKind.TERMINAL, title: "Terminal" - }]); + }]; const { container, getByText } = renderTabs(); const tab = container.querySelector(".Tab"); @@ -149,10 +148,10 @@ describe("", () => { }); it("disables 'Close To The Right' item if last tab clicked", () => { - dockStore.tabs = observable.array([ + dockStore.tabs = [ { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal" }, { id: "logs", kind: TabKind.POD_LOGS, title: "Pod Logs" }, - ]); + ]; const { container, getByText } = renderTabs(); const tab = container.querySelectorAll(".Tab")[1]; diff --git a/src/renderer/components/dock/create-resource.store.ts b/src/renderer/components/dock/create-resource.store.ts index 6b614db00b..8933ddf4d0 100644 --- a/src/renderer/components/dock/create-resource.store.ts +++ b/src/renderer/components/dock/create-resource.store.ts @@ -6,7 +6,7 @@ import { dockStore, IDockTab, TabKind } from "./dock.store"; export class CreateResourceStore extends DockTabStore { constructor() { super({ - storageName: "create_resource" + storageKey: "create_resource" }); } } diff --git a/src/renderer/components/dock/dock-tab.store.ts b/src/renderer/components/dock/dock-tab.store.ts index b66db14d1e..c951d221fd 100644 --- a/src/renderer/components/dock/dock-tab.store.ts +++ b/src/renderer/components/dock/dock-tab.store.ts @@ -1,25 +1,40 @@ -import { autorun, observable, reaction } from "mobx"; -import { autobind, createStorage } from "../../utils"; +import { autorun, observable, reaction, toJS } from "mobx"; +import { autobind, createStorage, StorageHelper } from "../../utils"; import { dockStore, TabId } from "./dock.store"; -interface Options { - storageName?: string; // name to sync data with localStorage - storageSerializer?: (data: T) => Partial; // allow to customize data before saving to localStorage +export interface DockTabStoreOptions { + autoInit?: boolean; // load data from storage when `storageKey` is provided and bind events, default: true + storageKey?: string; // save data to persistent storage under the key } -@autobind() -export class DockTabStore { - protected data = observable.map([]); +export type DockTabStorageState = Record; - constructor(protected options: Options = {}) { - const { storageName } = options; +@autobind() +export class DockTabStore { + protected storage?: StorageHelper>; + protected data = observable.map(); + + constructor(protected options: DockTabStoreOptions = {}) { + this.options = { + autoInit: true, + ...this.options, + }; + + if (this.options.autoInit) { + this.init(); + } + } + + protected init() { + const { storageKey } = this.options; // auto-save to local-storage - if (storageName) { - const storage = createStorage<[TabId, T][]>(storageName, []); - - this.data.replace(storage.get()); - reaction(() => this.serializeData(), (data: T | any) => storage.set(data)); + if (storageKey) { + this.storage = createStorage(storageKey, {}); + this.storage.whenReady.then(() => { + this.data.replace(this.storage.get()); + reaction(() => this.getStorableData(), data => this.storage.set(data)); + }); } // clear data for closed tabs @@ -34,14 +49,22 @@ export class DockTabStore { }); } - protected serializeData() { - const { storageSerializer } = this.options; + protected finalizeDataForSave(data: T): T { + return data; + } - return Array.from(this.data).map(([tabId, tabData]) => { - if (storageSerializer) return [tabId, storageSerializer(tabData)]; + protected getStorableData(): DockTabStorageState { + const allTabsData = toJS(this.data, { recurseEverything: true }); - return [tabId, tabData]; - }); + return Object.fromEntries( + Object.entries(allTabsData).map(([tabId, tabData]) => { + return [tabId, this.finalizeDataForSave(tabData)]; + }) + ); + } + + isReady(tabId: TabId): boolean { + return Boolean(this.getData(tabId) !== undefined); } getData(tabId: TabId) { @@ -58,5 +81,6 @@ export class DockTabStore { reset() { this.data.clear(); + this.storage?.clear(); } } diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock.store.ts index 423367093e..3a8ab4333c 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock.store.ts @@ -21,28 +21,72 @@ export interface IDockTab { pinned?: boolean; // not closable } +export interface DockStorageState { + height: number; + tabs: IDockTab[]; + selectedTabId?: TabId; + isOpen?: boolean; +} + @autobind() -export class DockStore { - protected initialTabs: IDockTab[] = [ - { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal" }, - ]; - - protected storage = createStorage("dock", {}); // keep settings in localStorage - public readonly defaultTabId = this.initialTabs[0].id; - public readonly minHeight = 100; - - @observable isOpen = false; +export class DockStore implements DockStorageState { + readonly minHeight = 100; @observable fullSize = false; - @observable height = this.defaultHeight; - @observable tabs = observable.array(this.initialTabs); - @observable selectedTabId = this.defaultTabId; + + private storage = createStorage("dock", { + height: 300, + tabs: [ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal" }, + ], + }); + + get isOpen(): boolean { + return this.storage.get().isOpen; + } + + set isOpen(isOpen: boolean) { + this.storage.merge({ isOpen }); + } + + get height(): number { + return this.storage.get().height; + } + + set height(height: number) { + this.storage.merge({ + height: Math.max(this.minHeight, Math.min(height || this.minHeight, this.maxHeight)), + }); + } + + get tabs(): IDockTab[] { + return this.storage.get().tabs; + } + + set tabs(tabs: IDockTab[]) { + this.storage.merge({ tabs }); + } + + get selectedTabId(): TabId | undefined { + return this.storage.get().selectedTabId || this.tabs[0]?.id; + } + + set selectedTabId(tabId: TabId) { + if (tabId && !this.getTabById(tabId)) return; // skip invalid ids + + this.storage.merge({ selectedTabId: tabId }); + } @computed get selectedTab() { return this.tabs.find(tab => tab.id === this.selectedTabId); } - get defaultHeight() { - return Math.round(window.innerHeight / 2.5); + constructor() { + this.init(); + } + + private init() { + // adjust terminal height if window size changes + window.addEventListener("resize", throttle(this.adjustHeight, 250)); } get maxHeight() { @@ -50,35 +94,14 @@ export class DockStore { const mainLayoutTabs = 33; const mainLayoutMargin = 16; const dockTabs = 33; - const preferedMax = window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs; + const preferredMax = window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs; - return Math.max(preferedMax, this.minHeight); // don't let max < min + return Math.max(preferredMax, this.minHeight); // don't let max < min } - constructor() { - Object.assign(this, this.storage.get()); - - reaction(() => ({ - isOpen: this.isOpen, - selectedTabId: this.selectedTabId, - height: this.height, - tabs: this.tabs.slice(), - }), data => { - this.storage.set(data); - }); - - // adjust terminal height if window size changes - window.addEventListener("resize", throttle(this.checkMaxHeight, 250)); - } - - protected checkMaxHeight() { - if (!this.height) { - this.setHeight(this.defaultHeight || this.minHeight); - } - - if (this.height > this.maxHeight) { - this.setHeight(this.maxHeight); - } + protected adjustHeight() { + if (this.height < this.minHeight) this.height = this.minHeight; + if (this.height > this.maxHeight) this.height = this.maxHeight; } onResize(callback: () => void, options?: IReactionOptions) { @@ -165,7 +188,7 @@ export class DockStore { if (!tab || tab.pinned) { return; } - this.tabs.remove(tab); + this.tabs = this.tabs.filter(tab => tab.id !== tabId); if (this.selectedTabId === tab.id) { if (this.tabs.length) { @@ -178,8 +201,7 @@ export class DockStore { if (!terminalStore.isConnected(newTab.id)) this.close(); } this.selectTab(newTab.id); - } - else { + } else { this.selectedTabId = null; this.close(); } @@ -219,17 +241,9 @@ export class DockStore { this.selectedTabId = this.getTabById(tabId)?.id ?? null; } - @action - setHeight(height?: number) { - this.height = Math.max(this.minHeight, Math.min(height || this.minHeight, this.maxHeight)); - } - @action reset() { - this.selectedTabId = this.defaultTabId; - this.tabs.replace(this.initialTabs); - this.setHeight(this.defaultHeight); - this.close(); + this.storage?.reset(); } } diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index 6d74544f45..19a233eb79 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -89,7 +89,7 @@ export class Dock extends React.Component { onStart={dockStore.open} onMinExtentSubceed={dockStore.close} onMinExtentExceed={dockStore.open} - onDrag={dockStore.setHeight} + onDrag={extent => dockStore.height = extent} />
{ - private watchers = new Map(); +export class EditResourceStore extends DockTabStore { + private watchers = new Map(); constructor() { super({ - storageName: "edit_resource_store", - storageSerializer: ({ draft, ...data }) => data, // skip saving draft in local-storage + storageKey: "edit_resource_store", }); + } + + protected async init() { + super.init(); + await this.storage.whenReady; autorun(() => { Array.from(this.data).forEach(([tabId, { resource }]) => { @@ -42,12 +47,34 @@ export class EditResourceStore extends DockTabStore { } } }, { - delay: 100 // make sure all stores initialized + delay: 100 // make sure all kube-object stores are initialized })); }); }); } + protected finalizeDataForSave({ draft, ...data }: EditingResource): EditingResource { + return data; // skip saving draft to local-storage + } + + isReady(tabId: TabId) { + const tabDataReady = super.isReady(tabId); + + return Boolean(tabDataReady && this.getResource(tabId)); // ready to edit resource + } + + getStore(tabId: TabId): KubeObjectStore | undefined { + return apiManager.getStore(this.getResourcePath(tabId)); + } + + getResource(tabId: TabId): KubeObject | undefined { + return this.getStore(tabId)?.getByPath(this.getResourcePath(tabId)); + } + + getResourcePath(tabId: TabId): string | undefined { + return this.getData(tabId)?.resource; + } + getTabByResource(object: KubeObject): IDockTab { const [tabId] = Array.from(this.data).find(([, { resource }]) => { return object.selfLink === resource; diff --git a/src/renderer/components/dock/edit-resource.tsx b/src/renderer/components/dock/edit-resource.tsx index 104f8f1ea3..236a63d923 100644 --- a/src/renderer/components/dock/edit-resource.tsx +++ b/src/renderer/components/dock/edit-resource.tsx @@ -1,8 +1,8 @@ import "./edit-resource.scss"; import React from "react"; -import { autorun, observable } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; +import { observable, when } from "mobx"; +import { observer } from "mobx-react"; import jsYaml from "js-yaml"; import { IDockTab } from "./dock.store"; import { cssNames } from "../../utils"; @@ -11,8 +11,6 @@ import { InfoPanel } from "./info-panel"; import { Badge } from "../badge"; import { EditorPanel } from "./editor-panel"; import { Spinner } from "../spinner"; -import { apiManager } from "../../api/api-manager"; -import { KubeObject } from "../../api/kube-object"; interface Props { className?: string; @@ -23,30 +21,28 @@ interface Props { export class EditResource extends React.Component { @observable error = ""; - @disposeOnUnmount - autoDumpResourceOnInit = autorun(() => { - if (!this.tabData) return; + async componentDidMount() { + await when(() => this.isReady); - if (this.tabData.draft === undefined && this.resource) { - this.saveDraft(this.resource); + if (!this.tabData.draft) { + this.saveDraft(this.resource); // make initial dump to editor } - }); + } get tabId() { return this.props.tab.id; } + get isReady() { + return editResourceStore.isReady(this.tabId); + } + get tabData() { return editResourceStore.getData(this.tabId); } - get resource(): KubeObject { - const { resource } = this.tabData; - const store = apiManager.getStore(resource); - - if (store) { - return store.getByPath(resource); - } + get resource() { + return editResourceStore.getResource(this.tabId); } saveDraft(draft: string | object) { @@ -68,8 +64,8 @@ export class EditResource extends React.Component { if (this.error) { return; } - const { resource, draft } = this.tabData; - const store = apiManager.getStore(resource); + const { draft } = this.tabData; + const store = editResourceStore.getStore(this.tabId); const updatedResource = await store.update(this.resource, jsYaml.safeLoad(draft)); this.saveDraft(updatedResource); // update with new resourceVersion to avoid further errors on save @@ -84,13 +80,13 @@ export class EditResource extends React.Component { }; render() { - const { tabId, resource, tabData, error, onChange, save } = this; - const { draft } = tabData; - - if (!resource || draft === undefined) { + if (!this.isReady) { return ; } - const { kind, getNs, getName } = resource; + + const { tabId, error, onChange, save } = this; + const { kind, getNs, getName } = this.resource; + const { draft } = this.tabData; return (
diff --git a/src/renderer/components/dock/install-chart.store.ts b/src/renderer/components/dock/install-chart.store.ts index 4979bb28a0..7a11deb65a 100644 --- a/src/renderer/components/dock/install-chart.store.ts +++ b/src/renderer/components/dock/install-chart.store.ts @@ -22,7 +22,7 @@ export class InstallChartStore extends DockTabStore { constructor() { super({ - storageName: "install_charts" + storageKey: "install_charts" }); autorun(() => { const { selectedTab, isOpen } = dockStore; diff --git a/src/renderer/components/dock/log-tab.store.ts b/src/renderer/components/dock/log-tab.store.ts index 3eec7812be..ca5d49873b 100644 --- a/src/renderer/components/dock/log-tab.store.ts +++ b/src/renderer/components/dock/log-tab.store.ts @@ -27,7 +27,7 @@ interface WorkloadLogsTabData { export class LogTabStore extends DockTabStore { constructor() { super({ - storageName: "pod_logs" + storageKey: "pod_logs" }); reaction(() => podsStore.items.length, () => { diff --git a/src/renderer/components/dock/upgrade-chart.store.ts b/src/renderer/components/dock/upgrade-chart.store.ts index 63468f3180..78fb4d5072 100644 --- a/src/renderer/components/dock/upgrade-chart.store.ts +++ b/src/renderer/components/dock/upgrade-chart.store.ts @@ -16,7 +16,7 @@ export class UpgradeChartStore extends DockTabStore { constructor() { super({ - storageName: "chart_releases" + storageKey: "chart_releases" }); autorun(() => { diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index bb4e384c27..ab352361fe 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -2,7 +2,7 @@ import "./item-list-layout.scss"; import groupBy from "lodash/groupBy"; import React, { ReactNode } from "react"; -import { computed, observable, reaction, toJS } from "mobx"; +import { computed } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog"; import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallback } from "../table"; @@ -45,6 +45,7 @@ export interface ItemListLayoutProps { isClusterScoped?: boolean; hideFilters?: boolean; searchFilters?: SearchFilter[]; + /** @deprecated */ filterItems?: ItemsFilter[]; // header (title, filtering, searching, etc.) @@ -93,29 +94,20 @@ const defaultProps: Partial = { customizeTableRowProps: () => ({} as TableRowProps), }; -interface ItemListLayoutUserSettings { - showAppliedFilters?: boolean; -} - @observer export class ItemListLayout extends React.Component { static defaultProps = defaultProps as object; - @observable userSettings: ItemListLayoutUserSettings = { - showAppliedFilters: false, - }; + private storage = createStorage("item_list_layout", { + showFilters: false, // setup defaults + }); - constructor(props: ItemListLayoutProps) { - super(props); + get showFilters(): boolean { + return this.storage.get().showFilters; + } - // keep ui user settings in local storage - const defaultUserSettings = toJS(this.userSettings); - const storage = createStorage("items_list_layout", defaultUserSettings); - - Object.assign(this.userSettings, storage.get()); // restore - disposeOnUnmount(this, [ - reaction(() => toJS(this.userSettings), settings => storage.set(settings)), - ]); + set showFilters(showFilters: boolean) { + this.storage.merge({ showFilters }); } async componentDidMount() { @@ -291,9 +283,9 @@ export class ItemListLayout extends React.Component { renderFilters() { const { hideFilters } = this.props; - const { isReady, userSettings, filters } = this; + const { isReady, filters } = this; - if (!isReady || !filters.length || hideFilters || !userSettings.showAppliedFilters) { + if (!isReady || !filters.length || hideFilters || !this.showFilters) { return; } @@ -334,13 +326,13 @@ export class ItemListLayout extends React.Component { } renderInfo() { - const { items, isReady, userSettings, filters } = this; + const { items, isReady, filters } = this; const allItemsCount = this.props.store.getTotalCount(); const itemsCount = items.length; const isFiltered = isReady && filters.length > 0; if (isFiltered) { - const toggleFilters = () => userSettings.showAppliedFilters = !userSettings.showAppliedFilters; + const toggleFilters = () => this.showFilters = !this.showFilters; return ( <>Filtered: {itemsCount} / {allItemsCount} diff --git a/src/renderer/components/layout/main-layout.scss b/src/renderer/components/layout/main-layout.scss index 4e456e357f..4297a53452 100755 --- a/src/renderer/components/layout/main-layout.scss +++ b/src/renderer/components/layout/main-layout.scss @@ -7,7 +7,6 @@ "aside footer"; grid-template-rows: [header] var(--main-layout-header) [tabs] min-content [main] 1fr [footer] auto; grid-template-columns: [sidebar] minmax(var(--main-layout-header), min-content) [main] 1fr; - height: 100%; > header { @@ -22,18 +21,15 @@ background: $sidebarBackground; white-space: nowrap; transition: width 150ms cubic-bezier(0.4, 0, 0.2, 1); + width: var(--sidebar-width); - &.pinned { - width: var(--sidebar-width); - } - - &:not(.pinned) { + &.compact { position: absolute; width: var(--main-layout-header); height: 100%; overflow: hidden; - &.accessible:hover { + &:hover { width: var(--sidebar-width); transition-delay: 750ms; box-shadow: 3px 3px 16px rgba(0, 0, 0, 0.35); diff --git a/src/renderer/components/layout/main-layout.tsx b/src/renderer/components/layout/main-layout.tsx index 7be6148e3e..c772582e8f 100755 --- a/src/renderer/components/layout/main-layout.tsx +++ b/src/renderer/components/layout/main-layout.tsx @@ -1,15 +1,15 @@ import "./main-layout.scss"; import React from "react"; -import { observable, reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; +import { observer } from "mobx-react"; import { getHostedCluster } from "../../../common/cluster-store"; -import { autobind, createStorage, cssNames } from "../../utils"; +import { cssNames } from "../../utils"; import { Dock } from "../dock"; import { ErrorBoundary } from "../error-boundary"; import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor"; import { MainLayoutHeader } from "./main-layout-header"; import { Sidebar } from "./sidebar"; +import { sidebarStorage } from "./sidebar-storage"; export interface MainLayoutProps { className?: any; @@ -20,65 +20,41 @@ export interface MainLayoutProps { @observer export class MainLayout extends React.Component { - public storage = createStorage("main_layout", { - pinnedSidebar: true, - sidebarWidth: 200, - }); - - @observable isPinned = this.storage.get().pinnedSidebar; - @observable isAccessible = true; - @observable sidebarWidth = this.storage.get().sidebarWidth; - - @disposeOnUnmount syncPinnedStateWithStorage = reaction( - () => this.isPinned, - (isPinned) => this.storage.merge({ pinnedSidebar: isPinned }) - ); - - @disposeOnUnmount syncWidthStateWithStorage = reaction( - () => this.sidebarWidth, - (sidebarWidth) => this.storage.merge({ sidebarWidth }) - ); - - - toggleSidebar = () => { - this.isPinned = !this.isPinned; - this.isAccessible = false; - setTimeout(() => (this.isAccessible = true), 250); + onSidebarCompactModeChange = () => { + sidebarStorage.merge(draft => { + draft.compact = !draft.compact; + }); }; - getSidebarSize = () => { - return { - "--sidebar-width": `${this.sidebarWidth}px`, - }; + onSidebarResize = (width: number) => { + sidebarStorage.merge({ width }); }; - @autobind() - adjustWidth(newWidth: number): void { - this.sidebarWidth = newWidth; - } - render() { - const { className, headerClass, footer, footerClass, children } = this.props; const cluster = getHostedCluster(); + const { onSidebarCompactModeChange, onSidebarResize } = this; + const { className, headerClass, footer, footerClass, children } = this.props; + const { compact, width: sidebarWidth } = sidebarStorage.get(); + const style = { "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties; if (!cluster) { return null; // fix: skip render when removing active (visible) cluster } return ( -
- +
+ -
); } diff --git a/src/renderer/components/layout/sidebar-context.ts b/src/renderer/components/layout/sidebar-context.ts deleted file mode 100644 index 7001bbc319..0000000000 --- a/src/renderer/components/layout/sidebar-context.ts +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -export const SidebarContext = React.createContext({ pinned: false }); - -export type SidebarContextValue = { - pinned: boolean; -}; diff --git a/src/renderer/components/layout/sidebar-item.scss b/src/renderer/components/layout/sidebar-item.scss new file mode 100644 index 0000000000..8a06b38d3f --- /dev/null +++ b/src/renderer/components/layout/sidebar-item.scss @@ -0,0 +1,63 @@ +.SidebarItem { + $itemSpacing: floor($unit / 2.6) floor($unit / 1.6); + + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 100%; + user-select: none; + + > .nav-item { + text-decoration: none; + padding: $itemSpacing; + width: 100%; + height: 100%; + color: inherit; + cursor: pointer; + + > .link-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.active, &:hover { + background: $lensBlue; + color: $sidebarActiveColor; + } + + .expand-icon { + --size: 20px; + } + } + + .sub-menu { + $borderSize: 4px; + border-left: $borderSize solid transparent; + + &.active { + border-left-color: $lensBlue; + } + + > .SidebarItem { + color: $textColorPrimary; + padding-left: 30px + $borderSize; + line-height: 22px; + + .SidebarItem { + padding-left: $padding * 2; // 3rd+ menu level + } + + .nav-item { + &.expandable { + font-weight: 500; + } + + &.active, &:hover { + color: $sidebarSubmenuActiveColor; + background: none; + } + } + } + } +} \ No newline at end of file diff --git a/src/renderer/components/layout/sidebar-item.tsx b/src/renderer/components/layout/sidebar-item.tsx new file mode 100644 index 0000000000..e2eb942db4 --- /dev/null +++ b/src/renderer/components/layout/sidebar-item.tsx @@ -0,0 +1,98 @@ +import "./sidebar-item.scss"; + +import React from "react"; +import { computed } from "mobx"; +import { cssNames, prevDefault } from "../../utils"; +import { observer } from "mobx-react"; +import { NavLink } from "react-router-dom"; +import { Icon } from "../icon"; +import { TabLayoutRoute } from "./tab-layout"; +import { sidebarStorage } from "./sidebar-storage"; +import { isActiveRoute } from "../../navigation"; + +interface SidebarItemProps { + id: string; // Used to save nav item collapse/expand state in local storage + url: string; + text: React.ReactNode | string; + className?: string; + icon?: React.ReactNode; + isHidden?: boolean; + isActive?: boolean; + subMenus?: TabLayoutRoute[]; +} + +@observer +export class SidebarItem extends React.Component { + static displayName = "SidebarItem"; + + get id(): string { + return this.props.id; // unique id, used in storage and integration tests + } + + get compact(): boolean { + return Boolean(sidebarStorage.get().compact); + } + + get expanded(): boolean { + return Boolean(sidebarStorage.get().expanded[this.id]); + } + + @computed get isExpandable(): boolean { + const { subMenus, children } = this.props; + const hasContent = subMenus?.length > 0 || children; + + return Boolean(hasContent && !this.compact) /*not available in compact-mode*/; + } + + toggleExpand = () => { + sidebarStorage.merge(draft => { + draft.expanded[this.id] = !draft.expanded[this.id]; + }); + }; + + render() { + const { isHidden, isActive, subMenus = [], icon, text, children, url, className } = this.props; + + if (isHidden) return null; + + const { id, compact, expanded, isExpandable, toggleExpand } = this; + const classNames = cssNames(SidebarItem.displayName, className, { + compact, + }); + + return ( +
+ isActive} + className={cssNames("nav-item flex gaps align-center", { expandable: isExpandable })} + onClick={isExpandable ? prevDefault(toggleExpand) : undefined}> + {icon} + {text} + {isExpandable && } + + {isExpandable && expanded && ( +
    + {subMenus.map(({ title, routePath, url = routePath }) => { + const subItemId = `${id}${routePath}`; + + return ( + + ); + })} + {children} +
+ )} +
+ ); + } +} diff --git a/src/renderer/components/layout/sidebar-nav-item.scss b/src/renderer/components/layout/sidebar-nav-item.scss deleted file mode 100644 index f99c21f2cc..0000000000 --- a/src/renderer/components/layout/sidebar-nav-item.scss +++ /dev/null @@ -1,76 +0,0 @@ -.SidebarNavItem { - $itemSpacing: floor($unit / 2.6) floor($unit / 1.6); - - width: 100%; - user-select: none; - flex-shrink: 0; - - .nav-item { - cursor: pointer; - width: inherit; - display: flex; - align-items: center; - text-decoration: none; - border: none; - padding: $itemSpacing; - - &.active, &:hover { - background: $lensBlue; - color: $sidebarActiveColor; - } - } - - .expand-icon { - --size: 20px; - } - - .sub-menu { - border-left: 4px solid transparent; - - &.active { - border-left-color: $lensBlue; - } - - a, .SidebarNavItem { - display: block; - border: none; - text-decoration: none; - color: $textColorPrimary; - font-weight: normal; - padding-left: 40px; // parent icon width - overflow: hidden; - text-overflow: ellipsis; - line-height: 0px; // hidden by default - height: 0px; - opacity: 0; - transition: 125ms line-height ease-out, 200ms 100ms opacity; - - &.visible { - line-height: 28px; - height: auto; - opacity: 1; - } - - &.active, &:hover { - color: $sidebarSubmenuActiveColor; - } - } - - .sub-menu-parent { - padding-left: 27px; - font-weight: 500; - - .nav-item { - &:hover { - background: transparent; - } - } - - .sub-menu { - a { - padding-left: $padding * 3; - } - } - } - } -} \ No newline at end of file diff --git a/src/renderer/components/layout/sidebar-nav-item.tsx b/src/renderer/components/layout/sidebar-nav-item.tsx deleted file mode 100644 index 7dfcbb50e6..0000000000 --- a/src/renderer/components/layout/sidebar-nav-item.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import "./sidebar-nav-item.scss"; - -import React from "react"; -import { computed, observable, reaction } from "mobx"; -import { observer } from "mobx-react"; -import { NavLink } from "react-router-dom"; - -import { createStorage, cssNames } from "../../utils"; -import { Icon } from "../icon"; -import { SidebarContext } from "./sidebar-context"; - -import type { TabLayoutRoute } from "./tab-layout"; -import type { SidebarContextValue } from "./sidebar-context"; - -interface SidebarNavItemProps { - id: string; // Used to save nav item collapse/expand state in local storage - url: string; - text: React.ReactNode | string; - className?: string; - icon?: React.ReactNode; - isHidden?: boolean; - isActive?: boolean; - subMenus?: TabLayoutRoute[]; -} - -const navItemStorage = createStorage<[string, boolean][]>("sidebar_menu_item", []); -const navItemState = observable.map(navItemStorage.get()); - -reaction(() => [...navItemState], (value) => navItemStorage.set(value)); - -@observer -export class SidebarNavItem extends React.Component { - static contextType = SidebarContext; - public context: SidebarContextValue; - - @computed get isExpanded() { - return navItemState.get(this.props.id); - } - - toggleSubMenu = () => { - navItemState.set(this.props.id, !this.isExpanded); - }; - - render() { - const { isHidden, isActive, subMenus = [], icon, text, url, children, className, id } = this.props; - - if (isHidden) { - return null; - } - const extendedView = (subMenus.length > 0 || children) && this.context.pinned; - - if (extendedView) { - return ( -
-
- {icon} - {text} - -
-
    - {subMenus.map(({ title, url }) => ( - - {title} - - ))} - {React.Children.toArray(children).map((child: React.ReactElement) => { - return React.cloneElement(child, { - className: cssNames(child.props.className, { visible: this.isExpanded }), - }); - })} -
-
- ); - } - - return ( - isActive}> - {icon} - {text} - - ); - } -} diff --git a/src/renderer/components/layout/sidebar-storage.ts b/src/renderer/components/layout/sidebar-storage.ts new file mode 100644 index 0000000000..ee06e0bc92 --- /dev/null +++ b/src/renderer/components/layout/sidebar-storage.ts @@ -0,0 +1,15 @@ +import { createStorage } from "../../utils"; + +export interface SidebarStorageState { + width: number; + compact: boolean; + expanded: { + [itemId: string]: boolean; + } +} + +export const sidebarStorage = createStorage("sidebar", { + width: 200, // sidebar size in non-compact mode + compact: false, // compact-mode (icons only) + expanded: {}, +}); diff --git a/src/renderer/components/layout/sidebar.scss b/src/renderer/components/layout/sidebar.scss index 1379c87d9e..5d39958c59 100644 --- a/src/renderer/components/layout/sidebar.scss +++ b/src/renderer/components/layout/sidebar.scss @@ -2,9 +2,9 @@ $iconSize: 24px; $itemSpacing: floor($unit / 2.6) floor($unit / 1.6); - &.pinned { + &.compact { .sidebar-nav { - overflow: auto; + @include hidden-scrollbar; // fix: scrollbar overlaps icons } } @@ -45,6 +45,7 @@ .sidebar-nav { padding: $padding / 1.5 0; + overflow: auto; .Icon { --size: #{$iconSize}; @@ -54,26 +55,6 @@ border-radius: 50%; } - .link-text { - flex: 1; - margin-left: $margin; - overflow: hidden; - text-overflow: ellipsis; - } - - > a { - display: flex; - align-items: center; - text-decoration: none; - border: none; - padding: $itemSpacing; - - &.active, &:hover { - background: $lensBlue; - color: $sidebarActiveColor; - } - } - hr { background-color: transparent; } diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index f47ba1702e..7f0ddd818f 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -28,17 +28,18 @@ import { isActiveRoute } from "../../navigation"; import { isAllowedResource } from "../../../common/rbac"; import { Spinner } from "../spinner"; import { ClusterPageMenuRegistration, clusterPageMenuRegistry, clusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries"; -import { SidebarNavItem } from "./sidebar-nav-item"; -import { SidebarContext } from "./sidebar-context"; +import { SidebarItem } from "./sidebar-item"; interface Props { className?: string; - isPinned: boolean; - toggle(): void; + compact?: boolean; // compact-mode view: show only icons and expand on :hover + toggle(): void; // compact-mode updater } @observer export class Sidebar extends React.Component { + static displayName = "Sidebar"; + async componentDidMount() { crdStore.reloadAll(); } @@ -59,13 +60,13 @@ export class Sidebar extends React.Component { }); return ( - ); }); @@ -117,7 +118,7 @@ export class Sidebar extends React.Component { } return ( - { } render() { - const { toggle, isPinned, className } = this.props; + const { toggle, compact, className } = this.props; const query = namespaceUrlParam.toObjectParam(); return ( - -
-
- - -
Lens
-
- -
-
- } - /> - } - /> - } - /> - } - /> - } - /> - } - text="Storage" - /> - } - text="Namespaces" - /> - } - text="Events" - /> - } - text="Apps" - /> - } - text="Access Control" - /> - } - text="Custom Resources" - > - {this.renderCustomResources()} - - {this.renderRegisteredMenus()} -
+
+
+ + +
Lens
+
+
- +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + text="Storage" + /> + } + text="Namespaces" + /> + } + text="Events" + /> + } + text="Apps" + /> + } + text="Access Control" + /> + } + text="Custom Resources" + > + {this.renderCustomResources()} + + {this.renderRegisteredMenus()} +
+
); } } diff --git a/src/renderer/hooks/useStorage.ts b/src/renderer/hooks/useStorage.ts index 97b0588d29..6867dcc79a 100644 --- a/src/renderer/hooks/useStorage.ts +++ b/src/renderer/hooks/useStorage.ts @@ -1,7 +1,8 @@ import { useState } from "react"; -import { createStorage, IStorageHelperOptions } from "../utils"; +import { createStorage } from "../utils"; +import { CreateObservableOptions } from "mobx/lib/api/observable"; -export function useStorage(key: string, initialValue?: T, options?: IStorageHelperOptions) { +export function useStorage(key: string, initialValue?: T, options?: CreateObservableOptions) { const storage = createStorage(key, initialValue, options); const [storageValue, setStorageValue] = useState(storage.get()); const setValue = (value: T) => { diff --git a/src/renderer/utils/__tests__/storageHelper.test.ts b/src/renderer/utils/__tests__/storageHelper.test.ts new file mode 100644 index 0000000000..8e13543ccd --- /dev/null +++ b/src/renderer/utils/__tests__/storageHelper.test.ts @@ -0,0 +1,190 @@ +import { reaction } from "mobx"; +import { StorageAdapter, StorageHelper } from "../storageHelper"; +import { delay } from "../../../common/utils/delay"; + +describe("renderer/utils/StorageHelper", () => { + describe("window.localStorage might be used as StorageAdapter", () => { + type StorageModel = string; + + const storageKey = "ui-settings"; + let storageHelper: StorageHelper; + + beforeEach(() => { + localStorage.clear(); + + storageHelper = new StorageHelper(storageKey, { + autoInit: false, + storage: localStorage, + defaultValue: "test", + }); + }); + + it("initialized with default value", async () => { + localStorage.setItem(storageKey, "saved"); // pretending it was saved previously + + expect(storageHelper.key).toBe(storageKey); + expect(storageHelper.defaultValue).toBe("test"); + expect(storageHelper.get()).toBe("test"); + + await storageHelper.init(); + + expect(storageHelper.key).toBe(storageKey); + expect(storageHelper.defaultValue).toBe("test"); + expect(storageHelper.get()).toBe("saved"); + }); + + it("updates storage", async () => { + storageHelper.init(); + + storageHelper.set("test2"); + expect(localStorage.getItem(storageKey)).toBe("test2"); + + localStorage.setItem(storageKey, "test3"); + storageHelper.init({ force: true }); // reload from underlying storage and merge + expect(storageHelper.get()).toBe("test3"); + }); + }); + + describe("Using custom StorageAdapter", () => { + type SettingsStorageModel = { + [key: string]: any; + message: string; + }; + + const storageKey = "mySettings"; + const storageMock: Record = {}; + let storageHelper: StorageHelper; + let storageHelperAsync: StorageHelper; + let storageAdapter: StorageAdapter; + + const storageHelperDefaultValue: SettingsStorageModel = { + message: "hello-world", + anyOtherStorableData: 123, + }; + + beforeEach(() => { + storageMock[storageKey] = { + message: "saved-before", + } as SettingsStorageModel; + + storageAdapter = { + onChange: jest.fn(), + getItem: jest.fn((key: string) => { + return storageMock[key]; + }), + setItem: jest.fn((key: string, value: any) => { + storageMock[key] = value; + }), + removeItem: jest.fn((key: string) => { + delete storageMock[key]; + }), + }; + + storageHelper = new StorageHelper(storageKey, { + autoInit: false, + defaultValue: storageHelperDefaultValue, + storage: storageAdapter, + }); + + storageHelperAsync = new StorageHelper(storageKey, { + autoInit: false, + defaultValue: storageHelperDefaultValue, + storage: { + ...storageAdapter, + async getItem(key: string): Promise { + await delay(500); // fake loading timeout + + return storageAdapter.getItem(key); + } + }, + }); + }); + + it("loads data from storage with fallback to default-value", () => { + expect(storageHelper.get()).toEqual(storageHelperDefaultValue); + storageHelper.init(); + + expect(storageHelper.get().message).toBe("saved-before"); + expect(storageAdapter.getItem).toHaveBeenCalledWith(storageHelper.key); + }); + + it("async loading from storage supported too", async () => { + expect(storageHelperAsync.initialized).toBeFalsy(); + storageHelperAsync.init(); + await delay(300); + expect(storageHelperAsync.get()).toEqual(storageHelperDefaultValue); + await delay(200); + expect(storageHelperAsync.get().message).toBe("saved-before"); + }); + + it("set() fully replaces data in storage", () => { + storageHelper.init(); + storageHelper.set({ message: "test2" }); + expect(storageHelper.get().message).toBe("test2"); + expect(storageMock[storageKey]).toEqual({ message: "test2" }); + expect(storageAdapter.setItem).toHaveBeenCalledWith(storageHelper.key, { message: "test2" }); + }); + + it("merge() does partial data tree updates", () => { + storageHelper.init(); + storageHelper.merge({ message: "updated" }); + + expect(storageHelper.get()).toEqual({ ...storageHelperDefaultValue, message: "updated" }); + expect(storageAdapter.setItem).toHaveBeenCalledWith(storageHelper.key, { ...storageHelperDefaultValue, message: "updated" }); + + storageHelper.merge(draft => { + draft.message = "updated2"; + }); + expect(storageHelper.get()).toEqual({ ...storageHelperDefaultValue, message: "updated2" }); + + storageHelper.merge(draft => ({ + message: draft.message.replace("2", "3") + })); + expect(storageHelper.get()).toEqual({ ...storageHelperDefaultValue, message: "updated3" }); + }); + + it("clears data in storage", () => { + storageHelper.init(); + + expect(storageHelper.get()).toBeTruthy(); + storageHelper.clear(); + expect(storageHelper.get()).toBeFalsy(); + expect(storageMock[storageKey]).toBeUndefined(); + expect(storageAdapter.removeItem).toHaveBeenCalledWith(storageHelper.key); + }); + + }); + + describe("data in storage-helper is observable (mobx)", () => { + let storageHelper: StorageHelper; + const defaultValue: any = { firstName: "Joe" }; + const observedChanges: any[] = []; + + beforeEach(() => { + observedChanges.length = 0; + + storageHelper = new StorageHelper("some-key", { + autoInit: true, + defaultValue, + storage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, + }); + }); + + it("storage.get() is observable", () => { + expect(storageHelper.get()).toEqual(defaultValue); + + reaction(() => storageHelper.toJS(), change => { + observedChanges.push(change); + }); + + storageHelper.merge({ lastName: "Black" }); + storageHelper.set("whatever"); + expect(observedChanges).toEqual([{ ...defaultValue, lastName: "Black" }, "whatever",]); + }); + }); + +}); diff --git a/src/renderer/utils/createStorage.ts b/src/renderer/utils/createStorage.ts index 54c064ab75..623f657461 100755 --- a/src/renderer/utils/createStorage.ts +++ b/src/renderer/utils/createStorage.ts @@ -1,73 +1,72 @@ -// Helper to work with browser's local/session storage api +// Keeps window.localStorage state in external JSON-files. +// Because app creates random port between restarts => storage session wiped out each time. +import type { CreateObservableOptions } from "mobx/lib/api/observable"; -export interface IStorageHelperOptions { - addKeyPrefix?: boolean; - useSession?: boolean; // use `sessionStorage` instead of `localStorage` -} +import path from "path"; +import { app, remote } from "electron"; +import { observable, reaction, when } from "mobx"; +import fse from "fs-extra"; +import { StorageHelper } from "./storageHelper"; +import { clusterStore, getHostedClusterId } from "../../common/cluster-store"; +import logger from "../../main/logger"; -export function createStorage(key: string, defaultValue?: T, options?: IStorageHelperOptions) { - return new StorageHelper(key, defaultValue, options); -} +let initialized = false; +const loaded = observable.box(false); +const storage = observable.map(); -export class StorageHelper { - static keyPrefix = "lens_"; +export function createStorage(key: string, defaultValue?: T, observableOptions?: CreateObservableOptions) { + const clusterId = getHostedClusterId(); + const savingFolder = path.resolve((app || remote.app).getPath("userData"), "lens-local-storage"); + const jsonFilePath = path.resolve(savingFolder, `${clusterId ?? "app"}.json`); - static defaultOptions: IStorageHelperOptions = { - addKeyPrefix: true, - useSession: false, - }; + if (!initialized) { + initialized = true; - constructor(protected key: string, protected defaultValue?: T, protected options?: IStorageHelperOptions) { - this.options = Object.assign({}, StorageHelper.defaultOptions, options); + // read once per cluster domain + fse.readJson(jsonFilePath) + .then((data = {}) => storage.merge(data)) + .catch(() => null) // ignore empty / non-existing / invalid json files + .finally(() => loaded.set(true)); - if (this.options.addKeyPrefix) { - this.key = StorageHelper.keyPrefix + key; + // bind auto-saving + reaction(() => storage.toJSON(), saveFile, { delay: 250 }); + + // remove json-file when cluster deleted + if (clusterId !== undefined) { + when(() => clusterStore.removedClusters.has(clusterId)).then(removeFile); } } - protected get storage() { - if (this.options.useSession) return window.sessionStorage; - - return window.localStorage; + async function saveFile(json = {}) { + try { + await fse.ensureDir(savingFolder, { mode: 0o755 }); + await fse.writeJson(jsonFilePath, json, { spaces: 2 }); + } catch (error) { + logger.error(`[save]: ${error}`, { json, jsonFilePath }); + } } - get(): T { - const strValue = this.storage.getItem(this.key); + function removeFile() { + logger.debug("[remove]:", jsonFilePath); + fse.unlink(jsonFilePath).catch(Function); + } - if (strValue != null) { - try { - return JSON.parse(strValue); - } catch (e) { - console.error(`Parsing json failed for pair: ${this.key}=${strValue}`); + return new StorageHelper(key, { + autoInit: true, + observable: observableOptions, + defaultValue, + storage: { + async getItem(key: string) { + await when(() => loaded.get()); + + return storage.get(key); + }, + setItem(key: string, value: any) { + storage.set(key, value); + }, + removeItem(key: string) { + storage.delete(key); } - } - - return this.defaultValue; - } - - set(value: T) { - this.storage.setItem(this.key, JSON.stringify(value)); - - return this; - } - - merge(value: Partial) { - const currentValue = this.get(); - - return this.set(Object.assign(currentValue, value)); - } - - clear() { - this.storage.removeItem(this.key); - - return this; - } - - getDefaultValue() { - return this.defaultValue; - } - - restoreDefaultValue() { - return this.set(this.defaultValue); - } + }, + }); } diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index e546f94154..cb4ef559e7 100755 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -7,6 +7,7 @@ export * from "./cssNames"; export * from "../../common/event-emitter"; export * from "./saveFile"; export * from "./prevDefault"; +export * from "./storageHelper"; export * from "./createStorage"; export * from "./interval"; export * from "./copyToClipboard"; diff --git a/src/renderer/utils/storageHelper.ts b/src/renderer/utils/storageHelper.ts new file mode 100755 index 0000000000..f6e6a3d5f9 --- /dev/null +++ b/src/renderer/utils/storageHelper.ts @@ -0,0 +1,162 @@ +// Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.) + +import type { CreateObservableOptions } from "mobx/lib/api/observable"; +import { action, comparer, observable, toJS, when } from "mobx"; +import produce, { Draft, enableMapSet, setAutoFreeze } from "immer"; +import { isEqual, isFunction, isPlainObject } from "lodash"; +import logger from "../../main/logger"; + +setAutoFreeze(false); // allow to merge observables +enableMapSet(); // allow merging maps and sets + +export interface StorageAdapter { + [metadata: string]: any; + getItem(key: string): T | Promise; + setItem(key: string, value: T): void; + removeItem(key: string): void; + onChange?(change: { key: string, value: T, oldValue?: T }): void; +} + +export interface StorageHelperOptions { + autoInit?: boolean; // start preloading data immediately, default: true + observable?: CreateObservableOptions; + storage: StorageAdapter; + defaultValue?: T; +} + +export class StorageHelper { + static defaultOptions: Partial> = { + autoInit: true, + observable: { + deep: true, + equals: comparer.shallow, + } + }; + + @observable private data = observable.box(); + @observable initialized = false; + whenReady = when(() => this.initialized); + + get storage(): StorageAdapter { + return this.options.storage; + } + + get defaultValue(): T { + return this.options.defaultValue; + } + + constructor(readonly key: string, private options: StorageHelperOptions) { + this.options = { ...StorageHelper.defaultOptions, ...options }; + this.configureObservable(); + this.reset(); + + if (this.options.autoInit) { + this.init(); + } + } + + @action + init({ force = false } = {}) { + if (this.initialized && !force) return; + + this.loadFromStorage({ + onData: (data: T) => { + const notEmpty = data != null; + const notDefault = !this.isDefaultValue(data); + + if (notEmpty && notDefault) { + this.merge(data); + } + + this.initialized = true; + }, + onError: (error?: any) => { + logger.error(`[init]: ${error}`, this); + }, + }); + } + + private loadFromStorage(opts: { onData?(data: T): void, onError?(error?: any): void } = {}) { + let data: T | Promise; + + try { + data = this.storage.getItem(this.key); // sync reading from storage when exposed + + if (data instanceof Promise) { + data.then(opts.onData, opts.onError); + } else { + opts?.onData(data); + } + } catch (error) { + logger.error(`[load]: ${error}`, this); + opts?.onError(error); + } + + return data; + } + + isDefaultValue(value: T): boolean { + return isEqual(value, this.defaultValue); + } + + @action + private configureObservable(options = this.options.observable) { + this.data = observable.box(this.data.get(), { + ...StorageHelper.defaultOptions.observable, // inherit default observability options + ...(options ?? {}), + }); + this.data.observe(change => { + const { newValue, oldValue } = toJS(change, { recurseEverything: true }); + + this.onChange(newValue, oldValue); + }); + } + + protected onChange(value: T, oldValue?: T) { + if (!this.initialized) return; + + try { + if (value == null) { + this.storage.removeItem(this.key); + } else { + this.storage.setItem(this.key, value); + } + + this.storage.onChange?.({ value, oldValue, key: this.key }); + } catch (error) { + logger.error(`[change]: ${error}`, this, { value, oldValue }); + } + } + + get(): T { + return this.data.get(); + } + + set(value: T) { + this.data.set(value); + } + + reset() { + this.set(this.defaultValue); + } + + clear() { + this.data.set(null); + } + + merge(value: Partial | ((draft: Draft) => Partial | void)) { + const nextValue = produce(this.get(), (state: Draft) => { + const newValue = isFunction(value) ? value(state) : value; + + return isPlainObject(newValue) + ? Object.assign(state, newValue) // partial updates for returned plain objects + : newValue; + }); + + this.set(nextValue as T); + } + + toJS() { + return toJS(this.get(), { recurseEverything: true }); + } +} diff --git a/yarn.lock b/yarn.lock index 6da8f68c34..2a39325781 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6858,6 +6858,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= +immer@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" + integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== + import-fresh@^3.0.0, import-fresh@^3.1.0: version "3.2.1" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"