diff --git a/src/common/catalog-entities/general.ts b/src/common/catalog-entities/general.ts index 5d13eeabee..4e856f0eae 100644 --- a/src/common/catalog-entities/general.ts +++ b/src/common/catalog-entities/general.ts @@ -39,6 +39,10 @@ export class GeneralEntity extends CatalogEntity(this) + ?.emit("onClickDetailIcon", this, context); + } + async onContextMenuOpen(context: CatalogEntityContextMenuContext) { if (!this.metadata.source || this.metadata.source === "local") { context.menuItems.push({ diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 8309945f24..886ffb186a 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { CatalogCategory, CatalogEntity, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; +import { CatalogCategory, CatalogEntity, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityActionContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { productName } from "../vars"; import { WeblinkStore } from "../weblink-store"; @@ -46,6 +46,12 @@ export class WebLink extends CatalogEntity(this) + ?.emit("onClickDetailIcon", this, context); + } + async onContextMenuOpen(context: CatalogEntityContextMenuContext) { if (this.metadata.source === "local") { context.menuItems.push({ diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index d59a21686c..a0605a9666 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -59,6 +59,7 @@ export interface CatalogCategoryEvents { load: () => void; catalogAddMenu: (context: CatalogEntityAddMenuContext) => void; contextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void; + onClickDetailIcon: (entity: CatalogEntity, context: CatalogEntityActionContext) => void; } export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter) { @@ -230,7 +231,21 @@ export abstract class CatalogEntity< return this.metadata.name; } + /** + * Trigger when user click on the icon in details panel + * + * @remarks + * Priority < this.onClickDetailIcon, if this.onClickDetailIcon presents, onRun won't be called. + */ public abstract onRun?(context: CatalogEntityActionContext): void | Promise; public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise; + + /** + * Trigger when user click on the icon in details panel + * + * @remarks + * Priority > this.onRun, if presents, onRun won't be called. + */ + public abstract onClickDetailIcon?(context: CatalogEntityActionContext): void; public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise; } diff --git a/src/main/catalog/__tests__/catalog-entity-registry.test.ts b/src/main/catalog/__tests__/catalog-entity-registry.test.ts index ee8450ae26..1fbcd819ed 100644 --- a/src/main/catalog/__tests__/catalog-entity-registry.test.ts +++ b/src/main/catalog/__tests__/catalog-entity-registry.test.ts @@ -32,6 +32,10 @@ class InvalidEntity extends CatalogEntity", () => { + + beforeEach(() => { + CatalogEntityDetailRegistry.createInstance(); + }); + + it("trigger onRun when click on detail panel icon", () => { + const onRun = jest.fn(); + + const item = new CatalogEntityItem({ + enabled: true, + apiVersion: "", + kind: "", + metadata: { + uid: "", + name: "", + labels: {}, + }, + status: { + phase: "", + }, + spec: {}, + getId: () => "", + getName: () => "", + onContextMenuOpen: () => {}, + onSettingsOpen: () => {}, + onRun, + }); + + render( + {}} + /> + ); + + userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); + + expect(onRun).toHaveBeenCalledTimes(1); + }); + + it("prioritize onClickDetailIcon over onRun", () => { + const onRun = jest.fn(); + const onClickDetailIcon = jest.fn(); + + const item = new CatalogEntityItem({ + enabled: true, + apiVersion: "", + kind: "", + metadata: { + uid: "", + name: "", + labels: {}, + }, + status: { + phase: "", + }, + spec: {}, + getId: () => "", + getName: () => "", + onContextMenuOpen: () => {}, + onSettingsOpen: () => {}, + onRun, + onClickDetailIcon, + }); + + render( + {}} + /> + ); + + userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); + + expect(onRun).toHaveBeenCalledTimes(0); + expect(onClickDetailIcon).toHaveBeenCalledTimes(1); + }); +}); + diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index fd1b874c82..3799b6721a 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -29,7 +29,7 @@ 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 +68,16 @@ export class CatalogEntityDetails extends Component item.onRun(catalogEntityRunContext)} - size={128} /> + onClick={() => { + if (typeof item.entity.onClickDetailIcon === "function") { + item.onClickDetailIcon(catalogEntityRunContext); + } else { + item.onRun(catalogEntityRunContext); + } + }} + size={128} + data-testid="detail-panel-hot-bar-icon" + /> {item?.enabled && (
Click to open 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 0000000000..3496928964 --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity-item.tsx @@ -0,0 +1,129 @@ +/** + * 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, CatalogEntityActionContext } 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"; + +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, + ]; + } + + /** + * Trigger when user click on the icon in details panel + * + * @remarks + * Priority < this.onClickDetailIcon, if this.onClickDetailIcon presents, onRun won't be called. + */ + onRun(ctx: CatalogEntityActionContext) { + this.entity.onRun(ctx); + } + + @action + async onContextMenuOpen(ctx: any) { + return this.entity.onContextMenuOpen(ctx); + } + + /** + * Trigger when user click on the icon in details panel + * + * @remarks + * Priority > this.onRun, if presents, onRun won't be called. + */ + onClickDetailIcon(ctx: CatalogEntityActionContext) { + this.entity.onClickDetailIcon?.(ctx); + } +} diff --git a/src/renderer/components/+catalog/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity.store.tsx index 05f317b7b1..c05ac55910 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity.store.tsx @@ -19,103 +19,13 @@ * 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 { 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 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 { CatalogEntityItem } from "./catalog-entity-item"; export class CatalogEntityStore extends ItemStore> { constructor() { diff --git a/src/renderer/components/+catalog/catalog-menu.module.css b/src/renderer/components/+catalog/catalog-menu.module.css index 84584e089d..1c3f08f6d7 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.test.tsx b/src/renderer/components/+catalog/catalog.test.tsx new file mode 100644 index 0000000000..9adf3598fd --- /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 { CatalogEntityDetailRegistry } from "../../../extensions/registries"; +import { createMemoryHistory } from "history"; +import { mockWindow } from "../../../../__mocks__/windowMock"; +import { + KubernetesCluster, + kubernetesClusterCategory, +} from "../../../common/catalog-entities/kubernetes-cluster"; +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 ""; + }, + }, + }; +}); + +// avoid crashese in +jest.mock("../../api/catalog-category-registry", () => { + return { + catalogCategoryRegistry: { + filteredItems: [], + }, + }; +}); + +describe("", () => { + beforeEach(() => { + CatalogEntityDetailRegistry.createInstance(); + }); + + beforeAll(() => { + // mock the return of getting CatalogEntityStore.selectedItem + jest + .spyOn(CatalogEntityStore.prototype, "selectedItem", "get") + .mockImplementation(() => { + const mockCluster = new KubernetesCluster({ + metadata: { + uid: "", + name: "", + source: "", + labels: {}, + distro: "", + kubeVersion: "", + }, + spec: { + kubeconfigPath: "", + kubeconfigContext: "", + icon: {}, + }, + status: { + phase: "disconnected", + reason: "", + message: "", + active: false, + }, + }); + + const entityItem = new CatalogEntityItem(mockCluster); + + return entityItem; + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("emits ", () => { + const history = createMemoryHistory(); + const mockLocation = { + pathname: "", + search: "", + state: "", + hash: "", + }; + const mockMatch = { + params: {}, + isExact: true, + path: "", + url: "", + }; + + render( + + ); + + const emit = jest.spyOn(kubernetesClusterCategory, "emit"); + + userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon")); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "onClickDetailIcon", + KubernetesCluster { + "apiVersion": "entity.k8slens.dev/v1alpha1", + "kind": "KubernetesCluster", + "metadata": Object { + "distro": "", + "kubeVersion": "", + "labels": Object {}, + "name": "", + "source": "", + "uid": "", + }, + "spec": Object { + "icon": Object {}, + "kubeconfigContext": "", + "kubeconfigPath": "", + }, + "status": Object { + "active": false, + "message": "", + "phase": "disconnected", + "reason": "", + }, + }, + Object { + "navigate": [Function], + "setCommandPaletteContext": [Function], + }, + ], + ] + `); + }); +}); diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 49a57c44b0..38e95aefce 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -25,7 +25,8 @@ 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";