From 6cc83bd353446c8a679da26dcf4e389b07bcf0b9 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 1 Mar 2021 20:31:47 +0200 Subject: [PATCH] persist local-storage in json-file due random port on app start Signed-off-by: Roman --- package.json | 1 + src/renderer/bootstrap.tsx | 5 +- .../+cluster/cluster-overview.store.ts | 59 +++-- .../components/+namespaces/namespace.store.ts | 31 +-- .../dock/__test__/dock-tabs.test.tsx | 9 +- .../components/dock/dock-tab.store.ts | 41 +-- src/renderer/components/dock/dock.store.ts | 112 ++++---- .../item-object-list/item-list-layout.tsx | 40 ++- .../components/layout/main-layout.scss | 10 +- .../components/layout/main-layout.tsx | 68 ++--- .../components/layout/sidebar-context.ts | 7 - ...idebar-nav-item.scss => sidebar-item.scss} | 52 ++-- .../components/layout/sidebar-item.tsx | 92 +++++++ .../components/layout/sidebar-nav-item.tsx | 83 ------ .../components/layout/sidebar-storage.ts | 15 ++ src/renderer/components/layout/sidebar.scss | 24 +- src/renderer/components/layout/sidebar.tsx | 243 +++++++++--------- src/renderer/hooks/useStorage.ts | 4 +- src/renderer/local-storage.ts | 86 +++++++ src/renderer/utils/createStorage.ts | 73 ------ src/renderer/utils/index.ts | 2 +- src/renderer/utils/storageHelper.ts | 170 ++++++++++++ yarn.lock | 5 + 23 files changed, 724 insertions(+), 508 deletions(-) delete mode 100644 src/renderer/components/layout/sidebar-context.ts rename src/renderer/components/layout/{sidebar-nav-item.scss => sidebar-item.scss} (69%) create mode 100644 src/renderer/components/layout/sidebar-item.tsx 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/local-storage.ts delete mode 100755 src/renderer/utils/createStorage.ts create mode 100755 src/renderer/utils/storageHelper.ts diff --git a/package.json b/package.json index 3639366298..dc52dfe6c6 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,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/bootstrap.tsx b/src/renderer/bootstrap.tsx index 4d46011442..1871f2a8d7 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -6,7 +6,7 @@ import * as MobxReact from "mobx-react"; import * as ReactRouter from "react-router"; import * as ReactRouterDom from "react-router-dom"; import { render, unmountComponentAtNode } from "react-dom"; -import { clusterStore } from "../common/cluster-store"; +import { clusterStore, getHostedClusterId } from "../common/cluster-store"; import { userStore } from "../common/user-store"; import { isMac } from "../common/vars"; import { workspaceStore } from "../common/workspace-store"; @@ -18,6 +18,7 @@ import { filesystemProvisionerStore } from "../main/extension-filesystem"; import { App } from "./components/app"; import { LensApp } from "./lens-app"; import { themeStore } from "./theme.store"; +import { lensLocalStorage } from "./local-storage"; type AppComponent = React.ComponentType & { init?(): Promise; @@ -33,6 +34,7 @@ export { }; export async function bootstrap(App: AppComponent) { + const clusterId = getHostedClusterId(); const rootElem = document.getElementById("app"); rootElem.classList.toggle("is-mac", isMac); @@ -48,6 +50,7 @@ export async function bootstrap(App: AppComponent) { extensionsStore.load(), filesystemProvisionerStore.load(), themeStore.init(), + lensLocalStorage.init(clusterId), ]); // Register additional store listeners diff --git a/src/renderer/components/+cluster/cluster-overview.store.ts b/src/renderer/components/+cluster/cluster-overview.store.ts index 64faa2394c..c8223aa26c 100644 --- a/src/renderer/components/+cluster/cluster-overview.store.ts +++ b/src/renderer/components/+cluster/cluster-overview.store.ts @@ -1,7 +1,8 @@ import { action, observable, reaction, when } from "mobx"; import { KubeObjectStore } from "../../kube-object.store"; import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints"; -import { autobind, createStorage } from "../../utils"; +import { autobind } from "../../utils"; +import { createStorage } from "../../local-storage"; import { IMetricsReqParams, normalizeMetrics } from "../../api/endpoints/metrics.api"; import { nodesStore } from "../+nodes/nodes.store"; import { apiManager } from "../../api/api-manager"; @@ -16,36 +17,50 @@ export enum MetricNodeRole { WORKER = "worker" } +export interface ClusterOverviewStorageState { + metricType: MetricType; + metricNodeRole: MetricNodeRole, +} + +const localStorage = createStorage("cluster_overview", { + metricType: MetricType.CPU, // setup defaults + metricNodeRole: MetricNodeRole.WORKER, +}); + @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; + + get metricType(): MetricType { + return localStorage.get().metricType; + } + + set metricType(value: MetricType) { + localStorage.merge({ metricType: value }); + } + + get metricNodeRole(): MetricNodeRole { + return localStorage.get().metricNodeRole; + } + + set metricNodeRole(value: MetricNodeRole) { + localStorage.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 async 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 +94,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(); + localStorage.reset(); } } diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 9995fbb7e5..43b9988ded 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -1,18 +1,19 @@ import { action, comparer, computed, IReactionDisposer, IReactionOptions, observable, reaction } from "mobx"; -import { autobind, createStorage } from "../../utils"; +import { autobind } from "../../utils"; +import { createStorage } from "../../local-storage"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; 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", ["default"]); export const namespaceUrlParam = createPageParam({ name: "namespaces", isSystem: true, multiValues: true, get defaultValue() { - return storage.get() ?? []; // initial namespaces coming from URL or local-storage (default) + return selectedNamespaces.get(); } }); @@ -41,6 +42,7 @@ export class NamespaceStore extends KubeObjectStore { } private async init() { + await selectedNamespaces.whenReady; await this.contextReady; this.setContext(this.initialNamespaces); @@ -57,8 +59,8 @@ export class NamespaceStore extends KubeObjectStore { private autoUpdateUrlAndLocalStorage(): IReactionDisposer { return this.onContextChange(namespaces => { - storage.set(namespaces); // save to local-storage - namespaceUrlParam.set(namespaces, { replaceHistory: true }); // update url + selectedNamespaces.set(namespaces); + namespaceUrlParam.set(namespaces, { replaceHistory: true }); }, { fireImmediately: true, }); @@ -71,24 +73,11 @@ export class NamespaceStore extends KubeObjectStore { }); } - @computed private get initialNamespaces(): string[] { - const namespaces = new Set(this.allowedNamespaces); - const prevSelectedNamespaces = storage.get(); + const namespaces = this.allowedNamespaces; - // return previously saved namespaces from local-storage (if any) - if (prevSelectedNamespaces) { - return prevSelectedNamespaces.filter(namespace => namespaces.has(namespace)); - } - - // otherwise select "default" or first allowed namespace - if (namespaces.has("default")) { - return ["default"]; - } else if (namespaces.size) { - return [Array.from(namespaces)[0]]; - } - - return []; + // return all namespaces (empty list) if "default" namespace doesn't exist + return selectedNamespaces.get().filter(namespace => namespaces.includes(namespace)); } @computed get allowedNamespaces(): string[] { 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/dock-tab.store.ts b/src/renderer/components/dock/dock-tab.store.ts index b66db14d1e..42a82f6a9c 100644 --- a/src/renderer/components/dock/dock-tab.store.ts +++ b/src/renderer/components/dock/dock-tab.store.ts @@ -1,25 +1,31 @@ import { autorun, observable, reaction } from "mobx"; -import { autobind, createStorage } from "../../utils"; +import { createStorage, StorageHelper } from "../../local-storage"; +import { autobind } 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 + storageName?: string; // persistent key + storageSerializer?: (data: T) => Partial; // allow to customize data before saving } @autobind() export class DockTabStore { - protected data = observable.map([]); + private storage?: StorageHelper>; + protected data = observable.map(); constructor(protected options: Options = {}) { - const { storageName } = options; + this.init(); + } + + protected async init() { + const { storageName: 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, {}); + await this.storage.whenReady; + this.data.replace(this.storage.get()); + reaction(() => this.serializeData(), (data: T | any) => this.storage.set(data)); } // clear data for closed tabs @@ -34,14 +40,19 @@ export class DockTabStore { }); } - protected serializeData() { + protected serializeData(): Record { + const data = this.data.toJSON(); const { storageSerializer } = this.options; - return Array.from(this.data).map(([tabId, tabData]) => { - if (storageSerializer) return [tabId, storageSerializer(tabData)]; + if (storageSerializer) { + return Object.entries(data).reduce((data, [tabId, tabData]) => { + data[tabId] = storageSerializer(tabData) as T; - return [tabId, tabData]; - }); + return data; + }, data); + } + + return data; } getData(tabId: TabId) { diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock.store.ts index 423367093e..7dc659feda 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock.store.ts @@ -1,6 +1,7 @@ import MD5 from "crypto-js/md5"; import { action, computed, IReactionOptions, observable, reaction } from "mobx"; -import { autobind, createStorage } from "../../utils"; +import { createStorage } from "../../local-storage"; +import { autobind } from "../../utils"; import throttle from "lodash/throttle"; export type TabId = string; @@ -21,28 +22,68 @@ export interface IDockTab { pinned?: boolean; // not closable } -@autobind() -export class DockStore { - protected initialTabs: IDockTab[] = [ +export interface DockStorageState { + height: number; + tabs: IDockTab[]; + selectedTabId?: TabId; + isOpen?: boolean; +} + +const localStorage = createStorage("dock", { + height: 300, + tabs: [ { 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; +@autobind() +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; + + get isOpen(): boolean { + return localStorage.get().isOpen; + } + + set isOpen(value: boolean) { + localStorage.merge({ isOpen: value }); + } + + get height(): number { + return localStorage.get().height; + } + + set height(value: number) { + localStorage.merge({ height: value }); + } + + get tabs(): IDockTab[] { + return localStorage.get().tabs; + } + + set tabs(value: IDockTab[]) { + localStorage.merge({ tabs: value }); + } + + get selectedTabId(): TabId { + return localStorage.get().selectedTabId || this.tabs[0]?.id; + } + + set selectedTabId(value: TabId) { + localStorage.merge({ selectedTabId: value }); + } @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 async init() { + // adjust terminal height if window size changes + window.addEventListener("resize", throttle(this.adjustHeight, 250)); } get maxHeight() { @@ -50,35 +91,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.setHeight(this.minHeight); + if (this.height > this.maxHeight) this.setHeight(this.maxHeight); } onResize(callback: () => void, options?: IReactionOptions) { @@ -165,7 +185,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 +198,7 @@ export class DockStore { if (!terminalStore.isConnected(newTab.id)) this.close(); } this.selectTab(newTab.id); - } - else { + } else { this.selectedTabId = null; this.close(); } @@ -226,10 +245,7 @@ export class DockStore { @action reset() { - this.selectedTabId = this.defaultTabId; - this.tabs.replace(this.initialTabs); - this.setHeight(this.defaultHeight); - this.close(); + localStorage.reset(); } } 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 adec4bef27..9b414edcab 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -2,11 +2,12 @@ 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 { createStorage } from "../../local-storage"; import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog"; import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallback } from "../table"; -import { autobind, createStorage, cssNames, IClassName, isReactNode, noop, prevDefault, stopPropagation } from "../../utils"; +import { autobind, cssNames, IClassName, isReactNode, noop, prevDefault, stopPropagation } from "../../utils"; import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons"; import { NoItems } from "../no-items"; import { Spinner } from "../spinner"; @@ -93,29 +94,20 @@ const defaultProps: Partial = { customizeTableRowProps: () => ({} as TableRowProps), }; -interface ItemListLayoutUserSettings { - showAppliedFilters?: boolean; -} +const localStorage = createStorage("item_list_layout", { + showFilters: false, // setup defaults +}); @observer export class ItemListLayout extends React.Component { static defaultProps = defaultProps as object; + + get showFilters(): boolean { + return localStorage.get().showFilters; + } - @observable userSettings: ItemListLayoutUserSettings = { - showAppliedFilters: false, - }; - - constructor(props: ItemListLayoutProps) { - super(props); - - // 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) { + localStorage.merge({ showFilters }); } async componentDidMount() { @@ -296,9 +288,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; } @@ -338,13 +330,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..d178f46c7d 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 { sidebarLocalStorage } 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 = () => { + sidebarLocalStorage.merge(draft => { + draft.compact = !draft.compact; + }); }; - getSidebarSize = () => { - return { - "--sidebar-width": `${this.sidebarWidth}px`, - }; + onSidebarResize = (width: number) => { + sidebarLocalStorage.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 } = sidebarLocalStorage.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-nav-item.scss b/src/renderer/components/layout/sidebar-item.scss similarity index 69% rename from src/renderer/components/layout/sidebar-nav-item.scss rename to src/renderer/components/layout/sidebar-item.scss index f99c21f2cc..804165edd6 100644 --- a/src/renderer/components/layout/sidebar-nav-item.scss +++ b/src/renderer/components/layout/sidebar-item.scss @@ -1,37 +1,48 @@ -.SidebarNavItem { +.SidebarItem { $itemSpacing: floor($unit / 2.6) floor($unit / 1.6); + display: flex; + flex-direction: column; + flex-shrink: 0; 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; + border: none; + cursor: pointer; + + a { + overflow: hidden; + text-overflow: ellipsis; + text-decoration: none; + vertical-align: middle; + flex-grow: 1; + min-width: 100px; + } &.active, &:hover { background: $lensBlue; color: $sidebarActiveColor; } - } - .expand-icon { - --size: 20px; + .expand-icon { + --size: 20px; + } } .sub-menu { border-left: 4px solid transparent; + &:empty, .compact & { + display: none; + } + &.active { border-left-color: $lensBlue; } - a, .SidebarNavItem { + a, .SidebarItem { display: block; border: none; text-decoration: none; @@ -55,22 +66,5 @@ 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-item.tsx b/src/renderer/components/layout/sidebar-item.tsx new file mode 100644 index 0000000000..1a26db68f1 --- /dev/null +++ b/src/renderer/components/layout/sidebar-item.tsx @@ -0,0 +1,92 @@ +import "./sidebar-item.scss"; + +import React from "react"; +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 { sidebarLocalStorage } from "./sidebar-storage"; + +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[]; + onToggle?(id: string, meta: { props: SidebarItemProps, event: React.MouseEvent }): void; +} + +@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 expanded(): boolean { + return sidebarLocalStorage.get().expanded[this.id]; + } + + get compact(): boolean { + return sidebarLocalStorage.get().compact; + } + + toggleExpand = (event: React.MouseEvent) => { + sidebarLocalStorage.merge(draft => { + draft.expanded[this.id] = !draft.expanded[this.id]; + }); + + this.props.onToggle?.(this.id, { + props: this.props, + event, + }); + }; + + render() { + const { isHidden, isActive, subMenus = [], icon, text, children, url } = this.props; + + if (isHidden) return null; + + const { id, expanded, compact } = this; + const isExpandable = (subMenus.length > 0 || children) && !compact; + const className = cssNames(SidebarItem.displayName, this.props.className, { + compact, + }); + + return ( +
+
+ isActive}> + {icon} {text} + + {isExpandable && ( + + )} +
+ {isExpandable && ( +
    + {subMenus.map(({ title, url }) => ( + + {title} + + ))} + {React.Children.toArray(children).map((child: React.ReactElement) => { + return React.cloneElement(child, { + className: cssNames(child.props.className, { visible: expanded }), + }); + })} +
+ )} +
+ ); + } +} 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..09ba4d3a03 --- /dev/null +++ b/src/renderer/components/layout/sidebar-storage.ts @@ -0,0 +1,15 @@ +import { createStorage } from "../../local-storage"; + +export interface SidebarLocalStorageModel { + width: number; + compact: boolean; + expanded: { + [itemId: string]: boolean; + } +} + +export const sidebarLocalStorage = 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..cbf4c95415 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}; @@ -79,6 +80,25 @@ } } + .SidebarItem { + &.crd-group { + padding-left: 27px; + font-weight: 500; + + .nav-item { + &:hover { + background: transparent; + } + } + + .sub-menu { + a { + padding-left: $padding * 3; + } + } + } + } + .loading { padding: $padding; text-align: center; diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index f47ba1702e..94840ffe4e 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,10 +60,10 @@ export class Sidebar extends React.Component { }); return ( - { } 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..e859cb17fd 100644 --- a/src/renderer/hooks/useStorage.ts +++ b/src/renderer/hooks/useStorage.ts @@ -1,7 +1,7 @@ import { useState } from "react"; -import { createStorage, IStorageHelperOptions } from "../utils"; +import { createStorage, StorageHelperOptions } from "../local-storage"; -export function useStorage(key: string, initialValue?: T, options?: IStorageHelperOptions) { +export function useStorage(key: string, initialValue?: T, options?: StorageHelperOptions) { const storage = createStorage(key, initialValue, options); const [storageValue, setStorageValue] = useState(storage.get()); const setValue = (value: T) => { diff --git a/src/renderer/local-storage.ts b/src/renderer/local-storage.ts new file mode 100644 index 0000000000..cfc098a199 --- /dev/null +++ b/src/renderer/local-storage.ts @@ -0,0 +1,86 @@ +import path from "path"; +import fse from "fs-extra"; +import { isEmpty } from "lodash"; +import { app, remote } from "electron"; +import { action, observable, reaction, when, } from "mobx"; +import { StorageHelper, StorageHelperOptions } from "./utils/storageHelper"; + +export * from "./utils/storageHelper"; + +export class LensLocalStorage { + private folderPath = path.resolve((app || remote.app).getPath("userData"), "lens-local-storage"); + private filePath: string; + + @observable state = observable.map(); + @observable isLoaded = false; + whenReady = when(() => this.isLoaded); + + getPath(fileName: string): string { + return path.resolve(this.folderPath, `${fileName}.json`); + } + + async init(clusterId?: string) { + this.filePath = this.getPath(clusterId ?? "app"); + + try { + await this.load(); + this.bindAutoSave(); + } catch (error) { + console.error(`[init]: ${error}`, this); + } + } + + private bindAutoSave() { + return reaction(() => this.state.toJSON(), state => this.save(state), { + delay: 500, // lazy backup + }); + } + + @action + async load() { + try { + await fse.ensureDir(this.folderPath, { mode: 0o755 }); + const state = await fse.readJson(this.filePath); + + this.state.replace(state); + } catch (error) { + } + this.isLoaded = true; + } + + private async save(state: object) { + if (isEmpty(state)) { + return; // skip empty state on clear + } + + try { + await fse.writeJson(this.filePath, state, { spaces: 2 }); + } catch (error) { + console.error(`[save]: ${error}`, this); + } + } + + @action + clear() { + this.state.clear(); + fse.unlink(this.filePath).catch(() => null); + } +} + +export const lensLocalStorage = new LensLocalStorage(); + +export function createStorage(key: string, defaultValue?: T, options: StorageHelperOptions = {}) { + return new StorageHelper(key, defaultValue, { + ...options, + storage: { + async getItem(key: string) { + await lensLocalStorage.whenReady; + + return lensLocalStorage.state.get(key); + }, + async setItem(key: string, value: any) { + lensLocalStorage.state.set(key, value); + }, + }, + }); +} diff --git a/src/renderer/utils/createStorage.ts b/src/renderer/utils/createStorage.ts deleted file mode 100755 index 54c064ab75..0000000000 --- a/src/renderer/utils/createStorage.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Helper to work with browser's local/session storage api - -export interface IStorageHelperOptions { - addKeyPrefix?: boolean; - useSession?: boolean; // use `sessionStorage` instead of `localStorage` -} - -export function createStorage(key: string, defaultValue?: T, options?: IStorageHelperOptions) { - return new StorageHelper(key, defaultValue, options); -} - -export class StorageHelper { - static keyPrefix = "lens_"; - - static defaultOptions: IStorageHelperOptions = { - addKeyPrefix: true, - useSession: false, - }; - - constructor(protected key: string, protected defaultValue?: T, protected options?: IStorageHelperOptions) { - this.options = Object.assign({}, StorageHelper.defaultOptions, options); - - if (this.options.addKeyPrefix) { - this.key = StorageHelper.keyPrefix + key; - } - } - - protected get storage() { - if (this.options.useSession) return window.sessionStorage; - - return window.localStorage; - } - - get(): T { - const strValue = this.storage.getItem(this.key); - - if (strValue != null) { - try { - return JSON.parse(strValue); - } catch (e) { - console.error(`Parsing json failed for pair: ${this.key}=${strValue}`); - } - } - - 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..8a8d17ab81 100755 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -7,7 +7,7 @@ export * from "./cssNames"; export * from "../../common/event-emitter"; export * from "./saveFile"; export * from "./prevDefault"; -export * from "./createStorage"; +export * from "./storageHelper"; export * from "./interval"; export * from "./copyToClipboard"; export * from "./formatDuration"; diff --git a/src/renderer/utils/storageHelper.ts b/src/renderer/utils/storageHelper.ts new file mode 100755 index 0000000000..f1bfe66727 --- /dev/null +++ b/src/renderer/utils/storageHelper.ts @@ -0,0 +1,170 @@ +// Helper for work with persistent local storage (default: window.localStorage) +// TODO: write unit/integration tests + +import type { CreateObservableOptions } from "mobx/lib/api/observable"; +import { action, comparer, observable, toJS, when } from "mobx"; +import produce, { Draft, setAutoFreeze } from "immer"; +import { isEmpty, isEqual, isFunction } from "lodash"; + +setAutoFreeze(false); // allow to merge observables + +export interface StorageHelperOptions extends StorageConfiguration { + autoInit?: boolean; // default: true +} + +export interface StorageConfiguration { + storage?: StorageAdapter; + observable?: CreateObservableOptions; +} + +export interface StorageAdapter> { + getItem(this: C, key: string): T | Promise; // import + setItem(this: C, key: string, value: T): void; // export + onChange?(this: C, value: T, oldValue?: T): void; +} + +export const localStorageAdapter: StorageAdapter = { + getItem(key: string) { + return JSON.parse(localStorage.getItem(key)); + }, + setItem(key: string, value: any) { + if (value != null) { + localStorage.setItem(key, JSON.stringify(value)); + } else { + localStorage.removeItem(key); + } + } +}; + +export class StorageHelper { + static defaultOptions: StorageHelperOptions = { + autoInit: true, + storage: localStorageAdapter, + observable: { + deep: true, + equals: comparer.shallow, + } + }; + + private data = observable.box(); + @observable.ref storage: StorageAdapter>; + @observable initialized = false; + whenReady = when(() => this.initialized); + + constructor(readonly key: string, readonly defaultValue?: T, readonly options: StorageHelperOptions = {}) { + this.options = { ...StorageHelper.defaultOptions, ...options }; + this.configure(); + this.reset(); + + if (this.options.autoInit) { + this.init(); + } + } + + @action + async init() { + if (this.initialized) return; + + try { + const value = await this.load(); + const notEmpty = this.hasValue(value); + const notDefault = !this.isDefaultValue(value); + + if (notEmpty && notDefault) { + this.merge(value); + } + this.initialized = true; + } catch (error) { + console.error(`[init]: ${error}`, this); + } + } + + hasValue(value: T) { + return !isEmpty(value); + } + + isDefaultValue(value: T) { + return isEqual(value, this.defaultValue); + } + + @action + configure({ storage, observable }: StorageConfiguration = this.options): this { + if (storage) this.configureStorage(storage); + if (observable) this.configureObservable(observable); + + return this; + } + + @action + configureStorage(storage: StorageAdapter) { + this.storage = Object.getOwnPropertyNames(storage).reduce((storage, name: keyof StorageAdapter) => { + storage[name] = storage[name]?.bind(this); // bind storage-adapter methods to "this"-context + + return storage; + }, { ...storage }); + } + + @action + configureObservable(options: CreateObservableOptions = {}) { + 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; + + this.storage.onChange?.(value, oldValue); + this.storage.setItem(this.key, value); + } + + async load(): Promise { + return this.storage.getItem(this.key); + } + + 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 updater = isFunction(value) ? value : (state: Draft) => Object.assign(state, value); + const currentValue = this.toJS(); + const nextValue = produce(currentValue, updater) as T; + + this.set(nextValue); + } + + // TODO: experiment / proxy to target object + proxyTo(object: object) { + return new Proxy(object, { + get: (target: object, prop: PropertyKey, receiver: any) => { + return Reflect.get(target, prop, receiver); + }, + set: (target: object, prop: PropertyKey, value: any, receiver: any) => { + return Reflect.set(target, prop, value, receiver); + } + }); + } + + toJS() { + return toJS(this.get(), { recurseEverything: true }); + } +} diff --git a/yarn.lock b/yarn.lock index bc2e606b71..7e2669a835 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6830,6 +6830,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"