diff --git a/src/features/add-cluster/navigation-using-application-menu.test.tsx b/src/features/add-cluster/navigation-using-application-menu.test.tsx index 41d11af30b..a114115300 100644 --- a/src/features/add-cluster/navigation-using-application-menu.test.tsx +++ b/src/features/add-cluster/navigation-using-application-menu.test.tsx @@ -29,7 +29,7 @@ describe("add-cluster - navigation using application menu", () => { describe("when navigating to add cluster using application menu", () => { beforeEach(async () => { - await applicationBuilder.applicationMenu.click("file.add-cluster"); + await applicationBuilder.applicationMenu.click("root.file.add-cluster"); }); it("renders", () => { diff --git a/src/features/application-menu/application-menu-in-legacy-extension-api.test.ts b/src/features/application-menu/application-menu-in-legacy-extension-api.test.ts new file mode 100644 index 0000000000..277dd41641 --- /dev/null +++ b/src/features/application-menu/application-menu-in-legacy-extension-api.test.ts @@ -0,0 +1,138 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { action } from "mobx"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { FakeExtensionOptions } from "../../renderer/components/test-utils/get-extension-fake"; +import applicationMenuItemInjectionToken from "./main/menu-items/application-menu-item-injection-token"; + +describe("application-menu-in-legacy-extension-api", () => { + let builder: ApplicationBuilder; + + beforeEach(async () => { + builder = getApplicationBuilder(); + + builder.beforeApplicationStart( + action((mainDi) => { + mainDi.register( + someTopMenuItemInjectable, + someNonExtensionBasedMenuItemInjectable, + ); + }), + ); + + await builder.startHidden(); + }); + + describe("when extension with application menu items is enabled", () => { + let onClickMock: jest.Mock; + let testExtensionOptions: FakeExtensionOptions; + + beforeEach(() => { + onClickMock = jest.fn(); + + testExtensionOptions = { + id: "some-test-extension", + name: "some-extension-name", + + mainOptions: { + appMenus: [ + { + id: "some-clickable-item", + parentId: "some-top-menu-item", + click: onClickMock, + }, + + { + parentId: "some-top-menu-item", + type: "separator", + }, + ], + }, + }; + + builder.extensions.enable(testExtensionOptions); + }); + + it("menu related items exist", () => { + const menuItemPathsForExtension = builder.applicationMenu.items.filter( + (x) => + x.startsWith("root.some-top-menu-item.some-extension-name"), + ); + + expect(menuItemPathsForExtension).toEqual([ + "root.some-top-menu-item.some-extension-name/application-menu-item/clickable-menu-item(some-clickable-item)", + "root.some-top-menu-item.some-extension-name/application-menu-item/separator(1)", + ]); + }); + + it("when the extension-based clickable menu item is clicked, does so", () => { + builder.applicationMenu.click( + "root.some-top-menu-item.some-extension-name/application-menu-item/clickable-menu-item(some-clickable-item)", + ); + + expect(onClickMock).toHaveBeenCalled(); + }); + + describe("when the extension is disabled", () => { + beforeEach(() => { + builder.extensions.disable(testExtensionOptions); + }); + + it("when related menu items no longer exist", () => { + const menuItemPathsForExtension = builder.applicationMenu.items.filter( + (x) => + x.startsWith("root.some-top-menu-item.some-extension-name"), + ); + + expect(menuItemPathsForExtension).toEqual([]); + }); + + it("when the extension is enabled again, also related menu items exist again", () => { + builder.extensions.enable(testExtensionOptions); + + const menuItemPathsForExtension = builder.applicationMenu.items.filter( + (x) => + x.startsWith("root.some-top-menu-item.some-extension-name"), + ); + + expect(menuItemPathsForExtension).toEqual([ + "root.some-top-menu-item.some-extension-name/application-menu-item/clickable-menu-item(some-clickable-item)", + "root.some-top-menu-item.some-extension-name/application-menu-item/separator(1)", + ]); + }); + }); + }); +}); + +const someTopMenuItemInjectable = getInjectable({ + id: "some-top-menu-item", + + instantiate: () => ({ + id: "some-top-menu-item", + parentId: "root" as const, + kind: "top-level-menu" as const, + label: "Some existing root menu item", + orderNumber: 42, + }), + + injectionToken: applicationMenuItemInjectionToken, +}); + +const someNonExtensionBasedMenuItemInjectable = getInjectable({ + id: "some-non-extension-based-menu-item", + + instantiate: () => ({ + id: "some-non-extension-based-menu-item", + parentId: "some-top-menu-item", + kind: "clickable-menu-item" as const, + label: "Some menu item", + onClick: () => {}, + orderNumber: 42, + }), + + injectionToken: applicationMenuItemInjectionToken, +}); diff --git a/src/features/application-menu/application-menu.test.ts b/src/features/application-menu/application-menu.test.ts index a4881d49cc..7ce6149540 100644 --- a/src/features/application-menu/application-menu.test.ts +++ b/src/features/application-menu/application-menu.test.ts @@ -6,6 +6,7 @@ import type { ApplicationBuilder } from "../../renderer/components/test-utils/ge import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import populateApplicationMenuInjectable from "./main/populate-application-menu.injectable"; import { advanceFakeTime, useFakeTime } from "../../common/test-utils/use-fake-time"; +import { getCompositePaths } from "./main/menu-items/get-composite/get-composite-paths/get-composite-paths"; describe("application-menu", () => { let builder: ApplicationBuilder; @@ -35,18 +36,21 @@ describe("application-menu", () => { }); describe("given enough time passes", () => { + let applicationMenuPaths: string[]; + beforeEach(() => { advanceFakeTime(100); - }); - - it("populates application menu", () => { - expect(populateApplicationMenuMock).toHaveBeenCalledWith( - expect.any(Array), + applicationMenuPaths = getCompositePaths( + populateApplicationMenuMock.mock.calls[0][0], ); }); - it("populates application menu lol", () => { - expect(populateApplicationMenuMock.mock.calls).toMatchSnapshot(); + it("populates application menu with at least something", () => { + expect(applicationMenuPaths.length).toBeGreaterThan(0); + }); + + it("populates application menu", () => { + expect(applicationMenuPaths).toMatchSnapshot(); }); }); }); diff --git a/src/features/application-menu/main/application-menu-item-composite.injectable.ts b/src/features/application-menu/main/application-menu-item-composite.injectable.ts new file mode 100644 index 0000000000..b7954cf820 --- /dev/null +++ b/src/features/application-menu/main/application-menu-item-composite.injectable.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import applicationMenuItemsInjectable from "./application-menu-items.injectable"; +import type { Composite } from "./menu-items/get-composite/get-composite"; +import getComposite from "./menu-items/get-composite/get-composite"; +import { computed } from "mobx"; +import { pipeline } from "@ogre-tools/fp"; +import { get } from "lodash/fp"; +import type { ApplicationMenuItemTypes } from "./menu-items/application-menu-item-injection-token"; + +export interface MenuItemRoot { id: "root"; kind: "root"; orderNumber: 0 } + +const applicationMenuItemCompositeInjectable = getInjectable({ + id: "application-menu-item-composite", + + instantiate: (di) => { + const menuItems = di.inject(applicationMenuItemsInjectable); + + return computed((): Composite => { + const items = menuItems.get(); + + return pipeline( + [{ id: "root" as const, kind: "root" as const, orderNumber: 0 as const }, ...items], + + x => getComposite({ + source: x, + rootId: "root", + getId: get("id"), + getParentId: get("parentId"), + }), + ); + }); + }, +}); + +export default applicationMenuItemCompositeInjectable; diff --git a/src/features/application-menu/main/application-menu-item-registrator.injectable.ts b/src/features/application-menu/main/application-menu-item-registrator.injectable.ts new file mode 100644 index 0000000000..e4743338fb --- /dev/null +++ b/src/features/application-menu/main/application-menu-item-registrator.injectable.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token"; +import type { LensExtension } from "../../../extensions/lens-extension"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; +import type { ClickableMenuItem, Separator } from "./menu-items/application-menu-item-injection-token"; +import applicationMenuItemInjectionToken from "./menu-items/application-menu-item-injection-token"; + +const applicationMenuItemRegistratorInjectable = getInjectable({ + id: "application-menu-item-registrator", + + instantiate: () => (ext: LensExtension) => { + const extension = ext as LensMainExtension; + + return extension.appMenus.map((registration, index) => { + const registrationId = registration.id || index; + const applicationMenuId = `${extension.sanitizedExtensionId}/application-menu-item`; + + return getInjectable({ + id: `${applicationMenuId}/${registrationId}`, + + instantiate: () => { + const orderNumber = 1000 + index * 10; + + if (registration.type === "separator") { + return { + kind: "separator" as const, + id: `${applicationMenuId}/separator(${registrationId})`, + parentId: registration.parentId, + orderNumber, + } as Separator; + } + + return { + kind: "clickable-menu-item" as const, + id: `${applicationMenuId}/clickable-menu-item(${registrationId})`, + parentId: registration.parentId, + // Todo: hide electron evens from this abstraction. + onClick: registration.click, + label: registration.label, + isShown: registration.visible ?? true, + orderNumber, + ...(registration.accelerator ? { keyboardShortcut: registration.accelerator as string } : {}), + } as ClickableMenuItem; + }, + + injectionToken: applicationMenuItemInjectionToken, + }); + }); + }, + + injectionToken: extensionRegistratorInjectionToken, +}); + +export default applicationMenuItemRegistratorInjectable; diff --git a/src/features/application-menu/main/application-menu-items.injectable.ts b/src/features/application-menu/main/application-menu-items.injectable.ts index b6771d8faf..8c3ccd2c0f 100644 --- a/src/features/application-menu/main/application-menu-items.injectable.ts +++ b/src/features/application-menu/main/application-menu-items.injectable.ts @@ -5,12 +5,8 @@ import { getInjectable } from "@ogre-tools/injectable"; import type { MenuItemConstructorOptions } from "electron"; import { computed } from "mobx"; -import type { ApplicationMenuItemTypes } from "./menu-items/application-menu-item-injection-token"; -import applicationMenuItemInjectionToken from "./menu-items/application-menu-item-injection-token"; -import { filter, map, sortBy } from "lodash/fp"; -import { pipeline } from "@ogre-tools/fp"; -import type { Composite } from "./menu-items/get-composite/get-composite"; -import getComposite from "./menu-items/get-composite/get-composite"; +import applicationMenuItemInjectionToken, { isShown } from "./menu-items/application-menu-item-injection-token"; +import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx"; export interface MenuItemOpts extends MenuItemConstructorOptions { submenu?: MenuItemConstructorOptions[]; @@ -20,76 +16,33 @@ const applicationMenuItemsInjectable = getInjectable({ id: "application-menu-items", instantiate: (di) => { - const allShown = pipeline( - di.injectMany(applicationMenuItemInjectionToken), - filter((x) => x.isShown !== false), + const computedInjectMany = di.inject(computedInjectManyInjectable); + + return computed(() => + computedInjectMany(applicationMenuItemInjectionToken) + .get() + .filter(isShown), ); - const roots = allShown.filter((x) => x.parentId === null); + // Prepare menu items order - const toMenuItemOpt = ( - x: Composite, - ): MenuItemOpts => ({ - // @ts-ignore - label: x.value.label, - id: x.id, - - submenu: pipeline( - x.children, - sortBy(x => x.value.orderNumber), - map(toMenuItemOpt), - ), - - // @ts-ignore - type: x.value.type, - // @ts-ignore - role: x.value.role, - // @ts-ignore - click: x.value.click, - // @ts-ignore - accelerator: x.value.accelerator, - }); - - const menuItems = pipeline( - roots, - - map((root) => - getComposite({ - source: allShown, - // @ts-ignore - rootId: root.id, - // @ts-ignore - getId: (x) => x.id, - // @ts-ignore - getParentId: (x) => x.parentId, - }), - ), - - map(toMenuItemOpt), - ); - - return computed((): MenuItemOpts[] => { - // Prepare menu items order - - // // Modify menu from extensions-api - // for (const menuItem of electronMenuItems.get()) { - // const parentMenu = appMenu.get(menuItem.parentId); - // - // if (!parentMenu) { - // logger.error( - // `[MENU]: cannot register menu item for parentId=${menuItem.parentId}, parent item doesn't exist`, - // { menuItem }, - // ); - // - // continue; - // } - // - // // (parentMenu.submenu ??= []).push(menuItem); - // } - - return menuItems; - }); + // // Modify menu from extensions-api + // for (const menuItem of electronMenuItems.get()) { + // const parentMenu = appMenu.get(menuItem.parentId); + // + // if (!parentMenu) { + // logger.error( + // `[MENU]: cannot register menu item for parentId=${menuItem.parentId}, parent item doesn't exist`, + // { menuItem }, + // ); + // + // continue; + // } + // + // // (parentMenu.submenu ??= []).push(menuItem); + // } }, }); + export default applicationMenuItemsInjectable; diff --git a/src/features/application-menu/main/application-menu-reactivity.injectable.ts b/src/features/application-menu/main/application-menu-reactivity.injectable.ts index f1f52d89e6..67631322aa 100644 --- a/src/features/application-menu/main/application-menu-reactivity.injectable.ts +++ b/src/features/application-menu/main/application-menu-reactivity.injectable.ts @@ -4,19 +4,19 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { autorun } from "mobx"; -import applicationMenuItemsInjectable from "./application-menu-items.injectable"; import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; import populateApplicationMenuInjectable from "./populate-application-menu.injectable"; +import applicationMenuItemCompositeInjectable from "./application-menu-item-composite.injectable"; const applicationMenuReactivityInjectable = getInjectable({ id: "application-menu-reactivity", instantiate: (di) => { - const applicationMenuItems = di.inject(applicationMenuItemsInjectable); + const applicationMenuItemComposite = di.inject(applicationMenuItemCompositeInjectable); const populateApplicationMenu = di.inject(populateApplicationMenuInjectable); return getStartableStoppable("application-menu-reactivity", () => - autorun(() => populateApplicationMenu(applicationMenuItems.get()), { + autorun(() => populateApplicationMenu(applicationMenuItemComposite.get()), { delay: 100, }), ); diff --git a/src/features/application-menu/main/menu-items/application-menu-item-injection-token.ts b/src/features/application-menu/main/menu-items/application-menu-item-injection-token.ts index 5063c14b86..ec64d63e7e 100644 --- a/src/features/application-menu/main/menu-items/application-menu-item-injection-token.ts +++ b/src/features/application-menu/main/menu-items/application-menu-item-injection-token.ts @@ -3,63 +3,104 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectionToken } from "@ogre-tools/injectable"; -import type { BrowserWindow, MenuItem, KeyboardEvent } from "electron"; +import type { BrowserWindow, KeyboardEvent, MenuItemConstructorOptions, MenuItem as ElectronMenuItem } from "electron"; +import type { SetOptional } from "type-fest"; -interface Shared { - parentId: string | null; - orderNumber: number; +export interface MayHaveKeyboardShortcut { + keyboardShortcut?: string; +} + +export interface Showable { isShown?: boolean; } +export const isShown = (showable: Showable) => showable.isShown !== false; -export interface ApplicationMenuItem extends Shared { - label: string; - accelerator?: string; - id: string; - +export interface Clickable { // TODO: This leaky abstraction is exposed in Extension API, therefore cannot be updated - click?: ( - menuItem: MenuItem, - browserWindow: BrowserWindow | undefined, - event: KeyboardEvent - ) => void; + onClick: (menuItem: ElectronMenuItem, browserWindow: (BrowserWindow) | (undefined), event: KeyboardEvent) => void; } -export interface Separator extends Shared { - type: "separator"; +export interface Labeled { + label: string; } -export interface OperationSystemAction extends Shared { - label?: string; - accelerator?: string; +export interface MaybeLabeled extends SetOptional {} - role: - | "services" - | "hide" - | "hideOthers" - | "unhide" - | "close" - | "undo" - | "redo" - | "cut" - | "copy" - | "paste" - | "delete" - | "selectAll" - | "toggleDevTools" - | "resetZoom" - | "zoomIn" - | "zoomOut" - | "togglefullscreen"; +export interface CanBeChildOfParent { + parentId: string; } +export interface Orderable { + orderNumber: number; +} + +export interface Identifiable { + id: string; +} + +type ApplicationMenuItemType = + // Note: "kind" is being used for Discriminated unions of TypeScript to achieve type narrowing. + // See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions + & Kind + & Identifiable + & CanBeChildOfParent + & Showable + & Orderable; + +interface Kind { kind: T } + +export type TopLevelMenu = + & ApplicationMenuItemType<"top-level-menu"> + & { parentId: "root" } + & Labeled + & MayHaveElectronRole; + +interface MayHaveElectronRole { + role?: ElectronRoles; +} + +type ElectronRoles = Exclude; + +export type SubMenu = + & ApplicationMenuItemType<"sub-menu"> + & Labeled + & CanBeChildOfParent; + +export type ClickableMenuItem = + & ApplicationMenuItemType<"clickable-menu-item"> + & MenuItem + & Labeled + & Clickable; + +export type OsActionMenuItem = + & ApplicationMenuItemType<"os-action-menu-item"> + & MenuItem + & MaybeLabeled + & TriggersElectronAction; + +type MenuItem = + & CanBeChildOfParent + & MayHaveKeyboardShortcut; + +interface TriggersElectronAction { + actionName: ElectronRoles; +} + +// Todo: SeparatorMenuItem +export type Separator = + & ApplicationMenuItemType<"separator"> + & CanBeChildOfParent; + export type ApplicationMenuItemTypes = - | ApplicationMenuItem + | TopLevelMenu + | SubMenu + | OsActionMenuItem + | ClickableMenuItem | Separator - | OperationSystemAction; +; -const applicationMenuItemInjectionToken = - getInjectionToken({ - id: "application-menu-item-injection-token", - }); +const applicationMenuItemInjectionToken = getInjectionToken({ + id: "application-menu-item-injection-token", +}); export default applicationMenuItemInjectionToken; diff --git a/src/features/application-menu/main/menu-items/edit/edit-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/edit/edit-menu-item.injectable.ts index 278faf7604..f62a4221ac 100644 --- a/src/features/application-menu/main/menu-items/edit/edit-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/edit/edit-menu-item.injectable.ts @@ -9,8 +9,9 @@ const editMenuItemInjectable = getInjectable({ id: "edit-application-menu-item", instantiate: () => ({ - parentId: null, + kind: "top-level-menu" as const, id: "edit", + parentId: "root" as const, orderNumber: 30, label: "Edit", }), diff --git a/src/features/application-menu/main/menu-items/edit/operation-system-actions/operation-system-actions.injectable.ts b/src/features/application-menu/main/menu-items/edit/operation-system-actions/operation-system-actions.injectable.ts index 64de60401f..aea229cc68 100644 --- a/src/features/application-menu/main/menu-items/edit/operation-system-actions/operation-system-actions.injectable.ts +++ b/src/features/application-menu/main/menu-items/edit/operation-system-actions/operation-system-actions.injectable.ts @@ -3,25 +3,21 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { - getApplicationMenuOperationSystemActionInjectable, -} from "../../get-application-menu-operation-system-action-injectable"; -import { - getApplicationMenuSeparatorInjectable, -} from "../../get-application-menu-separator-injectable"; +import { getApplicationMenuOperationSystemActionInjectable } from "../../get-application-menu-operation-system-action-injectable"; +import { getApplicationMenuSeparatorInjectable } from "../../get-application-menu-separator-injectable"; export const actionForUndo = getApplicationMenuOperationSystemActionInjectable({ id: "undo", parentId: "edit", orderNumber: 10, - role: "undo", + actionName: "undo", }); export const actionForRedo = getApplicationMenuOperationSystemActionInjectable({ id: "redo", parentId: "edit", orderNumber: 20, - role: "redo", + actionName: "redo", }); export const separator1 = getApplicationMenuSeparatorInjectable({ @@ -34,28 +30,28 @@ export const actionForCut = getApplicationMenuOperationSystemActionInjectable({ id: "cut", parentId: "edit", orderNumber: 40, - role: "cut", + actionName: "cut", }); export const actionForCopy = getApplicationMenuOperationSystemActionInjectable({ id: "copy", parentId: "edit", orderNumber: 50, - role: "copy", + actionName: "copy", }); export const actionForPaste = getApplicationMenuOperationSystemActionInjectable({ id: "paste", parentId: "edit", orderNumber: 60, - role: "paste", + actionName: "paste", }); export const actionForDelete = getApplicationMenuOperationSystemActionInjectable({ id: "delete", parentId: "edit", orderNumber: 70, - role: "delete", + actionName: "delete", }); export const separator2 = getApplicationMenuSeparatorInjectable({ @@ -68,6 +64,6 @@ export const actionForSelectAll = getApplicationMenuOperationSystemActionInjecta id: "selectAll", parentId: "edit", orderNumber: 90, - role: "selectAll", + actionName: "selectAll", }); diff --git a/src/features/application-menu/main/menu-items/file/add-cluster/add-cluster-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/file/add-cluster/add-cluster-menu-item.injectable.ts index bc3b3c8ffd..78c7201ca6 100644 --- a/src/features/application-menu/main/menu-items/file/add-cluster/add-cluster-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/file/add-cluster/add-cluster-menu-item.injectable.ts @@ -13,13 +13,14 @@ const addClusterMenuItemInjectable = getInjectable({ const navigateToAddCluster = di.inject(navigateToAddClusterInjectable); return { + kind: "clickable-menu-item" as const, parentId: "file", id: "add-cluster", orderNumber: 10, label: "Add Cluster", - accelerator: "CmdOrCtrl+Shift+A", + keyboardShortcut: "CmdOrCtrl+Shift+A", - click: () => { + onClick: () => { navigateToAddCluster(); }, }; diff --git a/src/features/application-menu/main/menu-items/file/close-window/close-window-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/file/close-window/close-window-menu-item.injectable.ts index 2173b2c233..745adb55df 100644 --- a/src/features/application-menu/main/menu-items/file/close-window/close-window-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/file/close-window/close-window-menu-item.injectable.ts @@ -13,11 +13,13 @@ const closeWindowMenuItemInjectable = getInjectable({ const isMac = di.inject(isMacInjectable); return { + id: "close-window", + kind: "os-action-menu-item" as const, parentId: "file", orderNumber: 60, - role: "close" as const, + actionName: "close" as const, label: "Close Window", - accelerator: "Shift+Cmd+W", + keyboardShortcut: "Shift+Cmd+W", isShown: isMac, }; }, diff --git a/src/features/application-menu/main/menu-items/file/file-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/file/file-menu-item.injectable.ts index 930ed662a3..dce2bb5cef 100644 --- a/src/features/application-menu/main/menu-items/file/file-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/file/file-menu-item.injectable.ts @@ -9,8 +9,9 @@ const fileMenuItemInjectable = getInjectable({ id: "file-application-menu-item", instantiate: () => ({ - parentId: null, + kind: "top-level-menu" as const, id: "file", + parentId: "root" as const, orderNumber: 20, label: "File", }), diff --git a/src/features/application-menu/main/menu-items/file/separators/separators.injectable.ts b/src/features/application-menu/main/menu-items/file/separators/separators.injectable.ts index 4f5e819bf7..a0f2cbe5d8 100644 --- a/src/features/application-menu/main/menu-items/file/separators/separators.injectable.ts +++ b/src/features/application-menu/main/menu-items/file/separators/separators.injectable.ts @@ -13,9 +13,3 @@ export const separator1 = getApplicationMenuSeparatorInjectable({ orderNumber: 20, isShownOnlyOnMac: true, }); - -export const separator2 = getApplicationMenuSeparatorInjectable({ - id: "separator-2-for-file", - parentId: "file", - orderNumber: 50, -}); diff --git a/src/features/application-menu/main/menu-items/get-application-menu-operation-system-action-injectable.ts b/src/features/application-menu/main/menu-items/get-application-menu-operation-system-action-injectable.ts index b6451a3af5..ebe6a6865e 100644 --- a/src/features/application-menu/main/menu-items/get-application-menu-operation-system-action-injectable.ts +++ b/src/features/application-menu/main/menu-items/get-application-menu-operation-system-action-injectable.ts @@ -3,20 +3,20 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { OperationSystemAction } from "./application-menu-item-injection-token"; +import type { OsActionMenuItem } from "./application-menu-item-injection-token"; import applicationMenuItemInjectionToken from "./application-menu-item-injection-token"; const getApplicationMenuOperationSystemActionInjectable = ({ id, - role, ...rest -}: { id: string } & OperationSystemAction) => +}: Omit) => getInjectable({ id: `application-menu-operation-system-action/${id}`, instantiate: () => ({ ...rest, - role, + id, + kind: "os-action-menu-item" as const, }), injectionToken: applicationMenuItemInjectionToken, diff --git a/src/features/application-menu/main/menu-items/get-application-menu-separator-injectable.ts b/src/features/application-menu/main/menu-items/get-application-menu-separator-injectable.ts index 05f6905614..d9eb1677cc 100644 --- a/src/features/application-menu/main/menu-items/get-application-menu-separator-injectable.ts +++ b/src/features/application-menu/main/menu-items/get-application-menu-separator-injectable.ts @@ -11,7 +11,10 @@ const getApplicationMenuSeparatorInjectable = ({ id, isShownOnlyOnMac = false, ...rest -}: { id: string; isShownOnlyOnMac?: boolean } & Omit) => +}: { isShownOnlyOnMac?: boolean } & Omit< + Separator, + "kind" | "isShown" +>) => getInjectable({ id: `application-menu-separator/${id}`, @@ -21,8 +24,9 @@ const getApplicationMenuSeparatorInjectable = ({ return { ...rest, + id, + kind: "separator" as const, isShown, - type: "separator" as const, }; }, diff --git a/src/features/application-menu/main/menu-items/help/help-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/help/help-menu-item.injectable.ts index 01b213bda7..5b83115362 100644 --- a/src/features/application-menu/main/menu-items/help/help-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/help/help-menu-item.injectable.ts @@ -9,10 +9,12 @@ const helpMenuItemInjectable = getInjectable({ id: "help-application-menu-item", instantiate: () => ({ - parentId: null, + kind: "top-level-menu" as const, id: "help", + parentId: "root" as const, orderNumber: 50, label: "Help", + role: "help" as const, }), injectionToken: applicationMenuItemInjectionToken, diff --git a/src/features/application-menu/main/menu-items/help/navigate-to-welcome/navigate-to-extensions-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/help/navigate-to-welcome/navigate-to-extensions-menu-item.injectable.ts index 4e687a6e1b..10100ecb4c 100644 --- a/src/features/application-menu/main/menu-items/help/navigate-to-welcome/navigate-to-extensions-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/help/navigate-to-welcome/navigate-to-extensions-menu-item.injectable.ts @@ -13,12 +13,13 @@ const navigateToWelcomeMenuItem = getInjectable({ const navigateToWelcome = di.inject(navigateToWelcomeInjectable); return { + kind: "clickable-menu-item" as const, parentId: "help", id: "navigate-to-welcome", orderNumber: 10, label: "Welcome", - click: () => { + onClick: () => { navigateToWelcome(); }, }; diff --git a/src/features/application-menu/main/menu-items/help/open-documentation/open-documentation-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/help/open-documentation/open-documentation-menu-item.injectable.ts index 5c0288fd10..ca63120810 100644 --- a/src/features/application-menu/main/menu-items/help/open-documentation/open-documentation-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/help/open-documentation/open-documentation-menu-item.injectable.ts @@ -16,13 +16,14 @@ const openDocumentationMenuItemInjectable = getInjectable({ const logger = di.inject(loggerInjectable); return { + kind: "clickable-menu-item" as const, parentId: "help", id: "open-documentation", orderNumber: 20, label: "Documentation", // TODO: Convert to async/await - click: () => { + onClick: () => { openLinkInBrowser(docsUrl).catch((error) => { logger.error("[MENU]: failed to open browser", { error }); }); diff --git a/src/features/application-menu/main/menu-items/help/open-support/open-support-item.injectable.ts b/src/features/application-menu/main/menu-items/help/open-support/open-support-item.injectable.ts index c5e15bebfb..7a1db63078 100644 --- a/src/features/application-menu/main/menu-items/help/open-support/open-support-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/help/open-support/open-support-item.injectable.ts @@ -16,13 +16,14 @@ const openSupportItemInjectable = getInjectable({ const logger = di.inject(loggerInjectable); return { + kind: "clickable-menu-item" as const, parentId: "help", id: "open-support", orderNumber: 30, label: "Support", // TODO: Convert to async/await - click: () => { + onClick: () => { openLinkInBrowser(supportUrl).catch((error) => { logger.error("[MENU]: failed to open browser", { error }); }); diff --git a/src/features/application-menu/main/menu-items/primary-for-mac/check-for-updates/check-for-updates-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/primary-for-mac/check-for-updates/check-for-updates-menu-item.injectable.ts index 65afba8f8b..cb4a0262fa 100644 --- a/src/features/application-menu/main/menu-items/primary-for-mac/check-for-updates/check-for-updates-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/primary-for-mac/check-for-updates/check-for-updates-menu-item.injectable.ts @@ -24,13 +24,14 @@ const checkForUpdatesMenuItemInjectable = getInjectable({ const isMac = di.inject(isMacInjectable); return { + kind: "clickable-menu-item" as const, id: "check-for-updates", parentId: isMac ? "primary-for-mac" : "help", orderNumber: isMac ? 20 : 50, label: "Check for updates", isShown: updatingIsEnabled, - click: () => { + onClick: () => { // Todo: implement using async/await processCheckingForUpdates("application-menu").then(() => showApplicationWindow(), diff --git a/src/features/application-menu/main/menu-items/primary-for-mac/navigate-to-extensions/navigate-to-extensions-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/primary-for-mac/navigate-to-extensions/navigate-to-extensions-menu-item.injectable.ts index 2a443068bf..55aa115591 100644 --- a/src/features/application-menu/main/menu-items/primary-for-mac/navigate-to-extensions/navigate-to-extensions-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/primary-for-mac/navigate-to-extensions/navigate-to-extensions-menu-item.injectable.ts @@ -15,13 +15,14 @@ const navigateToExtensionsMenuItem = getInjectable({ const isMac = di.inject(isMacInjectable); return { + kind: "clickable-menu-item" as const, parentId: isMac ? "primary-for-mac" : "file", id: "navigate-to-extensions", orderNumber: isMac ? 50 : 40, label: "Extensions", - accelerator: isMac ? "CmdOrCtrl+Shift+E" : "Ctrl+Shift+E", + keyboardShortcut: isMac ? "CmdOrCtrl+Shift+E" : "Ctrl+Shift+E", - click: () => { + onClick: () => { navigateToExtensions(); }, }; diff --git a/src/features/application-menu/main/menu-items/primary-for-mac/navigate-to-preferences/navigate-to-preferences-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/primary-for-mac/navigate-to-preferences/navigate-to-preferences-menu-item.injectable.ts index 7bda93d7af..14d25c4b45 100644 --- a/src/features/application-menu/main/menu-items/primary-for-mac/navigate-to-preferences/navigate-to-preferences-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/primary-for-mac/navigate-to-preferences/navigate-to-preferences-menu-item.injectable.ts @@ -15,13 +15,14 @@ const navigateToPreferencesMenuItemInjectable = getInjectable({ const isMac = di.inject(isMacInjectable); return { + kind: "clickable-menu-item" as const, parentId: isMac ? "primary-for-mac" : "file", id: "navigate-to-preferences", orderNumber: isMac ? 40 : 30, label: "Preferences", - accelerator: isMac ? "CmdOrCtrl+," : "Ctrl+,", + keyboardShortcut: isMac ? "CmdOrCtrl+," : "Ctrl+,", - click: () => { + onClick: () => { navigateToPreferences(); }, }; diff --git a/src/features/application-menu/main/menu-items/primary-for-mac/operation-system-actions/operation-system-actions.injectable.ts b/src/features/application-menu/main/menu-items/primary-for-mac/operation-system-actions/operation-system-actions.injectable.ts index e6459742ff..dabb493e2c 100644 --- a/src/features/application-menu/main/menu-items/primary-for-mac/operation-system-actions/operation-system-actions.injectable.ts +++ b/src/features/application-menu/main/menu-items/primary-for-mac/operation-system-actions/operation-system-actions.injectable.ts @@ -11,26 +11,26 @@ export const actionForServices = getApplicationMenuOperationSystemActionInjectab id: "services", parentId: "primary-for-mac", orderNumber: 80, - role: "services", + actionName: "services", }); export const actionForHide = getApplicationMenuOperationSystemActionInjectable({ id: "hide", parentId: "primary-for-mac", orderNumber: 100, - role: "hide", + actionName: "hide", }); export const actionForHideOthers = getApplicationMenuOperationSystemActionInjectable({ id: "hide-others", parentId: "primary-for-mac", orderNumber: 110, - role: "hideOthers", + actionName: "hideOthers", }); export const actionForUnhide = getApplicationMenuOperationSystemActionInjectable({ id: "unhide", parentId: "primary-for-mac", orderNumber: 120, - role: "unhide", + actionName: "unhide", }); diff --git a/src/features/application-menu/main/menu-items/primary-for-mac/primary-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/primary-for-mac/primary-menu-item.injectable.ts index 71798e3393..e8e3985e34 100644 --- a/src/features/application-menu/main/menu-items/primary-for-mac/primary-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/primary-for-mac/primary-menu-item.injectable.ts @@ -15,7 +15,8 @@ const primaryMenuItemInjectable = getInjectable({ const isMac = di.inject(isMacInjectable); return { - parentId: null, + kind: "top-level-menu" as const, + parentId: "root" as const, id: "primary-for-mac", orderNumber: 10, label: appName, diff --git a/src/features/application-menu/main/menu-items/primary-for-mac/quit-application/quit-application-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/primary-for-mac/quit-application/quit-application-menu-item.injectable.ts index fc7b44add5..d0ad3cdc95 100644 --- a/src/features/application-menu/main/menu-items/primary-for-mac/quit-application/quit-application-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/primary-for-mac/quit-application/quit-application-menu-item.injectable.ts @@ -15,14 +15,15 @@ const quitApplicationMenuItemInjectable = getInjectable({ const isMac = di.inject(isMacInjectable); return { + kind: "clickable-menu-item" as const, id: "quit", label: "Quit", parentId: isMac ? "primary-for-mac" : "file", orderNumber: isMac ? 140 : 70, - accelerator: isMac ? "Cmd+Q" : "Alt+F4", + keyboardShortcut: isMac ? "Cmd+Q" : "Alt+F4", - click: () => { + onClick: () => { stopServicesAndExitApp(); }, }; diff --git a/src/features/application-menu/main/menu-items/primary-for-mac/show-about-application/about-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/primary-for-mac/show-about-application/about-menu-item.injectable.ts index f80171be6e..85221a0ca3 100644 --- a/src/features/application-menu/main/menu-items/primary-for-mac/show-about-application/about-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/primary-for-mac/show-about-application/about-menu-item.injectable.ts @@ -17,12 +17,13 @@ const aboutMenuItemInjectable = getInjectable({ const isMac = di.inject(isMacInjectable); return { + kind: "clickable-menu-item" as const, id: "about", parentId: isMac ? "primary-for-mac" : "help", orderNumber: isMac ? 10 : 40, label: `About ${productName}`, - click() { + onClick() { showAbout(); }, }; diff --git a/src/features/application-menu/main/menu-items/view/go-back/go-back-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/go-back/go-back-menu-item.injectable.ts index f11df6f6e0..42860bdc5c 100644 --- a/src/features/application-menu/main/menu-items/view/go-back/go-back-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/view/go-back/go-back-menu-item.injectable.ts @@ -10,13 +10,14 @@ const goBackMenuItemInjectable = getInjectable({ id: "go-back-menu-item", instantiate: () => ({ + kind: "clickable-menu-item" as const, parentId: "view", id: "go-back", orderNumber: 40, label: "Back", - accelerator: "CmdOrCtrl+[", + keyboardShortcut: "CmdOrCtrl+[", - click: () => { + onClick: () => { webContents .getAllWebContents() .filter((wc) => wc.getType() === "window") diff --git a/src/features/application-menu/main/menu-items/view/go-forward/go-forward-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/go-forward/go-forward-menu-item.injectable.ts index 796b2c00c1..e37b8d1cf7 100644 --- a/src/features/application-menu/main/menu-items/view/go-forward/go-forward-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/view/go-forward/go-forward-menu-item.injectable.ts @@ -10,13 +10,14 @@ const goForwardMenuItemInjectable = getInjectable({ id: "go-forward-menu-item", instantiate: () => ({ + kind: "clickable-menu-item" as const, parentId: "view", id: "go-forward", orderNumber: 50, label: "Forward", - accelerator: "CmdOrCtrl+]", + keyboardShortcut: "CmdOrCtrl+]", - click: () => { + onClick: () => { webContents .getAllWebContents() .filter((wc) => wc.getType() === "window") diff --git a/src/features/application-menu/main/menu-items/view/navigate-to-catalog/navigate-to-catalog-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/navigate-to-catalog/navigate-to-catalog-menu-item.injectable.ts index a8053f1ddd..eb414fe1ec 100644 --- a/src/features/application-menu/main/menu-items/view/navigate-to-catalog/navigate-to-catalog-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/view/navigate-to-catalog/navigate-to-catalog-menu-item.injectable.ts @@ -13,13 +13,14 @@ const navigateToCatalogMenuItemInjectable = getInjectable({ const navigateToCatalog = di.inject(navigateToCatalogInjectable); return { + kind: "clickable-menu-item" as const, parentId: "view", id: "navigate-to-catalog", orderNumber: 10, label: "Catalog", - accelerator: "Shift+CmdOrCtrl+C", + keyboardShortcut: "Shift+CmdOrCtrl+C", - click: () => { + onClick: () => { navigateToCatalog(); }, }; diff --git a/src/features/application-menu/main/menu-items/view/open-command-palette/open-command-palette-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/open-command-palette/open-command-palette-menu-item.injectable.ts index 622335eaea..ac0bd77bfc 100644 --- a/src/features/application-menu/main/menu-items/view/open-command-palette/open-command-palette-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/view/open-command-palette/open-command-palette-menu-item.injectable.ts @@ -13,13 +13,14 @@ const openCommandPaletteMenuItemInjectable = getInjectable({ const broadcastMessage = di.inject(broadcastMessageInjectable); return { + kind: "clickable-menu-item" as const, parentId: "view", id: "open-command-palette", orderNumber: 20, label: "Command Palette...", - accelerator: "Shift+CmdOrCtrl+P", + keyboardShortcut: "Shift+CmdOrCtrl+P", - click(_m, _b, event) { + onClick(_m, _b, event) { /** * Don't broadcast unless it was triggered by menu iteration so that * there aren't double events in renderer diff --git a/src/features/application-menu/main/menu-items/view/operation-system-actions/operation-system-actions.injectable.ts b/src/features/application-menu/main/menu-items/view/operation-system-actions/operation-system-actions.injectable.ts index a994439d87..a7969d94f7 100644 --- a/src/features/application-menu/main/menu-items/view/operation-system-actions/operation-system-actions.injectable.ts +++ b/src/features/application-menu/main/menu-items/view/operation-system-actions/operation-system-actions.injectable.ts @@ -10,33 +10,33 @@ export const actionForToggleDevTools = getApplicationMenuOperationSystemActionIn id: "toggle-dev-tools", parentId: "view", orderNumber: 70, - role: "toggleDevTools", + actionName: "toggleDevTools", }); export const actionForResetZoom = getApplicationMenuOperationSystemActionInjectable({ id: "reset-zoom", parentId: "view", orderNumber: 90, - role: "resetZoom", + actionName: "resetZoom", }); export const actionForZoomIn = getApplicationMenuOperationSystemActionInjectable({ id: "zoom-in", parentId: "view", orderNumber: 100, - role: "zoomIn", + actionName: "zoomIn", }); export const actionForZoomOut = getApplicationMenuOperationSystemActionInjectable({ id: "zoom-out", parentId: "view", orderNumber: 110, - role: "zoomOut", + actionName: "zoomOut", }); export const actionForToggleFullScreen = getApplicationMenuOperationSystemActionInjectable({ id: "toggle-full-screen", parentId: "view", orderNumber: 130, - role: "togglefullscreen", + actionName: "togglefullscreen", }); diff --git a/src/features/application-menu/main/menu-items/view/reload/reload-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/reload/reload-menu-item.injectable.ts index 4b1b094eb6..683daf666a 100644 --- a/src/features/application-menu/main/menu-items/view/reload/reload-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/view/reload/reload-menu-item.injectable.ts @@ -15,13 +15,14 @@ const reloadMenuItemInjectable = getInjectable({ ); return { + kind: "clickable-menu-item" as const, parentId: "view", id: "reload", orderNumber: 60, label: "Reload", - accelerator: "CmdOrCtrl+R", + keyboardShortcut: "CmdOrCtrl+R", - click: () => { + onClick: () => { reloadApplicationWindow(); }, }; diff --git a/src/features/application-menu/main/menu-items/view/view-menu-item.injectable.ts b/src/features/application-menu/main/menu-items/view/view-menu-item.injectable.ts index 103838b10c..f45fbf7ebc 100644 --- a/src/features/application-menu/main/menu-items/view/view-menu-item.injectable.ts +++ b/src/features/application-menu/main/menu-items/view/view-menu-item.injectable.ts @@ -9,7 +9,8 @@ const viewMenuItemInjectable = getInjectable({ id: "view-application-menu-item", instantiate: () => ({ - parentId: null, + kind: "top-level-menu" as const, + parentId: "root" as const, id: "view", orderNumber: 40, label: "View", diff --git a/src/features/application-menu/main/populate-application-menu.injectable.ts b/src/features/application-menu/main/populate-application-menu.injectable.ts index bc6676ae66..8a6dc010c6 100644 --- a/src/features/application-menu/main/populate-application-menu.injectable.ts +++ b/src/features/application-menu/main/populate-application-menu.injectable.ts @@ -5,15 +5,112 @@ import { getInjectable } from "@ogre-tools/injectable"; import { Menu } from "electron"; import type { MenuItemOpts } from "./application-menu-items.injectable"; +import type { Composite } from "./menu-items/get-composite/get-composite"; +import type { ApplicationMenuItemTypes } from "./menu-items/application-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { map, sortBy } from "lodash/fp"; +import type { MenuItemRoot } from "./application-menu-item-composite.injectable"; const populateApplicationMenuInjectable = getInjectable({ id: "populate-application-menu", - instantiate: () => (applicationMenuItems: MenuItemOpts[]) => { - Menu.setApplicationMenu(Menu.buildFromTemplate(applicationMenuItems)); + instantiate: () => (composite: Composite) => { + const topLevelMenus = composite.children.filter( + (x): x is Composite => x.value.kind !== "root", + ); + + const electronTemplate = topLevelMenus.map(toHierarchicalElectronMenuItem); + + Menu.setApplicationMenu(Menu.buildFromTemplate(electronTemplate)); }, causesSideEffects: true, }); export default populateApplicationMenuInjectable; + +const toHierarchicalElectronMenuItem = ( + composite: Composite, +): MenuItemOpts => { + switch (composite.value.kind) { + case "top-level-menu": { + const { + id, + value: { label, role }, + } = composite; + + return { + ...(id ? { id } : {}), + ...(role ? { role } : {}), + label, + + submenu: pipeline( + composite.children, + sortBy((childComposite) => childComposite.value.orderNumber), + map(toHierarchicalElectronMenuItem), + ), + }; + } + + case "sub-menu": { + const { + id, + value: { label }, + } = composite; + + return { + ...(id ? { id } : {}), + label, + + submenu: pipeline( + composite.children, + sortBy((childComposite) => childComposite.value.orderNumber), + map(toHierarchicalElectronMenuItem), + ), + }; + } + + case "clickable-menu-item": { + const { + id, + value: { label, onClick, keyboardShortcut }, + } = composite; + + return { + ...(id ? { id } : {}), + ...(label ? { label } : {}), + ...(keyboardShortcut ? { accelerator: keyboardShortcut }: {}), + click: onClick, + }; + } + + case "os-action-menu-item": { + const { + value: { label, keyboardShortcut, actionName }, + } = composite; + + return { + ...(label ? { label } : {}), + ...(keyboardShortcut ? { accelerator: keyboardShortcut } : {}), + role: actionName, + }; + } + + case "separator": { + return { + type: "separator", + }; + } + + default: { + // Note: this will fail at transpilation time, if all ApplicationMenuItemTypes + // are not handled in switch/case. + const _exhaustiveCheck: never = composite.value; + + // Note: this code is unreachable, it is here to make ts not complain about + // _exhaustiveCheck not being used. + // See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking + throw new Error(`Tried to create application menu, but foreign menu item was encountered: ${_exhaustiveCheck} ${composite.value}`); + } + } +}; diff --git a/src/features/application-update/analytics-for-installing-update.test.ts b/src/features/application-update/analytics-for-installing-update.test.ts index 501516bf6e..0c0517ad49 100644 --- a/src/features/application-update/analytics-for-installing-update.test.ts +++ b/src/features/application-update/analytics-for-installing-update.test.ts @@ -142,7 +142,7 @@ describe("analytics for installing update", () => { it("when checking for updates using application menu, sends event to analytics for being checked from application menu", async () => { analyticsListenerMock.mockClear(); - builder.applicationMenu.click("root.check-for-updates"); + builder.applicationMenu.click("root.primary-for-mac.check-for-updates"); expect(analyticsListenerMock.mock.calls).toEqual([ [ diff --git a/src/features/extensions/navigation-using-application-menu.test.ts b/src/features/extensions/navigation-using-application-menu.test.ts index 8533b216f7..f419ed288a 100644 --- a/src/features/extensions/navigation-using-application-menu.test.ts +++ b/src/features/extensions/navigation-using-application-menu.test.ts @@ -48,7 +48,7 @@ describe("extensions - navigation using application menu", () => { describe("when navigating to extensions using application menu", () => { beforeEach(() => { - builder.applicationMenu.click("root.extensions"); + builder.applicationMenu.click("root.primary-for-mac.navigate-to-extensions"); }); it("focuses the window", () => { diff --git a/src/features/preferences/navigation-using-application-menu.test.ts b/src/features/preferences/navigation-using-application-menu.test.ts index c78e546442..cf663894e6 100644 --- a/src/features/preferences/navigation-using-application-menu.test.ts +++ b/src/features/preferences/navigation-using-application-menu.test.ts @@ -29,7 +29,7 @@ describe("preferences - navigation using application menu", () => { describe("when navigating to preferences using application menu", () => { beforeEach(() => { - applicationBuilder.applicationMenu.click("root.preferences"); + applicationBuilder.applicationMenu.click("root.primary-for-mac.navigate-to-preferences"); }); it("renders", () => { diff --git a/src/features/quitting-and-restarting-the-app/quitting-the-app-using-application-menu.test.ts b/src/features/quitting-and-restarting-the-app/quitting-the-app-using-application-menu.test.ts index 5e0989aa91..0eb53a89e8 100644 --- a/src/features/quitting-and-restarting-the-app/quitting-the-app-using-application-menu.test.ts +++ b/src/features/quitting-and-restarting-the-app/quitting-the-app-using-application-menu.test.ts @@ -45,7 +45,7 @@ describe("quitting the app using application menu", () => { describe("when application is quit", () => { beforeEach(() => { - builder.applicationMenu.click("root.quit"); + builder.applicationMenu.click("root.primary-for-mac.quit"); }); it("closes all windows", () => { diff --git a/src/features/welcome/navigation-using-application-menu.test.ts b/src/features/welcome/navigation-using-application-menu.test.ts index 1be9aa1d3a..bd5c34478c 100644 --- a/src/features/welcome/navigation-using-application-menu.test.ts +++ b/src/features/welcome/navigation-using-application-menu.test.ts @@ -29,7 +29,7 @@ describe("welcome - navigation using application menu", () => { describe("when navigated somewhere else", () => { beforeEach(() => { - applicationBuilder.applicationMenu.click("root.preferences"); + applicationBuilder.applicationMenu.click("root.primary-for-mac.navigate-to-preferences"); }); it("renders", () => { @@ -44,7 +44,7 @@ describe("welcome - navigation using application menu", () => { describe("when navigated to welcome using application menu", () => { beforeEach(() => { - applicationBuilder.applicationMenu.click("help.welcome"); + applicationBuilder.applicationMenu.click("root.help.navigate-to-welcome"); }); it("renders", () => { diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx index e3b51d5ed9..0edad5d93b 100644 --- a/src/renderer/components/test-utils/get-application-builder.tsx +++ b/src/renderer/components/test-utils/get-application-builder.tsx @@ -17,12 +17,9 @@ import type { DiContainer, Injectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable"; import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable"; import { pipeline } from "@ogre-tools/fp"; -import { compact, filter, first, flatMap, get, join, last, map, matches } from "lodash/fp"; +import { filter, first, get, join, last, map, matches } from "lodash/fp"; import preferenceNavigationItemsInjectable from "../+preferences/preferences-navigation/preference-navigation-items.injectable"; import navigateToPreferencesInjectable from "../../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; -import type { MenuItemOpts } from "../../../features/application-menu/main/application-menu-items.injectable"; -import applicationMenuItemsInjectable from "../../../features/application-menu/main/application-menu-items.injectable"; -import type { MenuItem, MenuItemConstructorOptions } from "electron"; import type { NavigateToHelmCharts } from "../../../common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable"; import navigateToHelmChartsInjectable from "../../../common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable"; import hostedClusterInjectable from "../../cluster-frame-context/hosted-cluster.injectable"; @@ -68,6 +65,11 @@ import { getExtensionFakeForMain, getExtensionFakeForRenderer } from "./get-exte import namespaceApiInjectable from "../../../common/k8s-api/endpoints/namespace.api.injectable"; import { Namespace } from "../../../common/k8s-api/endpoints"; import { overrideFsWithFakes } from "../../../test-utils/override-fs-with-fakes"; +import applicationMenuItemCompositeInjectable from "../../../features/application-menu/main/application-menu-item-composite.injectable"; +import { getCompositePaths } from "../../../features/application-menu/main/menu-items/get-composite/get-composite-paths/get-composite-paths"; +import { normalizeComposite } from "../../../features/application-menu/main/menu-items/get-composite/normalize-composite/normalize-composite"; +import type { ClickableMenuItem } from "../../../features/application-menu/main/menu-items/application-menu-item-injection-token"; +import type { Composite } from "../../../features/application-menu/main/menu-items/get-composite/get-composite"; type Callback = (di: DiContainer) => void | Promise; @@ -127,6 +129,7 @@ export interface ApplicationBuilder { applicationMenu: { click: (path: string) => void; + items: string[]; }; preferences: { close: () => void; @@ -343,37 +346,35 @@ export const getApplicationBuilder = () => { select: action((namespace) => selectedNamespaces.add(namespace)), }, applicationMenu: { + get items() { + const composite = mainDi.inject( + applicationMenuItemCompositeInjectable, + ).get(); + + return getCompositePaths(composite); + }, + click: (path: string) => { - const applicationMenuItems = mainDi.inject( - applicationMenuItemsInjectable, - ); + const composite = mainDi.inject( + applicationMenuItemCompositeInjectable, + ).get(); - const menuItems = pipeline( - applicationMenuItems.get(), - flatMap(toFlatChildren(null)), - filter((menuItem) => !!menuItem.click), - ); + const clickableMenuItems = normalizeComposite(composite).filter(isClickableMenuItem); + const clickableMenuItemMap = new Map(clickableMenuItems); + // TODO: find out why this any!? The typing of above map is strict, so why map.get() isn't? + const clickableMenuItem = clickableMenuItemMap.get(path); - const menuItem = menuItems.find((menuItem) => menuItem.path === path); - - if (!menuItem) { - const availableIds = menuItems.map(get("path")).join('", "'); + if (clickableMenuItem === undefined) { + const clickableIds = [...clickableMenuItemMap.keys()]; throw new Error( - `Tried to click application menu item with ID "${path}" which does not exist. Available IDs are: "${availableIds}"`, + `Tried to click application menu item with unknown path "${path}". Available clickable paths are: \n"${clickableIds.join('",\n"')}"`, ); } - menuItem.click?.( - { - menu: null as never, - commandId: 0, - userAccelerator: null, - ...menuItem, - } as MenuItem, - undefined, - {}, - ); + // Todo: prevent leaking of Electron. + // @ts-ignore + clickableMenuItem.value.onClick(); }, }, @@ -747,25 +748,6 @@ export const getApplicationBuilder = () => { return builder; }; -export type ToFlatChildren = (opts: MenuItemConstructorOptions) => (MenuItemOpts & { path: string })[]; - -function toFlatChildren(parentId: string | null | undefined): ToFlatChildren { - return ({ submenu = [], ...menuItem }) => [ - { - ...menuItem, - path: pipeline([parentId, menuItem.id], compact, join(".")), - }, - ...( - Array.isArray(submenu) - ? submenu.flatMap(toFlatChildren(menuItem.id)) - : [{ - ...submenu, - path: pipeline([parentId, menuItem.id], compact, join(".")), - }] - ), - ]; -} - export const rendererExtensionsStateInjectable = getInjectable({ id: "renderer-extensions-state", instantiate: () => observable.map(), @@ -897,3 +879,9 @@ const disableExtensionFor = }); }; +const isClickableMenuItem = ( + pathAndComposite: readonly [path: string, composite: any], +): pathAndComposite is [string, Composite] => + pathAndComposite[1].value.kind === "clickable-menu-item"; + +