1
0
mirror of https://github.com/lensapp/lens.git synced 2024-09-19 05:17:22 +03:00

Add extension API for registering custom category views (#4733)

This commit is contained in:
Sebastian Malton 2022-01-21 16:44:57 -05:00 committed by GitHub
parent 4f5a2988cb
commit 53ffc62391
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 301 additions and 13 deletions

View File

@ -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 <div>My view: {category.getId()}</div>;
}
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.

View File

@ -17,3 +17,11 @@ export function getOrInsert<K, V>(map: Map<K, V>, key: K, value: V): V {
return map.get(key);
}
/**
* Like `getOrInsert` but specifically for when `V` is `Map<any, any>` so that
* the typings are inferred.
*/
export function getOrInsertMap<K, MK, MV>(map: Map<K, Map<MK, MV>>, key: K): Map<MK, MV> {
return getOrInsert(map, key, new Map<MK, MV>());
}

View File

@ -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";

View File

@ -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<CatalogEntity>[] = [];
topBarItems: TopBarRegistration[] = [];
additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = [];
customCategoryViews: CustomCategoryViewRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation");

View File

@ -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);
});
});

View File

@ -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<CatalogViewRouteParam> {}
@ -40,6 +43,7 @@ interface Dependencies {
catalogPreviousActiveTabStorage: { set: (value: string ) => void };
catalogEntityStore: CatalogEntityStore;
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>;
}
@observer
@ -213,16 +217,44 @@ class NonInjectedCatalog extends React.Component<Props & Dependencies> {
);
}
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) => (
<View
key={index}
category={activeCategory}
/>
);
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 (
<ItemListLayout
className={styles.Catalog}
@ -230,14 +262,11 @@ class NonInjectedCatalog extends React.Component<Props & Dependencies> {
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<Props & Dependencies> {
return (
<MainLayout sidebar={this.renderNavigation()}>
<div className="p-6 h-full">
{this.renderList()}
{this.renderViews()}
</div>
{
selectedEntity
@ -281,6 +310,7 @@ export const Catalog = withInjectables<Dependencies, Props>( NonInjectedCatalog,
catalogEntityStore: di.inject(catalogEntityStoreInjectable),
catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable),
getCategoryColumns: di.inject(getCategoryColumnsInjectable),
customCategoryViews: di.inject(customCategoryViewsInjectable),
...props,
}),
});

View File

@ -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<string, RegisteredAdditionalCategoryColumn[]>());
const byGroup = getOrInsertMap(res, registration.group);
const byKind = getOrInsert(byGroup, registration.kind, []);
const id = `${ext.name}:${registration.id}`;

View File

@ -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<LensRendererExtension[]>;
}
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<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>> {
return computed(() => {
const res = new Map<string, Map<string, RegisteredCustomCategoryViewDecl>>();
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;

View File

@ -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<CustomCategoryViewProps>;
}
/**
* 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;
}