diff --git a/package.json b/package.json index 5d2d231392..a3ee10c9fd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "open-lens", "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", - "version": "5.0.0-beta.4", + "version": "5.0.0-beta.5", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 0512b200fb..92e5a3f35b 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -24,6 +24,27 @@ import { CatalogEntityItem } from "../../renderer/components/+catalog/catalog-en import { ClusterStore } from "../cluster-store"; import { HotbarStore } from "../hotbar-store"; +jest.mock("../../renderer/api/catalog-entity-registry", () => ({ + catalogEntityRegistry: { + items: [ + { + metadata: { + uid: "1dfa26e2ebab15780a3547e9c7fa785c", + name: "mycluster", + source: "local" + } + }, + { + metadata: { + uid: "55b42c3c7ba3b04193416cda405269a5", + name: "my_shiny_cluster", + source: "remote" + } + } + ] + } +})); + const testCluster = { uid: "test", name: "test", @@ -87,6 +108,17 @@ const awsCluster = { } }; +jest.mock("electron", () => { + return { + app: { + getVersion: () => "99.99.99", + getPath: () => "tmp", + getLocale: () => "en", + setLoginItemSettings: (): void => void 0, + } + }; +}); + describe("HotbarStore", () => { beforeEach(() => { ClusterStore.resetInstance(); @@ -264,4 +296,111 @@ describe("HotbarStore", () => { console.error = err; }); }); + + describe("pre beta-5 migrations", () => { + beforeEach(() => { + HotbarStore.resetInstance(); + const mockOpts = { + "tmp": { + "lens-hotbar-store.json": JSON.stringify({ + __internal__: { + migrations: { + version: "5.0.0-beta.3" + } + }, + "hotbars": [ + { + "id": "3caac17f-aec2-4723-9694-ad204465d935", + "name": "myhotbar", + "items": [ + { + "entity": { + "uid": "1dfa26e2ebab15780a3547e9c7fa785c" + } + }, + { + "entity": { + "uid": "55b42c3c7ba3b04193416cda405269a5" + } + }, + { + "entity": { + "uid": "176fd331968660832f62283219d7eb6e" + } + }, + { + "entity": { + "uid": "61c4fb45528840ebad1badc25da41d14", + "name": "user1-context", + "source": "local" + } + }, + { + "entity": { + "uid": "27d6f99fe9e7548a6e306760bfe19969", + "name": "foo2", + "source": "local" + } + }, + null, + { + "entity": { + "uid": "c0b20040646849bb4dcf773e43a0bf27", + "name": "multinode-demo", + "source": "local" + } + }, + null, + null, + null, + null, + null + ] + } + ], + }) + } + }; + + mockFs(mockOpts); + + return HotbarStore.createInstance().load(); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it("allows to retrieve a hotbar", () => { + const hotbar = HotbarStore.getInstance().getById("3caac17f-aec2-4723-9694-ad204465d935"); + + expect(hotbar.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); + }); + + it("clears cells without entity", () => { + const items = HotbarStore.getInstance().hotbars[0].items; + + expect(items[2]).toBeNull(); + }); + + it("adds extra data to cells with according entity", () => { + const items = HotbarStore.getInstance().hotbars[0].items; + + expect(items[0]).toEqual({ + entity: { + name: "mycluster", + source: "local", + uid: "1dfa26e2ebab15780a3547e9c7fa785c" + } + }); + + expect(items[1]).toEqual({ + entity: { + name: "my_shiny_cluster", + source: "remote", + uid: "55b42c3c7ba3b04193416cda405269a5" + } + }); + }); + }); }); diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index 4270756d97..2841c16046 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -29,6 +29,8 @@ import isNull from "lodash/isNull"; export interface HotbarItem { entity: { uid: string; + name?: string; + source?: string; }; params?: { [key: string]: string; @@ -144,9 +146,14 @@ export class HotbarStore extends BaseStore { } } + @action addToHotbar(item: CatalogEntityItem, cellIndex = -1) { const hotbar = this.getActive(); - const newItem = { entity: { uid: item.id }}; + const newItem = { entity: { + uid: item.id, + name: item.name, + source: item.source + }}; if (hotbar.items.find(i => i?.entity.uid === item.id)) { return; @@ -167,6 +174,7 @@ export class HotbarStore extends BaseStore { } } + @action removeFromHotbar(uid: string) { const hotbar = this.getActive(); const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); diff --git a/src/migrations/hotbar-store/5.0.0-beta.5.ts b/src/migrations/hotbar-store/5.0.0-beta.5.ts new file mode 100644 index 0000000000..fe6ff8dd68 --- /dev/null +++ b/src/migrations/hotbar-store/5.0.0-beta.5.ts @@ -0,0 +1,30 @@ +import type { Hotbar } from "../../common/hotbar-store"; +import { migration } from "../migration-wrapper"; +import { catalogEntityRegistry } from "../../renderer/api/catalog-entity-registry"; + +export default migration({ + version: "5.0.0-beta.5", + run(store) { + const hotbars: Hotbar[] = store.get("hotbars"); + + hotbars.forEach((hotbar, hotbarIndex) => { + hotbar.items.forEach((item, itemIndex) => { + const entity = catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item?.entity.uid); + + if (!entity) { + // Clear disabled item + hotbars[hotbarIndex].items[itemIndex] = null; + } else { + // Save additional data + hotbars[hotbarIndex].items[itemIndex].entity = { + ...item.entity, + name: entity.metadata.name, + source: entity.metadata.source + }; + } + }); + }); + + store.set("hotbars", hotbars); + } +}); diff --git a/src/migrations/hotbar-store/index.ts b/src/migrations/hotbar-store/index.ts index 842f144a18..d6824e8df0 100644 --- a/src/migrations/hotbar-store/index.ts +++ b/src/migrations/hotbar-store/index.ts @@ -2,8 +2,10 @@ import version500alpha0 from "./5.0.0-alpha.0"; import version500alpha2 from "./5.0.0-alpha.2"; +import version500beta5 from "./5.0.0-beta.5"; export default { ...version500alpha0, - ...version500alpha2 + ...version500alpha2, + ...version500beta5, }; diff --git a/src/renderer/components/+catalog/material-tooltip/material-tooltip.tsx b/src/renderer/components/+catalog/material-tooltip/material-tooltip.tsx new file mode 100644 index 0000000000..dbfd8b5323 --- /dev/null +++ b/src/renderer/components/+catalog/material-tooltip/material-tooltip.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { makeStyles, Tooltip, TooltipProps } from "@material-ui/core"; + +const useStyles = makeStyles(() => ({ + arrow: { + color: "var(--tooltipBackground)", + }, + tooltip: { + fontSize: 12, + backgroundColor: "var(--tooltipBackground)", + color: "var(--textColorAccent)", + padding: 8, + boxShadow: "0 8px 16px rgba(0,0,0,0.24)" + }, +})); + +export function MaterialTooltip(props: TooltipProps) { + const classes = useStyles(); + + return ( + + ); +} + +MaterialTooltip.defaultProps = { + arrow: true +}; + diff --git a/src/renderer/components/hotbar/hotbar-cell.tsx b/src/renderer/components/hotbar/hotbar-cell.tsx new file mode 100644 index 0000000000..aa19faa052 --- /dev/null +++ b/src/renderer/components/hotbar/hotbar-cell.tsx @@ -0,0 +1,32 @@ +import "./hotbar-menu.scss"; +import "./hotbar.commands"; + +import React, { HTMLAttributes, ReactNode, useState } from "react"; + +import { cssNames } from "../../utils"; + +interface Props extends HTMLAttributes { + children?: ReactNode; + index: number; + innerRef?: React.LegacyRef; +} + +export function HotbarCell({ innerRef, children, className, ...rest }: Props) { + const [animating, setAnimating] = useState(false); + const onAnimationEnd = () => { setAnimating(false); }; + const onClick = () => { + setAnimating(!className.includes("isDraggingOver")); + }; + + return ( +
+ {children} +
+ ); +} diff --git a/src/renderer/components/hotbar/hotbar-entity-icon.tsx b/src/renderer/components/hotbar/hotbar-entity-icon.tsx new file mode 100644 index 0000000000..52a93990ee --- /dev/null +++ b/src/renderer/components/hotbar/hotbar-entity-icon.tsx @@ -0,0 +1,115 @@ +import "./hotbar-icon.scss"; + +import React, { DOMAttributes } from "react"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import randomColor from "randomcolor"; + +import { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../../common/catalog"; +import { catalogCategoryRegistry } from "../../api/catalog-category-registry"; +import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { navigate } from "../../navigation"; +import { cssNames, IClassName } from "../../utils"; +import { ConfirmDialog } from "../confirm-dialog"; +import { Icon } from "../icon"; +import { HotbarIcon } from "./hotbar-icon"; + +interface Props extends DOMAttributes { + entity: CatalogEntity; + className?: IClassName; + errorClass?: IClassName; + remove: (uid: string) => void; +} + +@observer +export class HotbarEntityIcon extends React.Component { + @observable.deep private contextMenu: CatalogEntityContextMenuContext; + + componentDidMount() { + this.contextMenu = { + menuItems: [], + navigate: (url: string) => navigate(url) + }; + } + + get kindIcon() { + const className = "badge"; + const category = catalogCategoryRegistry.getCategoryForEntity(this.props.entity); + + if (!category) { + return ; + } + + if (category.metadata.icon.includes("; + } else { + return ; + } + } + + get ledIcon() { + const className = cssNames("led", { online: this.props.entity.status.phase == "connected"}); // TODO: make it more generic + + return
; + } + + isActive(item: CatalogEntity) { + return catalogEntityRegistry.activeEntity?.metadata?.uid == item.getId(); + } + + onMenuItemClick(menuItem: CatalogEntityContextMenu) { + if (menuItem.confirm) { + ConfirmDialog.open({ + okButtonProps: { + primary: false, + accent: true, + }, + ok: () => { + menuItem.onClick(); + }, + message: menuItem.confirm.message + }); + } else { + menuItem.onClick(); + } + } + + generateAvatarStyle(entity: CatalogEntity): React.CSSProperties { + return { + "backgroundColor": randomColor({ seed: `${entity.metadata.name}-${entity.metadata.source}`, luminosity: "dark" }) + }; + } + + render() { + const { + entity, errorClass, remove, + children, ...elemProps + } = this.props; + const className = cssNames("HotbarEntityIcon", this.props.className, { + interactive: true, + active: this.isActive(entity), + disabled: !entity + }); + const onOpen = async () => { + await entity.onContextMenuOpen(this.contextMenu); + }; + const menuItems = this.contextMenu?.menuItems.filter((menuItem) => !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source); + + return ( + + { this.ledIcon } + { this.kindIcon } + + ); + } +} diff --git a/src/renderer/components/hotbar/hotbar-icon.scss b/src/renderer/components/hotbar/hotbar-icon.scss index e06eb9f553..bd2c7bd5d9 100644 --- a/src/renderer/components/hotbar/hotbar-icon.scss +++ b/src/renderer/components/hotbar/hotbar-icon.scss @@ -41,6 +41,18 @@ transition: all 0s 0.8s; } + &.disabled { + opacity: 0.4; + cursor: default; + filter: grayscale(0.7); + + &:hover { + &:not(.active) { + box-shadow: none; + } + } + } + &.active, &.interactive:hover { img { opacity: 1; diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index c0396c4328..74154505e3 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -21,28 +21,51 @@ import "./hotbar-icon.scss"; -import React, { DOMAttributes } from "react"; -import { observer } from "mobx-react"; -import { cssNames, IClassName, iter } from "../../utils"; -import { Tooltip } from "../tooltip"; +import React, { DOMAttributes, useState } from "react"; import { Avatar } from "@material-ui/core"; -import { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../../common/catalog"; -import { Menu, MenuItem } from "../menu"; -import { Icon } from "../icon"; -import { computed, observable } from "mobx"; -import { navigate } from "../../navigation"; -import { HotbarStore } from "../../../common/hotbar-store"; -import { ConfirmDialog } from "../confirm-dialog"; import randomColor from "randomcolor"; -import { catalogCategoryRegistry } from "../../api/catalog-category-registry"; import GraphemeSplitter from "grapheme-splitter"; +import { CatalogEntityContextMenu } from "../../../common/catalog"; +import { cssNames, IClassName, iter } from "../../utils"; +import { ConfirmDialog } from "../confirm-dialog"; +import { Icon } from "../icon"; +import { Menu, MenuItem } from "../menu"; +import { MaterialTooltip } from "../+catalog/material-tooltip/material-tooltip"; + interface Props extends DOMAttributes { - entity: CatalogEntity; - index: number; + uid: string; + title: string; + source: string; + remove: (uid: string) => void; + onMenuOpen?: () => void; className?: IClassName; - errorClass?: IClassName; - isActive?: boolean; + active?: boolean; + menuItems?: CatalogEntityContextMenu[]; + disabled?: boolean; +} + +function generateAvatarStyle(seed: string): React.CSSProperties { + return { + "backgroundColor": randomColor({ seed, luminosity: "dark" }) + }; +} + +function onMenuItemClick(menuItem: CatalogEntityContextMenu) { + if (menuItem.confirm) { + ConfirmDialog.open({ + okButtonProps: { + primary: false, + accent: true, + }, + ok: () => { + menuItem.onClick(); + }, + message: menuItem.confirm.message + }); + } else { + menuItem.onClick(); + } } function getNameParts(name: string): string[] { @@ -61,20 +84,17 @@ function getNameParts(name: string): string[] { return name.split(/@+/); } -@observer -export class HotbarIcon extends React.Component { - @observable.deep private contextMenu: CatalogEntityContextMenuContext; - @observable menuOpen = false; +export function HotbarIcon(props: Props) { + const { uid, title, className, source, active, remove, disabled, menuItems, onMenuOpen, children, ...rest } = props; + const id = `hotbarIcon-${uid}`; + const [menuOpen, setMenuOpen] = useState(false); - componentDidMount() { - this.contextMenu = { - menuItems: [], - navigate: (url: string) => navigate(url) - }; - } + const toggleMenu = () => { + setMenuOpen(!menuOpen); + }; - @computed get iconString() { - const [rawFirst, rawSecond, rawThird] = getNameParts(this.props.entity.metadata.name); + const getIconString = () => { + const [rawFirst, rawSecond, rawThird] = getNameParts(title); const splitter = new GraphemeSplitter(); const first = splitter.iterateGraphemes(rawFirst); const second = rawSecond ? splitter.iterateGraphemes(rawSecond): first; @@ -85,114 +105,53 @@ export class HotbarIcon extends React.Component { ...iter.take(second, 1), ...iter.take(third, 1), ].filter(Boolean).join(""); - } + }; - get kindIcon() { - const className = "badge"; - const category = catalogCategoryRegistry.getCategoryForEntity(this.props.entity); - - if (!category) { - return ; - } - - if (category.metadata.icon.includes("; - } else { - return ; - } - } - - get ledIcon() { - const className = cssNames("led", { online: this.props.entity.status.phase == "connected"}); // TODO: make it more generic - - return
; - } - - toggleMenu() { - this.menuOpen = !this.menuOpen; - } - - remove(item: CatalogEntity) { - const hotbar = HotbarStore.getInstance(); - - hotbar.removeFromHotbar(item.getId()); - } - - onMenuItemClick(menuItem: CatalogEntityContextMenu) { - if (menuItem.confirm) { - ConfirmDialog.open({ - okButtonProps: { - primary: false, - accent: true, - }, - ok: () => { - menuItem.onClick(); - }, - message: menuItem.confirm.message - }); - } else { - menuItem.onClick(); - } - } - - generateAvatarStyle(entity: CatalogEntity): React.CSSProperties { - return { - "backgroundColor": randomColor({ seed: `${entity.metadata.name}-${entity.metadata.source}`, luminosity: "dark" }) - }; - } - - render() { - const { - entity, errorClass, isActive, - children, ...elemProps - } = this.props; - const entityIconId = `hotbar-icon-${this.props.index}`; - const className = cssNames("HotbarIcon flex inline", this.props.className, { - interactive: true, - active: isActive, - }); - const onOpen = async () => { - await entity.onContextMenuOpen(this.contextMenu); - this.toggleMenu(); - }; - const menuItems = this.contextMenu?.menuItems.filter((menuItem) => !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source); - - return ( -
- {entity.metadata.name} ({entity.metadata.source || "local"}) - - {this.iconString} - - { this.ledIcon } - { this.kindIcon } - onOpen()} - close={() => this.toggleMenu()}> - this.remove(entity) }> - Remove from Hotbar - - { this.contextMenu && menuItems.map((menuItem) => { - return ( - this.onMenuItemClick(menuItem) }> - {menuItem.title} - - ); - })} - - {children} -
- ); - } + return ( +
+ +
+ + {getIconString()} + + {children} +
+
+ { + onMenuOpen?.(); + toggleMenu(); + }} + close={() => toggleMenu()}> + { + evt.stopPropagation(); + remove(uid); + }}> + Remove from Hotbar + + { menuItems.map((menuItem) => { + return ( + onMenuItemClick(menuItem) }> + {menuItem.title} + + ); + })} + +
+ ); } + +HotbarIcon.defaultProps = { + menuItems: [] +}; diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index e2c1e45cdb..319b05f946 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -22,15 +22,17 @@ import "./hotbar-menu.scss"; import "./hotbar.commands"; -import React, { HTMLAttributes, ReactNode, useState } from "react"; +import React from "react"; import { observer } from "mobx-react"; -import { HotbarIcon } from "./hotbar-icon"; +import { HotbarEntityIcon } from "./hotbar-entity-icon"; import { cssNames, IClassName } from "../../utils"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { defaultHotbarCells, HotbarItem, HotbarStore } from "../../../common/hotbar-store"; -import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; +import { catalogEntityRunContext } from "../../api/catalog-entity"; import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; import { HotbarSelector } from "./hotbar-selector"; +import { HotbarCell } from "./hotbar-cell"; +import { HotbarIcon } from "./hotbar-icon"; interface Props { className?: IClassName; @@ -42,10 +44,6 @@ export class HotbarMenu extends React.Component { return HotbarStore.getInstance().getActive(); } - isActive(item: CatalogEntity) { - return catalogEntityRegistry.activeEntity?.metadata?.uid == item.getId(); - } - getEntity(item: HotbarItem) { const hotbar = HotbarStore.getInstance().getActive(); @@ -69,6 +67,12 @@ export class HotbarMenu extends React.Component { HotbarStore.getInstance().restackItems(from, to); } + removeItem(uid: string) { + const hotbar = HotbarStore.getInstance(); + + hotbar.removeFromHotbar(uid); + } + getMoveAwayDirection(entityId: string, cellIndex: number) { const draggableItemIndex = this.hotbar.items.findIndex(item => item?.entity.uid == entityId); @@ -78,7 +82,6 @@ export class HotbarMenu extends React.Component { renderGrid() { return this.hotbar.items.map((item, index) => { const entity = this.getEntity(item); - const isActive = !entity ? false : this.isActive(entity); return ( @@ -93,7 +96,7 @@ export class HotbarMenu extends React.Component { }, this.getMoveAwayDirection(snapshot.draggingOverWith, index))} {...provided.droppableProps} > - {entity && ( + {item && ( {(provided, snapshot) => { const style = { @@ -110,14 +113,23 @@ export class HotbarMenu extends React.Component { {...provided.dragHandleProps} style={style} > - entity.onRun(catalogEntityRunContext)} - className={cssNames({ isDragging: snapshot.isDragging })} - /> + {entity ? ( + entity.onRun(catalogEntityRunContext)} + className={cssNames({ isDragging: snapshot.isDragging })} + remove={this.removeItem} + /> + ) : ( + + )}
); }} @@ -148,33 +160,3 @@ export class HotbarMenu extends React.Component { ); } } - -interface HotbarCellProps extends HTMLAttributes { - children?: ReactNode; - index: number; - innerRef?: React.LegacyRef; -} - -function HotbarCell({ innerRef, children, className, ...rest }: HotbarCellProps) { - const [animating, setAnimating] = useState(false); - const onAnimationEnd = () => { setAnimating(false); }; - const onClick = () => { - if (className.includes("isDraggingOver")) { - return; - } - - setAnimating(true); - }; - - return ( -
- {children} -
- ); -} diff --git a/src/renderer/components/hotbar/hotbar-selector.tsx b/src/renderer/components/hotbar/hotbar-selector.tsx index 108db0c85e..a602cea6e6 100644 --- a/src/renderer/components/hotbar/hotbar-selector.tsx +++ b/src/renderer/components/hotbar/hotbar-selector.tsx @@ -23,43 +23,31 @@ import "./hotbar-selector.scss"; import React from "react"; import { Icon } from "../icon"; import { Badge } from "../badge"; -import { makeStyles, Tooltip } from "@material-ui/core"; import { Hotbar, HotbarStore } from "../../../common/hotbar-store"; import { CommandOverlay } from "../command-palette"; import { HotbarSwitchCommand } from "./hotbar-switch-command"; +import { MaterialTooltip } from "../+catalog/material-tooltip/material-tooltip"; interface Props { hotbar: Hotbar; } -const useStyles = makeStyles(() => ({ - arrow: { - color: "#222", - }, - tooltip: { - fontSize: 12, - backgroundColor: "#222", - }, -})); - - export function HotbarSelector({ hotbar }: Props) { const store = HotbarStore.getInstance(); const activeIndexDisplay = store.activeHotbarIndex + 1; - const classes = useStyles(); return (
store.switchToPrevious()} />
- + CommandOverlay.open()} /> - +
store.switchToNext()} />
diff --git a/src/renderer/themes/lens-dark.json b/src/renderer/themes/lens-dark.json index 0082f9ce63..f96eb81453 100644 --- a/src/renderer/themes/lens-dark.json +++ b/src/renderer/themes/lens-dark.json @@ -128,6 +128,7 @@ "settingsColor": "#909ba6", "navSelectedBackground": "#262b2e", "navHoverColor": "#dcddde", - "hrColor": "#ffffff0f" + "hrColor": "#ffffff0f", + "tooltipBackground": "#18191c" } } diff --git a/src/renderer/themes/lens-light.json b/src/renderer/themes/lens-light.json index 5a4365b500..d3b5938de3 100644 --- a/src/renderer/themes/lens-light.json +++ b/src/renderer/themes/lens-light.json @@ -130,6 +130,7 @@ "settingsColor": "#555555", "navSelectedBackground": "#ffffff", "navHoverColor": "#2e3135", - "hrColor": "#06060714" + "hrColor": "#06060714", + "tooltipBackground": "#ffffff" } }