diff --git a/extensions/example-extension/page.tsx b/extensions/example-extension/page.tsx index a49faa4c39..3d98f106c6 100644 --- a/extensions/example-extension/page.tsx +++ b/extensions/example-extension/page.tsx @@ -1,43 +1,61 @@ -import { Component, LensRendererExtension, Navigation } from "@k8slens/extensions"; +import { Component, Interface, K8sApi, LensRendererExtension } from "@k8slens/extensions"; import React from "react"; -import path from "path"; import { observer } from "mobx-react"; import { CoffeeDoodle } from "react-open-doodles"; -export const exampleId = Navigation.createPageParam({ - name: "exampleId", - defaultValue: "demo", -}); - -export function ExampleIcon(props: Component.IconProps) { - return ; +export interface ExamplePageProps extends Interface.PageComponentProps { + extension: LensRendererExtension; // provided in "./renderer.tsx" } +export interface ExamplePageParams { + exampleId: string; + selectedNamespaces: K8sApi.Namespace[]; +} + +export const namespaceStore = K8sApi.apiManager.getStore(K8sApi.namespacesApi); + @observer -export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> { +export class ExamplePage extends React.Component { + async componentDidMount() { + await namespaceStore.loadAll(); + } + deactivate = () => { const { extension } = this.props; extension.disable(); }; + renderSelectedNamespaces() { + const { selectedNamespaces } = this.props.params; + + return ( +
+ {selectedNamespaces.get().map(ns => { + const name = ns.getName(); + + return ; + })} +
+ ); + } + render() { - const exampleName = exampleId.get(); - const doodleStyle = { - width: "200px" - }; + const { exampleId } = this.props.params; return (
-
+
+ +
-

Hello from Example extension!

-

File: {__filename}

-

Location: {location.href}

+
Hello from Example extension!
+
Location: {location.href}
+
Namespaces: {this.renderSelectedNamespaces()}

exampleId.set("secret")}>Show secret button - {exampleName === "secret" && ( + {exampleId.get() === "secret" && ( )}

diff --git a/extensions/example-extension/renderer.tsx b/extensions/example-extension/renderer.tsx index 78fb369f6f..97cda5cc62 100644 --- a/extensions/example-extension/renderer.tsx +++ b/extensions/example-extension/renderer.tsx @@ -1,47 +1,45 @@ -import { LensRendererExtension } from "@k8slens/extensions"; -import { ExampleIcon, ExamplePage } from "./page"; +import { Component, Interface, K8sApi, LensRendererExtension } from "@k8slens/extensions"; +import { ExamplePage, ExamplePageParams, namespaceStore } from "./page"; import React from "react"; +import path from "path"; export default class ExampleExtension extends LensRendererExtension { - clusterPages = [ + clusterPages: Interface.PageRegistration[] = [ { - id: "example", - title: "Example Extension", components: { - Page: () => , + Page: (props: Interface.PageComponentProps) => { + return ; + }, }, params: { - // setup param "exampleId" with default value "demo" - // could be also {[paramName: string]: UrlParam} for advanced use-cases (custom parse/stringify) - exampleId: "demo" + // setup basic param "exampleId" with default value "demo" + exampleId: "demo", + + // setup advanced multi-values param "selectedNamespaces" with custom parsing/stringification + selectedNamespaces: { + defaultValueStringified: ["default", "kube-system"], + multiValues: true, + parse(values: string[]) { // from URL + return values.map(name => namespaceStore.getByName(name)).filter(Boolean); + }, + stringify(values: K8sApi.Namespace[]) { // to URL + return values.map(namespace => namespace.getName()); + }, + } } } ]; - clusterPageMenus = [ + clusterPageMenus: Interface.PageMenuRegistration[] = [ { title: "Example extension", components: { Icon: ExampleIcon, }, - target: { - pageId: "example", - params: { - exampleId: "demo-sample-2" - }, - }, - }, - { - title: "Example secret page", - components: { - Icon: ExampleIcon, - }, - target: { - pageId: "example", - params: { - exampleId: "secret" - }, - }, }, ]; } + +export function ExampleIcon(props: Component.IconProps) { + return ; +} diff --git a/src/extensions/interfaces/registrations.ts b/src/extensions/interfaces/registrations.ts index a2ebb10290..aea788233a 100644 --- a/src/extensions/interfaces/registrations.ts +++ b/src/extensions/interfaces/registrations.ts @@ -3,6 +3,6 @@ export type { ClusterFeatureRegistration, ClusterFeatureComponents } from "../re export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry"; 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 { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } 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/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index 6f0d333681..55ba3d6d64 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -1,4 +1,4 @@ -import { getExtensionPageUrl, globalPageRegistry, PageTargetParams } from "../page-registry"; +import { getExtensionPageUrl, globalPageRegistry, PageParams } from "../page-registry"; import { LensExtension } from "../../lens-extension"; import React from "react"; @@ -50,7 +50,7 @@ describe("getPageUrl", () => { }); it("gets page url with custom params", () => { - const params: PageTargetParams = { test1: "one", test2: "2" }; + const params: PageParams = { test1: "one", test2: "2" }; const searchParams = new URLSearchParams(params); const pageUrl = getExtensionPageUrl({ extensionId: ext.name, pageId: "page-with-params", params }); diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts index a85c31d4bb..052a483cbd 100644 --- a/src/extensions/registries/base-registry.ts +++ b/src/extensions/registries/base-registry.ts @@ -2,27 +2,33 @@ import { action, observable } from "mobx"; import { LensExtension } from "../lens-extension"; -export class BaseRegistry { - private items = observable([], { deep: false }); +export class BaseRegistry { + private items = observable.map(); - getItems(): T[] { - return this.items.toJS(); + getItems(): I[] { + return Array.from(this.items.values()); } - add(items: T | T[], ext?: LensExtension): () => void; // allow method overloading with required "ext" @action - add(items: T | T[]) { + add(items: T | T[], extension?: LensExtension) { const itemArray = [items].flat() as T[]; - this.items.push(...itemArray); + itemArray.forEach(item => { + this.items.set(item, this.getRegisteredItem(item, extension)); + }); return () => this.remove(...itemArray); } + // eslint-disable-next-line unused-imports/no-unused-vars-ts + protected getRegisteredItem(item: T, extension?: LensExtension): I { + return item as any; + } + @action remove(...items: T[]) { items.forEach(item => { - this.items.remove(item); // works because of {deep: false}; + this.items.delete(item); }); } } diff --git a/src/extensions/registries/page-menu-registry.ts b/src/extensions/registries/page-menu-registry.ts index 17ff094670..8fe5b68b3b 100644 --- a/src/extensions/registries/page-menu-registry.ts +++ b/src/extensions/registries/page-menu-registry.ts @@ -21,7 +21,7 @@ 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(menuItem => { diff --git a/src/extensions/registries/page-registry.ts b/src/extensions/registries/page-registry.ts index f3ae87f92b..344b196cd6 100644 --- a/src/extensions/registries/page-registry.ts +++ b/src/extensions/registries/page-registry.ts @@ -1,43 +1,51 @@ // Extensions-api -> Custom page registration -import type React from "react"; -import { action } from "mobx"; + +import React from "react"; +import { observer } from "mobx-react"; import { BaseRegistry } from "./base-registry"; import { LensExtension, sanitizeExtensionName } from "../lens-extension"; -import { PageParam } from "../../renderer/navigation/page-param"; -import logger from "../../main/logger"; +import { isPageParamInit, PageParam, PageParamInit } from "../../renderer/navigation/page-param"; +import { createPageParam } from "../../renderer/navigation/helpers"; export interface PageRegistration { /** - * Page-id, part of of extension's page url, must be unique within same extension + * Page ID, part of extension's page url, must be unique within same extension * When not provided, first registered page without "id" would be used for page-menus without target.pageId for same extension */ id?: string; + params?: PageParams; components: PageComponents; - /** - * Registered page params. - * Used to generate final page url when provided in getExtensionPageUrl()-helper. - * Advanced usage: provide `UrlParam` as values to customize parsing/stringification from/to URL. - */ - params?: PageTargetParams; } +// exclude "name" field since provided as key in page.params +export type ExtensionPageParamInit = Omit; + export interface PageComponents { Page: React.ComponentType; } -export interface PageTarget

{ +export interface PageTarget

{ extensionId?: string; pageId?: string; params?: P; } -export interface PageTargetParams { +export interface PageParams { [paramName: string]: V; } -export interface RegisteredPage extends PageRegistration { +export interface PageComponentProps

{ + params?: { + [N in keyof P]: PageParam; + } +} + +export interface RegisteredPage { + id: string; extensionId: string; url: string; // registered extension's page URL (without page params) + params: PageParams; // normalized params + components: PageComponents; // normalized components } export function getExtensionPageUrl(target: PageTarget): string { @@ -54,16 +62,11 @@ export function getExtensionPageUrl(target: PageTarget): string { if (registeredPage?.params) { Object.entries(registeredPage.params).forEach(([name, param]) => { - const targetParamValue = targetParams[name]; - - if (param instanceof PageParam) { - pageUrl.searchParams.set(name, param.stringify(targetParamValue)); + const paramValue = param.stringify(targetParams[name]); + if (param.init.skipEmpty && param.isEmpty(paramValue)) { + pageUrl.searchParams.delete(name); } else { - const value = String(targetParamValue ?? param); - - if (value) { - pageUrl.searchParams.set(name, value); - } + pageUrl.searchParams.set(name, paramValue); } }); } @@ -71,31 +74,41 @@ export function getExtensionPageUrl(target: PageTarget): string { return pageUrl.href.replace(pageUrl.origin, ""); } -export class PageRegistry extends BaseRegistry { - @action - add(pages: PageRegistration | PageRegistration[], extension: LensExtension) { - try { - const items = [pages].flat().map(page => this.registerPage(page, extension)); +export class PageRegistry extends BaseRegistry { + protected getRegisteredItem(page: PageRegistration, ext: LensExtension): RegisteredPage { + const { id: pageId } = page; + const extensionId = ext.name; + const params = this.normalizeParams(page.params); + const components = this.normalizeComponents(page.components, params); + const url = getExtensionPageUrl({ extensionId, pageId }); - return super.add(items); - } catch (error) { - return Function; // no-op - } + return { + id: pageId, extensionId, params, components, url, + }; } - registerPage(page: PageRegistration, ext: LensExtension): RegisteredPage { - try { - const { id: pageId } = page; - const extensionId = ext.name; + protected normalizeComponents(components: PageComponents, params?: PageParams): PageComponents { + if (params) { + const { Page } = components; - return { - ...page, - extensionId, - url: getExtensionPageUrl({ extensionId, pageId }), - }; - } catch (error) { - logger.error(`Failed to register page: ${error}`, { error }); + components.Page = observer((props: object) => React.createElement(Page, { params, ...props })); } + + return components; + } + + protected normalizeParams(params?: PageParams): PageParams { + if (!params) { + return; + } + Object.entries(params).forEach(([name, value]) => { + const paramInit: PageParamInit = isPageParamInit(value) ? value : { name, defaultValue: value }; + + paramInit.name ??= name; + params[name] = createPageParam(paramInit); + }); + + return params as PageParams; } getByPageTarget(target: PageTarget): RegisteredPage | null { diff --git a/src/extensions/renderer-api/navigation.ts b/src/extensions/renderer-api/navigation.ts index 2e08eb48c1..6e953920e1 100644 --- a/src/extensions/renderer-api/navigation.ts +++ b/src/extensions/renderer-api/navigation.ts @@ -1,4 +1,4 @@ -export { PageParam, PageParamInit } from "../../renderer/navigation/page-param"; -export { navigate, isActiveRoute, createPageParam } from "../../renderer/navigation"; +export { PageParamInit, PageParam } from "../../renderer/navigation/page-param"; +export { navigate, isActiveRoute, createPageParam } from "../../renderer/navigation/helpers"; export { hideDetails, showDetails, getDetailsUrl } from "../../renderer/components/kube-object/kube-object-details"; export { IURLParams } from "../../common/utils/buildUrl"; diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index 68d4773540..030467f653 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -46,8 +46,8 @@ export class ApiManager { }); } - getStore(api: string | KubeApi): KubeObjectStore { - return this.stores.get(this.resolveApi(api)); + getStore(api: string | KubeApi): S { + return this.stores.get(this.resolveApi(api)) as S; } } diff --git a/src/renderer/navigation/events.ts b/src/renderer/navigation/events.ts new file mode 100644 index 0000000000..971465706d --- /dev/null +++ b/src/renderer/navigation/events.ts @@ -0,0 +1,31 @@ +import { ipcRenderer } from "electron"; +import { reaction } from "mobx"; +import { getMatchedClusterId, navigate } from "./helpers"; +import { broadcastMessage, subscribeToBroadcast } from "../../common/ipc"; +import logger from "../../main/logger"; + +export function bindEvents() { + if (!ipcRenderer) { + return; + } + + if (process.isMainFrame) { + // Keep track of active cluster-id for handling IPC/menus/etc. + reaction(() => getMatchedClusterId(), clusterId => { + broadcastMessage("cluster-view:current-id", clusterId); + }, { + fireImmediately: true + }); + } + + // Handle navigation via IPC (e.g. from top menu) + subscribeToBroadcast("renderer:navigate", (event, url: string) => { + logger.info(`[IPC]: ${event.type} ${JSON.stringify(url)}`, event); + navigate(url); + }); + + // Reload dashboard window + subscribeToBroadcast("renderer:reload", () => { + location.reload(); + }); +} \ No newline at end of file diff --git a/src/renderer/navigation/helpers.ts b/src/renderer/navigation/helpers.ts new file mode 100644 index 0000000000..33ff1dd416 --- /dev/null +++ b/src/renderer/navigation/helpers.ts @@ -0,0 +1,36 @@ +import type { LocationDescriptor } from "history"; +import { matchPath, RouteProps } from "react-router"; +import { PageParam, PageParamInit } from "./page-param"; +import { clusterViewRoute, IClusterViewRouteParams } from "../components/cluster-manager/cluster-view.route"; +import { navigation } from "./history"; + +export function navigate(location: LocationDescriptor) { + const currentLocation = navigation.getPath(); + + navigation.push(location); + + if (currentLocation === navigation.getPath()) { + navigation.goBack(); // prevent sequences of same url in history + } +} + +export function createPageParam(init: PageParamInit) { + return new PageParam(init, navigation); +} + +export function matchRoute

(route: string | string[] | RouteProps) { + return matchPath

(navigation.location.pathname, route); +} + +export function isActiveRoute(route: string | string[] | RouteProps): boolean { + return !!matchRoute(route); +} + +export function getMatchedClusterId(): string { + const matched = matchPath(navigation.location.pathname, { + exact: true, + path: clusterViewRoute.path + }); + + return matched?.params.clusterId; +} \ No newline at end of file diff --git a/src/renderer/navigation/history.ts b/src/renderer/navigation/history.ts new file mode 100644 index 0000000000..522cb86b21 --- /dev/null +++ b/src/renderer/navigation/history.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from "electron"; +import { createBrowserHistory, createMemoryHistory } from "history"; +import { createObservableHistory } from "mobx-observable-history"; + +export const history = ipcRenderer ? createBrowserHistory() : createMemoryHistory(); +export const navigation = createObservableHistory(history); diff --git a/src/renderer/navigation/index.ts b/src/renderer/navigation/index.ts index 8e4bee117d..70959c2dbd 100644 --- a/src/renderer/navigation/index.ts +++ b/src/renderer/navigation/index.ts @@ -1,70 +1,8 @@ -// Navigation helpers +// Navigation (renderer) -import { ipcRenderer } from "electron"; -import { reaction } from "mobx"; -import { matchPath, RouteProps } from "react-router"; -import { createObservableHistory } from "mobx-observable-history"; -import { createBrowserHistory, createMemoryHistory, LocationDescriptor } from "history"; -import { broadcastMessage, subscribeToBroadcast } from "../../common/ipc"; -import { PageParam, PageParamInit } from "./page-param"; -import { clusterViewRoute, IClusterViewRouteParams } from "../components/cluster-manager/cluster-view.route"; -import logger from "../../main/logger"; +import { bindEvents } from "./events"; -export let history = ipcRenderer ? createBrowserHistory() : createMemoryHistory(); -export let navigation = createObservableHistory(history); +export * from "./history"; +export * from "./helpers"; -export function navigate(location: LocationDescriptor) { - const currentLocation = navigation.getPath(); - - navigation.push(location); - - if (currentLocation === navigation.getPath()) { - navigation.goBack(); // prevent sequences of same url in history - } -} - -export function matchParams

(route: string | string[] | RouteProps) { - return matchPath

(navigation.location.pathname, route); -} - -export function isActiveRoute(route: string | string[] | RouteProps): boolean { - return !!matchParams(route); -} - -export function getMatchedClusterId(): string { - const matched = matchPath(navigation.location.pathname, { - exact: true, - path: clusterViewRoute.path - }); - - return matched?.params.clusterId; -} - -export function createPageParam(init: PageParamInit) { - return new PageParam(init, navigation); -} - -if (ipcRenderer) { - history = createBrowserHistory(); - navigation = createObservableHistory(history); - - if (process.isMainFrame) { - // Keep track of active cluster-id for handling IPC/menus/etc. - reaction(() => getMatchedClusterId(), clusterId => { - broadcastMessage("cluster-view:current-id", clusterId); - }, { - fireImmediately: true - }); - } - - // Handle navigation via IPC (e.g. from top menu) - subscribeToBroadcast("renderer:navigate", (event, url: string) => { - logger.info(`[IPC]: ${event.type} ${JSON.stringify(url)}`, event); - navigate(url); - }); - - // Reload dashboard window - subscribeToBroadcast("renderer:reload", () => { - location.reload(); - }); -} +bindEvents(); \ No newline at end of file diff --git a/src/renderer/navigation/page-param.ts b/src/renderer/navigation/page-param.ts index 0577116294..5ab31f2d73 100644 --- a/src/renderer/navigation/page-param.ts +++ b/src/renderer/navigation/page-param.ts @@ -1,24 +1,25 @@ -// Manage observable URL-param via location.search +// Manage observable URL-param from document.location.search import { IObservableHistory } from "mobx-observable-history"; export interface PageParamInit { name: string; isSystem?: boolean; defaultValue?: V; + defaultValueStringified?: string | string[]; // serialized version of "defaultValue" multiValues?: boolean; // false == by default multiValueSep?: string; // joining multiple values with separator, default: "," skipEmpty?: boolean; // skip empty value(s), e.g. "?param=", default: true - parse?(values: string[]): V; // deserialize from URL - stringify?(values: V): string | string[]; // serialize params to URL + parse?(value: string[]): V; // deserialize from URL + stringify?(value: V): string | string[]; // serialize params to URL } -export class PageParam { +export class PageParam { static SYSTEM_PREFIX = "lens-"; readonly name: string; protected urlName: string; - constructor(private init: PageParamInit, private history: IObservableHistory) { + constructor(readonly init: PageParamInit, protected history: IObservableHistory) { const { isSystem, name, skipEmpty = true } = init; this.name = name; @@ -28,7 +29,7 @@ export class PageParam { this.urlName = `${isSystem ? PageParam.SYSTEM_PREFIX : ""}${name}`; } - isEmpty(value: V) { + isEmpty(value: V | any) { return [value].flat().every(value => value == "" || value == null); } @@ -59,12 +60,10 @@ export class PageParam { } get(): V { - const { history, urlName } = this; - const { multiValueSep, defaultValue, skipEmpty } = this.init; - const value = this.parse(history.searchParams.getAsArray(urlName, multiValueSep)); + const value = this.parse(this.getRaw()); - if (skipEmpty && this.isEmpty(value)) { - return defaultValue; + if (this.init.skipEmpty && this.isEmpty(value)) { + return this.getDefaultValue(); } return value; @@ -76,12 +75,29 @@ export class PageParam { this.history.merge({ search }, replaceHistory); } - getDefaultValue(){ - return this.init.defaultValue; + setRaw(value: string | string[]) { + const { history, urlName } = this; + const { multiValues, multiValueSep, skipEmpty } = this.init; + const paramValue = multiValues ? [value].flat().join(multiValueSep) : String(value); + + if (skipEmpty && this.isEmpty(paramValue)) { + history.searchParams.delete(urlName); + } else { + history.searchParams.set(urlName, paramValue); + } } - isDefault() { - return this.get() === this.getDefaultValue(); + getRaw(): string[] { + const { history, urlName } = this; + const { multiValueSep } = this.init; + + return history.searchParams.getAsArray(urlName, multiValueSep); + } + + getDefaultValue() { + const { defaultValue, defaultValueStringified } = this.init; + + return defaultValueStringified ? this.parse([defaultValueStringified].flat()) : defaultValue; } clear() { @@ -113,3 +129,12 @@ export class PageParam { }; } } + +export function isPageParamInit(paramInit: PageParamInit | any = {}): paramInit is PageParamInit { + const init: PageParamInit = paramInit; + + return [ + init.defaultValue !== undefined || init.defaultValueStringified !== undefined, + typeof init.parse === "function" && typeof init.stringify === "function", + ].some(Boolean); +}