1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Add ability to hide KubeObjectMenu Edit and Remove buttons in extensions (#5107)

This commit is contained in:
Sebastian Malton 2022-05-04 09:44:31 -07:00 committed by GitHub
parent e532b90b72
commit dbdde19222
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1245 additions and 765 deletions

View File

@ -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 { export interface CatalogEntityContextMenuContext {
/** /**
* Navigate to the specified pathname * Navigate to the specified pathname
*/ */
navigate: (pathname: string) => void; navigate: CatalogEntityContextMenuNavigate;
menuItems: CatalogEntityContextMenu[]; menuItems: CatalogEntityContextMenu[];
} }

View File

@ -32,6 +32,7 @@ export * from "./objects";
export * from "./openBrowser"; export * from "./openBrowser";
export * from "./paths"; export * from "./paths";
export * from "./promise-exec"; export * from "./promise-exec";
export * from "./readonly";
export * from "./reject-promise"; export * from "./reject-promise";
export * from "./singleton"; export * from "./singleton";
export * from "./sort-compare"; export * from "./sort-compare";

View File

@ -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<T>(src: T): ReadonlyDeep<T> {
return src as ReadonlyDeep<T>;
}

View File

@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * 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 { 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 { AppPreferenceRegistration, AppPreferenceComponents } from "../../renderer/components/+preferences/app-preferences/app-preference-registration";
export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry"; export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry";
export type { KubeObjectStatusRegistration } from "../../renderer/components/kube-object-status-icon/kube-object-status-registration"; 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 { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler";
export type { CustomCategoryViewProps, CustomCategoryViewComponents, CustomCategoryViewRegistration } from "../../renderer/components/+catalog/custom-views"; 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 { 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";

View File

@ -20,7 +20,7 @@ import type { AppPreferenceRegistration } from "../renderer/components/+preferen
import type { AdditionalCategoryColumnRegistration } from "../renderer/components/+catalog/custom-category-columns"; import type { AdditionalCategoryColumnRegistration } from "../renderer/components/+catalog/custom-category-columns";
import type { CustomCategoryViewRegistration } from "../renderer/components/+catalog/custom-views"; import type { CustomCategoryViewRegistration } from "../renderer/components/+catalog/custom-views";
import type { StatusBarRegistration } from "../renderer/components/status-bar/status-bar-registration"; 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 { 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 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"; 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 { pipeline } from "@ogre-tools/fp";
import { getExtensionRoutePath } from "../renderer/routes/get-extension-route-path"; import { getExtensionRoutePath } from "../renderer/routes/get-extension-route-path";
import { navigateToRouteInjectionToken } from "../common/front-end-routing/navigate-to-route-injection-token"; 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 { export class LensRendererExtension extends LensExtension {
globalPages: registries.PageRegistration[] = []; globalPages: registries.PageRegistration[] = [];
@ -49,6 +50,7 @@ export class LensRendererExtension extends LensExtension {
topBarItems: TopBarRegistration[] = []; topBarItems: TopBarRegistration[] = [];
additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = []; additionalCategoryColumns: AdditionalCategoryColumnRegistration[] = [];
customCategoryViews: CustomCategoryViewRegistration[] = []; customCategoryViews: CustomCategoryViewRegistration[] = [];
kubeObjectHandlers: KubeObjectHandlerRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) { async navigate<P extends object>(pageId?: string, params?: P) {
const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi( const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(

View File

@ -15,6 +15,10 @@ import sendCommandInjectable from "../../renderer/components/dock/terminal/send-
import { podsStore } from "../../renderer/components/+workloads-pods/pods.store"; import { podsStore } from "../../renderer/components/+workloads-pods/pods.store";
import renameTabInjectable from "../../renderer/components/dock/dock/rename-tab.injectable"; 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 { 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 // layouts
export * from "../../renderer/components/layout/main-layout"; export * from "../../renderer/components/layout/main-layout";
@ -43,6 +47,16 @@ export type {
} from "../../renderer/components/+catalog/custom-category-columns"; } from "../../renderer/components/+catalog/custom-category-columns";
// other components // 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/icon";
export * from "../../renderer/components/tooltip"; export * from "../../renderer/components/tooltip";
export * from "../../renderer/components/tabs"; export * from "../../renderer/components/tabs";
@ -50,7 +64,6 @@ export * from "../../renderer/components/table";
export * from "../../renderer/components/badge"; export * from "../../renderer/components/badge";
export * from "../../renderer/components/drawer"; export * from "../../renderer/components/drawer";
export * from "../../renderer/components/dialog"; export * from "../../renderer/components/dialog";
export * from "../../renderer/components/confirm-dialog";
export * from "../../renderer/components/line-progress"; export * from "../../renderer/components/line-progress";
export * from "../../renderer/components/menu"; export * from "../../renderer/components/menu";
export * from "../../renderer/components/notifications"; export * from "../../renderer/components/notifications";

View File

@ -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;

View File

@ -7,24 +7,32 @@ import React from "react";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import type { MenuActionsProps } from "../menu/menu-actions"; import type { MenuActionsProps } from "../menu/menu-actions";
import { MenuActions } 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 { observer } from "mobx-react";
import { makeObservable, observable } from "mobx"; import { makeObservable, observable } from "mobx";
import { navigate } from "../../navigation";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item"; 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<T extends CatalogEntity> extends MenuActionsProps { export interface CatalogEntityDrawerMenuProps<T extends CatalogEntity> extends MenuActionsProps {
entity: T; entity: T;
} }
interface Dependencies {
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
navigate: Navigate;
}
@observer @observer
export class CatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Component<CatalogEntityDrawerMenuProps<T>> { class NonInjectedCatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Component<CatalogEntityDrawerMenuProps<T> & Dependencies> {
@observable private contextMenu: CatalogEntityContextMenuContext; @observable private contextMenu: CatalogEntityContextMenuContext;
constructor(props: CatalogEntityDrawerMenuProps<T>) { constructor(props: CatalogEntityDrawerMenuProps<T> & Dependencies) {
super(props); super(props);
makeObservable(this); makeObservable(this);
} }
@ -32,52 +40,28 @@ export class CatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Comp
componentDidMount() { componentDidMount() {
this.contextMenu = { this.contextMenu = {
menuItems: [], menuItems: [],
navigate: (url: string) => navigate(url), navigate: this.props.navigate,
}; };
this.props.entity?.onContextMenuOpen(this.contextMenu); 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[] { getMenuItems(entity: T): React.ReactChild[] {
if (!entity) { if (!entity) {
return []; return [];
} }
const items: React.ReactChild[] = []; const items = this.contextMenu.menuItems
.filter(menuItem => menuItem.icon)
for (const menuItem of this.contextMenu.menuItems) { .map(this.props.normalizeMenuItem)
if (!menuItem.icon) { .map(menuItem => (
continue; <MenuItem key={menuItem.title} onClick={menuItem.onClick}>
}
const key = Icon.isSvg(menuItem.icon) ? "svg" : "material";
items.push(
<MenuItem key={menuItem.title} onClick={() => this.onMenuItemClick(menuItem)}>
<Icon <Icon
interactive interactive
tooltip={menuItem.title} tooltip={menuItem.title}
{...{ [key]: menuItem.icon }} {...{ [Icon.isSvg(menuItem.icon) ? "svg" : "material"]: menuItem.icon }}
/> />
</MenuItem>, </MenuItem>
); ));
}
items.push( items.push(
<HotbarToggleMenuItem <HotbarToggleMenuItem
@ -109,3 +93,11 @@ export class CatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Comp
); );
} }
} }
export const CatalogEntityDrawerMenu = withInjectables<Dependencies, CatalogEntityDrawerMenuProps<CatalogEntity>>(NonInjectedCatalogEntityDrawerMenu, {
getProps: (di, props) => ({
...props,
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
navigate: di.inject(navigateInjectable),
}),
}) as <Entity extends CatalogEntity>(props: CatalogEntityDrawerMenuProps<Entity>) => React.ReactElement;

View File

@ -11,10 +11,9 @@ import { ItemListLayout } from "../item-object-list";
import type { IComputedValue } from "mobx"; import type { IComputedValue } from "mobx";
import { action, computed, makeObservable, observable, reaction, runInAction, when } from "mobx"; import { action, computed, makeObservable, observable, reaction, runInAction, when } from "mobx";
import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store";
import { navigate } from "../../navigation";
import { MenuItem, MenuActions } from "../menu"; import { MenuItem, MenuActions } from "../menu";
import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; import type { CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import { ConfirmDialog } from "../confirm-dialog"; import type { HotbarStore } from "../../../common/hotbar-store";
import type { CatalogEntity } from "../../../common/catalog"; import type { CatalogEntity } from "../../../common/catalog";
import { catalogCategoryRegistry } from "../../../common/catalog"; import { catalogCategoryRegistry } from "../../../common/catalog";
import { CatalogAddButton } from "./catalog-add-button"; 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 type { AppEvent } from "../../../common/app-event-bus/event-bus";
import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable"; import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable";
import hotbarStoreInjectable from "../../../common/hotbar-store.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 { interface Dependencies {
catalogPreviousActiveTabStorage: { set: (value: string ) => void; get: () => string }; catalogPreviousActiveTabStorage: { set: (value: string ) => void; get: () => string };
@ -50,14 +52,14 @@ interface Dependencies {
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns; getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>; customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>;
emitEvent: (event: AppEvent) => void; emitEvent: (event: AppEvent) => void;
routeParameters: { routeParameters: {
group: IComputedValue<string>; group: IComputedValue<string>;
kind: IComputedValue<string>; kind: IComputedValue<string>;
}; };
navigateToCatalog: NavigateToCatalog; navigateToCatalog: NavigateToCatalog;
hotbarStore: HotbarStore; hotbarStore: HotbarStore;
navigate: Navigate;
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
} }
@observer @observer
@ -93,7 +95,7 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
async componentDidMount() { async componentDidMount() {
this.contextMenu = { this.contextMenu = {
menuItems: observable.array([]), menuItems: observable.array([]),
navigate: (url: string) => navigate(url), navigate: this.props.navigate,
}; };
disposeOnUnmount(this, [ disposeOnUnmount(this, [
this.props.catalogEntityStore.watch(), this.props.catalogEntityStore.watch(),
@ -149,23 +151,6 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
} }
}; };
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() { get categories() {
return catalogCategoryRegistry.items; return catalogCategoryRegistry.items;
} }
@ -209,8 +194,10 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
View Details View Details
</MenuItem> </MenuItem>
{ {
this.contextMenu.menuItems.map((menuItem, index) => ( this.contextMenu.menuItems
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}> .map(this.props.normalizeMenuItem)
.map((menuItem, index) => (
<MenuItem key={index} onClick={menuItem.onClick}>
{menuItem.title} {menuItem.title}
</MenuItem> </MenuItem>
)) ))
@ -342,8 +329,9 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
} }
} }
export const Catalog = withInjectables<Dependencies>( NonInjectedCatalog, { export const Catalog = withInjectables<Dependencies>(NonInjectedCatalog, {
getProps: (di) => ({ getProps: (di, props) => ({
...props,
catalogEntityStore: di.inject(catalogEntityStoreInjectable), catalogEntityStore: di.inject(catalogEntityStoreInjectable),
catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable), catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable),
getCategoryColumns: di.inject(getCategoryColumnsInjectable), getCategoryColumns: di.inject(getCategoryColumnsInjectable),
@ -352,5 +340,7 @@ export const Catalog = withInjectables<Dependencies>( NonInjectedCatalog, {
navigateToCatalog: di.inject(navigateToCatalogInjectable), navigateToCatalog: di.inject(navigateToCatalogInjectable),
emitEvent: di.inject(appEventBusInjectable).emit, emitEvent: di.inject(appEventBusInjectable).emit,
hotbarStore: di.inject(hotbarStoreInjectable), hotbarStore: di.inject(hotbarStoreInjectable),
navigate: di.inject(navigateInjectable),
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
}), }),
}); });

View File

@ -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<void>;
interface Dependencies {
attemptInstall: (request: InstallRequest, d: ExtendableDisposer) => Promise<void>;
getBaseRegistryUrl: () => Promise<string>;
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((
<p>
The <em>{name}</em> extension does not have a version or tag <code>{version}</code>.
</p>
));
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: (
<p>
Are you sure you want to install{" "}
<b>
{name}@{finalVersion}
</b>
?
</p>
),
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;

View File

@ -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;

View File

@ -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<void>;
getBaseRegistryUrl: () => Promise<string>;
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(
<p>
The <em>{name}</em> extension does not have a version or tag{" "}
<code>{version}</code>.
</p>,
);
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: (
<p>
Are you sure you want to install{" "}
<b>
{name}@{version}
</b>
?
</p>
),
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);
};

View File

@ -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<boolean>;
confirm: Confirm;
}
export type ConfirmUninstallExtension = (ext: InstalledExtension) => Promise<void>;
const confirmUninstallExtension = ({
uninstallExtension,
confirm,
}: Dependencies): ConfirmUninstallExtension => (
async (extension) => {
const displayName = extensionDisplayName(
extension.manifest.name,
extension.manifest.version,
);
const confirmed = await confirm({
message: (
<p>
Are you sure you want to uninstall extension <b>{displayName}</b>?
</p>
),
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;

View File

@ -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;

View File

@ -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<boolean>;
}
export const confirmUninstallExtension =
({ uninstallExtension }: Dependencies) =>
async (extension: InstalledExtension): Promise<void> => {
const displayName = extensionDisplayName(
extension.manifest.name,
extension.manifest.version,
);
const confirmed = await ConfirmDialog.confirm({
message: (
<p>
Are you sure you want to uninstall extension <b>{displayName}</b>?
</p>
),
labelOk: "Yes",
labelCancel: "No",
});
if (confirmed) {
await uninstallExtension(extension.id);
}
};

View File

@ -26,7 +26,8 @@ import { withInjectables } from "@ogre-tools/injectable-react";
import userExtensionsInjectable from "./user-extensions/user-extensions.injectable"; import userExtensionsInjectable from "./user-extensions/user-extensions.injectable";
import enableExtensionInjectable from "./enable-extension/enable-extension.injectable"; import enableExtensionInjectable from "./enable-extension/enable-extension.injectable";
import disableExtensionInjectable from "./disable-extension/disable-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 installFromInputInjectable from "./install-from-input/install-from-input.injectable";
import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable"; import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable";
import type { LensExtensionId } from "../../../extensions/lens-extension"; import type { LensExtensionId } from "../../../extensions/lens-extension";
@ -39,7 +40,7 @@ interface Dependencies {
userExtensions: IComputedValue<InstalledExtension[]>; userExtensions: IComputedValue<InstalledExtension[]>;
enableExtension: (id: LensExtensionId) => void; enableExtension: (id: LensExtensionId) => void;
disableExtension: (id: LensExtensionId) => void; disableExtension: (id: LensExtensionId) => void;
confirmUninstallExtension: (extension: InstalledExtension) => Promise<void>; confirmUninstallExtension: ConfirmUninstallExtension;
installFromInput: (input: string) => Promise<void>; installFromInput: (input: string) => Promise<void>;
installFromSelectFileDialog: () => Promise<void>; installFromSelectFileDialog: () => Promise<void>;
installOnDrop: (files: File[]) => Promise<void>; installOnDrop: (files: File[]) => Promise<void>;

View File

@ -5,7 +5,7 @@
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; import attemptInstallInjectable from "../attempt-install/attempt-install.injectable";
import { installFromInput } from "./install-from-input"; 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 import extensionInstallationStateStoreInjectable
from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";

View File

@ -12,7 +12,7 @@ import path from "path";
import React from "react"; import React from "react";
import { readFileNotify } from "../read-file-notify/read-file-notify"; import { readFileNotify } from "../read-file-notify/read-file-notify";
import type { InstallRequest } from "../attempt-install/install-request"; 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"; import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
interface Dependencies { interface Dependencies {

View File

@ -12,7 +12,6 @@ import React from "react";
import type { ClusterRoleBinding, ClusterRoleBindingSubject } from "../../../../common/k8s-api/endpoints"; import type { ClusterRoleBinding, ClusterRoleBindingSubject } from "../../../../common/k8s-api/endpoints";
import { autoBind, ObservableHashSet, prevDefault } from "../../../utils"; import { autoBind, ObservableHashSet, prevDefault } from "../../../utils";
import { AddRemoveButtons } from "../../add-remove-buttons"; import { AddRemoveButtons } from "../../add-remove-buttons";
import { ConfirmDialog } from "../../confirm-dialog";
import { DrawerTitle } from "../../drawer"; import { DrawerTitle } from "../../drawer";
import type { KubeObjectDetailsProps } from "../../kube-object-details"; import type { KubeObjectDetailsProps } from "../../kube-object-details";
import { KubeObjectMeta } from "../../kube-object-meta"; import { KubeObjectMeta } from "../../kube-object-meta";
@ -20,15 +19,22 @@ import { Table, TableCell, TableHead, TableRow } from "../../table";
import { ClusterRoleBindingDialog } from "./dialog"; import { ClusterRoleBindingDialog } from "./dialog";
import { clusterRoleBindingsStore } from "./store"; import { clusterRoleBindingsStore } from "./store";
import { hashClusterRoleBindingSubject } from "./hashers"; 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<ClusterRoleBinding> { export interface ClusterRoleBindingDetailsProps extends KubeObjectDetailsProps<ClusterRoleBinding> {
} }
interface Dependencies {
openConfirmDialog: OpenConfirmDialog;
}
@observer @observer
export class ClusterRoleBindingDetails extends React.Component<ClusterRoleBindingDetailsProps> { class NonInjectedClusterRoleBindingDetails extends React.Component<ClusterRoleBindingDetailsProps & Dependencies> {
selectedSubjects = new ObservableHashSet<ClusterRoleBindingSubject>([], hashClusterRoleBindingSubject); selectedSubjects = new ObservableHashSet<ClusterRoleBindingSubject>([], hashClusterRoleBindingSubject);
constructor(props: ClusterRoleBindingDetailsProps) { constructor(props: ClusterRoleBindingDetailsProps & Dependencies) {
super(props); super(props);
autoBind(this); autoBind(this);
} }
@ -42,10 +48,10 @@ export class ClusterRoleBindingDetails extends React.Component<ClusterRoleBindin
} }
removeSelectedSubjects() { removeSelectedSubjects() {
const { object: clusterRoleBinding } = this.props; const { object: clusterRoleBinding, openConfirmDialog } = this.props;
const { selectedSubjects } = this; const { selectedSubjects } = this;
ConfirmDialog.open({ openConfirmDialog({
ok: () => clusterRoleBindingsStore.removeSubjects(clusterRoleBinding, selectedSubjects), ok: () => clusterRoleBindingsStore.removeSubjects(clusterRoleBinding, selectedSubjects),
labelOk: `Remove`, labelOk: `Remove`,
message: ( message: (
@ -123,3 +129,10 @@ export class ClusterRoleBindingDetails extends React.Component<ClusterRoleBindin
); );
} }
} }
export const ClusterRoleBindingDetails = withInjectables<Dependencies, ClusterRoleBindingDetailsProps>(NonInjectedClusterRoleBindingDetails, {
getProps: (di, props) => ({
...props,
openConfirmDialog: di.inject(openConfirmDialogInjectable),
}),
});

View File

@ -11,7 +11,6 @@ import React from "react";
import type { RoleBinding, RoleBindingSubject } from "../../../../common/k8s-api/endpoints"; import type { RoleBinding, RoleBindingSubject } from "../../../../common/k8s-api/endpoints";
import { prevDefault } from "../../../utils"; import { prevDefault } from "../../../utils";
import { AddRemoveButtons } from "../../add-remove-buttons"; import { AddRemoveButtons } from "../../add-remove-buttons";
import { ConfirmDialog } from "../../confirm-dialog";
import { DrawerTitle } from "../../drawer"; import { DrawerTitle } from "../../drawer";
import type { KubeObjectDetailsProps } from "../../kube-object-details"; import type { KubeObjectDetailsProps } from "../../kube-object-details";
import { KubeObjectMeta } from "../../kube-object-meta"; import { KubeObjectMeta } from "../../kube-object-meta";
@ -20,12 +19,19 @@ import { RoleBindingDialog } from "./dialog";
import { roleBindingsStore } from "./store"; import { roleBindingsStore } from "./store";
import { ObservableHashSet } from "../../../../common/utils/hash-set"; import { ObservableHashSet } from "../../../../common/utils/hash-set";
import { hashRoleBindingSubject } from "./hashers"; 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<RoleBinding> { export interface RoleBindingDetailsProps extends KubeObjectDetailsProps<RoleBinding> {
} }
interface Dependencies {
openConfirmDialog: OpenConfirmDialog;
}
@observer @observer
export class RoleBindingDetails extends React.Component<RoleBindingDetailsProps> { class NonInjectedRoleBindingDetails extends React.Component<RoleBindingDetailsProps & Dependencies> {
selectedSubjects = new ObservableHashSet<RoleBindingSubject>([], hashRoleBindingSubject); selectedSubjects = new ObservableHashSet<RoleBindingSubject>([], hashRoleBindingSubject);
async componentDidMount() { async componentDidMount() {
@ -37,10 +43,10 @@ export class RoleBindingDetails extends React.Component<RoleBindingDetailsProps>
} }
removeSelectedSubjects = () => { removeSelectedSubjects = () => {
const { object: roleBinding } = this.props; const { object: roleBinding, openConfirmDialog } = this.props;
const { selectedSubjects } = this; const { selectedSubjects } = this;
ConfirmDialog.open({ openConfirmDialog({
ok: () => roleBindingsStore.removeSubjects(roleBinding, selectedSubjects.toJSON()), ok: () => roleBindingsStore.removeSubjects(roleBinding, selectedSubjects.toJSON()),
labelOk: `Remove`, labelOk: `Remove`,
message: ( message: (
@ -118,3 +124,10 @@ export class RoleBindingDetails extends React.Component<RoleBindingDetailsProps>
); );
} }
} }
export const RoleBindingDetails = withInjectables<Dependencies, RoleBindingDetailsProps>(NonInjectedRoleBindingDetails, {
getProps: (di, props) => ({
...props,
openConfirmDialog: di.inject(openConfirmDialogInjectable),
}),
});

View File

@ -9,13 +9,22 @@ import { cronJobApi } from "../../../common/k8s-api/endpoints";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { CronJobTriggerDialog } from "./cronjob-trigger-dialog"; import { CronJobTriggerDialog } from "./cronjob-trigger-dialog";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { ConfirmDialog } from "../confirm-dialog";
import { Notifications } from "../notifications"; 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<CronJob>) { export interface CronJobMenuProps extends KubeObjectMenuProps<CronJob> {}
const { object, toolbar } = props;
return ( interface Dependencies {
openConfirmDialog: OpenConfirmDialog;
}
const NonInjectedCronJobMenu = ({
object,
toolbar,
openConfirmDialog,
}: Dependencies & CronJobMenuProps) => (
<> <>
<MenuItem onClick={() => CronJobTriggerDialog.open(object)}> <MenuItem onClick={() => CronJobTriggerDialog.open(object)}>
<Icon material="play_circle_filled" tooltip="Trigger" interactive={toolbar}/> <Icon material="play_circle_filled" tooltip="Trigger" interactive={toolbar}/>
@ -23,7 +32,7 @@ export function CronJobMenu(props: KubeObjectMenuProps<CronJob>) {
</MenuItem> </MenuItem>
{object.isSuspend() ? {object.isSuspend() ?
<MenuItem onClick={() => ConfirmDialog.open({ <MenuItem onClick={() => openConfirmDialog({
ok: async () => { ok: async () => {
try { try {
await cronJobApi.resume({ namespace: object.getNs(), name: object.getName() }); await cronJobApi.resume({ namespace: object.getNs(), name: object.getName() });
@ -35,13 +44,14 @@ export function CronJobMenu(props: KubeObjectMenuProps<CronJob>) {
message: ( message: (
<p> <p>
Resume CronJob <b>{object.getName()}</b>? Resume CronJob <b>{object.getName()}</b>?
</p>), </p>
),
})}> })}>
<Icon material="play_circle_outline" tooltip="Resume" interactive={toolbar}/> <Icon material="play_circle_outline" tooltip="Resume" interactive={toolbar}/>
<span className="title">Resume</span> <span className="title">Resume</span>
</MenuItem> </MenuItem>
: <MenuItem onClick={() => ConfirmDialog.open({ : <MenuItem onClick={() => openConfirmDialog({
ok: async () => { ok: async () => {
try { try {
await cronJobApi.suspend({ namespace: object.getNs(), name: object.getName() }); await cronJobApi.suspend({ namespace: object.getNs(), name: object.getName() });
@ -53,12 +63,19 @@ export function CronJobMenu(props: KubeObjectMenuProps<CronJob>) {
message: ( message: (
<p> <p>
Suspend CronJob <b>{object.getName()}</b>? Suspend CronJob <b>{object.getName()}</b>?
</p>), </p>
),
})}> })}>
<Icon material="pause_circle_filled" tooltip="Suspend" interactive={toolbar}/> <Icon material="pause_circle_filled" tooltip="Suspend" interactive={toolbar}/>
<span className="title">Suspend</span> <span className="title">Suspend</span>
</MenuItem> </MenuItem>
} }
</> </>
); );
}
export const CronJobMenu = withInjectables<Dependencies, CronJobMenuProps>(NonInjectedCronJobMenu, {
getProps: (di, props) => ({
...props,
openConfirmDialog: di.inject(openConfirmDialogInjectable),
}),
});

View File

@ -9,21 +9,29 @@ import { deploymentApi } from "../../../common/k8s-api/endpoints";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { DeploymentScaleDialog } from "./deployment-scale-dialog"; import { DeploymentScaleDialog } from "./deployment-scale-dialog";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { ConfirmDialog } from "../confirm-dialog";
import { Notifications } from "../notifications"; 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<Deployment>) { export interface DeploymentMenuProps extends KubeObjectMenuProps<Deployment> {}
const { object, toolbar } = props;
return ( interface Dependencies {
openConfirmDialog: OpenConfirmDialog;
}
const NonInjectedDeploymentMenu = ({
object,
toolbar,
openConfirmDialog,
}: Dependencies & DeploymentMenuProps) => (
<> <>
<MenuItem onClick={() => DeploymentScaleDialog.open(object)}> <MenuItem onClick={() => DeploymentScaleDialog.open(object)}>
<Icon material="open_with" tooltip="Scale" interactive={toolbar}/> <Icon material="open_with" tooltip="Scale" interactive={toolbar}/>
<span className="title">Scale</span> <span className="title">Scale</span>
</MenuItem> </MenuItem>
<MenuItem onClick={() => ConfirmDialog.open({ <MenuItem onClick={() => openConfirmDialog({
ok: async () => ok: async () => {
{
try { try {
await deploymentApi.restart({ await deploymentApi.restart({
namespace: object.getNs(), namespace: object.getNs(),
@ -44,5 +52,11 @@ export function DeploymentMenu(props: KubeObjectMenuProps<Deployment>) {
<span className="title">Restart</span> <span className="title">Restart</span>
</MenuItem> </MenuItem>
</> </>
); );
}
export const DeploymentMenu = withInjectables<Dependencies, DeploymentMenuProps>(NonInjectedDeploymentMenu, {
getProps: (di, props) => ({
...props,
openConfirmDialog: di.inject(openConfirmDialogInjectable),
}),
});

View File

@ -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<StatefulSet>) {
const { object, toolbar } = props;
return (
<>
<MenuItem onClick={() => StatefulSetScaleDialog.open(object)}>
<Icon material="open_with" tooltip="Scale" interactive={toolbar}/>
<span className="title">Scale</span>
</MenuItem>
</>
);
}

View File

@ -5,8 +5,8 @@
import "./animate.scss"; import "./animate.scss";
import React from "react"; import React from "react";
import { observable, reaction, makeObservable } from "mobx"; import { observable, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { observer } from "mobx-react";
import { cssNames, noop } from "../../utils"; import { cssNames, noop } from "../../utils";
export type AnimateName = "opacity" | "slide-right" | "opacity-scale" | string; export type AnimateName = "opacity" | "slide-right" | "opacity-scale" | string;
@ -46,15 +46,24 @@ export class Animate extends React.Component<AnimateProps> {
return React.Children.only(this.props.children) as React.ReactElement<React.HTMLAttributes<any>>; return React.Children.only(this.props.children) as React.ReactElement<React.HTMLAttributes<any>>;
} }
private toggle(enter: boolean) {
if (enter) {
this.enter();
} else {
this.leave();
}
}
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ this.toggle(this.props.enter);
reaction(() => this.props.enter, enter => { }
if (enter) this.enter();
else this.leave(); componentDidUpdate(prevProps: Readonly<AnimateProps>): void {
}, { const { enter } = this.props;
fireImmediately: true,
}), if (prevProps.enter !== enter) {
]); this.toggle(enter);
}
} }
enter() { enter() {

View File

@ -7,7 +7,8 @@ import "./confirm-dialog.scss";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import React 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 { observer } from "mobx-react";
import { cssNames, noop, prevDefault } from "../../utils"; import { cssNames, noop, prevDefault } from "../../utils";
import type { ButtonProps } from "../button"; import type { ButtonProps } from "../button";
@ -16,6 +17,8 @@ import type { DialogProps } from "../dialog";
import { Dialog } from "../dialog"; import { Dialog } from "../dialog";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { withInjectables } from "@ogre-tools/injectable-react";
import confirmDialogStateInjectable from "./state.injectable";
export interface ConfirmDialogProps extends Partial<DialogProps> { export interface ConfirmDialogProps extends Partial<DialogProps> {
} }
@ -34,45 +37,30 @@ export interface ConfirmDialogBooleanParams {
cancelButtonProps?: Partial<ButtonProps>; cancelButtonProps?: Partial<ButtonProps>;
} }
const dialogState = observable.object({ interface Dependencies {
isOpen: false, state: IObservableValue<ConfirmDialogParams | undefined>;
params: null as ConfirmDialogParams, }
});
@observer const defaultParams: Partial<ConfirmDialogParams> = {
export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
@observable isSaving = false;
constructor(props: ConfirmDialogProps) {
super(props);
makeObservable(this);
}
static open(params: ConfirmDialogParams) {
dialogState.isOpen = true;
dialogState.params = params;
}
static confirm(params: ConfirmDialogBooleanParams): Promise<boolean> {
return new Promise(resolve => {
ConfirmDialog.open({
ok: () => resolve(true),
cancel: () => resolve(false),
...params,
});
});
}
static defaultParams: Partial<ConfirmDialogParams> = {
ok: noop, ok: noop,
cancel: noop, cancel: noop,
labelOk: "Ok", labelOk: "Ok",
labelCancel: "Cancel", labelCancel: "Cancel",
icon: <Icon big material="warning"/>, icon: <Icon big material="warning"/>,
}; };
get params(): ConfirmDialogParams { @observer
return Object.assign({}, ConfirmDialog.defaultParams, dialogState.params); class NonInjectedConfirmDialog extends React.Component<ConfirmDialogProps & Dependencies> {
@observable isSaving = false;
constructor(props: ConfirmDialogProps & Dependencies) {
super(props);
makeObservable(this);
}
@computed
get params() {
return Object.assign({}, defaultParams, this.props.state.get() ?? {});
} }
ok = async () => { ok = async () => {
@ -88,7 +76,7 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
); );
} finally { } finally {
this.isSaving = false; this.isSaving = false;
dialogState.isOpen = false; this.props.state.set(undefined);
} }
}; };
@ -108,12 +96,14 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
); );
} finally { } finally {
this.isSaving = false; this.isSaving = false;
dialogState.isOpen = false; this.props.state.set(undefined);
} }
}; };
render() { render() {
const { className, ...dialogProps } = this.props; const { state, className, ...dialogProps } = this.props;
const dialogState = state.get();
const isOpen = Boolean(dialogState);
const { const {
icon, labelOk, labelCancel, message, icon, labelOk, labelCancel, message,
okButtonProps = {}, okButtonProps = {},
@ -124,10 +114,10 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
<Dialog <Dialog
{...dialogProps} {...dialogProps}
className={cssNames("ConfirmDialog", className)} className={cssNames("ConfirmDialog", className)}
isOpen={dialogState.isOpen} isOpen={isOpen}
onClose={this.onClose} onClose={this.onClose}
close={this.close} close={this.close}
{...(dialogState.isOpen ? { "data-testid":"confirmation-dialog" } : {})} {...(isOpen ? { "data-testid": "confirmation-dialog" } : {})}
> >
<div className="confirm-content"> <div className="confirm-content">
{icon} {message} {icon} {message}
@ -154,3 +144,10 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
); );
} }
} }
export const ConfirmDialog = withInjectables<Dependencies, ConfirmDialogProps>(NonInjectedConfirmDialog, {
getProps: (di, props) => ({
...props,
state: di.inject(confirmDialogStateInjectable),
}),
});

View File

@ -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<boolean>;
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;

View File

@ -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;

View File

@ -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<ConfirmDialogParams | undefined>(),
});
export default confirmDialogStateInjectable;

View File

@ -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;

View File

@ -199,7 +199,7 @@ class NonInjectedDrawer extends React.Component<DrawerProps & Dependencies, Stat
)} )}
</div> </div>
{toolbar} {toolbar}
<Icon material="close" onClick={this.close}/> <Icon material="close" tooltip="Close" onClick={this.close}/>
</div> </div>
<div <div
className={cssNames("drawer-content flex column box grow", contentClass)} className={cssNames("drawer-content flex column box grow", contentClass)}

View File

@ -9,13 +9,15 @@ import React, { useState } from "react";
import type { CatalogEntityContextMenu } from "../../../common/catalog"; import type { CatalogEntityContextMenu } from "../../../common/catalog";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { ConfirmDialog } from "../confirm-dialog";
import { Menu, MenuItem } from "../menu"; import { Menu, MenuItem } from "../menu";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { AvatarProps } from "../avatar"; import type { AvatarProps } from "../avatar";
import { Avatar } from "../avatar"; import { Avatar } from "../avatar";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
export interface HotbarIconProps extends AvatarProps { export interface HotbarIconProps extends AvatarProps {
uid: string; uid: string;
@ -28,24 +30,17 @@ export interface HotbarIconProps extends AvatarProps {
tooltip?: string; tooltip?: string;
} }
function onMenuItemClick(menuItem: CatalogEntityContextMenu) { interface Dependencies {
if (menuItem.confirm) { normalizeMenuItem: NormalizeCatalogEntityContextMenu;
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
},
ok: () => {
menuItem.onClick();
},
message: menuItem.confirm.message,
});
} else {
menuItem.onClick();
}
} }
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 { uid, title, src, material, active, className, source, disabled, onMenuOpen, onClick, children, ...rest } = props;
const id = `hotbarIcon-${uid}`; const id = `hotbarIcon-${uid}`;
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@ -83,8 +78,10 @@ export const HotbarIcon = observer(({ menuItems = [], size = 40, tooltip, ...pro
}} }}
close={() => toggleMenu()}> close={() => toggleMenu()}>
{ {
menuItems.map((menuItem) => ( menuItems
<MenuItem key={menuItem.title} onClick={() => onMenuItemClick(menuItem)}> .map(normalizeMenuItem)
.map((menuItem) => (
<MenuItem key={menuItem.title} onClick={menuItem.onClick}>
{menuItem.title} {menuItem.title}
</MenuItem> </MenuItem>
)) ))
@ -93,3 +90,10 @@ export const HotbarIcon = observer(({ menuItems = [], size = 40, tooltip, ...pro
</div> </div>
); );
}); });
export const HotbarIcon = withInjectables<Dependencies, HotbarIconProps>(NonInjectedHotbarIcon, {
getProps: (di, props) => ({
...props,
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
}),
});

View File

@ -7,13 +7,15 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Select } from "../select"; import { Select } from "../select";
import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; import hotbarStoreInjectable from "../../../common/hotbar-store.injectable";
import { ConfirmDialog } from "../confirm-dialog";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import type { Hotbar } from "../../../common/hotbar-types"; import type { Hotbar } from "../../../common/hotbar-types";
import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable";
import openConfirmDialogInjectable from "../confirm-dialog/open.injectable";
interface Dependencies { interface Dependencies {
closeCommandOverlay: () => void; closeCommandOverlay: () => void;
openConfirmDialog: OpenConfirmDialog;
hotbarStore: { hotbarStore: {
hotbars: Hotbar[]; hotbars: Hotbar[];
getById: (id: string) => Hotbar | undefined; 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 => ({ const options = hotbarStore.hotbars.map(hotbar => ({
value: hotbar.id, value: hotbar.id,
label: hotbarStore.getDisplayLabel(hotbar), label: hotbarStore.getDisplayLabel(hotbar),
@ -36,8 +42,7 @@ const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarSt
} }
closeCommandOverlay(); closeCommandOverlay();
// TODO: make confirm dialog injectable openConfirmDialog({
ConfirmDialog.open({
okButtonProps: { okButtonProps: {
label: "Remove Hotbar", label: "Remove Hotbar",
primary: false, primary: false,
@ -71,8 +76,9 @@ const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarSt
export const HotbarRemoveCommand = withInjectables<Dependencies>(NonInjectedHotbarRemoveCommand, { export const HotbarRemoveCommand = withInjectables<Dependencies>(NonInjectedHotbarRemoveCommand, {
getProps: (di, props) => ({ getProps: (di, props) => ({
...props,
closeCommandOverlay: di.inject(commandOverlayInjectable).close, closeCommandOverlay: di.inject(commandOverlayInjectable).close,
hotbarStore: di.inject(hotbarStoreInjectable), hotbarStore: di.inject(hotbarStoreInjectable),
...props, openConfirmDialog: di.inject(openConfirmDialogInjectable),
}), }),
}); });

View File

@ -15,22 +15,69 @@ import { withTooltip } from "../tooltip";
import isNumber from "lodash/isNumber"; import isNumber from "lodash/isNumber";
import { decode } from "../../../common/utils/base64"; import { decode } from "../../../common/utils/base64";
export interface IconProps extends React.HTMLAttributes<any>, TooltipDecoratorProps { export interface BaseIconProps {
material?: string; // material-icon, see available names at https://material.io/icons/ /**
svg?: string; // svg-filename without extension in current folder * One of the names from https://material.io/icons/
link?: LocationDescriptor; // render icon as NavLink from react-router-dom */
href?: string; // render icon as hyperlink material?: string;
size?: string | number; // icon-size
small?: boolean; // pre-defined icon-size /**
smallest?: boolean; // pre-defined icon-size * Either an SVG data URL or one of the following strings
big?: boolean; // pre-defined icon-size */
active?: boolean; // apply active-state styles svg?: string;
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) /**
* 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; sticker?: boolean;
disabled?: boolean; disabled?: boolean;
} }
export interface IconProps extends React.HTMLAttributes<any>, TooltipDecoratorProps, BaseIconProps {}
@withTooltip @withTooltip
export class Icon extends React.PureComponent<IconProps> { export class Icon extends React.PureComponent<IconProps> {
private readonly ref = createRef<HTMLAnchorElement>(); private readonly ref = createRef<HTMLAnchorElement>();

View File

@ -10,7 +10,6 @@ import React from "react";
import { computed, makeObservable } from "mobx"; import { computed, makeObservable } from "mobx";
import { Observer, observer } from "mobx-react"; import { Observer, observer } from "mobx-react";
import type { ConfirmDialogParams } from "../confirm-dialog"; import type { ConfirmDialogParams } from "../confirm-dialog";
import { ConfirmDialog } from "../confirm-dialog";
import type { TableCellProps, TableProps, TableRowProps, TableSortCallbacks } from "../table"; import type { TableCellProps, TableProps, TableRowProps, TableSortCallbacks } from "../table";
import { Table, TableCell, TableHead, TableRow } from "../table"; import { Table, TableCell, TableHead, TableRow } from "../table";
import type { IClassName } from "../../utils"; import type { IClassName } from "../../utils";
@ -27,6 +26,9 @@ import { MenuActions } from "../menu/menu-actions";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { Checkbox } from "../checkbox"; import { Checkbox } from "../checkbox";
import { UserStore } from "../../../common/user-store"; 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<I extends ItemObject> { export interface ItemListLayoutContentProps<I extends ItemObject> {
getFilters: () => Filter[]; getFilters: () => Filter[];
@ -63,9 +65,13 @@ export interface ItemListLayoutContentProps<I extends ItemObject> {
failedToLoadMessage?: React.ReactNode; failedToLoadMessage?: React.ReactNode;
} }
interface Dependencies {
openConfirmDialog: OpenConfirmDialog;
}
@observer @observer
export class ItemListLayoutContent<I extends ItemObject> extends React.Component<ItemListLayoutContentProps<I>> { class NonInjectedItemListLayoutContent<I extends ItemObject> extends React.Component<ItemListLayoutContentProps<I> & Dependencies> {
constructor(props: ItemListLayoutContentProps<I>) { constructor(props: ItemListLayoutContentProps<I> & Dependencies) {
super(props); super(props);
makeObservable(this); makeObservable(this);
autoBind(this); autoBind(this);
@ -150,7 +156,7 @@ export class ItemListLayoutContent<I extends ItemObject> extends React.Component
} }
removeItemsDialog(selectedItems: I[]) { removeItemsDialog(selectedItems: I[]) {
const { customizeRemoveDialog, store } = this.props; const { customizeRemoveDialog, store, openConfirmDialog } = this.props;
const visibleMaxNamesCount = 5; const visibleMaxNamesCount = 5;
const selectedNames = selectedItems.map(ns => ns.getName()).slice(0, visibleMaxNamesCount).join(", "); const selectedNames = selectedItems.map(ns => ns.getName()).slice(0, visibleMaxNamesCount).join(", ");
const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {}; const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {};
@ -168,7 +174,7 @@ export class ItemListLayoutContent<I extends ItemObject> extends React.Component
? () => store.removeItems(selectedItems) ? () => store.removeItems(selectedItems)
: store.removeSelectedItems; : store.removeSelectedItems;
ConfirmDialog.open({ openConfirmDialog({
ok: onConfirm, ok: onConfirm,
labelOk: "Remove", labelOk: "Remove",
message, message,
@ -315,3 +321,10 @@ export class ItemListLayoutContent<I extends ItemObject> extends React.Component
); );
} }
} }
export const ItemListLayoutContent = withInjectables<Dependencies, ItemListLayoutContentProps<ItemObject>>(NonInjectedItemListLayoutContent, {
getProps: (di, props) => ({
...props,
openConfirmDialog: di.inject(openConfirmDialogInjectable),
}),
}) as <I extends ItemObject>(props: ItemListLayoutContentProps<I>) => React.ReactElement;

View File

@ -5,15 +5,14 @@ exports[`kube-object-menu given kube object renders 1`] = `
<div> <div>
<div> <div>
<ul <ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom" class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
> >
<li> <li>
Some menu item Some menu item
</li> </li>
<li <li
class="MenuItem" class="MenuItem"
data-testid="menu-action-remove" data-testid="menu-action-delete"
tabindex="0" tabindex="0"
> >
<i <i
@ -45,15 +44,14 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
<div> <div>
<div> <div>
<ul <ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom" class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
> >
<li> <li>
Some menu item Some menu item
</li> </li>
<li <li
class="MenuItem" class="MenuItem"
data-testid="menu-action-remove" data-testid="menu-action-delete"
tabindex="0" tabindex="0"
> >
<i <i
@ -78,9 +76,8 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
</div> </div>
</div> </div>
<div <div
class="Animate opacity-scale Dialog flex center ConfirmDialog modal" class="Dialog flex center ConfirmDialog modal"
data-testid="confirmation-dialog" data-testid="confirmation-dialog"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
> >
<div <div
class="box" class="box"
@ -99,7 +96,6 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
</span> </span>
</i> </i>
<div>
<p> <p>
Remove Remove
some-kind some-kind
@ -114,7 +110,6 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
? ?
</p> </p>
</div> </div>
</div>
<div <div
class="confirm-buttons" class="confirm-buttons"
> >
@ -142,15 +137,14 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
<div> <div>
<div> <div>
<ul <ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom" class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
> >
<li> <li>
Some menu item Some menu item
</li> </li>
<li <li
class="MenuItem" class="MenuItem"
data-testid="menu-action-remove" data-testid="menu-action-delete"
tabindex="0" tabindex="0"
> >
<i <i
@ -175,9 +169,8 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
</div> </div>
</div> </div>
<div <div
class="Animate opacity-scale Dialog flex center ConfirmDialog modal" class="Dialog flex center ConfirmDialog modal"
data-testid="confirmation-dialog" data-testid="confirmation-dialog"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
> >
<div <div
class="box" class="box"
@ -196,7 +189,6 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
</span> </span>
</i> </i>
<div>
<p> <p>
Remove Remove
some-kind some-kind
@ -211,7 +203,6 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
? ?
</p> </p>
</div> </div>
</div>
<div <div
class="confirm-buttons" class="confirm-buttons"
> >
@ -239,15 +230,14 @@ exports[`kube-object-menu given kube object without namespace when removing kube
<div> <div>
<div> <div>
<ul <ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom" class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
> >
<li> <li>
Some menu item Some menu item
</li> </li>
<li <li
class="MenuItem" class="MenuItem"
data-testid="menu-action-remove" data-testid="menu-action-delete"
tabindex="0" tabindex="0"
> >
<i <i
@ -272,9 +262,8 @@ exports[`kube-object-menu given kube object without namespace when removing kube
</div> </div>
</div> </div>
<div <div
class="Animate opacity-scale Dialog flex center ConfirmDialog modal" class="Dialog flex center ConfirmDialog modal"
data-testid="confirmation-dialog" data-testid="confirmation-dialog"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
> >
<div <div
class="box" class="box"
@ -293,7 +282,6 @@ exports[`kube-object-menu given kube object without namespace when removing kube
</span> </span>
</i> </i>
<div>
<p> <p>
Remove Remove
some-kind some-kind
@ -308,7 +296,6 @@ exports[`kube-object-menu given kube object without namespace when removing kube
? ?
</p> </p>
</div> </div>
</div>
<div <div
class="confirm-buttons" class="confirm-buttons"
> >
@ -335,8 +322,7 @@ exports[`kube-object-menu given no kube object, renders 1`] = `
<body> <body>
<div> <div>
<ul <ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom" class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
/> />
</div> </div>
</body> </body>

View File

@ -5,7 +5,7 @@
import { conforms, includes, eq } from "lodash/fp"; import { conforms, includes, eq } from "lodash/fp";
import type { KubeObject } from "../../../../../common/k8s-api/kube-object"; import type { KubeObject } from "../../../../../common/k8s-api/kube-object";
import type { LensRendererExtension } from "../../../../../extensions/lens-renderer-extension"; import type { LensRendererExtension } from "../../../../../extensions/lens-renderer-extension";
import { staticKubeObjectMenuItems as staticMenuItems } from "./static-kube-object-menu-items"; import { staticKubeObjectMenuItems as staticMenuItems } from "../static-kube-object-menu-items";
interface Dependencies { interface Dependencies {
extensions: LensRendererExtension[]; extensions: LensRendererExtension[];
@ -31,7 +31,6 @@ export const getKubeObjectMenuItems = ({
apiVersions: includes(kubeObject.apiVersion), apiVersions: includes(kubeObject.apiVersion),
}), }),
) )
.map((item) => item.components.MenuItem); .map((item) => item.components.MenuItem);
}; };

View File

@ -2,11 +2,10 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { ServiceAccountMenu } from "../../../+user-management/+service-accounts/service-account-menu"; import { ServiceAccountMenu } from "../../+user-management/+service-accounts/service-account-menu";
import { CronJobMenu } from "../../../+workloads-cronjobs/cron-job-menu"; import { CronJobMenu } from "../../+workloads-cronjobs/cron-job-menu";
import { DeploymentMenu } from "../../../+workloads-deployments/deployment-menu"; import { DeploymentMenu } from "../../+workloads-deployments/deployment-menu";
import { ReplicaSetMenu } from "../../../+workloads-replicasets/replica-set-menu"; import { ReplicaSetMenu } from "../../+workloads-replicasets/replica-set-menu";
import { StatefulSetMenu } from "../../../+workloads-statefulsets/stateful-set-menu";
export const staticKubeObjectMenuItems = [ export const staticKubeObjectMenuItems = [
{ {
@ -37,11 +36,4 @@ export const staticKubeObjectMenuItems = [
MenuItem: ReplicaSetMenu, MenuItem: ReplicaSetMenu,
}, },
}, },
{
kind: "StatefulSet",
apiVersions: ["apps/v1"],
components: {
MenuItem: StatefulSetMenu,
},
},
]; ];

View File

@ -4,7 +4,7 @@
*/ */
import type React from "react"; import type React from "react";
import type { KubeObject } from "../../../../../common/k8s-api/kube-object"; import type { KubeObject } from "../../../common/k8s-api/kube-object";
export interface KubeObjectMenuItemProps { export interface KubeObjectMenuItemProps {
object: KubeObject; object: KubeObject;

View File

@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import React from "react"; import React from "react";
import { screen } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { KubeObject } from "../../../common/k8s-api/kube-object"; import { KubeObject } from "../../../common/k8s-api/kube-object";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@ -21,7 +21,7 @@ import type { Cluster } from "../../../common/cluster/cluster";
import type { ApiManager } from "../../../common/k8s-api/api-manager"; import type { ApiManager } from "../../../common/k8s-api/api-manager";
import apiManagerInjectable from "./dependencies/api-manager.injectable"; import apiManagerInjectable from "./dependencies/api-manager.injectable";
import { KubeObjectMenu } from "./index"; import { KubeObjectMenu } from "./index";
import type { KubeObjectMenuRegistration } from "./dependencies/kube-object-menu-items/kube-object-menu-registration"; import type { KubeObjectMenuRegistration } from "./kube-object-menu-registration";
import { computed } from "mobx"; import { computed } from "mobx";
import { LensRendererExtension } from "../../../extensions/lens-renderer-extension"; import { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable"; import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
@ -30,6 +30,8 @@ import createEditResourceTabInjectable from "../dock/edit-resource/edit-resource
// TODO: Make tooltips free of side effects by making it deterministic // TODO: Make tooltips free of side effects by making it deterministic
jest.mock("../tooltip"); jest.mock("../tooltip");
// TODO: make `animated={false}` not required to make tests deterministic
class SomeTestExtension extends LensRendererExtension { class SomeTestExtension extends LensRendererExtension {
constructor( constructor(
kubeObjectMenuItems: KubeObjectMenuRegistration[], kubeObjectMenuItems: KubeObjectMenuRegistration[],
@ -144,7 +146,7 @@ describe("kube-object-menu", () => {
({ baseElement } = render( ({ baseElement } = render(
<div> <div>
<ConfirmDialog /> <ConfirmDialog animated={false} />
<KubeObjectMenu <KubeObjectMenu
object={objectStub} object={objectStub}
@ -164,23 +166,18 @@ describe("kube-object-menu", () => {
}); });
describe("when removing kube object", () => { describe("when removing kube object", () => {
beforeEach(() => { beforeEach(async () => {
const menuItem = screen.getByTestId("menu-action-remove"); userEvent.click(await screen.findByTestId("menu-action-delete"));
userEvent.click(menuItem);
}); });
it("renders", () => { it("renders", async () => {
await screen.findByTestId("confirmation-dialog");
expect(baseElement).toMatchSnapshot(); expect(baseElement).toMatchSnapshot();
}); });
it("opens a confirmation dialog", () => {
screen.getByTestId("confirmation-dialog");
});
describe("when remove is confirmed", () => { describe("when remove is confirmed", () => {
beforeEach(() => { beforeEach(async () => {
const confirmRemovalButton = screen.getByTestId("confirm"); const confirmRemovalButton = await screen.findByTestId("confirm");
userEvent.click(confirmRemovalButton); userEvent.click(confirmRemovalButton);
}); });
@ -189,18 +186,19 @@ describe("kube-object-menu", () => {
expect(removeActionMock).toHaveBeenCalledWith(); expect(removeActionMock).toHaveBeenCalledWith();
}); });
it("does not close the confirmation dialog yet", () => { it("does not close the confirmation dialog yet", async () => {
screen.getByTestId("confirmation-dialog"); await screen.findByTestId("confirmation-dialog");
}); });
it("when removal resolves, closes the confirmation dialog", async () => { it("when removal resolves, closes the confirmation dialog", async () => {
await removeActionMock.resolve(); await removeActionMock.resolve();
await waitFor(() => {
expect(screen.queryByTestId("confirmation-dialog")).toBeNull(); expect(screen.queryByTestId("confirmation-dialog")).toBeNull();
}); });
}); });
}); });
}); });
});
describe("given kube object with namespace", () => { describe("given kube object with namespace", () => {
let baseElement: Element; let baseElement: Element;
@ -219,7 +217,7 @@ describe("kube-object-menu", () => {
({ baseElement } = render( ({ baseElement } = render(
<div> <div>
<ConfirmDialog /> <ConfirmDialog animated={false} />
<KubeObjectMenu <KubeObjectMenu
object={objectStub} object={objectStub}
@ -230,8 +228,8 @@ describe("kube-object-menu", () => {
)); ));
}); });
it("when removing kube object, renders confirmation dialog with namespace", () => { it("when removing kube object, renders confirmation dialog with namespace", async () => {
const menuItem = screen.getByTestId("menu-action-remove"); const menuItem = await screen.findByTestId("menu-action-delete");
userEvent.click(menuItem); userEvent.click(menuItem);
@ -256,7 +254,7 @@ describe("kube-object-menu", () => {
({ baseElement } = render( ({ baseElement } = render(
<div> <div>
<ConfirmDialog /> <ConfirmDialog animated={false} />
<KubeObjectMenu <KubeObjectMenu
object={objectStub} object={objectStub}
@ -267,8 +265,8 @@ describe("kube-object-menu", () => {
)); ));
}); });
it("when removing kube object, renders confirmation dialog without namespace", () => { it("when removing kube object, renders confirmation dialog without namespace", async () => {
const menuItem = screen.getByTestId("menu-action-remove"); const menuItem = await screen.findByTestId("menu-action-delete");
userEvent.click(menuItem); userEvent.click(menuItem);

View File

@ -4,10 +4,10 @@
*/ */
import React from "react"; import React from "react";
import { autoBind, cssNames } from "../../utils"; import { cssNames } from "../../utils";
import type { KubeObject } from "../../../common/k8s-api/kube-object"; import type { KubeObject } from "../../../common/k8s-api/kube-object";
import type { MenuActionsProps } from "../menu"; import type { MenuActionsProps } from "../menu";
import { MenuActions } from "../menu"; import { MenuItem, MenuActions } from "../menu";
import identity from "lodash/identity"; import identity from "lodash/identity";
import type { ApiManager } from "../../../common/k8s-api/api-manager"; import type { ApiManager } from "../../../common/k8s-api/api-manager";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
@ -16,6 +16,16 @@ import createEditResourceTabInjectable from "../dock/edit-resource/edit-resource
import hideDetailsInjectable from "./dependencies/hide-details.injectable"; import hideDetailsInjectable from "./dependencies/hide-details.injectable";
import kubeObjectMenuItemsInjectable from "./dependencies/kube-object-menu-items/kube-object-menu-items.injectable"; import kubeObjectMenuItemsInjectable from "./dependencies/kube-object-menu-items/kube-object-menu-items.injectable";
import apiManagerInjectable from "./dependencies/api-manager.injectable"; import apiManagerInjectable from "./dependencies/api-manager.injectable";
import type { OnKubeObjectContextMenuOpen } from "./on-context-menu-open.injectable";
import onKubeObjectContextMenuOpenInjectable from "./on-context-menu-open.injectable";
import type { KubeObjectContextMenuItem } from "../../kube-object/handler";
import { observable, runInAction } from "mobx";
import type { WithConfirmation } from "../confirm-dialog/with-confirm.injectable";
import type { Navigate } from "../../navigation/navigate.injectable";
import { Icon } from "../icon";
import navigateInjectable from "../../navigation/navigate.injectable";
import withConfirmationInjectable from "../confirm-dialog/with-confirm.injectable";
import { observer } from "mobx-react";
export interface KubeObjectMenuProps<TKubeObject extends KubeObject> extends MenuActionsProps { export interface KubeObjectMenuProps<TKubeObject extends KubeObject> extends MenuActionsProps {
object: TKubeObject | null | undefined; object: TKubeObject | null | undefined;
@ -29,52 +39,17 @@ interface Dependencies {
clusterName: string; clusterName: string;
hideDetails: () => void; hideDetails: () => void;
createEditResourceTab: (kubeObject: KubeObject) => void; createEditResourceTab: (kubeObject: KubeObject) => void;
onContextMenuOpen: OnKubeObjectContextMenuOpen;
withConfirmation: WithConfirmation;
navigate: Navigate;
} }
class NonInjectedKubeObjectMenu<TKubeObject extends KubeObject, Props extends KubeObjectMenuProps<TKubeObject> & Dependencies> extends React.Component<Props> { @observer
constructor(props: Props) { class NonInjectedKubeObjectMenu<Kube extends KubeObject> extends React.Component<KubeObjectMenuProps<Kube> & Dependencies> {
super(props); private menuItems = observable.array<KubeObjectContextMenuItem>();
autoBind(this);
}
get store() {
const { object } = this.props;
if (!object) return null;
return this.props.apiManager.getStore(object.selfLink);
}
get isEditable() {
return this.props.editable ?? Boolean(this.store?.patch);
}
get isRemovable() {
return this.props.removable ?? Boolean(this.store?.remove);
}
async update() {
this.props.hideDetails();
this.props.createEditResourceTab(this.props.object);
}
async remove() {
this.props.hideDetails();
const { object, removeAction } = this.props;
if (removeAction) await removeAction();
else await this.store.remove(object);
}
renderRemoveMessage() {
const { object } = this.props;
if (!object) {
return null;
}
private renderRemoveMessage(object: KubeObject) {
const breadcrumbParts = [object.getNs(), object.getName()]; const breadcrumbParts = [object.getNs(), object.getName()];
const breadcrumb = breadcrumbParts.filter(identity).join("/"); const breadcrumb = breadcrumbParts.filter(identity).join("/");
return ( return (
@ -84,7 +59,7 @@ class NonInjectedKubeObjectMenu<TKubeObject extends KubeObject, Props extends Ku
); );
} }
getMenuItems(): React.ReactChild[] { private renderMenuItems() {
const { object, toolbar } = this.props; const { object, toolbar } = this.props;
return this.props.kubeObjectMenuItems.map((MenuItem, index) => ( return this.props.kubeObjectMenuItems.map((MenuItem, index) => (
@ -92,43 +67,134 @@ class NonInjectedKubeObjectMenu<TKubeObject extends KubeObject, Props extends Ku
)); ));
} }
private emitOnContextMenuOpen(object: KubeObject) {
const {
apiManager,
editable,
removable,
hideDetails,
createEditResourceTab,
withConfirmation,
removeAction,
onContextMenuOpen,
navigate,
updateAction,
} = this.props;
const store = apiManager.getStore(object.selfLink);
const isEditable = editable ?? (Boolean(store?.patch) || Boolean(updateAction));
const isRemovable = removable ?? (Boolean(store?.remove) || Boolean(removeAction));
runInAction(() => {
this.menuItems.clear();
if (isRemovable) {
this.menuItems.push({
title: "Delete",
icon: "delete",
onClick: withConfirmation({
message: this.renderRemoveMessage(object),
labelOk: "Remove",
ok: async () => {
hideDetails();
if (removeAction) {
await removeAction();
} else if (store?.remove) {
await store.remove(object);
}
},
}),
});
}
if (isEditable) {
this.menuItems.push({
title: "Edit",
icon: "edit",
onClick: async () => {
hideDetails();
if (updateAction) {
await updateAction();
} else {
createEditResourceTab(object);
}
},
});
}
});
onContextMenuOpen(object, {
menuItems: this.menuItems,
navigate,
});
}
private renderContextMenuItems = (object: KubeObject) => (
[...this.menuItems]
.reverse() // This is done because the order that we "grow" is right->left
.map(({ icon, ...rest }) => ({
...rest,
icon: typeof icon === "string"
? { material: icon }
: icon,
}))
.map((item, index) => (
<MenuItem
key={`context-menu-item-${index}`}
onClick={() => item.onClick(object)}
data-testid={`menu-action-${item.title.toLowerCase().replace(/\s+/, "-")}`}
>
<Icon
{...item.icon}
interactive={this.props.toolbar}
tooltip={item.title}
/>
<span className="title">
{item.title}
</span>
</MenuItem>
))
);
render() { render() {
const { remove, update, renderRemoveMessage, isEditable, isRemovable } = this; const {
const { className, editable, removable, ...menuProps } = this.props; className,
editable,
removable,
object,
removeAction, // This is here so we don't pass it down to `<MenuAction>`
removeConfirmationMessage, // This is here so we don't pass it down to `<MenuAction>`
updateAction, // This is here so we don't pass it down to `<MenuAction>`
...menuProps
} = this.props;
return ( return (
<MenuActions <MenuActions
className={cssNames("KubeObjectMenu", className)} className={cssNames("KubeObjectMenu", className)}
updateAction={isEditable ? update : undefined} onOpen={object ? () => this.emitOnContextMenuOpen(object) : undefined}
removeAction={isRemovable ? remove : undefined}
removeConfirmationMessage={renderRemoveMessage}
{...menuProps} {...menuProps}
> >
{this.getMenuItems()} {this.renderMenuItems()}
{object && this.renderContextMenuItems(object)}
</MenuActions> </MenuActions>
); );
} }
} }
const InjectedKubeObjectMenu = withInjectables<Dependencies, KubeObjectMenuProps<KubeObject>>( export const KubeObjectMenu = withInjectables<Dependencies, KubeObjectMenuProps<KubeObject>>(NonInjectedKubeObjectMenu, {
NonInjectedKubeObjectMenu,
{
getProps: (di, props) => ({ getProps: (di, props) => ({
...props,
clusterName: di.inject(clusterNameInjectable), clusterName: di.inject(clusterNameInjectable),
apiManager: di.inject(apiManagerInjectable), apiManager: di.inject(apiManagerInjectable),
createEditResourceTab: di.inject(createEditResourceTabInjectable), createEditResourceTab: di.inject(createEditResourceTabInjectable),
hideDetails: di.inject(hideDetailsInjectable), hideDetails: di.inject(hideDetailsInjectable),
kubeObjectMenuItems: di.inject(kubeObjectMenuItemsInjectable, { kubeObjectMenuItems: di.inject(kubeObjectMenuItemsInjectable, {
kubeObject: props.object, kubeObject: props.object,
}), }),
...props, onContextMenuOpen: di.inject(onKubeObjectContextMenuOpenInjectable),
navigate: di.inject(navigateInjectable),
withConfirmation: di.inject(withConfirmationInjectable),
}), }),
}, }) as <T extends KubeObject>(props: KubeObjectMenuProps<T>) => React.ReactElement;
);
export function KubeObjectMenu<T extends KubeObject>(
props: KubeObjectMenuProps<T>,
) {
return <InjectedKubeObjectMenu {...props} />;
}

View File

@ -0,0 +1,27 @@
/**
* 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 { KubeObject } from "../../../common/k8s-api/kube-object";
import type { KubeObjectOnContextMenuOpenContext } from "../../kube-object/handler";
import kubeObjectHandlersInjectable from "../../kube-object/handlers.injectable";
export type OnKubeObjectContextMenuOpen = (obj: KubeObject, ctx: KubeObjectOnContextMenuOpenContext) => void;
const onKubeObjectContextMenuOpenInjectable = getInjectable({
id: "on-kube-object-context-menu-open",
instantiate: (di): OnKubeObjectContextMenuOpen => {
const handlers = di.inject(kubeObjectHandlersInjectable);
return (obj, ctx) => {
const specificHandlers = handlers.get().get(obj.apiVersion)?.get(obj.kind) ?? [];
for (const { onContextMenuOpen } of specificHandlers) {
onContextMenuOpen?.(ctx);
}
};
},
});
export default onKubeObjectContextMenuOpenInjectable;

View File

@ -7,48 +7,41 @@ import styles from "./sidebar-cluster.module.scss";
import { observable } from "mobx"; import { observable } from "mobx";
import React, { useState } from "react"; import React, { useState } from "react";
import { broadcastMessage } from "../../../common/ipc"; import { broadcastMessage } from "../../../common/ipc";
import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; import type { CatalogEntity, CatalogEntityContextMenu } from "../../api/catalog-entity";
import { IpcRendererNavigationEvents } from "../../navigation/events"; import { IpcRendererNavigationEvents } from "../../navigation/events";
import { Avatar } from "../avatar"; import { Avatar } from "../avatar";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { navigate } from "../../navigation";
import { Menu, MenuItem } from "../menu"; import { Menu, MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; import hotbarStoreInjectable from "../../../common/hotbar-store.injectable";
import type { HotbarStore } from "../../../common/hotbar-store"; import type { HotbarStore } from "../../../common/hotbar-store";
import { observer } from "mobx-react"; import type { Navigate } from "../../navigation/navigate.injectable";
import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable";
import navigateInjectable from "../../navigation/navigate.injectable";
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
const contextMenu: CatalogEntityContextMenuContext = observable({ export interface SidebarClusterProps {
menuItems: [], clusterEntity: CatalogEntity;
navigate: (url: string, forceMainFrame = true) => {
if (forceMainFrame) {
broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url);
} else {
navigate(url);
}
},
});
function onMenuItemClick(menuItem: CatalogEntityContextMenu) {
if (menuItem.confirm) {
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
},
ok: () => {
menuItem.onClick();
},
message: menuItem.confirm.message,
});
} else {
menuItem.onClick();
}
} }
function renderLoadingSidebarCluster() { interface Dependencies {
navigate: Navigate;
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
hotbarStore: HotbarStore;
}
function NonInjectedSidebarCluster({
clusterEntity,
hotbarStore,
navigate,
normalizeMenuItem,
}: SidebarClusterProps & Dependencies) {
const [opened, setOpened] = useState(false);
const [menuItems] = useState(observable.array<CatalogEntityContextMenu>());
if (!clusterEntity) {
// render a Loading version of the SidebarCluster
return ( return (
<div className={styles.SidebarCluster}> <div className={styles.SidebarCluster}>
<Avatar <Avatar
@ -60,21 +53,6 @@ function renderLoadingSidebarCluster() {
<div className={styles.loadingClusterName} /> <div className={styles.loadingClusterName} />
</div> </div>
); );
}
interface Dependencies {
hotbarStore: HotbarStore;
}
interface SidebarClusterProps {
clusterEntity: CatalogEntity;
}
const NonInjectedSidebarCluster = observer(({ clusterEntity, hotbarStore }: Dependencies & SidebarClusterProps) => {
const [opened, setOpened] = useState(false);
if (!clusterEntity) {
return renderLoadingSidebarCluster();
} }
const onMenuOpen = () => { const onMenuOpen = () => {
@ -86,8 +64,17 @@ const NonInjectedSidebarCluster = observer(({ clusterEntity, hotbarStore }: Depe
? () => hotbarStore.removeFromHotbar(clusterEntity.getId()) ? () => hotbarStore.removeFromHotbar(clusterEntity.getId())
: () => hotbarStore.addToHotbar(clusterEntity); : () => hotbarStore.addToHotbar(clusterEntity);
contextMenu.menuItems = [{ title, onClick }]; menuItems.replace([{ title, onClick }]);
clusterEntity.onContextMenuOpen(contextMenu); clusterEntity.onContextMenuOpen({
menuItems,
navigate: (url, forceMainFrame = true) => {
if (forceMainFrame) {
broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url);
} else {
navigate(url);
}
},
});
toggle(); toggle();
}; };
@ -139,8 +126,10 @@ const NonInjectedSidebarCluster = observer(({ clusterEntity, hotbarStore }: Depe
className={styles.menu} className={styles.menu}
> >
{ {
contextMenu.menuItems.map((menuItem) => ( menuItems
<MenuItem key={menuItem.title} onClick={() => onMenuItemClick(menuItem)}> .map(normalizeMenuItem)
.map((menuItem) => (
<MenuItem key={menuItem.title} onClick={menuItem.onClick}>
{menuItem.title} {menuItem.title}
</MenuItem> </MenuItem>
)) ))
@ -148,15 +137,13 @@ const NonInjectedSidebarCluster = observer(({ clusterEntity, hotbarStore }: Depe
</Menu> </Menu>
</div> </div>
); );
}); }
export const SidebarCluster = withInjectables<Dependencies, SidebarClusterProps>( export const SidebarCluster = withInjectables<Dependencies, SidebarClusterProps>(NonInjectedSidebarCluster, {
NonInjectedSidebarCluster,
{
getProps: (di, props) => ({ getProps: (di, props) => ({
hotbarStore: di.inject(hotbarStoreInjectable),
...props, ...props,
hotbarStore: di.inject(hotbarStoreInjectable),
navigate: di.inject(navigateInjectable),
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
}), }),
}, });
);

View File

@ -6,34 +6,47 @@
import "./menu-actions.scss"; import "./menu-actions.scss";
import React, { isValidElement } from "react"; import React, { isValidElement } from "react";
import { observable, makeObservable } from "mobx"; import { observable, makeObservable, reaction } from "mobx";
import { observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { autoBind, cssNames } from "../../utils"; import { autoBind, cssNames } from "../../utils";
import { ConfirmDialog } from "../confirm-dialog";
import type { IconProps } from "../icon"; import type { IconProps } from "../icon";
import { Icon } from "../icon"; import { Icon } from "../icon";
import type { MenuProps } from "./menu"; import type { MenuProps } from "./menu";
import { Menu, MenuItem } from "./menu"; import { Menu, MenuItem } from "./menu";
import uniqueId from "lodash/uniqueId"; import uniqueId from "lodash/uniqueId";
import isString from "lodash/isString"; import isString from "lodash/isString";
import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import openConfirmDialogInjectable from "../confirm-dialog/open.injectable";
export interface MenuActionsProps extends Partial<MenuProps> { export interface MenuActionsProps extends Partial<MenuProps> {
className?: string; className?: string;
toolbar?: boolean; // display menu as toolbar with icons toolbar?: boolean; // display menu as toolbar with icons
autoCloseOnSelect?: boolean; autoCloseOnSelect?: boolean;
triggerIcon?: string | IconProps | React.ReactNode; triggerIcon?: string | IconProps | React.ReactNode;
/**
* @deprecated Provide your own remove `<MenuItem>` as part of the `children` passed to this component
*/
removeConfirmationMessage?: React.ReactNode | (() => React.ReactNode); removeConfirmationMessage?: React.ReactNode | (() => React.ReactNode);
updateAction?(): void; /**
removeAction?(): void; * @deprecated Provide your own update `<MenuItem>` as part of the `children` passed to this component
onOpen?(): void; */
updateAction?: () => void | Promise<void>;
/**
* @deprecated Provide your own remove `<MenuItem>` as part of the `children` passed to this component
*/
removeAction?: () => void | Promise<void>;
onOpen?: () => void;
}
interface Dependencies {
openConfirmDialog: OpenConfirmDialog;
} }
@observer @observer
export class MenuActions extends React.Component<MenuActionsProps> { class NonInjectedMenuActions extends React.Component<MenuActionsProps & Dependencies> {
static defaultProps: MenuActionsProps = { static defaultProps = {
get removeConfirmationMessage() { removeConfirmationMessage: "Remove item?",
return `Remove item?`;
},
}; };
public id = uniqueId("menu_actions_"); public id = uniqueId("menu_actions_");
@ -45,22 +58,34 @@ export class MenuActions extends React.Component<MenuActionsProps> {
this.isOpen = !this.isOpen; this.isOpen = !this.isOpen;
}; };
constructor(props: MenuActionsProps) { constructor(props: MenuActionsProps & Dependencies) {
super(props); super(props);
makeObservable(this); makeObservable(this);
autoBind(this); autoBind(this);
} }
componentDidMount(): void {
disposeOnUnmount(this, [
reaction(() => this.isOpen, (isOpen) => {
if (isOpen) {
this.props.onOpen?.();
}
}, {
fireImmediately: true,
}),
]);
}
remove() { remove() {
const { removeAction } = this.props; const { removeAction, openConfirmDialog } = this.props;
let { removeConfirmationMessage } = this.props; let { removeConfirmationMessage } = this.props;
if (typeof removeConfirmationMessage === "function") { if (typeof removeConfirmationMessage === "function") {
removeConfirmationMessage = removeConfirmationMessage(); removeConfirmationMessage = removeConfirmationMessage();
} }
ConfirmDialog.open({ openConfirmDialog({
ok: removeAction, ok: removeAction,
labelOk: `Remove`, labelOk: "Remove",
message: <div>{removeConfirmationMessage}</div>, message: <div>{removeConfirmationMessage}</div>,
}); });
} }
@ -83,10 +108,6 @@ export class MenuActions extends React.Component<MenuActionsProps> {
...(typeof triggerIcon === "object" ? triggerIcon : {}), ...(typeof triggerIcon === "object" ? triggerIcon : {}),
}; };
if (this.props.onOpen) {
iconProps.onClick = this.props.onOpen;
}
if (iconProps.tooltip && this.isOpen) { if (iconProps.tooltip && this.isOpen) {
delete iconProps.tooltip; // don't show tooltip for icon when menu is open delete iconProps.tooltip; // don't show tooltip for icon when menu is open
} }
@ -101,10 +122,6 @@ export class MenuActions extends React.Component<MenuActionsProps> {
className, toolbar, autoCloseOnSelect, children, updateAction, removeAction, triggerIcon, removeConfirmationMessage, className, toolbar, autoCloseOnSelect, children, updateAction, removeAction, triggerIcon, removeConfirmationMessage,
...menuProps ...menuProps
} = this.props; } = this.props;
const menuClassName = cssNames("MenuActions flex", className, {
toolbar,
gaps: toolbar, // add spacing for .flex
});
const autoClose = !toolbar; const autoClose = !toolbar;
return ( return (
@ -113,11 +130,17 @@ export class MenuActions extends React.Component<MenuActionsProps> {
<Menu <Menu
htmlFor={this.id} htmlFor={this.id}
isOpen={this.isOpen} open={this.toggle} close={this.toggle} isOpen={this.isOpen}
className={menuClassName} open={this.toggle}
close={this.toggle}
className={cssNames("MenuActions flex", className, {
toolbar,
gaps: toolbar, // add spacing for .flex
})}
animated={!toolbar}
usePortal={autoClose} usePortal={autoClose}
closeOnScroll={autoClose} closeOnScroll={autoClose}
closeOnClickItem={autoCloseOnSelect ?? autoClose } closeOnClickItem={autoCloseOnSelect ?? autoClose}
closeOnClickOutside={autoClose} closeOnClickOutside={autoClose}
{...menuProps} {...menuProps}
> >
@ -139,3 +162,10 @@ export class MenuActions extends React.Component<MenuActionsProps> {
); );
} }
} }
export const MenuActions = withInjectables<Dependencies, MenuActionsProps>(NonInjectedMenuActions, {
getProps: (di, props) => ({
...props,
openConfirmDialog: di.inject(openConfirmDialogInjectable),
}),
});

View File

@ -42,6 +42,7 @@ export interface MenuProps {
closeOnScroll?: boolean; // applicable when usePortal={true} closeOnScroll?: boolean; // applicable when usePortal={true}
position?: MenuPosition; // applicable when usePortal={false} position?: MenuPosition; // applicable when usePortal={false}
children?: ReactNode; children?: ReactNode;
animated?: boolean;
toggleEvent?: "click" | "contextmenu"; toggleEvent?: "click" | "contextmenu";
} }
@ -58,6 +59,7 @@ const defaultPropsMenu: Partial<MenuProps> = {
closeOnClickOutside: true, closeOnClickOutside: true,
closeOnScroll: false, closeOnScroll: false,
toggleEvent: "click", toggleEvent: "click",
animated: true,
}; };
export class Menu extends React.Component<MenuProps, State> { export class Menu extends React.Component<MenuProps, State> {
@ -297,7 +299,7 @@ export class Menu extends React.Component<MenuProps, State> {
} }
render() { render() {
const { position, id } = this.props; const { position, id, animated } = this.props;
let { className, usePortal } = this.props; let { className, usePortal } = this.props;
className = cssNames("Menu", className, this.state.position || position, { className = cssNames("Menu", className, this.state.position || position, {
@ -319,9 +321,7 @@ export class Menu extends React.Component<MenuProps, State> {
return item; return item;
}); });
const menu = ( let menu = (
<MenuContext.Provider value={this}>
<Animate enter={this.isOpen}>
<ul <ul
id={id} id={id}
ref={this.bindRef} ref={this.bindRef}
@ -334,13 +334,27 @@ export class Menu extends React.Component<MenuProps, State> {
> >
{menuItems} {menuItems}
</ul> </ul>
);
if (animated) {
menu = (
<Animate enter={this.isOpen}>
{menu}
</Animate> </Animate>
);
}
menu = (
<MenuContext.Provider value={this}>
{menu}
</MenuContext.Provider> </MenuContext.Provider>
); );
if (usePortal === true) usePortal = document.body; if (usePortal === true) usePortal = document.body;
return usePortal instanceof HTMLElement ? createPortal(menu, usePortal) : menu; return usePortal instanceof HTMLElement
? createPortal(menu, usePortal)
: menu;
} }
} }

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { RequireAtLeastOne } from "type-fest";
import type { KubeObject } from "../../common/k8s-api/kube-object";
import type { BaseIconProps } from "../components/icon";
export interface KubeObjectContextMenuItem {
/**
* If the type is `string` then it is shorthand for {@link BaseIconProps.material}
*
* This is required because this item can be either rendered as a context menu or as a toolbar in
* the kube object details page.
*/
icon: string | BaseIconProps;
/**
* The title text for the menu item or the hover text for the icon.
*/
title: string;
/**
* The action when clicked
*/
onClick: (obj: KubeObject) => void;
}
export interface KubeObjectOnContextMenuOpenContext {
menuItems: KubeObjectContextMenuItem[];
navigate: (location: string) => void;
}
export type KubeObjectOnContextMenuOpen = (ctx: KubeObjectOnContextMenuOpenContext) => void;
export interface KubeObjectHandlers {
onContextMenuOpen: KubeObjectOnContextMenuOpen;
}
export type KubeObjectHandlerRegistration = {
apiVersions: string[];
kind: string;
} & RequireAtLeastOne<KubeObjectHandlers>;

View File

@ -0,0 +1,41 @@
/**
* 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 { computed } from "mobx";
import rendererExtensionsInjectable from "../../extensions/renderer-extensions.injectable";
import { getOrInsert, getOrInsertMap, readonly } from "../utils";
import type { KubeObjectHandlerRegistration, KubeObjectHandlers } from "./handler";
import { staticKubeObjectContextMenuHandlers } from "./static-handlers";
const kubeObjectHandlersInjectable = getInjectable({
id: "kube-object-handlers",
instantiate: (di) => {
const extensions = di.inject(rendererExtensionsInjectable);
return computed(() => {
const res = new Map<string, Map<string, Partial<KubeObjectHandlers>[]>>();
const addAllHandlers = (registrations: KubeObjectHandlerRegistration[]) => {
for (const { apiVersions, kind, ...handlers } of registrations) {
for (const apiVersion of apiVersions) {
const byApiVersion = getOrInsertMap(res, apiVersion);
const byKind = getOrInsert(byApiVersion, kind, []);
byKind.push(handlers);
}
}
};
extensions.get()
.map(ext => ext.kubeObjectHandlers)
.forEach(addAllHandlers);
addAllHandlers(staticKubeObjectContextMenuHandlers);
return readonly(res);
});
},
});
export default kubeObjectHandlersInjectable;

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { KubeObjectHandlerRegistration } from "./handler";
import { StatefulSetScaleDialog } from "../components/+workloads-statefulsets/statefulset-scale-dialog";
import type { StatefulSet } from "../../common/k8s-api/endpoints";
export const staticKubeObjectContextMenuHandlers: KubeObjectHandlerRegistration[] = [
{
kind: "StatefulSet",
apiVersions: ["apps/v1"],
onContextMenuOpen: (ctx) => {
ctx.menuItems.push({
icon: "open_with",
title: "Scale",
onClick: (obj: StatefulSet) => StatefulSetScaleDialog.open(obj),
});
},
},
];

View File

@ -11,6 +11,9 @@ import { navigation } from "./history";
import type { PageParamInit } from "./page-param"; import type { PageParamInit } from "./page-param";
import { PageParam } from "./page-param"; import { PageParam } from "./page-param";
/**
* @deprecated use `di.inject(navigateInjectable)` instead
*/
export function navigate(location: LocationDescriptor) { export function navigate(location: LocationDescriptor) {
const currentLocation = createPath(navigation.location); const currentLocation = createPath(navigation.location);

View File

@ -0,0 +1,16 @@
/**
* 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 { LocationDescriptor } from "history";
import { navigate } from "./helpers";
export type Navigate = (desc: LocationDescriptor) => void;
const navigateInjectable = getInjectable({
id: "navigate",
instantiate: (): Navigate => (desc) => navigate(desc),
});
export default navigateInjectable;

View File

@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import attemptInstallByInfoInjectable from "../../components/+extensions/attempt-install-by-info/attempt-install-by-info.injectable"; import attemptInstallByInfoInjectable from "../../components/+extensions/attempt-install-by-info.injectable";
import { bindProtocolAddRouteHandlers } from "./bind-protocol-add-route-handlers"; import { bindProtocolAddRouteHandlers } from "./bind-protocol-add-route-handlers";
import lensProtocolRouterRendererInjectable from "../lens-protocol-router-renderer/lens-protocol-router-renderer.injectable"; import lensProtocolRouterRendererInjectable from "../lens-protocol-router-renderer/lens-protocol-router-renderer.injectable";
import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";

View File

@ -13,13 +13,13 @@ import {
LensProtocolRouter, LensProtocolRouter,
} from "../../../common/protocol-handler"; } from "../../../common/protocol-handler";
import { Notifications } from "../../components/notifications"; import { Notifications } from "../../components/notifications";
import type { ExtensionInfo } from "../../components/+extensions/attempt-install-by-info/attempt-install-by-info";
import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import type { NavigateToEntitySettings } from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable"; import type { NavigateToEntitySettings } from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable";
import type { NavigateToClusterView } from "../../../common/front-end-routing/routes/cluster-view/navigate-to-cluster-view.injectable"; import type { NavigateToClusterView } from "../../../common/front-end-routing/routes/cluster-view/navigate-to-cluster-view.injectable";
import type { AttemptInstallByInfo } from "../../components/+extensions/attempt-install-by-info.injectable";
interface Dependencies { interface Dependencies {
attemptInstallByInfo: (extensionInfo: ExtensionInfo) => Promise<void>; attemptInstallByInfo: AttemptInstallByInfo;
lensProtocolRouterRenderer: LensProtocolRouterRenderer; lensProtocolRouterRenderer: LensProtocolRouterRenderer;
navigateToCatalog: NavigateToCatalog; navigateToCatalog: NavigateToCatalog;
navigateToAddCluster: () => void; navigateToAddCluster: () => void;