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:
parent
596e60a6f3
commit
13dbdac2f2
@ -6,7 +6,7 @@ export default class SupportPageMainExtension extends LensMainExtension {
|
||||
parentId: "help",
|
||||
label: "Support",
|
||||
click: () => {
|
||||
this.navigate("/support");
|
||||
this.navigate();
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -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: (
|
||||
<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/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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<P extends object>(pageId?: string, params?: P, frameId?: number) {
|
||||
const windowManager = WindowManager.getInstance<WindowManager>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<P extends object>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
// Base class for extensions-api registries
|
||||
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 });
|
||||
|
||||
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])
|
||||
|
||||
@ -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<P extends object = any> {
|
||||
extensionId?: string;
|
||||
params?: object;
|
||||
pageId?: string;
|
||||
params?: P;
|
||||
}
|
||||
|
||||
export interface PageMenuRegistration {
|
||||
@ -22,19 +21,19 @@ export interface PageMenuComponents {
|
||||
Icon: React.ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
export class PageMenuRegistry<T extends PageMenuRegistration> extends BaseRegistry<T> {
|
||||
|
||||
export class PageMenuRegistry extends BaseRegistry<PageMenuRegistration, Required<PageMenuRegistration>> {
|
||||
@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<PageMenuRegistration>();
|
||||
export const clusterPageMenuRegistry = new PageMenuRegistry<PageMenuRegistration>();
|
||||
export const globalPageMenuRegistry = new PageMenuRegistry();
|
||||
export const clusterPageMenuRegistry = new PageMenuRegistry();
|
||||
|
||||
@ -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<any>;
|
||||
}
|
||||
|
||||
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<P extends object>({ extensionId, pageId = "", params }: PageMenuTarget<P>): 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<T extends PageRegistration> extends BaseRegistry<T> {
|
||||
|
||||
export class PageRegistry extends BaseRegistry<PageRegistration, RegisteredPage> {
|
||||
@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<P extends object>({ 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<PageRegistration>();
|
||||
export const clusterPageRegistry = new PageRegistry<PageRegistration>();
|
||||
export const globalPageRegistry = new PageRegistry();
|
||||
export const clusterPageRegistry = new PageRegistry();
|
||||
|
||||
@ -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 <Page/>
|
||||
};
|
||||
return <Route key={routePath} path={routePath} exact={exact} component={Component}/>
|
||||
return <Route key={routePath} path={routePath} exact={exact} component={Page}/>
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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<Props> {
|
||||
{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 (
|
||||
<Icon
|
||||
key={routePath}
|
||||
key={pageUrl}
|
||||
tooltip={title}
|
||||
active={isActiveRoute({ path: routePath, exact })}
|
||||
onClick={() => navigate(compile(routePath)(target.params))}
|
||||
active={isActive}
|
||||
onClick={() => navigate(pageUrl)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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<SidebarContextValue>({ pinned: false });
|
||||
type SidebarContextValue = {
|
||||
@ -195,15 +194,14 @@ export class Sidebar extends React.Component<Props> {
|
||||
{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 (
|
||||
<SidebarNavItem
|
||||
key={url}
|
||||
url={url}
|
||||
text={title}
|
||||
icon={<Icon/>}
|
||||
isActive={isActiveRoute({ path: routePath, exact })}
|
||||
key={pageUrl} url={pageUrl}
|
||||
text={title} icon={<Icon/>}
|
||||
isActive={isActive}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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 {
|
||||
return !!matchPath(navigation.location.pathname, route);
|
||||
return !!matchParams(route);
|
||||
}
|
||||
|
||||
// common params for all pages
|
||||
|
||||
Loading…
Reference in New Issue
Block a user