diff --git a/extensions/support-page/main.ts b/extensions/support-page/main.ts
index 43a016ca02..870478afb2 100644
--- a/extensions/support-page/main.ts
+++ b/extensions/support-page/main.ts
@@ -6,7 +6,7 @@ export default class SupportPageMainExtension extends LensMainExtension {
parentId: "help",
label: "Support",
click: () => {
- this.navigate("/support");
+ this.navigate();
}
}
]
diff --git a/extensions/support-page/renderer.tsx b/extensions/support-page/renderer.tsx
index 92d460fdfc..0137ea7771 100644
--- a/extensions/support-page/renderer.tsx
+++ b/extensions/support-page/renderer.tsx
@@ -5,8 +5,6 @@ import { SupportPage } from "./src/support";
export default class SupportPageRendererExtension extends LensRendererExtension {
globalPages: Interface.PageRegistration[] = [
{
- id: "support",
- routePath: "/support",
components: {
Page: SupportPage,
}
@@ -16,7 +14,7 @@ export default class SupportPageRendererExtension extends LensRendererExtension
statusBarItems: Interface.StatusBarRegistration[] = [
{
item: (
-
this.navigate("/support")}>
+
this.navigate()}>
)
diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts
index de9aae40ec..af091e0c1c 100644
--- a/src/extensions/lens-main-extension.ts
+++ b/src/extensions/lens-main-extension.ts
@@ -2,14 +2,18 @@ import type { MenuRegistration } from "./registries/menu-registry";
import { observable } from "mobx";
import { LensExtension } from "./lens-extension"
import { WindowManager } from "../main/window-manager";
-import { getPageUrl } from "./registries/page-registry"
+import { getExtensionPageUrl } from "./registries/page-registry"
export class LensMainExtension extends LensExtension {
@observable.shallow appMenus: MenuRegistration[] = []
- async navigate(location?: string, frameId?: number) {
+ async navigate
(pageId?: string, params?: P, frameId?: number) {
const windowManager = WindowManager.getInstance();
- const url = getPageUrl(this, location); // get full path to extension's page
- await windowManager.navigate(url, frameId);
+ const pageUrl = getExtensionPageUrl({
+ extensionId: this.name,
+ pageId: pageId,
+ params: params ?? {}, // compile to url with params
+ });
+ await windowManager.navigate(pageUrl, frameId);
}
}
diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts
index 5968124cbc..0fb346e1a1 100644
--- a/src/extensions/lens-renderer-extension.ts
+++ b/src/extensions/lens-renderer-extension.ts
@@ -1,7 +1,7 @@
import type { AppPreferenceRegistration, ClusterFeatureRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries"
import { observable } from "mobx";
import { LensExtension } from "./lens-extension"
-import { getPageUrl } from "./registries/page-registry"
+import { getExtensionPageUrl } from "./registries/page-registry"
export class LensRendererExtension extends LensExtension {
@observable.shallow globalPages: PageRegistration[] = []
@@ -15,8 +15,13 @@ export class LensRendererExtension extends LensExtension {
@observable.shallow kubeObjectDetailItems: KubeObjectDetailRegistration[] = []
@observable.shallow kubeObjectMenuItems: KubeObjectMenuRegistration[] = []
- async navigate(location?: string) {
+ async navigate(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation");
- navigate(getPageUrl(this, location));
+ const pageUrl = getExtensionPageUrl({
+ extensionId: this.name,
+ pageId: pageId,
+ params: params ?? {}, // compile to url with params
+ });
+ navigate(pageUrl);
}
}
diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts
index 32185f683c..47a5f82d9d 100644
--- a/src/extensions/registries/__tests__/page-registry.test.ts
+++ b/src/extensions/registries/__tests__/page-registry.test.ts
@@ -1,4 +1,4 @@
-import { getPageUrl, globalPageRegistry } from "../page-registry"
+import { getExtensionPageUrl, globalPageRegistry, PageRegistration } from "../page-registry"
import { LensExtension } from "../../lens-extension"
import React from "react";
@@ -18,20 +18,19 @@ describe("getPageUrl", () => {
})
it("returns a page url for extension", () => {
- expect(getPageUrl(ext)).toBe("/extension/foo-bar")
+ expect(getExtensionPageUrl({ extensionId: ext.name })).toBe("/extension/foo-bar")
})
it("allows to pass base url as parameter", () => {
- expect(getPageUrl(ext, "/test")).toBe("/extension/foo-bar/test")
+ expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "/test" })).toBe("/extension/foo-bar/test")
})
it("removes @", () => {
- ext.manifest.name = "@foo/bar"
- expect(getPageUrl(ext)).toBe("/extension/foo-bar")
+ expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo-bar")
})
it("adds / prefix", () => {
- expect(getPageUrl(ext, "test")).toBe("/extension/foo-bar/test")
+ expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "test" })).toBe("/extension/foo-bar/test")
})
})
@@ -57,12 +56,24 @@ describe("globalPageRegistry", () => {
id: "another-page",
components: {
Page: () => React.createElement('Text')
+ },
+ },
+ {
+ components: {
+ Page: () => React.createElement('Default')
}
},
], ext)
})
describe("getByPageMenuTarget", () => {
+ it("matching to first registered page without id", () => {
+ const page = globalPageRegistry.getByPageMenuTarget({ extensionId: ext.name })
+ expect(page.id).toEqual(undefined);
+ expect(page.extensionId).toEqual(ext.name);
+ expect(page.routePath).toEqual(getExtensionPageUrl({ extensionId: ext.name }));
+ });
+
it("returns matching page", () => {
const page = globalPageRegistry.getByPageMenuTarget({
pageId: "test-page",
diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts
index bc23c29f5d..e82c9d11bf 100644
--- a/src/extensions/registries/base-registry.ts
+++ b/src/extensions/registries/base-registry.ts
@@ -1,13 +1,15 @@
// Base class for extensions-api registries
import { action, observable } from "mobx";
+import { LensExtension } from "../lens-extension";
-export class BaseRegistry {
+export class BaseRegistry {
private items = observable([], { deep: false });
- getItems(): T[] {
- return this.items.toJS();
+ getItems(): I[] {
+ return this.items.toJS() as I[];
}
+ add(items: T | T[], ext?: LensExtension): () => void; // allow method overloading with required "ext"
@action
add(items: T | T[]) {
const normalizedItems = (Array.isArray(items) ? items : [items])
diff --git a/src/extensions/registries/page-menu-registry.ts b/src/extensions/registries/page-menu-registry.ts
index 9da53dea06..ebaaa42dbb 100644
--- a/src/extensions/registries/page-menu-registry.ts
+++ b/src/extensions/registries/page-menu-registry.ts
@@ -1,15 +1,14 @@
// Extensions-api -> Register page menu items
-
+import type { IconProps } from "../../renderer/components/icon";
import type React from "react";
import { action } from "mobx";
-import type { IconProps } from "../../renderer/components/icon";
import { BaseRegistry } from "./base-registry";
import { LensExtension } from "../lens-extension";
-export interface PageMenuTarget {
- pageId: string;
+export interface PageMenuTarget {
extensionId?: string;
- params?: object;
+ pageId?: string;
+ params?: P;
}
export interface PageMenuRegistration {
@@ -22,19 +21,19 @@ export interface PageMenuComponents {
Icon: React.ComponentType;
}
-export class PageMenuRegistry extends BaseRegistry {
-
+export class PageMenuRegistry extends BaseRegistry> {
@action
- add(items: T[], ext?: LensExtension) {
- const normalizedItems = items.map((menu) => {
- if (menu.target && !menu.target.extensionId) {
- menu.target.extensionId = ext.name
- }
- return menu
+ add(items: PageMenuRegistration[], ext: LensExtension) {
+ const normalizedItems = items.map(menuItem => {
+ menuItem.target = {
+ extensionId: ext.name,
+ ...(menuItem.target || {}),
+ };
+ return menuItem
})
return super.add(normalizedItems);
}
}
-export const globalPageMenuRegistry = new PageMenuRegistry();
-export const clusterPageMenuRegistry = new PageMenuRegistry();
+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 0b1556288e..bd265bc77c 100644
--- a/src/extensions/registries/page-registry.ts
+++ b/src/extensions/registries/page-registry.ts
@@ -1,59 +1,96 @@
// Extensions-api -> Custom page registration
-
-import React from "react";
+import type { PageMenuTarget } from "./page-menu-registry";
+import type React from "react";
+import path from "path";
import { action } from "mobx";
import { compile } from "path-to-regexp";
import { BaseRegistry } from "./base-registry";
-import { LensExtension } from "../lens-extension"
-import type { PageMenuTarget } from "./page-menu-registry";
+import { LensExtension } from "../lens-extension";
+import logger from "../../main/logger";
export interface PageRegistration {
- id: string; // will be automatically prefixed with extension name
- 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
+ /**
+ * Page ID or additional route path to indicate uniqueness within current extension registered pages
+ * Might contain special url placeholders, e.g. "/users/:userId?" (? - marks as optional param)
+ * When not provided, first registered page without "id" would be used for page-menus without target.pageId for same extension
+ */
+ id?: string;
+ /**
+ * Alias to page ID which assume to be used as path with possible :param placeholders
+ * @deprecated
+ */
+ routePath?: string;
+ /**
+ * Strict route matching to provided page-id, read also: https://reactrouter.com/web/api/NavLink/exact-bool
+ * In case when more than one page registered at same extension "pageId" is required to identify different pages,
+ * It might be useful to provide `exact: true` in some cases to avoid overlapping routes.
+ * Without {exact:true} second page never matches since first page-id/route already includes partial route.
+ * @example const pages = [
+ * {id: "/users", exact: true},
+ * {id: "/users/:userId?"}
+ * ]
+ * Pro-tip: registering pages in opposite order will make same effect without "exact".
+ */
+ exact?: boolean;
components: PageComponents;
}
+export interface RegisteredPage extends PageRegistration {
+ extensionId: string; // required for compiling registered page to url with page-menu-target to compare
+ routePath: string; // full route-path to registered extension page
+}
+
export interface PageComponents {
Page: React.ComponentType;
}
-const routePrefix = "/extension/:name"
-
-export function sanitizeExtensioName(name: string) {
+export function sanitizeExtensionName(name: string) {
return name.replace("@", "").replace("/", "-")
}
-export function getPageUrl(ext: LensExtension, baseUrl = "") {
- if (baseUrl !== "" && !baseUrl.startsWith("/")) {
- baseUrl = "/" + baseUrl
+export function getExtensionPageUrl({ extensionId, pageId = "", params }: PageMenuTarget
): string {
+ const extensionBaseUrl = compile(`/extension/:name`)({
+ name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path
+ });
+ const extPageRoutePath = path.join(extensionBaseUrl, pageId); // page-id might contain route :param-s, so don't compile yet
+ if (params) {
+ return compile(extPageRoutePath)(params); // might throw error when required params not passed
}
- const validUrlName = sanitizeExtensioName(ext.name);
- return compile(routePrefix)({ name: validUrlName }) + baseUrl;
+ return extPageRoutePath;
}
-export class PageRegistry extends BaseRegistry {
-
+export class PageRegistry extends BaseRegistry {
@action
- add(items: T[], ext?: LensExtension) {
- const normalizedItems = items.map((page) => {
- if (!page.routePath) {
- page.routePath = `/${page.id}`
- }
- page.routePath = getPageUrl(ext, page.routePath)
- return page
- })
- return super.add(normalizedItems);
+ add(items: PageRegistration[], ext: LensExtension) {
+ let registeredPages: RegisteredPage[] = [];
+ try {
+ registeredPages = items.map(page => ({
+ ...page,
+ extensionId: ext.name,
+ routePath: getExtensionPageUrl({ extensionId: ext.name, pageId: page.id ?? page.routePath }),
+ }))
+ } catch (err) {
+ logger.error(`[EXTENSION]: page-registration failed`, {
+ items,
+ extension: ext,
+ error: String(err),
+ })
+ }
+ return super.add(registeredPages);
}
- getByPageMenuTarget(target: PageMenuTarget) {
- if (!target) {
- return null
- }
- const routePath = `/extension/${sanitizeExtensioName(target.extensionId)}/`
- return this.getItems().find((page) => page.routePath.startsWith(routePath) && page.id === target.pageId) || null
+ getUrl({ extensionId, id: pageId }: RegisteredPage, params?: P) {
+ return getExtensionPageUrl({ extensionId, pageId, params });
+ }
+
+ getByPageMenuTarget(target: PageMenuTarget = {}): RegisteredPage | null {
+ const targetUrl = getExtensionPageUrl(target);
+ return this.getItems().find(({ id: pageId, extensionId }) => {
+ const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); // compiled with provided params
+ return targetUrl === pageUrl;
+ }) || null;
}
}
-export const globalPageRegistry = new PageRegistry();
-export const clusterPageRegistry = new PageRegistry();
+export const globalPageRegistry = new PageRegistry();
+export const clusterPageRegistry = new PageRegistry();
diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx
index cb8d02172b..57f2095c97 100755
--- a/src/renderer/components/app.tsx
+++ b/src/renderer/components/app.tsx
@@ -29,7 +29,6 @@ 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";
@@ -37,7 +36,6 @@ import logger from "../../main/logger";
import { clusterIpc } from "../../common/cluster-ipc";
import { webFrame } from "electron";
import { clusterPageRegistry } from "../../extensions/registries/page-registry";
-import { clusterPageMenuRegistry } from "../../extensions/registries";
import { extensionLoader } from "../../extensions/extension-loader";
import { appEventBus } from "../../common/event-bus";
import whatInput from 'what-input';
@@ -75,10 +73,7 @@ export class App extends React.Component {
renderExtensionRoutes() {
return clusterPageRegistry.getItems().map(({ components: { Page }, exact, routePath }) => {
- const Component = () => {
- return
- };
- return
+ return
})
}
diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx
index 302e4e76d2..1fb818a812 100644
--- a/src/renderer/components/cluster-manager/clusters-menu.tsx
+++ b/src/renderer/components/cluster-manager/clusters-menu.tsx
@@ -14,7 +14,7 @@ import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon";
import { autobind, cssNames, IClassName } from "../../utils";
import { Badge } from "../badge";
-import { isActiveRoute, navigate } from "../../navigation";
+import { navigate, navigation } from "../../navigation";
import { addClusterURL } from "../+add-cluster";
import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page";
@@ -22,8 +22,7 @@ import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterViewURL } from "./cluster-view.route";
-import { globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries";
-import { compile } from "path-to-regexp";
+import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries";
interface Props {
className?: IClassName;
@@ -152,13 +151,15 @@ export class ClustersMenu extends React.Component {
{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => {
const registeredPage = globalPageRegistry.getByPageMenuTarget(target);
if (!registeredPage) return;
- const { routePath, exact } = registeredPage;
+ const { extensionId, id: pageId } = registeredPage;
+ const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params });
+ const isActive = pageUrl === navigation.location.pathname;
return (
navigate(compile(routePath)(target.params))}
+ active={isActive}
+ onClick={() => navigate(pageUrl)}
/>
)
})}
diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx
index 8485f1e134..b77847b4ef 100644
--- a/src/renderer/components/layout/sidebar.tsx
+++ b/src/renderer/components/layout/sidebar.tsx
@@ -26,11 +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 { isActiveRoute } from "../../navigation";
+import { isActiveRoute, navigation } from "../../navigation";
import { isAllowedResource } from "../../../common/rbac"
import { Spinner } from "../spinner";
-import { clusterPageMenuRegistry, clusterPageRegistry } from "../../../extensions/registries";
-import { compile } from "path-to-regexp";
+import { clusterPageMenuRegistry, clusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries";
const SidebarContext = React.createContext({ pinned: false });
type SidebarContextValue = {
@@ -195,15 +194,14 @@ export class Sidebar extends React.Component {
{clusterPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => {
const registeredPage = clusterPageRegistry.getByPageMenuTarget(target);
if (!registeredPage) return;
- const { routePath, exact } = registeredPage;
- const url = compile(routePath)(target.params)
+ const { extensionId, id: pageId } = registeredPage;
+ const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params });
+ const isActive = pageUrl === navigation.location.pathname;
return (
}
- isActive={isActiveRoute({ path: routePath, exact })}
+ key={pageUrl} url={pageUrl}
+ text={title} icon={}
+ isActive={isActive}
/>
)
})}
diff --git a/src/renderer/navigation.ts b/src/renderer/navigation.ts
index e8d2dba2bb..70073d2ff2 100644
--- a/src/renderer/navigation.ts
+++ b/src/renderer/navigation.ts
@@ -22,8 +22,12 @@ export function navigate(location: LocationDescriptor) {
}
}
+export function matchParams(route: string | string[] | RouteProps) {
+ return matchPath
(navigation.location.pathname, route);
+}
+
export function isActiveRoute(route: string | string[] | RouteProps): boolean {
- return !!matchPath(navigation.location.pathname, route);
+ return !!matchParams(route);
}
// common params for all pages