1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Add support for custom columns on catalog categories (#4708)

This commit is contained in:
Sebastian Malton 2022-01-19 10:22:22 -05:00 committed by GitHub
parent d31ab690c2
commit 411b9a9a88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 772 additions and 125 deletions

View File

@ -103,8 +103,12 @@ publish-npm: node_modules build-npm
cd src/extensions/npm/extensions && npm publish --access=public --tag=$(NPM_RELEASE_TAG) cd src/extensions/npm/extensions && npm publish --access=public --tag=$(NPM_RELEASE_TAG)
git restore src/extensions/npm/extensions/package.json git restore src/extensions/npm/extensions/package.json
.PHONY: build-docs
build-docs:
yarn typedocs-extensions-api
.PHONY: docs .PHONY: docs
docs: docs: build-docs
yarn mkdocs-serve-local yarn mkdocs-serve-local
.PHONY: clean-extensions .PHONY: clean-extensions

View File

@ -14,26 +14,27 @@ Each guide or code sample includes the following:
## Guides ## Guides
| Guide | APIs | | Guide | APIs |
| ----- | ----- | | --------------------------------------------------------------- | ---------------------- |
| [Generate new extension project](generator.md) || | [Generate new extension project](generator.md) | |
| [Main process extension](main-extension.md) | Main.LensExtension | | [Main process extension](main-extension.md) | Main.LensExtension |
| [Renderer process extension](renderer-extension.md) | Renderer.LensExtension | | [Renderer process extension](renderer-extension.md) | Renderer.LensExtension |
| [Resource stack (cluster feature)](resource-stack.md) | | | [Resource stack (cluster feature)](resource-stack.md) | |
| [Extending KubernetesCluster)](extending-kubernetes-cluster.md) | | | [Extending KubernetesCluster)](extending-kubernetes-cluster.md) | |
| [Stores](stores.md) | | | [Stores](stores.md) | |
| [Components](components.md) | | | [Components](components.md) | |
| [KubeObjectListLayout](kube-object-list-layout.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | |
| [Working with mobx](working-with-mobx.md) | | | [Working with mobx](working-with-mobx.md) | |
| [Protocol Handlers](protocol-handlers.md) | | | [Protocol Handlers](protocol-handlers.md) | |
| [Sending Data between main and renderer](ipc.md) | | | [Sending Data between main and renderer](ipc.md) | |
| [Catalog Entities and Categories](catalog.md) | |
## Samples ## Samples
| Sample | APIs | | Sample | APIs |
| ----- | ----- | | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps | | [hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps | | [styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps | | [styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps | | [styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension <br> Renderer.K8sApi.KubeApi <br> Renderer.K8sApi.KubeObjectStore <br> Renderer.Component.KubeObjectListLayout <br> Renderer.Component.KubeObjectDetailsProps <br> Renderer.Component.IconProps | | [custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension <br> Renderer.K8sApi.KubeApi <br> Renderer.K8sApi.KubeObjectStore <br> Renderer.Component.KubeObjectListLayout <br> Renderer.Component.KubeObjectDetailsProps <br> Renderer.Component.IconProps |

View File

@ -1,5 +1,27 @@
# Catalog (WIP) # 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 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.

View File

@ -16,11 +16,11 @@ import { Main } from "@k8slens/extensions";
export default class ExampleExtensionMain extends Main.LensExtension { export default class ExampleExtensionMain extends Main.LensExtension {
onActivate() { onActivate() {
console.log('custom main process extension code started'); console.log("custom main process extension code started");
} }
onDeactivate() { 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: Disable extensions from the Lens Extensions page:
1. Navigate to **File** > **Extensions** in the top menu bar. 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. 2. Click **Disable** on the extension you want to disable.
The example above logs messages when the extension is enabled and disabled. 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. 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. 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` ### `appMenus`
The Main Extension API allows you to customize the UI application menu. The Main Extension API allows you to customize the UI application menu.
The following example demonstrates adding an item to the **Help** menu. The following example demonstrates adding an item to the **Help** menu.
``` typescript ```typescript
import { Main } from "@k8slens/extensions"; import { Main } from "@k8slens/extensions";
export default class SamplePageMainExtension extends Main.LensExtension { export default class SamplePageMainExtension extends Main.LensExtension {
@ -57,9 +57,9 @@ export default class SamplePageMainExtension extends Main.LensExtension {
label: "Sample", label: "Sample",
click() { click() {
console.log("Sample clicked"); console.log("Sample clicked");
} },
} },
] ];
} }
``` ```
@ -67,18 +67,18 @@ export default class SamplePageMainExtension extends Main.LensExtension {
`MenuRegistration` extends Electron's `MenuItemConstructorOptions` interface. `MenuRegistration` extends Electron's `MenuItemConstructorOptions` interface.
The properties of the appMenus array objects are defined as follows: 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. - `parentId` is the name of the menu where your new menu item will be listed.
Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`. Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`.
`"lens"` is valid on Mac only. `"lens"` is valid on Mac only.
* `label` is the name of your menu item. - `label` is the name of your menu item.
* `click()` is called when the menu item is selected. - `click()` is called when the menu item is selected.
In this example, we simply log a message. In this example, we simply log a message.
However, you would typically have this navigate to a specific page or perform another operation. 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. 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: The following example demonstrates how an application menu can be used to navigate to such a page:
``` typescript ```typescript
import { Main } from "@k8slens/extensions"; import { Main } from "@k8slens/extensions";
export default class SamplePageMainExtension extends Main.LensExtension { export default class SamplePageMainExtension extends Main.LensExtension {
@ -86,9 +86,9 @@ export default class SamplePageMainExtension extends Main.LensExtension {
{ {
parentId: "help", parentId: "help",
label: "Sample", 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`. `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 { interface TrayMenuRegistration {
label?: string; label?: string;
click?: (menuItem: TrayMenuRegistration) => void; click?: (menuItem: TrayMenuRegistration) => void;
id?: string; id?: string;
type?: "normal" | "separator" | "submenu" type?: "normal" | "separator" | "submenu";
toolTip?: string; toolTip?: string;
enabled?: boolean; enabled?: boolean;
submenu?: TrayMenuRegistration[] submenu?: TrayMenuRegistration[];
} }
``` ```
The following example demonstrates how tray menus can be added from extension: The following example demonstrates how tray menus can be added from extension:
``` typescript ```typescript
import { Main } from "@k8slens/extensions"; import { Main } from "@k8slens/extensions";
export default class SampleTrayMenuMainExtension extends Main.LensExtension { export default class SampleTrayMenuMainExtension extends Main.LensExtension {
trayMenus = [{ trayMenus = [
label: "menu from the extension", {
click: () => { console.log("tray menu clicked!") } label: "menu from the extension",
}] click: () => {
console.log("tray menu clicked!");
},
},
];
} }
``` ```
### `addCatalogSource()` and `removeCatalogSource()` Methods ### `addCatalogSource()` and `removeCatalogSource()` Methods
The `Main.LensExtension` class also provides the `addCatalogSource()` and `removeCatalogSource()` methods, for managing custom catalog items (or entities). 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. See the [`Catalog`](catalog.md) documentation for full details about the catalog.

View File

@ -8,6 +8,7 @@ import type TypedEmitter from "typed-emitter";
import { observable, makeObservable } from "mobx"; import { observable, makeObservable } from "mobx";
import { once } from "lodash"; import { once } from "lodash";
import { iter, Disposer } from "../utils"; import { iter, Disposer } from "../utils";
import type { CategoryColumnRegistration } from "../../renderer/components/+catalog/custom-category-columns";
type ExtractEntityMetadataType<Entity> = Entity extends CatalogEntity<infer Metadata> ? Metadata : never; type ExtractEntityMetadataType<Entity> = Entity extends CatalogEntity<infer Metadata> ? Metadata : never;
type ExtractEntityStatusType<Entity> = Entity extends CatalogEntity<any, infer Status> ? Status : never; type ExtractEntityStatusType<Entity> = Entity extends CatalogEntity<any, infer Status> ? Status : never;
@ -46,6 +47,7 @@ export interface CatalogCategorySpec {
* The grouping for for the category. This MUST be a DNS label. * The grouping for for the category. This MUST be a DNS label.
*/ */
group: string; group: string;
/** /**
* The specific versions of the constructors. * 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` * `name = "v1alpha1"` then the resulting `.apiVersion` MUST be `entity.k8slens.dev/v1alpha1`
*/ */
versions: CatalogCategoryVersion<CatalogEntity>[]; versions: CatalogCategoryVersion<CatalogEntity>[];
/**
* This is the concerning the category
*/
names: { names: {
/** /**
* The kind of entity that this category is for. This value MUST be a DNS * The kind of entity that this category is for. This value MUST be a DNS
@ -62,38 +68,107 @@ export interface CatalogCategorySpec {
*/ */
kind: string; 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 type AddMenuFilter = (menu: CatalogEntityAddMenu) => any;
export interface CatalogCategoryEvents { export interface CatalogCategoryEvents {
/**
* This event will be emitted when the category is loaded in the catalog
* view.
*/
load: () => void; 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; 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; contextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void;
} }
export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter<CatalogCategoryEvents>) { export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter<CatalogCategoryEvents>) {
/**
* The version of category that you are wanting to declare.
*
* Currently supported values:
*
* - `"catalog.k8slens.dev/v1alpha1"`
*/
abstract readonly apiVersion: string; abstract readonly apiVersion: string;
/**
* The kind of item you wish to declare.
*
* Currently supported values:
*
* - `"CatalogCategory"`
*/
abstract readonly kind: string; 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; name: string;
/**
* Either an `<svg>` or the name of an icon from {@link IconProps}
*/
icon: string; icon: string;
}; };
/**
* The most important part of a category, as it is where entity versions are declared.
*/
abstract spec: CatalogCategorySpec; abstract spec: CatalogCategorySpec;
/**
* @internal
*/
protected filters = observable.set<AddMenuFilter>([], { protected filters = observable.set<AddMenuFilter>([], {
deep: false, 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("/") ?? []; const [group, kind] = id.split("/") ?? [];
return { group, kind }; return { group, kind };
} }
/**
* Get the ID of this category
*/
public getId(): string { public getId(): string {
return `${this.spec.group}/${this.spec.names.kind}`; return `${this.spec.group}/${this.spec.names.kind}`;
} }

View File

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

View File

@ -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<K, V>(map: Map<K, V>, key: K, value: V): V {
if (!map.has(key)) {
map.set(key, value);
}
return map.get(key);
}

View File

@ -10,11 +10,19 @@ export function noop<T extends any[]>(...args: T): void {
return void args; return void args;
} }
/**
* A typecorrect version of <function>.bind()
*/
export function bind<BoundArgs extends any[], NonBoundArgs extends any[], ReturnType>(fn: (...args: [...BoundArgs, ...NonBoundArgs]) => ReturnType, thisArg: any, ...boundArgs: BoundArgs): (...args: NonBoundArgs) => ReturnType {
return fn.bind(thisArg, ...boundArgs);
}
export * from "./app-version"; export * from "./app-version";
export * from "./autobind"; export * from "./autobind";
export * from "./camelCase"; export * from "./camelCase";
export * from "./cloneJson"; export * from "./cloneJson";
export * from "./cluster-id-url-parsing"; export * from "./cluster-id-url-parsing";
export * from "./collection-functions";
export * from "./convertCpu"; export * from "./convertCpu";
export * from "./convertMemory"; export * from "./convertMemory";
export * from "./debouncePromise"; export * from "./debouncePromise";

View File

@ -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 { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration";
import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands"; 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 { 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 { export class LensRendererExtension extends LensExtension {
globalPages: registries.PageRegistration[] = []; globalPages: registries.PageRegistration[] = [];
@ -33,6 +34,7 @@ export class LensRendererExtension extends LensExtension {
welcomeBanners: WelcomeBannerRegistration[] = []; welcomeBanners: WelcomeBannerRegistration[] = [];
catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = [];
topBarItems: TopBarRegistration[] = []; topBarItems: TopBarRegistration[] = [];
additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) { async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation"); const { navigate } = await import("../renderer/navigation");

View File

@ -31,6 +31,11 @@ export * from "../../renderer/components/input/input";
// command-overlay // command-overlay
export const CommandOverlay = asLegacyGlobalObjectForExtensionApi(commandOverlayInjectable); export const CommandOverlay = asLegacyGlobalObjectForExtensionApi(commandOverlayInjectable);
export type {
CategoryColumnRegistration,
AdditionalCategoryColumnRegistration,
} from "../../renderer/components/+catalog/custom-category-columns";
// other components // other components
export * from "../../renderer/components/icon"; export * from "../../renderer/components/icon";
export * from "../../renderer/components/tooltip"; export * from "../../renderer/components/tooltip";

View File

@ -8,10 +8,8 @@ import extensionsInjectable from "./extensions.injectable";
import type { LensRendererExtension } from "./lens-renderer-extension"; import type { LensRendererExtension } from "./lens-renderer-extension";
const rendererExtensionsInjectable = getInjectable({ const rendererExtensionsInjectable = getInjectable({
instantiate: (di) => di.inject(extensionsInjectable) as IComputedValue<LensRendererExtension[]>,
lifecycle: lifecycleEnum.singleton, lifecycle: lifecycleEnum.singleton,
instantiate: (di) =>
di.inject(extensionsInjectable) as IComputedValue<LensRendererExtension[]>,
}); });
export default rendererExtensionsInjectable; export default rendererExtensionsInjectable;

View File

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

View File

@ -28,25 +28,18 @@ import { RenderDelay } from "../render-delay/render-delay";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item"; import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item";
import { Avatar } from "../avatar"; import { Avatar } from "../avatar";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import { getLabelBadges } from "./helpers";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import catalogPreviousActiveTabStorageInjectable import catalogPreviousActiveTabStorageInjectable from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
import type { GetCategoryColumnsParams, CategoryColumns } from "./get-category-columns.injectable";
enum sortBy { import getCategoryColumnsInjectable from "./get-category-columns.injectable";
name = "name",
kind = "kind",
source = "source",
status = "status",
}
interface Props extends RouteComponentProps<CatalogViewRouteParam> {} interface Props extends RouteComponentProps<CatalogViewRouteParam> {}
interface Dependencies { interface Dependencies {
catalogPreviousActiveTabStorage: { set: (value: string ) => void } catalogPreviousActiveTabStorage: { set: (value: string ) => void };
catalogEntityStore: CatalogEntityStore catalogEntityStore: CatalogEntityStore;
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
} }
@observer @observer
@ -228,46 +221,23 @@ class NonInjectedCatalog extends React.Component<Props & Dependencies> {
return null; return null;
} }
const { sortingCallbacks, searchFilters, renderTableContents, renderTableHeader } = this.props.getCategoryColumns({ activeCategory });
return ( return (
<ItemListLayout <ItemListLayout
className={styles.Catalog} className={styles.Catalog}
tableId={tableId} tableId={tableId}
renderHeaderTitle={activeCategory?.metadata.name || "Browse All"} renderHeaderTitle={activeCategory?.metadata.name ?? "Browse All"}
isSelectable={false} isSelectable={false}
isConfigurable={true} isConfigurable={true}
store={this.props.catalogEntityStore} store={this.props.catalogEntityStore}
sortingCallbacks={{ sortingCallbacks={sortingCallbacks}
[sortBy.name]: entity => entity.getName(), searchFilters={searchFilters}
[sortBy.source]: entity => entity.getSource(), renderTableHeader={renderTableHeader}
[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)}
customizeTableRowProps={entity => ({ customizeTableRowProps={entity => ({
disabled: !entity.isEnabled(), disabled: !entity.isEnabled(),
})} })}
renderTableContents={entity => [ renderTableContents={renderTableContents}
this.renderName(entity),
!activeCategory && entity.kind,
entity.getSource(),
getLabelBadges(entity),
<span key="phase" className={entity.status.phase}>{entity.status.phase}</span>,
].filter(Boolean)}
onDetails={this.onDetails} onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu} renderItemMenu={this.renderItemMenu}
/> />
@ -306,17 +276,11 @@ class NonInjectedCatalog extends React.Component<Props & Dependencies> {
} }
} }
export const Catalog = withInjectables<Dependencies, Props>( export const Catalog = withInjectables<Dependencies, Props>( NonInjectedCatalog, {
NonInjectedCatalog, getProps: (di, props) => ({
{ catalogEntityStore: di.inject(catalogEntityStoreInjectable),
getProps: (di, props) => ({ catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable),
catalogEntityStore: di.inject(catalogEntityStoreInjectable), getCategoryColumns: di.inject(getCategoryColumnsInjectable),
...props,
catalogPreviousActiveTabStorage: di.inject( }),
catalogPreviousActiveTabStorageInjectable, });
),
...props,
}),
},
);

View File

@ -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<LensRendererExtension[]>;
}
function getAdditionCategoryColumns({ extensions }: Dependencies): IComputedValue<Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>> {
return computed(() => {
const res = new Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>();
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 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;

View File

@ -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[];
}

View File

@ -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<Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>>;
}
export interface GetCategoryColumnsParams {
activeCategory: CatalogCategory | null | undefined;
}
export type CategoryColumns = Required<Pick<ItemListLayoutProps<CatalogEntity>, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>;
function getSpecificCategoryColumns(activeCategory: CatalogCategory, extensionColumns: IComputedValue<Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>>): 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;

View File

@ -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 (
<>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
src={entity.spec.icon?.src}
background={entity.spec.icon?.background}
className={styles.catalogAvatar}
size={24}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material} small/>}
</Avatar>
<span>{entity.getName()}</span>
<Icon
small
className={styles.pinIcon}
material={!isItemInHotbar && "push_pin"}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={onClick}
/>
</>
);
}
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 => (
<span key="phase" className={entity.status.phase}>
{entity.status.phase}
</span>
),
titleProps: {
title: "Status",
className: styles.statusCell,
id: "status",
sortBy: "status",
},
searchFilter: entity => entity.status.phase,
},
];

View File

@ -14,18 +14,66 @@ import { Checkbox } from "../checkbox";
export type TableCellElem = React.ReactElement<TableCellProps>; export type TableCellElem = React.ReactElement<TableCellProps>;
export interface TableCellProps extends React.DOMAttributes<HTMLDivElement> { export interface TableCellProps extends React.DOMAttributes<HTMLDivElement> {
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; className?: string;
/**
* The actual value of the cell
*/
title?: ReactNode; title?: ReactNode;
scrollable?: boolean; // content inside could be scrolled
checkbox?: boolean; // render cell with a checkbox /**
isChecked?: boolean; // mark checkbox as checked or not * content inside could be scrolled
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 <Table sortable={}/> scrollable?: boolean;
showWithColumn?: string // id of the column which follow same visibility rules
_sorting?: Partial<TableSortParams>; // <Table> sorting state, don't use this prop outside (!) /**
_sort?(sortBy: TableSortBy): void; // <Table> sort function, don't use this prop outside (!) * render cell with a checkbox
_nowrap?: boolean; // indicator, might come from parent <TableHead>, don't use this prop outside (!) */
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 <Table sortable={}/>
*/
sortBy?: TableSortBy;
/**
* id of the column which follow same visibility rules
*/
showWithColumn?: string
/**
* @internal
*/
_sorting?: Partial<TableSortParams>;
/**
* @internal
*/
_sort?(sortBy: TableSortBy): void;
/**
* @internal
* indicator, might come from parent <TableHead>, don't use this prop outside (!)
*/
_nowrap?: boolean;
} }
export class TableCell extends React.Component<TableCellProps> { export class TableCell extends React.Component<TableCellProps> {