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:
parent
4f5a2988cb
commit
53ffc62391
@ -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.
|
||||
|
@ -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>());
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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");
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
@ -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}`;
|
||||
|
||||
|
58
src/renderer/components/+catalog/custom-views.injectable.ts
Normal file
58
src/renderer/components/+catalog/custom-views.injectable.ts
Normal 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;
|
57
src/renderer/components/+catalog/custom-views.ts
Normal file
57
src/renderer/components/+catalog/custom-views.ts
Normal 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user