From 5d746cdff5ff7c2c2d340ae4917b977664e7fdb5 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 28 Dec 2020 15:18:03 +0300 Subject: [PATCH] Dock tabs context menu (#1863) * Adding context menu to dock tabs Signed-off-by: Alex Andreev * Allowing to open menu by contextmenu event Signed-off-by: Alex Andreev * Adding DockTab tests with fine-tuning jest config Signed-off-by: Alex Andreev * Adding disable state to menu items Signed-off-by: Alex Andreev * Removing empty lines Signed-off-by: Alex Andreev * Moving jest-canvas-mock to dev dependencies Signed-off-by: Alex Andreev --- __mocks__/@linguiMacro.ts | 1 + __mocks__/imageMock.ts | 1 + package.json | 5 +- .../dock/__test__/dock-tabs.test.tsx | 158 ++++++++++++++++++ src/renderer/components/dock/dock-tab.tsx | 53 +++++- src/renderer/components/dock/dock-tabs.tsx | 51 ++++++ src/renderer/components/dock/dock.store.ts | 26 +++ src/renderer/components/dock/dock.tsx | 57 ++----- src/renderer/components/menu/menu.tsx | 11 +- src/renderer/components/tabs/tabs.tsx | 1 + yarn.lock | 22 ++- 11 files changed, 337 insertions(+), 49 deletions(-) create mode 100644 __mocks__/imageMock.ts create mode 100644 src/renderer/components/dock/__test__/dock-tabs.test.tsx create mode 100644 src/renderer/components/dock/dock-tabs.tsx diff --git a/__mocks__/@linguiMacro.ts b/__mocks__/@linguiMacro.ts index 5a0c157331..a1154b42dd 100644 --- a/__mocks__/@linguiMacro.ts +++ b/__mocks__/@linguiMacro.ts @@ -1,3 +1,4 @@ module.exports = { Trans: ({ children }: { children: React.ReactNode }) => children, + t: (message: string) => message }; diff --git a/__mocks__/imageMock.ts b/__mocks__/imageMock.ts new file mode 100644 index 0000000000..a099545376 --- /dev/null +++ b/__mocks__/imageMock.ts @@ -0,0 +1 @@ +module.exports = {}; \ No newline at end of file diff --git a/package.json b/package.json index 2a5b194ad6..98a1d4d2ac 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ }, "moduleNameMapper": { "\\.(css|scss)$": "/__mocks__/styleMock.ts", + "\\.(svg)$": "/__mocks__/imageMock.ts", "^@lingui/macro$": "/__mocks__/@linguiMacro.ts" }, "modulePathIgnorePatterns": [ @@ -83,7 +84,8 @@ "/src/extensions/npm" ], "setupFiles": [ - "/src/jest.setup.ts" + "/src/jest.setup.ts", + "jest-canvas-mock" ] }, "build": { @@ -342,6 +344,7 @@ "identity-obj-proxy": "^3.0.0", "include-media": "^1.4.9", "jest": "^26.0.1", + "jest-canvas-mock": "^2.3.0", "jest-fetch-mock": "^3.0.3", "jest-mock-extended": "^1.0.10", "make-plural": "^6.2.1", diff --git a/src/renderer/components/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx new file mode 100644 index 0000000000..bcf6b94a2b --- /dev/null +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; + +import { DockTabs } from "../dock-tabs"; +import { dockStore, IDockTab, TabKind } from "../dock.store"; +import { createResourceTab } from "../create-resource.store"; +import { createTerminalTab } from "../terminal.store"; +import { observable } from "mobx"; + +const onChangeTab = jest.fn(); + +const getComponent = () => ( + +); + +const renderTabs = () => render(getComponent()); + +const getTabKinds = () => dockStore.tabs.map(tab => tab.kind); + +describe("", () => { + beforeEach(() => { + createTerminalTab(); + createResourceTab(); + createTerminalTab(); + createResourceTab(); + createTerminalTab(); + }); + + afterEach(() => { + dockStore.reset(); + }); + + it("renders w/o errors", () => { + const { container } = renderTabs(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("has 6 tabs (1 tab is initial terminal)", () => { + const { container } = renderTabs(); + const tabs = container.querySelectorAll(".Tab"); + + expect(tabs.length).toBe(6); + }); + + it("opens a context menu", () => { + const { container, getByText } = renderTabs(); + const tab = container.querySelector(".Tab"); + + fireEvent.contextMenu(tab); + expect(getByText("Close all tabs")).toBeInTheDocument(); + }); + + it("closes selected tab", () => { + const { container, getByText, rerender } = renderTabs(); + const tab = container.querySelector(".Tab"); + + fireEvent.contextMenu(tab); + const command = getByText("Close"); + + fireEvent.click(command); + rerender(getComponent()); + const tabs = container.querySelectorAll(".Tab"); + + expect(tabs.length).toBe(5); + expect(getTabKinds()).toEqual([ + TabKind.TERMINAL, + TabKind.CREATE_RESOURCE, + TabKind.TERMINAL, + TabKind.CREATE_RESOURCE, + TabKind.TERMINAL + ]); + }); + + it("closes other tabs", () => { + const { container, getByText, rerender } = renderTabs(); + const tab = container.querySelectorAll(".Tab")[3]; + + fireEvent.contextMenu(tab); + const command = getByText("Close other tabs"); + + fireEvent.click(command); + rerender(getComponent()); + const tabs = container.querySelectorAll(".Tab"); + + expect(tabs.length).toBe(1); + expect(getTabKinds()).toEqual([TabKind.TERMINAL]); + }); + + it("closes all tabs", () => { + const { container, getByText, rerender } = renderTabs(); + const tab = container.querySelector(".Tab"); + + fireEvent.contextMenu(tab); + const command = getByText("Close all tabs"); + + fireEvent.click(command); + rerender(getComponent()); + const tabs = container.querySelectorAll(".Tab"); + + expect(tabs.length).toBe(0); + }); + + it("closes tabs to the right", () => { + const { container, getByText, rerender } = renderTabs(); + const tab = container.querySelectorAll(".Tab")[3]; + + fireEvent.contextMenu(tab); + const command = getByText("Close tabs to the right"); + + fireEvent.click(command); + rerender(getComponent()); + const tabs = container.querySelectorAll(".Tab"); + + expect(tabs.length).toBe(4); + expect(getTabKinds()).toEqual([ + TabKind.TERMINAL, + TabKind.TERMINAL, + TabKind.CREATE_RESOURCE, + TabKind.TERMINAL + ]); + }); + + it("disables 'Close All' & 'Close Other' items if only 1 tab available", () => { + dockStore.tabs = observable.array([{ + id: "terminal", kind: TabKind.TERMINAL, title: "Terminal" + }]); + const { container, getByText } = renderTabs(); + const tab = container.querySelector(".Tab"); + + fireEvent.contextMenu(tab); + const closeAll = getByText("Close all tabs"); + const closeOthers = getByText("Close other tabs"); + + expect(closeAll).toHaveClass("disabled"); + expect(closeOthers).toHaveClass("disabled"); + }); + + it("disables 'Close To The Right' item if last tab clicked", () => { + dockStore.tabs = observable.array([ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal" }, + { id: "logs", kind: TabKind.POD_LOGS, title: "Pod Logs" }, + ]); + const { container, getByText } = renderTabs(); + const tab = container.querySelectorAll(".Tab")[1]; + + fireEvent.contextMenu(tab); + const command = getByText("Close tabs to the right"); + + expect(command).toHaveClass("disabled"); + }); +}); diff --git a/src/renderer/components/dock/dock-tab.tsx b/src/renderer/components/dock/dock-tab.tsx index ceaee18303..1c6db6c7c2 100644 --- a/src/renderer/components/dock/dock-tab.tsx +++ b/src/renderer/components/dock/dock-tab.tsx @@ -2,11 +2,13 @@ import "./dock-tab.scss"; import React from "react"; import { observer } from "mobx-react"; -import { t } from "@lingui/macro"; +import { t, Trans } from "@lingui/macro"; import { autobind, cssNames, prevDefault } from "../../utils"; import { dockStore, IDockTab } from "./dock.store"; import { Tab, TabProps } from "../tabs"; import { Icon } from "../icon"; +import { Menu, MenuItem } from "../menu"; +import { observable } from "mobx"; import { _i18n } from "../../i18n"; export interface DockTabProps extends TabProps { @@ -15,6 +17,8 @@ export interface DockTabProps extends TabProps { @observer export class DockTab extends React.Component { + @observable menuVisible = false; + get tabId() { return this.props.value.id; } @@ -24,6 +28,38 @@ export class DockTab extends React.Component { dockStore.closeTab(this.tabId); } + renderMenu() { + const { closeTab, closeAllTabs, closeOtherTabs, closeTabsToTheRight, tabs, getTabIndex } = dockStore; + const closeAllDisabled = tabs.length === 1; + const closeOtherDisabled = tabs.length === 1; + const closeRightDisabled = getTabIndex(this.tabId) === tabs.length - 1; + + return ( + this.menuVisible = true} + close={() => this.menuVisible = false} + toggleEvent="contextmenu" + > + closeTab(this.tabId)}> + Close + + closeAllTabs()} disabled={closeAllDisabled}> + Close all tabs + + closeOtherTabs(this.tabId)} disabled={closeOtherDisabled}> + Close other tabs + + closeTabsToTheRight(this.tabId)} disabled={closeRightDisabled}> + Close tabs to the right + + + ); + } + render() { const { className, moreActions, ...tabProps } = this.props; const { title, pinned } = tabProps.value; @@ -42,11 +78,16 @@ export class DockTab extends React.Component { ); return ( - + <> + this.menuVisible = true} + label={label} + /> + {this.renderMenu()} + ); } } \ No newline at end of file diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx new file mode 100644 index 0000000000..54451ddd89 --- /dev/null +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -0,0 +1,51 @@ +import React, { Fragment } from "react"; + +import { Icon } from "../icon"; +import { Tabs } from "../tabs/tabs"; +import { isCreateResourceTab } from "./create-resource.store"; +import { DockTab } from "./dock-tab"; +import { IDockTab } from "./dock.store"; +import { isEditResourceTab } from "./edit-resource.store"; +import { isInstallChartTab } from "./install-chart.store"; +import { isPodLogsTab } from "./pod-logs.store"; +import { TerminalTab } from "./terminal-tab"; +import { isTerminalTab } from "./terminal.store"; +import { isUpgradeChartTab } from "./upgrade-chart.store"; + +interface Props { + tabs: IDockTab[] + autoFocus: boolean + selectedTab: IDockTab + onChangeTab: (tab: IDockTab) => void +} + +export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: Props) => { + const renderTab = (tab: IDockTab) => { + if (isTerminalTab(tab)) { + return ; + } + + if (isCreateResourceTab(tab) || isEditResourceTab(tab)) { + return ; + } + + if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) { + return } />; + } + + if (isPodLogsTab(tab)) { + return ; + } + }; + + return ( + + {tabs.map(tab => {renderTab(tab)})} + + ); +}; \ No newline at end of file diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock.store.ts index 60c6085bcc..91d72d98d9 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock.store.ts @@ -123,6 +123,10 @@ export class DockStore { return this.tabs.find(tab => tab.id === tabId); } + getTabIndex(tabId: TabId) { + return this.tabs.findIndex(tab => tab.id === tabId); + } + protected getNewTabNumber(kind: TabKind) { const tabNumbers = this.tabs .filter(tab => tab.kind === kind) @@ -182,6 +186,28 @@ export class DockStore { } } + closeTabs(tabs: IDockTab[]) { + tabs.forEach(tab => this.closeTab(tab.id)); + } + + closeAllTabs() { + this.closeTabs([...this.tabs]); + } + + closeOtherTabs(tabId: TabId) { + const index = this.getTabIndex(tabId); + const tabs = [...this.tabs.slice(0, index), ...this.tabs.slice(index + 1)]; + + this.closeTabs(tabs); + } + + closeTabsToTheRight(tabId: TabId) { + const index = this.getTabIndex(tabId); + const tabs = this.tabs.slice(index + 1); + + this.closeTabs(tabs); + } + @action selectTab(tabId: TabId) { this.selectedTabId = this.getTabById(tabId)?.id ?? null; diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index 6f513e7586..e03765a916 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -1,29 +1,28 @@ import "./dock.scss"; -import React, { Fragment } from "react"; -import { observer } from "mobx-react"; +import React from "react"; import { Trans } from "@lingui/macro"; -import { autobind, cssNames, prevDefault } from "../../utils"; -import { ResizingAnchor, ResizeDirection } from "../resizing-anchor"; +import { observer } from "mobx-react"; + +import { cssNames, prevDefault } from "../../utils"; import { Icon } from "../icon"; -import { Tabs } from "../tabs/tabs"; import { MenuItem } from "../menu"; import { MenuActions } from "../menu/menu-actions"; -import { dockStore, IDockTab } from "./dock.store"; -import { DockTab } from "./dock-tab"; -import { TerminalTab } from "./terminal-tab"; -import { TerminalWindow } from "./terminal-window"; +import { ResizeDirection, ResizingAnchor } from "../resizing-anchor"; import { CreateResource } from "./create-resource"; -import { InstallChart } from "./install-chart"; -import { EditResource } from "./edit-resource"; -import { UpgradeChart } from "./upgrade-chart"; -import { createTerminalTab, isTerminalTab } from "./terminal.store"; import { createResourceTab, isCreateResourceTab } from "./create-resource.store"; +import { DockTabs } from "./dock-tabs"; +import { dockStore, IDockTab } from "./dock.store"; +import { EditResource } from "./edit-resource"; import { isEditResourceTab } from "./edit-resource.store"; +import { InstallChart } from "./install-chart"; import { isInstallChartTab } from "./install-chart.store"; -import { isUpgradeChartTab } from "./upgrade-chart.store"; import { PodLogs } from "./pod-logs"; import { isPodLogsTab } from "./pod-logs.store"; +import { TerminalWindow } from "./terminal-window"; +import { createTerminalTab, isTerminalTab } from "./terminal.store"; +import { UpgradeChart } from "./upgrade-chart"; +import { isUpgradeChartTab } from "./upgrade-chart.store"; interface Props { className?: string; @@ -54,25 +53,6 @@ export class Dock extends React.Component { selectTab(tab.id); }; - @autobind() - renderTab(tab: IDockTab) { - if (isTerminalTab(tab)) { - return ; - } - - if (isCreateResourceTab(tab) || isEditResourceTab(tab)) { - return ; - } - - if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) { - return } />; - } - - if (isPodLogsTab(tab)) { - return ; - } - } - renderTabContent() { const { isOpen, height, selectedTab: tab } = dockStore; @@ -112,13 +92,12 @@ export class Dock extends React.Component { onDrag={dockStore.setHeight} />
- - {tabs.map(tab => {this.renderTab(tab)})} - + onChangeTab={this.onChangeTab} + />
New tab }} closeOnScroll={false}> diff --git a/src/renderer/components/menu/menu.tsx b/src/renderer/components/menu/menu.tsx index db2b4ed195..bcc1eb9196 100644 --- a/src/renderer/components/menu/menu.tsx +++ b/src/renderer/components/menu/menu.tsx @@ -31,6 +31,7 @@ export interface MenuProps { closeOnScroll?: boolean; // applicable when usePortal={true} position?: MenuPosition; // applicable when usePortal={false} children?: ReactNode; + toggleEvent?: "click" | "contextmenu"; } interface State { @@ -44,6 +45,7 @@ const defaultPropsMenu: Partial = { closeOnClickItem: true, closeOnClickOutside: true, closeOnScroll: false, + toggleEvent: "click" }; @autobind() @@ -72,18 +74,19 @@ export class Menu extends React.Component { this.opener = document.getElementById(this.props.htmlFor); // might not exist in sub-menus if (this.opener) { - this.opener.addEventListener("click", this.toggle); + this.opener.addEventListener(this.props.toggleEvent, this.toggle); this.opener.addEventListener("keydown", this.onKeyDown); } this.elem.addEventListener("keydown", this.onKeyDown); window.addEventListener("resize", this.onWindowResize); window.addEventListener("click", this.onClickOutside, true); window.addEventListener("scroll", this.onScrollOutside, true); + window.addEventListener("contextmenu", this.onContextMenu, true); } componentWillUnmount() { if (this.opener) { - this.opener.removeEventListener("click", this.toggle); + this.opener.removeEventListener(this.props.toggleEvent, this.toggle); this.opener.removeEventListener("keydown", this.onKeyDown); } this.elem.removeEventListener("keydown", this.onKeyDown); @@ -198,6 +201,10 @@ export class Menu extends React.Component { } } + onContextMenu() { + this.close(); + } + onWindowResize() { if (!this.isOpen) return; this.refreshPosition(); diff --git a/src/renderer/components/tabs/tabs.tsx b/src/renderer/components/tabs/tabs.tsx index ad01f50920..721d7ad6f1 100644 --- a/src/renderer/components/tabs/tabs.tsx +++ b/src/renderer/components/tabs/tabs.tsx @@ -51,6 +51,7 @@ export class Tabs extends React.PureComponent { } export interface TabProps extends DOMAttributes { + id?: string; className?: string; active?: boolean; disabled?: boolean; diff --git a/yarn.lock b/yarn.lock index 88f6fd3d0e..c2b0efc19c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4482,7 +4482,7 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= -color-name@^1.0.0, color-name@~1.1.4: +color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -5016,6 +5016,11 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssfontparser@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3" + integrity sha1-9AIvyPlwDGgCnVQghK+69CWj8+M= + cssom@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" @@ -8423,6 +8428,14 @@ jake@^10.6.1: filelist "^1.0.1" minimatch "^3.0.4" +jest-canvas-mock@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.3.0.tgz#50f4cc178ae52c4c0e2ce4fd3a3ad2a41ad4eb36" + integrity sha512-3TMyR66VG2MzAW8Negzec03bbcIjVJMfGNvKzrEnbws1CYKqMNkvIJ8LbkoGYfp42tKqDmhIpQq3v+MNLW2A2w== + dependencies: + cssfontparser "^1.2.1" + moo-color "^1.0.2" + jest-changed-files@^26.0.1: version "26.0.1" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.0.1.tgz#1334630c6a1ad75784120f39c3aa9278e59f349f" @@ -10168,6 +10181,13 @@ moment@^2.10.2, moment@^2.26.0: resolved "https://registry.yarnpkg.com/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a" integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw== +moo-color@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.2.tgz#837c40758d2d58763825d1359a84e330531eca64" + integrity sha512-5iXz5n9LWQzx/C2WesGFfpE6RLamzdHwsn3KpfzShwbfIqs7stnoEpaNErf/7+3mbxwZ4s8Foq7I0tPxw7BWHg== + dependencies: + color-name "^1.1.4" + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"