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

Add behaviour for tray menu items originating from extensions (#5609)

This commit is contained in:
Janne Savolainen 2022-06-17 15:07:26 +03:00 committed by GitHub
parent 2a5b4af344
commit 10ba9ef853
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 248 additions and 196 deletions

View File

@ -46,7 +46,7 @@ describe("cluster - sidebar and tab navigation for extensions", () => {
const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder);
const testExtension = getRendererExtensionFake(extensionStubWithSidebarItems); const testExtension = getRendererExtensionFake(extensionStubWithSidebarItems);
await applicationBuilder.addExtensions(testExtension); await applicationBuilder.extensions.renderer.enable(testExtension);
}); });
describe("given no state for expanded sidebar items exists, and navigated to child sidebar item, when rendered", () => { describe("given no state for expanded sidebar items exists, and navigated to child sidebar item, when rendered", () => {

View File

@ -23,7 +23,7 @@ describe("extension special characters in page registrations", () => {
extensionWithPagesHavingSpecialCharacters, extensionWithPagesHavingSpecialCharacters,
); );
await applicationBuilder.addExtensions(testExtension); await applicationBuilder.extensions.renderer.enable(testExtension);
rendered = await applicationBuilder.render(); rendered = await applicationBuilder.render();
}); });

View File

@ -27,7 +27,7 @@ describe("navigate to extension page", () => {
extensionWithPagesHavingParameters, extensionWithPagesHavingParameters,
); );
await applicationBuilder.addExtensions(testExtension); await applicationBuilder.extensions.renderer.enable(testExtension);
rendered = await applicationBuilder.render(); rendered = await applicationBuilder.render();

View File

@ -44,11 +44,11 @@ describe("preferences - navigation to extension specific preferences", () => {
}); });
describe("when extension with specific preferences is enabled", () => { describe("when extension with specific preferences is enabled", () => {
beforeEach(() => { beforeEach(async () => {
const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder);
const testExtension = getRendererExtensionFake(extensionStubWithExtensionSpecificPreferenceItems); const testExtension = getRendererExtensionFake(extensionStubWithExtensionSpecificPreferenceItems);
applicationBuilder.addExtensions(testExtension); await applicationBuilder.extensions.renderer.enable(testExtension);
}); });
it("renders", () => { it("renders", () => {

View File

@ -50,7 +50,7 @@ describe("preferences - navigation to telemetry preferences", () => {
const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder); const getRendererExtensionFake = getRendererExtensionFakeFor(applicationBuilder);
const testExtensionWithTelemetryPreferenceItems = getRendererExtensionFake(extensionStubWithTelemetryPreferenceItems); const testExtensionWithTelemetryPreferenceItems = getRendererExtensionFake(extensionStubWithTelemetryPreferenceItems);
applicationBuilder.addExtensions( applicationBuilder.extensions.renderer.enable(
testExtensionWithTelemetryPreferenceItems, testExtensionWithTelemetryPreferenceItems,
); );
}); });
@ -105,7 +105,7 @@ describe("preferences - navigation to telemetry preferences", () => {
], ],
}); });
applicationBuilder.addExtensions( applicationBuilder.extensions.renderer.enable(
testExtensionWithTelemetryPreferenceItems, testExtensionWithTelemetryPreferenceItems,
); );

View File

@ -0,0 +1,134 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { LensMainExtension } from "../../extensions/lens-main-extension";
import type { TrayMenuRegistration } from "../../main/tray/tray-menu-registration";
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import loggerInjectable from "../../common/logger.injectable";
import type { Logger } from "../../common/logger";
describe("clicking tray menu item originating from extension", () => {
let applicationBuilder: ApplicationBuilder;
let logErrorMock: jest.Mock;
beforeEach(async () => {
applicationBuilder = getApplicationBuilder();
applicationBuilder.beforeApplicationStart(({ mainDi }) => {
logErrorMock = jest.fn();
mainDi.override(loggerInjectable, () => ({ error: logErrorMock }) as unknown as Logger);
});
await applicationBuilder.render();
});
describe("when extension is enabled", () => {
let someExtension: SomeTestExtension;
let clickMock: jest.Mock;
beforeEach(async () => {
clickMock = jest.fn();
someExtension = new SomeTestExtension({
id: "some-extension-id",
trayMenus: [{ label: "some-label", click: clickMock }],
});
await applicationBuilder.extensions.main.enable(someExtension);
});
it("when item is clicked, triggers the click handler", () => {
applicationBuilder.tray.click(
"some-label-tray-menu-item-for-extension-some-extension-id-instance-1",
);
expect(clickMock).toHaveBeenCalled();
});
describe("given click handler throws synchronously, when item is clicked", () => {
beforeEach(() => {
clickMock.mockImplementation(() => {
throw new Error("some-error");
});
applicationBuilder.tray.click(
"some-label-tray-menu-item-for-extension-some-extension-id-instance-1",
);
});
it("logs the error", () => {
expect(logErrorMock).toHaveBeenCalledWith(
'[TRAY]: Clicking of tray item "some-label" from extension "some-extension-id" failed.',
expect.any(Error),
);
});
});
describe("given click handler rejects asynchronously, when item is clicked", () => {
beforeEach(() => {
clickMock.mockImplementation(() => Promise.reject("some-rejection"));
applicationBuilder.tray.click(
"some-label-tray-menu-item-for-extension-some-extension-id-instance-1",
);
});
it("logs the error", () => {
expect(logErrorMock).toHaveBeenCalledWith(
'[TRAY]: Clicking of tray item "some-label" from extension "some-extension-id" failed.',
"some-rejection",
);
});
});
describe("when extension is disabled", () => {
beforeEach(() => {
applicationBuilder.extensions.main.disable(someExtension);
});
it("does not have the tray menu item from extension", () => {
applicationBuilder.extensions.main.disable(someExtension);
expect(
applicationBuilder.tray.get(
"some-label-tray-menu-item-for-extension-some-extension-id-instance-1",
),
).toBeNull();
});
// Note: Motivation here is to make sure that enabling same extension does not throw
it("when extension is re-enabled, has the tray menu item from extension", async () => {
await applicationBuilder.extensions.main.enable(someExtension);
expect(
applicationBuilder.tray.get(
"some-label-tray-menu-item-for-extension-some-extension-id-instance-2",
),
).not.toBeNull();
});
});
});
});
class SomeTestExtension extends LensMainExtension {
constructor({ id, trayMenus }: {
id: string;
trayMenus: TrayMenuRegistration[];
}) {
super({
id,
absolutePath: "irrelevant",
isBundled: false,
isCompatible: false,
isEnabled: false,
manifest: { name: id, version: "some-version", engines: { lens: "^5.5.0" }},
manifestPath: "irrelevant",
});
this.trayMenus = trayMenus;
}
}

View File

@ -55,20 +55,27 @@ const toItemInjectablesFor = (extension: LensMainExtension, installationCounter:
label: computed(() => registration.label || ""), label: computed(() => registration.label || ""),
tooltip: registration.toolTip, tooltip: registration.toolTip,
click: pipeline( click: () => {
() => { const decorated = pipeline(
registration.click?.(registration); registration.click || (() => {}),
},
withErrorLoggingFor(() => `[TRAY]: Clicking of tray item "${trayItemId}" from extension "${extension.sanitizedExtensionId}" failed.`), withErrorLoggingFor(
() =>
`[TRAY]: Clicking of tray item "${trayItemId}" from extension "${extension.sanitizedExtensionId}" failed.`,
),
// TODO: Find out how to improve typing so that instead of // TODO: Find out how to improve typing so that instead of
// x => withErrorSuppression(x) there could only be withErrorSuppression // x => withErrorSuppression(x) there could only be withErrorSuppression
(x) => withErrorSuppression(x), x => withErrorSuppression(x),
), );
return decorated(registration);
},
enabled: computed(() => !!registration.enabled), enabled: computed(() => !!registration.enabled),
visible: computed(() => true), visible: computed(() => true),
extension,
}), }),
injectionToken: trayMenuItemInjectionToken, injectionToken: trayMenuItemInjectionToken,

View File

@ -1,19 +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 { computed } from "mobx";
import mainExtensionsInjectable from "../../extensions/main-extensions.injectable";
const trayItemsInjectable = getInjectable({
id: "tray-items",
instantiate: (di) => {
const extensions = di.inject(mainExtensionsInjectable);
return computed(() => extensions.get().flatMap(extension => extension.trayMenus));
},
});
export default trayItemsInjectable;

View File

@ -1,120 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { DiContainer } from "@ogre-tools/injectable";
import { LensMainExtension } from "../../extensions/lens-main-extension";
import trayItemsInjectable from "./tray-menu-items.injectable";
import type { IComputedValue } from "mobx";
import { computed, ObservableMap, runInAction } from "mobx";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import mainExtensionsInjectable from "../../extensions/main-extensions.injectable";
import type { TrayMenuRegistration } from "./tray-menu-registration";
describe("tray-menu-items", () => {
let di: DiContainer;
let trayMenuItems: IComputedValue<TrayMenuRegistration[]>;
let extensionsStub: ObservableMap<string, LensMainExtension>;
beforeEach(async () => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
extensionsStub = new ObservableMap();
di.override(
mainExtensionsInjectable,
() => computed(() => [...extensionsStub.values()]),
);
trayMenuItems = di.inject(trayItemsInjectable);
});
it("does not have any items yet", () => {
expect(trayMenuItems.get()).toHaveLength(0);
});
describe("when extension is enabled", () => {
beforeEach(() => {
const someExtension = new SomeTestExtension({
id: "some-extension-id",
trayMenus: [{ label: "tray-menu-from-some-extension" }],
});
runInAction(() => {
extensionsStub.set("some-extension-id", someExtension);
});
});
it("has tray menu items", () => {
expect(trayMenuItems.get()).toEqual([
{
label: "tray-menu-from-some-extension",
},
]);
});
it("when disabling extension, does not have tray menu items", () => {
runInAction(() => {
extensionsStub.delete("some-extension-id");
});
expect(trayMenuItems.get()).toHaveLength(0);
});
describe("when other extension is enabled", () => {
beforeEach(() => {
const someOtherExtension = new SomeTestExtension({
id: "some-extension-id",
trayMenus: [{ label: "some-label-from-second-extension" }],
});
runInAction(() => {
extensionsStub.set("some-other-extension-id", someOtherExtension);
});
});
it("has tray menu items for both extensions", () => {
expect(trayMenuItems.get()).toEqual([
{
label: "tray-menu-from-some-extension",
},
{
label: "some-label-from-second-extension",
},
]);
});
it("when extension is disabled, still returns tray menu items for extensions that are enabled", () => {
runInAction(() => {
extensionsStub.delete("some-other-extension-id");
});
expect(trayMenuItems.get()).toEqual([
{
label: "tray-menu-from-some-extension",
},
]);
});
});
});
});
class SomeTestExtension extends LensMainExtension {
constructor({ id, trayMenus }: {
id: string;
trayMenus: TrayMenuRegistration[];
}) {
super({
id,
absolutePath: "irrelevant",
isBundled: false,
isCompatible: false,
isEnabled: false,
manifest: { name: id, version: "some-version", engines: { lens: "^5.5.0" }},
manifestPath: "irrelevant",
});
this.trayMenus = trayMenus;
}
}

View File

@ -6,7 +6,7 @@ import type { LensRendererExtension } from "../../../extensions/lens-renderer-ex
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable"; import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable"; import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable";
import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token"; import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token";
import type { IObservableArray } from "mobx"; import type { IObservableArray, ObservableSet } from "mobx";
import { computed, observable, runInAction } from "mobx"; import { computed, observable, runInAction } from "mobx";
import { renderFor } from "./renderFor"; import { renderFor } from "./renderFor";
import React from "react"; import React from "react";
@ -24,7 +24,7 @@ import type { ClusterStore } from "../../../common/cluster-store/cluster-store";
import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable"; import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable";
import currentRouteComponentInjectable from "../../routes/current-route-component.injectable"; import currentRouteComponentInjectable from "../../routes/current-route-component.injectable";
import { pipeline } from "@ogre-tools/fp"; import { pipeline } from "@ogre-tools/fp";
import { flatMap, compact, join, get, filter, map, matches, find } from "lodash/fp"; import { flatMap, compact, join, get, filter, map, matches } from "lodash/fp";
import preferenceNavigationItemsInjectable from "../+preferences/preferences-navigation/preference-navigation-items.injectable"; import preferenceNavigationItemsInjectable from "../+preferences/preferences-navigation/preference-navigation-items.injectable";
import navigateToPreferencesInjectable from "../../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; import navigateToPreferencesInjectable from "../../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable";
import type { MenuItemOpts } from "../../../main/menu/application-menu-items.injectable"; import type { MenuItemOpts } from "../../../main/menu/application-menu-items.injectable";
@ -56,13 +56,31 @@ import { openMenu } from "react-select-event";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { StatusBar } from "../status-bar/status-bar"; import { StatusBar } from "../status-bar/status-bar";
import lensProxyPortInjectable from "../../../main/lens-proxy/lens-proxy-port.injectable"; import lensProxyPortInjectable from "../../../main/lens-proxy/lens-proxy-port.injectable";
import type { LensMainExtension } from "../../../extensions/lens-main-extension";
import trayMenuItemsInjectable from "../../../main/tray/tray-menu-item/tray-menu-items.injectable";
import type { LensExtension } from "../../../extensions/lens-extension";
type Callback = (dis: DiContainers) => void | Promise<void>; type Callback = (dis: DiContainers) => void | Promise<void>;
type EnableExtensions<T> = (...extensions: T[]) => Promise<void>;
type DisableExtensions<T> = (...extensions: T[]) => void;
export interface ApplicationBuilder { export interface ApplicationBuilder {
dis: DiContainers; dis: DiContainers;
setEnvironmentToClusterFrame: () => ApplicationBuilder; setEnvironmentToClusterFrame: () => ApplicationBuilder;
addExtensions: (...extensions: LensRendererExtension[]) => Promise<ApplicationBuilder>;
extensions: {
renderer: {
enable: EnableExtensions<LensRendererExtension>;
disable: DisableExtensions<LensRendererExtension>;
};
main: {
enable: EnableExtensions<LensMainExtension>;
disable: DisableExtensions<LensMainExtension>;
};
};
allowKubeResource: (resourceName: KubeResource) => ApplicationBuilder; allowKubeResource: (resourceName: KubeResource) => ApplicationBuilder;
beforeApplicationStart: (callback: Callback) => ApplicationBuilder; beforeApplicationStart: (callback: Callback) => ApplicationBuilder;
beforeRender: (callback: Callback) => ApplicationBuilder; beforeRender: (callback: Callback) => ApplicationBuilder;
@ -135,7 +153,8 @@ export const getApplicationBuilder = () => {
const beforeApplicationStartCallbacks: Callback[] = []; const beforeApplicationStartCallbacks: Callback[] = [];
const beforeRenderCallbacks: Callback[] = []; const beforeRenderCallbacks: Callback[] = [];
const extensionsState = observable.array<LensRendererExtension>(); const rendererExtensionsState = observable.set<LensRendererExtension>();
const mainExtensionsState = observable.set<LensMainExtension>();
rendererDi.override(subscribeStoresInjectable, () => () => () => {}); rendererDi.override(subscribeStoresInjectable, () => () => () => {});
@ -174,14 +193,13 @@ export const getApplicationBuilder = () => {
); );
rendererDi.override(rendererExtensionsInjectable, () => rendererDi.override(rendererExtensionsInjectable, () =>
computed(() => extensionsState), computed(() => [...rendererExtensionsState]),
); );
mainDi.override(mainExtensionsInjectable, () => mainDi.override(mainExtensionsInjectable, () =>
computed(() => []), computed(() => [...mainExtensionsState]),
); );
let trayMenuItemsStateFake: TrayMenuItem[];
let trayMenuIconPath: string; let trayMenuIconPath: string;
mainDi.override(electronTrayInjectable, () => ({ mainDi.override(electronTrayInjectable, () => ({
@ -191,9 +209,7 @@ export const getApplicationBuilder = () => {
trayMenuIconPath = iconPaths.normal; trayMenuIconPath = iconPaths.normal;
}, },
stop: () => {}, stop: () => {},
setMenuItems: (items) => { setMenuItems: () => {},
trayMenuItemsStateFake = items;
},
setIconPath: (path) => { setIconPath: (path) => {
trayMenuIconPath = path; trayMenuIconPath = path;
}, },
@ -202,6 +218,43 @@ export const getApplicationBuilder = () => {
let allowedResourcesState: IObservableArray<KubeResource>; let allowedResourcesState: IObservableArray<KubeResource>;
let rendered: RenderResult; let rendered: RenderResult;
const enableExtensionsFor = <T extends ObservableSet>(
extensionState: T,
di: DiContainer,
) => {
let index = 0;
return async (...extensions: LensExtension[]) => {
const extensionRegistrators = di.injectMany(
extensionRegistratorInjectionToken,
);
const addAndEnableExtensions = async () => {
index++;
const registratorPromises = extensions.flatMap((extension) =>
extensionRegistrators.map((registrator) =>
registrator(extension, index),
),
);
await Promise.all(registratorPromises);
runInAction(() => {
extensions.forEach((extension) => {
extensionState.add(extension);
});
});
};
if (rendered) {
await addAndEnableExtensions();
} else {
builder.beforeRender(addAndEnableExtensions);
}
};
};
const builder: ApplicationBuilder = { const builder: ApplicationBuilder = {
dis, dis,
@ -242,18 +295,20 @@ export const getApplicationBuilder = () => {
tray: { tray: {
get: (id: string) => { get: (id: string) => {
return trayMenuItemsStateFake.find(matches({ id })) ?? null; const trayMenuItems = mainDi.inject(trayMenuItemsInjectable).get();
return trayMenuItems.find(matches({ id })) ?? null;
}, },
getIconPath: () => trayMenuIconPath, getIconPath: () => trayMenuIconPath,
click: async (id: string) => { click: async (id: string) => {
const menuItem = pipeline( const trayMenuItems = mainDi.inject(trayMenuItemsInjectable).get();
trayMenuItemsStateFake,
find((menuItem) => menuItem.id === id), const menuItem = trayMenuItems.find(matches({ id })) ?? null;
);
if (!menuItem) { if (!menuItem) {
const availableIds = pipeline( const availableIds = pipeline(
trayMenuItemsStateFake, trayMenuItems,
filter(item => !!item.click), filter(item => !!item.click),
map(item => item.id), map(item => item.id),
join(", "), join(", "),
@ -345,32 +400,16 @@ export const getApplicationBuilder = () => {
return builder; return builder;
}, },
addExtensions: async (...extensions) => { extensions: {
const extensionRegistrators = rendererDi.injectMany( renderer: {
extensionRegistratorInjectionToken, enable: enableExtensionsFor(rendererExtensionsState, rendererDi),
); disable: disableExtensionsFor(rendererExtensionsState),
},
const addAndEnableExtensions = async () => { main: {
const registratorPromises = extensions.flatMap((extension) => enable: enableExtensionsFor(mainExtensionsState, mainDi),
extensionRegistrators.map((registrator) => registrator(extension, 1)), disable: disableExtensionsFor(mainExtensionsState),
); },
await Promise.all(registratorPromises);
runInAction(() => {
extensions.forEach((extension) => {
extensionsState.push(extension);
});
});
};
if (rendered) {
await addAndEnableExtensions();
} else {
builder.beforeRender(addAndEnableExtensions);
}
return builder;
}, },
allowKubeResource: (resourceName) => { allowKubeResource: (resourceName) => {
@ -494,3 +533,14 @@ function toFlatChildren(parentId: string | null | undefined): ToFlatChildren {
), ),
]; ];
} }
const disableExtensionsFor =
<T extends ObservableSet>(extensionState: T) =>
(...extensions: LensExtension[]) => {
extensions.forEach((extension) => {
runInAction(() => {
extensionState.delete(extension);
});
});
};