From cfdb4c93c19f0526e4ef59f6e5c53bcd8f49dd5f Mon Sep 17 00:00:00 2001 From: "Hung-Han (Henry) Chen" Date: Mon, 4 Oct 2021 16:04:37 +0300 Subject: [PATCH] Add onRunHook extension api Signed-off-by: Hung-Han (Henry) Chen --- src/extensions/renderer-api/catalog.ts | 29 +++- src/renderer/api/catalog-entity-registry.ts | 45 +++++ .../+catalog/catalog-entity-details.tsx | 4 +- .../+catalog/catalog-entity-item.tsx | 18 +- .../+catalog/catalog-entity.store.tsx | 5 + .../components/+catalog/catalog.test.tsx | 160 ++++++++++++++++++ src/renderer/components/+catalog/catalog.tsx | 11 +- .../components/hotbar/hotbar-menu.tsx | 24 ++- 8 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 src/renderer/components/+catalog/catalog.test.tsx diff --git a/src/extensions/renderer-api/catalog.ts b/src/extensions/renderer-api/catalog.ts index 2843bf8737..1e6f8e2d9b 100644 --- a/src/extensions/renderer-api/catalog.ts +++ b/src/extensions/renderer-api/catalog.ts @@ -22,7 +22,7 @@ import type { CatalogCategory, CatalogEntity } from "../../common/catalog"; import { catalogEntityRegistry as registry } from "../../renderer/api/catalog-entity-registry"; - +import type { CatelogEntityOnRunHook } from "../../renderer/api/catalog-entity-registry"; export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry"; export class CatalogEntityRegistry { @@ -48,6 +48,33 @@ export class CatalogEntityRegistry { getItemsForCategory(category: CatalogCategory): T[] { return registry.getItemsForCategory(category); } + + /** + * Add a onRun hook to a catalog entity. + * @param catalogEntityUid The uid of the catalog entity + * @param onRunHook The function that should return a boolean if the onRun of catalog entity should be triggered. + * @returns A function to remove that hook + */ + addOnRunHook(catalogEntityUid: CatalogEntity["metadata"]["uid"], onRunHook: CatelogEntityOnRunHook) { + return registry.addOnRunHook(catalogEntityUid, onRunHook); + } + + /** + * Returns one catalog entity onRun hook by catalog entity uid + */ + getOnRunHook(catalogEntityUid: CatalogEntity["metadata"]["uid"]): CatelogEntityOnRunHook | undefined { + return registry.getOnRunHook(catalogEntityUid); + } + + /** + * Returns all catalog entities' onRun hooks + */ + getAllOnRunHooks(): Array<{ + catalogEntityUid: CatalogEntity["metadata"]["uid"]; + onRunHook: CatelogEntityOnRunHook; + }> { + return registry.getAllOnRunHooks(); + } } export const catalogEntities = new CatalogEntityRegistry(); diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index d731e2562a..26d50a6259 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -27,8 +27,12 @@ import type { Cluster } from "../../main/cluster"; import { ClusterStore } from "../../common/cluster-store"; import { Disposer, iter } from "../utils"; import { once } from "lodash"; +import type { CatalogEntityItem } from "../../renderer/components/+catalog/catalog-entity-item" export type EntityFilter = (entity: CatalogEntity) => any; +export type CatelogEntityOnRunHook = (entity: CatalogEntity | CatalogEntityItem) => boolean | Promise; + +type CatalogEntityUid = CatalogEntity["metadata"]["uid"]; export class CatalogEntityRegistry { @observable protected activeEntityId: string | undefined = undefined; @@ -36,6 +40,12 @@ export class CatalogEntityRegistry { protected filters = observable.set([], { deep: false, }); + protected entityOnRunHooks = observable.set<{ + catalogEntityUid: CatalogEntityUid; + onRunHook: CatelogEntityOnRunHook + }>([], { + deep: false, + }); /** * Buffer for keeping entities that don't yet have CatalogCategory synced @@ -169,6 +179,41 @@ export class CatalogEntityRegistry { return once(() => void this.filters.delete(fn)); } + + /** + * Add a onRun hook to a catalog entity. + * @param uid The uid of the catalog entity + * @param onRunHook The function that should return a boolean if the onRun of catalog entity should be triggered. + * @returns A function to remove that hook + */ + addOnRunHook(catalogEntityUid: CatalogEntityUid, onRunHook: CatelogEntityOnRunHook): Disposer { + this.entityOnRunHooks.add({ + catalogEntityUid, + onRunHook, + }); + + return once(() => void this.entityOnRunHooks.delete({ + catalogEntityUid, + onRunHook, + })); + } + + /** + * Returns one catalog entity onRun hook by catalog entity uid + */ + getOnRunHook(_catalogEntityUid: CatalogEntityUid): CatelogEntityOnRunHook | undefined { + return Array.from(this.entityOnRunHooks).find(({ catalogEntityUid }) => catalogEntityUid === _catalogEntityUid)?.onRunHook; + } + + /** + * Returns all catalog entities' onRun hooks + */ + getAllOnRunHooks(): Array<{ + catalogEntityUid: CatalogEntityUid; + onRunHook: CatelogEntityOnRunHook; + }> { + return Array.from(this.entityOnRunHooks); + } } export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index df4928b0be..4e6eaae86c 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -23,7 +23,6 @@ import "./catalog-entity-details.scss"; import React, { Component } from "react"; import { observer } from "mobx-react"; import { Drawer, DrawerItem } from "../drawer"; -import { catalogEntityRunContext } from "../../api/catalog-entity"; import type { CatalogCategory, CatalogEntity } from "../../../common/catalog"; import { Icon } from "../icon"; import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu"; @@ -35,6 +34,7 @@ import { isDevelopment } from "../../../common/vars"; interface Props { item: CatalogEntityItem | null | undefined; hideDetails(): void; + onClickDetailPanelIcon: (catalogEntitiyItem: CatalogEntityItem) => void; } @observer @@ -69,7 +69,7 @@ export class CatalogEntityDetails extends Component { - item.onRun(catalogEntityRunContext); + this.props.onClickDetailPanelIcon(item); }} size={128} data-testid="detail-panel-hot-bar-icon" diff --git a/src/renderer/components/+catalog/catalog-entity-item.tsx b/src/renderer/components/+catalog/catalog-entity-item.tsx index 5ed01aded6..486c63ac1b 100644 --- a/src/renderer/components/+catalog/catalog-entity-item.tsx +++ b/src/renderer/components/+catalog/catalog-entity-item.tsx @@ -22,12 +22,14 @@ import styles from "./catalog.module.css"; import React from "react"; import { action, computed } from "mobx"; import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity"; +import type { CatelogEntityOnRunHook } from "../../api/catalog-entity-registry"; import type { ItemObject } from "../../../common/item.store"; import { Badge } from "../badge"; import { navigation } from "../../navigation"; import { searchUrlParam } from "../input"; import { makeCss } from "../../../common/utils/makeCss"; import { KubeObject } from "../../../common/k8s-api/kube-object"; +import { toJS } from "mobx"; const css = makeCss(styles); @@ -102,8 +104,20 @@ export class CatalogEntityItem implements ItemObject { ]; } - onRun(ctx: CatalogEntityActionContext) { - this.entity.onRun(ctx); + onRun(onRunHook: CatelogEntityOnRunHook | undefined, ctx: CatalogEntityActionContext) { + if (!onRunHook) { + this.entity.onRun(ctx); + + return; + } + + if (typeof onRunHook === "function") { + // if onRunHook() returns a Promise, we wait for it to resolve + // if not, just take whatever it returns + Promise.resolve(onRunHook(toJS(this.entity))).then((shouldRun) => { + if (shouldRun) this.entity.onRun(ctx); + }); + } } @action diff --git a/src/renderer/components/+catalog/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity.store.tsx index c05ac55910..381200f2e0 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity.store.tsx @@ -22,6 +22,7 @@ import { computed, IReactionDisposer, makeObservable, observable, reaction } from "mobx"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import type { CatalogEntity } from "../../api/catalog-entity"; +import type { CatelogEntityOnRunHook } from "../../api/catalog-entity-registry"; import { ItemStore } from "../../../common/item.store"; import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog"; import { autoBind } from "../../../common/utils"; @@ -49,6 +50,10 @@ export class CatalogEntityStore extends ItemStore e.getId() === this.selectedItemId); } + getCatalogEntityOnRunHook(catalogEntityUid: CatalogEntity["metadata"]["uid"]): CatelogEntityOnRunHook | undefined { + return catalogEntityRegistry.getOnRunHook(catalogEntityUid); + } + watch() { const disposers: IReactionDisposer[] = [ reaction(() => this.entities, () => this.loadAll()), diff --git a/src/renderer/components/+catalog/catalog.test.tsx b/src/renderer/components/+catalog/catalog.test.tsx new file mode 100644 index 0000000000..f52cef5979 --- /dev/null +++ b/src/renderer/components/+catalog/catalog.test.tsx @@ -0,0 +1,160 @@ +/** + * 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 React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Catalog } from "./catalog"; +import { createMemoryHistory } from "history"; +import { mockWindow } from "../../../../__mocks__/windowMock"; +import { kubernetesClusterCategory } from "../../../common/catalog-entities/kubernetes-cluster"; +import { catalogCategoryRegistry, CatalogEntity } 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"; + +mockWindow(); + +const isCatalogEntityItem = (entity: any): entity is CatalogEntityItem => typeof entity.enable === "boolean"; + +// avoid TypeError: Cannot read property 'getPath' of undefined +jest.mock("@electron/remote", () => { + return { + app: { + getPath: () => { + // avoid TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined + return ""; + }, + }, + }; +}); + +describe("", () => { + const history = createMemoryHistory(); + const mockLocation = { + pathname: "", + search: "", + state: "", + hash: "", + }; + const mockMatch = { + params: { + // will be used to match activeCategory + // need to be the same as property values in kubernetesClusterCategory + group: "entity.k8slens.dev", + kind: "KubernetesCluster", + }, + isExact: true, + path: "", + url: "", + }; + + const catalogEntityUid = "a_catalogEntity_uid"; + const catalogEntity = { + enabled: true, + apiVersion: "api", + kind: "kind", + metadata: { + uid: catalogEntityUid, + name: "a catalog entity", + labels: { + test: "label", + }, + }, + status: { + phase: "", + }, + spec: {}, + }; + const catalogEntityItemMethods = { + getId: () => "", + getName: () => "", + onContextMenuOpen: () => {}, + onSettingsOpen: () => {}, + onRun: () => {}, + }; + + beforeEach(() => { + CatalogEntityDetailRegistry.createInstance(); + // mock the return of getting CatalogCategoryRegistry.filteredItems + jest + .spyOn(catalogCategoryRegistry, "filteredItems", "get") + .mockImplementation(() => { + return [kubernetesClusterCategory]; + }); + + // we don't care what this.renderList renders in this test case. + jest.spyOn(Catalog.prototype, "renderList").mockImplementation(() => { + return empty renderList; + }); + }); + + afterEach(() => { + CatalogEntityDetailRegistry.resetInstance(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it("can use catalogEntityRegistry.addOnRunHook to add hooks for catalog entities", (done) => { + const catalogEntityItem = new CatalogEntityItem({ + ...catalogEntity, + ...catalogEntityItemMethods, + }); + + // mock as if there is a selected item > the detail panel opens + jest + .spyOn(CatalogEntityStore.prototype, "selectedItem", "get") + .mockImplementation(() => { + return catalogEntityItem; + }); + + let hookGetCalled = false; + + catalogEntityRegistry.addOnRunHook(catalogEntityUid, (entity) => { + hookGetCalled = true; + expect(hookGetCalled).toBe(true); + + expect(entity.apiVersion).toBe(catalogEntity.apiVersion); + expect(entity.kind).toBe(catalogEntity.kind); + + if (isCatalogEntityItem(entity)) { + expect(entity.enabled).toBe(catalogEntity.enabled); + } + + if (!isCatalogEntityItem(entity)) { + expect(entity.metadata).toBe(catalogEntity.metadata); + expect(entity.status).toBe(catalogEntity.status); + expect(entity.spec).toBe(catalogEntity.spec); + } + + done(); + + return true; + }); + + render( + + ); + + userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); + }); +}); diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 38e95aefce..d21dd0d08d 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -127,10 +127,18 @@ export class Catalog extends React.Component { if (this.catalogEntityStore.selectedItemId) { this.catalogEntityStore.selectedItemId = null; } else { - item.onRun(catalogEntityRunContext); + const onRunHook = this.catalogEntityStore.getCatalogEntityOnRunHook(item.entity.metadata.uid); + + item.onRun(onRunHook, catalogEntityRunContext); } }; + onClickDetailPanelIcon = (item: CatalogEntityItem) => { + const onRunHook = this.catalogEntityStore.getCatalogEntityOnRunHook(item.entity.metadata.uid); + + item.onRun(onRunHook, catalogEntityRunContext); + }; + onMenuItemClick(menuItem: CatalogEntityContextMenu) { if (menuItem.confirm) { ConfirmDialog.open({ @@ -276,6 +284,7 @@ export class Catalog extends React.Component { ? this.catalogEntityStore.selectedItemId = null} + onClickDetailPanelIcon={this.onClickDetailPanelIcon} /> : ( diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index 69c4d69bb9..d2ebf99bd8 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -26,6 +26,7 @@ import { observer } from "mobx-react"; import { HotbarEntityIcon } from "./hotbar-entity-icon"; import { cssNames, IClassName } from "../../utils"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import type { CatelogEntityOnRunHook} from "../../api/catalog-entity-registry"; import { HotbarStore } from "../../../common/hotbar-store"; import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; @@ -33,6 +34,7 @@ import { HotbarSelector } from "./hotbar-selector"; import { HotbarCell } from "./hotbar-cell"; import { HotbarIcon } from "./hotbar-icon"; import { defaultHotbarCells, HotbarItem } from "../../../common/hotbar-types"; +import { toJS } from "mobx"; interface Props { className?: IClassName; @@ -54,6 +56,10 @@ export class HotbarMenu extends React.Component { return catalogEntityRegistry.getById(item?.entity.uid) ?? null; } + getEntityOnRunHook(uid: string): CatelogEntityOnRunHook | undefined { + return catalogEntityRegistry.getOnRunHook(uid); + } + onDragEnd(result: DropResult) { const { source, destination } = result; @@ -124,7 +130,23 @@ export class HotbarMenu extends React.Component { key={index} index={index} entity={entity} - onClick={() => entity.onRun(catalogEntityRunContext)} + onClick={() => { + const onRunHook = this.getEntityOnRunHook(entity.metadata.uid); + + if (!onRunHook) { + entity.onRun(catalogEntityRunContext); + + return; + } + + if (typeof onRunHook === "function") { + // if onRunHook() returns a Promise, we wait for it to resolve + // if not, just take whatever it returns + Promise.resolve(onRunHook(toJS(entity))).then((shouldRun) => { + if (shouldRun) entity.onRun(catalogEntityRunContext); + }); + } + }} className={cssNames({ isDragging: snapshot.isDragging })} remove={this.removeItem} add={this.addItem}