diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index a031e2766a..c8b905ffab 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -278,11 +278,22 @@ export interface CatalogEntitySettingsMenu { }; } +export interface CatalogEntityContextMenuNavigate { + /** + * @param pathname The location to navigate to in the main iframe + */ + (pathname: string, forceMainFrame?: boolean): void; + /** + * @param pathname The location to navigate to in the current iframe. Useful for when called within the cluster frame + */ + (pathname: string, forceMainFrame: false): void; +} + export interface CatalogEntityContextMenuContext { /** * Navigate to the specified pathname */ - navigate: (pathname: string) => void; + navigate: CatalogEntityContextMenuNavigate; menuItems: CatalogEntityContextMenu[]; } diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index eab3239350..cda7cf9eb2 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -32,6 +32,7 @@ export * from "./objects"; export * from "./openBrowser"; export * from "./paths"; export * from "./promise-exec"; +export * from "./readonly"; export * from "./reject-promise"; export * from "./singleton"; export * from "./sort-compare"; diff --git a/src/common/utils/readonly.ts b/src/common/utils/readonly.ts new file mode 100644 index 0000000000..b379594d9d --- /dev/null +++ b/src/common/utils/readonly.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ReadonlyDeep } from "type-fest"; + +export function readonly(src: T): ReadonlyDeep { + return src as ReadonlyDeep; +} diff --git a/src/extensions/common-api/registrations.ts b/src/extensions/common-api/registrations.ts index 01b4407a6b..a35e23ef11 100644 --- a/src/extensions/common-api/registrations.ts +++ b/src/extensions/common-api/registrations.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ export type { StatusBarRegistration } from "../../renderer/components/status-bar/status-bar-registration"; -export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../../renderer/components/kube-object-menu/dependencies/kube-object-menu-items/kube-object-menu-registration"; +export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../../renderer/components/kube-object-menu/kube-object-menu-registration"; export type { AppPreferenceRegistration, AppPreferenceComponents } from "../../renderer/components/+preferences/app-preferences/app-preference-registration"; export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry"; export type { KubeObjectStatusRegistration } from "../../renderer/components/kube-object-status-icon/kube-object-status-registration"; @@ -12,3 +12,4 @@ export type { ClusterPageMenuRegistration, ClusterPageMenuComponents } from "../ export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler"; export type { CustomCategoryViewProps, CustomCategoryViewComponents, CustomCategoryViewRegistration } from "../../renderer/components/+catalog/custom-views"; export type { ShellEnvModifier, ShellEnvContext } from "../../main/shell-session/shell-env-modifier/shell-env-modifier-registration"; +export type { KubeObjectContextMenuItem, KubeObjectOnContextMenuOpenContext, KubeObjectOnContextMenuOpen, KubeObjectHandlers, KubeObjectHandlerRegistration } from "../../renderer/kube-object/handler"; diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 9c897431dc..a443ac7a49 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -20,7 +20,7 @@ import type { AppPreferenceRegistration } from "../renderer/components/+preferen import type { AdditionalCategoryColumnRegistration } from "../renderer/components/+catalog/custom-category-columns"; import type { CustomCategoryViewRegistration } from "../renderer/components/+catalog/custom-views"; import type { StatusBarRegistration } from "../renderer/components/status-bar/status-bar-registration"; -import type { KubeObjectMenuRegistration } from "../renderer/components/kube-object-menu/dependencies/kube-object-menu-items/kube-object-menu-registration"; +import type { KubeObjectMenuRegistration } from "../renderer/components/kube-object-menu/kube-object-menu-registration"; import type { WorkloadsOverviewDetailRegistration } from "../renderer/components/+workloads-overview/workloads-overview-detail-registration"; import type { KubeObjectStatusRegistration } from "../renderer/components/kube-object-status-icon/kube-object-status-registration"; import { Environments, getEnvironmentSpecificLegacyGlobalDiForExtensionApi } from "./as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; @@ -30,6 +30,7 @@ import extensionPageParametersInjectable from "../renderer/routes/extension-page import { pipeline } from "@ogre-tools/fp"; import { getExtensionRoutePath } from "../renderer/routes/get-extension-route-path"; import { navigateToRouteInjectionToken } from "../common/front-end-routing/navigate-to-route-injection-token"; +import type { KubeObjectHandlerRegistration } from "../renderer/kube-object/handler"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -49,6 +50,7 @@ export class LensRendererExtension extends LensExtension { topBarItems: TopBarRegistration[] = []; additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = []; customCategoryViews: CustomCategoryViewRegistration[] = []; + kubeObjectHandlers: KubeObjectHandlerRegistration[] = []; async navigate

(pageId?: string, params?: P) { const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi( diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 21733bd85e..13facef264 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -15,6 +15,10 @@ import sendCommandInjectable from "../../renderer/components/dock/terminal/send- import { podsStore } from "../../renderer/components/+workloads-pods/pods.store"; import renameTabInjectable from "../../renderer/components/dock/dock/rename-tab.injectable"; import { asLegacyGlobalObjectForExtensionApiWithModifications } from "../as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api-with-modifications"; +import { ConfirmDialog as _ConfirmDialog } from "../../renderer/components/confirm-dialog"; +import type { ConfirmDialogBooleanParams, ConfirmDialogParams, ConfirmDialogProps } from "../../renderer/components/confirm-dialog"; +import openConfirmDialogInjectable from "../../renderer/components/confirm-dialog/open.injectable"; +import confirmInjectable from "../../renderer/components/confirm-dialog/confirm.injectable"; // layouts export * from "../../renderer/components/layout/main-layout"; @@ -43,6 +47,16 @@ export type { } from "../../renderer/components/+catalog/custom-category-columns"; // other components +export type { + ConfirmDialogBooleanParams, + ConfirmDialogParams, + ConfirmDialogProps, +}; +export const ConfirmDialog = Object.assign(_ConfirmDialog, { + open: asLegacyGlobalFunctionForExtensionApi(openConfirmDialogInjectable), + confirm: asLegacyGlobalFunctionForExtensionApi(confirmInjectable), +}); + export * from "../../renderer/components/icon"; export * from "../../renderer/components/tooltip"; export * from "../../renderer/components/tabs"; @@ -50,7 +64,6 @@ export * from "../../renderer/components/table"; export * from "../../renderer/components/badge"; export * from "../../renderer/components/drawer"; export * from "../../renderer/components/dialog"; -export * from "../../renderer/components/confirm-dialog"; export * from "../../renderer/components/line-progress"; export * from "../../renderer/components/menu"; export * from "../../renderer/components/notifications"; diff --git a/src/renderer/catalog/normalize-menu-item.injectable.ts b/src/renderer/catalog/normalize-menu-item.injectable.ts new file mode 100644 index 0000000000..5d6688ade5 --- /dev/null +++ b/src/renderer/catalog/normalize-menu-item.injectable.ts @@ -0,0 +1,43 @@ +/** + * 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 type { CatalogEntityContextMenu } from "../api/catalog-entity"; +import withConfirmationInjectable from "../components/confirm-dialog/with-confirm.injectable"; + +export interface NormalizedCatalogEntityContextMenu { + title: string; + icon?: string; + onClick: () => void; +} + +export type NormalizeCatalogEntityContextMenu = (menuItem: CatalogEntityContextMenu) => NormalizedCatalogEntityContextMenu; + +const normalizeCatalogEntityContextMenuInjectable = getInjectable({ + id: "normalize-catalog-entity-context-menu", + instantiate: (di): NormalizeCatalogEntityContextMenu => { + const withConfirmation = di.inject(withConfirmationInjectable); + + return (menuItem) => { + if (menuItem.confirm) { + return { + title: menuItem.title, + icon: menuItem.icon, + onClick: withConfirmation({ + message: menuItem.confirm.message, + ok: menuItem.onClick, + okButtonProps: { + primary: false, + accent: true, + }, + }), + }; + } + + return menuItem; + }; + }, +}); + +export default normalizeCatalogEntityContextMenuInjectable; diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx index 93d9ea7e0d..9fd5867965 100644 --- a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -7,24 +7,32 @@ import React from "react"; import { cssNames } from "../../utils"; import type { MenuActionsProps } from "../menu/menu-actions"; import { MenuActions } from "../menu/menu-actions"; -import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; +import type { CatalogEntity, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; import { observer } from "mobx-react"; import { makeObservable, observable } from "mobx"; -import { navigate } from "../../navigation"; import { MenuItem } from "../menu"; -import { ConfirmDialog } from "../confirm-dialog"; import { Icon } from "../icon"; import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { Navigate } from "../../navigation/navigate.injectable"; +import navigateInjectable from "../../navigation/navigate.injectable"; +import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable"; +import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable"; export interface CatalogEntityDrawerMenuProps extends MenuActionsProps { entity: T; } +interface Dependencies { + normalizeMenuItem: NormalizeCatalogEntityContextMenu; + navigate: Navigate; +} + @observer -export class CatalogEntityDrawerMenu extends React.Component> { +class NonInjectedCatalogEntityDrawerMenu extends React.Component & Dependencies> { @observable private contextMenu: CatalogEntityContextMenuContext; - constructor(props: CatalogEntityDrawerMenuProps) { + constructor(props: CatalogEntityDrawerMenuProps & Dependencies) { super(props); makeObservable(this); } @@ -32,52 +40,28 @@ export class CatalogEntityDrawerMenu extends React.Comp componentDidMount() { this.contextMenu = { menuItems: [], - navigate: (url: string) => navigate(url), + navigate: this.props.navigate, }; this.props.entity?.onContextMenuOpen(this.contextMenu); } - onMenuItemClick(menuItem: CatalogEntityContextMenu) { - if (menuItem.confirm) { - ConfirmDialog.open({ - okButtonProps: { - primary: false, - accent: true, - }, - ok: () => { - menuItem.onClick(); - }, - message: menuItem.confirm.message, - }); - } else { - menuItem.onClick(); - } - } - getMenuItems(entity: T): React.ReactChild[] { if (!entity) { return []; } - const items: React.ReactChild[] = []; - - for (const menuItem of this.contextMenu.menuItems) { - if (!menuItem.icon) { - continue; - } - - const key = Icon.isSvg(menuItem.icon) ? "svg" : "material"; - - items.push( - this.onMenuItemClick(menuItem)}> + const items = this.contextMenu.menuItems + .filter(menuItem => menuItem.icon) + .map(this.props.normalizeMenuItem) + .map(menuItem => ( + - , - ); - } + + )); items.push( extends React.Comp ); } } + +export const CatalogEntityDrawerMenu = withInjectables>(NonInjectedCatalogEntityDrawerMenu, { + getProps: (di, props) => ({ + ...props, + normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable), + navigate: di.inject(navigateInjectable), + }), +}) as (props: CatalogEntityDrawerMenuProps) => React.ReactElement; diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index e8ab985d1c..adc9820f11 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -11,10 +11,9 @@ import { ItemListLayout } from "../item-object-list"; import type { IComputedValue } from "mobx"; import { action, computed, makeObservable, observable, reaction, runInAction, when } from "mobx"; import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; -import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; -import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; -import { ConfirmDialog } from "../confirm-dialog"; +import type { CatalogEntityContextMenuContext } from "../../api/catalog-entity"; +import type { HotbarStore } from "../../../common/hotbar-store"; import type { CatalogEntity } from "../../../common/catalog"; import { catalogCategoryRegistry } from "../../../common/catalog"; import { CatalogAddButton } from "./catalog-add-button"; @@ -42,7 +41,10 @@ import { browseCatalogTab } from "./catalog-browse-tab"; import type { AppEvent } from "../../../common/app-event-bus/event-bus"; import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable"; import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; -import type { HotbarStore } from "../../../common/hotbar-store"; +import type { Navigate } from "../../navigation/navigate.injectable"; +import navigateInjectable from "../../navigation/navigate.injectable"; +import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable"; +import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable"; interface Dependencies { catalogPreviousActiveTabStorage: { set: (value: string ) => void; get: () => string }; @@ -50,14 +52,14 @@ interface Dependencies { getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns; customCategoryViews: IComputedValue>>; emitEvent: (event: AppEvent) => void; - routeParameters: { group: IComputedValue; kind: IComputedValue; }; - navigateToCatalog: NavigateToCatalog; hotbarStore: HotbarStore; + navigate: Navigate; + normalizeMenuItem: NormalizeCatalogEntityContextMenu; } @observer @@ -93,7 +95,7 @@ class NonInjectedCatalog extends React.Component { async componentDidMount() { this.contextMenu = { menuItems: observable.array([]), - navigate: (url: string) => navigate(url), + navigate: this.props.navigate, }; disposeOnUnmount(this, [ this.props.catalogEntityStore.watch(), @@ -149,23 +151,6 @@ class NonInjectedCatalog extends React.Component { } }; - onMenuItemClick(menuItem: CatalogEntityContextMenu) { - if (menuItem.confirm) { - ConfirmDialog.open({ - okButtonProps: { - primary: false, - accent: true, - }, - ok: () => { - menuItem.onClick(); - }, - message: menuItem.confirm.message, - }); - } else { - menuItem.onClick(); - } - } - get categories() { return catalogCategoryRegistry.items; } @@ -209,11 +194,13 @@ class NonInjectedCatalog extends React.Component { View Details { - this.contextMenu.menuItems.map((menuItem, index) => ( - this.onMenuItemClick(menuItem)}> - {menuItem.title} - - )) + this.contextMenu.menuItems + .map(this.props.normalizeMenuItem) + .map((menuItem, index) => ( + + {menuItem.title} + + )) } { } } -export const Catalog = withInjectables( NonInjectedCatalog, { - getProps: (di) => ({ +export const Catalog = withInjectables(NonInjectedCatalog, { + getProps: (di, props) => ({ + ...props, catalogEntityStore: di.inject(catalogEntityStoreInjectable), catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable), getCategoryColumns: di.inject(getCategoryColumnsInjectable), @@ -352,5 +340,7 @@ export const Catalog = withInjectables( NonInjectedCatalog, { navigateToCatalog: di.inject(navigateToCatalogInjectable), emitEvent: di.inject(appEventBusInjectable).emit, hotbarStore: di.inject(hotbarStoreInjectable), + navigate: di.inject(navigateInjectable), + normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable), }), }); diff --git a/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx b/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx new file mode 100644 index 0000000000..4c1dbf69d1 --- /dev/null +++ b/src/renderer/components/+extensions/attempt-install-by-info.injectable.tsx @@ -0,0 +1,138 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { ExtendableDisposer } from "../../../common/utils"; +import { downloadFile, downloadJson } from "../../../common/utils"; +import { Notifications } from "../notifications"; +import React from "react"; +import path from "path"; +import { SemVer } from "semver"; +import URLParse from "url-parse"; +import type { InstallRequest } from "./attempt-install/install-request"; +import { reduce } from "lodash"; +import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; +import type { Confirm } from "../confirm-dialog/confirm.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import attemptInstallInjectable from "./attempt-install/attempt-install.injectable"; +import getBaseRegistryUrlInjectable from "./get-base-registry-url/get-base-registry-url.injectable"; +import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; +import confirmInjectable from "../confirm-dialog/confirm.injectable"; + +export interface ExtensionInfo { + name: string; + version?: string; + requireConfirmation?: boolean; +} + +export type AttemptInstallByInfo = (info: ExtensionInfo) => Promise; + +interface Dependencies { + attemptInstall: (request: InstallRequest, d: ExtendableDisposer) => Promise; + getBaseRegistryUrl: () => Promise; + extensionInstallationStateStore: ExtensionInstallationStateStore; + confirm: Confirm; +} + +const attemptInstallByInfo = ({ + attemptInstall, + getBaseRegistryUrl, + extensionInstallationStateStore, + confirm, +}: Dependencies): AttemptInstallByInfo => ( + async (info) => { + const { name, version, requireConfirmation = false } = info; + const disposer = extensionInstallationStateStore.startPreInstall(); + const baseUrl = await getBaseRegistryUrl(); + const registryUrl = new URLParse(baseUrl).set("pathname", name).toString(); + let json: any; + let finalVersion = version; + + try { + json = await downloadJson({ url: registryUrl }).promise; + + if (!json || json.error || typeof json.versions !== "object" || !json.versions) { + const message = json?.error ? `: ${json.error}` : ""; + + Notifications.error(`Failed to get registry information for that extension${message}`); + + return disposer(); + } + } catch (error) { + if (error instanceof SyntaxError) { + // assume invalid JSON + console.warn("Set registry has invalid json", { url: baseUrl }, error); + Notifications.error("Failed to get valid registry information for that extension. Registry did not return valid JSON"); + } else { + console.error("Failed to download registry information", error); + Notifications.error(`Failed to get valid registry information for that extension. ${error}`); + } + + return disposer(); + } + + if (version) { + if (!json.versions[version]) { + if (json["dist-tags"][version]) { + finalVersion = json["dist-tags"][version]; + } else { + Notifications.error(( +

+ The {name} extension does not have a version or tag {version}. +

+ )); + + return disposer(); + } + } + } else { + const versions = Object.keys(json.versions) + .map(version => new SemVer(version, { loose: true, includePrerelease: true })) + // ignore pre-releases for auto picking the version + .filter(version => version.prerelease.length === 0); + + finalVersion = reduce( + versions, + (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev, + ).format(); + } + + if (requireConfirmation) { + const proceed = await confirm({ + message: ( +

+ Are you sure you want to install{" "} + + {name}@{finalVersion} + + ? +

+ ), + labelCancel: "Cancel", + labelOk: "Install", + }); + + if (!proceed) { + return disposer(); + } + } + + const url = json.versions[finalVersion].dist.tarball; + const fileName = path.basename(url); + const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 }); + + return attemptInstall({ fileName, dataP }, disposer); + } +); + +const attemptInstallByInfoInjectable = getInjectable({ + id: "attempt-install-by-info", + instantiate: (di) => attemptInstallByInfo({ + attemptInstall: di.inject(attemptInstallInjectable), + getBaseRegistryUrl: di.inject(getBaseRegistryUrlInjectable), + extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), + confirm: di.inject(confirmInjectable), + }), +}); + +export default attemptInstallByInfoInjectable; diff --git a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts deleted file mode 100644 index 3703c8e56e..0000000000 --- a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * 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 { attemptInstallByInfo } from "./attempt-install-by-info"; -import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; -import getBaseRegistryUrlInjectable from "../get-base-registry-url/get-base-registry-url.injectable"; -import extensionInstallationStateStoreInjectable - from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; - -const attemptInstallByInfoInjectable = getInjectable({ - id: "attempt-install-by-info", - - instantiate: (di) => - attemptInstallByInfo({ - attemptInstall: di.inject(attemptInstallInjectable), - getBaseRegistryUrl: di.inject(getBaseRegistryUrlInjectable), - extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable), - }), -}); - -export default attemptInstallByInfoInjectable; diff --git a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx b/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx deleted file mode 100644 index c737bb97d1..0000000000 --- a/src/renderer/components/+extensions/attempt-install-by-info/attempt-install-by-info.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { ExtendableDisposer } from "../../../../common/utils"; -import { downloadFile, downloadJson } from "../../../../common/utils"; -import { Notifications } from "../../notifications"; -import { ConfirmDialog } from "../../confirm-dialog"; -import React from "react"; -import path from "path"; -import { SemVer } from "semver"; -import URLParse from "url-parse"; -import type { InstallRequest } from "../attempt-install/install-request"; -import lodash from "lodash"; -import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; - -export interface ExtensionInfo { - name: string; - version?: string; - requireConfirmation?: boolean; -} - -interface Dependencies { - attemptInstall: (request: InstallRequest, d: ExtendableDisposer) => Promise; - getBaseRegistryUrl: () => Promise; - extensionInstallationStateStore: ExtensionInstallationStateStore; -} - -export const attemptInstallByInfo = ({ attemptInstall, getBaseRegistryUrl, extensionInstallationStateStore }: Dependencies) => async ({ - name, - version, - requireConfirmation = false, -}: ExtensionInfo) => { - const disposer = extensionInstallationStateStore.startPreInstall(); - const baseUrl = await getBaseRegistryUrl(); - const registryUrl = new URLParse(baseUrl).set("pathname", name).toString(); - let json: any; - - try { - json = await downloadJson({ url: registryUrl }).promise; - - if (!json || json.error || typeof json.versions !== "object" || !json.versions) { - const message = json?.error ? `: ${json.error}` : ""; - - Notifications.error(`Failed to get registry information for that extension${message}`); - - return disposer(); - } - } catch (error) { - if (error instanceof SyntaxError) { - // assume invalid JSON - console.warn("Set registry has invalid json", { url: baseUrl }, error); - Notifications.error("Failed to get valid registry information for that extension. Registry did not return valid JSON"); - } else { - console.error("Failed to download registry information", error); - Notifications.error(`Failed to get valid registry information for that extension. ${error}`); - } - - return disposer(); - } - - if (version) { - if (!json.versions[version]) { - if (json["dist-tags"][version]) { - version = json["dist-tags"][version]; - } else { - Notifications.error( -

- The {name} extension does not have a version or tag{" "} - {version}. -

, - ); - - return disposer(); - } - } - } else { - const versions = Object.keys(json.versions) - .map( - version => - new SemVer(version, { loose: true, includePrerelease: true }), - ) - // ignore pre-releases for auto picking the version - .filter(version => version.prerelease.length === 0); - - version = lodash.reduce(versions, (prev, curr) => - prev.compareMain(curr) === -1 ? curr : prev, - ).format(); - } - - if (requireConfirmation) { - const proceed = await ConfirmDialog.confirm({ - message: ( -

- Are you sure you want to install{" "} - - {name}@{version} - - ? -

- ), - labelCancel: "Cancel", - labelOk: "Install", - }); - - if (!proceed) { - return disposer(); - } - } - - const url = json.versions[version].dist.tarball; - const fileName = path.basename(url); - const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 }); - - return attemptInstall({ fileName, dataP }, disposer); -}; diff --git a/src/renderer/components/+extensions/confirm-uninstall-extension.injectable.tsx b/src/renderer/components/+extensions/confirm-uninstall-extension.injectable.tsx new file mode 100644 index 0000000000..e48f7c0032 --- /dev/null +++ b/src/renderer/components/+extensions/confirm-uninstall-extension.injectable.tsx @@ -0,0 +1,54 @@ +/** + * 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 React from "react"; +import type { InstalledExtension } from "../../../extensions/extension-discovery/extension-discovery"; +import type { LensExtensionId } from "../../../extensions/lens-extension"; +import { extensionDisplayName } from "../../../extensions/lens-extension"; +import type { Confirm } from "../confirm-dialog/confirm.injectable"; +import confirmInjectable from "../confirm-dialog/confirm.injectable"; +import uninstallExtensionInjectable from "./uninstall-extension/uninstall-extension.injectable"; + +interface Dependencies { + uninstallExtension: (id: LensExtensionId) => Promise; + confirm: Confirm; +} + +export type ConfirmUninstallExtension = (ext: InstalledExtension) => Promise; + +const confirmUninstallExtension = ({ + uninstallExtension, + confirm, +}: Dependencies): ConfirmUninstallExtension => ( + async (extension) => { + const displayName = extensionDisplayName( + extension.manifest.name, + extension.manifest.version, + ); + const confirmed = await confirm({ + message: ( +

+ Are you sure you want to uninstall extension {displayName}? +

+ ), + labelOk: "Yes", + labelCancel: "No", + }); + + if (confirmed) { + await uninstallExtension(extension.id); + } + } +); + +const confirmUninstallExtensionInjectable = getInjectable({ + id: "confirm-uninstall-extension", + instantiate: (di) => confirmUninstallExtension({ + uninstallExtension: di.inject(uninstallExtensionInjectable), + confirm: di.inject(confirmInjectable), + }), +}); + +export default confirmUninstallExtensionInjectable; diff --git a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.injectable.ts b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.injectable.ts deleted file mode 100644 index 560b5adc74..0000000000 --- a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.injectable.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * 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 { confirmUninstallExtension } from "./confirm-uninstall-extension"; -import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable"; - -const confirmUninstallExtensionInjectable = getInjectable({ - id: "confirm-uninstall-extension", - - instantiate: (di) => - confirmUninstallExtension({ - uninstallExtension: di.inject(uninstallExtensionInjectable), - }), -}); - -export default confirmUninstallExtensionInjectable; diff --git a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx b/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx deleted file mode 100644 index 2954a2e8e6..0000000000 --- a/src/renderer/components/+extensions/confirm-uninstall-extension/confirm-uninstall-extension.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import React from "react"; -import type { InstalledExtension } from "../../../../extensions/extension-discovery/extension-discovery"; -import type { LensExtensionId } from "../../../../extensions/lens-extension"; -import { extensionDisplayName } from "../../../../extensions/lens-extension"; -import { ConfirmDialog } from "../../confirm-dialog"; - -interface Dependencies { - uninstallExtension: (id: LensExtensionId) => Promise; -} - -export const confirmUninstallExtension = - ({ uninstallExtension }: Dependencies) => - async (extension: InstalledExtension): Promise => { - const displayName = extensionDisplayName( - extension.manifest.name, - extension.manifest.version, - ); - const confirmed = await ConfirmDialog.confirm({ - message: ( -

- Are you sure you want to uninstall extension {displayName}? -

- ), - labelOk: "Yes", - labelCancel: "No", - }); - - if (confirmed) { - await uninstallExtension(extension.id); - } - }; diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 21a96eb873..6bcc068f5f 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -26,7 +26,8 @@ import { withInjectables } from "@ogre-tools/injectable-react"; import userExtensionsInjectable from "./user-extensions/user-extensions.injectable"; import enableExtensionInjectable from "./enable-extension/enable-extension.injectable"; import disableExtensionInjectable from "./disable-extension/disable-extension.injectable"; -import confirmUninstallExtensionInjectable from "./confirm-uninstall-extension/confirm-uninstall-extension.injectable"; +import type { ConfirmUninstallExtension } from "./confirm-uninstall-extension.injectable"; +import confirmUninstallExtensionInjectable from "./confirm-uninstall-extension.injectable"; import installFromInputInjectable from "./install-from-input/install-from-input.injectable"; import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable"; import type { LensExtensionId } from "../../../extensions/lens-extension"; @@ -39,7 +40,7 @@ interface Dependencies { userExtensions: IComputedValue; enableExtension: (id: LensExtensionId) => void; disableExtension: (id: LensExtensionId) => void; - confirmUninstallExtension: (extension: InstalledExtension) => Promise; + confirmUninstallExtension: ConfirmUninstallExtension; installFromInput: (input: string) => Promise; installFromSelectFileDialog: () => Promise; installOnDrop: (files: File[]) => Promise; diff --git a/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts b/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts index 36596cd1ff..f5417758e8 100644 --- a/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts +++ b/src/renderer/components/+extensions/install-from-input/install-from-input.injectable.ts @@ -5,7 +5,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; import { installFromInput } from "./install-from-input"; -import attemptInstallByInfoInjectable from "../attempt-install-by-info/attempt-install-by-info.injectable"; +import attemptInstallByInfoInjectable from "../attempt-install-by-info.injectable"; import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; diff --git a/src/renderer/components/+extensions/install-from-input/install-from-input.tsx b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx index 9fd3442a29..90d20ea7a4 100644 --- a/src/renderer/components/+extensions/install-from-input/install-from-input.tsx +++ b/src/renderer/components/+extensions/install-from-input/install-from-input.tsx @@ -12,7 +12,7 @@ import path from "path"; import React from "react"; import { readFileNotify } from "../read-file-notify/read-file-notify"; import type { InstallRequest } from "../attempt-install/install-request"; -import type { ExtensionInfo } from "../attempt-install-by-info/attempt-install-by-info"; +import type { ExtensionInfo } from "../attempt-install-by-info.injectable"; import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; interface Dependencies { diff --git a/src/renderer/components/+user-management/+cluster-role-bindings/details.tsx b/src/renderer/components/+user-management/+cluster-role-bindings/details.tsx index 30704e5166..3ee31a9301 100644 --- a/src/renderer/components/+user-management/+cluster-role-bindings/details.tsx +++ b/src/renderer/components/+user-management/+cluster-role-bindings/details.tsx @@ -12,7 +12,6 @@ import React from "react"; import type { ClusterRoleBinding, ClusterRoleBindingSubject } from "../../../../common/k8s-api/endpoints"; import { autoBind, ObservableHashSet, prevDefault } from "../../../utils"; import { AddRemoveButtons } from "../../add-remove-buttons"; -import { ConfirmDialog } from "../../confirm-dialog"; import { DrawerTitle } from "../../drawer"; import type { KubeObjectDetailsProps } from "../../kube-object-details"; import { KubeObjectMeta } from "../../kube-object-meta"; @@ -20,15 +19,22 @@ import { Table, TableCell, TableHead, TableRow } from "../../table"; import { ClusterRoleBindingDialog } from "./dialog"; import { clusterRoleBindingsStore } from "./store"; import { hashClusterRoleBindingSubject } from "./hashers"; +import type { OpenConfirmDialog } from "../../confirm-dialog/open.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import openConfirmDialogInjectable from "../../confirm-dialog/open.injectable"; export interface ClusterRoleBindingDetailsProps extends KubeObjectDetailsProps { } +interface Dependencies { + openConfirmDialog: OpenConfirmDialog; +} + @observer -export class ClusterRoleBindingDetails extends React.Component { +class NonInjectedClusterRoleBindingDetails extends React.Component { selectedSubjects = new ObservableHashSet([], hashClusterRoleBindingSubject); - constructor(props: ClusterRoleBindingDetailsProps) { + constructor(props: ClusterRoleBindingDetailsProps & Dependencies) { super(props); autoBind(this); } @@ -42,10 +48,10 @@ export class ClusterRoleBindingDetails extends React.Component clusterRoleBindingsStore.removeSubjects(clusterRoleBinding, selectedSubjects), labelOk: `Remove`, message: ( @@ -123,3 +129,10 @@ export class ClusterRoleBindingDetails extends React.Component(NonInjectedClusterRoleBindingDetails, { + getProps: (di, props) => ({ + ...props, + openConfirmDialog: di.inject(openConfirmDialogInjectable), + }), +}); diff --git a/src/renderer/components/+user-management/+role-bindings/details.tsx b/src/renderer/components/+user-management/+role-bindings/details.tsx index 3fec19e8fb..76a0cc685f 100644 --- a/src/renderer/components/+user-management/+role-bindings/details.tsx +++ b/src/renderer/components/+user-management/+role-bindings/details.tsx @@ -11,7 +11,6 @@ import React from "react"; import type { RoleBinding, RoleBindingSubject } from "../../../../common/k8s-api/endpoints"; import { prevDefault } from "../../../utils"; import { AddRemoveButtons } from "../../add-remove-buttons"; -import { ConfirmDialog } from "../../confirm-dialog"; import { DrawerTitle } from "../../drawer"; import type { KubeObjectDetailsProps } from "../../kube-object-details"; import { KubeObjectMeta } from "../../kube-object-meta"; @@ -20,12 +19,19 @@ import { RoleBindingDialog } from "./dialog"; import { roleBindingsStore } from "./store"; import { ObservableHashSet } from "../../../../common/utils/hash-set"; import { hashRoleBindingSubject } from "./hashers"; +import type { OpenConfirmDialog } from "../../confirm-dialog/open.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import openConfirmDialogInjectable from "../../confirm-dialog/open.injectable"; export interface RoleBindingDetailsProps extends KubeObjectDetailsProps { } +interface Dependencies { + openConfirmDialog: OpenConfirmDialog; +} + @observer -export class RoleBindingDetails extends React.Component { +class NonInjectedRoleBindingDetails extends React.Component { selectedSubjects = new ObservableHashSet([], hashRoleBindingSubject); async componentDidMount() { @@ -37,10 +43,10 @@ export class RoleBindingDetails extends React.Component } removeSelectedSubjects = () => { - const { object: roleBinding } = this.props; + const { object: roleBinding, openConfirmDialog } = this.props; const { selectedSubjects } = this; - ConfirmDialog.open({ + openConfirmDialog({ ok: () => roleBindingsStore.removeSubjects(roleBinding, selectedSubjects.toJSON()), labelOk: `Remove`, message: ( @@ -118,3 +124,10 @@ export class RoleBindingDetails extends React.Component ); } } + +export const RoleBindingDetails = withInjectables(NonInjectedRoleBindingDetails, { + getProps: (di, props) => ({ + ...props, + openConfirmDialog: di.inject(openConfirmDialogInjectable), + }), +}); diff --git a/src/renderer/components/+workloads-cronjobs/cron-job-menu.tsx b/src/renderer/components/+workloads-cronjobs/cron-job-menu.tsx index d242b28d40..f20245f47f 100644 --- a/src/renderer/components/+workloads-cronjobs/cron-job-menu.tsx +++ b/src/renderer/components/+workloads-cronjobs/cron-job-menu.tsx @@ -9,56 +9,73 @@ import { cronJobApi } from "../../../common/k8s-api/endpoints"; import { MenuItem } from "../menu"; import { CronJobTriggerDialog } from "./cronjob-trigger-dialog"; import { Icon } from "../icon"; -import { ConfirmDialog } from "../confirm-dialog"; import { Notifications } from "../notifications"; +import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; -export function CronJobMenu(props: KubeObjectMenuProps) { - const { object, toolbar } = props; +export interface CronJobMenuProps extends KubeObjectMenuProps {} - return ( - <> - CronJobTriggerDialog.open(object)}> - - Trigger +interface Dependencies { + openConfirmDialog: OpenConfirmDialog; +} + +const NonInjectedCronJobMenu = ({ + object, + toolbar, + openConfirmDialog, +}: Dependencies & CronJobMenuProps) => ( + <> + CronJobTriggerDialog.open(object)}> + + Trigger + + + {object.isSuspend() ? + openConfirmDialog({ + ok: async () => { + try { + await cronJobApi.resume({ namespace: object.getNs(), name: object.getName() }); + } catch (err) { + Notifications.error(err); + } + }, + labelOk: `Resume`, + message: ( +

+ Resume CronJob {object.getName()}? +

+ ), + })}> + + Resume
- {object.isSuspend() ? - ConfirmDialog.open({ - ok: async () => { - try { - await cronJobApi.resume({ namespace: object.getNs(), name: object.getName() }); - } catch (err) { - Notifications.error(err); - } - }, - labelOk: `Resume`, - message: ( -

- Resume CronJob {object.getName()}? -

), - })}> - - Resume -
+ : openConfirmDialog({ + ok: async () => { + try { + await cronJobApi.suspend({ namespace: object.getNs(), name: object.getName() }); + } catch (err) { + Notifications.error(err); + } + }, + labelOk: `Suspend`, + message: ( +

+ Suspend CronJob {object.getName()}? +

+ ), + })}> + + Suspend +
+ } + +); - : ConfirmDialog.open({ - ok: async () => { - try { - await cronJobApi.suspend({ namespace: object.getNs(), name: object.getName() }); - } catch (err) { - Notifications.error(err); - } - }, - labelOk: `Suspend`, - message: ( -

- Suspend CronJob {object.getName()}? -

), - })}> - - Suspend -
- } - - ); -} +export const CronJobMenu = withInjectables(NonInjectedCronJobMenu, { + getProps: (di, props) => ({ + ...props, + openConfirmDialog: di.inject(openConfirmDialogInjectable), + }), +}); diff --git a/src/renderer/components/+workloads-deployments/deployment-menu.tsx b/src/renderer/components/+workloads-deployments/deployment-menu.tsx index f4e1e5572a..b98ca7927c 100644 --- a/src/renderer/components/+workloads-deployments/deployment-menu.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-menu.tsx @@ -9,40 +9,54 @@ import { deploymentApi } from "../../../common/k8s-api/endpoints"; import { MenuItem } from "../menu"; import { DeploymentScaleDialog } from "./deployment-scale-dialog"; import { Icon } from "../icon"; -import { ConfirmDialog } from "../confirm-dialog"; import { Notifications } from "../notifications"; +import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; -export function DeploymentMenu(props: KubeObjectMenuProps) { - const { object, toolbar } = props; +export interface DeploymentMenuProps extends KubeObjectMenuProps {} - return ( - <> - DeploymentScaleDialog.open(object)}> - - Scale - - ConfirmDialog.open({ - ok: async () => - { - try { - await deploymentApi.restart({ - namespace: object.getNs(), - name: object.getName(), - }); - } catch (err) { - Notifications.error(err); - } - }, - labelOk: `Restart`, - message: ( -

- Are you sure you want to restart deployment {object.getName()}? -

- ), - })}> - - Restart -
- - ); +interface Dependencies { + openConfirmDialog: OpenConfirmDialog; } + +const NonInjectedDeploymentMenu = ({ + object, + toolbar, + openConfirmDialog, +}: Dependencies & DeploymentMenuProps) => ( + <> + DeploymentScaleDialog.open(object)}> + + Scale + + openConfirmDialog({ + ok: async () => { + try { + await deploymentApi.restart({ + namespace: object.getNs(), + name: object.getName(), + }); + } catch (err) { + Notifications.error(err); + } + }, + labelOk: `Restart`, + message: ( +

+ Are you sure you want to restart deployment {object.getName()}? +

+ ), + })}> + + Restart +
+ +); + +export const DeploymentMenu = withInjectables(NonInjectedDeploymentMenu, { + getProps: (di, props) => ({ + ...props, + openConfirmDialog: di.inject(openConfirmDialogInjectable), + }), +}); diff --git a/src/renderer/components/+workloads-statefulsets/stateful-set-menu.tsx b/src/renderer/components/+workloads-statefulsets/stateful-set-menu.tsx deleted file mode 100644 index b9725f6e13..0000000000 --- a/src/renderer/components/+workloads-statefulsets/stateful-set-menu.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import React from "react"; -import type { KubeObjectMenuProps } from "../kube-object-menu"; -import type { StatefulSet } from "../../../common/k8s-api/endpoints"; -import { MenuItem } from "../menu"; -import { StatefulSetScaleDialog } from "./statefulset-scale-dialog"; -import { Icon } from "../icon"; - -export function StatefulSetMenu(props: KubeObjectMenuProps) { - const { object, toolbar } = props; - - return ( - <> - StatefulSetScaleDialog.open(object)}> - - Scale - - - ); -} diff --git a/src/renderer/components/animate/animate.tsx b/src/renderer/components/animate/animate.tsx index df160883fb..febb134cb9 100644 --- a/src/renderer/components/animate/animate.tsx +++ b/src/renderer/components/animate/animate.tsx @@ -5,8 +5,8 @@ import "./animate.scss"; import React from "react"; -import { observable, reaction, makeObservable } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; +import { observable, makeObservable } from "mobx"; +import { observer } from "mobx-react"; import { cssNames, noop } from "../../utils"; export type AnimateName = "opacity" | "slide-right" | "opacity-scale" | string; @@ -46,15 +46,24 @@ export class Animate extends React.Component { return React.Children.only(this.props.children) as React.ReactElement>; } + private toggle(enter: boolean) { + if (enter) { + this.enter(); + } else { + this.leave(); + } + } + componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.enter, enter => { - if (enter) this.enter(); - else this.leave(); - }, { - fireImmediately: true, - }), - ]); + this.toggle(this.props.enter); + } + + componentDidUpdate(prevProps: Readonly): void { + const { enter } = this.props; + + if (prevProps.enter !== enter) { + this.toggle(enter); + } } enter() { diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.tsx b/src/renderer/components/confirm-dialog/confirm-dialog.tsx index 191c719bcc..5ce655de54 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.tsx +++ b/src/renderer/components/confirm-dialog/confirm-dialog.tsx @@ -7,7 +7,8 @@ import "./confirm-dialog.scss"; import type { ReactNode } from "react"; import React from "react"; -import { observable, makeObservable } from "mobx"; +import type { IObservableValue } from "mobx"; +import { observable, makeObservable, computed } from "mobx"; import { observer } from "mobx-react"; import { cssNames, noop, prevDefault } from "../../utils"; import type { ButtonProps } from "../button"; @@ -16,6 +17,8 @@ import type { DialogProps } from "../dialog"; import { Dialog } from "../dialog"; import { Icon } from "../icon"; import { Notifications } from "../notifications"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import confirmDialogStateInjectable from "./state.injectable"; export interface ConfirmDialogProps extends Partial { } @@ -34,45 +37,30 @@ export interface ConfirmDialogBooleanParams { cancelButtonProps?: Partial; } -const dialogState = observable.object({ - isOpen: false, - params: null as ConfirmDialogParams, -}); +interface Dependencies { + state: IObservableValue; +} + +const defaultParams: Partial = { + ok: noop, + cancel: noop, + labelOk: "Ok", + labelCancel: "Cancel", + icon: , +}; @observer -export class ConfirmDialog extends React.Component { +class NonInjectedConfirmDialog extends React.Component { @observable isSaving = false; - constructor(props: ConfirmDialogProps) { + constructor(props: ConfirmDialogProps & Dependencies) { super(props); makeObservable(this); } - static open(params: ConfirmDialogParams) { - dialogState.isOpen = true; - dialogState.params = params; - } - - static confirm(params: ConfirmDialogBooleanParams): Promise { - return new Promise(resolve => { - ConfirmDialog.open({ - ok: () => resolve(true), - cancel: () => resolve(false), - ...params, - }); - }); - } - - static defaultParams: Partial = { - ok: noop, - cancel: noop, - labelOk: "Ok", - labelCancel: "Cancel", - icon: , - }; - - get params(): ConfirmDialogParams { - return Object.assign({}, ConfirmDialog.defaultParams, dialogState.params); + @computed + get params() { + return Object.assign({}, defaultParams, this.props.state.get() ?? {}); } ok = async () => { @@ -88,7 +76,7 @@ export class ConfirmDialog extends React.Component { ); } finally { this.isSaving = false; - dialogState.isOpen = false; + this.props.state.set(undefined); } }; @@ -108,12 +96,14 @@ export class ConfirmDialog extends React.Component { ); } finally { this.isSaving = false; - dialogState.isOpen = false; + this.props.state.set(undefined); } }; render() { - const { className, ...dialogProps } = this.props; + const { state, className, ...dialogProps } = this.props; + const dialogState = state.get(); + const isOpen = Boolean(dialogState); const { icon, labelOk, labelCancel, message, okButtonProps = {}, @@ -124,10 +114,10 @@ export class ConfirmDialog extends React.Component {
{icon} {message} @@ -154,3 +144,10 @@ export class ConfirmDialog extends React.Component { ); } } + +export const ConfirmDialog = withInjectables(NonInjectedConfirmDialog, { + getProps: (di, props) => ({ + ...props, + state: di.inject(confirmDialogStateInjectable), + }), +}); diff --git a/src/renderer/components/confirm-dialog/confirm.injectable.ts b/src/renderer/components/confirm-dialog/confirm.injectable.ts new file mode 100644 index 0000000000..ac7be031bf --- /dev/null +++ b/src/renderer/components/confirm-dialog/confirm.injectable.ts @@ -0,0 +1,26 @@ +/** + * 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 type { ConfirmDialogBooleanParams } from "./confirm-dialog"; +import openConfirmDialogInjectable from "./open.injectable"; + +export type Confirm = (params: ConfirmDialogBooleanParams) => Promise; + +const confirmInjectable = getInjectable({ + id: "confirm", + instantiate: (di): Confirm => { + const open = di.inject(openConfirmDialogInjectable); + + return (params) => new Promise(resolve => { + open({ + ok: () => resolve(true), + cancel: () => resolve(false), + ...params, + }); + }); + }, +}); + +export default confirmInjectable; diff --git a/src/renderer/components/confirm-dialog/open.injectable.ts b/src/renderer/components/confirm-dialog/open.injectable.ts new file mode 100644 index 0000000000..5e8b806891 --- /dev/null +++ b/src/renderer/components/confirm-dialog/open.injectable.ts @@ -0,0 +1,20 @@ +/** + * 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 type { ConfirmDialogParams } from "./confirm-dialog"; +import confirmDialogStateInjectable from "./state.injectable"; + +export type OpenConfirmDialog = (params: ConfirmDialogParams) => void; + +const openConfirmDialogInjectable = getInjectable({ + id: "open-confirm-dialog", + instantiate: (di): OpenConfirmDialog => { + const state = di.inject(confirmDialogStateInjectable); + + return params => state.set(params); + }, +}); + +export default openConfirmDialogInjectable; diff --git a/src/renderer/components/confirm-dialog/state.injectable.ts b/src/renderer/components/confirm-dialog/state.injectable.ts new file mode 100644 index 0000000000..a5f1212cce --- /dev/null +++ b/src/renderer/components/confirm-dialog/state.injectable.ts @@ -0,0 +1,14 @@ +/** + * 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 { observable } from "mobx"; +import type { ConfirmDialogParams } from "."; + +const confirmDialogStateInjectable = getInjectable({ + id: "confirm-dialog-state", + instantiate: () => observable.box(), +}); + +export default confirmDialogStateInjectable; diff --git a/src/renderer/components/confirm-dialog/with-confirm.injectable.ts b/src/renderer/components/confirm-dialog/with-confirm.injectable.ts new file mode 100644 index 0000000000..364c9266de --- /dev/null +++ b/src/renderer/components/confirm-dialog/with-confirm.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ConfirmDialogParams } from "./confirm-dialog"; +import { getInjectable } from "@ogre-tools/injectable"; +import openConfirmDialogInjectable from "./open.injectable"; + +export type WithConfirmation = (params: ConfirmDialogParams) => () => void; + +const withConfirmationInjectable = getInjectable({ + id: "with-confirmation", + instantiate: (di): WithConfirmation => { + const open = di.inject(openConfirmDialogInjectable); + + return (params) => () => open(params); + }, +}); + +export default withConfirmationInjectable; diff --git a/src/renderer/components/drawer/drawer.tsx b/src/renderer/components/drawer/drawer.tsx index c1a3283b20..e770ec8a4b 100644 --- a/src/renderer/components/drawer/drawer.tsx +++ b/src/renderer/components/drawer/drawer.tsx @@ -199,7 +199,7 @@ class NonInjectedDrawer extends React.Component {toolbar} - +
{ - menuItem.onClick(); - }, - message: menuItem.confirm.message, - }); - } else { - menuItem.onClick(); - } +interface Dependencies { + normalizeMenuItem: NormalizeCatalogEntityContextMenu; } -export const HotbarIcon = observer(({ menuItems = [], size = 40, tooltip, ...props }: HotbarIconProps) => { +const NonInjectedHotbarIcon = observer(({ + menuItems = [], + size = 40, + tooltip, + normalizeMenuItem, + ...props +}: HotbarIconProps & Dependencies) => { const { uid, title, src, material, active, className, source, disabled, onMenuOpen, onClick, children, ...rest } = props; const id = `hotbarIcon-${uid}`; const [menuOpen, setMenuOpen] = useState(false); @@ -83,13 +78,22 @@ export const HotbarIcon = observer(({ menuItems = [], size = 40, tooltip, ...pro }} close={() => toggleMenu()}> { - menuItems.map((menuItem) => ( - onMenuItemClick(menuItem)}> - {menuItem.title} - - )) + menuItems + .map(normalizeMenuItem) + .map((menuItem) => ( + + {menuItem.title} + + )) }
); }); + +export const HotbarIcon = withInjectables(NonInjectedHotbarIcon, { + getProps: (di, props) => ({ + ...props, + normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable), + }), +}); diff --git a/src/renderer/components/hotbar/hotbar-remove-command.tsx b/src/renderer/components/hotbar/hotbar-remove-command.tsx index 27189bdebb..f7bdbbe481 100644 --- a/src/renderer/components/hotbar/hotbar-remove-command.tsx +++ b/src/renderer/components/hotbar/hotbar-remove-command.tsx @@ -7,13 +7,15 @@ import React from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; -import { ConfirmDialog } from "../confirm-dialog"; import { withInjectables } from "@ogre-tools/injectable-react"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import type { Hotbar } from "../../../common/hotbar-types"; +import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable"; +import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; interface Dependencies { closeCommandOverlay: () => void; + openConfirmDialog: OpenConfirmDialog; hotbarStore: { hotbars: Hotbar[]; getById: (id: string) => Hotbar | undefined; @@ -22,7 +24,11 @@ interface Dependencies { }; } -const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarStore }: Dependencies) => { +const NonInjectedHotbarRemoveCommand = observer(({ + closeCommandOverlay, + hotbarStore, + openConfirmDialog, +}: Dependencies) => { const options = hotbarStore.hotbars.map(hotbar => ({ value: hotbar.id, label: hotbarStore.getDisplayLabel(hotbar), @@ -36,8 +42,7 @@ const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarSt } closeCommandOverlay(); - // TODO: make confirm dialog injectable - ConfirmDialog.open({ + openConfirmDialog({ okButtonProps: { label: "Remove Hotbar", primary: false, @@ -71,8 +76,9 @@ const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarSt export const HotbarRemoveCommand = withInjectables(NonInjectedHotbarRemoveCommand, { getProps: (di, props) => ({ + ...props, closeCommandOverlay: di.inject(commandOverlayInjectable).close, hotbarStore: di.inject(hotbarStoreInjectable), - ...props, + openConfirmDialog: di.inject(openConfirmDialogInjectable), }), }); diff --git a/src/renderer/components/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index fcd5bba17d..ac5ec07bca 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -15,22 +15,69 @@ import { withTooltip } from "../tooltip"; import isNumber from "lodash/isNumber"; import { decode } from "../../../common/utils/base64"; -export interface IconProps extends React.HTMLAttributes, TooltipDecoratorProps { - material?: string; // material-icon, see available names at https://material.io/icons/ - svg?: string; // svg-filename without extension in current folder - link?: LocationDescriptor; // render icon as NavLink from react-router-dom - href?: string; // render icon as hyperlink - size?: string | number; // icon-size - small?: boolean; // pre-defined icon-size - smallest?: boolean; // pre-defined icon-size - big?: boolean; // pre-defined icon-size - active?: boolean; // apply active-state styles - interactive?: boolean; // indicates that icon is interactive and highlight it on focus/hover - focusable?: boolean; // allow focus to the icon + show .active styles (default: "true", when icon is interactive) +export interface BaseIconProps { + /** + * One of the names from https://material.io/icons/ + */ + material?: string; + + /** + * Either an SVG data URL or one of the following strings + */ + svg?: string; + + /** + * render icon as NavLink from react-router-dom + */ + link?: LocationDescriptor; + + /** + * render icon as hyperlink + */ + href?: string; + + /** + * The icon size (css units) + */ + size?: string | number; + + /** + * A pre-defined icon-size + */ + small?: boolean; + + /** + * A pre-defined icon-size + */ + smallest?: boolean; + + /** + * A pre-defined icon-size + */ + big?: boolean; + + /** + * apply active-state styles + */ + active?: boolean; + + /** + * indicates that icon is interactive and highlight it on focus/hover + */ + interactive?: boolean; + + /** + * Allow focus to the icon to show `.active` styles. Only applicable if {@link IconProps.interactive} is `true`. + * + * @default true + */ + focusable?: boolean; sticker?: boolean; disabled?: boolean; } +export interface IconProps extends React.HTMLAttributes, TooltipDecoratorProps, BaseIconProps {} + @withTooltip export class Icon extends React.PureComponent { private readonly ref = createRef(); diff --git a/src/renderer/components/item-object-list/content.tsx b/src/renderer/components/item-object-list/content.tsx index 190884df42..97a05ab219 100644 --- a/src/renderer/components/item-object-list/content.tsx +++ b/src/renderer/components/item-object-list/content.tsx @@ -10,7 +10,6 @@ import React from "react"; import { computed, makeObservable } from "mobx"; import { Observer, observer } from "mobx-react"; import type { ConfirmDialogParams } from "../confirm-dialog"; -import { ConfirmDialog } from "../confirm-dialog"; import type { TableCellProps, TableProps, TableRowProps, TableSortCallbacks } from "../table"; import { Table, TableCell, TableHead, TableRow } from "../table"; import type { IClassName } from "../../utils"; @@ -27,6 +26,9 @@ import { MenuActions } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Checkbox } from "../checkbox"; import { UserStore } from "../../../common/user-store"; +import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; export interface ItemListLayoutContentProps { getFilters: () => Filter[]; @@ -63,9 +65,13 @@ export interface ItemListLayoutContentProps { failedToLoadMessage?: React.ReactNode; } +interface Dependencies { + openConfirmDialog: OpenConfirmDialog; +} + @observer -export class ItemListLayoutContent extends React.Component> { - constructor(props: ItemListLayoutContentProps) { +class NonInjectedItemListLayoutContent extends React.Component & Dependencies> { + constructor(props: ItemListLayoutContentProps & Dependencies) { super(props); makeObservable(this); autoBind(this); @@ -150,7 +156,7 @@ export class ItemListLayoutContent extends React.Component } removeItemsDialog(selectedItems: I[]) { - const { customizeRemoveDialog, store } = this.props; + const { customizeRemoveDialog, store, openConfirmDialog } = this.props; const visibleMaxNamesCount = 5; const selectedNames = selectedItems.map(ns => ns.getName()).slice(0, visibleMaxNamesCount).join(", "); const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {}; @@ -168,7 +174,7 @@ export class ItemListLayoutContent extends React.Component ? () => store.removeItems(selectedItems) : store.removeSelectedItems; - ConfirmDialog.open({ + openConfirmDialog({ ok: onConfirm, labelOk: "Remove", message, @@ -315,3 +321,10 @@ export class ItemListLayoutContent extends React.Component ); } } + +export const ItemListLayoutContent = withInjectables>(NonInjectedItemListLayoutContent, { + getProps: (di, props) => ({ + ...props, + openConfirmDialog: di.inject(openConfirmDialogInjectable), + }), +}) as (props: ItemListLayoutContentProps) => React.ReactElement; diff --git a/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap index b0200f73d5..2d33cccf7a 100644 --- a/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap +++ b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap @@ -5,15 +5,14 @@ exports[`kube-object-menu given kube object renders 1`] = `