From 53ffc623910b199a059a508667ff6628d0b73b33 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 21 Jan 2022 16:44:57 -0500 Subject: [PATCH] Add extension API for registering custom category views (#4733) --- docs/extensions/guides/catalog.md | 36 +++++++ src/common/utils/collection-functions.ts | 8 ++ src/extensions/common-api/registrations.ts | 1 + src/extensions/lens-renderer-extension.ts | 2 + .../+catalog/__tests__/custom-views.test.ts | 96 +++++++++++++++++++ src/renderer/components/+catalog/catalog.tsx | 52 +++++++--- .../custom-category-columns.injectable.tsx | 4 +- .../+catalog/custom-views.injectable.ts | 58 +++++++++++ .../components/+catalog/custom-views.ts | 57 +++++++++++ 9 files changed, 301 insertions(+), 13 deletions(-) create mode 100644 src/renderer/components/+catalog/__tests__/custom-views.test.ts create mode 100644 src/renderer/components/+catalog/custom-views.injectable.ts create mode 100644 src/renderer/components/+catalog/custom-views.ts diff --git a/docs/extensions/guides/catalog.md b/docs/extensions/guides/catalog.md index 24746df0ae..f428f323e0 100644 --- a/docs/extensions/guides/catalog.md +++ b/docs/extensions/guides/catalog.md @@ -21,6 +21,42 @@ The categories provided by Lens itself have the following names: To register a category, call the `Main.Catalog.catalogCategories.add()` and `Renderer.Catalog.catalogCategories.add()` with instances of your class. +### Custom Category Views + +By default when a specific category is selected in the catalog page a list of entities of the group and kind that the category has registered. +It is possible to register custom views for specific categories by registering them on your `Renderer.LensExtension` class. + +A registration takes the form of a [Common.Types.CustomCategoryViewRegistration](../api/interfaces/Common.Types.CustomCategoryViewRegistration.md) + +For example: + +```typescript +import { Renderer, Common } from "@k8slens/extensions"; + +function MyKubernetesClusterView({ + category, +}: Common.Types.CustomCategoryViewProps) { + return
My view: {category.getId()}
; +} + +export default class extends Renderer.LensExtension { + customCategoryViews = [ + { + group: "entity.k8slens.dev", + kind: "KubernetesCluster", + priority: 10, + components: { + View: MyKubernetesClusterView, + }, + }, + ]; +} +``` + +Will register a new view for the KubernetesCluster category, and because the priority is < 50 it will be displayed above the default list view. + +The default list view has a priority of 50 and and custom views with priority (defaulting to 50) >= 50 will be displayed afterwards. + ## Entities An entity is the data within the catalog. diff --git a/src/common/utils/collection-functions.ts b/src/common/utils/collection-functions.ts index 205f7f0cb1..a58209f9ab 100644 --- a/src/common/utils/collection-functions.ts +++ b/src/common/utils/collection-functions.ts @@ -17,3 +17,11 @@ export function getOrInsert(map: Map, key: K, value: V): V { return map.get(key); } + +/** + * Like `getOrInsert` but specifically for when `V` is `Map` so that + * the typings are inferred. + */ +export function getOrInsertMap(map: Map>, key: K): Map { + return getOrInsert(map, key, new Map()); +} diff --git a/src/extensions/common-api/registrations.ts b/src/extensions/common-api/registrations.ts index 666ed01a26..20512afb22 100644 --- a/src/extensions/common-api/registrations.ts +++ b/src/extensions/common-api/registrations.ts @@ -11,3 +11,4 @@ export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, export type { ClusterPageMenuRegistration, ClusterPageMenuComponents } from "../registries/page-menu-registry"; export type { StatusBarRegistration } from "../registries/status-bar-registry"; export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler"; +export type { CustomCategoryViewProps, CustomCategoryViewComponents, CustomCategoryViewRegistration } from "../../renderer/components/+catalog/custom-views"; diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index fe0795c2c1..7d511c5504 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -17,6 +17,7 @@ import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/ 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"; +import type { CustomCategoryViewRegistration } from "../renderer/components/+catalog/custom-views"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -35,6 +36,7 @@ export class LensRendererExtension extends LensExtension { catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; topBarItems: TopBarRegistration[] = []; additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = []; + customCategoryViews: CustomCategoryViewRegistration[] = []; async navigate

(pageId?: string, params?: P) { const { navigate } = await import("../renderer/navigation"); diff --git a/src/renderer/components/+catalog/__tests__/custom-views.test.ts b/src/renderer/components/+catalog/__tests__/custom-views.test.ts new file mode 100644 index 0000000000..af2dad0f55 --- /dev/null +++ b/src/renderer/components/+catalog/__tests__/custom-views.test.ts @@ -0,0 +1,96 @@ +/** + * 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 React from "react"; +import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension"; +import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import type { CustomCategoryViewRegistration } from "../custom-views"; +import customCategoryViewsInjectable from "../custom-views.injectable"; + +describe("Custom Category Views", () => { + let di: ConfigurableDependencyInjectionContainer; + + beforeEach(() => { + di = getDiForUnitTesting(); + }); + + it("should order items correctly over all extensions", () => { + const component1 = (): React.ReactElement => null; + const component2 = (): React.ReactElement => null; + + di.override(rendererExtensionsInjectable, () => computed(() => [ + { + customCategoryViews: [ + { + components: { + View: component1, + }, + group: "foo", + kind: "bar", + priority: 100, + } as CustomCategoryViewRegistration, + ], + }, + { + customCategoryViews: [ + { + components: { + View: component2, + }, + group: "foo", + kind: "bar", + priority: 95, + } as CustomCategoryViewRegistration, + ], + }, + ] as LensRendererExtension[])); + + const customCategoryViews = di.inject(customCategoryViewsInjectable); + const { after } = customCategoryViews.get().get("foo").get("bar"); + + expect(after[0].View).toBe(component2); + expect(after[1].View).toBe(component1); + }); + + it("should put put priority < 50 items in before", () => { + const component1 = (): React.ReactElement => null; + const component2 = (): React.ReactElement => null; + + di.override(rendererExtensionsInjectable, () => computed(() => [ + { + customCategoryViews: [ + { + components: { + View: component1, + }, + group: "foo", + kind: "bar", + priority: 40, + } as CustomCategoryViewRegistration, + ], + }, + { + customCategoryViews: [ + { + components: { + View: component2, + }, + group: "foo", + kind: "bar", + priority: 95, + } as CustomCategoryViewRegistration, + ], + }, + ] as LensRendererExtension[])); + + const customCategoryViews = di.inject(customCategoryViewsInjectable); + const { before } = customCategoryViews.get().get("foo").get("bar"); + + expect(before[0].View).toBe(component1); + }); +}); diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 107e02db30..194f35f481 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -8,7 +8,7 @@ import styles from "./catalog.module.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { ItemListLayout } from "../item-object-list"; -import { action, makeObservable, observable, reaction, runInAction, when } from "mobx"; +import { action, IComputedValue, makeObservable, observable, reaction, runInAction, when } from "mobx"; import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; @@ -33,6 +33,9 @@ import catalogPreviousActiveTabStorageInjectable from "./catalog-previous-active import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; import type { GetCategoryColumnsParams, CategoryColumns } from "./get-category-columns.injectable"; import getCategoryColumnsInjectable from "./get-category-columns.injectable"; +import type { RegisteredCustomCategoryViewDecl } from "./custom-views.injectable"; +import customCategoryViewsInjectable from "./custom-views.injectable"; +import type { CustomCategoryViewComponents } from "./custom-views"; interface Props extends RouteComponentProps {} @@ -40,6 +43,7 @@ interface Dependencies { catalogPreviousActiveTabStorage: { set: (value: string ) => void }; catalogEntityStore: CatalogEntityStore; getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns; + customCategoryViews: IComputedValue>>; } @observer @@ -213,16 +217,44 @@ class NonInjectedCatalog extends React.Component { ); } + renderViews = () => { + const { catalogEntityStore, customCategoryViews } = this.props; + const { activeCategory } = catalogEntityStore; + + if (!activeCategory) { + return this.renderList(); + } + + const customViews = customCategoryViews.get() + .get(activeCategory.spec.group) + ?.get(activeCategory.spec.names.kind); + const renderView = ({ View }: CustomCategoryViewComponents, index: number) => ( + + ); + + return ( + <> + {customViews?.before.map(renderView)} + {this.renderList()} + {customViews?.after.map(renderView)} + + ); + }; + renderList() { - const { activeCategory } = this.props.catalogEntityStore; - const tableId = activeCategory ? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}` : "catalog-items"; + const { catalogEntityStore, getCategoryColumns } = this.props; + const { activeCategory } = catalogEntityStore; + const tableId = activeCategory + ? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}` + : "catalog-items"; if (this.activeTab === undefined) { return null; } - const { sortingCallbacks, searchFilters, renderTableContents, renderTableHeader } = this.props.getCategoryColumns({ activeCategory }); - return ( { renderHeaderTitle={activeCategory?.metadata.name ?? "Browse All"} isSelectable={false} isConfigurable={true} - store={this.props.catalogEntityStore} - sortingCallbacks={sortingCallbacks} - searchFilters={searchFilters} - renderTableHeader={renderTableHeader} + store={catalogEntityStore} customizeTableRowProps={entity => ({ disabled: !entity.isEnabled(), })} - renderTableContents={renderTableContents} + {...getCategoryColumns({ activeCategory })} onDetails={this.onDetails} renderItemMenu={this.renderItemMenu} /> @@ -254,7 +283,7 @@ class NonInjectedCatalog extends React.Component { return (

- {this.renderList()} + {this.renderViews()}
{ selectedEntity @@ -281,6 +310,7 @@ export const Catalog = withInjectables( NonInjectedCatalog, catalogEntityStore: di.inject(catalogEntityStoreInjectable), catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable), getCategoryColumns: di.inject(getCategoryColumnsInjectable), + customCategoryViews: di.inject(customCategoryViewsInjectable), ...props, }), }); diff --git a/src/renderer/components/+catalog/custom-category-columns.injectable.tsx b/src/renderer/components/+catalog/custom-category-columns.injectable.tsx index 26d4a304d7..2d315f383a 100644 --- a/src/renderer/components/+catalog/custom-category-columns.injectable.tsx +++ b/src/renderer/components/+catalog/custom-category-columns.injectable.tsx @@ -6,7 +6,7 @@ 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 { getOrInsert, getOrInsertMap } from "../../utils"; import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; interface Dependencies { @@ -19,7 +19,7 @@ function getAdditionCategoryColumns({ extensions }: Dependencies): IComputedValu 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 byGroup = getOrInsertMap(res, registration.group); const byKind = getOrInsert(byGroup, registration.kind, []); const id = `${ext.name}:${registration.id}`; diff --git a/src/renderer/components/+catalog/custom-views.injectable.ts b/src/renderer/components/+catalog/custom-views.injectable.ts new file mode 100644 index 0000000000..70fbd6457a --- /dev/null +++ b/src/renderer/components/+catalog/custom-views.injectable.ts @@ -0,0 +1,58 @@ +/** + * 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 { computed, IComputedValue } from "mobx"; +import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension"; +import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable"; +import { getOrInsert, getOrInsertMap } from "../../utils"; +import type { CustomCategoryViewComponents } from "./custom-views"; + +interface Dependencies { + extensions: IComputedValue; +} + +export interface RegisteredCustomCategoryViewDecl { + /** + * The asc sorted list of items with priority set to < 50 + */ + before: CustomCategoryViewComponents[]; + /** + * The asc sorted list of items with priority not set or set to >= 50 + */ + after: CustomCategoryViewComponents[]; +} + +function getCustomCategoryViews({ extensions }: Dependencies): IComputedValue>> { + return computed(() => { + const res = new Map>(); + const registrations = extensions.get() + .flatMap(ext => ext.customCategoryViews) + .map(({ priority = 50, ...rest }) => ({ priority, ...rest })); + const sortedRegistrations = orderBy(registrations, "priority", "asc"); + + for (const { priority, group, kind, components } of sortedRegistrations) { + const byGroup = getOrInsertMap(res, group); + const { before, after } = getOrInsert(byGroup, kind, { before: [], after: [] }); + + if (priority < 50) { + before.push(components); + } else { + after.push(components); + } + } + + return res; + }); +} + +const customCategoryViewsInjectable = getInjectable({ + instantiate: (di) => getCustomCategoryViews({ + extensions: di.inject(rendererExtensionsInjectable), + }), + lifecycle: lifecycleEnum.singleton, +}); + +export default customCategoryViewsInjectable; diff --git a/src/renderer/components/+catalog/custom-views.ts b/src/renderer/components/+catalog/custom-views.ts new file mode 100644 index 0000000000..73ee0a853a --- /dev/null +++ b/src/renderer/components/+catalog/custom-views.ts @@ -0,0 +1,57 @@ +/** + * 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 { CatalogCategory } from "../../api/catalog-entity"; + +/** + * The props for CustomCategoryViewComponents.View + */ +export interface CustomCategoryViewProps { + /** + * The category instance itself + */ + category: CatalogCategory; +} + +/** + * The components for the category view. + */ +export interface CustomCategoryViewComponents { + View: React.ComponentType; +} + +/** + * This is the type used to declare additional views for a specific category + */ +export interface CustomCategoryViewRegistration { + /** + * 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; + + /** + * The sorting order value. Used to determine the total order of the views. + * + * @default 50 + */ + priority?: number; + + /** + * The components for this registration + */ + components: CustomCategoryViewComponents; +}