diff --git a/.eslintrc.js b/.eslintrc.js index a80fcdc805..3fda195286 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,6 +16,12 @@ module.exports = { react: { version: packageJson.devDependencies.react || "detect", }, + // the package eslint-import-resolver-typescript is required for this line which fixes errors when using .d.ts files + "import/resolver": { + "typescript": { + "alwaysTryTypes": true, + }, + }, }, overrides: [ { diff --git a/Makefile b/Makefile index 6763e7e42a..3f77546354 100644 --- a/Makefile +++ b/Makefile @@ -103,8 +103,12 @@ publish-npm: node_modules build-npm cd src/extensions/npm/extensions && npm publish --access=public --tag=$(NPM_RELEASE_TAG) git restore src/extensions/npm/extensions/package.json +.PHONY: build-docs +build-docs: + yarn typedocs-extensions-api + .PHONY: docs -docs: +docs: build-docs yarn mkdocs-serve-local .PHONY: clean-extensions diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index 434aec7444..90368645f3 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -14,25 +14,27 @@ Each guide or code sample includes the following: ## Guides -| Guide | APIs | -| ----- | ----- | -| [Generate new extension project](generator.md) || -| [Main process extension](main-extension.md) | Main.LensExtension | -| [Renderer process extension](renderer-extension.md) | Renderer.LensExtension | -| [Resource stack (cluster feature)](resource-stack.md) | | -| [Stores](stores.md) | | -| [Components](components.md) | | -| [KubeObjectListLayout](kube-object-list-layout.md) | | -| [Working with mobx](working-with-mobx.md) | | -| [Protocol Handlers](protocol-handlers.md) | | -| [Sending Data between main and renderer](ipc.md) | | +| Guide | APIs | +| --------------------------------------------------------------- | ---------------------- | +| [Generate new extension project](generator.md) | | +| [Main process extension](main-extension.md) | Main.LensExtension | +| [Renderer process extension](renderer-extension.md) | Renderer.LensExtension | +| [Resource stack (cluster feature)](resource-stack.md) | | +| [Extending KubernetesCluster)](extending-kubernetes-cluster.md) | | +| [Stores](stores.md) | | +| [Components](components.md) | | +| [KubeObjectListLayout](kube-object-list-layout.md) | | +| [Working with mobx](working-with-mobx.md) | | +| [Protocol Handlers](protocol-handlers.md) | | +| [Sending Data between main and renderer](ipc.md) | | +| [Catalog Entities and Categories](catalog.md) | | ## Samples -| Sample | APIs | -| ----- | ----- | -[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | -[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | -[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | -[styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | -[custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension
Renderer.K8sApi.KubeApi
Renderer.K8sApi.KubeObjectStore
Renderer.Component.KubeObjectListLayout
Renderer.Component.KubeObjectDetailsProps
Renderer.Component.IconProps | +| Sample | APIs | +| ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | +| [styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | +| [styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | +| [styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | +| [custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension
Renderer.K8sApi.KubeApi
Renderer.K8sApi.KubeObjectStore
Renderer.Component.KubeObjectListLayout
Renderer.Component.KubeObjectDetailsProps
Renderer.Component.IconProps | diff --git a/docs/extensions/guides/catalog.md b/docs/extensions/guides/catalog.md index 5425382638..24746df0ae 100644 --- a/docs/extensions/guides/catalog.md +++ b/docs/extensions/guides/catalog.md @@ -1,5 +1,27 @@ # Catalog (WIP) -## CatalogCategoryRegistry +This guide is a brief overview about how the catalog works within Lens. +The catalog should be thought of as the single source of truth about data within Lens. -## CatalogEntityRegistry \ No newline at end of file +The data flow is unidirectional, it only flows from the main side to the renderer side. +All data is public within the catalog. + +## Categories + +A category is the declaration to the catalog of a specific kind of entity. +It declares the currently supported versions of that kind of entity but providing the constructors for the entity classes. + +To declare a new category class you must create a new class that extends [Common.Catalog.CatalogCategory](../api/classes/Common.Catalog.CatalogCategory.md) and implement all of the abstract fields. + +The categories provided by Lens itself have the following names: + +- `KubernetesClusters` +- `WebLinks` +- `General` + +To register a category, call the `Main.Catalog.catalogCategories.add()` and `Renderer.Catalog.catalogCategories.add()` with instances of your class. + +## Entities + +An entity is the data within the catalog. +All entities are typed and the class instances will be recreated on the renderer side by the catalog and the category registrations. diff --git a/docs/extensions/guides/extending-kubernetes-cluster.md b/docs/extensions/guides/extending-kubernetes-cluster.md new file mode 100644 index 0000000000..5c8170a2fe --- /dev/null +++ b/docs/extensions/guides/extending-kubernetes-cluster.md @@ -0,0 +1,69 @@ +# Extending KubernetesCluster + +Extension can specify it's own subclass of Common.Catalog.KubernetesCluster. Extension can also specify a new Category for it in the Catalog. + +## Extending Common.Catalog.KubernetesCluster + +``` typescript +import { Common } from "@k8slens/extensions"; + +// The kind must be different from KubernetesCluster's kind +export const kind = "ManagedDevCluster"; + +export class ManagedDevCluster extends Common.Catalog.KubernetesCluster { + public static readonly kind = kind; + + public readonly kind = kind; +} +``` + +## Extending Common.Catalog.CatalogCategory + +These custom Catalog entities can be added a new Category in the Catalog. + +``` typescript +import { Common } from "@k8slens/extensions"; +import { kind, ManagedDevCluster } from "../entities/ManagedDevCluster"; + +class ManagedDevClusterCategory extends Common.Catalog.CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "Managed Dev Clusters", + icon: "" + }; + public spec: Common.Catalog.CatalogCategorySpec = { + group: "entity.k8slens.dev", + versions: [ + { + name: "v1alpha1", + entityClass: ManagedDevCluster as any, + }, + ], + names: { + kind + }, + }; +} + +export { ManagedDevClusterCategory }; +export type { ManagedDevClusterCategory as ManagedDevClusterCategoryType }; +``` + +The category needs to be registered in the `onActivate()` method both in main and renderer + +``` typescript +// in main's on onActivate +Main.Catalog.catalogCategories.add(new ManagedDevClusterCategory()); +``` + +``` typescript +// in renderer's on onActivate +Renderer.Catalog.catalogCategories.add(new ManagedDevClusterCategory()); +``` + +You can then add the entities to the Catalog as a new source: + +``` typescript +this.addCatalogSource("managedDevClusters", this.managedDevClusters); +``` diff --git a/docs/extensions/guides/main-extension.md b/docs/extensions/guides/main-extension.md index a0e20880bf..d05368529c 100644 --- a/docs/extensions/guides/main-extension.md +++ b/docs/extensions/guides/main-extension.md @@ -16,11 +16,11 @@ import { Main } from "@k8slens/extensions"; export default class ExampleExtensionMain extends Main.LensExtension { onActivate() { - console.log('custom main process extension code started'); + console.log("custom main process extension code started"); } onDeactivate() { - console.log('custom main process extension de-activated'); + console.log("custom main process extension de-activated"); } } ``` @@ -33,21 +33,21 @@ Implementing `onDeactivate()` gives you the opportunity to clean up after your e Disable extensions from the Lens Extensions page: 1. Navigate to **File** > **Extensions** in the top menu bar. -(On Mac, it is **Lens** > **Extensions**.) + (On Mac, it is **Lens** > **Extensions**.) 2. Click **Disable** on the extension you want to disable. The example above logs messages when the extension is enabled and disabled. To see standard output from the main process there must be a console connected to it. Achieve this by starting Lens from the command prompt. -For more details on accessing Lens state data, please see the [Stores](../stores) guide. +For more details on accessing Lens state data, please see the [Stores](stores.md) guide. ### `appMenus` The Main Extension API allows you to customize the UI application menu. The following example demonstrates adding an item to the **Help** menu. -``` typescript +```typescript import { Main } from "@k8slens/extensions"; export default class SamplePageMainExtension extends Main.LensExtension { @@ -57,9 +57,9 @@ export default class SamplePageMainExtension extends Main.LensExtension { label: "Sample", click() { console.log("Sample clicked"); - } - } - ] + }, + }, + ]; } ``` @@ -67,18 +67,18 @@ export default class SamplePageMainExtension extends Main.LensExtension { `MenuRegistration` extends Electron's `MenuItemConstructorOptions` interface. The properties of the appMenus array objects are defined as follows: -* `parentId` is the name of the menu where your new menu item will be listed. -Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`. -`"lens"` is valid on Mac only. -* `label` is the name of your menu item. -* `click()` is called when the menu item is selected. -In this example, we simply log a message. -However, you would typically have this navigate to a specific page or perform another operation. -Note that pages are associated with the [`Renderer.LensExtension`](renderer-extension.md) class and can be defined in the process of extending it. +- `parentId` is the name of the menu where your new menu item will be listed. + Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`. + `"lens"` is valid on Mac only. +- `label` is the name of your menu item. +- `click()` is called when the menu item is selected. + In this example, we simply log a message. + However, you would typically have this navigate to a specific page or perform another operation. + Note that pages are associated with the [`Renderer.LensExtension`](renderer-extension.md) class and can be defined in the process of extending it. The following example demonstrates how an application menu can be used to navigate to such a page: -``` typescript +```typescript import { Main } from "@k8slens/extensions"; export default class SamplePageMainExtension extends Main.LensExtension { @@ -86,9 +86,9 @@ export default class SamplePageMainExtension extends Main.LensExtension { { parentId: "help", label: "Sample", - click: () => this.navigate("myGlobalPage") - } - ] + click: () => this.navigate("myGlobalPage"), + }, + ]; } ``` @@ -99,32 +99,36 @@ This page would be defined in your extension's `Renderer.LensExtension` implemen `trayMenus` is an array of `TrayMenuRegistration` objects. Most importantly you can define a `label` and a `click` handler. Other properties are `submenu`, `enabled`, `toolTip`, `id` and `type`. -``` typescript +```typescript interface TrayMenuRegistration { label?: string; click?: (menuItem: TrayMenuRegistration) => void; id?: string; - type?: "normal" | "separator" | "submenu" + type?: "normal" | "separator" | "submenu"; toolTip?: string; enabled?: boolean; - submenu?: TrayMenuRegistration[] + submenu?: TrayMenuRegistration[]; } ``` The following example demonstrates how tray menus can be added from extension: -``` typescript +```typescript import { Main } from "@k8slens/extensions"; export default class SampleTrayMenuMainExtension extends Main.LensExtension { - trayMenus = [{ - label: "menu from the extension", - click: () => { console.log("tray menu clicked!") } - }] + trayMenus = [ + { + label: "menu from the extension", + click: () => { + console.log("tray menu clicked!"); + }, + }, + ]; } ``` ### `addCatalogSource()` and `removeCatalogSource()` Methods The `Main.LensExtension` class also provides the `addCatalogSource()` and `removeCatalogSource()` methods, for managing custom catalog items (or entities). -See the [`Catalog`](catalog.md) documentation for full details about the catalog. \ No newline at end of file +See the [`Catalog`](catalog.md) documentation for full details about the catalog. diff --git a/mkdocs.yml b/mkdocs.yml index 4ed763e6d3..b869a63ee9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,7 @@ nav: - Renderer Extension: extensions/guides/renderer-extension.md - Catalog: extensions/guides/catalog.md - Resource Stack: extensions/guides/resource-stack.md + - Extending KubernetesCluster: extensions/guides/extending-kubernetes-cluster.md - Stores: extensions/guides/stores.md - Working with MobX: extensions/guides/working-with-mobx.md - Protocol Handlers: extensions/guides/protocol-handlers.md diff --git a/package.json b/package.json index 0443b60eea..2667dea25b 100644 --- a/package.json +++ b/package.json @@ -340,6 +340,7 @@ "esbuild": "^0.13.15", "esbuild-loader": "^2.16.0", "eslint": "^7.32.0", + "eslint-import-resolver-typescript": "^2.5.0", "eslint-plugin-header": "^3.1.1", "eslint-plugin-import": "^2.25.3", "eslint-plugin-react": "^7.27.1", diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index ee93106adf..f11bd2d593 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -5,7 +5,6 @@ import fs from "fs"; import mockFs from "mock-fs"; -import yaml from "js-yaml"; import path from "path"; import fse from "fs-extra"; import type { Cluster } from "../cluster/cluster"; @@ -334,159 +333,6 @@ users: }); }); - describe("pre 2.0 config with an existing cluster", () => { - beforeEach(() => { - ClusterStore.resetInstance(); - - const mockOpts = { - "some-directory-for-user-data": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "1.0.0", - }, - }, - cluster1: minimalValidKubeConfig, - }), - }, - }; - - mockFs(mockOpts); - - clusterStore = mainDi.inject(clusterStoreInjectable); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it("migrates to modern format with kubeconfig in a file", async () => { - const config = clusterStore.clustersList[0].kubeConfigPath; - - expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[`); - }); - }); - - describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => { - beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { - "some-directory-for-user-data": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "2.4.1", - }, - }, - cluster1: { - kubeConfig: JSON.stringify({ - apiVersion: "v1", - clusters: [ - { - cluster: { - server: "https://10.211.55.6:8443", - }, - name: "minikube", - }, - ], - contexts: [ - { - context: { - cluster: "minikube", - user: "minikube", - name: "minikube", - }, - name: "minikube", - }, - ], - "current-context": "minikube", - kind: "Config", - preferences: {}, - users: [ - { - name: "minikube", - user: { - "client-certificate": "/Users/foo/.minikube/client.crt", - "client-key": "/Users/foo/.minikube/client.key", - "auth-provider": { - config: { - "access-token": ["should be string"], - expiry: ["should be string"], - }, - }, - }, - }, - ], - }), - }, - }), - }, - }; - - mockFs(mockOpts); - - clusterStore = mainDi.inject(clusterStoreInjectable); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it("replaces array format access token and expiry into string", async () => { - const file = clusterStore.clustersList[0].kubeConfigPath; - const config = fs.readFileSync(file, "utf8"); - const kc = yaml.load(config) as Record; - - expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe( - "should be string", - ); - expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe( - "should be string", - ); - }); - }); - - describe("pre 2.6.0 config with a cluster icon", () => { - beforeEach(() => { - ClusterStore.resetInstance(); - const mockOpts = { - "some-directory-for-user-data": { - "lens-cluster-store.json": JSON.stringify({ - __internal__: { - migrations: { - version: "2.4.1", - }, - }, - cluster1: { - kubeConfig: minimalValidKubeConfig, - icon: "icon_path", - preferences: { - terminalCWD: "/some-directory-for-user-data", - }, - }, - }), - icon_path: testDataIcon, - }, - }; - - mockFs(mockOpts); - - clusterStore = mainDi.inject(clusterStoreInjectable); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it("moves the icon into preferences", async () => { - const storedClusterData = clusterStore.clustersList[0]; - - expect(Object.prototype.hasOwnProperty.call(storedClusterData, "icon")).toBe(false); - expect(Object.prototype.hasOwnProperty.call(storedClusterData.preferences, "icon")).toBe(true); - expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true); - }); - }); - describe("pre 3.6.0-beta.1 config with an existing cluster", () => { beforeEach(() => { ClusterStore.resetInstance(); diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 2dfb3750ff..813d5d3866 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -59,8 +59,8 @@ export interface KubernetesClusterStatus extends CatalogEntityStatus { } export class KubernetesCluster extends CatalogEntity { - public static readonly apiVersion = "entity.k8slens.dev/v1alpha1"; - public static readonly kind = "KubernetesCluster"; + public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1"; + public static readonly kind: string = "KubernetesCluster"; public readonly apiVersion = KubernetesCluster.apiVersion; public readonly kind = KubernetesCluster.kind; diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 70ebd1aa6b..46ce8228ff 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -8,6 +8,7 @@ import type TypedEmitter from "typed-emitter"; import { observable, makeObservable } from "mobx"; import { once } from "lodash"; import { iter, Disposer } from "../utils"; +import type { CategoryColumnRegistration } from "../../renderer/components/+catalog/custom-category-columns"; type ExtractEntityMetadataType = Entity extends CatalogEntity ? Metadata : never; type ExtractEntityStatusType = Entity extends CatalogEntity ? Status : never; @@ -46,6 +47,7 @@ export interface CatalogCategorySpec { * The grouping for for the category. This MUST be a DNS label. */ group: string; + /** * The specific versions of the constructors. * @@ -54,6 +56,10 @@ export interface CatalogCategorySpec { * `name = "v1alpha1"` then the resulting `.apiVersion` MUST be `entity.k8slens.dev/v1alpha1` */ versions: CatalogCategoryVersion[]; + + /** + * This is the concerning the category + */ names: { /** * The kind of entity that this category is for. This value MUST be a DNS @@ -62,38 +68,107 @@ export interface CatalogCategorySpec { */ kind: string; }; + + /** + * These are the columns used for displaying entities when in the catalog. + * + * If this is not provided then some default columns will be used, similar in + * scope to the columns in the "Browse" view. + * + * Even if you provide columns, a "Name" column will be provided as well with + * `priority: 0`. + * + * These columns will not be used in the "Browse" view. + */ + displayColumns?: CategoryColumnRegistration[]; } /** - * If the filter returns true, the menu item is displayed + * If the filter return a thruthy value, the menu item is displayed */ export type AddMenuFilter = (menu: CatalogEntityAddMenu) => any; export interface CatalogCategoryEvents { + /** + * This event will be emitted when the category is loaded in the catalog + * view. + */ load: () => void; + + /** + * This event will be emitted when the catalog add menu is opened and is the + * way to added entries to that menu. + */ catalogAddMenu: (context: CatalogEntityAddMenuContext) => void; + + /** + * This event will be emitted when the context menu for an entity is declared + * by this category is opened. + */ contextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void; } export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter) { + /** + * The version of category that you are wanting to declare. + * + * Currently supported values: + * + * - `"catalog.k8slens.dev/v1alpha1"` + */ abstract readonly apiVersion: string; + + /** + * The kind of item you wish to declare. + * + * Currently supported values: + * + * - `"CatalogCategory"` + */ abstract readonly kind: string; - abstract metadata: { + + /** + * The data about the category itself + */ + abstract readonly metadata: { + /** + * The name of your category. The category can be searched for by this + * value. This will also be used for the catalog menu. + */ name: string; + + /** + * Either an `` or the name of an icon from {@link IconProps} + */ icon: string; }; + + /** + * The most important part of a category, as it is where entity versions are declared. + */ abstract spec: CatalogCategorySpec; + /** + * @internal + */ protected filters = observable.set([], { deep: false, }); - static parseId(id = ""): { group?: string, kind?: string } { + /** + * Parse a category ID into parts. + * @param id The id of a category is parse + * @returns The group and kind parts of the ID + */ + public static parseId(id: string): { group?: string, kind?: string } { const [group, kind] = id.split("/") ?? []; return { group, kind }; } + /** + * Get the ID of this category + */ public getId(): string { return `${this.spec.group}/${this.spec.names.kind}`; } diff --git a/src/common/user-store/preferences-helpers.ts b/src/common/user-store/preferences-helpers.ts index da4b7893b7..c2e9adce7e 100644 --- a/src/common/user-store/preferences-helpers.ts +++ b/src/common/user-store/preferences-helpers.ts @@ -67,6 +67,15 @@ const colorTheme: PreferenceDescription = { }, }; +const terminalTheme: PreferenceDescription = { + fromStore(val) { + return val || ""; + }, + toStore(val) { + return val || undefined; + }, +}; + const localeTimezone: PreferenceDescription = { fromStore(val) { return val || moment.tz.guess(true) || "UTC"; @@ -335,6 +344,7 @@ export const DESCRIPTORS = { httpsProxy, shell, colorTheme, + terminalTheme, localeTimezone, allowUntrustedCAs, allowTelemetry, diff --git a/src/common/user-store/user-store.ts b/src/common/user-store/user-store.ts index c6e0051e99..b0726d62ca 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -49,6 +49,7 @@ export class UserStore extends BaseStore /* implements UserStore @observable allowErrorReporting: boolean; @observable allowUntrustedCAs: boolean; @observable colorTheme: string; + @observable terminalTheme: string; @observable localeTimezone: string; @observable downloadMirror: string; @observable httpsProxy?: string; @@ -170,6 +171,7 @@ export class UserStore extends BaseStore /* implements UserStore this.httpsProxy = DESCRIPTORS.httpsProxy.fromStore(preferences?.httpsProxy); this.shell = DESCRIPTORS.shell.fromStore(preferences?.shell); this.colorTheme = DESCRIPTORS.colorTheme.fromStore(preferences?.colorTheme); + this.terminalTheme = DESCRIPTORS.terminalTheme.fromStore(preferences?.terminalTheme); this.localeTimezone = DESCRIPTORS.localeTimezone.fromStore(preferences?.localeTimezone); this.allowUntrustedCAs = DESCRIPTORS.allowUntrustedCAs.fromStore(preferences?.allowUntrustedCAs); this.allowTelemetry = DESCRIPTORS.allowTelemetry.fromStore(preferences?.allowTelemetry); @@ -194,6 +196,7 @@ export class UserStore extends BaseStore /* implements UserStore httpsProxy: DESCRIPTORS.httpsProxy.toStore(this.httpsProxy), shell: DESCRIPTORS.shell.toStore(this.shell), colorTheme: DESCRIPTORS.colorTheme.toStore(this.colorTheme), + terminalTheme: DESCRIPTORS.terminalTheme.toStore(this.terminalTheme), localeTimezone: DESCRIPTORS.localeTimezone.toStore(this.localeTimezone), allowUntrustedCAs: DESCRIPTORS.allowUntrustedCAs.toStore(this.allowUntrustedCAs), allowTelemetry: DESCRIPTORS.allowTelemetry.toStore(this.allowTelemetry), diff --git a/src/common/utils/__tests__/bind.test.ts b/src/common/utils/__tests__/bind.test.ts new file mode 100644 index 0000000000..77850a88d9 --- /dev/null +++ b/src/common/utils/__tests__/bind.test.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { bind } from "../index"; + +describe("bind", () => { + it("should work correctly", () => { + function foobar(bound: number, nonBound: number): number { + expect(typeof bound).toBe("number"); + expect(typeof nonBound).toBe("number"); + + return bound + nonBound; + } + const foobarBound = bind(foobar, null, 5); + + expect(foobarBound(10)).toBe(15); + }); +}); diff --git a/src/common/utils/collection-functions.ts b/src/common/utils/collection-functions.ts new file mode 100644 index 0000000000..205f7f0cb1 --- /dev/null +++ b/src/common/utils/collection-functions.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +/** + * Get the value behind `key`. If it was not present, first insert `value` + * @param map The map to interact with + * @param key The key to insert into the map with + * @param value The value to optional add to the map + * @returns The value in the map + */ +export function getOrInsert(map: Map, key: K, value: V): V { + if (!map.has(key)) { + map.set(key, value); + } + + return map.get(key); +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 630c93160b..1400608aab 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -10,11 +10,19 @@ export function noop(...args: T): void { return void args; } +/** + * A typecorrect version of .bind() + */ +export function bind(fn: (...args: [...BoundArgs, ...NonBoundArgs]) => ReturnType, thisArg: any, ...boundArgs: BoundArgs): (...args: NonBoundArgs) => ReturnType { + return fn.bind(thisArg, ...boundArgs); +} + export * from "./app-version"; export * from "./autobind"; export * from "./camelCase"; export * from "./cloneJson"; export * from "./cluster-id-url-parsing"; +export * from "./collection-functions"; export * from "./convertCpu"; export * from "./convertMemory"; export * from "./debouncePromise"; diff --git a/src/extensions/common-api/registrations.ts b/src/extensions/common-api/registrations.ts index be1f24d025..666ed01a26 100644 --- a/src/extensions/common-api/registrations.ts +++ b/src/extensions/common-api/registrations.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export type { AppPreferenceRegistration, AppPreferenceComponents } from "../registries/app-preference-registry"; +export type { AppPreferenceRegistration, AppPreferenceComponents } from "../../renderer/components/+preferences/app-preferences/app-preference-registration"; 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"; diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index d961991dcb..0708e11c8e 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -269,7 +269,6 @@ export class ExtensionLoader { return this.autoInitExtensions(async (extension: LensRendererExtension) => { const removeItems = [ registries.GlobalPageRegistry.getInstance().add(extension.globalPages, extension), - registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences), registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), @@ -291,11 +290,12 @@ export class ExtensionLoader { }); }; - loadOnClusterRenderer = (entity: KubernetesCluster) => { + loadOnClusterRenderer = (getCluster: () => KubernetesCluster) => { logger.debug(`${logModule}: load on cluster renderer (dashboard)`); this.autoInitExtensions(async (extension: LensRendererExtension) => { - if ((await extension.isEnabledForCluster(entity)) === false) { + // getCluster must be a callback, as the entity might be available only after an extension has been loaded + if ((await extension.isEnabledForCluster(getCluster())) === false) { return []; } @@ -324,11 +324,15 @@ export class ExtensionLoader { this.extensions.get(extension.id).availableUpdate = await extension.checkForUpdate(); } - protected autoInitExtensions(register: (ext: LensExtension) => Promise) { - const loadingExtensions: ExtensionLoading[] = []; + protected async loadExtensions(installedExtensions: Map, register: (ext: LensExtension) => Promise) { + // Steps of the function: + // 1. require and call .activate for each Extension + // 2. Wait until every extension's onActivate has been resolved + // 3. Call .enable for each extension + // 4. Return ExtensionLoading[] - reaction(() => this.toJSON(), async installedExtensions => { - for (const [extId, extension] of installedExtensions) { + const extensions = [...installedExtensions.entries()] + .map(([extId, extension]) => { const alreadyInit = this.instances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); if (extension.isCompatible && extension.isEnabled && !alreadyInit) { @@ -337,7 +341,8 @@ export class ExtensionLoader { if (!LensExtensionClass) { this.nonInstancesByName.add(extension.manifest.name); - continue; + + return null; } // const instance = new LensExtensionClass(extension, this.extensionUpdateChecker); @@ -347,27 +352,49 @@ export class ExtensionLoader { this.extensionUpdateChecker, ); - const loaded = instance.enable(register).catch((err) => { - logger.error(`${logModule}: failed to enable`, { ext: extension, err }); - }); - - loadingExtensions.push({ - isBundled: extension.isBundled, - loaded, - }); this.instances.set(extId, instance); + + return { + extId, + instance, + isBundled: extension.isBundled, + activated: instance.activate(), + }; } catch (err) { logger.error(`${logModule}: activation extension error`, { ext: extension, err }); } } else if (!extension.isEnabled && alreadyInit) { this.removeInstance(extId); } - } - }, { - fireImmediately: true, - }); - return loadingExtensions; + return null; + }) + // Remove null values + .filter(extension => Boolean(extension)); + + // We first need to wait until each extension's `onActivate` is resolved, + // as this might register new catalog categories. Afterwards we can safely .enable the extension. + await Promise.all(extensions.map(extension => extension.activated)); + + // Return ExtensionLoading[] + return extensions.map(extension => { + const loaded = extension.instance.enable(register).catch((err) => { + logger.error(`${logModule}: failed to enable`, { ext: extension, err }); + }); + + return { + isBundled: extension.isBundled, + loaded, + }; + }); + } + + protected autoInitExtensions(register: (ext: LensExtension) => Promise) { + // Setup reaction to load extensions on JSON changes + reaction(() => this.toJSON(), installedExtensions => this.loadExtensions(installedExtensions, register)); + + // Load initial extensions + return this.loadExtensions(this.toJSON(), register); } protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null { diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 1973a324f4..def4ac1d9a 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -91,7 +91,6 @@ export class LensExtension { } try { - await this.onActivate(); this._isEnabled = true; this[Disposers].push(...await register(this)); @@ -118,6 +117,12 @@ export class LensExtension { } } + @action + activate() { + return this.onActivate(); + } + + public async checkForUpdate() { return this.updateChecker?.run(this.manifest); } diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 9504b7b70a..fe0795c2c1 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -15,13 +15,15 @@ import type { KubernetesCluster } from "../common/catalog-entities"; import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration"; import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands"; +import type { AppPreferenceRegistration } from "../renderer/components/+preferences/app-preferences/app-preference-registration"; +import type { AdditionalCategoryColumnRegistration } from "../renderer/components/+catalog/custom-category-columns"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; clusterPages: registries.PageRegistration[] = []; clusterPageMenus: registries.ClusterPageMenuRegistration[] = []; kubeObjectStatusTexts: registries.KubeObjectStatusRegistration[] = []; - appPreferences: registries.AppPreferenceRegistration[] = []; + appPreferences: AppPreferenceRegistration[] = []; entitySettings: registries.EntitySettingRegistration[] = []; statusBarItems: registries.StatusBarRegistration[] = []; kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = []; @@ -32,6 +34,7 @@ export class LensRendererExtension extends LensExtension { welcomeBanners: WelcomeBannerRegistration[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; topBarItems: TopBarRegistration[] = []; + additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = []; async navigate

(pageId?: string, params?: P) { const { navigate } = await import("../renderer/navigation"); diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index 2d49670973..477f406b2c 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -7,7 +7,6 @@ export * from "./page-registry"; export * from "./page-menu-registry"; -export * from "./app-preference-registry"; export * from "./status-bar-registry"; export * from "./kube-object-detail-registry"; export * from "./kube-object-menu-registry"; diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 3181e7144a..79372c71dd 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -31,6 +31,11 @@ export * from "../../renderer/components/input/input"; // command-overlay export const CommandOverlay = asLegacyGlobalObjectForExtensionApi(commandOverlayInjectable); +export type { + CategoryColumnRegistration, + AdditionalCategoryColumnRegistration, +} from "../../renderer/components/+catalog/custom-category-columns"; + // other components export * from "../../renderer/components/icon"; export * from "../../renderer/components/tooltip"; diff --git a/src/extensions/renderer-extensions.injectable.ts b/src/extensions/renderer-extensions.injectable.ts index 24e3bd0557..3f06df5ca8 100644 --- a/src/extensions/renderer-extensions.injectable.ts +++ b/src/extensions/renderer-extensions.injectable.ts @@ -8,10 +8,8 @@ import extensionsInjectable from "./extensions.injectable"; import type { LensRendererExtension } from "./lens-renderer-extension"; const rendererExtensionsInjectable = getInjectable({ + instantiate: (di) => di.inject(extensionsInjectable) as IComputedValue, lifecycle: lifecycleEnum.singleton, - - instantiate: (di) => - di.inject(extensionsInjectable) as IComputedValue, }); export default rendererExtensionsInjectable; diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index 9e9c92f526..65f60d8e4e 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -4,7 +4,7 @@ */ import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; -import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor, CatalogEntityKindData } from "../../common/catalog"; +import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor } from "../../common/catalog"; import { iter } from "../../common/utils"; export class CatalogEntityRegistry { @@ -43,8 +43,8 @@ export class CatalogEntityRegistry { return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; } - getItemsByEntityClass({ apiVersion, kind }: CatalogEntityKindData & CatalogEntityConstructor): T[] { - return this.getItemsForApiKind(apiVersion, kind); + getItemsByEntityClass(constructor: CatalogEntityConstructor): T[] { + return this.items.filter((item) => item instanceof constructor) as T[]; } } diff --git a/src/migrations/cluster-store/2.0.0-beta.2.ts b/src/migrations/cluster-store/2.0.0-beta.2.ts deleted file mode 100644 index dafd87bf7f..0000000000 --- a/src/migrations/cluster-store/2.0.0-beta.2.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { MigrationDeclaration } from "../helpers"; - -/** - * Early store format had the kubeconfig directly under context name, this moves - * it under the kubeConfig key - */ - -export default { - version: "2.0.0-beta.2", - run(store) { - for (const value of store) { - const contextName = value[0]; - - // Looping all the keys gives out the store internal stuff too... - if (contextName === "__internal__" || Object.prototype.hasOwnProperty.call(value[1], "kubeConfig")) continue; - store.set(contextName, { kubeConfig: value[1] }); - } - }, -} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.4.1.ts b/src/migrations/cluster-store/2.4.1.ts deleted file mode 100644 index d53d2536e5..0000000000 --- a/src/migrations/cluster-store/2.4.1.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { MigrationDeclaration } from "../helpers"; - -// Cleans up a store that had the state related data stored - -export default { - version: "2.4.1", - run(store) { - for (const value of store) { - const contextName = value[0]; - - if (contextName === "__internal__") continue; - const cluster = value[1]; - - store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {}}); - } - }, -} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.6.0-beta.2.ts b/src/migrations/cluster-store/2.6.0-beta.2.ts deleted file mode 100644 index a91c97a3a7..0000000000 --- a/src/migrations/cluster-store/2.6.0-beta.2.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// Move cluster icon from root to preferences -import type { MigrationDeclaration } from "../helpers"; - -export default { - version: "2.6.0-beta.2", - run(store) { - for (const value of store) { - const clusterKey = value[0]; - - if (clusterKey === "__internal__") continue; - const cluster = value[1]; - - if (!cluster.preferences) cluster.preferences = {}; - - if (cluster.icon) { - cluster.preferences.icon = cluster.icon; - delete (cluster["icon"]); - } - store.set(clusterKey, { contextName: clusterKey, kubeConfig: value[1].kubeConfig, preferences: value[1].preferences }); - } - }, -} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.6.0-beta.3.ts b/src/migrations/cluster-store/2.6.0-beta.3.ts deleted file mode 100644 index 56d6f39e37..0000000000 --- a/src/migrations/cluster-store/2.6.0-beta.3.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import yaml from "js-yaml"; -import { MigrationDeclaration, migrationLog } from "../helpers"; - -export default { - version: "2.6.0-beta.3", - run(store) { - for (const value of store) { - const clusterKey = value[0]; - - if (clusterKey === "__internal__") continue; - const cluster = value[1]; - - if (!cluster.kubeConfig) continue; - const config = yaml.load(cluster.kubeConfig); - - if (!config || typeof config !== "object" || !Object.prototype.hasOwnProperty.call(config, "users")) { - continue; - } - - const kubeConfig = config as Record; - const userObj = kubeConfig.users[0]; - - if (userObj) { - const user = userObj.user; - - if (user["auth-provider"] && user["auth-provider"].config) { - const authConfig = user["auth-provider"].config; - - if (authConfig["access-token"]) { - authConfig["access-token"] = `${authConfig["access-token"]}`; - } - - if (authConfig.expiry) { - authConfig.expiry = `${authConfig.expiry}`; - } - migrationLog(authConfig); - user["auth-provider"].config = authConfig; - kubeConfig.users = [{ - name: userObj.name, - user, - }]; - cluster.kubeConfig = yaml.dump(kubeConfig); - store.set(clusterKey, cluster); - } - } - } - }, -} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.7.0-beta.0.ts b/src/migrations/cluster-store/2.7.0-beta.0.ts deleted file mode 100644 index 591ce312f7..0000000000 --- a/src/migrations/cluster-store/2.7.0-beta.0.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// Add existing clusters to "default" workspace -import type { MigrationDeclaration } from "../helpers"; - -export default { - version: "2.7.0-beta.0", - run(store) { - for (const value of store) { - const clusterKey = value[0]; - - if (clusterKey === "__internal__") continue; - const cluster = value[1]; - - cluster.workspace = "default"; - store.set(clusterKey, cluster); - } - }, -} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.7.0-beta.1.ts b/src/migrations/cluster-store/2.7.0-beta.1.ts deleted file mode 100644 index 1270c3c712..0000000000 --- a/src/migrations/cluster-store/2.7.0-beta.1.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// Add id for clusters and store them to array -import { v4 as uuid } from "uuid"; -import type { MigrationDeclaration } from "../helpers"; - -export default { - version: "2.7.0-beta.1", - run(store) { - const clusters: any[] = []; - - for (const value of store) { - const clusterKey = value[0]; - - if (clusterKey === "__internal__") continue; - if (clusterKey === "clusters") continue; - const cluster = value[1]; - - cluster.id = uuid(); - - if (!cluster.preferences.clusterName) { - cluster.preferences.clusterName = clusterKey; - } - clusters.push(cluster); - store.delete(clusterKey); - } - - if (clusters.length > 0) { - store.set("clusters", clusters); - } - }, -} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/index.ts b/src/migrations/cluster-store/index.ts index 21b3b31e78..4851d01cae 100644 --- a/src/migrations/cluster-store/index.ts +++ b/src/migrations/cluster-store/index.ts @@ -7,24 +7,12 @@ import { joinMigrations } from "../helpers"; -import version200Beta2 from "./2.0.0-beta.2"; -import version241 from "./2.4.1"; -import version260Beta2 from "./2.6.0-beta.2"; -import version260Beta3 from "./2.6.0-beta.3"; -import version270Beta0 from "./2.7.0-beta.0"; -import version270Beta1 from "./2.7.0-beta.1"; import version360Beta1 from "./3.6.0-beta.1"; import version500Beta10 from "./5.0.0-beta.10"; import version500Beta13 from "./5.0.0-beta.13"; import snap from "./snap"; export default joinMigrations( - version200Beta2, - version241, - version260Beta2, - version260Beta3, - version270Beta0, - version270Beta1, version360Beta1, version500Beta10, version500Beta13, diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index e6becb6cc1..85477245a4 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -47,10 +47,25 @@ export class CatalogEntityRegistry { makeObservable(this); } - get activeEntity(): CatalogEntity | null { + protected getActiveEntityById() { return this._entities.get(this.activeEntityId) || null; } + get activeEntity(): CatalogEntity | null { + const entity = this.getActiveEntityById(); + + // If the entity was not found but there are rawEntities to be processed, + // try to process them and return the entity. + // This might happen if an extension registered a new Catalog category. + if (this.activeEntityId && !entity && this.rawEntities.length > 0) { + this.processRawEntities(); + + return this.getActiveEntityById(); + } + + return entity; + } + set activeEntity(raw: CatalogEntity | string | null) { if (raw) { const id = typeof raw === "string" diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 81f4a52e45..9be509ab61 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -98,9 +98,6 @@ export async function bootstrap(di: DependencyInjectionContainer) { logger.info(`${logPrefix} initializing IpcRendererListeners`); initializers.initIpcRendererListeners(extensionLoader); - logger.info(`${logPrefix} initializing StatusBarRegistry`); - initializers.initStatusBarRegistry(); - extensionLoader.init(); const extensionDiscovery = di.inject(extensionDiscoveryInjectable); diff --git a/src/renderer/components/+catalog/__tests__/custom-columns.test.ts b/src/renderer/components/+catalog/__tests__/custom-columns.test.ts new file mode 100644 index 0000000000..00fd5e033b --- /dev/null +++ b/src/renderer/components/+catalog/__tests__/custom-columns.test.ts @@ -0,0 +1,123 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import type { CatalogCategorySpec } from "../../../../common/catalog"; +import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +import { CatalogCategory } from "../../../api/catalog-entity"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns"; +import getCategoryColumnsInjectable, { CategoryColumns, GetCategoryColumnsParams } from "../get-category-columns.injectable"; + +class TestCategory extends CatalogCategory { + apiVersion = "catalog.k8slens.dev/v1alpha1"; + kind = "CatalogCategory"; + metadata: { + name: "Test"; + icon: "question_mark"; + }; + spec: CatalogCategorySpec = { + group: "foo.bar.bat", + names: { + kind: "Test", + }, + versions: [], + }; + + constructor(columns?: CategoryColumnRegistration[]) { + super(); + this.spec.displayColumns = columns; + } +} + +describe("Custom Category Columns", () => { + let di: ConfigurableDependencyInjectionContainer; + + beforeEach(() => { + di = getDiForUnitTesting(); + }); + + describe("without extensions", () => { + let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns; + + beforeEach(() => { + di.override(rendererExtensionsInjectable, () => computed(() => [] as LensRendererExtension[])); + getCategoryColumns = di.inject(getCategoryColumnsInjectable); + }); + + it("should contain a kind column if activeCategory is falsy", () => { + expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem.title === "Kind")).toBeTruthy(); + }); + + it("should not contain a kind column if activeCategory is truthy", () => { + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "Kind")).toBeFalsy(); + }); + + it("should include the default columns if the provided category doesn't provide any", () => { + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "Source")).toBeTruthy(); + }); + + it("should not include the default columns if the provided category provides any", () => { + expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem.title === "Source")).toBeFalsy(); + }); + + it("should include the displayColumns from the provided category", () => { + const columns: CategoryColumnRegistration[] = [ + { + id: "foo", + renderCell: () => null, + titleProps: { + title: "Foo", + }, + }, + ]; + + expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem.title === "Foo")).toBeTruthy(); + }); + }); + + describe("with extensions", () => { + let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns; + + beforeEach(() => { + di.override(rendererExtensionsInjectable, () => computed(() => [ + { + name: "test-extension", + additionalCategoryColumns: [ + { + group: "foo.bar.bat", + id: "high", + kind: "Test", + renderCell: () => "", + titleProps: { + title: "High", + }, + } as AdditionalCategoryColumnRegistration, + { + group: "foo.bar", + id: "high", + kind: "Test", + renderCell: () => "", + titleProps: { + title: "High2", + }, + } as AdditionalCategoryColumnRegistration, + ], + } as LensRendererExtension, + ])); + getCategoryColumns = di.inject(getCategoryColumnsInjectable); + }); + + it("should include columns from extensions that match", () => { + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "High")).toBeTruthy(); + }); + + it("should not include columns from extensions that don't match", () => { + expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "High2")).toBeFalsy(); + }); + }); +}); diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index ae6ab3795f..107e02db30 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -28,25 +28,18 @@ import { RenderDelay } from "../render-delay/render-delay"; import { Icon } from "../icon"; import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item"; import { Avatar } from "../avatar"; -import { KubeObject } from "../../../common/k8s-api/kube-object"; -import { getLabelBadges } from "./helpers"; import { withInjectables } from "@ogre-tools/injectable-react"; -import catalogPreviousActiveTabStorageInjectable - from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable"; +import catalogPreviousActiveTabStorageInjectable from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable"; import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; - -enum sortBy { - name = "name", - kind = "kind", - source = "source", - status = "status", -} +import type { GetCategoryColumnsParams, CategoryColumns } from "./get-category-columns.injectable"; +import getCategoryColumnsInjectable from "./get-category-columns.injectable"; interface Props extends RouteComponentProps {} interface Dependencies { - catalogPreviousActiveTabStorage: { set: (value: string ) => void } - catalogEntityStore: CatalogEntityStore + catalogPreviousActiveTabStorage: { set: (value: string ) => void }; + catalogEntityStore: CatalogEntityStore; + getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns; } @observer @@ -228,46 +221,23 @@ class NonInjectedCatalog extends React.Component { return null; } + const { sortingCallbacks, searchFilters, renderTableContents, renderTableHeader } = this.props.getCategoryColumns({ activeCategory }); + return ( entity.getName(), - [sortBy.source]: entity => entity.getSource(), - [sortBy.status]: entity => entity.status.phase, - [sortBy.kind]: entity => entity.kind, - }} - searchFilters={[ - entity => [ - entity.getName(), - entity.getId(), - entity.status.phase, - `source=${entity.getSource()}`, - ...KubeObject.stringifyLabels(entity.metadata.labels), - ], - ]} - renderTableHeader={[ - { title: "Name", className: styles.entityName, sortBy: sortBy.name, id: "name" }, - !activeCategory && { title: "Kind", sortBy: sortBy.kind, id: "kind" }, - { title: "Source", className: styles.sourceCell, sortBy: sortBy.source, id: "source" }, - { title: "Labels", className: `${styles.labelsCell} scrollable`, id: "labels" }, - { title: "Status", className: styles.statusCell, sortBy: sortBy.status, id: "status" }, - ].filter(Boolean)} + sortingCallbacks={sortingCallbacks} + searchFilters={searchFilters} + renderTableHeader={renderTableHeader} customizeTableRowProps={entity => ({ disabled: !entity.isEnabled(), })} - renderTableContents={entity => [ - this.renderName(entity), - !activeCategory && entity.kind, - entity.getSource(), - getLabelBadges(entity), - {entity.status.phase}, - ].filter(Boolean)} + renderTableContents={renderTableContents} onDetails={this.onDetails} renderItemMenu={this.renderItemMenu} /> @@ -306,17 +276,11 @@ class NonInjectedCatalog extends React.Component { } } -export const Catalog = withInjectables( - NonInjectedCatalog, - { - getProps: (di, props) => ({ - catalogEntityStore: di.inject(catalogEntityStoreInjectable), - - catalogPreviousActiveTabStorage: di.inject( - catalogPreviousActiveTabStorageInjectable, - ), - - ...props, - }), - }, -); +export const Catalog = withInjectables( NonInjectedCatalog, { + getProps: (di, props) => ({ + catalogEntityStore: di.inject(catalogEntityStoreInjectable), + catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable), + getCategoryColumns: di.inject(getCategoryColumnsInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+catalog/custom-category-columns.injectable.tsx b/src/renderer/components/+catalog/custom-category-columns.injectable.tsx new file mode 100644 index 0000000000..26d4a304d7 --- /dev/null +++ b/src/renderer/components/+catalog/custom-category-columns.injectable.tsx @@ -0,0 +1,54 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed, IComputedValue } from "mobx"; +import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension"; +import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable"; +import { getOrInsert } from "../../utils"; +import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; + +interface Dependencies { + extensions: IComputedValue; +} + +function getAdditionCategoryColumns({ extensions }: Dependencies): IComputedValue>> { + return computed(() => { + const res = new Map>(); + + for (const ext of extensions.get()) { + for (const { renderCell, titleProps, priority = 50, searchFilter, sortCallback, ...registration } of ext.additionalCategoryColumns) { + const byGroup = getOrInsert(res, registration.group, new Map()); + const byKind = getOrInsert(byGroup, registration.kind, []); + const id = `${ext.name}:${registration.id}`; + + byKind.push({ + renderCell, + priority, + id, + titleProps: { + id, + ...titleProps, + sortBy: sortCallback + ? id + : undefined, + }, + searchFilter, + sortCallback, + }); + } + } + + return res; + }); +} + +const categoryColumnsInjectable = getInjectable({ + instantiate: (di) => getAdditionCategoryColumns({ + extensions: di.inject(rendererExtensionsInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default categoryColumnsInjectable; diff --git a/src/renderer/components/+catalog/custom-category-columns.ts b/src/renderer/components/+catalog/custom-category-columns.ts new file mode 100644 index 0000000000..3d8a832ac0 --- /dev/null +++ b/src/renderer/components/+catalog/custom-category-columns.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type React from "react"; +import type { CatalogEntity } from "../../../common/catalog"; +import type { TableCellProps } from "../table"; + +/** + * These are the supported props for the title cell + */ +export interface TitleCellProps { + className?: string; + title: React.ReactNode; +} + +export interface CategoryColumnRegistration { + /** + * The sorting order value. + * + * @default 50 + */ + priority?: number; + + /** + * This value MUST to be unique to your extension + */ + id: string; + + /** + * This function will be called to generate the cells (on demand) for the column + */ + renderCell: (entity: CatalogEntity) => React.ReactNode; + + /** + * This function will be used to generate the columns title cell. + */ + titleProps: TitleCellProps; + + /** + * If provided then the column will support sorting and this function will be called to + * determine a row's ordering. + * + * strings are sorted ahead of numbers, and arrays determine ordering between equal + * elements of the previous index. + */ + sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[]; + + /** + * If provided then searching is supported on this column and this function will be called + * to determine if the current search string matches for this row. + */ + searchFilter?: (entity: CatalogEntity) => string | string[]; +} + +/** + * This is the type used to declare new catalog category columns + */ +export interface AdditionalCategoryColumnRegistration extends CategoryColumnRegistration { + /** + * The catalog entity kind that is declared by the category for this registration + * + * e.g. + * - `"KubernetesCluster"` + */ + kind: string; + + /** + * The catalog entity group that is declared by the category for this registration + * + * e.g. + * - `"entity.k8slens.dev"` + */ + group: string; +} + +export interface RegisteredAdditionalCategoryColumn { + id: string; + priority: number; + renderCell: (entity: CatalogEntity) => React.ReactNode; + titleProps: TableCellProps; + sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[]; + searchFilter?: (entity: CatalogEntity) => string | string[]; +} diff --git a/src/renderer/components/+catalog/get-category-columns.injectable.ts b/src/renderer/components/+catalog/get-category-columns.injectable.ts new file mode 100644 index 0000000000..66fe0cddee --- /dev/null +++ b/src/renderer/components/+catalog/get-category-columns.injectable.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { orderBy } from "lodash"; +import type { IComputedValue } from "mobx"; +import type { CatalogCategory, CatalogEntity } from "../../../common/catalog"; +import { bind } from "../../utils"; +import type { ItemListLayoutProps } from "../item-object-list"; +import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; +import categoryColumnsInjectable from "./custom-category-columns.injectable"; +import { defaultCategoryColumns, browseAllColumns, nameCategoryColumn } from "./internal-category-columns"; + +interface Dependencies { + extensionColumns: IComputedValue>>; +} + +export interface GetCategoryColumnsParams { + activeCategory: CatalogCategory | null | undefined; +} + +export type CategoryColumns = Required, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>; + +function getSpecificCategoryColumns(activeCategory: CatalogCategory, extensionColumns: IComputedValue>>): RegisteredAdditionalCategoryColumn[] { + const fromExtensions = ( + extensionColumns + .get() + .get(activeCategory.spec.group) + ?.get(activeCategory.spec.names.kind) + ?? [] + ); + const fromCategory = activeCategory.spec.displayColumns?.map(({ priority = 50, ...column }) => ({ + priority, + ...column, + })) ?? defaultCategoryColumns; + + return [ + nameCategoryColumn, + ...fromExtensions, + ...fromCategory, + ]; +} + +function getBrowseAllColumns(): RegisteredAdditionalCategoryColumn[] { + return [ + ...browseAllColumns, + nameCategoryColumn, + ...defaultCategoryColumns, + ]; +} + +function getCategoryColumns({ extensionColumns }: Dependencies, { activeCategory }: GetCategoryColumnsParams): CategoryColumns { + const allRegistrations = orderBy( + activeCategory + ? getSpecificCategoryColumns(activeCategory, extensionColumns) + : getBrowseAllColumns(), + "priority", + "asc", + ); + + const sortingCallbacks: CategoryColumns["sortingCallbacks"] = {}; + const searchFilters: CategoryColumns["searchFilters"] = []; + const renderTableHeader: CategoryColumns["renderTableHeader"] = []; + const tableRowRenderers: ((entity: CatalogEntity) => React.ReactNode)[] = []; + + for (const registration of allRegistrations) { + if (registration.sortCallback) { + sortingCallbacks[registration.id] = registration.sortCallback; + } + + if (registration.searchFilter) { + searchFilters.push(registration.searchFilter); + } + + tableRowRenderers.push(registration.renderCell); + renderTableHeader.push(registration.titleProps); + } + + return { + sortingCallbacks, + renderTableHeader, + renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)), + searchFilters, + }; +} + +const getCategoryColumnsInjectable = getInjectable({ + instantiate: (di) => bind(getCategoryColumns, null, { + extensionColumns: di.inject(categoryColumnsInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default getCategoryColumnsInjectable; diff --git a/src/renderer/components/+catalog/internal-category-columns.tsx b/src/renderer/components/+catalog/internal-category-columns.tsx new file mode 100644 index 0000000000..6aee0acc1d --- /dev/null +++ b/src/renderer/components/+catalog/internal-category-columns.tsx @@ -0,0 +1,121 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import styles from "./catalog.module.scss"; + +import React from "react"; +import { HotbarStore } from "../../../common/hotbar-store"; +import type { CatalogEntity } from "../../api/catalog-entity"; +import { Avatar } from "../avatar"; +import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; +import { Icon } from "../icon"; +import { prevDefault } from "../../utils"; +import { getLabelBadges } from "./helpers"; +import { KubeObject } from "../../../common/k8s-api/kube-object"; + +function renderEntityName(entity: CatalogEntity) { + const hotbarStore = HotbarStore.getInstance(); + const isItemInHotbar = hotbarStore.isAddedToActive(entity); + const onClick = prevDefault( + isItemInHotbar + ? () => hotbarStore.removeFromHotbar(entity.getId()) + : () => hotbarStore.addToHotbar(entity), + ); + + return ( + <> + + {entity.spec.icon?.material && } + + {entity.getName()} + + + ); +} + +export const browseAllColumns: RegisteredAdditionalCategoryColumn[] = [ + { + id: "kind", + priority: 5, + renderCell: entity => entity.kind, + titleProps: { + id: "kind", + sortBy: "kind", + title: "Kind", + }, + sortCallback: entity => entity.kind, + }, +]; + +export const nameCategoryColumn: RegisteredAdditionalCategoryColumn = { + id: "name", + priority: 0, + renderCell: renderEntityName, + titleProps: { + title: "Name", + className: styles.entityName, + id: "name", + sortBy: "name", + }, + searchFilter: entity => entity.getName(), + sortCallback: entity => `name=${entity.getName()}`, +}; + +export const defaultCategoryColumns: RegisteredAdditionalCategoryColumn[] = [ + { + id: "source", + priority: 10, + renderCell: entity => entity.getSource(), + titleProps: { + title: "Source", + className: styles.sourceCell, + id: "source", + sortBy: "source", + }, + sortCallback: entity => entity.getSource(), + searchFilter: entity => `source=${entity.getSource()}`, + }, + { + id: "labels", + priority: 20, + renderCell: getLabelBadges, + titleProps: { + id: "labels", + title: "Labels", + className: `${styles.labelsCell} scrollable`, + }, + searchFilter: entity => KubeObject.stringifyLabels(entity.metadata.labels), + }, + { + id: "status", + priority: 30, + renderCell: entity => ( + + {entity.status.phase} + + ), + titleProps: { + title: "Status", + className: styles.statusCell, + id: "status", + sortBy: "status", + }, + searchFilter: entity => entity.status.phase, + }, +]; diff --git a/src/extensions/registries/app-preference-registry.ts b/src/renderer/components/+preferences/app-preferences/app-preference-registration.d.ts similarity index 60% rename from src/extensions/registries/app-preference-registry.ts rename to src/renderer/components/+preferences/app-preferences/app-preference-registration.d.ts index f533418ca6..4c76722dd5 100644 --- a/src/extensions/registries/app-preference-registry.ts +++ b/src/renderer/components/+preferences/app-preferences/app-preference-registration.d.ts @@ -4,7 +4,6 @@ */ import type React from "react"; -import { BaseRegistry } from "./base-registry"; export interface AppPreferenceComponents { Hint: React.ComponentType; @@ -22,11 +21,3 @@ export interface RegisteredAppPreference extends AppPreferenceRegistration { id: string; } -export class AppPreferenceRegistry extends BaseRegistry { - getRegisteredItem(item: AppPreferenceRegistration): RegisteredAppPreference { - return { - id: item.id || item.title.toLowerCase().replace(/[^0-9a-zA-Z]+/g, "-"), - ...item, - }; - } -} diff --git a/src/renderer/components/+preferences/app-preferences/app-preferences.injectable.ts b/src/renderer/components/+preferences/app-preferences/app-preferences.injectable.ts new file mode 100644 index 0000000000..fc9deb8656 --- /dev/null +++ b/src/renderer/components/+preferences/app-preferences/app-preferences.injectable.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +import { getAppPreferences } from "./get-app-preferences"; + +const appPreferencesInjectable = getInjectable({ + instantiate: (di) => + getAppPreferences({ + extensions: di.inject(rendererExtensionsInjectable), + }), + + lifecycle: lifecycleEnum.singleton, +}); + +export default appPreferencesInjectable; diff --git a/src/renderer/components/+preferences/app-preferences/get-app-preferences.ts b/src/renderer/components/+preferences/app-preferences/get-app-preferences.ts new file mode 100644 index 0000000000..3120e14f77 --- /dev/null +++ b/src/renderer/components/+preferences/app-preferences/get-app-preferences.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { computed, IComputedValue } from "mobx"; +import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension"; +import type { AppPreferenceRegistration, RegisteredAppPreference } from "./app-preference-registration"; + +interface Dependencies { + extensions: IComputedValue; +} + +function getRegisteredItem(item: AppPreferenceRegistration): RegisteredAppPreference { + return { + id: item.id || item.title.toLowerCase().replace(/[^0-9a-zA-Z]+/g, "-"), + ...item, + }; +} + + +export const getAppPreferences = ({ extensions }: Dependencies) => { + return computed(() => ( + extensions.get() + .flatMap((extension) => extension.appPreferences) + .map(getRegisteredItem) + )); +}; diff --git a/src/renderer/components/+preferences/application.tsx b/src/renderer/components/+preferences/application.tsx index bd1e940ca6..deaae917d0 100644 --- a/src/renderer/components/+preferences/application.tsx +++ b/src/renderer/components/+preferences/application.tsx @@ -14,10 +14,12 @@ import { isWindows } from "../../../common/vars"; import { Switch } from "../switch"; import moment from "moment-timezone"; import { CONSTANTS, defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../common/user-store/preferences-helpers"; -import { action } from "mobx"; +import { action, IComputedValue } from "mobx"; import { isUrl } from "../input/input_validators"; -import { AppPreferenceRegistry } from "../../../extensions/registries"; import { ExtensionSettings } from "./extension-settings"; +import type { RegisteredAppPreference } from "./app-preferences/app-preference-registration"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import appPreferencesInjectable from "./app-preferences/app-preferences.injectable"; const timezoneOptions: SelectOption[] = moment.tz.names().map(zone => ({ label: zone, @@ -28,7 +30,11 @@ const updateChannelOptions: SelectOption[] = Array.from( ([value, { label }]) => ({ value, label }), ); -export const Application = observer(() => { +interface Dependencies { + appPreferenceItems: IComputedValue +} + +const NonInjectedApplication: React.FC = ({ appPreferenceItems }) => { const userStore = UserStore.getInstance(); const defaultShell = process.env.SHELL || process.env.PTYSHELL @@ -40,25 +46,39 @@ export const Application = observer(() => { const [customUrl, setCustomUrl] = React.useState(userStore.extensionRegistryUrl.customUrl || ""); const [shell, setShell] = React.useState(userStore.shell || ""); - const extensionSettings = AppPreferenceRegistry.getInstance().getItems().filter((preference) => preference.showInPreferencesTab === "application"); + const extensionSettings = appPreferenceItems.get().filter((preference) => preference.showInPreferencesTab === "application"); + const themeStore = ThemeStore.getInstance(); return (

Application

- + userStore.terminalTheme = value} + /> +
- + {
-
+
@@ -111,10 +131,10 @@ export const Application = observer(() => { />
-
+
- + userStore.openAtLogin = !userStore.openAtLogin}> Automatically start Lens on login @@ -127,7 +147,7 @@ export const Application = observer(() => { ))}
- +