diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 092754394d..ffc4e361da 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -20,9 +20,11 @@ */ import { anyObject } from "jest-mock-extended"; +import { merge } from "lodash"; import mockFs from "mock-fs"; import logger from "../../main/logger"; import { AppPaths } from "../app-paths"; +import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../catalog"; import { ClusterStore } from "../cluster-store"; import { HotbarStore } from "../hotbar-store"; @@ -54,68 +56,58 @@ jest.mock("../../main/catalog/catalog-entity-registry", () => ({ }, })); -const testCluster = { - uid: "test", - name: "test", +function getMockCatalogEntity(data: Partial & CatalogEntityKindData): CatalogEntity { + return merge(data, { + getName: jest.fn(() => data.metadata?.name), + getId: jest.fn(() => data.metadata?.uid), + getSource: jest.fn(() => data.metadata?.source ?? "unknown"), + isEnabled: jest.fn(() => data.status?.enabled ?? true), + onContextMenuOpen: jest.fn(), + onSettingsOpen: jest.fn(), + metadata: {}, + spec: {}, + status: {}, + }) as CatalogEntity; +} + +const testCluster = getMockCatalogEntity({ apiVersion: "v1", kind: "Cluster", status: { phase: "Running", }, - spec: {}, - getName: jest.fn(), - getId: jest.fn(), - onDetailsOpen: jest.fn(), - onContextMenuOpen: jest.fn(), - onSettingsOpen: jest.fn(), metadata: { uid: "test", name: "test", labels: {}, }, -}; +}); -const minikubeCluster = { - uid: "minikube", - name: "minikube", +const minikubeCluster = getMockCatalogEntity({ apiVersion: "v1", kind: "Cluster", status: { phase: "Running", }, - spec: {}, - getName: jest.fn(), - getId: jest.fn(), - onDetailsOpen: jest.fn(), - onContextMenuOpen: jest.fn(), - onSettingsOpen: jest.fn(), metadata: { uid: "minikube", name: "minikube", labels: {}, }, -}; +}); -const awsCluster = { - uid: "aws", - name: "aws", +const awsCluster = getMockCatalogEntity({ apiVersion: "v1", kind: "Cluster", status: { phase: "Running", }, - spec: {}, - getName: jest.fn(), - getId: jest.fn(), - onDetailsOpen: jest.fn(), - onContextMenuOpen: jest.fn(), - onSettingsOpen: jest.fn(), metadata: { uid: "aws", name: "aws", labels: {}, }, -}; +}); jest.mock("electron", () => ({ app: { diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 9015dbd828..79083440c7 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -38,14 +38,44 @@ export type CatalogEntityConstructor = ( ); export interface CatalogCategoryVersion { + /** + * The specific version that the associated constructor is for. This MUST be + * a DNS label and SHOULD be of the form `vN`, `vNalphaY`, or `vNbetaY` where + * `N` and `Y` are both integers greater than 0. + * + * Examples: The following are valid values for this field. + * - `v1` + * - `v1beta1` + * - `v1alpha2` + * - `v3beta2` + */ name: string; + + /** + * The constructor for the entities. + */ entityClass: CatalogEntityConstructor; } export interface CatalogCategorySpec { + /** + * The grouping for for the category. This MUST be a DNS label. + */ group: string; + /** + * The specific versions of the constructors. + * + * NOTE: the field `.apiVersion` after construction MUST match `{.group}/{.versions.[] | .name}`. + * For example, if `group = "entity.k8slens.dev"` and there is an entry in `.versions` with + * `name = "v1alpha1"` then the resulting `.apiVersion` MUST be `entity.k8slens.dev/v1alpha1` + */ versions: CatalogCategoryVersion[]; names: { + /** + * The kind of entity that this category is for. This value MUST be a DNS + * label and MUST be equal to the `kind` fields that are produced by the + * `.versions.[] | .entityClass` fields. + */ kind: string; }; } @@ -114,6 +144,7 @@ export abstract class CatalogCategory extends (EventEmitter as new () => TypedEm export interface CatalogEntityMetadata { uid: string; name: string; + shortName?: string; description?: string; source?: string; labels: Record; @@ -211,7 +242,14 @@ export abstract class CatalogEntity< Status extends CatalogEntityStatus = CatalogEntityStatus, Spec extends CatalogEntitySpec = CatalogEntitySpec, > implements CatalogEntityKindData { + /** + * The group and version of this class. + */ public abstract readonly apiVersion: string; + + /** + * A DNS label name of the entity. + */ public abstract readonly kind: string; @observable metadata: Metadata; @@ -225,14 +263,35 @@ export abstract class CatalogEntity< this.spec = data.spec; } + /** + * Get the UID of this entity + */ public getId(): string { return this.metadata.uid; } + /** + * Get the name of this entity + */ public getName(): string { return this.metadata.name; } + /** + * Get the specified source of this entity, defaulting to `"unknown"` if not + * provided + */ + public getSource(): string { + return this.metadata.source ?? "unknown"; + } + + /** + * Get if this entity is enabled. + */ + public isEnabled(): boolean { + return this.status.enabled ?? true; + } + public abstract onRun?(context: CatalogEntityActionContext): void | Promise; public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise; public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise; diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index 4ac95833da..1d678a558c 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -27,14 +27,15 @@ import type { CatalogCategory, CatalogEntity } from "../../../common/catalog"; import { Icon } from "../icon"; import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; -import type { CatalogEntityItem } from "./catalog-entity-item"; import { isDevelopment } from "../../../common/vars"; import { cssNames } from "../../utils"; import { Avatar } from "../avatar"; +import { getLabelBadges } from "./helpers"; interface Props { - item: CatalogEntityItem | null | undefined; + entity: T; hideDetails(): void; + onRun: () => void; } @observer @@ -47,32 +48,30 @@ export class CatalogEntityDetails extends Component) { - const detailItems = CatalogEntityDetailRegistry.getInstance().getItemsForKind(item.kind, item.apiVersion); - const details = detailItems.map(({ components }, index) => { - return ; - }); - - const showDetails = detailItems.find((item) => item.priority > 999) === undefined; + renderContent(entity: T) { + const { onRun, hideDetails } = this.props; + const detailItems = CatalogEntityDetailRegistry.getInstance().getItemsForKind(entity.kind, entity.apiVersion); + const details = detailItems.map(({ components }, index) => ); + const showDefaultDetails = detailItems.find((item) => item.priority > 999) === undefined; return ( <> - {showDetails && ( + {showDefaultDetails && (
item.onRun()} + background={entity.spec.icon?.background} + onClick={onRun} className={styles.avatar} > - {item.entity.spec.icon?.material && } + {entity.spec.icon?.material && } - {item?.enabled && ( + {entity.isEnabled() && (
Click to open
@@ -80,23 +79,23 @@ export class CatalogEntityDetails extends Component
- {item.name} + {entity.getName()} - {item.kind} + {entity.kind} - {item.source} + {entity.getSource()} - {item.phase} + {entity.status.phase} - {...item.getLabelBadges(this.props.hideDetails)} + {getLabelBadges(entity, hideDetails)} {isDevelopment && ( - {item.getId()} + {entity.getId()} )}
@@ -110,19 +109,18 @@ export class CatalogEntityDetails extends Component} + title={`${entity.kind}: ${entity.getName()}`} + toolbar={} onClose={hideDetails} > - {item && this.renderContent(item)} + {this.renderContent(entity)} ); } diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx index 525afeea68..f841e4d433 100644 --- a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -29,11 +29,10 @@ import { navigate } from "../../navigation"; import { MenuItem } from "../menu"; import { ConfirmDialog } from "../confirm-dialog"; import { Icon } from "../icon"; -import type { CatalogEntityItem } from "./catalog-entity-item"; import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item"; export interface CatalogEntityDrawerMenuProps extends MenuActionsProps { - item: CatalogEntityItem | null | undefined; + entity: T; } @observer @@ -50,7 +49,7 @@ export class CatalogEntityDrawerMenu extends React.Comp menuItems: [], navigate: (url: string) => navigate(url), }; - this.props.item?.onContextMenuOpen(this.contextMenu); + this.props.entity?.onContextMenuOpen(this.contextMenu); } onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -108,9 +107,9 @@ export class CatalogEntityDrawerMenu extends React.Comp } render() { - const { className, item: entity, ...menuProps } = this.props; + const { className, entity, ...menuProps } = this.props; - if (!this.contextMenu || !entity.enabled) { + if (!this.contextMenu || !entity.isEnabled()) { return null; } @@ -120,7 +119,7 @@ export class CatalogEntityDrawerMenu extends React.Comp toolbar {...menuProps} > - {this.getMenuItems(entity.entity)} + {this.getMenuItems(entity)} ); } diff --git a/src/renderer/components/+catalog/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity.store.tsx index 225767d78f..8b7743f470 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity.store.tsx @@ -25,9 +25,8 @@ import type { CatalogEntity } from "../../api/catalog-entity"; import { ItemStore } from "../../../common/item.store"; import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog"; import { autoBind, disposer } from "../../../common/utils"; -import { CatalogEntityItem } from "./catalog-entity-item"; -export class CatalogEntityStore extends ItemStore> { +export class CatalogEntityStore extends ItemStore { constructor(private registry: CatalogEntityRegistry = catalogEntityRegistry) { super(); makeObservable(this); @@ -39,10 +38,10 @@ export class CatalogEntityStore extends ItemStore new CatalogEntityItem(entity, this.registry)); + return this.registry.filteredItems; } - return this.registry.getItemsForCategory(this.activeCategory, { filtered: true }).map(entity => new CatalogEntityItem(entity, this.registry)); + return this.registry.getItemsForCategory(this.activeCategory, { filtered: true }); } @computed get selectedItem() { @@ -68,4 +67,8 @@ export class CatalogEntityStore extends ItemStore this.entities, undefined, true); } + + onRun(entity: CatalogEntity): void { + this.registry.onRun(entity); + } } diff --git a/src/renderer/components/+catalog/catalog.test.tsx b/src/renderer/components/+catalog/catalog.test.tsx index 7d3aaa23e4..0f80aa475b 100644 --- a/src/renderer/components/+catalog/catalog.test.tsx +++ b/src/renderer/components/+catalog/catalog.test.tsx @@ -29,7 +29,6 @@ import { kubernetesClusterCategory } from "../../../common/catalog-entities/kube import { catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntity, CatalogEntityActionContext, CatalogEntityData } from "../../../common/catalog"; import { CatalogEntityRegistry } from "../../../renderer/api/catalog-entity-registry"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; -import { CatalogEntityItem } from "./catalog-entity-item"; import { CatalogEntityStore } from "./catalog-entity.store"; import { AppPaths } from "../../../common/app-paths"; @@ -130,7 +129,7 @@ describe("", () => { const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); - const catalogEntityItem = new CatalogEntityItem(createMockCatalogEntity(onRun), catalogEntityRegistry); + const catalogEntityItem = createMockCatalogEntity(onRun); // mock as if there is a selected item > the detail panel opens jest @@ -166,7 +165,7 @@ describe("", () => { const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); - const catalogEntityItem = new CatalogEntityItem(createMockCatalogEntity(onRun), catalogEntityRegistry); + const catalogEntityItem = createMockCatalogEntity(onRun); // mock as if there is a selected item > the detail panel opens jest @@ -200,7 +199,7 @@ describe("", () => { const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); - const catalogEntityItem = new CatalogEntityItem(createMockCatalogEntity(onRun), catalogEntityRegistry); + const catalogEntityItem = createMockCatalogEntity(onRun); // mock as if there is a selected item > the detail panel opens jest @@ -235,7 +234,7 @@ describe("", () => { const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(() => done()); - const catalogEntityItem = new CatalogEntityItem(createMockCatalogEntity(onRun), catalogEntityRegistry); + const catalogEntityItem = createMockCatalogEntity(onRun); // mock as if there is a selected item > the detail panel opens jest @@ -265,7 +264,7 @@ describe("", () => { const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); - const catalogEntityItem = new CatalogEntityItem(createMockCatalogEntity(onRun), catalogEntityRegistry); + const catalogEntityItem = createMockCatalogEntity(onRun); // mock as if there is a selected item > the detail panel opens jest @@ -302,7 +301,7 @@ describe("", () => { const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); const onRun = jest.fn(); - const catalogEntityItem = new CatalogEntityItem(createMockCatalogEntity(onRun), catalogEntityRegistry); + const catalogEntityItem = createMockCatalogEntity(onRun); // mock as if there is a selected item > the detail panel opens jest diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 8c9455d805..595ba6a81c 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -26,7 +26,6 @@ import { disposeOnUnmount, observer } from "mobx-react"; import { ItemListLayout } from "../item-object-list"; import { action, makeObservable, observable, reaction, runInAction, when } from "mobx"; import { CatalogEntityStore } from "./catalog-entity.store"; -import type { CatalogEntityItem } from "./catalog-entity-item"; import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; @@ -45,6 +44,8 @@ import { RenderDelay } from "../render-delay/render-delay"; import { Icon } from "../icon"; import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item"; import { Avatar } from "../avatar"; +import { KubeObject } from "../../../common/k8s-api/kube-object"; +import { getLabelBadges } from "./helpers"; export const previousActiveTab = createStorage("catalog-previous-active-tab", browseCatalogTab); @@ -125,19 +126,19 @@ export class Catalog extends React.Component { })); } - addToHotbar(item: CatalogEntityItem): void { - HotbarStore.getInstance().addToHotbar(item.entity); + addToHotbar(entity: CatalogEntity): void { + HotbarStore.getInstance().addToHotbar(entity); } - removeFromHotbar(item: CatalogEntityItem): void { - HotbarStore.getInstance().removeFromHotbar(item.getId()); + removeFromHotbar(entity: CatalogEntity): void { + HotbarStore.getInstance().removeFromHotbar(entity.getId()); } - onDetails = (item: CatalogEntityItem) => { + onDetails = (entity: CatalogEntity) => { if (this.catalogEntityStore.selectedItemId) { this.catalogEntityStore.selectedItemId = null; } else { - item.onRun(); + this.catalogEntityStore.onRun(entity); } }; @@ -179,16 +180,16 @@ export class Catalog extends React.Component { ); } - renderItemMenu = (item: CatalogEntityItem) => { + renderItemMenu = (entity: CatalogEntity) => { const onOpen = () => { this.contextMenu.menuItems = []; - item.onContextMenuOpen(this.contextMenu); + entity.onContextMenuOpen(this.contextMenu); }; return ( - this.catalogEntityStore.selectedItemId = item.getId()}> + this.catalogEntityStore.selectedItemId = entity.getId()}> View Details { @@ -200,7 +201,7 @@ export class Catalog extends React.Component { } @@ -208,29 +209,29 @@ export class Catalog extends React.Component { ); }; - renderName(item: CatalogEntityItem) { - const isItemInHotbar = HotbarStore.getInstance().isAddedToActive(item.entity); + renderName(entity: CatalogEntity) { + const isItemInHotbar = HotbarStore.getInstance().isAddedToActive(entity); return ( <> - {item.entity.spec.icon?.material && } + {entity.spec.icon?.material && } - {item.name} + {entity.getName()} isItemInHotbar ? this.removeFromHotbar(item) : this.addToHotbar(item))} + onClick={prevDefault(() => isItemInHotbar ? this.removeFromHotbar(entity) : this.addToHotbar(entity))} /> ); @@ -253,13 +254,19 @@ export class Catalog extends React.Component { isConfigurable={true} store={this.catalogEntityStore} sortingCallbacks={{ - [sortBy.name]: item => item.name, - [sortBy.source]: item => item.source, - [sortBy.status]: item => item.phase, - [sortBy.kind]: item => item.kind, + [sortBy.name]: entity => entity.getName(), + [sortBy.source]: entity => entity.getSource(), + [sortBy.status]: entity => entity.status.phase, + [sortBy.kind]: entity => entity.kind, }} searchFilters={[ - entity => entity.searchFields, + 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" }, @@ -268,15 +275,15 @@ export class Catalog extends React.Component { { title: "Labels", className: `${styles.labelsCell} scrollable`, id: "labels" }, { title: "Status", className: styles.statusCell, sortBy: sortBy.status, id: "status" }, ].filter(Boolean)} - customizeTableRowProps={item => ({ - disabled: !item.enabled, + customizeTableRowProps={entity => ({ + disabled: !entity.isEnabled(), })} - renderTableContents={item => [ - this.renderName(item), - !activeCategory && item.kind, - item.source, - item.getLabelBadges(), - {item.phase}, + renderTableContents={entity => [ + this.renderName(entity), + !activeCategory && entity.kind, + entity.getSource(), + getLabelBadges(entity), + {entity.status.phase}, ].filter(Boolean)} onDetails={this.onDetails} renderItemMenu={this.renderItemMenu} @@ -289,16 +296,19 @@ export class Catalog extends React.Component { return null; } + const selectedEntity = this.catalogEntityStore.selectedItem; + return (
{this.renderList()}
{ - this.catalogEntityStore.selectedItem + selectedEntity ? this.catalogEntityStore.selectedItemId = null} + onRun={() => this.catalogEntityStore.onRun(selectedEntity)} /> : ( diff --git a/src/renderer/components/+catalog/helpers.tsx b/src/renderer/components/+catalog/helpers.tsx new file mode 100644 index 0000000000..319bab8d2a --- /dev/null +++ b/src/renderer/components/+catalog/helpers.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import styles from "./catalog.module.scss"; +import React from "react"; +import { KubeObject } from "../../../common/k8s-api/kube-object"; +import type { CatalogEntity } from "../../api/catalog-entity"; +import { Badge } from "../badge"; +import { searchUrlParam } from "../input"; + +/** + * @param entity The entity to render badge labels for + */ +export function getLabelBadges(entity: CatalogEntity, onClick?: (evt: React.MouseEvent) => void) { + return KubeObject.stringifyLabels(entity.metadata.labels) + .map(label => ( + { + searchUrlParam.set(label); + onClick?.(event); + event.stopPropagation(); + }} + expandable={false} + /> + )); +}