diff --git a/docs/extensions/capabilities/common-capabilities.md b/docs/extensions/capabilities/common-capabilities.md index a56f7d8804..84f1cbdffa 100644 --- a/docs/extensions/capabilities/common-capabilities.md +++ b/docs/extensions/capabilities/common-capabilities.md @@ -100,13 +100,20 @@ import { ExamplePage } from "./src/example-page" export default class ExampleRendererExtension extends LensRendererExtension { globalPages = [ { - path: "/example-route", - hideInMenu: true, components: { Page: ExamplePage, } } ] + + globalPageMenus = [ + { + title: "Example page", // used in icon's tooltip + components: { + Icon: () => , + } + } + ] } ``` @@ -146,11 +153,20 @@ import { ExampleIcon, ExamplePage } from "./src/page" export default class ExampleExtension extends LensRendererExtension { clusterPages = [ { - path: "/extension-example", - title: "Example Extension", + routePath: "/extension-example", // optional + exact: true, // optional components: { Page: () => , - MenuIcon: ExampleIcon, + } + } + ] + + clusterPageMenus = [ + { + url: "/extension-example", // optional + title: "Example Extension", + components: { + Icon: ExampleIcon, } } ] @@ -199,11 +215,8 @@ export default class ExampleExtension extends LensRendererExtension { statusBarItems = [ { item: ( -
Navigation.navigate("/example-page")} - > - +
this.navigate("/example-page")} > +
) } diff --git a/docs/extensions/get-started/anatomy.md b/docs/extensions/get-started/anatomy.md index 523c6b9781..04707d818c 100644 --- a/docs/extensions/get-started/anatomy.md +++ b/docs/extensions/get-started/anatomy.md @@ -84,11 +84,9 @@ import React from "react" export default class ExampleExtension extends LensRendererExtension { clusterPages = [ { - path: "/extension-example", - title: "Hello World", + routePath: "/extension-example", components: { Page: () => , - MenuIcon: ExampleIcon, } } ] diff --git a/extensions/support-page/main.ts b/extensions/support-page/main.ts index 333411ec16..870478afb2 100644 --- a/extensions/support-page/main.ts +++ b/extensions/support-page/main.ts @@ -1,5 +1,4 @@ import { LensMainExtension } from "@k8slens/extensions"; -import { supportPageURL } from "./src/support.route"; export default class SupportPageMainExtension extends LensMainExtension { appMenus = [ @@ -7,7 +6,7 @@ export default class SupportPageMainExtension extends LensMainExtension { parentId: "help", label: "Support", click: () => { - this.navigate(supportPageURL()); + this.navigate(); } } ] diff --git a/extensions/support-page/renderer.tsx b/extensions/support-page/renderer.tsx index b76ef0601c..0137ea7771 100644 --- a/extensions/support-page/renderer.tsx +++ b/extensions/support-page/renderer.tsx @@ -1,28 +1,21 @@ import React from "react"; -import { Component, LensRendererExtension, Navigation } from "@k8slens/extensions"; -import { supportPageRoute, supportPageURL } from "./src/support.route"; -import { Support } from "./src/support"; +import { Component, Interface, LensRendererExtension } from "@k8slens/extensions"; +import { SupportPage } from "./src/support"; export default class SupportPageRendererExtension extends LensRendererExtension { - globalPages = [ + globalPages: Interface.PageRegistration[] = [ { - ...supportPageRoute, - url: supportPageURL(), - hideInMenu: true, components: { - Page: Support, + Page: SupportPage, } } ] - statusBarItems = [ + statusBarItems: Interface.StatusBarRegistration[] = [ { item: ( -
Navigation.navigate(supportPageURL())} - > - +
this.navigate()}> +
) } diff --git a/extensions/support-page/src/support.route.ts b/extensions/support-page/src/support.route.ts deleted file mode 100644 index 3d9c007f06..0000000000 --- a/extensions/support-page/src/support.route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { RouteProps } from "react-router"; - -export const supportPageRoute: RouteProps = { - path: "/support" -} - -export const supportPageURL = () => supportPageRoute.path.toString(); diff --git a/extensions/support-page/src/support.scss b/extensions/support-page/src/support.scss index 1876061fdd..2c65a0cd70 100644 --- a/extensions/support-page/src/support.scss +++ b/extensions/support-page/src/support.scss @@ -1,4 +1,4 @@ -.PageLayout.Support { +.SupportPage { a[target=_blank] { text-decoration: none; border-bottom: 1px solid; diff --git a/extensions/support-page/src/support.tsx b/extensions/support-page/src/support.tsx index 6c588a1cef..fbdf54d9eb 100644 --- a/extensions/support-page/src/support.tsx +++ b/extensions/support-page/src/support.tsx @@ -6,12 +6,12 @@ import { observer } from "mobx-react" import { App, Component } from "@k8slens/extensions"; @observer -export class Support extends React.Component { +export class SupportPage extends React.Component { render() { const { PageLayout } = Component; const { slackUrl, issuesTrackerUrl } = App; return ( - Support}> + Support}>

Community Slack Channel

Ask a question, see what's being discussed, join the conversation here diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index 74081a2061..8845ae91a8 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -13,6 +13,7 @@ const itif = (condition: boolean) => condition ? it : it.skip 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", () => { const TEST_NAMESPACE = "integration-tests" @@ -394,7 +395,7 @@ describe("Lens integration tests", () => { if (drawer !== "") { it(`shows ${drawer} drawer`, async () => { expect(clusterAdded).toBe(true) - await app.client.click(`.sidebar-nav #${drawerId} span.link-text`) + 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) }) } @@ -409,7 +410,7 @@ describe("Lens integration tests", () => { // hide the drawer it(`hides ${drawer} drawer`, async () => { expect(clusterAdded).toBe(true) - await app.client.click(`.sidebar-nav #${drawerId} span.link-text`) + 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() }) } @@ -428,7 +429,7 @@ describe("Lens integration tests", () => { it(`shows a logs for a pod`, async () => { expect(clusterAdded).toBe(true) // Go to Pods page - await app.client.click(".sidebar-nav #workloads span.link-text") + await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text") await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods") await app.client.click('a[href^="/pods"]') await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver") @@ -479,7 +480,7 @@ describe("Lens integration tests", () => { it(`creates a pod in ${TEST_NAMESPACE} namespace`, async () => { expect(clusterAdded).toBe(true) - await app.client.click(".sidebar-nav #workloads span.link-text") + await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text") await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods") await app.client.click('a[href^="/pods"]') await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver") diff --git a/src/extensions/dynamic-page.tsx b/src/extensions/dynamic-page.tsx deleted file mode 100644 index 854e0f27fd..0000000000 --- a/src/extensions/dynamic-page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from "react"; -import { cssNames } from "../renderer/utils"; -import { TabLayout } from "../renderer/components/layout/tab-layout"; -import { PageRegistration } from "./registries/page-registry" - -export class DynamicPage extends React.Component<{ page: PageRegistration }> { - render() { - const { className, components: { Page }, subPages = [] } = this.props.page; - return ( - - - - ) - } -} diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 78ff1fd3ee..b75fd5147d 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -56,30 +56,31 @@ export class ExtensionLoader { loadOnMain() { logger.info('[EXTENSIONS-LOADER]: load on main') - this.autoInitExtensions((extension: LensMainExtension) => [ - registries.menuRegistry.add(...extension.appMenus) + this.autoInitExtensions((ext: LensMainExtension) => [ + registries.menuRegistry.add(ext.appMenus, { key: ext }) ]); } loadOnClusterManagerRenderer() { logger.info('[EXTENSIONS-LOADER]: load on main renderer (cluster manager)') - this.autoInitExtensions((extension: LensRendererExtension) => [ - registries.globalPageRegistry.add(...extension.globalPages), - registries.appPreferenceRegistry.add(...extension.appPreferences), - registries.clusterFeatureRegistry.add(...extension.clusterFeatures), - registries.statusBarRegistry.add(...extension.statusBarItems), + this.autoInitExtensions((ext: LensRendererExtension) => [ + registries.globalPageRegistry.add(ext.globalPages, { key: ext }), + registries.globalPageMenuRegistry.add(ext.globalPageMenus, { key: ext }), + registries.appPreferenceRegistry.add(ext.appPreferences, { key: ext }), + registries.clusterFeatureRegistry.add(ext.clusterFeatures, { key: ext }), + registries.statusBarRegistry.add(ext.statusBarItems, { key: ext }), ]); } loadOnClusterRenderer() { logger.info('[EXTENSIONS-LOADER]: load on cluster renderer (dashboard)') - this.autoInitExtensions((extension: LensRendererExtension) => [ - registries.clusterPageRegistry.add(...extension.clusterPages), - registries.kubeObjectMenuRegistry.add(...extension.kubeObjectMenuItems), - registries.kubeObjectDetailRegistry.add(...extension.kubeObjectDetailItems), - registries.kubeObjectStatusRegistry.add(...extension.kubeObjectStatusTexts) + this.autoInitExtensions((ext: LensRendererExtension) => [ + registries.clusterPageRegistry.add(ext.clusterPages, { key: ext }), + registries.clusterPageMenuRegistry.add(ext.clusterPageMenus, { key: ext }), + registries.kubeObjectMenuRegistry.add(ext.kubeObjectMenuItems, { key: ext }), + registries.kubeObjectDetailRegistry.add(ext.kubeObjectDetailItems, { key: ext }), + registries.kubeObjectStatusRegistry.add(ext.kubeObjectStatusTexts, { key: ext }) ]) - } protected autoInitExtensions(register: (ext: LensExtension) => Function[]) { diff --git a/src/extensions/interfaces/registrations.ts b/src/extensions/interfaces/registrations.ts index 1d875127d0..14c9f66c22 100644 --- a/src/extensions/interfaces/registrations.ts +++ b/src/extensions/interfaces/registrations.ts @@ -4,4 +4,5 @@ export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from ". export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry" export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry" export type { PageRegistration, PageComponents } from "../registries/page-registry" +export type { PageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry" export type { StatusBarRegistration } from "../registries/status-bar-registry" \ No newline at end of file diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index f1ffb9184b..ca135a0b39 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -1,5 +1,6 @@ import type { InstalledExtension } from "./extension-manager"; import { action, observable, reaction } from "mobx"; +import { compile } from "path-to-regexp" import logger from "../main/logger"; export type LensExtensionId = string; // path to manifest (package.json) @@ -14,6 +15,7 @@ export interface LensExtensionManifest { } export class LensExtension { + readonly routePrefix = "/extension/:name" readonly manifest: LensExtensionManifest; readonly manifestPath: string; readonly isBundled: boolean; @@ -42,6 +44,14 @@ export class LensExtension { return this.manifest.description } + getPageUrl(baseUrl = "") { + return compile(this.routePrefix)({ name: this.name }) + baseUrl; + } + + getPageRoute(baseRoute = "") { + return this.routePrefix + baseRoute; + } + @action async enable() { if (this.isEnabled) return; diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index 5e5aa073f3..0055344a66 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -6,7 +6,9 @@ import { WindowManager } from "../main/window-manager"; export class LensMainExtension extends LensExtension { @observable.shallow appMenus: MenuRegistration[] = [] - async navigate(location: string, frameId?: number) { - await WindowManager.getInstance().navigate(location, frameId) + async navigate(location?: string, frameId?: number) { + const windowManager = WindowManager.getInstance(); + const url = this.getPageUrl(location); // get full path to extension's page + await windowManager.navigate(url, frameId); } } diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index d26bc4764e..87ca214805 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -1,23 +1,21 @@ -import type { - AppPreferenceRegistration, ClusterFeatureRegistration, - KubeObjectMenuRegistration, KubeObjectDetailRegistration, - PageRegistration, StatusBarRegistration, KubeObjectStatusRegistration -} from "./registries" +import type { AppPreferenceRegistration, ClusterFeatureRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries" import { observable } from "mobx"; import { LensExtension } from "./lens-extension" -import { ipcRenderer } from "electron" export class LensRendererExtension extends LensExtension { @observable.shallow globalPages: PageRegistration[] = [] @observable.shallow clusterPages: PageRegistration[] = [] - @observable.shallow kubeObjectStatusTexts: KubeObjectStatusRegistration[] = [] + @observable.shallow globalPageMenus: PageMenuRegistration[] = [] + @observable.shallow clusterPageMenus: PageMenuRegistration[] = [] + @observable.shallow kubeObjectStatusTexts: KubeObjectStatusRegistration[] = [] @observable.shallow appPreferences: AppPreferenceRegistration[] = [] @observable.shallow clusterFeatures: ClusterFeatureRegistration[] = [] @observable.shallow statusBarItems: StatusBarRegistration[] = [] @observable.shallow kubeObjectDetailItems: KubeObjectDetailRegistration[] = [] @observable.shallow kubeObjectMenuItems: KubeObjectMenuRegistration[] = [] - navigate(location: string) { - ipcRenderer.emit("renderer:navigate", location) + async navigate(location?: string) { + const { navigate } = await import("../renderer/navigation"); + navigate(this.getPageUrl(location)); } } diff --git a/src/extensions/registries/app-preference-registry.ts b/src/extensions/registries/app-preference-registry.ts index 6c54911f82..dc15ec3e20 100644 --- a/src/extensions/registries/app-preference-registry.ts +++ b/src/extensions/registries/app-preference-registry.ts @@ -1,12 +1,12 @@ import type React from "react" -import { BaseRegistry } from "./base-registry"; +import { BaseRegistry, BaseRegistryItem } from "./base-registry"; export interface AppPreferenceComponents { Hint: React.ComponentType; Input: React.ComponentType; } -export interface AppPreferenceRegistration { +export interface AppPreferenceRegistration extends BaseRegistryItem { title: string; components: AppPreferenceComponents; } diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts index ff23e36cad..76678791f9 100644 --- a/src/extensions/registries/base-registry.ts +++ b/src/extensions/registries/base-registry.ts @@ -1,23 +1,65 @@ // Base class for extensions-api registries import { action, observable } from "mobx"; +import { LensExtension } from "../lens-extension"; +import { getRandId } from "../../common/utils"; -export class BaseRegistry { - protected items = observable([], { deep: false }); +export type BaseRegistryKey = LensExtension | null; +export type BaseRegistryItemId = string | symbol; - getItems(): T[] { - return this.items.toJS(); +export interface BaseRegistryItem { + id?: BaseRegistryItemId; // uniq id, generated automatically when not provided +} + +export interface BaseRegistryAddMeta { + key?: BaseRegistryKey; + merge?: boolean +} + +export class BaseRegistry { + private items = observable.map([], { deep: false }); + + getItems(): (T & { extension?: LensExtension | null })[] { + return Array.from(this.items).map(([ext, items]) => { + return items.map(item => ({ + ...item, + extension: ext, + })) + }).flat() + } + + getById(itemId: BaseRegistryItemId, key?: BaseRegistryKey): T { + const byId = (item: BaseRegistryItem) => item.id === itemId; + if (key) { + return this.items.get(key)?.find(byId) + } + return this.getItems().find(byId); } @action - add(...items: T[]) { - this.items.push(...items); - return () => this.remove(...items); + add(items: T | T[], { key = null, merge = true }: BaseRegistryAddMeta = {}) { + const normalizedItems = (Array.isArray(items) ? items : [items]).map((item: T) => { + item.id = item.id || getRandId(); + return item; + }); + if (merge && this.items.has(key)) { + const newItems = new Set(this.items.get(key)); + normalizedItems.forEach(item => newItems.add(item)) + this.items.set(key, [...newItems]); + } else { + this.items.set(key, normalizedItems); + } + return () => this.remove(normalizedItems, key) } @action - remove(...items: T[]) { - items.forEach(item => { - this.items.remove(item); // works because of {deep: false}; - }) + remove(items: T[], key: BaseRegistryKey = null) { + const storedItems = this.items.get(key); + if (!storedItems) return; + const newItems = storedItems.filter(item => !items.includes(item)); // works because of {deep: false}; + if (newItems.length > 0) { + this.items.set(key, newItems) + } else { + this.items.delete(key); + } } } diff --git a/src/extensions/registries/cluster-feature-registry.ts b/src/extensions/registries/cluster-feature-registry.ts index f17ee0ff92..17a4ef1eef 100644 --- a/src/extensions/registries/cluster-feature-registry.ts +++ b/src/extensions/registries/cluster-feature-registry.ts @@ -1,16 +1,18 @@ -import { BaseRegistry } from "./base-registry"; +import type React from "react" +import { BaseRegistry, BaseRegistryItem } from "./base-registry"; import { ClusterFeature } from "../cluster-feature"; export interface ClusterFeatureComponents { Description: React.ComponentType; } -export interface ClusterFeatureRegistration { +export interface ClusterFeatureRegistration extends BaseRegistryItem { title: string; components: ClusterFeatureComponents feature: ClusterFeature } -export class ClusterFeatureRegistry extends BaseRegistry {} +export class ClusterFeatureRegistry extends BaseRegistry { +} export const clusterFeatureRegistry = new ClusterFeatureRegistry() diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index fcb9ad03f2..cdcfb7124b 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -1,6 +1,7 @@ // All registries managed by extensions api export * from "./page-registry" +export * from "./page-menu-registry" export * from "./menu-registry" export * from "./app-preference-registry" export * from "./status-bar-registry" diff --git a/src/extensions/registries/kube-object-detail-registry.ts b/src/extensions/registries/kube-object-detail-registry.ts index 1869424e92..63d9cfa30d 100644 --- a/src/extensions/registries/kube-object-detail-registry.ts +++ b/src/extensions/registries/kube-object-detail-registry.ts @@ -1,11 +1,11 @@ import React from "react" -import { BaseRegistry } from "./base-registry"; +import { BaseRegistry, BaseRegistryItem } from "./base-registry"; export interface KubeObjectDetailComponents { Details: React.ComponentType; } -export interface KubeObjectDetailRegistration { +export interface KubeObjectDetailRegistration extends BaseRegistryItem { kind: string; apiVersions: string[]; components: KubeObjectDetailComponents; @@ -14,7 +14,7 @@ export interface KubeObjectDetailRegistration { export class KubeObjectDetailRegistry extends BaseRegistry { getItemsForKind(kind: string, apiVersion: string) { - const items = this.items.filter((item) => { + const items = this.getItems().filter((item) => { return item.kind === kind && item.apiVersions.includes(apiVersion) }).map((item) => { if (item.priority === null) { diff --git a/src/extensions/registries/kube-object-menu-registry.ts b/src/extensions/registries/kube-object-menu-registry.ts index 335b1b7441..3e0cfb0251 100644 --- a/src/extensions/registries/kube-object-menu-registry.ts +++ b/src/extensions/registries/kube-object-menu-registry.ts @@ -1,11 +1,11 @@ import React from "react" -import { BaseRegistry } from "./base-registry"; +import { BaseRegistry, BaseRegistryItem } from "./base-registry"; export interface KubeObjectMenuComponents { MenuItem: React.ComponentType; } -export interface KubeObjectMenuRegistration { +export interface KubeObjectMenuRegistration extends BaseRegistryItem { kind: string; apiVersions: string[]; components: KubeObjectMenuComponents; @@ -13,7 +13,7 @@ export interface KubeObjectMenuRegistration { export class KubeObjectMenuRegistry extends BaseRegistry { getItemsForKind(kind: string, apiVersion: string) { - return this.items.filter((item) => { + return this.getItems().filter((item) => { return item.kind === kind && item.apiVersions.includes(apiVersion) }) } diff --git a/src/extensions/registries/kube-object-status-registry.ts b/src/extensions/registries/kube-object-status-registry.ts index bd3a6e0225..3a516a82e1 100644 --- a/src/extensions/registries/kube-object-status-registry.ts +++ b/src/extensions/registries/kube-object-status-registry.ts @@ -1,7 +1,7 @@ import { KubeObject, KubeObjectStatus } from "../renderer-api/k8s-api"; -import { BaseRegistry } from "./base-registry"; +import { BaseRegistry, BaseRegistryItem } from "./base-registry"; -export interface KubeObjectStatusRegistration { +export interface KubeObjectStatusRegistration extends BaseRegistryItem { kind: string; apiVersions: string[]; resolve: (object: KubeObject) => KubeObjectStatus; @@ -9,7 +9,7 @@ export interface KubeObjectStatusRegistration { export class KubeObjectStatusRegistry extends BaseRegistry { getItemsForKind(kind: string, apiVersion: string) { - return this.items.filter((item) => { + return this.getItems().filter((item) => { return item.kind === kind && item.apiVersions.includes(apiVersion) }) } diff --git a/src/extensions/registries/page-menu-registry.ts b/src/extensions/registries/page-menu-registry.ts new file mode 100644 index 0000000000..6bd161ccb2 --- /dev/null +++ b/src/extensions/registries/page-menu-registry.ts @@ -0,0 +1,34 @@ +// Extensions-api -> Register page menu items + +import type React from "react"; +import type { IconProps } from "../../renderer/components/icon"; +import { BaseRegistry, BaseRegistryItem, BaseRegistryItemId } from "./base-registry"; + +export interface PageMenuRegistration extends BaseRegistryItem { + id: BaseRegistryItemId; // required id from page-registry item to match with + url?: string; // when not provided initial extension's path used, e.g. "/extension/lens-extension-name" + title: React.ReactNode; + components: PageMenuComponents; + subMenus?: PageSubMenuRegistration[]; +} + +export interface PageSubMenuRegistration { + url: string; + title: React.ReactNode; +} + +export interface PageMenuComponents { + Icon: React.ComponentType; +} + +export class PageMenuRegistry extends BaseRegistry { + getItems() { + return super.getItems().map(item => { + item.url = item.extension.getPageUrl(item.url) + return item + }); + } +} + +export const globalPageMenuRegistry = new PageMenuRegistry>(); +export const clusterPageMenuRegistry = new PageMenuRegistry(); diff --git a/src/extensions/registries/page-registry.ts b/src/extensions/registries/page-registry.ts index 0f448e1284..886ab8a584 100644 --- a/src/extensions/registries/page-registry.ts +++ b/src/extensions/registries/page-registry.ts @@ -1,31 +1,33 @@ // Extensions-api -> Custom page registration -import type React from "react"; -import type { RouteProps } from "react-router"; -import type { IconProps } from "../../renderer/components/icon"; -import type { IClassName } from "../../renderer/utils"; -import type { TabRoute } from "../../renderer/components/layout/tab-layout"; -import { BaseRegistry } from "./base-registry"; +import React from "react"; +import { BaseRegistry, BaseRegistryItem } from "./base-registry"; -export interface PageRegistration extends RouteProps { - className?: IClassName; - url?: string; // initial url to be used for building menus and tabs, otherwise "path" applied by default - title?: React.ReactNode; // used in sidebar's & tabs-layout if provided - hideInMenu?: boolean; // hide element within app's navigation menu - subPages?: (PageRegistration & TabRoute)[]; +export interface PageRegistration extends BaseRegistryItem { + routePath?: string; // additional (suffix) route path to base extension's route: "/extension/:name" + exact?: boolean; // route matching flag, see: https://reactrouter.com/web/api/NavLink/exact-bool + components: PageComponents; + subPages?: SubPageRegistration[]; +} + +export interface SubPageRegistration { + routePath: string; // required for sub-pages + exact?: boolean; components: PageComponents; } export interface PageComponents { Page: React.ComponentType; - MenuIcon?: React.ComponentType; } -export class GlobalPageRegistry extends BaseRegistry { +export class PageRegistry extends BaseRegistry { + getItems() { + return super.getItems().map(item => { + item.routePath = item.extension.getPageRoute(item.routePath) + return item + }); + } } -export class ClusterPageRegistry extends BaseRegistry { -} - -export const globalPageRegistry = new GlobalPageRegistry(); -export const clusterPageRegistry = new ClusterPageRegistry(); +export const globalPageRegistry = new PageRegistry>(); +export const clusterPageRegistry = new PageRegistry(); diff --git a/src/extensions/registries/status-bar-registry.ts b/src/extensions/registries/status-bar-registry.ts index 88c4132d30..c1029e6d68 100644 --- a/src/extensions/registries/status-bar-registry.ts +++ b/src/extensions/registries/status-bar-registry.ts @@ -1,9 +1,9 @@ // Extensions API -> Status bar customizations import React from "react"; -import { BaseRegistry } from "./base-registry"; +import { BaseRegistry, BaseRegistryItem } from "./base-registry"; -export interface StatusBarRegistration { +export interface StatusBarRegistration extends BaseRegistryItem { item?: React.ReactNode; } diff --git a/src/extensions/renderer-api/navigation.ts b/src/extensions/renderer-api/navigation.ts index 2c82fd4db5..f923f6e152 100644 --- a/src/extensions/renderer-api/navigation.ts +++ b/src/extensions/renderer-api/navigation.ts @@ -1,4 +1,3 @@ export { navigate } from "../../renderer/navigation"; export { hideDetails, showDetails, getDetailsUrl } from "../../renderer/navigation" -export { RouteProps } from "react-router" export { IURLParams } from "../../common/utils/buildUrl"; diff --git a/src/renderer/components/+apps/apps.tsx b/src/renderer/components/+apps/apps.tsx index 5ee297a1c0..469765aa68 100644 --- a/src/renderer/components/+apps/apps.tsx +++ b/src/renderer/components/+apps/apps.tsx @@ -1,41 +1,34 @@ import React from "react"; import { observer } from "mobx-react"; -import { Redirect, Route, Switch } from "react-router"; import { Trans } from "@lingui/macro"; -import { TabLayout, TabRoute } from "../layout/tab-layout"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts"; import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases"; import { namespaceStore } from "../+namespaces/namespace.store"; @observer export class Apps extends React.Component { - static get tabRoutes(): TabRoute[] { + static get tabRoutes(): TabLayoutRoute[] { const query = namespaceStore.getContextParams(); return [ { title: Charts, component: HelmCharts, url: helmChartsURL(), - path: helmChartsRoute.path, + routePath: helmChartsRoute.path.toString(), }, { title: Releases, component: HelmReleases, url: releaseURL({ query }), - path: releaseRoute.path, + routePath: releaseRoute.path.toString(), }, ] } render() { - const tabRoutes = Apps.tabRoutes; return ( - - - {tabRoutes.map((route, index) => )} - - - + ) } } diff --git a/src/renderer/components/+config/config.route.ts b/src/renderer/components/+config/config.route.ts index f480e84952..d747f49efd 100644 --- a/src/renderer/components/+config/config.route.ts +++ b/src/renderer/components/+config/config.route.ts @@ -5,7 +5,7 @@ import { configMapsURL } from "../+config-maps/config-maps.route"; export const configRoute: RouteProps = { get path() { - return Config.tabRoutes.map(({ path }) => path).flat() + return Config.tabRoutes.map(({ routePath }) => routePath).flat() } } diff --git a/src/renderer/components/+config/config.tsx b/src/renderer/components/+config/config.tsx index 7e42c7fcc8..f738cb273e 100644 --- a/src/renderer/components/+config/config.tsx +++ b/src/renderer/components/+config/config.tsx @@ -1,33 +1,26 @@ import React from "react"; import { observer } from "mobx-react"; -import { Redirect, Route, Switch } from "react-router"; import { Trans } from "@lingui/macro"; -import { TabLayout, TabRoute } from "../layout/tab-layout"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { ConfigMaps, configMapsRoute, configMapsURL } from "../+config-maps"; import { Secrets, secretsRoute, secretsURL } from "../+config-secrets"; import { namespaceStore } from "../+namespaces/namespace.store"; import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config-resource-quotas"; -import { PodDisruptionBudgets, pdbRoute, pdbURL } from "../+config-pod-disruption-budgets"; -import { configURL } from "./config.route"; +import { pdbRoute, pdbURL, PodDisruptionBudgets } from "../+config-pod-disruption-budgets"; import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers"; import { isAllowedResource } from "../../../common/rbac" -import { buildURL } from "../../../common/utils/buildUrl"; - -export const certificatesURL = buildURL("/certificates"); -export const issuersURL = buildURL("/issuers"); -export const clusterIssuersURL = buildURL("/clusterissuers"); @observer export class Config extends React.Component { - static get tabRoutes(): TabRoute[] { + static get tabRoutes(): TabLayoutRoute[] { const query = namespaceStore.getContextParams() - const routes: TabRoute[] = [] + const routes: TabLayoutRoute[] = [] if (isAllowedResource("configmaps")) { routes.push({ title: ConfigMaps, component: ConfigMaps, url: configMapsURL({ query }), - path: configMapsRoute.path, + routePath: configMapsRoute.path.toString(), }) } if (isAllowedResource("secrets")) { @@ -35,7 +28,7 @@ export class Config extends React.Component { title: Secrets, component: Secrets, url: secretsURL({ query }), - path: secretsRoute.path, + routePath: secretsRoute.path.toString(), }) } if (isAllowedResource("resourcequotas")) { @@ -43,7 +36,7 @@ export class Config extends React.Component { title: Resource Quotas, component: ResourceQuotas, url: resourceQuotaURL({ query }), - path: resourceQuotaRoute.path, + routePath: resourceQuotaRoute.path.toString(), }) } if (isAllowedResource("horizontalpodautoscalers")) { @@ -51,7 +44,7 @@ export class Config extends React.Component { title: HPA, component: HorizontalPodAutoscalers, url: hpaURL({ query }), - path: hpaRoute.path, + routePath: hpaRoute.path.toString(), }) } if (isAllowedResource("poddisruptionbudgets")) { @@ -59,21 +52,15 @@ export class Config extends React.Component { title: Pod Disruption Budgets, component: PodDisruptionBudgets, url: pdbURL({ query }), - path: pdbRoute.path, + routePath: pdbRoute.path.toString(), }) } return routes; } render() { - const tabRoutes = Config.tabRoutes; return ( - - - {tabRoutes.map((route, index) => )} - - - + ) } } diff --git a/src/renderer/components/+custom-resources/custom-resources.tsx b/src/renderer/components/+custom-resources/custom-resources.tsx index 9dbd882bd1..ee34809013 100644 --- a/src/renderer/components/+custom-resources/custom-resources.tsx +++ b/src/renderer/components/+custom-resources/custom-resources.tsx @@ -2,23 +2,20 @@ import React from "react"; import { observer } from "mobx-react"; import { Redirect, Route, Switch } from "react-router"; import { Trans } from "@lingui/macro"; -import { TabLayout, TabRoute } from "../layout/tab-layout"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { crdResourcesRoute, crdRoute, crdURL, crdDefinitionsRoute } from "./crd.route"; import { CrdList } from "./crd-list"; import { CrdResources } from "./crd-resources"; -// todo: next steps - customization via plugins -// todo: list views (rows content), full details view and if possible chart/prometheus hooks - @observer export class CustomResources extends React.Component { - static get tabRoutes(): TabRoute[] { + static get tabRoutes(): TabLayoutRoute[] { return [ { title: Definitions, component: CustomResources, url: crdURL(), - path: crdRoute.path, + routePath: crdRoute.path.toString(), } ] } diff --git a/src/renderer/components/+network/network.route.ts b/src/renderer/components/+network/network.route.ts index 3029b85fa2..c13f7cedf7 100644 --- a/src/renderer/components/+network/network.route.ts +++ b/src/renderer/components/+network/network.route.ts @@ -5,7 +5,7 @@ import { IURLParams } from "../../../common/utils/buildUrl"; export const networkRoute: RouteProps = { get path() { - return Network.tabRoutes.map(({ path }) => path).flat() + return Network.tabRoutes.map(({ routePath }) => routePath).flat() } } diff --git a/src/renderer/components/+network/network.tsx b/src/renderer/components/+network/network.tsx index 2932f5860c..9e0b2d92c6 100644 --- a/src/renderer/components/+network/network.tsx +++ b/src/renderer/components/+network/network.tsx @@ -2,32 +2,26 @@ import "./network.scss" import React from "react"; import { observer } from "mobx-react"; -import { Redirect, Route, Switch } from "react-router"; -import { RouteComponentProps } from "react-router-dom"; import { Trans } from "@lingui/macro"; -import { TabLayout, TabRoute } from "../layout/tab-layout"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { Services, servicesRoute, servicesURL } from "../+network-services"; -import { Endpoints, endpointRoute, endpointURL } from "../+network-endpoints"; +import { endpointRoute, Endpoints, endpointURL } from "../+network-endpoints"; import { Ingresses, ingressRoute, ingressURL } from "../+network-ingresses"; import { NetworkPolicies, networkPoliciesRoute, networkPoliciesURL } from "../+network-policies"; import { namespaceStore } from "../+namespaces/namespace.store"; -import { networkURL } from "./network.route"; import { isAllowedResource } from "../../../common/rbac"; -interface Props extends RouteComponentProps<{}> { -} - @observer -export class Network extends React.Component { - static get tabRoutes(): TabRoute[] { +export class Network extends React.Component { + static get tabRoutes(): TabLayoutRoute[] { const query = namespaceStore.getContextParams() - const routes: TabRoute[] = []; + const routes: TabLayoutRoute[] = []; if (isAllowedResource("services")) { routes.push({ title: Services, component: Services, url: servicesURL({ query }), - path: servicesRoute.path, + routePath: servicesRoute.path.toString(), }) } if (isAllowedResource("endpoints")) { @@ -35,7 +29,7 @@ export class Network extends React.Component { title: Endpoints, component: Endpoints, url: endpointURL({ query }), - path: endpointRoute.path, + routePath: endpointRoute.path.toString(), }) } if (isAllowedResource("ingresses")) { @@ -43,7 +37,7 @@ export class Network extends React.Component { title: Ingresses, component: Ingresses, url: ingressURL({ query }), - path: ingressRoute.path, + routePath: ingressRoute.path.toString(), }) } if (isAllowedResource("networkpolicies")) { @@ -51,21 +45,15 @@ export class Network extends React.Component { title: Network Policies, component: NetworkPolicies, url: networkPoliciesURL({ query }), - path: networkPoliciesRoute.path, + routePath: networkPoliciesRoute.path.toString(), }) } return routes } render() { - const tabRoutes = Network.tabRoutes; return ( - - - {tabRoutes.map((route, index) => )} - - - + ) } } diff --git a/src/renderer/components/+storage/storage.route.ts b/src/renderer/components/+storage/storage.route.ts index 6a7ab065f9..6fe3bb6a0a 100644 --- a/src/renderer/components/+storage/storage.route.ts +++ b/src/renderer/components/+storage/storage.route.ts @@ -5,7 +5,7 @@ import { IURLParams } from "../../../common/utils/buildUrl"; export const storageRoute: RouteProps = { get path() { - return Storage.tabRoutes.map(({ path }) => path).flat() + return Storage.tabRoutes.map(({ routePath }) => routePath).flat() } } diff --git a/src/renderer/components/+storage/storage.tsx b/src/renderer/components/+storage/storage.tsx index c86afad2e0..e8a105944b 100644 --- a/src/renderer/components/+storage/storage.tsx +++ b/src/renderer/components/+storage/storage.tsx @@ -2,31 +2,25 @@ import "./storage.scss" import React from "react"; import { observer } from "mobx-react"; -import { Redirect, Route, Switch } from "react-router"; -import { RouteComponentProps } from "react-router-dom"; import { Trans } from "@lingui/macro"; -import { TabLayout, TabRoute } from "../layout/tab-layout"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { PersistentVolumes, volumesRoute, volumesURL } from "../+storage-volumes"; import { StorageClasses, storageClassesRoute, storageClassesURL } from "../+storage-classes"; import { PersistentVolumeClaims, volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims"; import { namespaceStore } from "../+namespaces/namespace.store"; -import { storageURL } from "./storage.route"; import { isAllowedResource } from "../../../common/rbac"; -interface Props extends RouteComponentProps<{}> { -} - @observer -export class Storage extends React.Component { +export class Storage extends React.Component { static get tabRoutes() { - const tabRoutes: TabRoute[] = []; + const tabRoutes: TabLayoutRoute[] = []; const query = namespaceStore.getContextParams() tabRoutes.push({ title: Persistent Volume Claims, component: PersistentVolumeClaims, url: volumeClaimsURL({ query }), - path: volumeClaimsRoute.path, + routePath: volumeClaimsRoute.path.toString(), }) if (isAllowedResource('persistentvolumes')) { @@ -34,7 +28,7 @@ export class Storage extends React.Component { title: Persistent Volumes, component: PersistentVolumes, url: volumesURL(), - path: volumesRoute.path, + routePath: volumesRoute.path.toString(), }); } @@ -43,21 +37,15 @@ export class Storage extends React.Component { title: Storage Classes, component: StorageClasses, url: storageClassesURL(), - path: storageClassesRoute.path, + routePath: storageClassesRoute.path.toString(), }) } return tabRoutes; } render() { - const tabRoutes = Storage.tabRoutes; return ( - - - {tabRoutes.map((route, index) => )} - - - + ) } } diff --git a/src/renderer/components/+user-management/user-management.route.ts b/src/renderer/components/+user-management/user-management.route.ts index 04de465e3f..aa520fb128 100644 --- a/src/renderer/components/+user-management/user-management.route.ts +++ b/src/renderer/components/+user-management/user-management.route.ts @@ -4,7 +4,7 @@ import { UserManagement } from "./user-management" export const usersManagementRoute: RouteProps = { get path() { - return UserManagement.tabRoutes.map(({ path }) => path).flat() + return UserManagement.tabRoutes.map(({ routePath }) => routePath).flat() } } diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index 97cf71140e..e7985fae6a 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -1,44 +1,39 @@ import "./user-management.scss" import React from "react"; import { observer } from "mobx-react"; -import { Redirect, Route, Switch } from "react-router"; -import { RouteComponentProps } from "react-router-dom"; import { Trans } from "@lingui/macro"; -import { TabLayout, TabRoute } from "../layout/tab-layout"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { Roles } from "../+user-management-roles"; import { RoleBindings } from "../+user-management-roles-bindings"; import { ServiceAccounts } from "../+user-management-service-accounts"; -import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL, usersManagementURL } from "./user-management.route"; +import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; import { namespaceStore } from "../+namespaces/namespace.store"; import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies"; import { isAllowedResource } from "../../../common/rbac"; -interface Props extends RouteComponentProps<{}> { -} - @observer -export class UserManagement extends React.Component { +export class UserManagement extends React.Component { static get tabRoutes() { - const tabRoutes: TabRoute[] = []; + const tabRoutes: TabLayoutRoute[] = []; const query = namespaceStore.getContextParams() tabRoutes.push( { title: Service Accounts, component: ServiceAccounts, url: serviceAccountsURL({ query }), - path: serviceAccountsRoute.path, + routePath: serviceAccountsRoute.path.toString(), }, { title: Role Bindings, component: RoleBindings, url: roleBindingsURL({ query }), - path: roleBindingsRoute.path, + routePath: roleBindingsRoute.path.toString(), }, { title: Roles, component: Roles, url: rolesURL({ query }), - path: rolesRoute.path, + routePath: rolesRoute.path.toString(), }, ) if (isAllowedResource("podsecuritypolicies")) { @@ -46,21 +41,15 @@ export class UserManagement extends React.Component { title: Pod Security Policies, component: PodSecurityPolicies, url: podSecurityPoliciesURL(), - path: podSecurityPoliciesRoute.path, + routePath: podSecurityPoliciesRoute.path.toString(), }) } return tabRoutes; } render() { - const tabRoutes = UserManagement.tabRoutes; return ( - - - {tabRoutes.map((route, index) => )} - - - + ) } } diff --git a/src/renderer/components/+workloads/workloads.route.ts b/src/renderer/components/+workloads/workloads.route.ts index 47044b0d18..03e1f384a1 100644 --- a/src/renderer/components/+workloads/workloads.route.ts +++ b/src/renderer/components/+workloads/workloads.route.ts @@ -5,7 +5,7 @@ import { Workloads } from "./workloads"; export const workloadsRoute: RouteProps = { get path() { - return Workloads.tabRoutes.map(({ path }) => path).flat() + return Workloads.tabRoutes.map(({ routePath }) => routePath).flat() } } diff --git a/src/renderer/components/+workloads/workloads.tsx b/src/renderer/components/+workloads/workloads.tsx index fe6e1606b5..8c75e0b203 100644 --- a/src/renderer/components/+workloads/workloads.tsx +++ b/src/renderer/components/+workloads/workloads.tsx @@ -2,12 +2,10 @@ import "./workloads.scss" import React from "react"; import { observer } from "mobx-react"; -import { Redirect, Route, Switch } from "react-router"; -import { RouteComponentProps } from "react-router-dom"; import { Trans } from "@lingui/macro"; -import { TabLayout, TabRoute } from "../layout/tab-layout"; +import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { WorkloadsOverview } from "../+workloads-overview/overview"; -import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, statefulSetsRoute, statefulSetsURL, workloadsURL } from "./workloads.route"; +import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, statefulSetsRoute, statefulSetsURL } from "./workloads.route"; import { namespaceStore } from "../+namespaces/namespace.store"; import { Pods } from "../+workloads-pods"; import { Deployments } from "../+workloads-deployments"; @@ -17,19 +15,16 @@ import { Jobs } from "../+workloads-jobs"; import { CronJobs } from "../+workloads-cronjobs"; import { isAllowedResource } from "../../../common/rbac" -interface Props extends RouteComponentProps { -} - @observer -export class Workloads extends React.Component { - static get tabRoutes(): TabRoute[] { +export class Workloads extends React.Component { + static get tabRoutes(): TabLayoutRoute[] { const query = namespaceStore.getContextParams(); - const routes: TabRoute[] = [ + const routes: TabLayoutRoute[] = [ { title: Overview, component: WorkloadsOverview, url: overviewURL({ query }), - path: overviewRoute.path + routePath: overviewRoute.path.toString() } ] if (isAllowedResource("pods")) { @@ -37,7 +32,7 @@ export class Workloads extends React.Component { title: Pods, component: Pods, url: podsURL({ query }), - path: podsRoute.path + routePath: podsRoute.path.toString() }) } if (isAllowedResource("deployments")) { @@ -45,7 +40,7 @@ export class Workloads extends React.Component { title: Deployments, component: Deployments, url: deploymentsURL({ query }), - path: deploymentsRoute.path, + routePath: deploymentsRoute.path.toString(), }) } if (isAllowedResource("daemonsets")) { @@ -53,7 +48,7 @@ export class Workloads extends React.Component { title: DaemonSets, component: DaemonSets, url: daemonSetsURL({ query }), - path: daemonSetsRoute.path, + routePath: daemonSetsRoute.path.toString(), }) } if (isAllowedResource("statefulsets")) { @@ -61,7 +56,7 @@ export class Workloads extends React.Component { title: StatefulSets, component: StatefulSets, url: statefulSetsURL({ query }), - path: statefulSetsRoute.path, + routePath: statefulSetsRoute.path.toString(), }) } if (isAllowedResource("jobs")) { @@ -69,7 +64,7 @@ export class Workloads extends React.Component { title: Jobs, component: Jobs, url: jobsURL({ query }), - path: jobsRoute.path, + routePath: jobsRoute.path.toString(), }) } if (isAllowedResource("cronjobs")) { @@ -77,21 +72,15 @@ export class Workloads extends React.Component { title: CronJobs, component: CronJobs, url: cronJobsURL({ query }), - path: cronJobsRoute.path, + routePath: cronJobsRoute.path.toString(), }) } return routes; } render() { - const tabRoutes = Workloads.tabRoutes; return ( - - - {tabRoutes.map((route, index) => )} - - - + ) } } diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index a5ebe554d1..45b7d565f2 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -29,6 +29,7 @@ import { CustomResources } from "./+custom-resources/custom-resources"; import { crdRoute } from "./+custom-resources"; import { isAllowedResource } from "../../common/rbac"; import { MainLayout } from "./layout/main-layout"; +import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; import { ErrorBoundary } from "./error-boundary"; import { Terminal } from "./dock/terminal"; import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store"; @@ -36,7 +37,7 @@ import logger from "../../main/logger"; import { clusterIpc } from "../../common/cluster-ipc"; import { webFrame } from "electron"; import { clusterPageRegistry } from "../../extensions/registries/page-registry"; -import { DynamicPage } from "../../extensions/dynamic-page"; +import { clusterPageMenuRegistry } from "../../extensions/registries"; import { extensionLoader } from "../../extensions/extension-loader"; import { appEventBus } from "../../common/event-bus"; import whatInput from 'what-input'; @@ -52,9 +53,13 @@ export class App extends React.Component { await clusterIpc.setFrameId.invokeFromRenderer(clusterId, frameId); await getHostedCluster().whenReady; // cluster.activate() is done at this point extensionLoader.loadOnClusterRenderer(); - appEventBus.emit({name: "cluster", action: "open", params: { - clusterId: clusterId - }}) + appEventBus.emit({ + name: "cluster", + action: "open", + params: { + clusterId: clusterId + } + }) window.addEventListener("online", () => { window.location.reload() }) @@ -68,6 +73,34 @@ export class App extends React.Component { return workloadsURL(); } + renderExtensionRoutes() { + return clusterPageRegistry.getItems().map(({ id: pageId, components: { Page }, exact, routePath, subPages }) => { + const Component = () => { + if (subPages) { + const tabs: TabLayoutRoute[] = subPages.map(({ exact, routePath, components: { Page } }) => { + const menuItem = clusterPageMenuRegistry.getById(pageId); + if (!menuItem) return; + return { + routePath, exact, + component: Page, + url: menuItem.url, + title: menuItem.title, + } + }).filter(Boolean); + if (tabs.length > 0) { + return ( + + + + ) + } + } + return + }; + return + }) + } + render() { return ( @@ -86,12 +119,11 @@ export class App extends React.Component { - {clusterPageRegistry.getItems().map(page => { - return }/> - })} + {this.renderExtensionRoutes()} - + + diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 4a42a0419d..1522376c3e 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -69,8 +69,8 @@ export class ClusterManager extends React.Component { - {globalPageRegistry.getItems().map(({ path, url = String(path), components: { Page } }) => { - return + {globalPageRegistry.getItems().map(({ routePath, exact, components: { Page } }) => { + return })} diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index 3d1e6debaf..7844399227 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -5,6 +5,7 @@ import { remote } from "electron" import type { Cluster } from "../../../main/cluster"; import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd"; import { observer } from "mobx-react"; +import { matchPath } from "react-router"; import { _i18n } from "../../i18n"; import { t, Trans } from "@lingui/macro"; import { userStore } from "../../../common/user-store"; @@ -14,7 +15,7 @@ import { ClusterIcon } from "../cluster-icon"; import { Icon } from "../icon"; import { autobind, cssNames, IClassName } from "../../utils"; import { Badge } from "../badge"; -import { navigate } from "../../navigation"; +import { navigate, navigation } from "../../navigation"; import { addClusterURL } from "../+add-cluster"; import { clusterSettingsURL } from "../+cluster-settings"; import { landingURL } from "../+landing-page"; @@ -22,7 +23,7 @@ import { Tooltip } from "../tooltip"; import { ConfirmDialog } from "../confirm-dialog"; import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterViewURL } from "./cluster-view.route"; -import { globalPageRegistry } from "../../../extensions/registries/page-registry"; +import { globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries"; interface Props { className?: IClassName; @@ -138,7 +139,7 @@ export class ClustersMenu extends React.Component {

-
+
Add Cluster @@ -148,9 +149,19 @@ export class ClustersMenu extends React.Component { )}
- {globalPageRegistry.getItems().map(({ path, url = String(path), hideInMenu, components: { MenuIcon } }) => { - if (!MenuIcon || hideInMenu) return; - return navigate(url)}/> + {globalPageMenuRegistry.getItems().map(({ id: menuItemId, title, url, components: { Icon } }) => { + const registeredPage = globalPageRegistry.getById(menuItemId); + if (!registeredPage) return; + const { routePath, exact } = registeredPage; + const isActive = !!matchPath(navigation.location.pathname, { path: routePath, exact }); + return ( + navigate(url)} + /> + ) })}
diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index 8bce1a32c8..b70d7a3e62 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -1,20 +1,20 @@ -import type { TabRoute } from "./tab-layout"; +import type { TabLayoutRoute } from "./tab-layout"; import "./sidebar.scss"; import React from "react"; import { computed, observable, reaction } from "mobx"; import { observer } from "mobx-react"; -import { matchPath, NavLink } from "react-router-dom"; +import { NavLink } from "react-router-dom"; import { Trans } from "@lingui/macro"; import { createStorage, cssNames } from "../../utils"; import { Icon } from "../icon"; import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route"; -import { namespacesURL } from "../+namespaces/namespaces.route"; -import { nodesURL } from "../+nodes/nodes.route"; +import { namespacesRoute, namespacesURL } from "../+namespaces/namespaces.route"; +import { nodesRoute, nodesURL } from "../+nodes/nodes.route"; import { usersManagementRoute, usersManagementURL } from "../+user-management/user-management.route"; import { networkRoute, networkURL } from "../+network/network.route"; import { storageRoute, storageURL } from "../+storage/storage.route"; -import { clusterURL } from "../+cluster"; +import { clusterRoute, clusterURL } from "../+cluster"; import { Config, configRoute, configURL } from "../+config"; import { eventRoute, eventsURL } from "../+events"; import { Apps, appsRoute, appsURL } from "../+apps"; @@ -26,10 +26,10 @@ import { Network } from "../+network"; import { crdStore } from "../+custom-resources/crd.store"; import { CrdList, crdResourcesRoute, crdRoute, crdURL } from "../+custom-resources"; import { CustomResources } from "../+custom-resources/custom-resources"; -import { navigation } from "../../navigation"; -import { clusterPageRegistry } from "../../../extensions/registries/page-registry"; -import { isAllowedResource } from "../../../common/rbac"; +import { isActiveRoute } from "../../navigation"; +import { isAllowedResource } from "../../../common/rbac" import { Spinner } from "../spinner"; +import { clusterPageMenuRegistry, clusterPageRegistry } from "../../../extensions/registries"; const SidebarContext = React.createContext({ pinned: false }); type SidebarContextValue = { @@ -52,22 +52,21 @@ export class Sidebar extends React.Component { renderCustomResources() { if (crdStore.isLoading) { - return + return } return Object.entries(crdStore.groups).map(([group, crds]) => { - const submenus = crds.map((crd) => { + const submenus: TabLayoutRoute[] = crds.map((crd) => { return { title: crd.getResourceKind(), component: CrdList, url: crd.getResourceUrl(), - path: crdResourcesRoute.path, + routePath: String(crdResourcesRoute.path), }; }); return ( {
- +
Lens
{
Cluster} - icon={} + icon={} /> Nodes} - icon={} + icon={} /> Workloads} - icon={} + icon={} /> Configuration} - icon={} + icon={} /> Network} - icon={} + icon={} /> } + icon={} text={Storage} /> } + icon={} text={Namespaces} /> } + icon={} text={Events} /> } + icon={} text={Apps} /> } + icon={} text={Access Control} /> } + icon={} text={Custom Resources} > {this.renderCustomResources()} - {clusterPageRegistry.getItems().map(({ path, title, url = String(path), hideInMenu, components: { MenuIcon } }) => { - if (!MenuIcon || hideInMenu) { - return; - } + {clusterPageMenuRegistry.getItems().map(({ id: menuItemId, title, url, components: { Icon } }) => { + const registeredPage = clusterPageRegistry.getById(menuItemId); + if (!registeredPage) return; + const { routePath, exact } = registeredPage; return ( } + icon={} + isActive={isActiveRoute({ path: routePath, exact })} /> ) })} @@ -211,58 +213,50 @@ export class Sidebar extends React.Component { } interface SidebarNavItemProps { - id: string; url: string; text: React.ReactNode | string; className?: string; icon?: React.ReactNode; isHidden?: boolean; - routePath?: string | string[]; - subMenus?: TabRoute[]; + isActive?: boolean; + subMenus?: TabLayoutRoute[]; + testId?: string; // data-test-id="" property for integration tests } const navItemStorage = createStorage<[string, boolean][]>("sidebar_menu_item", []); const navItemState = observable.map(navItemStorage.get()); -reaction( - () => [...navItemState], - (value) => navItemStorage.set(value) -); +reaction(() => [...navItemState], (value) => navItemStorage.set(value)); @observer class SidebarNavItem extends React.Component { static contextType = SidebarContext; public context: SidebarContextValue; + get itemId() { + return this.props.url; + } + @computed get isExpanded() { - return navItemState.get(this.props.id); + return navItemState.get(this.itemId); } toggleSubMenu = () => { - navItemState.set(this.props.id, !this.isExpanded); - }; - - isActive = () => { - const { routePath, url } = this.props; - const { pathname } = navigation.location; - return !!matchPath(pathname, { - path: routePath || url, - }); + navItemState.set(this.itemId, !this.isExpanded); }; render() { - const { id, isHidden, subMenus = [], icon, text, url, children, className } = this.props; + const { isHidden, isActive, subMenus = [], icon, text, url, children, className, testId } = this.props; if (isHidden) { return null; } const extendedView = (subMenus.length > 0 || children) && this.context.pinned; if (extendedView) { - const isActive = this.isActive(); return ( -
+
{icon} {text} - +
    {subMenus.map(({ title, url }) => ( @@ -280,7 +274,7 @@ class SidebarNavItem extends React.Component { ); } return ( - + isActive}> {icon} {text} diff --git a/src/renderer/components/layout/tab-layout.tsx b/src/renderer/components/layout/tab-layout.tsx index 7369399627..6f00b13f7f 100644 --- a/src/renderer/components/layout/tab-layout.tsx +++ b/src/renderer/components/layout/tab-layout.tsx @@ -1,38 +1,55 @@ import "./tab-layout.scss"; + import React, { ReactNode } from "react"; -import { matchPath, RouteProps } from "react-router-dom"; +import { matchPath, Redirect, Route, Switch } from "react-router"; import { observer } from "mobx-react"; -import { cssNames } from "../../utils"; +import { cssNames, IClassName } from "../../utils"; import { Tab, Tabs } from "../tabs"; import { ErrorBoundary } from "../error-boundary"; import { navigate, navigation } from "../../navigation"; -export interface TabRoute extends RouteProps { - title: React.ReactNode; - url: string; -} - export interface TabLayoutProps { - children: ReactNode; - className?: any; - tabs?: TabRoute[]; - contentClass?: string; + className?: IClassName; + contentClass?: IClassName; + tabs?: TabLayoutRoute[]; + children?: ReactNode; } -export const TabLayout = observer(({ className, contentClass, tabs, children }: TabLayoutProps) => { - const routePath = navigation.location.pathname; +export interface TabLayoutRoute { + routePath: string; + title: React.ReactNode; + component: React.ComponentType; + url?: string; // page-url, if not provided `routePath` is used (doesn't work when path has some :placeholder(s)) + exact?: boolean; // route-path matching rule + default?: boolean; // initial tab to open with provided `url, by default tabs[0] is used +} + +export const TabLayout = observer(({ className, contentClass, tabs = [], children }: TabLayoutProps) => { + const currentLocation = navigation.location.pathname; + const hasTabs = tabs.length > 0; + const startTabUrl = hasTabs ? (tabs.find(tab => tab.default) || tabs[0])?.url : null; return (
    - {tabs && ( + {hasTabs && ( navigate(url)}> - {tabs.map(({ title, path, url, ...routeProps }) => { - const isActive = !!matchPath(routePath, { path, ...routeProps }); + {tabs.map(({ title, routePath, url = routePath, exact }) => { + const isActive = !!matchPath(currentLocation, { path: routePath, exact }); return ; })} )} -
    - {children} +
    + + {hasTabs && ( + + {tabs.map(({ routePath, exact, component }) => { + return ; + })} + + + )} + {children} +
    ); diff --git a/src/renderer/navigation.ts b/src/renderer/navigation.ts index e894b819c8..e8d2dba2bb 100644 --- a/src/renderer/navigation.ts +++ b/src/renderer/navigation.ts @@ -1,14 +1,14 @@ // Navigation helpers import { ipcRenderer } from "electron"; -import { matchPath } from "react-router"; +import { matchPath, RouteProps } from "react-router"; import { reaction } from "mobx"; import { createObservableHistory } from "mobx-observable-history"; -import { createBrowserHistory, createMemoryHistory, LocationDescriptor } from "history"; +import { createBrowserHistory, LocationDescriptor } from "history"; import logger from "../main/logger"; import { clusterViewRoute, IClusterViewRouteParams } from "./components/cluster-manager/cluster-view.route"; -export const history = typeof window !== "undefined" ? createBrowserHistory() : createMemoryHistory(); +export const history = createBrowserHistory(); export const navigation = createObservableHistory(history); /** @@ -22,6 +22,10 @@ export function navigate(location: LocationDescriptor) { } } +export function isActiveRoute(route: string | string[] | RouteProps): boolean { + return !!matchPath(navigation.location.pathname, route); +} + // common params for all pages export interface IQueryParams { namespaces?: string[]; // selected context namespaces