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:
parent
d31ab690c2
commit
411b9a9a88
6
Makefile
6
Makefile
@ -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
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,28 +99,32 @@ 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!");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/common/utils/__tests__/bind.test.ts
Normal file
19
src/common/utils/__tests__/bind.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/common/utils/collection-functions.ts
Normal file
19
src/common/utils/collection-functions.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@ -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;
|
||||||
85
src/renderer/components/+catalog/custom-category-columns.ts
Normal file
85
src/renderer/components/+catalog/custom-category-columns.ts
Normal 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[];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
121
src/renderer/components/+catalog/internal-category-columns.tsx
Normal file
121
src/renderer/components/+catalog/internal-category-columns.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -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> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user