From 4af796c53255874c16bee43ec1c5005ab14f8ba1 Mon Sep 17 00:00:00 2001 From: chh <1474479+chenhunghan@users.noreply.github.com> Date: Tue, 5 Oct 2021 22:36:44 +0300 Subject: [PATCH] Before hook for onRun of Catalog entities (#3911) Co-authored-by: Sebastian Malton --- package.json | 1 + src/extensions/lens-renderer-extension.ts | 2 +- src/extensions/renderer-api/catalog.ts | 13 +- src/jest.setup.ts | 7 + src/renderer/api/catalog-entity-registry.ts | 77 +++- .../+catalog/catalog-entity-details.tsx | 9 +- .../+catalog/catalog-entity-drawer-menu.tsx | 2 +- .../+catalog/catalog-entity-item.tsx | 114 ++++++ .../+catalog/catalog-entity.store.tsx | 116 +----- .../+catalog/catalog-menu.module.css | 2 +- .../components/+catalog/catalog-menu.tsx | 12 +- .../components/+catalog/catalog.test.tsx | 375 ++++++++++++++++++ src/renderer/components/+catalog/catalog.tsx | 17 +- .../activate-entity-command.tsx | 4 +- .../components/hotbar/hotbar-menu.tsx | 4 +- src/renderer/components/icon/icon.tsx | 4 +- yarn.lock | 5 + 17 files changed, 636 insertions(+), 128 deletions(-) create mode 100644 src/renderer/components/+catalog/catalog-entity-item.tsx create mode 100644 src/renderer/components/+catalog/catalog.test.tsx diff --git a/package.json b/package.json index 6842828c8adb..09804b171aa5 100644 --- a/package.json +++ b/package.json @@ -358,6 +358,7 @@ "postcss": "^8.3.6", "postcss-loader": "4.3.0", "postinstall-postinstall": "^2.1.0", + "prettier": "^2.4.1", "progress-bar-webpack-plugin": "^2.1.0", "randomcolor": "^0.6.2", "raw-loader": "^4.0.2", diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index abaac223082f..4a5abadbade8 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -77,7 +77,7 @@ export class LensRendererExtension extends LensExtension { } /** - * Add a filtering function for the catalog catogries. This will be removed if the extension is disabled. + * Add a filtering function for the catalog categories. This will be removed if the extension is disabled. * @param fn The function which should return a truthy value for those categories which should be kept. * @returns A function to clean up the filter */ diff --git a/src/extensions/renderer-api/catalog.ts b/src/extensions/renderer-api/catalog.ts index 2843bf873707..389107c80d8c 100644 --- a/src/extensions/renderer-api/catalog.ts +++ b/src/extensions/renderer-api/catalog.ts @@ -22,7 +22,8 @@ import type { CatalogCategory, CatalogEntity } from "../../common/catalog"; import { catalogEntityRegistry as registry } from "../../renderer/api/catalog-entity-registry"; - +import type { CatalogEntityOnBeforeRun } from "../../renderer/api/catalog-entity-registry"; +import type { Disposer } from "../../common/utils"; export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry"; export class CatalogEntityRegistry { @@ -48,6 +49,16 @@ export class CatalogEntityRegistry { getItemsForCategory(category: CatalogCategory): T[] { return registry.getItemsForCategory(category); } + + /** + * Add a onBeforeRun hook to a catalog entity. If `onBeforeRun` was previously added then it will not be added again + * @param catalogEntityUid The uid of the catalog entity + * @param onBeforeRun The function that should return a boolean if the onRun of catalog entity should be triggered. + * @returns A function to remove that hook + */ + addOnBeforeRun(entity: CatalogEntity, onBeforeRun: CatalogEntityOnBeforeRun): Disposer { + return registry.addOnBeforeRun(entity, onBeforeRun); + } } export const catalogEntities = new CatalogEntityRegistry(); diff --git a/src/jest.setup.ts b/src/jest.setup.ts index 29b0465c8bd2..a2349603862d 100644 --- a/src/jest.setup.ts +++ b/src/jest.setup.ts @@ -21,10 +21,17 @@ import fetchMock from "jest-fetch-mock"; import configurePackages from "./common/configure-packages"; +import { configure } from "mobx"; // setup default configuration for external npm-packages configurePackages(); +configure({ + // Needed because we want to use jest.spyOn() + // ref https://github.com/mobxjs/mobx/issues/2784 + safeDescriptors: false, +}); + // rewire global.fetch to call 'fetchMock' fetchMock.enableMocks(); diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index d731e2562a8c..f9c59fce0965 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { computed, observable, makeObservable, action } from "mobx"; +import { computed, observable, makeObservable, action, ObservableSet } from "mobx"; import { ipcRendererOn } from "../../common/ipc"; import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; import "../../common/catalog-entities"; @@ -27,8 +27,13 @@ import type { Cluster } from "../../main/cluster"; import { ClusterStore } from "../../common/cluster-store"; import { Disposer, iter } from "../utils"; import { once } from "lodash"; +import logger from "../../common/logger"; +import { catalogEntityRunContext } from "./catalog-entity"; export type EntityFilter = (entity: CatalogEntity) => any; +export type CatalogEntityOnBeforeRun = (entity: CatalogEntity) => boolean | Promise; + +type CatalogEntityUid = CatalogEntity["metadata"]["uid"]; export class CatalogEntityRegistry { @observable protected activeEntityId: string | undefined = undefined; @@ -36,6 +41,9 @@ export class CatalogEntityRegistry { protected filters = observable.set([], { deep: false, }); + protected onBeforeRunHooks = observable.map>({}, { + deep: false, + }); /** * Buffer for keeping entities that don't yet have CatalogCategory synced @@ -169,6 +177,73 @@ export class CatalogEntityRegistry { return once(() => void this.filters.delete(fn)); } + + /** + * Add a onBeforeRun hook to a catalog entity. If `onBeforeRun` was previously added then it will not be added again + * @param catalogEntityUid The uid of the catalog entity + * @param onBeforeRun The function that should return a boolean if the onRun of catalog entity should be triggered. + * @returns A function to remove that hook + */ + addOnBeforeRun(entityOrId: CatalogEntity | CatalogEntityUid, onBeforeRun: CatalogEntityOnBeforeRun): Disposer { + logger.debug(`[CATALOG-ENTITY-REGISTRY]: adding onBeforeRun to ${entityOrId}`); + + const id = typeof entityOrId === "string" + ? entityOrId + : entityOrId.getId(); + const hooks = this.onBeforeRunHooks.get(id) ?? + this.onBeforeRunHooks.set(id, observable.set([], { deep: false })).get(id); + + hooks.add(onBeforeRun); + + return once(() => void hooks.delete(onBeforeRun)); + } + + /** + * Runs all the registered `onBeforeRun` hooks, short circuiting on the first falsy returned/resolved valued + * @param entity The entity to run the hooks on + * @returns Whether the entities `onRun` method should be executed + */ + async onBeforeRun(entity: CatalogEntity): Promise { + logger.debug(`[CATALOG-ENTITY-REGISTRY]: run onBeforeRun on ${entity.getId()}`); + + const hooks = this.onBeforeRunHooks.get(entity.getId()); + + if (!hooks) { + return true; + } + + for (const onBeforeRun of hooks) { + try { + if (!await onBeforeRun(entity)) { + return false; + } + } catch (error) { + logger.warn(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onBeforeRun threw an error`, error); + + // If a handler throws treat it as if it has returned `false` + // Namely: assume that its internal logic has failed and didn't complete as expected + return false; + } + } + + return true; + } + + /** + * Perform the onBeforeRun check and, if successful, then proceed to call `entity`'s onRun method + * @param entity The instance to invoke the hooks and then execute the onRun + */ + onRun(entity: CatalogEntity): void { + this.onBeforeRun(entity) + .then(doOnRun => { + if (doOnRun) { + return entity.onRun?.(catalogEntityRunContext); + } else { + logger.debug(`onBeforeRun for ${entity.getId()} returned false`); + } + }) + .catch(error => logger.error(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onRun threw an error`, error)); + } } 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 fd1b874c828b..bff73a3fa20a 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -23,13 +23,12 @@ 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"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; import { HotbarIcon } from "../hotbar/hotbar-icon"; -import type { CatalogEntityItem } from "./catalog-entity.store"; +import type { CatalogEntityItem } from "./catalog-entity-item"; import { isDevelopment } from "../../../common/vars"; interface Props { @@ -68,8 +67,10 @@ export class CatalogEntityDetails extends Component item.onRun(catalogEntityRunContext)} - size={128} /> + onClick={() => item.onRun()} + size={128} + data-testid="detail-panel-hot-bar-icon" + /> {item?.enabled && (
Click to open diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx index af3104afe28c..ac4d31092a81 100644 --- a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -30,7 +30,7 @@ import { MenuItem } from "../menu"; import { ConfirmDialog } from "../confirm-dialog"; import { HotbarStore } from "../../../common/hotbar-store"; import { Icon } from "../icon"; -import type { CatalogEntityItem } from "./catalog-entity.store"; +import type { CatalogEntityItem } from "./catalog-entity-item"; export interface CatalogEntityDrawerMenuProps extends MenuActionsProps { item: CatalogEntityItem | null | undefined; diff --git a/src/renderer/components/+catalog/catalog-entity-item.tsx b/src/renderer/components/+catalog/catalog-entity-item.tsx new file mode 100644 index 000000000000..b16406758fc7 --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity-item.tsx @@ -0,0 +1,114 @@ +/** + * 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.css"; +import React from "react"; +import { action, computed } from "mobx"; +import type { CatalogEntity } from "../../api/catalog-entity"; +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 type { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; + +const css = makeCss(styles); + +export class CatalogEntityItem implements ItemObject { + constructor(public entity: T, private registry: CatalogEntityRegistry) {} + + get kind() { + return this.entity.kind; + } + + get apiVersion() { + return this.entity.apiVersion; + } + + get name() { + return this.entity.metadata.name; + } + + getName() { + return this.entity.metadata.name; + } + + get id() { + return this.entity.metadata.uid; + } + + getId() { + return this.id; + } + + @computed get phase() { + return this.entity.status.phase; + } + + get enabled() { + return this.entity.status.enabled ?? true; + } + + get labels() { + return KubeObject.stringifyLabels(this.entity.metadata.labels); + } + + getLabelBadges(onClick?: React.MouseEventHandler) { + return this.labels + .map(label => ( + { + navigation.searchParams.set(searchUrlParam.name, label); + onClick?.(event); + event.stopPropagation(); + }} + expandable={false} + /> + )); + } + + get source() { + return this.entity.metadata.source || "unknown"; + } + + get searchFields() { + return [ + this.name, + this.id, + this.phase, + `source=${this.source}`, + ...this.labels, + ]; + } + + onRun() { + this.registry.onRun(this.entity); + } + + @action + async onContextMenuOpen(ctx: any) { + return this.entity.onContextMenuOpen(ctx); + } +} diff --git a/src/renderer/components/+catalog/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity.store.tsx index 05f317b7b1fa..8bbc33501050 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity.store.tsx @@ -19,106 +19,16 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import styles from "./catalog.module.css"; - -import React from "react"; -import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from "mobx"; -import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity"; -import { ItemObject, ItemStore } from "../../../common/item.store"; +import { computed, makeObservable, observable, reaction } from "mobx"; +import { catalogEntityRegistry, CatalogEntityRegistry } from "../../api/catalog-entity-registry"; +import type { CatalogEntity } from "../../api/catalog-entity"; +import { ItemStore } from "../../../common/item.store"; import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog"; -import { autoBind } from "../../../common/utils"; -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"; - -const css = makeCss(styles); - -export class CatalogEntityItem implements ItemObject { - constructor(public entity: T) {} - - get kind() { - return this.entity.kind; - } - - get apiVersion() { - return this.entity.apiVersion; - } - - get name() { - return this.entity.metadata.name; - } - - getName() { - return this.entity.metadata.name; - } - - get id() { - return this.entity.metadata.uid; - } - - getId() { - return this.id; - } - - @computed get phase() { - return this.entity.status.phase; - } - - get enabled() { - return this.entity.status.enabled ?? true; - } - - get labels() { - return KubeObject.stringifyLabels(this.entity.metadata.labels); - } - - getLabelBadges(onClick?: React.MouseEventHandler) { - return this.labels - .map(label => ( - { - navigation.searchParams.set(searchUrlParam.name, label); - onClick?.(event); - event.stopPropagation(); - }} - expandable={false} - /> - )); - } - - get source() { - return this.entity.metadata.source || "unknown"; - } - - get searchFields() { - return [ - this.name, - this.id, - this.phase, - `source=${this.source}`, - ...this.labels, - ]; - } - - onRun(ctx: CatalogEntityActionContext) { - this.entity.onRun(ctx); - } - - @action - async onContextMenuOpen(ctx: any) { - return this.entity.onContextMenuOpen(ctx); - } -} +import { autoBind, disposer } from "../../../common/utils"; +import { CatalogEntityItem } from "./catalog-entity-item"; export class CatalogEntityStore extends ItemStore> { - constructor() { + constructor(private registry: CatalogEntityRegistry = catalogEntityRegistry) { super(); makeObservable(this); autoBind(this); @@ -129,10 +39,10 @@ export class CatalogEntityStore extends ItemStore new CatalogEntityItem(entity)); + return this.registry.filteredItems.map(entity => new CatalogEntityItem(entity, this.registry)); } - return catalogEntityRegistry.getItemsForCategory(this.activeCategory, { filtered: true }).map(entity => new CatalogEntityItem(entity)); + return this.registry.getItemsForCategory(this.activeCategory, { filtered: true }).map(entity => new CatalogEntityItem(entity, this.registry)); } @computed get selectedItem() { @@ -140,12 +50,10 @@ export class CatalogEntityStore extends ItemStore this.entities, () => this.loadAll()), - reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100}) - ]; - - return () => disposers.forEach((dispose) => dispose()); + reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100}), + ); } loadAll() { diff --git a/src/renderer/components/+catalog/catalog-menu.module.css b/src/renderer/components/+catalog/catalog-menu.module.css index 84584e089d6b..1c3f08f6d70c 100644 --- a/src/renderer/components/+catalog/catalog-menu.module.css +++ b/src/renderer/components/+catalog/catalog-menu.module.css @@ -32,4 +32,4 @@ .catalog { @apply p-5 font-bold text-2xl; color: var(--textColorAccent); -} \ No newline at end of file +} diff --git a/src/renderer/components/+catalog/catalog-menu.tsx b/src/renderer/components/+catalog/catalog-menu.tsx index 6634b2399de2..b2d013dfcdd1 100644 --- a/src/renderer/components/+catalog/catalog-menu.tsx +++ b/src/renderer/components/+catalog/catalog-menu.tsx @@ -41,11 +41,15 @@ function getCategories() { } function getCategoryIcon(category: CatalogCategory) { - if (!category.metadata?.icon) return null; + const { icon } = category.metadata ?? {}; - return category.metadata.icon.includes(" - : ; + if (typeof icon === "string") { + return icon.includes(" + : ; + } + + return null; } function Item(props: TreeItemProps) { diff --git a/src/renderer/components/+catalog/catalog.test.tsx b/src/renderer/components/+catalog/catalog.test.tsx new file mode 100644 index 000000000000..ac510d8fb4ac --- /dev/null +++ b/src/renderer/components/+catalog/catalog.test.tsx @@ -0,0 +1,375 @@ +/** + * 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, CatalogCategoryRegistry } 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(); + +// 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: () => catalogEntity.metadata.uid, + getName: () => catalogEntity.metadata.name, + 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.addOnBeforeRun to add hooks for catalog entities", (done) => { + const catalogCategoryRegistry = new CatalogCategoryRegistry(); + const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); + const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); + const onRun = jest.fn(); + const catalogEntityItem = new CatalogEntityItem({ + ...catalogEntity, + ...catalogEntityItemMethods, + onRun, + }, catalogEntityRegistry); + + // mock as if there is a selected item > the detail panel opens + jest + .spyOn(catalogEntityStore, "selectedItem", "get") + .mockImplementation(() => catalogEntityItem); + + catalogEntityRegistry.addOnBeforeRun( + catalogEntityUid, + (entity) => { + expect(entity).toMatchInlineSnapshot(` + Object { + "apiVersion": "api", + "enabled": true, + "getId": [Function], + "getName": [Function], + "kind": "kind", + "metadata": Object { + "labels": Object { + "test": "label", + }, + "name": "a catalog entity", + "uid": "a_catalogEntity_uid", + }, + "onContextMenuOpen": [Function], + "onRun": [MockFunction], + "onSettingsOpen": [Function], + "spec": Object {}, + "status": Object { + "phase": "", + }, + } + `); + + setTimeout(() => { + expect(onRun).toHaveBeenCalled(); + done(); + }, 500); + + return true; + } + ); + + render( + + ); + + userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); + }); + + it("onBeforeRun return false => onRun wont be triggered", (done) => { + const catalogCategoryRegistry = new CatalogCategoryRegistry(); + const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); + const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); + const onRun = jest.fn(); + const catalogEntityItem = new CatalogEntityItem({ + ...catalogEntity, + ...catalogEntityItemMethods, + onRun, + }, catalogEntityRegistry); + + // mock as if there is a selected item > the detail panel opens + jest + .spyOn(catalogEntityStore, "selectedItem", "get") + .mockImplementation(() => catalogEntityItem); + + catalogEntityRegistry.addOnBeforeRun( + catalogEntityUid, + () => { + setTimeout(() => { + expect(onRun).not.toHaveBeenCalled(); + done(); + }, 500); + + return false; + } + ); + + render( + + ); + + userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); + }); + + it("addOnBeforeRun throw an exception => onRun wont be triggered", (done) => { + const catalogCategoryRegistry = new CatalogCategoryRegistry(); + const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); + const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); + const onRun = jest.fn(); + const catalogEntityItem = new CatalogEntityItem({ + ...catalogEntity, + ...catalogEntityItemMethods, + onRun, + }, catalogEntityRegistry); + + // mock as if there is a selected item > the detail panel opens + jest + .spyOn(catalogEntityStore, "selectedItem", "get") + .mockImplementation(() => catalogEntityItem); + + catalogEntityRegistry.addOnBeforeRun( + catalogEntityUid, + () => { + setTimeout(() => { + expect(onRun).not.toHaveBeenCalled(); + done(); + }, 500); + + throw new Error("error!"); + } + ); + + render( + + ); + + userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); + }); + + it("addOnRunHook return a promise and resolve true => onRun()", (done) => { + const catalogCategoryRegistry = new CatalogCategoryRegistry(); + const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); + const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); + const onRun = jest.fn(() => done()); + const catalogEntityItem = new CatalogEntityItem({ + ...catalogEntity, + ...catalogEntityItemMethods, + onRun, + }, catalogEntityRegistry); + + // mock as if there is a selected item > the detail panel opens + jest + .spyOn(catalogEntityStore, "selectedItem", "get") + .mockImplementation(() => catalogEntityItem); + + catalogEntityRegistry.addOnBeforeRun( + catalogEntityUid, + async () => { + return true; + } + ); + + render( + + ); + + userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); + }); + + it("addOnRunHook return a promise and resolve false => onRun() wont be triggered", (done) => { + const catalogCategoryRegistry = new CatalogCategoryRegistry(); + const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); + const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); + const onRun = jest.fn(); + const catalogEntityItem = new CatalogEntityItem({ + ...catalogEntity, + ...catalogEntityItemMethods, + onRun, + }, catalogEntityRegistry); + + // mock as if there is a selected item > the detail panel opens + jest + .spyOn(catalogEntityStore, "selectedItem", "get") + .mockImplementation(() => catalogEntityItem); + + catalogEntityRegistry.addOnBeforeRun( + catalogEntityUid, + async () => { + expect(onRun).not.toBeCalled(); + + setTimeout(() => { + expect(onRun).not.toBeCalled(); + done(); + }, 500); + + return false; + } + ); + + render( + + ); + + userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); + }); + + it("addOnRunHook return a promise and reject => onRun wont be triggered", (done) => { + const catalogCategoryRegistry = new CatalogCategoryRegistry(); + const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); + const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry); + const onRun = jest.fn(); + const catalogEntityItem = new CatalogEntityItem({ + ...catalogEntity, + ...catalogEntityItemMethods, + onRun, + }, catalogEntityRegistry); + + // mock as if there is a selected item > the detail panel opens + jest + .spyOn(catalogEntityStore, "selectedItem", "get") + .mockImplementation(() => catalogEntityItem); + + catalogEntityRegistry.addOnBeforeRun( + catalogEntityUid, + async () => { + setTimeout(() => { + expect(onRun).not.toHaveBeenCalled(); + done(); + }, 500); + + throw new Error("rejection!"); + } + ); + + 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 49a57c44b0ac..5a479d437de1 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -25,10 +25,11 @@ import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { ItemListLayout } from "../item-object-list"; import { action, makeObservable, observable, reaction, runInAction, when } from "mobx"; -import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store"; +import { CatalogEntityStore } from "./catalog-entity.store"; +import type { CatalogEntityItem } from "./catalog-entity-item"; import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; -import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; +import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; import { HotbarStore } from "../../../common/hotbar-store"; import { ConfirmDialog } from "../confirm-dialog"; import { catalogCategoryRegistry, CatalogEntity } from "../../../common/catalog"; @@ -55,7 +56,10 @@ enum sortBy { const css = makeCss(styles); -interface Props extends RouteComponentProps {} +interface Props extends RouteComponentProps { + catalogEntityStore?: CatalogEntityStore; +} + @observer export class Catalog extends React.Component { @observable private catalogEntityStore?: CatalogEntityStore; @@ -65,8 +69,11 @@ export class Catalog extends React.Component { constructor(props: Props) { super(props); makeObservable(this); - this.catalogEntityStore = new CatalogEntityStore(); + this.catalogEntityStore = props.catalogEntityStore; } + static defaultProps = { + catalogEntityStore: new CatalogEntityStore(), + }; get routeActiveTab(): string { const { group, kind } = this.props.match.params ?? {}; @@ -126,7 +133,7 @@ export class Catalog extends React.Component { if (this.catalogEntityStore.selectedItemId) { this.catalogEntityStore.selectedItemId = null; } else { - item.onRun(catalogEntityRunContext); + item.onRun(); } }; diff --git a/src/renderer/components/activate-entity-command/activate-entity-command.tsx b/src/renderer/components/activate-entity-command/activate-entity-command.tsx index 110afd6b3c9c..f0b06769d871 100644 --- a/src/renderer/components/activate-entity-command/activate-entity-command.tsx +++ b/src/renderer/components/activate-entity-command/activate-entity-command.tsx @@ -22,7 +22,7 @@ import { computed } from "mobx"; import { observer } from "mobx-react"; import React from "react"; -import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; +import type { CatalogEntity } from "../../api/catalog-entity"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { CommandOverlay } from "../command-palette"; import { Select } from "../select"; @@ -37,7 +37,7 @@ export class ActivateEntityCommand extends React.Component { } onSelect(entity: CatalogEntity): void { - entity.onRun?.(catalogEntityRunContext); + catalogEntityRegistry.onRun(entity); CommandOverlay.close(); } diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index 69c4d69bb984..905527943780 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -27,7 +27,7 @@ import { HotbarEntityIcon } from "./hotbar-entity-icon"; import { cssNames, IClassName } from "../../utils"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { HotbarStore } from "../../../common/hotbar-store"; -import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; +import type { CatalogEntity } from "../../api/catalog-entity"; import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; import { HotbarSelector } from "./hotbar-selector"; import { HotbarCell } from "./hotbar-cell"; @@ -124,7 +124,7 @@ export class HotbarMenu extends React.Component { key={index} index={index} entity={entity} - onClick={() => entity.onRun(catalogEntityRunContext)} + onClick={() => catalogEntityRegistry.onRun(entity)} className={cssNames({ isDragging: snapshot.isDragging })} remove={this.removeItem} add={this.addItem} diff --git a/src/renderer/components/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index ee24b6e3fa19..48dfb518200d 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -114,14 +114,14 @@ export class Icon extends React.PureComponent { }; // render as inline svg-icon - if (svg) { + if (typeof svg === "string") { const svgIconText = svg.includes("; } // render as material-icon - if (material) { + if (typeof material === "string") { iconContent = {material}; } diff --git a/yarn.lock b/yarn.lock index ed3127b816f5..0de219ade0ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11231,6 +11231,11 @@ prepend-http@^2.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= +prettier@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.4.1.tgz#671e11c89c14a4cfc876ce564106c4a6726c9f5c" + integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA== + pretty-error@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3"