From be8fd7ed455363f30cf64649035a212cf31e3959 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 4 Mar 2021 18:47:13 +0200 Subject: [PATCH] - refactoring: usages of `renderer/utils/createStorage` - refactoring: Sidebar, SidebarItem and MainLayout - ux-fix: expanding sidebar by clicking to the arrow-icon (for consistency with compact mode where no sub-menus) Signed-off-by: Roman --- integration/__tests__/app.tests.ts | 2 - integration/__tests__/cluster-pages.tests.ts | 454 ++++++++---------- integration/__tests__/workspace.tests.ts | 6 +- .../+cluster/cluster-overview.store.ts | 56 ++- .../components/+namespaces/namespace.store.ts | 10 +- .../dock/__test__/dock-tabs.test.tsx | 9 +- .../components/dock/dock-tab.store.ts | 40 +- src/renderer/components/dock/dock.store.ts | 120 +++-- src/renderer/components/dock/dock.tsx | 2 +- .../item-object-list/item-list-layout.tsx | 35 +- .../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 | 90 ++++ .../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/utils/createStorage.ts | 44 +- 20 files changed, 676 insertions(+), 694 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 diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index af029a23e9..56d4cc7774 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -3,10 +3,8 @@ import * as utils from "../helpers/utils"; import { listHelmRepositories } from "../helpers/utils"; import { fail } from "assert"; - jest.setTimeout(60000); -// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below) describe("Lens integration tests", () => { let app: Application; diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index e73774f86a..26d519bb3e 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -7,14 +7,9 @@ import { Application } from "spectron"; import * as utils from "../helpers/utils"; import { addMinikubeCluster, minikubeReady, waitForMinikubeDashboard } from "../helpers/minikube"; -import { exec } from "child_process"; -import * as util from "util"; - -export const promiseExec = util.promisify(exec); jest.setTimeout(60000); -// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below) describe("Lens cluster pages", () => { const TEST_NAMESPACE = "integration-tests"; const BACKSPACE = "\uE003"; @@ -53,6 +48,18 @@ describe("Lens cluster pages", () => { } }; + function getMainMenuSelectors(itemId: string) { + const baseSelector = `.Sidebar [data-test-id="${itemId}"]`; + + return { + sidebarItemRoot: baseSelector, + expandIcon: `${baseSelector} .expand-icon`, + pageLink(href: string) { + return `${baseSelector} a[href^="/${href}"]`; + } + }; + } + describe("cluster pages", () => { beforeAll(appStartAddCluster, 40000); @@ -63,275 +70,228 @@ describe("Lens cluster pages", () => { } }); - const tests: { - drawer?: string - drawerId?: string - pages: { - name: string, - href: string, - expectedSelector: string, - expectedText: string - }[] - }[] = [{ - drawer: "", - drawerId: "", - pages: [{ - name: "Cluster", - href: "cluster", + type SidebarItem = { + testId: string; + expectedSelector?: string; + expectedText?: string; + subMenu?: { + href: string; + expectedSelector: string; + expectedText: string; + }[]; + }; + + const sidebarMenu: SidebarItem[] = [ + { + testId: "cluster", expectedSelector: "div.ClusterOverview div.label", - expectedText: "Master" - }] - }, - { - drawer: "", - drawerId: "", - pages: [{ - name: "Nodes", - href: "nodes", + expectedText: "Master", + }, + { + testId: "nodes", expectedSelector: "h5.title", expectedText: "Nodes" - }] - }, - { - drawer: "Workloads", - drawerId: "workloads", - pages: [{ - name: "Overview", - href: "workloads", - expectedSelector: "h5.box", - expectedText: "Overview" }, { - name: "Pods", - href: "pods", - expectedSelector: "h5.title", - expectedText: "Pods" + testId: "workloads", + subMenu: [ + { + href: "workloads", + expectedSelector: "h5", + expectedText: "Overview", + }, + { + href: "pods", + expectedSelector: "h5.title", + expectedText: "Pods" + }, + { + href: "deployments", + expectedSelector: "h5.title", + expectedText: "Deployments" + }, + { + href: "daemonsets", + expectedSelector: "h5.title", + expectedText: "Daemon Sets" + }, + { + href: "statefulsets", + expectedSelector: "h5.title", + expectedText: "Stateful Sets" + }, + { + href: "replicasets", + expectedSelector: "h5.title", + expectedText: "Replica Sets" + }, + { + href: "jobs", + expectedSelector: "h5.title", + expectedText: "Jobs" + }, + { + href: "cronjobs", + expectedSelector: "h5.title", + expectedText: "Cron Jobs" + }] }, { - name: "Deployments", - href: "deployments", - expectedSelector: "h5.title", - expectedText: "Deployments" + testId: "config", + subMenu: [ + { + href: "configmaps", + expectedSelector: "h5.title", + expectedText: "Config Maps" + }, + { + href: "secrets", + expectedSelector: "h5.title", + expectedText: "Secrets" + }, + { + href: "resourcequotas", + expectedSelector: "h5.title", + expectedText: "Resource Quotas" + }, + { + href: "limitranges", + expectedSelector: "h5.title", + expectedText: "Limit Ranges" + }, + { + href: "hpa", + expectedSelector: "h5.title", + expectedText: "Horizontal Pod Autoscalers" + }, + { + href: "poddisruptionbudgets", + expectedSelector: "h5.title", + expectedText: "Pod Disruption Budgets" + }] }, { - name: "DaemonSets", - href: "daemonsets", - expectedSelector: "h5.title", - expectedText: "Daemon Sets" + testId: "networks", + subMenu: [ + { + href: "services", + expectedSelector: "h5.title", + expectedText: "Services" + }, + { + href: "endpoints", + expectedSelector: "h5.title", + expectedText: "Endpoints" + }, + { + href: "ingresses", + expectedSelector: "h5.title", + expectedText: "Ingresses" + }, + { + href: "network-policies", + expectedSelector: "h5.title", + expectedText: "Network Policies" + }] }, { - name: "StatefulSets", - href: "statefulsets", - expectedSelector: "h5.title", - expectedText: "Stateful Sets" + testId: "storage", + subMenu: [ + { + href: "persistent-volume-claims", + expectedSelector: "h5.title", + expectedText: "Persistent Volume Claims" + }, + { + href: "persistent-volumes", + expectedSelector: "h5.title", + expectedText: "Persistent Volumes" + }, + { + href: "storage-classes", + expectedSelector: "h5.title", + expectedText: "Storage Classes" + }] }, { - name: "ReplicaSets", - href: "replicasets", + testId: "namespaces", expectedSelector: "h5.title", - expectedText: "Replica Sets" + expectedText: "Namespaces", }, { - name: "Jobs", - href: "jobs", + testId: "events", expectedSelector: "h5.title", - expectedText: "Jobs" + expectedText: "Events", }, { - name: "CronJobs", - href: "cronjobs", - expectedSelector: "h5.title", - expectedText: "Cron Jobs" - }] - }, - { - drawer: "Configuration", - drawerId: "config", - pages: [{ - name: "ConfigMaps", - href: "configmaps", - expectedSelector: "h5.title", - expectedText: "Config Maps" + testId: "apps", + subMenu: [ + { + href: "apps/charts", + expectedSelector: "div.HelmCharts input", + expectedText: "" + }, + { + href: "apps/releases", + expectedSelector: "h5.title", + expectedText: "Releases" + }] }, { - name: "Secrets", - href: "secrets", - expectedSelector: "h5.title", - expectedText: "Secrets" + testId: "users", + subMenu: [ + { + href: "service-accounts", + expectedSelector: "h5.title", + expectedText: "Service Accounts" + }, + { + href: "role-bindings", + expectedSelector: "h5.title", + expectedText: "Role Bindings" + }, + { + href: "roles", + expectedSelector: "h5.title", + expectedText: "Roles" + }, + { + href: "pod-security-policies", + expectedSelector: "h5.title", + expectedText: "Pod Security Policies" + }] }, { - name: "Resource Quotas", - href: "resourcequotas", - expectedSelector: "h5.title", - expectedText: "Resource Quotas" - }, - { - name: "Limit Ranges", - href: "limitranges", - expectedSelector: "h5.title", - expectedText: "Limit Ranges" - }, - { - name: "HPA", - href: "hpa", - expectedSelector: "h5.title", - expectedText: "Horizontal Pod Autoscalers" - }, - { - name: "Pod Disruption Budgets", - href: "poddisruptionbudgets", - expectedSelector: "h5.title", - expectedText: "Pod Disruption Budgets" - }] - }, - { - drawer: "Network", - drawerId: "networks", - pages: [{ - name: "Services", - href: "services", - expectedSelector: "h5.title", - expectedText: "Services" - }, - { - name: "Endpoints", - href: "endpoints", - expectedSelector: "h5.title", - expectedText: "Endpoints" - }, - { - name: "Ingresses", - href: "ingresses", - expectedSelector: "h5.title", - expectedText: "Ingresses" - }, - { - name: "Network Policies", - href: "network-policies", - expectedSelector: "h5.title", - expectedText: "Network Policies" - }] - }, - { - drawer: "Storage", - drawerId: "storage", - pages: [{ - name: "Persistent Volume Claims", - href: "persistent-volume-claims", - expectedSelector: "h5.title", - expectedText: "Persistent Volume Claims" - }, - { - name: "Persistent Volumes", - href: "persistent-volumes", - expectedSelector: "h5.title", - expectedText: "Persistent Volumes" - }, - { - name: "Storage Classes", - href: "storage-classes", - expectedSelector: "h5.title", - expectedText: "Storage Classes" - }] - }, - { - drawer: "", - drawerId: "", - pages: [{ - name: "Namespaces", - href: "namespaces", - expectedSelector: "h5.title", - expectedText: "Namespaces" - }] - }, - { - drawer: "", - drawerId: "", - pages: [{ - name: "Events", - href: "events", - expectedSelector: "h5.title", - expectedText: "Events" - }] - }, - { - drawer: "Apps", - drawerId: "apps", - pages: [{ - name: "Charts", - href: "apps/charts", - expectedSelector: "div.HelmCharts input", - expectedText: "" - }, - { - name: "Releases", - href: "apps/releases", - expectedSelector: "h5.title", - expectedText: "Releases" - }] - }, - { - drawer: "Access Control", - drawerId: "users", - pages: [{ - name: "Service Accounts", - href: "service-accounts", - expectedSelector: "h5.title", - expectedText: "Service Accounts" - }, - { - name: "Role Bindings", - href: "role-bindings", - expectedSelector: "h5.title", - expectedText: "Role Bindings" - }, - { - name: "Roles", - href: "roles", - expectedSelector: "h5.title", - expectedText: "Roles" - }, - { - name: "Pod Security Policies", - href: "pod-security-policies", - expectedSelector: "h5.title", - expectedText: "Pod Security Policies" - }] - }, - { - drawer: "Custom Resources", - drawerId: "custom-resources", - pages: [{ - name: "Definitions", - href: "crd/definitions", - expectedSelector: "h5.title", - expectedText: "Custom Resources" - }] - }]; + testId: "custom-resources", + subMenu: [{ + href: "crd/definitions", + expectedSelector: "h5.title", + expectedText: "Custom Resources" + }] + }]; - tests.forEach(({ drawer = "", drawerId = "", pages }) => { - if (drawer !== "") { - it(`shows ${drawer} drawer`, async () => { + sidebarMenu.forEach(({ testId, expectedSelector, expectedText, subMenu }) => { + const { sidebarItemRoot, expandIcon, pageLink } = getMainMenuSelectors(testId); + + if (subMenu) { + it(`expands submenu for pages in "${testId}"`, 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(expandIcon); + await app.client.waitForExist(pageLink(subMenu[0].href)); }); - } - pages.forEach(({ name, href, expectedSelector, expectedText }) => { - it(`shows ${drawer}->${name} page`, async () => { + subMenu.forEach(({ href, expectedText, expectedSelector }) => { + it(`opens page "${expectedText.toLowerCase() || href}"`, async () => { + expect(clusterAdded).toBe(true); + await app.client.click(pageLink(href)); + await app.client.waitUntilTextExists(expectedSelector, expectedText); + }); + }); + } else { + it(`opens page "${testId}"`, async () => { expect(clusterAdded).toBe(true); - await app.client.click(`a[href^="/${href}"]`); + await app.client.click(sidebarItemRoot); 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(); - }); } }); }); @@ -348,7 +308,7 @@ describe("Lens cluster pages", () => { it(`shows a logs 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(getMainMenuSelectors("workloads").expandIcon); await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); await app.client.click('a[href^="/pods"]'); await app.client.click(".NamespaceSelect"); @@ -415,7 +375,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(getMainMenuSelectors("workloads").expandIcon); await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); await app.client.click('a[href^="/pods"]'); diff --git a/integration/__tests__/workspace.tests.ts b/integration/__tests__/workspace.tests.ts index 4164151b0f..54cc573c14 100644 --- a/integration/__tests__/workspace.tests.ts +++ b/integration/__tests__/workspace.tests.ts @@ -1,14 +1,10 @@ import { Application } from "spectron"; import * as utils from "../helpers/utils"; import { addMinikubeCluster, minikubeReady } from "../helpers/minikube"; -import { exec } from "child_process"; -import * as util from "util"; - -export const promiseExec = util.promisify(exec); jest.setTimeout(60000); -describe("Lens integration tests", () => { +describe("Lens workspace tests", () => { let app: Application; const ready = minikubeReady("workspace-int-tests"); 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/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 9995fbb7e5..7d634c805e 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -5,14 +5,14 @@ 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) + return selectedNamespaces.get() ?? []; // initial namespaces coming from URL or local-storage (default) } }); @@ -42,6 +42,7 @@ export class NamespaceStore extends KubeObjectStore { private async init() { await this.contextReady; + await selectedNamespaces.whenReady; this.setContext(this.initialNamespaces); this.autoLoadAllowedNamespaces(); @@ -57,7 +58,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 +72,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/dock-tab.store.ts b/src/renderer/components/dock/dock-tab.store.ts index b66db14d1e..d67f1a52e7 100644 --- a/src/renderer/components/dock/dock-tab.store.ts +++ b/src/renderer/components/dock/dock-tab.store.ts @@ -1,25 +1,30 @@ import { autorun, observable, reaction } from "mobx"; -import { autobind, createStorage } from "../../utils"; +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 + 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 => this.storage.set(data)); } // clear data for closed tabs @@ -34,14 +39,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..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} />
= { 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() { @@ -296,9 +287,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 +329,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-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..7982b20b1c --- /dev/null +++ b/src/renderer/components/layout/sidebar-item.tsx @@ -0,0 +1,90 @@ +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 { sidebarStorage } 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 Boolean(sidebarStorage.get().expanded[this.id]); + } + + get compact(): boolean { + return Boolean(sidebarStorage.get().compact); + } + + toggleExpand = (event: React.MouseEvent) => { + sidebarStorage.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, className } = this.props; + + if (isHidden) return null; + + const { id, expanded, compact } = this; + const isExpandable = (subMenus.length > 0 || children) && !compact; + const classNames = cssNames(SidebarItem.displayName, 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..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..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/utils/createStorage.ts b/src/renderer/utils/createStorage.ts index b91226a3e3..a8b5cd75e7 100755 --- a/src/renderer/utils/createStorage.ts +++ b/src/renderer/utils/createStorage.ts @@ -3,11 +3,11 @@ import { app, remote } from "electron"; import path from "path"; -import { ensureDirSync, readJsonSync, writeJson } from "fs-extra"; +import { ensureDir, readJson, writeJson } from "fs-extra"; import type { CreateObservableOptions } from "mobx/lib/api/observable"; import { action, comparer, observable, reaction, toJS, when } from "mobx"; import produce, { Draft, setAutoFreeze } from "immer"; -import { isEqual, isFunction, isObject } from "lodash"; +import { isEqual, isFunction, isObject, noop } from "lodash"; setAutoFreeze(false); // allow to merge observables @@ -26,17 +26,17 @@ export interface StorageConfiguration { export interface StorageAdapter { [metadata: string]: any; - getItem(key: string): T; // import + getItem(key: string): T | Promise; // import setItem(key: string, value: T): void; // export removeItem?(key: string): void; // if not provided setItem(key,undefined) will be used - onChange?(data: { key: string, value: T, oldValue?: T }): void; + onChange?(change: { key: string, value: T, oldValue?: T }): void; } export class StorageHelper { static defaultOptions: StorageHelperOptions = { autoInit: true, get storage() { - return jsonFileSyncStorageAdapter; + return jsonFileStorageAdapter; }, observable: { deep: true, @@ -60,11 +60,11 @@ export class StorageHelper { } @action - init() { + async init() { if (this.initialized) return; try { - const value = this.load(); + const value = await this.load(); const notEmpty = value != null; const notDefault = !this.isDefaultValue(value); @@ -109,7 +109,7 @@ export class StorageHelper { this.storage.setItem(this.key, value); } - load(): T { + async load(): Promise { return this.storage.getItem(this.key); } @@ -158,8 +158,11 @@ export const localStorageAdapter: StorageAdapter = { } }; -// TODO: remove after merge https://github.com/lensapp/lens/pull/2269 -export const jsonFileSyncStorageAdapter: StorageAdapter = { +/** + * Keep intended window.localStorage state in external JSON-file. + * Reason: app creates random ports between restarts and as a result storage not persistent. + */ +export const jsonFileStorageAdapter: StorageAdapter = { cwd: path.resolve((app || remote.app).getPath("userData"), "lens-local-storage"), data: observable.map([], { deep: false }), initialized: false, @@ -170,34 +173,31 @@ export const jsonFileSyncStorageAdapter: StorageAdapter = { return path.resolve(this.cwd, `${clusterId ?? "app"}.json`); }, - init() { + async init() { if (this.initialized) return; - try { - ensureDirSync(this.cwd, { mode: 0o755 }); - const data = readJsonSync(this.filePath, { throws: false }) || {}; + const data = await readJson(this.filePath).catch(noop); + if (data) { this.data.replace(data); - this.bindAutoSave(); - } catch (error) { - console.error(`[init]: ${this.filePath} failed: ${error}`, this); - } finally { - this.initialized = true; } + this.bindAutoSave(); + this.initialized = true; }, bindAutoSave() { return reaction(() => this.data.toJSON(), async (data) => { try { + await ensureDir(this.cwd, { mode: 0o755 }); await writeJson(this.filePath, data, { spaces: 2 }); } catch (error) { - console.error(`[save]: ${this.filePath} failed: ${error}`, this); + console.error(`[save]: ${this.filePath}: ${error}`, this); } }); }, - getItem(key: string) { - this.init(); + async getItem(key: string) { + await this.init(); return this.data.get(key); },