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

This commit is contained in:
Sebastian Malton authored and GitHub committed 2022-05-04 12:44:31 -04:00
1 parent e532b90b72
commit dbdde19222
51 files changed
+1245 -765

No files matched your search

@@ -5,15 +5,14 @@ exports[`kube-object-menu given kube object renders 1`] = `
<div>
<div>
<ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
>
<li>
Some menu item
</li>
<li
class="MenuItem"
data-testid="menu-action-remove"
data-testid="menu-action-delete"
tabindex="0"
>
<i
@@ -45,15 +44,14 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
<div>
<div>
<ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
>
<li>
Some menu item
</li>
<li
class="MenuItem"
data-testid="menu-action-remove"
data-testid="menu-action-delete"
tabindex="0"
>
<i
@@ -78,9 +76,8 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
</div>
</div>
<div
class="Animate opacity-scale Dialog flex center ConfirmDialog modal"
class="Dialog flex center ConfirmDialog modal"
data-testid="confirmation-dialog"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
>
<div
class="box"
@@ -99,21 +96,19 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
</span>
</i>
<div>
<p>
Remove
some-kind
<b>
some-namespace/some-name
</b>
from
<b>
Some name
</b>
?
</p>
</div>
<p>
Remove
some-kind
<b>
some-namespace/some-name
</b>
from
<b>
Some name
</b>
?
</p>
</div>
<div
class="confirm-buttons"
@@ -142,15 +137,14 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
<div>
<div>
<ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
>
<li>
Some menu item
</li>
<li
class="MenuItem"
data-testid="menu-action-remove"
data-testid="menu-action-delete"
tabindex="0"
>
<i
@@ -175,9 +169,8 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
</div>
</div>
<div
class="Animate opacity-scale Dialog flex center ConfirmDialog modal"
class="Dialog flex center ConfirmDialog modal"
data-testid="confirmation-dialog"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
>
<div
class="box"
@@ -196,21 +189,19 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
</span>
</i>
<div>
<p>
Remove
some-kind
<b>
some-namespace/some-name
</b>
from
<b>
Some name
</b>
?
</p>
</div>
<p>
Remove
some-kind
<b>
some-namespace/some-name
</b>
from
<b>
Some name
</b>
?
</p>
</div>
<div
class="confirm-buttons"
@@ -239,15 +230,14 @@ exports[`kube-object-menu given kube object without namespace when removing kube
<div>
<div>
<ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
>
<li>
Some menu item
</li>
<li
class="MenuItem"
data-testid="menu-action-remove"
data-testid="menu-action-delete"
tabindex="0"
>
<i
@@ -272,9 +262,8 @@ exports[`kube-object-menu given kube object without namespace when removing kube
</div>
</div>
<div
class="Animate opacity-scale Dialog flex center ConfirmDialog modal"
class="Dialog flex center ConfirmDialog modal"
data-testid="confirmation-dialog"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
>
<div
class="box"
@@ -293,21 +282,19 @@ exports[`kube-object-menu given kube object without namespace when removing kube
</span>
</i>
<div>
<p>
Remove
some-kind
<b>
some-name
</b>
from
<b>
Some name
</b>
?
</p>
</div>
<p>
Remove
some-kind
<b>
some-name
</b>
from
<b>
Some name
</b>
?
</p>
</div>
<div
class="confirm-buttons"
@@ -335,8 +322,7 @@ exports[`kube-object-menu given no kube object, renders 1`] = `
<body>
<div>
<ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
style="--enter-duration: 100ms; --leave-duration: 100ms;"
class="Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"
/>
</div>
</body>
@@ -5,7 +5,7 @@
import { conforms, includes, eq } from "lodash/fp";
import type { KubeObject } from "../../../../../common/k8s-api/kube-object";
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 {
extensions: LensRendererExtension[];
@@ -31,7 +31,6 @@ export const getKubeObjectMenuItems = ({
apiVersions: includes(kubeObject.apiVersion),
}),
)
.map((item) => item.components.MenuItem);
};
@@ -2,11 +2,10 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { ServiceAccountMenu } from "../../../+user-management/+service-accounts/service-account-menu";
import { CronJobMenu } from "../../../+workloads-cronjobs/cron-job-menu";
import { DeploymentMenu } from "../../../+workloads-deployments/deployment-menu";
import { ReplicaSetMenu } from "../../../+workloads-replicasets/replica-set-menu";
import { StatefulSetMenu } from "../../../+workloads-statefulsets/stateful-set-menu";
import { ServiceAccountMenu } from "../../+user-management/+service-accounts/service-account-menu";
import { CronJobMenu } from "../../+workloads-cronjobs/cron-job-menu";
import { DeploymentMenu } from "../../+workloads-deployments/deployment-menu";
import { ReplicaSetMenu } from "../../+workloads-replicasets/replica-set-menu";
export const staticKubeObjectMenuItems = [
{
@@ -37,11 +36,4 @@ export const staticKubeObjectMenuItems = [
MenuItem: ReplicaSetMenu,
},
},
{
kind: "StatefulSet",
apiVersions: ["apps/v1"],
components: {
MenuItem: StatefulSetMenu,
},
},
];
@@ -4,7 +4,7 @@
*/
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 {
object: KubeObject;
@@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
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 { KubeObject } from "../../../common/k8s-api/kube-object";
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 apiManagerInjectable from "./dependencies/api-manager.injectable";
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 { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
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
jest.mock("../tooltip");
// TODO: make `animated={false}` not required to make tests deterministic
class SomeTestExtension extends LensRendererExtension {
constructor(
kubeObjectMenuItems: KubeObjectMenuRegistration[],
@@ -144,7 +146,7 @@ describe("kube-object-menu", () => {
({ baseElement } = render(
<div>
<ConfirmDialog />
<ConfirmDialog animated={false} />
<KubeObjectMenu
object={objectStub}
@@ -164,23 +166,18 @@ describe("kube-object-menu", () => {
});
describe("when removing kube object", () => {
beforeEach(() => {
const menuItem = screen.getByTestId("menu-action-remove");
userEvent.click(menuItem);
beforeEach(async () => {
userEvent.click(await screen.findByTestId("menu-action-delete"));
});
it("renders", () => {
it("renders", async () => {
await screen.findByTestId("confirmation-dialog");
expect(baseElement).toMatchSnapshot();
});
it("opens a confirmation dialog", () => {
screen.getByTestId("confirmation-dialog");
});
describe("when remove is confirmed", () => {
beforeEach(() => {
const confirmRemovalButton = screen.getByTestId("confirm");
beforeEach(async () => {
const confirmRemovalButton = await screen.findByTestId("confirm");
userEvent.click(confirmRemovalButton);
});
@@ -189,14 +186,15 @@ describe("kube-object-menu", () => {
expect(removeActionMock).toHaveBeenCalledWith();
});
it("does not close the confirmation dialog yet", () => {
screen.getByTestId("confirmation-dialog");
it("does not close the confirmation dialog yet", async () => {
await screen.findByTestId("confirmation-dialog");
});
it("when removal resolves, closes the confirmation dialog", async () => {
await removeActionMock.resolve();
expect(screen.queryByTestId("confirmation-dialog")).toBeNull();
await waitFor(() => {
expect(screen.queryByTestId("confirmation-dialog")).toBeNull();
});
});
});
});
@@ -219,7 +217,7 @@ describe("kube-object-menu", () => {
({ baseElement } = render(
<div>
<ConfirmDialog />
<ConfirmDialog animated={false} />
<KubeObjectMenu
object={objectStub}
@@ -230,8 +228,8 @@ describe("kube-object-menu", () => {
));
});
it("when removing kube object, renders confirmation dialog with namespace", () => {
const menuItem = screen.getByTestId("menu-action-remove");
it("when removing kube object, renders confirmation dialog with namespace", async () => {
const menuItem = await screen.findByTestId("menu-action-delete");
userEvent.click(menuItem);
@@ -256,7 +254,7 @@ describe("kube-object-menu", () => {
({ baseElement } = render(
<div>
<ConfirmDialog />
<ConfirmDialog animated={false} />
<KubeObjectMenu
object={objectStub}
@@ -267,8 +265,8 @@ describe("kube-object-menu", () => {
));
});
it("when removing kube object, renders confirmation dialog without namespace", () => {
const menuItem = screen.getByTestId("menu-action-remove");
it("when removing kube object, renders confirmation dialog without namespace", async () => {
const menuItem = await screen.findByTestId("menu-action-delete");
userEvent.click(menuItem);
@@ -4,10 +4,10 @@
*/
import React from "react";
import { autoBind, cssNames } from "../../utils";
import { cssNames } from "../../utils";
import type { KubeObject } from "../../../common/k8s-api/kube-object";
import type { MenuActionsProps } from "../menu";
import { MenuActions } from "../menu";
import { MenuItem, MenuActions } from "../menu";
import identity from "lodash/identity";
import type { ApiManager } from "../../../common/k8s-api/api-manager";
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 kubeObjectMenuItemsInjectable from "./dependencies/kube-object-menu-items/kube-object-menu-items.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 {
object: TKubeObject | null | undefined;
@@ -29,52 +39,17 @@ interface Dependencies {
clusterName: string;
hideDetails: () => 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> {
constructor(props: Props) {
super(props);
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;
}
@observer
class NonInjectedKubeObjectMenu<Kube extends KubeObject> extends React.Component<KubeObjectMenuProps<Kube> & Dependencies> {
private menuItems = observable.array<KubeObjectContextMenuItem>();
private renderRemoveMessage(object: KubeObject) {
const breadcrumbParts = [object.getNs(), object.getName()];
const breadcrumb = breadcrumbParts.filter(identity).join("/");
return (
@@ -84,7 +59,7 @@ class NonInjectedKubeObjectMenu<TKubeObject extends KubeObject, Props extends Ku
);
}
getMenuItems(): React.ReactChild[] {
private renderMenuItems() {
const { object, toolbar } = this.props;
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() {
const { remove, update, renderRemoveMessage, isEditable, isRemovable } = this;
const { className, editable, removable, ...menuProps } = this.props;
const {
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 (
<MenuActions
className={cssNames("KubeObjectMenu", className)}
updateAction={isEditable ? update : undefined}
removeAction={isRemovable ? remove : undefined}
removeConfirmationMessage={renderRemoveMessage}
onOpen={object ? () => this.emitOnContextMenuOpen(object) : undefined}
{...menuProps}
>
{this.getMenuItems()}
{this.renderMenuItems()}
{object && this.renderContextMenuItems(object)}
</MenuActions>
);
}
}
const InjectedKubeObjectMenu = withInjectables<Dependencies, KubeObjectMenuProps<KubeObject>>(
NonInjectedKubeObjectMenu,
{
getProps: (di, props) => ({
clusterName: di.inject(clusterNameInjectable),
apiManager: di.inject(apiManagerInjectable),
createEditResourceTab: di.inject(createEditResourceTabInjectable),
hideDetails: di.inject(hideDetailsInjectable),
kubeObjectMenuItems: di.inject(kubeObjectMenuItemsInjectable, {
kubeObject: props.object,
}),
...props,
export const KubeObjectMenu = withInjectables<Dependencies, KubeObjectMenuProps<KubeObject>>(NonInjectedKubeObjectMenu, {
getProps: (di, props) => ({
...props,
clusterName: di.inject(clusterNameInjectable),
apiManager: di.inject(apiManagerInjectable),
createEditResourceTab: di.inject(createEditResourceTabInjectable),
hideDetails: di.inject(hideDetailsInjectable),
kubeObjectMenuItems: di.inject(kubeObjectMenuItemsInjectable, {
kubeObject: props.object,
}),
},
);
export function KubeObjectMenu<T extends KubeObject>(
props: KubeObjectMenuProps<T>,
) {
return <InjectedKubeObjectMenu {...props} />;
}
onContextMenuOpen: di.inject(onKubeObjectContextMenuOpenInjectable),
navigate: di.inject(navigateInjectable),
withConfirmation: di.inject(withConfirmationInjectable),
}),
}) as <T extends KubeObject>(props: KubeObjectMenuProps<T>) => React.ReactElement;
@@ -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;