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 09a0bce0a1..90368645f3 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -14,26 +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) | | -| [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) | | +| 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/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/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/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/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 26d2f2d810..fe0795c2c1 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -16,6 +16,7 @@ import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/we 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[] = []; @@ -33,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/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/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/renderer/components/table/table-cell.tsx b/src/renderer/components/table/table-cell.tsx index 6165cd81ed..af6f95eec9 100644 --- a/src/renderer/components/table/table-cell.tsx +++ b/src/renderer/components/table/table-cell.tsx @@ -14,18 +14,66 @@ import { Checkbox } from "../checkbox"; export type TableCellElem = React.ReactElement; export interface TableCellProps extends React.DOMAttributes { - id?: string; // used for configuration visibility of columns + /** + * used for configuration visibility of columns + */ + id?: string; + + /** + * Any css class names for this table cell. Only used if `title` is a "simple" react node + */ className?: string; + + /** + * The actual value of the cell + */ title?: ReactNode; - scrollable?: boolean; // content inside could be scrolled - checkbox?: boolean; // render cell with a checkbox - isChecked?: boolean; // mark checkbox as checked or not - renderBoolean?: boolean; // show "true" or "false" for all of the children elements are "typeof boolean" - sortBy?: TableSortBy; // column name, must be same as key in sortable object - showWithColumn?: string // id of the column which follow same visibility rules - _sorting?: Partial; //
sorting state, don't use this prop outside (!) - _sort?(sortBy: TableSortBy): void; //
sort function, don't use this prop outside (!) - _nowrap?: boolean; // indicator, might come from parent , don't use this prop outside (!) + + /** + * content inside could be scrolled + */ + scrollable?: boolean; + + /** + * render cell with a checkbox + */ + checkbox?: boolean; + + /** + * mark checkbox as checked or not + */ + isChecked?: boolean; + + /** + * show "true" or "false" for all of the children elements are "typeof boolean" + */ + renderBoolean?: boolean; + + /** + * column name, must be same as key in sortable object
+ */ + sortBy?: TableSortBy; + + /** + * id of the column which follow same visibility rules + */ + showWithColumn?: string + + /** + * @internal + */ + _sorting?: Partial; + + /** + * @internal + */ + _sort?(sortBy: TableSortBy): void; + + /** + * @internal + * indicator, might come from parent , don't use this prop outside (!) + */ + _nowrap?: boolean; } export class TableCell extends React.Component {