From 0fb927f96baf9e5e7f067284ec147e73d4d128cd Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 7 Jun 2021 10:09:00 +0300 Subject: [PATCH] catalog details panel (#2939) Signed-off-by: Jari Kolehmainen Co-authored-by: Sebastian Malton --- integration/helpers/minikube.ts | 2 + .../catalog-entities/kubernetes-cluster.ts | 6 +- src/common/catalog/catalog-entity.ts | 19 ++- src/extensions/extension-loader.ts | 1 + src/extensions/lens-renderer-extension.ts | 3 +- .../catalog-entity-detail-registry.ts | 46 +++++++ src/extensions/registries/index.ts | 1 + .../+catalog/catalog-entity-details.scss | 43 ++++++ .../+catalog/catalog-entity-details.tsx | 129 ++++++++++++++++++ .../+catalog/catalog-entity-drawer-menu.tsx | 127 +++++++++++++++++ src/renderer/components/+catalog/catalog.tsx | 23 ++-- .../components/hotbar/hotbar-entity-icon.tsx | 1 + .../components/hotbar/hotbar-icon.tsx | 9 +- .../components/hotbar/hotbar-menu.tsx | 2 + 14 files changed, 395 insertions(+), 17 deletions(-) create mode 100644 src/extensions/registries/catalog-entity-detail-registry.ts create mode 100644 src/renderer/components/+catalog/catalog-entity-details.scss create mode 100644 src/renderer/components/+catalog/catalog-entity-details.tsx create mode 100644 src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index c94b31aa93..bbe411047b 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -64,6 +64,8 @@ export async function waitForMinikubeDashboard(app: Application) { await app.client.setValue(".Input.SearchInput input", "minikube"); await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.click("div.TableRow"); + await app.client.waitUntilTextExists("div.drawer-title-text", "KubernetesCluster: minikube"); + await app.client.click("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root"); await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); await app.client.waitForExist(`iframe[name="minikube"]`); await app.client.frame("minikube"); diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index dc6d36d023..67d82e3501 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -104,6 +104,7 @@ export class KubernetesCluster extends CatalogEntity context.navigate(`/entity/${this.metadata.uid}/settings`) }, @@ -112,6 +113,7 @@ export class KubernetesCluster extends CatalogEntity ClusterStore.getInstance().removeById(this.metadata.uid), confirm: { @@ -123,6 +125,7 @@ export class KubernetesCluster extends CatalogEntity { requestMain(clusterDisconnectHandler, this.metadata.uid); } @@ -130,6 +133,7 @@ export class KubernetesCluster extends CatalogEntity { context.navigate(`/cluster/${this.metadata.uid}`); } @@ -147,7 +151,7 @@ export class KubernetesClusterCategory extends CatalogCategory { public readonly kind = "CatalogCategory"; public metadata = { name: "Kubernetes Clusters", - icon: require(`!!raw-loader!./icons/kubernetes.svg`).default // eslint-disable-line + icon: require(`!!raw-loader!./icons/kubernetes.svg`).default, // eslint-disable-line }; public spec: CatalogCategorySpec = { group: "entity.k8slens.dev", diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index afc15d4c64..6c58497204 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -96,9 +96,25 @@ export interface CatalogEntityActionContext { } export interface CatalogEntityContextMenu { + /** + * Menu title + */ title: string; - onlyVisibleForSource?: string; // show only if empty or if matches with entity source + /** + * Menu icon + */ + icon?: string; + /** + * Show only if empty or if value matches with entity.metadata.source + */ + onlyVisibleForSource?: string; + /** + * OnClick handler + */ onClick: () => void | Promise; + /** + * Confirm click with a message + */ confirm?: { message: string; } @@ -175,7 +191,6 @@ export abstract class CatalogEntity< } public abstract onRun?(context: CatalogEntityActionContext): void | Promise; - public abstract onDetailsOpen(context: CatalogEntityActionContext): void | Promise; public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise; public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise; } diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 8651455380..6a4af088e8 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -250,6 +250,7 @@ export class ExtensionLoader extends Singleton { registries.statusBarRegistry.add(extension.statusBarItems), registries.commandRegistry.add(extension.commands), registries.welcomeMenuRegistry.add(extension.welcomeMenus), + registries.catalogEntityDetailRegistry.add(extension.catalogEntityDetailItems), ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index e9105dbcd9..fb26d383b0 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -20,7 +20,7 @@ */ import type { - AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, + AppPreferenceRegistration, CatalogEntityDetailRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, WorkloadsOverviewDetailRegistration, } from "./registries"; import type { Cluster } from "../main/cluster"; @@ -43,6 +43,7 @@ export class LensRendererExtension extends LensExtension { kubeWorkloadsOverviewItems: WorkloadsOverviewDetailRegistration[] = []; commands: CommandRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = []; + catalogEntityDetailItems: CatalogEntityDetailRegistration[] = []; async navigate

(pageId?: string, params?: P) { const { navigate } = await import("../renderer/navigation"); diff --git a/src/extensions/registries/catalog-entity-detail-registry.ts b/src/extensions/registries/catalog-entity-detail-registry.ts new file mode 100644 index 0000000000..0ed00b7a8b --- /dev/null +++ b/src/extensions/registries/catalog-entity-detail-registry.ts @@ -0,0 +1,46 @@ +/** + * 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 type React from "react"; +import { BaseRegistry } from "./base-registry"; + +export interface CatalogEntityDetailComponents { + Details: React.ComponentType; +} + +export interface CatalogEntityDetailRegistration { + kind: string; + apiVersions: string[]; + components: CatalogEntityDetailComponents; + priority?: number; +} + +export class CatalogEntityDetailRegistry extends BaseRegistry { + getItemsForKind(kind: string, apiVersion: string) { + const items = this.getItems().filter((item) => { + return item.kind === kind && item.apiVersions.includes(apiVersion); + }); + + return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50)); + } +} + +export const catalogEntityDetailRegistry = new CatalogEntityDetailRegistry(); diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index e3a6490d61..b9ba986521 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -33,4 +33,5 @@ export * from "./command-registry"; export * from "./entity-setting-registry"; export * from "./welcome-menu-registry"; export * from "./protocol-handler-registry"; +export * from "./catalog-entity-detail-registry"; export * from "./workloads-overview-detail-registry"; diff --git a/src/renderer/components/+catalog/catalog-entity-details.scss b/src/renderer/components/+catalog/catalog-entity-details.scss new file mode 100644 index 0000000000..f63e7c3ba3 --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity-details.scss @@ -0,0 +1,43 @@ +/** + * 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. + */ + + .CatalogEntityDetails { + .EntityMetadata { + margin-right: $margin; + } + .EntityIcon.box.top.left { + margin-right: $margin * 2; + + .IconHint { + text-align: center; + font-size: var(--font-size-small); + text-transform: uppercase; + margin-top: $margin; + cursor: default; + user-select: none; + opacity: 0.5; + } + + div * { + font-size: 1.5em; + } + } + } diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx new file mode 100644 index 0000000000..8837920de4 --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity-details.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 "./catalog-entity-details.scss"; +import React, { Component } from "react"; +import { observer } from "mobx-react"; +import { Drawer, DrawerItem, DrawerItemLabels } from "../drawer"; +import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; +import type { CatalogCategory } from "../../../common/catalog"; +import { Icon } from "../icon"; +import { KubeObject } from "../../api/kube-object"; +import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu"; +import { catalogEntityDetailRegistry } from "../../../extensions/registries"; +import { HotbarIcon } from "../hotbar/hotbar-icon"; + +interface Props { + entity: CatalogEntity; + hideDetails(): void; +} + +@observer +export class CatalogEntityDetails extends Component { + private abortController?: AbortController; + + constructor(props: Props) { + super(props); + } + + componentWillUnmount() { + this.abortController?.abort(); + } + + categoryIcon(category: CatalogCategory) { + if (category.metadata.icon.includes("; + } else { + return ; + } + } + + openEntity() { + this.props.entity.onRun(catalogEntityRunContext); + } + + renderContent() { + const { entity } = this.props; + const labels = KubeObject.stringifyLabels(entity.metadata.labels); + const detailItems = catalogEntityDetailRegistry.getItemsForKind(entity.kind, entity.apiVersion); + const details = detailItems.map((item, index) => { + return ; + }); + + const showDetails = detailItems.find((item) => item.priority > 999) === undefined; + + return ( + <> + {showDetails && ( +

+
+ this.openEntity()} + size={128} /> +
+ Click to open +
+
+
+ + {entity.metadata.name} + + + {entity.kind} + + + {entity.metadata.source} + + +
+
+ )} +
+ {details} +
+ + ); + } + + render() { + const { entity, hideDetails } = this.props; + const title = `${entity.kind}: ${entity.metadata.name}`; + + return ( + } + onClose={hideDetails} + > + {this.renderContent()} + + ); + } +} diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx new file mode 100644 index 0000000000..77c9df5d47 --- /dev/null +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -0,0 +1,127 @@ +/** + * 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 { cssNames } from "../../utils"; +import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; +import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; +import { observer } from "mobx-react"; +import { makeObservable, observable } from "mobx"; +import { navigate } from "../../navigation"; +import { MenuItem } from "../menu"; +import { ConfirmDialog } from "../confirm-dialog"; +import { HotbarStore } from "../../../common/hotbar-store"; +import { Icon } from "../icon"; + +export interface CatalogEntityDrawerMenuProps extends MenuActionsProps { + entity: T | null | undefined; +} + +@observer +export class CatalogEntityDrawerMenu extends React.Component> { + @observable private contextMenu: CatalogEntityContextMenuContext; + + constructor(props: CatalogEntityDrawerMenuProps) { + super(props); + makeObservable(this); + } + + componentDidMount() { + this.contextMenu = { + menuItems: [], + navigate: (url: string) => navigate(url) + }; + this.props.entity?.onContextMenuOpen(this.contextMenu); + } + + onMenuItemClick(menuItem: CatalogEntityContextMenu) { + if (menuItem.confirm) { + ConfirmDialog.open({ + okButtonProps: { + primary: false, + accent: true, + }, + ok: () => { + menuItem.onClick(); + }, + message: menuItem.confirm.message + }); + } else { + menuItem.onClick(); + } + } + + addToHotbar(entity: CatalogEntity): void { + HotbarStore.getInstance().addToHotbar(entity); + } + + getMenuItems(entity: T): React.ReactChild[] { + if (!entity) { + return []; + } + + const menuItems = this.contextMenu.menuItems.filter((menuItem) => { + return menuItem.icon && !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source; + }); + + const items = menuItems.map((menuItem, index) => { + const props = menuItem.icon.includes(" this.onMenuItemClick(menuItem)}> + + + ); + + }); + + items.unshift( + this.addToHotbar(entity) }> + + + ); + + items.reverse(); + + return items; + } + + render() { + if (!this.contextMenu) { + return null; + } + + const { className, entity, ...menuProps } = this.props; + + return ( + + {this.getMenuItems(entity)} + + ); + } +} diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 066295b864..90adf27a65 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -29,7 +29,7 @@ import { navigate } from "../../navigation"; import { kebabCase } from "lodash"; import { PageLayout } from "../layout/page-layout"; import { MenuItem, MenuActions } from "../menu"; -import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; +import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; import { Badge } from "../badge"; import { HotbarStore } from "../../../common/hotbar-store"; import { ConfirmDialog } from "../confirm-dialog"; @@ -40,6 +40,7 @@ import type { RouteComponentProps } from "react-router"; import type { ICatalogViewRouteParam } from "./catalog.route"; import { Notifications } from "../notifications"; import { Avatar } from "../avatar/avatar"; +import { CatalogEntityDetails } from "./catalog-entity-details"; enum sortBy { name = "name", @@ -55,6 +56,7 @@ export class Catalog extends React.Component { @observable private catalogEntityStore?: CatalogEntityStore; @observable private contextMenu: CatalogEntityContextMenuContext; @observable activeTab?: string; + @observable selectedItem?: CatalogEntityItem; constructor(props: Props) { super(props); @@ -103,7 +105,7 @@ export class Catalog extends React.Component { } onDetails(item: CatalogEntityItem) { - item.onRun(catalogEntityRunContext); + this.selectedItem = item; } onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -181,12 +183,6 @@ export class Catalog extends React.Component { }; renderIcon(item: CatalogEntityItem) { - const category = catalogCategoryRegistry.getCategoryForEntity(item.entity); - - if (!category) { - return null; - } - return ( { item.labels.map((label) => ), { title: item.phase, className: kebabCase(item.phase) } ]} + detailsItem={this.selectedItem} onDetails={(item: CatalogEntityItem) => this.onDetails(item) } renderItemMenu={this.renderItemMenu} /> @@ -287,7 +284,15 @@ export class Catalog extends React.Component { provideBackButtonNavigation={false} contentGaps={false}> { this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() } - + { !this.selectedItem && ( + + )} + { this.selectedItem && ( + this.selectedItem = null} + /> + )} ); } diff --git a/src/renderer/components/hotbar/hotbar-entity-icon.tsx b/src/renderer/components/hotbar/hotbar-entity-icon.tsx index 8c267e8956..f346727523 100644 --- a/src/renderer/components/hotbar/hotbar-entity-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-entity-icon.tsx @@ -39,6 +39,7 @@ interface Props extends DOMAttributes { errorClass?: IClassName; add: (item: CatalogEntity, index: number) => void; remove: (uid: string) => void; + size?: number; } @observer diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index 6dd86414e7..dd74a944de 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -31,7 +31,7 @@ import { MaterialTooltip } from "../material-tooltip/material-tooltip"; import { observer } from "mobx-react"; import { Avatar } from "../avatar/avatar"; -interface Props extends DOMAttributes { +export interface HotbarIconProps extends DOMAttributes { uid: string; title: string; source: string; @@ -40,6 +40,7 @@ interface Props extends DOMAttributes { active?: boolean; menuItems?: CatalogEntityContextMenu[]; disabled?: boolean; + size?: number; } function onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -59,7 +60,7 @@ function onMenuItemClick(menuItem: CatalogEntityContextMenu) { } } -export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => { +export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: HotbarIconProps) => { const { uid, title, active, className, source, disabled, onMenuOpen, children, ...rest } = props; const id = `hotbarIcon-${uid}`; const [menuOpen, setMenuOpen] = useState(false); @@ -77,8 +78,8 @@ export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => { title={title} colorHash={`${title}-${source}`} className={active ? "active" : "default"} - width={40} - height={40} + width={size} + height={size} /> {children} diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index d3bb838ac7..68d93b1675 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -157,6 +157,7 @@ export class HotbarMenu extends React.Component { className={cssNames({ isDragging: snapshot.isDragging })} remove={this.removeItem} add={this.addItem} + size={40} /> ) : ( { source={item.entity.source} menuItems={disabledMenuItems} disabled + size={40} /> )}