1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

PageRegistration / PageMenuRegistration fixes & more simplification (#1386)

* PageRegistration & PageMenuRegistration fixes & more simplification, fix #1383

Signed-off-by: Roman <ixrock@gmail.com>

* clean up test, deprecate page.routePath

Signed-off-by: Roman <ixrock@gmail.com>

* fix: proper `isActive` state matching for page-menu-target to page considering current document location

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman 2020-11-16 15:18:53 +02:00 committed by GitHub
parent 596e60a6f3
commit 13dbdac2f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 147 additions and 93 deletions

View File

@ -6,7 +6,7 @@ export default class SupportPageMainExtension extends LensMainExtension {
parentId: "help", parentId: "help",
label: "Support", label: "Support",
click: () => { click: () => {
this.navigate("/support"); this.navigate();
} }
} }
] ]

View File

@ -5,8 +5,6 @@ import { SupportPage } from "./src/support";
export default class SupportPageRendererExtension extends LensRendererExtension { export default class SupportPageRendererExtension extends LensRendererExtension {
globalPages: Interface.PageRegistration[] = [ globalPages: Interface.PageRegistration[] = [
{ {
id: "support",
routePath: "/support",
components: { components: {
Page: SupportPage, Page: SupportPage,
} }
@ -16,7 +14,7 @@ export default class SupportPageRendererExtension extends LensRendererExtension
statusBarItems: Interface.StatusBarRegistration[] = [ statusBarItems: Interface.StatusBarRegistration[] = [
{ {
item: ( item: (
<div className="SupportPageIcon flex align-center" onClick={() => this.navigate("/support")}> <div className="SupportPageIcon flex align-center" onClick={() => this.navigate()}>
<Component.Icon interactive material="help" smallest/> <Component.Icon interactive material="help" smallest/>
</div> </div>
) )

View File

@ -2,14 +2,18 @@ import type { MenuRegistration } from "./registries/menu-registry";
import { observable } from "mobx"; import { observable } from "mobx";
import { LensExtension } from "./lens-extension" import { LensExtension } from "./lens-extension"
import { WindowManager } from "../main/window-manager"; import { WindowManager } from "../main/window-manager";
import { getPageUrl } from "./registries/page-registry" import { getExtensionPageUrl } from "./registries/page-registry"
export class LensMainExtension extends LensExtension { export class LensMainExtension extends LensExtension {
@observable.shallow appMenus: MenuRegistration[] = [] @observable.shallow appMenus: MenuRegistration[] = []
async navigate(location?: string, frameId?: number) { async navigate<P extends object>(pageId?: string, params?: P, frameId?: number) {
const windowManager = WindowManager.getInstance<WindowManager>(); const windowManager = WindowManager.getInstance<WindowManager>();
const url = getPageUrl(this, location); // get full path to extension's page const pageUrl = getExtensionPageUrl({
await windowManager.navigate(url, frameId); extensionId: this.name,
pageId: pageId,
params: params ?? {}, // compile to url with params
});
await windowManager.navigate(pageUrl, frameId);
} }
} }

View File

@ -1,7 +1,7 @@
import type { AppPreferenceRegistration, ClusterFeatureRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries" import type { AppPreferenceRegistration, ClusterFeatureRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries"
import { observable } from "mobx"; import { observable } from "mobx";
import { LensExtension } from "./lens-extension" import { LensExtension } from "./lens-extension"
import { getPageUrl } from "./registries/page-registry" import { getExtensionPageUrl } from "./registries/page-registry"
export class LensRendererExtension extends LensExtension { export class LensRendererExtension extends LensExtension {
@observable.shallow globalPages: PageRegistration[] = [] @observable.shallow globalPages: PageRegistration[] = []
@ -15,8 +15,13 @@ export class LensRendererExtension extends LensExtension {
@observable.shallow kubeObjectDetailItems: KubeObjectDetailRegistration[] = [] @observable.shallow kubeObjectDetailItems: KubeObjectDetailRegistration[] = []
@observable.shallow kubeObjectMenuItems: KubeObjectMenuRegistration[] = [] @observable.shallow kubeObjectMenuItems: KubeObjectMenuRegistration[] = []
async navigate(location?: string) { async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation"); 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);
} }
} }

View File

@ -1,4 +1,4 @@
import { getPageUrl, globalPageRegistry } from "../page-registry" import { getExtensionPageUrl, globalPageRegistry, PageRegistration } from "../page-registry"
import { LensExtension } from "../../lens-extension" import { LensExtension } from "../../lens-extension"
import React from "react"; import React from "react";
@ -18,20 +18,19 @@ describe("getPageUrl", () => {
}) })
it("returns a page url for extension", () => { 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", () => { 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 @", () => { it("removes @", () => {
ext.manifest.name = "@foo/bar" expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo-bar")
expect(getPageUrl(ext)).toBe("/extension/foo-bar")
}) })
it("adds / prefix", () => { 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", id: "another-page",
components: { components: {
Page: () => React.createElement('Text') Page: () => React.createElement('Text')
},
},
{
components: {
Page: () => React.createElement('Default')
} }
}, },
], ext) ], ext)
}) })
describe("getByPageMenuTarget", () => { 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", () => { it("returns matching page", () => {
const page = globalPageRegistry.getByPageMenuTarget({ const page = globalPageRegistry.getByPageMenuTarget({
pageId: "test-page", pageId: "test-page",

View File

@ -1,13 +1,15 @@
// Base class for extensions-api registries // Base class for extensions-api registries
import { action, observable } from "mobx"; import { action, observable } from "mobx";
import { LensExtension } from "../lens-extension";
export class BaseRegistry<T = any> { export class BaseRegistry<T = object, I extends T = T> {
private items = observable<T>([], { deep: false }); private items = observable<T>([], { deep: false });
getItems(): T[] { getItems(): I[] {
return this.items.toJS(); return this.items.toJS() as I[];
} }
add(items: T | T[], ext?: LensExtension): () => void; // allow method overloading with required "ext"
@action @action
add(items: T | T[]) { add(items: T | T[]) {
const normalizedItems = (Array.isArray(items) ? items : [items]) const normalizedItems = (Array.isArray(items) ? items : [items])

View File

@ -1,15 +1,14 @@
// Extensions-api -> Register page menu items // Extensions-api -> Register page menu items
import type { IconProps } from "../../renderer/components/icon";
import type React from "react"; import type React from "react";
import { action } from "mobx"; import { action } from "mobx";
import type { IconProps } from "../../renderer/components/icon";
import { BaseRegistry } from "./base-registry"; import { BaseRegistry } from "./base-registry";
import { LensExtension } from "../lens-extension"; import { LensExtension } from "../lens-extension";
export interface PageMenuTarget { export interface PageMenuTarget<P extends object = any> {
pageId: string;
extensionId?: string; extensionId?: string;
params?: object; pageId?: string;
params?: P;
} }
export interface PageMenuRegistration { export interface PageMenuRegistration {
@ -22,19 +21,19 @@ export interface PageMenuComponents {
Icon: React.ComponentType<IconProps>; Icon: React.ComponentType<IconProps>;
} }
export class PageMenuRegistry<T extends PageMenuRegistration> extends BaseRegistry<T> { export class PageMenuRegistry extends BaseRegistry<PageMenuRegistration, Required<PageMenuRegistration>> {
@action @action
add(items: T[], ext?: LensExtension) { add(items: PageMenuRegistration[], ext: LensExtension) {
const normalizedItems = items.map((menu) => { const normalizedItems = items.map(menuItem => {
if (menu.target && !menu.target.extensionId) { menuItem.target = {
menu.target.extensionId = ext.name extensionId: ext.name,
} ...(menuItem.target || {}),
return menu };
return menuItem
}) })
return super.add(normalizedItems); return super.add(normalizedItems);
} }
} }
export const globalPageMenuRegistry = new PageMenuRegistry<PageMenuRegistration>(); export const globalPageMenuRegistry = new PageMenuRegistry();
export const clusterPageMenuRegistry = new PageMenuRegistry<PageMenuRegistration>(); export const clusterPageMenuRegistry = new PageMenuRegistry();

View File

@ -1,59 +1,96 @@
// Extensions-api -> Custom page registration // Extensions-api -> Custom page registration
import type { PageMenuTarget } from "./page-menu-registry";
import React from "react"; import type React from "react";
import path from "path";
import { action } from "mobx"; import { action } from "mobx";
import { compile } from "path-to-regexp"; import { compile } from "path-to-regexp";
import { BaseRegistry } from "./base-registry"; import { BaseRegistry } from "./base-registry";
import { LensExtension } from "../lens-extension" import { LensExtension } from "../lens-extension";
import type { PageMenuTarget } from "./page-menu-registry"; import logger from "../../main/logger";
export interface PageRegistration { 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" * Page ID or additional route path to indicate uniqueness within current extension registered pages
exact?: boolean; // route matching flag, see: https://reactrouter.com/web/api/NavLink/exact-bool * 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; 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 { export interface PageComponents {
Page: React.ComponentType<any>; Page: React.ComponentType<any>;
} }
const routePrefix = "/extension/:name" export function sanitizeExtensionName(name: string) {
export function sanitizeExtensioName(name: string) {
return name.replace("@", "").replace("/", "-") return name.replace("@", "").replace("/", "-")
} }
export function getPageUrl(ext: LensExtension, baseUrl = "") { export function getExtensionPageUrl<P extends object>({ extensionId, pageId = "", params }: PageMenuTarget<P>): string {
if (baseUrl !== "" && !baseUrl.startsWith("/")) { const extensionBaseUrl = compile(`/extension/:name`)({
baseUrl = "/" + baseUrl 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 extPageRoutePath;
return compile(routePrefix)({ name: validUrlName }) + baseUrl;
} }
export class PageRegistry<T extends PageRegistration> extends BaseRegistry<T> { export class PageRegistry extends BaseRegistry<PageRegistration, RegisteredPage> {
@action @action
add(items: T[], ext?: LensExtension) { add(items: PageRegistration[], ext: LensExtension) {
const normalizedItems = items.map((page) => { let registeredPages: RegisteredPage[] = [];
if (!page.routePath) { try {
page.routePath = `/${page.id}` registeredPages = items.map(page => ({
} ...page,
page.routePath = getPageUrl(ext, page.routePath) extensionId: ext.name,
return page routePath: getExtensionPageUrl({ extensionId: ext.name, pageId: page.id ?? page.routePath }),
}) }))
return super.add(normalizedItems); } catch (err) {
logger.error(`[EXTENSION]: page-registration failed`, {
items,
extension: ext,
error: String(err),
})
}
return super.add(registeredPages);
} }
getByPageMenuTarget(target: PageMenuTarget) { getUrl<P extends object>({ extensionId, id: pageId }: RegisteredPage, params?: P) {
if (!target) { return getExtensionPageUrl({ extensionId, pageId, params });
return null }
}
const routePath = `/extension/${sanitizeExtensioName(target.extensionId)}/` getByPageMenuTarget(target: PageMenuTarget = {}): RegisteredPage | null {
return this.getItems().find((page) => page.routePath.startsWith(routePath) && page.id === target.pageId) || 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<PageRegistration>(); export const globalPageRegistry = new PageRegistry();
export const clusterPageRegistry = new PageRegistry<PageRegistration>(); export const clusterPageRegistry = new PageRegistry();

View File

@ -29,7 +29,6 @@ import { CustomResources } from "./+custom-resources/custom-resources";
import { crdRoute } from "./+custom-resources"; import { crdRoute } from "./+custom-resources";
import { isAllowedResource } from "../../common/rbac"; import { isAllowedResource } from "../../common/rbac";
import { MainLayout } from "./layout/main-layout"; import { MainLayout } from "./layout/main-layout";
import { TabLayout, TabLayoutRoute } from "./layout/tab-layout";
import { ErrorBoundary } from "./error-boundary"; import { ErrorBoundary } from "./error-boundary";
import { Terminal } from "./dock/terminal"; import { Terminal } from "./dock/terminal";
import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store"; import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store";
@ -37,7 +36,6 @@ import logger from "../../main/logger";
import { clusterIpc } from "../../common/cluster-ipc"; import { clusterIpc } from "../../common/cluster-ipc";
import { webFrame } from "electron"; import { webFrame } from "electron";
import { clusterPageRegistry } from "../../extensions/registries/page-registry"; import { clusterPageRegistry } from "../../extensions/registries/page-registry";
import { clusterPageMenuRegistry } from "../../extensions/registries";
import { extensionLoader } from "../../extensions/extension-loader"; import { extensionLoader } from "../../extensions/extension-loader";
import { appEventBus } from "../../common/event-bus"; import { appEventBus } from "../../common/event-bus";
import whatInput from 'what-input'; import whatInput from 'what-input';
@ -75,10 +73,7 @@ export class App extends React.Component {
renderExtensionRoutes() { renderExtensionRoutes() {
return clusterPageRegistry.getItems().map(({ components: { Page }, exact, routePath }) => { return clusterPageRegistry.getItems().map(({ components: { Page }, exact, routePath }) => {
const Component = () => { return <Route key={routePath} path={routePath} exact={exact} component={Page}/>
return <Page/>
};
return <Route key={routePath} path={routePath} exact={exact} component={Component}/>
}) })
} }

View File

@ -14,7 +14,7 @@ import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { autobind, cssNames, IClassName } from "../../utils"; import { autobind, cssNames, IClassName } from "../../utils";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { isActiveRoute, navigate } from "../../navigation"; import { navigate, navigation } from "../../navigation";
import { addClusterURL } from "../+add-cluster"; import { addClusterURL } from "../+add-cluster";
import { clusterSettingsURL } from "../+cluster-settings"; import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page"; import { landingURL } from "../+landing-page";
@ -22,8 +22,7 @@ import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterViewURL } from "./cluster-view.route"; import { clusterViewURL } from "./cluster-view.route";
import { globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries"; import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries";
import { compile } from "path-to-regexp";
interface Props { interface Props {
className?: IClassName; className?: IClassName;
@ -152,13 +151,15 @@ export class ClustersMenu extends React.Component<Props> {
{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { {globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => {
const registeredPage = globalPageRegistry.getByPageMenuTarget(target); const registeredPage = globalPageRegistry.getByPageMenuTarget(target);
if (!registeredPage) return; 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 ( return (
<Icon <Icon
key={routePath} key={pageUrl}
tooltip={title} tooltip={title}
active={isActiveRoute({ path: routePath, exact })} active={isActive}
onClick={() => navigate(compile(routePath)(target.params))} onClick={() => navigate(pageUrl)}
/> />
) )
})} })}

View File

@ -26,11 +26,10 @@ import { Network } from "../+network";
import { crdStore } from "../+custom-resources/crd.store"; import { crdStore } from "../+custom-resources/crd.store";
import { CrdList, crdResourcesRoute, crdRoute, crdURL } from "../+custom-resources"; import { CrdList, crdResourcesRoute, crdRoute, crdURL } from "../+custom-resources";
import { CustomResources } from "../+custom-resources/custom-resources"; import { CustomResources } from "../+custom-resources/custom-resources";
import { isActiveRoute } from "../../navigation"; import { isActiveRoute, navigation } from "../../navigation";
import { isAllowedResource } from "../../../common/rbac" import { isAllowedResource } from "../../../common/rbac"
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { clusterPageMenuRegistry, clusterPageRegistry } from "../../../extensions/registries"; import { clusterPageMenuRegistry, clusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries";
import { compile } from "path-to-regexp";
const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false }); const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false });
type SidebarContextValue = { type SidebarContextValue = {
@ -195,15 +194,14 @@ export class Sidebar extends React.Component<Props> {
{clusterPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { {clusterPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => {
const registeredPage = clusterPageRegistry.getByPageMenuTarget(target); const registeredPage = clusterPageRegistry.getByPageMenuTarget(target);
if (!registeredPage) return; if (!registeredPage) return;
const { routePath, exact } = registeredPage; const { extensionId, id: pageId } = registeredPage;
const url = compile(routePath)(target.params) const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params });
const isActive = pageUrl === navigation.location.pathname;
return ( return (
<SidebarNavItem <SidebarNavItem
key={url} key={pageUrl} url={pageUrl}
url={url} text={title} icon={<Icon/>}
text={title} isActive={isActive}
icon={<Icon/>}
isActive={isActiveRoute({ path: routePath, exact })}
/> />
) )
})} })}

View File

@ -22,8 +22,12 @@ export function navigate(location: LocationDescriptor) {
} }
} }
export function matchParams<P>(route: string | string[] | RouteProps) {
return matchPath<P>(navigation.location.pathname, route);
}
export function isActiveRoute(route: string | string[] | RouteProps): boolean { export function isActiveRoute(route: string | string[] | RouteProps): boolean {
return !!matchPath(navigation.location.pathname, route); return !!matchParams(route);
} }
// common params for all pages // common params for all pages