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

Hello from Example extension!

-

File: {__filename}

- +
+ {selectedNamespaces.get().map(ns => { + const name = ns.getName(); + + return ; + })} +
+ ); + } + + render() { + const { exampleId } = this.props.params; + + return ( +
+
+ +
+ +
Hello from Example extension!
+
Location: {location.href}
+
Namespaces: {this.renderSelectedNamespaces()}
+ +

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

); } diff --git a/extensions/example-extension/renderer.tsx b/extensions/example-extension/renderer.tsx index 1a7d473ecd..76a00c3bf6 100644 --- a/extensions/example-extension/renderer.tsx +++ b/extensions/example-extension/renderer.tsx @@ -1,25 +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 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.ClusterPageMenuRegistration[] = [ { - target: { pageId: "example", params: {} }, - title: "Example Extension", + title: "Example extension", components: { Icon: ExampleIcon, - } - } + }, + }, ]; } + +export function ExampleIcon(props: Component.IconProps) { + return ; +} diff --git a/package.json b/package.json index 0d17a6cfbf..d5c6629bf4 100644 --- a/package.json +++ b/package.json @@ -198,20 +198,6 @@ "@hapi/call": "^8.0.0", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.12.0", - "@types/crypto-js": "^3.1.47", - "@types/electron-window-state": "^2.0.34", - "@types/fs-extra": "^9.0.1", - "@types/http-proxy": "^1.17.4", - "@types/js-yaml": "^3.12.4", - "@types/jsdom": "^16.2.4", - "@types/jsonpath": "^0.2.0", - "@types/lodash": "^4.14.155", - "@types/marked": "^0.7.4", - "@types/mock-fs": "^4.10.0", - "@types/node": "^12.12.45", - "@types/proper-lockfile": "^4.1.1", - "@types/react-beautiful-dnd": "^13.0.0", - "@types/tar": "^4.0.4", "array-move": "^3.0.0", "await-lock": "^2.1.0", "chalk": "^4.1.0", @@ -235,12 +221,16 @@ "md5-file": "^5.0.0", "mobx": "^5.15.7", "mobx-observable-history": "^1.0.3", + "mobx-react": "^6.2.2", "mock-fs": "^4.12.0", "node-pty": "^0.9.0", "npm": "^6.14.8", "openid-client": "^3.15.2", "path-to-regexp": "^6.1.0", "proper-lockfile": "^4.1.1", + "react": "^17.0.1", + "react-dom": "^17.0.1", + "react-router": "^5.2.0", "request": "^2.88.2", "request-promise-native": "^1.0.8", "semver": "^7.3.2", @@ -287,6 +277,7 @@ "@types/http-proxy": "^1.17.4", "@types/jest": "^25.2.3", "@types/js-yaml": "^3.12.4", + "@types/jsdom": "^16.2.4", "@types/jsonpath": "^0.2.0", "@types/lodash": "^4.14.155", "@types/marked": "^0.7.4", @@ -298,9 +289,10 @@ "@types/npm": "^2.0.31", "@types/progress-bar-webpack-plugin": "^2.1.0", "@types/proper-lockfile": "^4.1.1", - "@types/react": "^16.9.35", + "@types/react": "^17.0.0", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-router-dom": "^5.1.5", + "@types/react-dom": "^17.0.0", + "@types/react-router-dom": "^5.1.6", "@types/react-select": "^3.0.13", "@types/react-window": "^1.8.2", "@types/request": "^2.48.5", @@ -309,6 +301,7 @@ "@types/sharp": "^0.26.0", "@types/shelljs": "^0.8.8", "@types/spdy": "^3.4.4", + "@types/tar": "^4.0.4", "@types/tcp-port-used": "^1.0.0", "@types/tempy": "^0.3.0", "@types/terser-webpack-plugin": "^3.0.0", @@ -352,7 +345,6 @@ "jest-mock-extended": "^1.0.10", "make-plural": "^6.2.1", "mini-css-extract-plugin": "^0.9.0", - "mobx-react": "^6.2.2", "moment": "^2.26.0", "node-loader": "^0.6.0", "node-sass": "^4.14.1", @@ -362,11 +354,8 @@ "prettier": "^2.2.0", "progress-bar-webpack-plugin": "^2.1.0", "raw-loader": "^4.0.1", - "react": "^16.14.0", "react-beautiful-dnd": "^13.0.0", - "react-dom": "^16.13.1", "react-refresh": "^0.9.0", - "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "react-select": "^3.1.0", "react-window": "^1.8.5", @@ -382,7 +371,7 @@ "typedoc": "0.17.0-3", "typedoc-plugin-markdown": "^2.4.0", "typeface-roboto": "^0.0.75", - "typescript": "^4.0.2", + "typescript": "4.0.2", "url-loader": "^4.1.0", "webpack": "^4.44.2", "webpack-cli": "^3.3.11", diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index b1006b5f58..582135d7f0 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -14,7 +14,6 @@ export * from "./splitArray"; export * from "./saveToAppFiles"; export * from "./singleton"; export * from "./openExternal"; -export * from "./rectify-array"; export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./tar"; diff --git a/src/common/utils/rectify-array.ts b/src/common/utils/rectify-array.ts deleted file mode 100644 index 0e4d701114..0000000000 --- a/src/common/utils/rectify-array.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * rectify condences the single item or array of T type, to an array. - * @param items either one item or an array of items - * @returns a list of items - */ -export function rectify(items: T | T[]): T[] { - return Array.isArray(items) ? items : [items]; -} diff --git a/src/extensions/cluster-feature.ts b/src/extensions/cluster-feature.ts index 625f2b5973..36a2f0bfb8 100644 --- a/src/extensions/cluster-feature.ts +++ b/src/extensions/cluster-feature.ts @@ -41,7 +41,7 @@ export abstract class ClusterFeature { /** * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be installed. The implementation * of this method should install kubernetes resources using the applyResources() method, or by directly accessing the kubernetes api (K8sApi) - * + * * @param cluster the cluster that the feature is to be installed on */ abstract async install(cluster: Cluster): Promise; @@ -49,7 +49,7 @@ export abstract class ClusterFeature { /** * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be upgraded. The implementation * of this method should upgrade the kubernetes resources already installed, if relevant to the feature - * + * * @param cluster the cluster that the feature is to be upgraded on */ abstract async upgrade(cluster: Cluster): Promise; @@ -57,26 +57,26 @@ export abstract class ClusterFeature { /** * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be uninstalled. The implementation * of this method should uninstall kubernetes resources using the kubernetes api (K8sApi) - * + * * @param cluster the cluster that the feature is to be uninstalled from */ abstract async uninstall(cluster: Cluster): Promise; /** * to be implemented in the derived class, this method is called periodically by Lens to determine details about the feature's current status. The implementation - * of this method should provide the current status information. The currentVersion and latestVersion fields may be displayed by Lens in describing the feature. + * of this method should provide the current status information. The currentVersion and latestVersion fields may be displayed by Lens in describing the feature. * The installed field should be set to true if the feature has been installed, otherwise false. Also, Lens relies on the canUpgrade field to determine if the feature * can be upgraded so the implementation should set the canUpgrade field according to specific rules for the feature, if relevant. - * + * * @param cluster the cluster that the feature may be installed on - * + * * @return a promise, resolved with the updated ClusterFeatureStatus */ abstract async updateStatus(cluster: Cluster): Promise; /** * this is a helper method that conveniently applies kubernetes resources to the cluster. - * + * * @param cluster the cluster that the resources are to be applied to * @param resourceSpec as a string type this is a folder path that is searched for files specifying kubernetes resources. The files are read and if any of the resource * files are templated, the template parameters are filled using the templateContext field (See renderTemplate() method). Finally the resources are applied to the @@ -101,9 +101,9 @@ export abstract class ClusterFeature { /** * this is a helper method that conveniently reads kubernetes resource files into a string array. It also fills templated resource files with the template parameter values * specified by the templateContext field. Templated files must end with the extension '.hb' and the template syntax must be compatible with handlebars.js - * + * * @param folderPath this is a folder path that is searched for files defining kubernetes resources. - * + * * @return an array of strings, each string being the contents of a resource file found in the folder path. This can be passed directly to applyResources() */ protected renderTemplates(folderPath: string): string[] { diff --git a/src/extensions/interfaces/registrations.ts b/src/extensions/interfaces/registrations.ts index a2ebb10290..47c63062ea 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 { PageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; +export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; +export type { PageMenuRegistration, ClusterPageMenuRegistration, 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-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index b6c00d8353..25afaa76fe 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -1,14 +1,13 @@ -import type { AppPreferenceRegistration, ClusterFeatureRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries"; +import type { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries"; import type { Cluster } from "../main/cluster"; import { LensExtension } from "./lens-extension"; import { getExtensionPageUrl } from "./registries/page-registry"; - export class LensRendererExtension extends LensExtension { globalPages: PageRegistration[] = []; clusterPages: PageRegistration[] = []; globalPageMenus: PageMenuRegistration[] = []; - clusterPageMenus: PageMenuRegistration[] = []; + clusterPageMenus: ClusterPageMenuRegistration[] = []; kubeObjectStatusTexts: KubeObjectStatusRegistration[] = []; appPreferences: AppPreferenceRegistration[] = []; clusterFeatures: ClusterFeatureRegistration[] = []; diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index 78db140ed7..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 } from "../page-registry"; +import { getExtensionPageUrl, globalPageRegistry, PageParams } from "../page-registry"; import { LensExtension } from "../../lens-extension"; import React from "react"; @@ -17,6 +17,16 @@ describe("getPageUrl", () => { isBundled: false, isEnabled: true }); + globalPageRegistry.add({ + id: "page-with-params", + components: { + Page: () => React.createElement("Page with params") + }, + params: { + test1: "test1-default", + test2: "" // no default value, just declaration + }, + }, ext); }); it("returns a page url for extension", () => { @@ -34,6 +44,24 @@ describe("getPageUrl", () => { it("adds / prefix", () => { expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "test" })).toBe("/extension/foo-bar/test"); }); + + it("normalize possible multi-slashes in page.id", () => { + expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "//test/" })).toBe("/extension/foo-bar/test"); + }); + + it("gets page url with custom params", () => { + const params: PageParams = { test1: "one", test2: "2" }; + const searchParams = new URLSearchParams(params); + const pageUrl = getExtensionPageUrl({ extensionId: ext.name, pageId: "page-with-params", params }); + + expect(pageUrl).toBe(`/extension/foo-bar/page-with-params?${searchParams}`); + }); + + it("gets page url with default custom params", () => { + const defaultPageUrl = getExtensionPageUrl({ extensionId: ext.name, pageId: "page-with-params", }); + + expect(defaultPageUrl).toBe(`/extension/foo-bar/page-with-params?test1=test1-default`); + }); }); describe("globalPageRegistry", () => { @@ -70,17 +98,17 @@ describe("globalPageRegistry", () => { ], ext); }); - describe("getByPageMenuTarget", () => { + describe("getByPageTarget", () => { it("matching to first registered page without id", () => { - const page = globalPageRegistry.getByPageMenuTarget({ extensionId: ext.name }); + const page = globalPageRegistry.getByPageTarget({ extensionId: ext.name }); expect(page.id).toEqual(undefined); expect(page.extensionId).toEqual(ext.name); - expect(page.routePath).toEqual(getExtensionPageUrl({ extensionId: ext.name })); + expect(page.url).toEqual(getExtensionPageUrl({ extensionId: ext.name })); }); it("returns matching page", () => { - const page = globalPageRegistry.getByPageMenuTarget({ + const page = globalPageRegistry.getByPageTarget({ pageId: "test-page", extensionId: ext.name }); @@ -89,7 +117,7 @@ describe("globalPageRegistry", () => { }); it("returns null if target not found", () => { - const page = globalPageRegistry.getByPageMenuTarget({ + const page = globalPageRegistry.getByPageTarget({ pageId: "wrong-page", extensionId: ext.name }); diff --git a/src/extensions/registries/base-registry.ts b/src/extensions/registries/base-registry.ts index 6d5485b32b..052a483cbd 100644 --- a/src/extensions/registries/base-registry.ts +++ b/src/extensions/registries/base-registry.ts @@ -1,29 +1,34 @@ // Base class for extensions-api registries import { action, observable } from "mobx"; import { LensExtension } from "../lens-extension"; -import { rectify } from "../../common/utils"; -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[]) { - const itemArray = rectify(items); + 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 8ccbc9cd6c..8fe5b68b3b 100644 --- a/src/extensions/registries/page-menu-registry.ts +++ b/src/extensions/registries/page-menu-registry.ts @@ -1,19 +1,13 @@ // Extensions-api -> Register page menu items import type { IconProps } from "../../renderer/components/icon"; import type React from "react"; +import type { PageTarget, RegisteredPage } from "./page-registry"; import { action } from "mobx"; import { BaseRegistry } from "./base-registry"; import { LensExtension } from "../lens-extension"; -import { RegisteredPage } from "./page-registry"; - -export interface PageMenuTarget

{ - extensionId?: string; - pageId?: string; - params?: P; -} export interface PageMenuRegistration { - target?: PageMenuTarget; + target?: PageTarget; title: React.ReactNode; components: PageMenuComponents; } @@ -27,9 +21,9 @@ export interface PageMenuComponents { Icon: React.ComponentType; } -export class GlobalPageMenuRegistry extends BaseRegistry { +export class PageMenuRegistry extends BaseRegistry { @action - add(items: PageMenuRegistration[], ext: LensExtension) { + add(items: T[], ext: LensExtension) { const normalizedItems = items.map(menuItem => { menuItem.target = { extensionId: ext.name, @@ -43,33 +37,25 @@ export class GlobalPageMenuRegistry extends BaseRegistry { } } -export class ClusterPageMenuRegistry extends BaseRegistry { - @action - add(items: PageMenuRegistration[], ext: LensExtension) { - const normalizedItems = items.map(menuItem => { - menuItem.target = { - extensionId: ext.name, - ...(menuItem.target || {}), - }; - - return menuItem; - }); - - return super.add(normalizedItems); - } - +export class ClusterPageMenuRegistry extends PageMenuRegistry { getRootItems() { return this.getItems().filter((item) => !item.parentId); } getSubItems(parent: ClusterPageMenuRegistration) { - return this.getItems().filter((item) => item.parentId === parent.id && item.target.extensionId === parent.target.extensionId); + return this.getItems().filter((item) => ( + item.parentId === parent.id && + item.target.extensionId === parent.target.extensionId + )); } - getByPage(page: RegisteredPage) { - return this.getItems().find((item) => item.target?.pageId == page.id && item.target?.extensionId === page.extensionId); + getByPage({ id: pageId, extensionId }: RegisteredPage) { + return this.getItems().find((item) => ( + item.target.pageId == pageId && + item.target.extensionId === extensionId + )); } } -export const globalPageMenuRegistry = new GlobalPageMenuRegistry(); +export const globalPageMenuRegistry = new PageMenuRegistry(); export const clusterPageMenuRegistry = new ClusterPageMenuRegistry(); diff --git a/src/extensions/registries/page-registry.ts b/src/extensions/registries/page-registry.ts index 591dcba836..0ec6f27da0 100644 --- a/src/extensions/registries/page-registry.ts +++ b/src/extensions/registries/page-registry.ts @@ -1,93 +1,120 @@ // Extensions-api -> Custom page registration -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 React from "react"; +import { observer } from "mobx-react"; import { BaseRegistry } from "./base-registry"; import { LensExtension, sanitizeExtensionName } from "../lens-extension"; -import logger from "../../main/logger"; -import { rectify } from "../../common/utils"; +import { PageParam, PageParamInit } from "../../renderer/navigation/page-param"; +import { createPageParam } from "../../renderer/navigation/helpers"; export interface PageRegistration { /** - * 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) + * 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; - /** - * 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; + params?: PageParams; 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 -} +// exclude "name" field since provided as key in page.params +export type ExtensionPageParamInit = Omit; export interface PageComponents { Page: React.ComponentType; } -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.posix.join(extensionBaseUrl, pageId); - - if (params) { - return compile(extPageRoutePath)(params); // might throw error when required params not passed - } - - return extPageRoutePath; +export interface PageTarget

{ + extensionId?: string; + pageId?: string; + params?: P; } -export class PageRegistry extends BaseRegistry { - @action - add(items: PageRegistration | PageRegistration[], ext: LensExtension) { - const itemArray = rectify(items); - let registeredPages: RegisteredPage[] = []; +export interface PageParams { + [paramName: string]: V; +} - try { - registeredPages = itemArray.map(page => ({ - ...page, - extensionId: ext.name, - routePath: getExtensionPageUrl({ extensionId: ext.name, pageId: page.id }), - })); - } catch (err) { - logger.error(`[EXTENSION]: page-registration failed`, { - items, - extension: ext, - error: String(err), - }); +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 { + const { extensionId, pageId = "", params: targetParams = {} } = target; + + const pagePath = ["/extension", sanitizeExtensionName(extensionId), pageId] + .filter(Boolean) + .join("/").replace(/\/+/g, "/").replace(/\/$/, ""); // normalize multi-slashes (e.g. coming from page.id) + + const pageUrl = new URL(pagePath, `http://localhost`); + + // stringify params to matched target page + const registeredPage = globalPageRegistry.getByPageTarget(target) || clusterPageRegistry.getByPageTarget(target); + + if (registeredPage?.params) { + Object.entries(registeredPage.params).forEach(([name, param]) => { + const paramValue = param.stringify(targetParams[name]); + + if (param.init.skipEmpty && param.isEmpty(paramValue)) { + pageUrl.searchParams.delete(name); + } else { + pageUrl.searchParams.set(name, paramValue); + } + }); + } + + return pageUrl.href.replace(pageUrl.origin, ""); +} + +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 { + id: pageId, extensionId, params, components, url, + }; + } + + protected normalizeComponents(components: PageComponents, params?: PageParams): PageComponents { + if (params) { + const { Page } = components; + + components.Page = observer((props: object) => React.createElement(Page, { params, ...props })); } - return super.add(registeredPages); + return components; } - getUrl

({ extensionId, id: pageId }: RegisteredPage, params?: P) { - return getExtensionPageUrl({ extensionId, pageId, params }); + protected normalizeParams(params?: PageParams): PageParams { + if (!params) { + return; + } + Object.entries(params).forEach(([name, value]) => { + const paramInit: PageParamInit = typeof value === "object" + ? { name, ...value } + : { name, defaultValue: value }; + + params[paramInit.name] = createPageParam(paramInit); + }); + + return params as PageParams; } - 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; + getByPageTarget(target: PageTarget): RegisteredPage | null { + return this.getItems().find(page => page.extensionId === target.extensionId && page.id === target.pageId) || null; } } diff --git a/src/extensions/renderer-api/navigation.ts b/src/extensions/renderer-api/navigation.ts index a1191a4b30..fd1f9196cc 100644 --- a/src/extensions/renderer-api/navigation.ts +++ b/src/extensions/renderer-api/navigation.ts @@ -1,3 +1,12 @@ -export { navigate } from "../../renderer/navigation"; -export { hideDetails, showDetails, getDetailsUrl } from "../../renderer/navigation"; +import { PageParam, PageParamInit } from "../../renderer/navigation/page-param"; +import { navigation } from "../../renderer/navigation"; + +export type { PageParamInit, PageParam } from "../../renderer/navigation/page-param"; +export { navigate, isActiveRoute } from "../../renderer/navigation/helpers"; +export { hideDetails, showDetails, getDetailsUrl } from "../../renderer/components/kube-object/kube-object-details"; export { IURLParams } from "../../common/utils/buildUrl"; + +// exporting to extensions-api version of helper without `isSystem` flag +export function createPageParam(init: PageParamInit) { + return new PageParam(init, navigation); +} diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index 01e5ceb228..db06077f0b 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -50,8 +50,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/components/+apps-releases/release-details.tsx b/src/renderer/components/+apps-releases/release-details.tsx index 41ad5f1c8f..17a434a234 100644 --- a/src/renderer/components/+apps-releases/release-details.tsx +++ b/src/renderer/components/+apps-releases/release-details.tsx @@ -20,13 +20,13 @@ import { Button } from "../button"; import { releaseStore } from "./release.store"; import { Notifications } from "../notifications"; import { createUpgradeChartTab } from "../dock/upgrade-chart.store"; -import { getDetailsUrl } from "../../navigation"; import { _i18n } from "../../i18n"; import { themeStore } from "../../theme.store"; import { apiManager } from "../../api/api-manager"; import { SubTitle } from "../layout/sub-title"; import { secretsStore } from "../+config-secrets/secrets.store"; import { Secret } from "../../api/endpoints"; +import { getDetailsUrl } from "../kube-object"; interface Props { release: HelmRelease; @@ -161,10 +161,7 @@ export class ReleaseDetails extends Component { const name = item.getName(); const namespace = item.getNs(); const api = apiManager.getApi(item.metadata.selfLink); - const detailsUrl = api ? getDetailsUrl(api.getUrl({ - name, - namespace, - })) : ""; + const detailsUrl = api ? getDetailsUrl(api.getUrl({ name, namespace })) : ""; return ( diff --git a/src/renderer/components/+apps/apps.tsx b/src/renderer/components/+apps/apps.tsx index c863b93aab..0660c71853 100644 --- a/src/renderer/components/+apps/apps.tsx +++ b/src/renderer/components/+apps/apps.tsx @@ -4,12 +4,12 @@ import { Trans } from "@lingui/macro"; 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"; +import { namespaceUrlParam } from "../+namespaces/namespace.store"; @observer export class Apps extends React.Component { static get tabRoutes(): TabLayoutRoute[] { - const query = namespaceStore.getContextParams(); + const query = namespaceUrlParam.toObjectParam(); return [ { diff --git a/src/renderer/components/+cluster/cluster-issues.tsx b/src/renderer/components/+cluster/cluster-issues.tsx index aef925ef94..854748f060 100644 --- a/src/renderer/components/+cluster/cluster-issues.tsx +++ b/src/renderer/components/+cluster/cluster-issues.tsx @@ -10,11 +10,11 @@ import { Table, TableCell, TableHead, TableRow } from "../table"; import { nodesStore } from "../+nodes/nodes.store"; import { eventStore } from "../+events/event.store"; import { autobind, cssNames, prevDefault } from "../../utils"; -import { getSelectedDetails, showDetails } from "../../navigation"; import { ItemObject } from "../../item.store"; import { Spinner } from "../spinner"; import { themeStore } from "../../theme.store"; import { lookupApiLink } from "../../api/kube-api"; +import { kubeSelectedUrlParam, showDetails } from "../kube-object"; interface Props { className?: string; @@ -85,7 +85,7 @@ export class ClusterIssues extends React.Component { showDetails(selfLink))} > diff --git a/src/renderer/components/+config-autoscalers/hpa-details.tsx b/src/renderer/components/+config-autoscalers/hpa-details.tsx index b6fa920b37..e9ac05fcbd 100644 --- a/src/renderer/components/+config-autoscalers/hpa-details.tsx +++ b/src/renderer/components/+config-autoscalers/hpa-details.tsx @@ -5,13 +5,12 @@ import { observer } from "mobx-react"; import { Link } from "react-router-dom"; import { DrawerItem, DrawerTitle } from "../drawer"; import { Badge } from "../badge"; -import { KubeObjectDetailsProps } from "../kube-object"; +import { KubeObjectDetailsProps, getDetailsUrl } from "../kube-object"; import { cssNames } from "../../utils"; import { HorizontalPodAutoscaler, HpaMetricType, IHpaMetric } from "../../api/endpoints/hpa.api"; import { KubeEventDetails } from "../+events/kube-event-details"; import { Trans } from "@lingui/macro"; import { Table, TableCell, TableHead, TableRow } from "../table"; -import { getDetailsUrl } from "../../navigation"; import { lookupApiLink } from "../../api/kube-api"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; diff --git a/src/renderer/components/+config-secrets/add-secret-dialog.tsx b/src/renderer/components/+config-secrets/add-secret-dialog.tsx index 6f40f5bb58..8b4339dac1 100644 --- a/src/renderer/components/+config-secrets/add-secret-dialog.tsx +++ b/src/renderer/components/+config-secrets/add-secret-dialog.tsx @@ -17,8 +17,8 @@ import { Icon } from "../icon"; import { IKubeObjectMetadata } from "../../api/kube-object"; import { base64 } from "../../utils"; import { Notifications } from "../notifications"; -import { showDetails } from "../../navigation"; import upperFirst from "lodash/upperFirst"; +import { showDetails } from "../kube-object"; interface Props extends Partial { } diff --git a/src/renderer/components/+config/config.tsx b/src/renderer/components/+config/config.tsx index 0d26baf812..3deb964651 100644 --- a/src/renderer/components/+config/config.tsx +++ b/src/renderer/components/+config/config.tsx @@ -4,7 +4,7 @@ import { Trans } from "@lingui/macro"; 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 { namespaceUrlParam } from "../+namespaces/namespace.store"; import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config-resource-quotas"; import { pdbRoute, pdbURL, PodDisruptionBudgets } from "../+config-pod-disruption-budgets"; import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers"; @@ -13,7 +13,7 @@ import { isAllowedResource } from "../../../common/rbac"; @observer export class Config extends React.Component { static get tabRoutes(): TabLayoutRoute[] { - const query = namespaceStore.getContextParams(); + const query = namespaceUrlParam.toObjectParam(); const routes: TabLayoutRoute[] = []; if (isAllowedResource("configmaps")) { diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index 83a05250a0..0737001e8c 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -10,9 +10,16 @@ import { KubeObjectListLayout } from "../kube-object"; import { crdStore } from "./crd.store"; import { CustomResourceDefinition } from "../../api/endpoints/crd.api"; import { Select, SelectOption } from "../select"; -import { navigation, setQueryParams } from "../../navigation"; +import { createPageParam } from "../../navigation"; import { Icon } from "../icon"; +export const crdGroupsUrlParam = createPageParam({ + name: "groups", + multiValues: true, + isSystem: true, + defaultValue: [], +}); + enum sortBy { kind = "kind", group = "group", @@ -23,17 +30,19 @@ enum sortBy { @observer export class CrdList extends React.Component { - @computed get groups() { - return navigation.searchParams.getAsArray("groups"); + @computed get groups(): string[] { + return crdGroupsUrlParam.get(); } - onGroupChange(group: string) { - const groups = [...this.groups]; - const index = groups.findIndex(item => item == group); + onSelectGroup(group: string) { + const groups = new Set(this.groups); - if (index !== -1) groups.splice(index, 1); - else groups.push(group); - setQueryParams({ groups }); + if (groups.has(group)) { + groups.delete(group); // toggle selection + } else { + groups.add(group); + } + crdGroupsUrlParam.set(Array.from(groups)); } render() { @@ -71,7 +80,7 @@ export class CrdList extends React.Component { className="group-select" placeholder={placeholder} options={Object.keys(crdStore.groups)} - onChange={({ value: group }: SelectOption) => this.onGroupChange(group)} + onChange={({ value: group }: SelectOption) => this.onSelectGroup(group)} controlShouldRenderValue={false} formatOptionLabel={({ value: group }: SelectOption) => { const isSelected = selectedGroups.includes(group); diff --git a/src/renderer/components/+events/event-details.tsx b/src/renderer/components/+events/event-details.tsx index d514d67521..1f3caaa99d 100644 --- a/src/renderer/components/+events/event-details.tsx +++ b/src/renderer/components/+events/event-details.tsx @@ -6,10 +6,9 @@ import { Trans } from "@lingui/macro"; import { DrawerItem, DrawerTitle } from "../drawer"; import { Link } from "react-router-dom"; import { observer } from "mobx-react"; -import { KubeObjectDetailsProps } from "../kube-object"; +import { KubeObjectDetailsProps, getDetailsUrl } from "../kube-object"; import { KubeEvent } from "../../api/endpoints/events.api"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; -import { getDetailsUrl } from "../../navigation"; import { Table, TableCell, TableHead, TableRow } from "../table"; import { lookupApiLink } from "../../api/kube-api"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index 3d6977c656..de35f6a789 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -4,14 +4,13 @@ import React, { Fragment } from "react"; import { observer } from "mobx-react"; import { TabLayout } from "../layout/tab-layout"; import { eventStore } from "./event.store"; -import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object"; +import { KubeObjectListLayout, KubeObjectListLayoutProps, getDetailsUrl } from "../kube-object"; import { Trans } from "@lingui/macro"; import { KubeEvent } from "../../api/endpoints/events.api"; import { Tooltip } from "../tooltip"; import { Link } from "react-router-dom"; import { cssNames, IClassName, stopPropagation } from "../../utils"; import { Icon } from "../icon"; -import { getDetailsUrl } from "../../navigation"; import { lookupApiLink } from "../../api/kube-api"; enum sortBy { diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index 2708582d6e..a0b9d945c3 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -7,9 +7,8 @@ import { Trans } from "@lingui/macro"; import { DrawerItem } from "../drawer"; import { cssNames } from "../../utils"; import { Namespace } from "../../api/endpoints"; -import { KubeObjectDetailsProps } from "../kube-object"; +import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; import { Link } from "react-router-dom"; -import { getDetailsUrl } from "../../navigation"; import { Spinner } from "../spinner"; import { resourceQuotaStore } from "../+config-resource-quotas/resource-quotas.store"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 0f0d79e47d..ad02dd137c 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -1,44 +1,52 @@ -import { action, observable, reaction } from "mobx"; +import { action, comparer, observable, reaction } from "mobx"; import { autobind, createStorage } from "../../utils"; import { KubeObjectStore } from "../../kube-object.store"; import { Namespace, namespacesApi } from "../../api/endpoints"; -import { IQueryParams, navigation, setQueryParams } from "../../navigation"; +import { createPageParam } from "../../navigation"; import { apiManager } from "../../api/api-manager"; import { isAllowedResource } from "../../../common/rbac"; import { getHostedCluster } from "../../../common/cluster-store"; +const storage = createStorage("context_namespaces", []); + +export const namespaceUrlParam = createPageParam({ + name: "namespaces", + isSystem: true, + multiValues: true, + get defaultValue() { + return storage.get(); // initial namespaces coming from URL or local-storage (default) + } +}); + @autobind() export class NamespaceStore extends KubeObjectStore { api = namespacesApi; contextNs = observable.array(); - protected storage = createStorage("context_ns", this.contextNs); - - get initNamespaces() { - const fromUrl = navigation.searchParams.getAsArray("namespaces"); - - return fromUrl.length ? fromUrl : this.storage.get(); - } - constructor() { super(); + this.init(); + } - // restore context namespaces - const { initNamespaces: namespaces } = this; + private init() { + this.setContext(this.initNamespaces); - this.setContext(namespaces); - this.updateUrl(namespaces); - - // sync with local-storage & url-search-params - reaction(() => this.contextNs.toJS(), namespaces => { - this.storage.set(namespaces); - this.updateUrl(namespaces); + return reaction(() => this.contextNs.toJS(), namespaces => { + storage.set(namespaces); // save to local-storage + namespaceUrlParam.set(namespaces, { replaceHistory: true }); // update url + }, { + fireImmediately: true, + equals: comparer.identity, }); } - getContextParams(): Partial { + get initNamespaces() { + return namespaceUrlParam.get(); + } + + getContextParams() { return { - namespaces: this.contextNs + namespaces: this.contextNs.toJS(), }; } @@ -47,16 +55,12 @@ export class NamespaceStore extends KubeObjectStore { // if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted if (accessibleNamespaces.length > 0) { - return () => { return; }; + return Function; // no-op } return super.subscribe(apis); } - protected updateUrl(namespaces: string[]) { - setQueryParams({ namespaces }, { replace: true }); - } - protected async loadItems(namespaces?: string[]) { if (!isAllowedResource("namespaces")) { if (namespaces) return namespaces.map(this.getDummyNamespace); @@ -84,6 +88,7 @@ export class NamespaceStore extends KubeObjectStore { }); } + @action setContext(namespaces: string[]) { this.contextNs.replace(namespaces); } @@ -94,6 +99,7 @@ export class NamespaceStore extends KubeObjectStore { return context.every(namespace => this.contextNs.includes(namespace)); } + @action toggleContext(namespace: string) { if (this.hasContext(namespace)) this.contextNs.remove(namespace); else this.contextNs.push(namespace); @@ -105,6 +111,7 @@ export class NamespaceStore extends KubeObjectStore { this.contextNs.clear(); } + @action async remove(item: Namespace) { await super.remove(item); this.contextNs.remove(item.getName()); diff --git a/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx b/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx index 17750419c0..1ea71dd314 100644 --- a/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx +++ b/src/renderer/components/+network-endpoints/endpoint-subset-list.tsx @@ -7,8 +7,8 @@ import { Trans } from "@lingui/macro"; import { Table, TableCell, TableHead, TableRow } from "../table"; import { autobind } from "../../utils"; import { lookupApiLink } from "../../api/kube-api"; -import { getDetailsUrl } from "../../navigation"; import { Link } from "react-router-dom"; +import { getDetailsUrl } from "../kube-object"; interface Props { subset: EndpointSubset; diff --git a/src/renderer/components/+network-services/service-details-endpoint.tsx b/src/renderer/components/+network-services/service-details-endpoint.tsx index e6d0ad1ac3..732b2b9582 100644 --- a/src/renderer/components/+network-services/service-details-endpoint.tsx +++ b/src/renderer/components/+network-services/service-details-endpoint.tsx @@ -3,10 +3,10 @@ import { observer } from "mobx-react"; import React from "react"; import { Table, TableHead, TableCell, TableRow } from "../table"; import { prevDefault } from "../../utils"; -import { showDetails } from "../../navigation"; import { Trans } from "@lingui/macro"; import { endpointStore } from "../+network-endpoints/endpoints.store"; import { Spinner } from "../spinner"; +import { showDetails } from "../kube-object"; interface Props { endpoint: KubeObject; diff --git a/src/renderer/components/+network/network.tsx b/src/renderer/components/+network/network.tsx index 601a49b645..133935e63e 100644 --- a/src/renderer/components/+network/network.tsx +++ b/src/renderer/components/+network/network.tsx @@ -8,13 +8,13 @@ import { Services, servicesRoute, servicesURL } from "../+network-services"; 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 { namespaceUrlParam } from "../+namespaces/namespace.store"; import { isAllowedResource } from "../../../common/rbac"; @observer export class Network extends React.Component { static get tabRoutes(): TabLayoutRoute[] { - const query = namespaceStore.getContextParams(); + const query = namespaceUrlParam.toObjectParam(); const routes: TabLayoutRoute[] = []; if (isAllowedResource("services")) { diff --git a/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx b/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx index e9d9f36dc7..2ec5d93c8b 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx +++ b/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx @@ -10,13 +10,11 @@ import { podsStore } from "../+workloads-pods/pods.store"; import { Link } from "react-router-dom"; import { KubeEventDetails } from "../+events/kube-event-details"; import { volumeClaimStore } from "./volume-claim.store"; -import { getDetailsUrl } from "../../navigation"; import { ResourceMetrics } from "../resource-metrics"; import { VolumeClaimDiskChart } from "./volume-claim-disk-chart"; -import { KubeObjectDetailsProps } from "../kube-object"; +import { getDetailsUrl, KubeObjectDetailsProps, KubeObjectMeta } from "../kube-object"; import { PersistentVolumeClaim } from "../../api/endpoints"; import { _i18n } from "../../i18n"; -import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; interface Props extends KubeObjectDetailsProps { diff --git a/src/renderer/components/+storage-volume-claims/volume-claims.tsx b/src/renderer/components/+storage-volume-claims/volume-claims.tsx index b81495ca6a..d8c15bf928 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claims.tsx +++ b/src/renderer/components/+storage-volume-claims/volume-claims.tsx @@ -7,11 +7,10 @@ import { Trans } from "@lingui/macro"; import { volumeClaimStore } from "./volume-claim.store"; import { PersistentVolumeClaim } from "../../api/endpoints/persistent-volume-claims.api"; import { podsStore } from "../+workloads-pods/pods.store"; -import { KubeObjectListLayout } from "../kube-object"; +import { getDetailsUrl, KubeObjectListLayout } from "../kube-object"; import { IVolumeClaimsRouteParams } from "./volume-claims.route"; import { unitsToBytes } from "../../utils/convertMemory"; import { stopPropagation } from "../../utils"; -import { getDetailsUrl } from "../../navigation"; import { storageClassApi } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; diff --git a/src/renderer/components/+storage-volumes/volume-details.tsx b/src/renderer/components/+storage-volumes/volume-details.tsx index 9ccc862228..a977f1d9bb 100644 --- a/src/renderer/components/+storage-volumes/volume-details.tsx +++ b/src/renderer/components/+storage-volumes/volume-details.tsx @@ -8,9 +8,8 @@ import { observer } from "mobx-react"; import { DrawerItem, DrawerTitle } from "../drawer"; import { Badge } from "../badge"; import { KubeEventDetails } from "../+events/kube-event-details"; -import { getDetailsUrl } from "../../navigation"; import { PersistentVolume, pvcApi } from "../../api/endpoints"; -import { KubeObjectDetailsProps } from "../kube-object"; +import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; diff --git a/src/renderer/components/+storage-volumes/volumes.tsx b/src/renderer/components/+storage-volumes/volumes.tsx index 94a6bf6ff1..a0f38dfbf7 100644 --- a/src/renderer/components/+storage-volumes/volumes.tsx +++ b/src/renderer/components/+storage-volumes/volumes.tsx @@ -5,10 +5,9 @@ import { observer } from "mobx-react"; import { Trans } from "@lingui/macro"; import { Link, RouteComponentProps } from "react-router-dom"; import { PersistentVolume } from "../../api/endpoints/persistent-volume.api"; -import { KubeObjectListLayout } from "../kube-object"; +import { getDetailsUrl, KubeObjectListLayout } from "../kube-object"; import { IVolumesRouteParams } from "./volumes.route"; import { stopPropagation } from "../../utils"; -import { getDetailsUrl } from "../../navigation"; import { volumesStore } from "./volumes.store"; import { pvcApi, storageClassApi } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; diff --git a/src/renderer/components/+storage/storage.tsx b/src/renderer/components/+storage/storage.tsx index e193cb4fa6..9eb86892d7 100644 --- a/src/renderer/components/+storage/storage.tsx +++ b/src/renderer/components/+storage/storage.tsx @@ -7,14 +7,14 @@ 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 { namespaceUrlParam } from "../+namespaces/namespace.store"; import { isAllowedResource } from "../../../common/rbac"; @observer export class Storage extends React.Component { static get tabRoutes() { const tabRoutes: TabLayoutRoute[] = []; - const query = namespaceStore.getContextParams(); + const query = namespaceUrlParam.toObjectParam(); tabRoutes.push({ title: Persistent Volume Claims, diff --git a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx index 4580ac6eab..471416033e 100644 --- a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx +++ b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx @@ -16,11 +16,11 @@ import { NamespaceSelect } from "../+namespaces/namespace-select"; import { Checkbox } from "../checkbox"; import { KubeObject } from "../../api/kube-object"; import { Notifications } from "../notifications"; -import { showDetails } from "../../navigation"; import { rolesStore } from "../+user-management-roles/roles.store"; import { namespaceStore } from "../+namespaces/namespace.store"; import { serviceAccountsStore } from "../+user-management-service-accounts/service-accounts.store"; import { roleBindingsStore } from "./role-bindings.store"; +import { showDetails } from "../kube-object"; interface BindingSelectOption extends SelectOption { value: string; // binding name diff --git a/src/renderer/components/+user-management-roles/add-role-dialog.tsx b/src/renderer/components/+user-management-roles/add-role-dialog.tsx index 23500439e0..d6a1315de2 100644 --- a/src/renderer/components/+user-management-roles/add-role-dialog.tsx +++ b/src/renderer/components/+user-management-roles/add-role-dialog.tsx @@ -10,7 +10,7 @@ import { Wizard, WizardStep } from "../wizard"; import { Notifications } from "../notifications"; import { rolesStore } from "./roles.store"; import { Input } from "../input"; -import { showDetails } from "../../navigation"; +import { showDetails } from "../kube-object"; interface Props extends Partial { } diff --git a/src/renderer/components/+user-management-service-accounts/create-service-account-dialog.tsx b/src/renderer/components/+user-management-service-accounts/create-service-account-dialog.tsx index 1563fc225b..d9404db0c4 100644 --- a/src/renderer/components/+user-management-service-accounts/create-service-account-dialog.tsx +++ b/src/renderer/components/+user-management-service-accounts/create-service-account-dialog.tsx @@ -13,7 +13,7 @@ import { Input } from "../input"; import { systemName } from "../input/input_validators"; import { NamespaceSelect } from "../+namespaces/namespace-select"; import { Notifications } from "../notifications"; -import { showDetails } from "../../navigation"; +import { showDetails } from "../kube-object"; interface Props extends Partial { } diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts-details.tsx b/src/renderer/components/+user-management-service-accounts/service-accounts-details.tsx index 9d84d1c559..daefdaffe8 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts-details.tsx +++ b/src/renderer/components/+user-management-service-accounts/service-accounts-details.tsx @@ -11,8 +11,7 @@ import { secretsStore } from "../+config-secrets/secrets.store"; import { Link } from "react-router-dom"; import { Secret, ServiceAccount } from "../../api/endpoints"; import { KubeEventDetails } from "../+events/kube-event-details"; -import { getDetailsUrl } from "../../navigation"; -import { KubeObjectDetailsProps } from "../kube-object"; +import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { Icon } from "../icon"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index c42de7213f..b4ce7f8cd5 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -7,7 +7,7 @@ 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 } from "./user-management.route"; -import { namespaceStore } from "../+namespaces/namespace.store"; +import { namespaceUrlParam } from "../+namespaces/namespace.store"; import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies"; import { isAllowedResource } from "../../../common/rbac"; @@ -15,7 +15,7 @@ import { isAllowedResource } from "../../../common/rbac"; export class UserManagement extends React.Component { static get tabRoutes() { const tabRoutes: TabLayoutRoute[] = []; - const query = namespaceStore.getContextParams(); + const query = namespaceUrlParam.toObjectParam(); tabRoutes.push( { diff --git a/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx b/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx index fe319c0a7e..60fa680148 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx +++ b/src/renderer/components/+workloads-cronjobs/cronjob-details.tsx @@ -10,8 +10,7 @@ import { jobStore } from "../+workloads-jobs/job.store"; import { Link } from "react-router-dom"; import { KubeEventDetails } from "../+events/kube-event-details"; import { cronJobStore } from "./cronjob.store"; -import { getDetailsUrl } from "../../navigation"; -import { KubeObjectDetailsProps } from "../kube-object"; +import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; import { CronJob, Job } from "../../api/endpoints"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; diff --git a/src/renderer/components/+workloads-jobs/job-details.tsx b/src/renderer/components/+workloads-jobs/job-details.tsx index 51c40c97d5..9af2d53ec2 100644 --- a/src/renderer/components/+workloads-jobs/job-details.tsx +++ b/src/renderer/components/+workloads-jobs/job-details.tsx @@ -13,8 +13,7 @@ import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities" import { KubeEventDetails } from "../+events/kube-event-details"; import { podsStore } from "../+workloads-pods/pods.store"; import { jobStore } from "./job.store"; -import { getDetailsUrl } from "../../navigation"; -import { KubeObjectDetailsProps } from "../kube-object"; +import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; import { Job } from "../../api/endpoints"; import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { lookupApiLink } from "../../api/kube-api"; diff --git a/src/renderer/components/+workloads-pods/pod-details-list.tsx b/src/renderer/components/+workloads-pods/pod-details-list.tsx index e35482eab4..90d301345f 100644 --- a/src/renderer/components/+workloads-pods/pod-details-list.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-list.tsx @@ -2,6 +2,7 @@ import "./pod-details-list.scss"; import React from "react"; import kebabCase from "lodash/kebabCase"; +import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { Trans } from "@lingui/macro"; import { podsStore } from "./pods.store"; @@ -10,11 +11,10 @@ import { autobind, bytesToUnits, cssNames, interval, prevDefault } from "../../u import { LineProgress } from "../line-progress"; import { KubeObject } from "../../api/kube-object"; import { Table, TableCell, TableHead, TableRow } from "../table"; -import { showDetails } from "../../navigation"; -import { reaction } from "mobx"; import { Spinner } from "../spinner"; import { DrawerTitle } from "../drawer"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { showDetails } from "../kube-object"; enum sortBy { name = "name", diff --git a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx b/src/renderer/components/+workloads-pods/pod-details-secrets.tsx index 554e8840d2..af1515c1b4 100644 --- a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-secrets.tsx @@ -5,7 +5,7 @@ import { Link } from "react-router-dom"; import { autorun, observable } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { Pod, Secret, secretsApi } from "../../api/endpoints"; -import { getDetailsUrl } from "../../navigation"; +import { getDetailsUrl } from "../kube-object"; interface Props { pod: Pod; diff --git a/src/renderer/components/+workloads-pods/pod-details.tsx b/src/renderer/components/+workloads-pods/pod-details.tsx index 18348d23ee..6e9be639ff 100644 --- a/src/renderer/components/+workloads-pods/pod-details.tsx +++ b/src/renderer/components/+workloads-pods/pod-details.tsx @@ -18,8 +18,7 @@ import { KubeEventDetails } from "../+events/kube-event-details"; import { PodDetailsSecrets } from "./pod-details-secrets"; import { ResourceMetrics } from "../resource-metrics"; import { podsStore } from "./pods.store"; -import { getDetailsUrl } from "../../navigation"; -import { KubeObjectDetailsProps } from "../kube-object"; +import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; import { getItemMetrics } from "../../api/endpoints/metrics.api"; import { PodCharts, podMetricTabs } from "./pod-charts"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx index 0a5d1cc2e3..0de919a654 100644 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ b/src/renderer/components/+workloads-pods/pods.tsx @@ -9,11 +9,10 @@ import { RouteComponentProps } from "react-router"; import { volumeClaimStore } from "../+storage-volume-claims/volume-claim.store"; import { IPodsRouteParams } from "../+workloads"; import { eventStore } from "../+events/event.store"; -import { KubeObjectListLayout } from "../kube-object"; +import { getDetailsUrl, KubeObjectListLayout } from "../kube-object"; import { nodesApi, Pod } from "../../api/endpoints"; import { StatusBrick } from "../status-brick"; import { cssNames, stopPropagation } from "../../utils"; -import { getDetailsUrl } from "../../navigation"; import toPairs from "lodash/toPairs"; import startCase from "lodash/startCase"; import kebabCase from "lodash/kebabCase"; diff --git a/src/renderer/components/+workloads/workloads.tsx b/src/renderer/components/+workloads/workloads.tsx index 1cdd6714fe..f901748d27 100644 --- a/src/renderer/components/+workloads/workloads.tsx +++ b/src/renderer/components/+workloads/workloads.tsx @@ -6,7 +6,7 @@ import { Trans } from "@lingui/macro"; 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, replicaSetsRoute, replicaSetsURL, statefulSetsRoute, statefulSetsURL } from "./workloads.route"; -import { namespaceStore } from "../+namespaces/namespace.store"; +import { namespaceUrlParam } from "../+namespaces/namespace.store"; import { Pods } from "../+workloads-pods"; import { Deployments } from "../+workloads-deployments"; import { DaemonSets } from "../+workloads-daemonsets"; @@ -19,7 +19,7 @@ import { ReplicaSets } from "../+workloads-replicasets"; @observer export class Workloads extends React.Component { static get tabRoutes(): TabLayoutRoute[] { - const query = namespaceStore.getContextParams(); + const query = namespaceUrlParam.toObjectParam(); const routes: TabLayoutRoute[] = [ { title: Overview, diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index e6af7e11d2..6ad41318ab 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -41,10 +41,10 @@ import { broadcastMessage, requestMain } from "../../common/ipc"; import whatInput from "what-input"; import { clusterSetFrameIdHandler } from "../../common/cluster-ipc"; import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries"; -import { TabLayoutRoute, TabLayout } from "./layout/tab-layout"; +import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; import { eventStore } from "./+events/event.store"; -import { reaction, computed } from "mobx"; +import { computed, reaction } from "mobx"; import { nodesStore } from "./+nodes/nodes.store"; import { podsStore } from "./+workloads-pods/pods.store"; import { sum } from "lodash"; @@ -129,16 +129,15 @@ export class App extends React.Component { if (!menuItem.id) { return routes; } - clusterPageMenuRegistry.getSubItems(menuItem).forEach((item) => { - const page = clusterPageRegistry.getByPageMenuTarget(item.target); + clusterPageMenuRegistry.getSubItems(menuItem).forEach((subMenu) => { + const page = clusterPageRegistry.getByPageTarget(subMenu.target); if (page) { routes.push({ - routePath: page.routePath, - url: getExtensionPageUrl({ extensionId: page.extensionId, pageId: page.id, params: item.target.params }), - title: item.title, + routePath: page.url, + url: getExtensionPageUrl(subMenu.target), + title: subMenu.title, component: page.components.Page, - exact: page.exact }); } }); @@ -151,14 +150,14 @@ export class App extends React.Component { const tabRoutes = this.getTabLayoutRoutes(menu); if (tabRoutes.length > 0) { - const pageComponent = () => ; + const pageComponent = () => ; - return tab.routePath)} />; + return tab.routePath)}/>; } else { - const page = clusterPageRegistry.getByPageMenuTarget(menu.target); + const page = clusterPageRegistry.getByPageTarget(menu.target); if (page) { - return ; + return ; } } }); @@ -169,7 +168,7 @@ export class App extends React.Component { const menu = clusterPageMenuRegistry.getByPage(page); if (!menu) { - return ; + return ; } }); } diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 80df472a64..68a358766c 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -71,8 +71,8 @@ export class ClusterManager extends React.Component { - {globalPageRegistry.getItems().map(({ routePath, exact, components: { Page } }) => { - return ; + {globalPageRegistry.getItems().map(({ url, components: { Page } }) => { + return ; })} diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index f0e537996a..4930a975d6 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -15,7 +15,7 @@ import { ClusterIcon } from "../cluster-icon"; import { Icon } from "../icon"; import { autobind, cssNames, IClassName } from "../../utils"; import { Badge } from "../badge"; -import { navigate, navigation } from "../../navigation"; +import { isActiveRoute, navigate } from "../../navigation"; import { addClusterURL } from "../+add-cluster"; import { clusterSettingsURL } from "../+cluster-settings"; import { landingURL } from "../+landing-page"; @@ -158,12 +158,13 @@ export class ClustersMenu extends React.Component {

{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { - const registeredPage = globalPageRegistry.getByPageMenuTarget(target); + const registeredPage = globalPageRegistry.getByPageTarget(target); - if (!registeredPage) return; - const { extensionId, id: pageId } = registeredPage; - const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); - const isActive = pageUrl === navigation.location.pathname; + if (!registeredPage){ + return; + } + const pageUrl = getExtensionPageUrl(target); + const isActive = isActiveRoute(registeredPage.url); return ( { // render icon type if (link) { - return ; + const { className, children } = iconProps; + + return ( + + {children} + + ); } if (href) { diff --git a/src/renderer/components/input/search-input-url.tsx b/src/renderer/components/input/search-input-url.tsx index 6a507128e0..2b1045ede2 100644 --- a/src/renderer/components/input/search-input-url.tsx +++ b/src/renderer/components/input/search-input-url.tsx @@ -2,9 +2,15 @@ import React from "react"; import debounce from "lodash/debounce"; import { autorun, observable } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { getSearch, setSearch } from "../../navigation"; import { InputProps } from "./input"; import { SearchInput } from "./search-input"; +import { createPageParam } from "../../navigation"; + +export const searchUrlParam = createPageParam({ + name: "search", + isSystem: true, + defaultValue: "", +}); interface Props extends InputProps { compact?: boolean; // show only search-icon when not focused @@ -12,11 +18,11 @@ interface Props extends InputProps { @observer export class SearchInputUrl extends React.Component { - @observable inputVal = ""; // fix: use empty string to avoid react warnings + @observable inputVal = ""; // fix: use empty string on init to avoid react warnings @disposeOnUnmount - updateInput = autorun(() => this.inputVal = getSearch()); - updateUrl = debounce((val: string) => setSearch(val), 250); + updateInput = autorun(() => this.inputVal = searchUrlParam.get()); + updateUrl = debounce((val: string) => searchUrlParam.set(val), 250); setValue = (value: string) => { this.inputVal = value; diff --git a/src/renderer/components/item-object-list/page-filters.store.ts b/src/renderer/components/item-object-list/page-filters.store.ts index 8ca3ce930f..9bff008aa6 100644 --- a/src/renderer/components/item-object-list/page-filters.store.ts +++ b/src/renderer/components/item-object-list/page-filters.store.ts @@ -1,7 +1,7 @@ import { computed, observable, reaction } from "mobx"; import { autobind } from "../../utils"; -import { getSearch, setSearch } from "../../navigation"; import { namespaceStore } from "../+namespaces/namespace.store"; +import { searchUrlParam } from "../input/search-input-url"; export enum FilterType { SEARCH = "search", @@ -54,8 +54,8 @@ export class PageFiltersStore { protected syncWithGlobalSearch() { const disposers = [ - reaction(() => this.getValues(FilterType.SEARCH)[0], setSearch), - reaction(() => getSearch(), search => { + reaction(() => this.getValues(FilterType.SEARCH)[0], search => searchUrlParam.set(search)), + reaction(() => searchUrlParam.get(), search => { const filter = this.getByType(FilterType.SEARCH); if (filter) { diff --git a/src/renderer/components/kube-object/kube-object-details.tsx b/src/renderer/components/kube-object/kube-object-details.tsx index 2f0d2a69a7..988282e238 100644 --- a/src/renderer/components/kube-object/kube-object-details.tsx +++ b/src/renderer/components/kube-object/kube-object-details.tsx @@ -4,7 +4,7 @@ import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { computed, observable, reaction } from "mobx"; import { Trans } from "@lingui/macro"; -import { getDetails, hideDetails } from "../../navigation"; +import { createPageParam, navigation } from "../../navigation"; import { Drawer } from "../drawer"; import { KubeObject } from "../../api/kube-object"; import { Spinner } from "../spinner"; @@ -14,6 +14,43 @@ import { CrdResourceDetails } from "../+custom-resources"; import { KubeObjectMenu } from "./kube-object-menu"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +export const kubeDetailsUrlParam = createPageParam({ + name: "kube-details", + isSystem: true, +}); + +export const kubeSelectedUrlParam = createPageParam({ + name: "kube-selected", + isSystem: true, + get defaultValue() { + return kubeDetailsUrlParam.get(); + } +}); + +export function showDetails(details = "", resetSelected = true) { + const detailsUrl = getDetailsUrl(details, resetSelected); + + navigation.merge({ search: detailsUrl }); +} + +export function hideDetails() { + showDetails(); +} + +export function getDetailsUrl(details: string, resetSelected = false) { + const detailsUrl = kubeDetailsUrlParam.toSearchString({ value: details }); + + if (resetSelected) { + const params = new URLSearchParams(detailsUrl); + + params.delete(kubeSelectedUrlParam.name); + + return `?${params.toString()}`; + } + + return detailsUrl; +} + export interface KubeObjectDetailsProps { className?: string; object: T; @@ -25,7 +62,7 @@ export class KubeObjectDetails extends React.Component { @observable.ref loadingError: React.ReactNode; @computed get path() { - return getDetails(); + return kubeDetailsUrlParam.get(); } @computed get object() { @@ -70,7 +107,7 @@ export class KubeObjectDetails extends React.Component { const { object, isLoading, loadingError, isCrdInstance } = this; const isOpen = !!(object || isLoading || loadingError); let title = ""; - let details: JSX.Element[]; + let details: React.ReactNode[]; if (object) { const { kind, getName } = object; @@ -81,7 +118,7 @@ export class KubeObjectDetails extends React.Component { }); if (isCrdInstance && details.length === 0) { - details.push(); + details.push(); } } @@ -90,7 +127,7 @@ export class KubeObjectDetails extends React.Component { className="KubeObjectDetails flex column" open={isOpen} title={title} - toolbar={} + toolbar={} onClose={hideDetails} > {isLoading && } diff --git a/src/renderer/components/kube-object/kube-object-list-layout.tsx b/src/renderer/components/kube-object/kube-object-list-layout.tsx index e68194f0f4..25922f0f72 100644 --- a/src/renderer/components/kube-object/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object/kube-object-list-layout.tsx @@ -3,10 +3,10 @@ import { computed } from "mobx"; import { observer } from "mobx-react"; import { cssNames } from "../../utils"; import { KubeObject } from "../../api/kube-object"; -import { getSelectedDetails, showDetails } from "../../navigation"; import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/item-list-layout"; import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectMenu } from "./kube-object-menu"; +import { kubeSelectedUrlParam, showDetails } from "./kube-object-details"; export interface KubeObjectListLayoutProps extends ItemListLayoutProps { store: KubeObjectStore; @@ -15,14 +15,13 @@ export interface KubeObjectListLayoutProps extends ItemListLayoutProps { @observer export class KubeObjectListLayout extends React.Component { @computed get selectedItem() { - return this.props.store.getByPath(getSelectedDetails()); + return this.props.store.getByPath(kubeSelectedUrlParam.get()); } onDetails = (item: KubeObject) => { if (this.props.onDetails) { this.props.onDetails(item); - } - else { + } else { showDetails(item.selfLink); } }; diff --git a/src/renderer/components/kube-object/kube-object-menu.tsx b/src/renderer/components/kube-object/kube-object-menu.tsx index 25ad6cc8ad..7a418e2689 100644 --- a/src/renderer/components/kube-object/kube-object-menu.tsx +++ b/src/renderer/components/kube-object/kube-object-menu.tsx @@ -4,7 +4,7 @@ import { autobind, cssNames } from "../../utils"; import { KubeObject } from "../../api/kube-object"; import { editResourceTab } from "../dock/edit-resource.store"; import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; -import { hideDetails } from "../../navigation"; +import { hideDetails } from "./kube-object-details"; import { apiManager } from "../../api/api-manager"; import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; diff --git a/src/renderer/components/kube-object/kube-object-meta.tsx b/src/renderer/components/kube-object/kube-object-meta.tsx index b93eb89cb0..9f02d6086a 100644 --- a/src/renderer/components/kube-object/kube-object-meta.tsx +++ b/src/renderer/components/kube-object/kube-object-meta.tsx @@ -2,10 +2,10 @@ import React from "react"; import { Trans } from "@lingui/macro"; import { IKubeMetaField, KubeObject } from "../../api/kube-object"; import { DrawerItem, DrawerItemLabels } from "../drawer"; -import { getDetailsUrl } from "../../navigation"; import { lookupApiLink } from "../../api/kube-api"; import { Link } from "react-router-dom"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { getDetailsUrl } from "./kube-object-details"; export interface KubeObjectMetaProps { object: KubeObject; diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index 5386d9fb52..c8de3d2116 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -17,7 +17,7 @@ import { clusterRoute, clusterURL } from "../+cluster"; import { Config, configRoute, configURL } from "../+config"; import { eventRoute, eventsURL } from "../+events"; import { Apps, appsRoute, appsURL } from "../+apps"; -import { namespaceStore } from "../+namespaces/namespace.store"; +import { namespaceUrlParam } from "../+namespaces/namespace.store"; import { Workloads } from "../+workloads"; import { UserManagement } from "../+user-management"; import { Storage } from "../+storage"; @@ -75,21 +75,23 @@ export class Sidebar extends React.Component { } getTabLayoutRoutes(menu: ClusterPageMenuRegistration): TabLayoutRoute[] { - if (!menu.id) { - return []; - } const routes: TabLayoutRoute[] = []; - clusterPageMenuRegistry.getSubItems(menu).forEach((subItem) => { - const subPage = clusterPageRegistry.getByPageMenuTarget(subItem.target); + if (!menu.id) { + return routes; + } + + clusterPageMenuRegistry.getSubItems(menu).forEach((subMenu) => { + const subPage = clusterPageRegistry.getByPageTarget(subMenu.target); if (subPage) { + const { extensionId, id: pageId } = subPage; + routes.push({ - routePath: subPage.routePath, - url: getExtensionPageUrl({ extensionId: subPage.extensionId, pageId: subPage.id, params: subItem.target.params }), - title: subItem.title, + routePath: subPage.url, + url: getExtensionPageUrl({ extensionId, pageId, params: subMenu.target.params }), + title: subMenu.title, component: subPage.components.Page, - exact: subPage.exact }); } }); @@ -99,7 +101,7 @@ export class Sidebar extends React.Component { renderRegisteredMenus() { return clusterPageMenuRegistry.getRootItems().map((menuItem, index) => { - const registeredPage = clusterPageRegistry.getByPageMenuTarget(menuItem.target); + const registeredPage = clusterPageRegistry.getByPageTarget(menuItem.target); const tabRoutes = this.getTabLayoutRoutes(menuItem); const id = `registered-item-${index}`; let pageUrl: string; @@ -109,7 +111,7 @@ export class Sidebar extends React.Component { const { extensionId, id: pageId } = registeredPage; pageUrl = getExtensionPageUrl({ extensionId, pageId, params: menuItem.target.params }); - isActive = isActiveRoute(registeredPage.routePath); + isActive = isActiveRoute(registeredPage.url); } else if (tabRoutes.length > 0) { pageUrl = tabRoutes[0].url; isActive = isActiveRoute(tabRoutes.map((tab) => tab.routePath)); @@ -133,7 +135,7 @@ export class Sidebar extends React.Component { render() { const { toggle, isPinned, className } = this.props; - const query = namespaceStore.getContextParams(); + const query = namespaceUrlParam.toObjectParam(); return ( diff --git a/src/renderer/components/table/table.tsx b/src/renderer/components/table/table.tsx index a055ab432b..9b5d396e85 100644 --- a/src/renderer/components/table/table.tsx +++ b/src/renderer/components/table/table.tsx @@ -1,19 +1,17 @@ import "./table.scss"; import React from "react"; +import { orderBy } from "lodash"; import { observer } from "mobx-react"; -import { computed, observable } from "mobx"; +import { observable } from "mobx"; import { autobind, cssNames, noop } from "../../utils"; import { TableRow, TableRowElem, TableRowProps } from "./table-row"; import { TableHead, TableHeadElem, TableHeadProps } from "./table-head"; import { TableCellElem } from "./table-cell"; import { VirtualList } from "../virtual-list"; -import { navigation, setQueryParams } from "../../navigation"; -import orderBy from "lodash/orderBy"; +import { createPageParam } from "../../navigation"; import { ItemObject } from "../../item.store"; -// todo: refactor + decouple search from location - export type TableSortBy = string; export type TableOrderBy = "asc" | "desc" | string; export type TableSortParams = { sortBy: TableSortBy; orderBy: TableOrderBy }; @@ -43,6 +41,16 @@ export interface TableProps extends React.DOMAttributes { getTableRow?: (uid: string) => React.ReactElement; } +export const sortByUrlParam = createPageParam({ + name: "sort", + isSystem: true, +}); + +export const orderByUrlParam = createPageParam({ + name: "order", + isSystem: true, +}); + @observer export class Table extends React.Component { static defaultProps: TableProps = { @@ -53,18 +61,13 @@ export class Table extends React.Component { sortSyncWithUrl: true, }; - @observable sortParamsLocal = this.props.sortByDefault; - - @computed get sortParams(): Partial { - if (this.props.sortSyncWithUrl) { - const sortBy = navigation.searchParams.get("sortBy"); - const orderBy = navigation.searchParams.get("orderBy"); - - return { sortBy, orderBy }; - } - - return this.sortParamsLocal || {}; - } + @observable sortParams: Partial = Object.assign( + this.props.sortSyncWithUrl ? { + sortBy: sortByUrlParam.get(), + orderBy: orderByUrlParam.get(), + } : {}, + this.props.sortByDefault, + ); renderHead() { const { sortable, children } = this.props; @@ -101,29 +104,24 @@ export class Table extends React.Component { } getSorted(items: any[]) { - const { sortParams } = this; - const sortingCallback = this.props.sortable[sortParams.sortBy] || noop; + const { sortBy, orderBy: order } = this.sortParams; + const sortingCallback = this.props.sortable[sortBy] || noop; - return orderBy( - items, - sortingCallback, - sortParams.orderBy as any - ); + return orderBy(items, sortingCallback, order as any); } @autobind() - protected onSort(params: TableSortParams) { + protected onSort({ sortBy, orderBy }: TableSortParams) { + this.sortParams = { sortBy, orderBy }; const { sortSyncWithUrl, onSort } = this.props; if (sortSyncWithUrl) { - setQueryParams(params); - } - else { - this.sortParamsLocal = params; + sortByUrlParam.set(sortBy); + orderByUrlParam.set(orderBy); } if (onSort) { - onSort(params); + onSort({ sortBy, orderBy }); } } diff --git a/src/renderer/navigation.ts b/src/renderer/navigation.ts deleted file mode 100644 index 2824dad490..0000000000 --- a/src/renderer/navigation.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Navigation helpers - -import { matchPath, RouteProps } from "react-router"; -import { reaction } from "mobx"; -import { createObservableHistory } from "mobx-observable-history"; -import { createBrowserHistory, LocationDescriptor } from "history"; -import logger from "../main/logger"; -import { clusterViewRoute, IClusterViewRouteParams } from "./components/cluster-manager/cluster-view.route"; -import { broadcastMessage, subscribeToBroadcast } from "../common/ipc"; - -export const history = createBrowserHistory(); -export const navigation = createObservableHistory(history); - -/** - * Navigate to a location. Works only in renderer. - */ -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); -} - -// common params for all pages -export interface IQueryParams { - namespaces?: string[]; // selected context namespaces - details?: string; // serialized resource details - selected?: string; // mark resource as selected - search?: string; // search-input value - sortBy?: string; // sorting params for table-list - orderBy?: string; -} - -export function getQueryString(params?: Partial, merge = true) { - const searchParams = navigation.searchParams.copyWith(params); - - if (!merge) { - Array.from(searchParams.keys()).forEach(key => { - if (!(key in params)) searchParams.delete(key); - }); - } - - return searchParams.toString({ withPrefix: true }); -} - -export function setQueryParams(params?: T & IQueryParams, { merge = true, replace = false } = {}) { - const newSearch = getQueryString(params, merge); - - navigation.merge({ search: newSearch }, replace); -} - -export function getDetails() { - return navigation.searchParams.get("details"); -} - -export function getSelectedDetails() { - return navigation.searchParams.get("selected") || getDetails(); -} - -export function getDetailsUrl(details: string) { - if (!details) return ""; - - return getQueryString({ - details, - selected: getSelectedDetails(), - }); -} - -/** - * Show details. Works only in renderer. - */ -export function showDetails(path: string, resetSelected = true) { - navigation.searchParams.merge({ - details: path, - selected: resetSelected ? null : getSelectedDetails(), - }); -} - -/** - * Hide details. Works only in renderer. - */ -export function hideDetails() { - showDetails(null); -} - -export function setSearch(text: string) { - navigation.replace({ - search: getQueryString({ search: text }) - }); -} - -export function getSearch() { - return navigation.searchParams.get("search") || ""; -} - -export function getMatchedClusterId(): string { - const matched = matchPath(navigation.location.pathname, { - exact: true, - path: clusterViewRoute.path - }); - - return matched?.params.clusterId; -} - -//-- EVENTS - -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, location: LocationDescriptor) => { - logger.info(`[IPC]: ${event.type} ${JSON.stringify(location)}`, event); - navigate(location); -}); - -// Reload dashboard window -subscribeToBroadcast("renderer:reload", () => { - location.reload(); -}); 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..378f6edb96 --- /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, PageSystemParamInit } 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: PageSystemParamInit) { + 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 new file mode 100644 index 0000000000..70959c2dbd --- /dev/null +++ b/src/renderer/navigation/index.ts @@ -0,0 +1,8 @@ +// Navigation (renderer) + +import { bindEvents } from "./events"; + +export * from "./history"; +export * from "./helpers"; + +bindEvents(); \ No newline at end of file diff --git a/src/renderer/navigation/page-param.ts b/src/renderer/navigation/page-param.ts new file mode 100644 index 0000000000..e73da03c44 --- /dev/null +++ b/src/renderer/navigation/page-param.ts @@ -0,0 +1,135 @@ +// Manage observable URL-param from document.location.search +import { IObservableHistory } from "mobx-observable-history"; + +export interface PageParamInit { + name: string; + 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?(value: string[]): V; // deserialize from URL + stringify?(value: V): string | string[]; // serialize params to URL +} + +export interface PageSystemParamInit extends PageParamInit { + isSystem?: boolean; +} + +export class PageParam { + static SYSTEM_PREFIX = "lens-"; + + readonly name: string; + protected urlName: string; + + constructor(readonly init: PageParamInit | PageSystemParamInit, protected history: IObservableHistory) { + const { isSystem, name } = init as PageSystemParamInit; + + this.name = name; + this.init.skipEmpty ??= true; + this.init.multiValueSep ??= ","; + + // prefixing to avoid collisions with extensions + this.urlName = `${isSystem ? PageParam.SYSTEM_PREFIX : ""}${name}`; + } + + isEmpty(value: V | any) { + return [value].flat().every(value => value == "" || value == null); + } + + parse(values: string[]): V { + const { parse, multiValues } = this.init; + + if (!multiValues) values.splice(1); // reduce values to single item + const parsedValues = [parse ? parse(values) : values].flat(); + + return multiValues ? parsedValues : parsedValues[0] as any; + } + + stringify(value: V = this.get()): string { + const { stringify, multiValues, multiValueSep, skipEmpty } = this.init; + + if (skipEmpty && this.isEmpty(value)) { + return ""; + } + + if (multiValues) { + const values = [value].flat(); + const stringValues = [stringify ? stringify(value) : values.map(String)].flat(); + + return stringValues.join(multiValueSep); + } + + return [stringify ? stringify(value) : String(value)].flat()[0]; + } + + get(): V { + const value = this.parse(this.getRaw()); + + if (this.init.skipEmpty && this.isEmpty(value)) { + return this.getDefaultValue(); + } + + return value; + } + + set(value: V, { mergeGlobals = true, replaceHistory = false } = {}) { + const search = this.toSearchString({ mergeGlobals, value }); + + this.history.merge({ search }, replaceHistory); + } + + 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); + } + } + + 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() { + this.history.searchParams.delete(this.urlName); + } + + toSearchString({ withPrefix = true, mergeGlobals = true, value = this.get() } = {}): string { + const { history, urlName, init: { skipEmpty } } = this; + const searchParams = new URLSearchParams(mergeGlobals ? history.location.search : ""); + + searchParams.set(urlName, this.stringify(value)); + + if (skipEmpty) { + searchParams.forEach((value: any, paramName) => { + if (this.isEmpty(value)) searchParams.delete(paramName); + }); + } + + if (Array.from(searchParams).length > 0) { + return `${withPrefix ? "?" : ""}${searchParams}`; + } + + return ""; + } + + toObjectParam(value = this.get()): Record { + return { + [this.urlName]: value, + }; + } +} diff --git a/yarn.lock b/yarn.lock index cbfe4a3ccd..fe17a27076 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2272,10 +2272,17 @@ dependencies: "@types/react" "*" -"@types/react-router-dom@^5.1.5": - version "5.1.5" - resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.5.tgz#7c334a2ea785dbad2b2dcdd83d2cf3d9973da090" - integrity sha512-ArBM4B1g3BWLGbaGvwBGO75GNFbLDUthrDojV2vHLih/Tq8M+tgvY1DSwkuNrPSwdp/GUL93WSEpTZs8nVyJLw== +"@types/react-dom@^17.0.0": + version "17.0.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.0.tgz#b3b691eb956c4b3401777ee67b900cb28415d95a" + integrity sha512-lUqY7OlkF/RbNtD5nIq7ot8NquXrdFrjSOR6+w9a9RFQevGi1oZO1dcJbXMeONAPKtZ2UrZOEJ5UOCVsxbLk/g== + dependencies: + "@types/react" "*" + +"@types/react-router-dom@^5.1.6": + version "5.1.6" + resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.6.tgz#07b14e7ab1893a837c8565634960dc398564b1fb" + integrity sha512-gjrxYqxz37zWEdMVvQtWPFMFj1dRDb4TGOcgyOfSXTrEXdF92L00WE3C471O3TV/RF1oskcStkXsOU0Ete4s/g== dependencies: "@types/history" "*" "@types/react" "*" @@ -2312,7 +2319,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.9.35": +"@types/react@*": version "16.9.35" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368" integrity sha512-q0n0SsWcGc8nDqH2GJfWQWUOmZSJhXV64CjVN5SvcNti3TdEaA3AH0D8DwNmMdzjMAC/78tB8nAZIlV8yTz+zQ== @@ -2320,6 +2327,14 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/react@^17.0.0": + version "17.0.0" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8" + integrity sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/relateurl@*": version "0.2.28" resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6" @@ -5023,6 +5038,11 @@ csstype@^2.2.0, csstype@^2.5.2, csstype@^2.5.7, csstype@^2.6.5, csstype@^2.6.7: resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b" integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w== +csstype@^3.0.2: + version "3.0.5" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.5.tgz#7fdec6a28a67ae18647c51668a9ff95bb2fa7bb8" + integrity sha512-uVDi8LpBUKQj6sdxNaTetL6FpeCqTjOvAQuQUa/qAqq8oOd4ivkbhgnqayl0dnPal8Tb/yB1tF+gOvCBiicaiQ== + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -12108,15 +12128,14 @@ react-beautiful-dnd@^13.0.0: redux "^4.0.4" use-memo-one "^1.1.1" -react-dom@^16.13.1: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" - integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== +react-dom@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6" + integrity sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.19.1" + scheduler "^0.20.1" react-input-autosize@^2.2.2: version "2.2.2" @@ -12217,15 +12236,6 @@ react-zlib-js@^1.0.4: resolved "https://registry.yarnpkg.com/react-zlib-js/-/react-zlib-js-1.0.4.tgz#dd2b9fbf56d5ab224fa7a99affbbedeba9aa3dc7" integrity sha512-ynXD9DFxpE7vtGoa3ZwBtPmZrkZYw2plzHGbanUjBOSN4RtuXdektSfABykHtTiWEHMh7WdYj45LHtp228ZF1A== -react@^16.14.0: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" - integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - prop-types "^15.6.2" - react@^16.8.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" @@ -12235,6 +12245,14 @@ react@^16.8.0: object-assign "^4.1.1" prop-types "^15.6.2" +react@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127" + integrity sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + read-cmd-shim@^1.0.1, read-cmd-shim@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-1.0.5.tgz#87e43eba50098ba5a32d0ceb583ab8e43b961c16" @@ -12934,10 +12952,10 @@ saxes@^5.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.19.1: - version "0.19.1" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" - integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== +scheduler@^0.20.1: + version "0.20.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.1.tgz#da0b907e24026b01181ecbc75efdc7f27b5a000c" + integrity sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -14530,7 +14548,7 @@ typeface-roboto@^0.0.75: resolved "https://registry.yarnpkg.com/typeface-roboto/-/typeface-roboto-0.0.75.tgz#98d5ba35ec234bbc7172374c8297277099cc712b" integrity sha512-VrR/IiH00Z1tFP4vDGfwZ1esNqTiDMchBEXYY9kilT6wRGgFoCAlgkEUMHb1E3mB0FsfZhv756IF0+R+SFPfdg== -typescript@^4.0.2: +typescript@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2" integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ==