diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index ceb45e15e0..8e4748e515 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -1,7 +1,71 @@ import mockFs from "mock-fs"; +import { CatalogEntityItem } from "../../renderer/components/+catalog/catalog-entity.store"; import { ClusterStore } from "../cluster-store"; import { HotbarStore } from "../hotbar-store"; +const testCluster = { + uid: "test", + name: "test", + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running" + }, + spec: {}, + getName: jest.fn(), + getId: jest.fn(), + onDetailsOpen: jest.fn(), + onContextMenuOpen: jest.fn(), + onSettingsOpen: jest.fn(), + metadata: { + uid: "test", + name: "test", + labels: {} + } +}; + +const minikubeCluster = { + uid: "minikube", + name: "minikube", + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running" + }, + spec: {}, + getName: jest.fn(), + getId: jest.fn(), + onDetailsOpen: jest.fn(), + onContextMenuOpen: jest.fn(), + onSettingsOpen: jest.fn(), + metadata: { + uid: "minikube", + name: "minikube", + labels: {} + } +}; + +const awsCluster = { + uid: "aws", + name: "aws", + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running" + }, + spec: {}, + getName: jest.fn(), + getId: jest.fn(), + onDetailsOpen: jest.fn(), + onContextMenuOpen: jest.fn(), + onSettingsOpen: jest.fn(), + metadata: { + uid: "aws", + name: "aws", + labels: {} + } +}; + describe("HotbarStore", () => { beforeEach(() => { ClusterStore.resetInstance(); @@ -31,4 +95,137 @@ describe("HotbarStore", () => { expect(hotbarStore.hotbars.length).toEqual(2); }); }); + + describe("hotbar items", () => { + it("initially creates 12 empty cells", () => { + const hotbarStore = HotbarStore.createInstance(); + + hotbarStore.load(); + expect(hotbarStore.getActive().items.length).toEqual(12); + }); + + it("adds items", () => { + const hotbarStore = HotbarStore.createInstance(); + const entity = new CatalogEntityItem(testCluster); + + hotbarStore.load(); + hotbarStore.addToHotbar(entity); + const items = hotbarStore.getActive().items.filter(Boolean); + + expect(items.length).toEqual(1); + }); + + it("removes items", () => { + const hotbarStore = HotbarStore.createInstance(); + const entity = new CatalogEntityItem(testCluster); + + hotbarStore.load(); + hotbarStore.addToHotbar(entity); + hotbarStore.removeFromHotbar("test"); + const items = hotbarStore.getActive().items.filter(Boolean); + + expect(items.length).toEqual(0); + }); + + it("does nothing if removing with invalid uid", () => { + const hotbarStore = HotbarStore.createInstance(); + const entity = new CatalogEntityItem(testCluster); + + hotbarStore.load(); + hotbarStore.addToHotbar(entity); + hotbarStore.removeFromHotbar("invalid uid"); + const items = hotbarStore.getActive().items.filter(Boolean); + + expect(items.length).toEqual(1); + }); + + it("moves item to empty cell", () => { + const hotbarStore = HotbarStore.createInstance(); + const test = new CatalogEntityItem(testCluster); + const minikube = new CatalogEntityItem(minikubeCluster); + const aws = new CatalogEntityItem(awsCluster); + + hotbarStore.load(); + hotbarStore.addToHotbar(test); + hotbarStore.addToHotbar(minikube); + hotbarStore.addToHotbar(aws); + + expect(hotbarStore.getActive().items[5]).toBeNull(); + + hotbarStore.restackItems(1, 5); + + expect(hotbarStore.getActive().items[5]).toBeTruthy(); + expect(hotbarStore.getActive().items[5].entity.uid).toEqual("minikube"); + }); + + it("moves items down", () => { + const hotbarStore = HotbarStore.createInstance(); + const test = new CatalogEntityItem(testCluster); + const minikube = new CatalogEntityItem(minikubeCluster); + const aws = new CatalogEntityItem(awsCluster); + + hotbarStore.load(); + hotbarStore.addToHotbar(test); + hotbarStore.addToHotbar(minikube); + hotbarStore.addToHotbar(aws); + + // aws -> test + hotbarStore.restackItems(2, 0); + + const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); + + expect(items.slice(0, 4)).toEqual(["aws", "test", "minikube", null]); + }); + + it("moves items up", () => { + const hotbarStore = HotbarStore.createInstance(); + const test = new CatalogEntityItem(testCluster); + const minikube = new CatalogEntityItem(minikubeCluster); + const aws = new CatalogEntityItem(awsCluster); + + hotbarStore.load(); + hotbarStore.addToHotbar(test); + hotbarStore.addToHotbar(minikube); + hotbarStore.addToHotbar(aws); + + // test -> aws + hotbarStore.restackItems(0, 2); + + const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); + + expect(items.slice(0, 4)).toEqual(["minikube", "aws", "test", null]); + }); + + it("does nothing when item moved to same cell", () => { + const hotbarStore = HotbarStore.createInstance(); + const test = new CatalogEntityItem(testCluster); + + hotbarStore.load(); + hotbarStore.addToHotbar(test); + hotbarStore.restackItems(0, 0); + + expect(hotbarStore.getActive().items[0].entity.uid).toEqual("test"); + }); + + it("throws if invalid arguments provided", () => { + // Prevent writing to stderr during this render. + const err = console.error; + + console.error = jest.fn(); + + const hotbarStore = HotbarStore.createInstance(); + const test = new CatalogEntityItem(testCluster); + + hotbarStore.load(); + hotbarStore.addToHotbar(test); + + expect(() => hotbarStore.restackItems(-5, 0)).toThrow(); + expect(() => hotbarStore.restackItems(2, -1)).toThrow(); + expect(() => hotbarStore.restackItems(14, 1)).toThrow(); + expect(() => hotbarStore.restackItems(11, 112)).toThrow(); + + // Restore writing to stderr. + console.error = err; + }); + }); }); diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index fa0116937b..e4a871f093 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -4,7 +4,6 @@ import migrations from "../migrations/hotbar-store"; import * as uuid from "uuid"; import { CatalogEntityItem } from "../renderer/components/+catalog/catalog-entity.store"; import isNull from "lodash/isNull"; -import { CatalogEntity } from "./catalog/catalog-entity"; export interface HotbarItem { entity: { @@ -147,9 +146,9 @@ export class HotbarStore extends BaseStore { } } - removeFromHotbar(item: CatalogEntity) { + removeFromHotbar(uid: string) { const hotbar = this.getActive(); - const index = hotbar.items.findIndex((i) => i?.entity.uid === item.getId()); + const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); if (index == -1) { return; @@ -158,6 +157,40 @@ export class HotbarStore extends BaseStore { hotbar.items[index] = null; } + findClosestEmptyIndex(from: number, direction = 1) { + let index = from; + + while(this.getActive().items[index] != null) { + index += direction; + } + + return index; + } + + restackItems(from: number, to: number): void { + const { items } = this.getActive(); + const source = items[from]; + const moveDown = from < to; + + if (from < 0 || to < 0 || from >= items.length || to >= items.length || isNaN(from) || isNaN(to)) { + throw new Error("Invalid 'from' or 'to' arguments"); + } + + if (from == to) { + return; + } + + items.splice(from, 1, null); + + if (items[to] == null) { + items.splice(to, 1, source); + } else { + // Move cells up or down to closes empty cell + items.splice(this.findClosestEmptyIndex(to, moveDown ? -1 : 1), 1); + items.splice(to, 0, source); + } + } + switchToPrevious() { const hotbarStore = HotbarStore.getInstance(); let index = hotbarStore.activeHotbarIndex - 1; diff --git a/src/renderer/components/hotbar/hotbar-icon.scss b/src/renderer/components/hotbar/hotbar-icon.scss index 1da432d568..c6134f16e0 100644 --- a/src/renderer/components/hotbar/hotbar-icon.scss +++ b/src/renderer/components/hotbar/hotbar-icon.scss @@ -14,7 +14,7 @@ } &.active { - box-shadow: 0 0 0px 3px #ffffff; + box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px var(--textColorAccent); transition: all 0s 0.8s; } @@ -24,6 +24,14 @@ } } + &:hover { + box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px #ffffff30; + } + + &.isDragging { + box-shadow: none; + } + > .led { position: absolute; left: 3px; diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index 5bc6489c8c..82be0ee88c 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -94,7 +94,7 @@ export class HotbarIcon extends React.Component { remove(item: CatalogEntity) { const hotbar = HotbarStore.getInstance(); - hotbar.removeFromHotbar(item); + hotbar.removeFromHotbar(item.getId()); } onMenuItemClick(menuItem: CatalogEntityContextMenu) { diff --git a/src/renderer/components/hotbar/hotbar-menu.scss b/src/renderer/components/hotbar/hotbar-menu.scss index 6c8559fbd9..1805fdf4c5 100644 --- a/src/renderer/components/hotbar/hotbar-menu.scss +++ b/src/renderer/components/hotbar/hotbar-menu.scss @@ -44,15 +44,14 @@ background: var(--layoutBackground); border-radius: 6px; position: relative; - transform: translateZ(0); // Remove flickering artifacts - &:hover { - &:not(:empty) { - box-shadow: 0 0 0px 3px #ffffff1a; - } + &.isDraggingOver { + box-shadow: 0 0 0px 3px $clusterMenuBackground, 0 0 0px 6px #fff; } &.animating { + transform: translateZ(0); // Remove flickering artifacts + &:empty { animation: shake .6s cubic-bezier(.36,.07,.19,.97) both; transform: translate3d(0, 0, 0); @@ -61,48 +60,11 @@ } &:not(:empty) { - animation: outline 0.8s cubic-bezier(0.19, 1, 0.22, 1); + animation: outline 1s cubic-bezier(0.19, 1, 0.22, 1); } } } } - - .HotbarSelector { - height: 26px; - background-color: var(--layoutBackground); - position: relative; - - &:before { - content: " "; - position: absolute; - width: 100%; - height: 20px; - background: linear-gradient(0deg, var(--clusterMenuBackground), transparent); - top: -20px; - } - - .Badge { - cursor: pointer; - background: var(--secondaryBackground); - width: 100%; - color: var(--settingsColor); - padding-top: 3px; - } - - .Icon { - --size: 16px; - padding: 0 4px; - - &:hover { - box-shadow: none; - background-color: transparent; - } - - &.previous { - transform: rotateY(180deg); - } - } - } } @keyframes shake { @@ -130,6 +92,6 @@ } 100% { - box-shadow: 0 0 0px 0px $clusterMenuBackground, 0 0 0px 3px #ffffff; + box-shadow: 0 0 0px 3px $clusterMenuBackground, 0 0 0px 6px #ffffff; } } \ No newline at end of file diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index 168f630327..29008f073c 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -1,18 +1,15 @@ import "./hotbar-menu.scss"; import "./hotbar.commands"; -import React, { ReactNode, useState } from "react"; +import React, { HTMLAttributes, ReactNode, useState } from "react"; import { observer } from "mobx-react"; import { HotbarIcon } from "./hotbar-icon"; import { cssNames, IClassName } from "../../utils"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { HotbarItem, HotbarStore } from "../../../common/hotbar-store"; +import { defaultHotbarCells, HotbarItem, HotbarStore } from "../../../common/hotbar-store"; import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; -import { Icon } from "../icon"; -import { Badge } from "../badge"; -import { CommandOverlay } from "../command-palette"; -import { HotbarSwitchCommand } from "./hotbar-switch-command"; -import { Tooltip, TooltipPosition } from "../tooltip"; +import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; +import { HotbarSelector } from "./hotbar-selector"; interface Props { className?: IClassName; @@ -38,36 +35,67 @@ export class HotbarMenu extends React.Component { return item ? catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid) : null; } - previous() { - HotbarStore.getInstance().switchToPrevious(); - } + onDragEnd(result: DropResult) { + const { source, destination } = result; - next() { - HotbarStore.getInstance().switchToNext(); - } + if (!destination) { // Dropped outside of the list + return; + } - openSelector() { - CommandOverlay.open(); + const from = parseInt(source.droppableId); + const to = parseInt(destination.droppableId); + + HotbarStore.getInstance().restackItems(from, to); } renderGrid() { - if (!this.hotbar.items.length) return; - return this.hotbar.items.map((item, index) => { const entity = this.getEntity(item); return ( - - {entity && ( - + {(provided, snapshot) => ( + entity.onRun(catalogEntityRunContext)} - /> + key={entity ? entity.getId() : `cell${index}`} + innerRef={provided.innerRef} + className={cssNames({ isDraggingOver: snapshot.isDraggingOver })} + {...provided.droppableProps} + > + {entity && ( + + {(provided, snapshot) => { + const style = { + zIndex: defaultHotbarCells - index, + position: "absolute", + ...provided.draggableProps.style, + } as React.CSSProperties; + + return ( +
+ entity.onRun(catalogEntityRunContext)} + className={cssNames({ isDragging: snapshot.isDragging })} + /> +
+ ); + }} +
+ )} + {provided.placeholder} +
)} -
+ ); }); } @@ -76,48 +104,46 @@ export class HotbarMenu extends React.Component { const { className } = this.props; const hotbarStore = HotbarStore.getInstance(); const hotbar = hotbarStore.getActive(); - const activeIndexDisplay = hotbarStore.activeHotbarIndex + 1; return (
- {this.renderGrid()} -
-
- this.previous()} /> -
- this.openSelector()} /> - - {hotbar.name} - -
- this.next()} /> + + {this.renderGrid()} +
+
); } } -interface HotbarCellProps { +interface HotbarCellProps extends HTMLAttributes { children?: ReactNode; index: number; + innerRef?: React.LegacyRef; } -function HotbarCell(props: HotbarCellProps) { +function HotbarCell({ innerRef, children, className, ...rest }: HotbarCellProps) { const [animating, setAnimating] = useState(false); const onAnimationEnd = () => { setAnimating(false); }; - const onClick = () => { setAnimating(true); }; + const onClick = () => { + if (className.includes("isDraggingOver")) { + return; + } + + setAnimating(true); + }; return (
- {props.children} + {children}
); } diff --git a/src/renderer/components/hotbar/hotbar-selector.scss b/src/renderer/components/hotbar/hotbar-selector.scss new file mode 100644 index 0000000000..5467442eaa --- /dev/null +++ b/src/renderer/components/hotbar/hotbar-selector.scss @@ -0,0 +1,36 @@ +.HotbarSelector { + height: 26px; + background-color: var(--layoutBackground); + position: relative; + + &:before { + content: " "; + position: absolute; + width: 100%; + height: 20px; + background: linear-gradient(0deg, var(--clusterMenuBackground), transparent); + top: -20px; + } + + .Badge { + cursor: pointer; + background: var(--secondaryBackground); + width: 100%; + color: var(--settingsColor); + padding-top: 3px; + } + + .Icon { + --size: 16px; + padding: 0 4px; + + &:hover { + box-shadow: none; + background-color: transparent; + } + + &.previous { + transform: rotateY(180deg); + } + } +} \ No newline at end of file diff --git a/src/renderer/components/hotbar/hotbar-selector.tsx b/src/renderer/components/hotbar/hotbar-selector.tsx new file mode 100644 index 0000000000..d4919d7f0b --- /dev/null +++ b/src/renderer/components/hotbar/hotbar-selector.tsx @@ -0,0 +1,46 @@ +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"; + +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/components/layout/page-layout.scss b/src/renderer/components/layout/page-layout.scss index 2c6bcaf6aa..2a4400d76f 100644 --- a/src/renderer/components/layout/page-layout.scss +++ b/src/renderer/components/layout/page-layout.scss @@ -24,7 +24,7 @@ // covers whole app view area &.showOnTop { position: fixed !important; // allow to cover ClustersMenu - z-index: 3; + z-index: 13; left: 0; top: 0; right: 0;