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

Merge remote-tracking branch 'origin/master' into eliminate-gst-from-app-paths

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen 2022-01-05 14:45:39 +02:00
commit 4224937260
No known key found for this signature in database
GPG Key ID: 5F465B5672372402
52 changed files with 1168 additions and 409 deletions

View File

@ -37,9 +37,9 @@ export default class ExampleMainExtension extends Main.LensExtension {
} }
``` ```
### App Menus ### Menus
This extension can register custom app menus that will be displayed on OS native menus. This extension can register custom app and tray menus that will be displayed on OS native menus.
Example: Example:
@ -56,6 +56,29 @@ export default class ExampleMainExtension extends Main.LensExtension {
} }
} }
] ]
trayMenus = [
{
label: "My links",
submenu: [
{
label: "Lens",
click() {
Main.Navigation.navigate("https://k8slens.dev");
}
},
{
type: "separator"
},
{
label: "Lens Github",
click() {
Main.Navigation.navigate("https://github.com/lensapp/lens");
}
}
]
}
]
} }
``` ```

View File

@ -3,7 +3,7 @@
The Main Extension API is the interface to Lens's main process. The Main Extension API is the interface to Lens's main process.
Lens runs in both main and renderer processes. Lens runs in both main and renderer processes.
The Main Extension API allows you to access, configure, and customize Lens data, add custom application menu items and [protocol handlers](protocol-handlers.md), and run custom code in Lens's main process. The Main Extension API allows you to access, configure, and customize Lens data, add custom application menu items and [protocol handlers](protocol-handlers.md), and run custom code in Lens's main process.
It also provides convenient methods for navigating to built-in Lens pages and extension pages, as well as adding and removing sources of catalog entities. It also provides convenient methods for navigating to built-in Lens pages and extension pages, as well as adding and removing sources of catalog entities.
## `Main.LensExtension` Class ## `Main.LensExtension` Class
@ -45,7 +45,6 @@ For more details on accessing Lens state data, please see the [Stores](../stores
### `appMenus` ### `appMenus`
The Main Extension API allows you to customize the UI application menu. The Main Extension API allows you to customize the UI application menu.
Note that this is the only UI feature that the Main Extension API allows you to customize.
The following example demonstrates adding an item to the **Help** menu. The following example demonstrates adding an item to the **Help** menu.
``` typescript ``` typescript
@ -65,7 +64,7 @@ export default class SamplePageMainExtension extends Main.LensExtension {
``` ```
`appMenus` is an array of objects that satisfy the `MenuRegistration` interface. `appMenus` is an array of objects that satisfy the `MenuRegistration` interface.
`MenuRegistration` extends React's `MenuItemConstructorOptions` interface. `MenuRegistration` extends Electron's `MenuItemConstructorOptions` interface.
The properties of the appMenus array objects are defined as follows: The properties of the appMenus array objects are defined as follows:
* `parentId` is the name of the menu where your new menu item will be listed. * `parentId` is the name of the menu where your new menu item will be listed.
@ -96,6 +95,35 @@ export default class SamplePageMainExtension extends Main.LensExtension {
When the menu item is clicked the `navigate()` method looks for and displays a global page with id `"myGlobalPage"`. When the menu item is clicked the `navigate()` method looks for and displays a global page with id `"myGlobalPage"`.
This page would be defined in your extension's `Renderer.LensExtension` implementation (See [`Renderer.LensExtension`](renderer-extension.md)). This page would be defined in your extension's `Renderer.LensExtension` implementation (See [`Renderer.LensExtension`](renderer-extension.md)).
### `trayMenus`
`trayMenus` is an array of `TrayMenuRegistration` objects. Most importantly you can define a `label` and a `click` handler. Other properties are `submenu`, `enabled`, `toolTip`, `id` and `type`.
``` typescript
interface TrayMenuRegistration {
label?: string;
click?: (menuItem: TrayMenuRegistration) => void;
id?: string;
type?: "normal" | "separator" | "submenu"
toolTip?: string;
enabled?: boolean;
submenu?: TrayMenuRegistration[]
}
```
The following example demonstrates how tray menus can be added from extension:
``` typescript
import { Main } from "@k8slens/extensions";
export default class SampleTrayMenuMainExtension extends Main.LensExtension {
trayMenus = [{
label: "menu from the extension",
click: () => { console.log("tray menu clicked!") }
}]
}
```
### `addCatalogSource()` and `removeCatalogSource()` Methods ### `addCatalogSource()` and `removeCatalogSource()` Methods
The `Main.LensExtension` class also provides the `addCatalogSource()` and `removeCatalogSource()` methods, for managing custom catalog items (or entities). The `Main.LensExtension` class also provides the `addCatalogSource()` and `removeCatalogSource()` methods, for managing custom catalog items (or entities).

View File

@ -272,10 +272,7 @@ export class ExtensionLoader {
registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.EntitySettingRegistry.getInstance().add(extension.entitySettings),
registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems),
registries.CommandRegistry.getInstance().add(extension.commands), registries.CommandRegistry.getInstance().add(extension.commands),
registries.WelcomeMenuRegistry.getInstance().add(extension.welcomeMenus),
registries.WelcomeBannerRegistry.getInstance().add(extension.welcomeBanners),
registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems),
registries.TopBarRegistry.getInstance().add(extension.topBarItems),
]; ];
this.events.on("remove", (removedExtension: LensRendererExtension) => { this.events.on("remove", (removedExtension: LensRendererExtension) => {

View File

@ -25,9 +25,10 @@ import { catalogEntityRegistry } from "../main/catalog";
import type { CatalogEntity } from "../common/catalog"; import type { CatalogEntity } from "../common/catalog";
import type { IObservableArray } from "mobx"; import type { IObservableArray } from "mobx";
import type { MenuRegistration } from "../main/menu/menu-registration"; import type { MenuRegistration } from "../main/menu/menu-registration";
import type { TrayMenuRegistration } from "../main/tray/tray-menu-registration";
export class LensMainExtension extends LensExtension { export class LensMainExtension extends LensExtension {
appMenus: MenuRegistration[] = []; appMenus: MenuRegistration[] = [];
trayMenus: TrayMenuRegistration[] = [];
async navigate(pageId?: string, params?: Record<string, any>, frameId?: number) { async navigate(pageId?: string, params?: Record<string, any>, frameId?: number) {
return WindowManager.getInstance().navigateExtension(this.id, pageId, params, frameId); return WindowManager.getInstance().navigateExtension(this.id, pageId, params, frameId);

View File

@ -26,7 +26,10 @@ import type { CatalogEntity } from "../common/catalog";
import type { Disposer } from "../common/utils"; import type { Disposer } from "../common/utils";
import { catalogEntityRegistry, EntityFilter } from "../renderer/api/catalog-entity-registry"; import { catalogEntityRegistry, EntityFilter } from "../renderer/api/catalog-entity-registry";
import { catalogCategoryRegistry, CategoryFilter } from "../renderer/api/catalog-category-registry"; import { catalogCategoryRegistry, CategoryFilter } from "../renderer/api/catalog-category-registry";
import type { TopBarRegistration } from "../renderer/components/layout/top-bar/top-bar-registration";
import type { KubernetesCluster } from "../common/catalog-entities"; import type { KubernetesCluster } from "../common/catalog-entities";
import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration";
import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration";
export class LensRendererExtension extends LensExtension { export class LensRendererExtension extends LensExtension {
globalPages: registries.PageRegistration[] = []; globalPages: registries.PageRegistration[] = [];
@ -40,10 +43,10 @@ export class LensRendererExtension extends LensExtension {
kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = []; kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = [];
kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = [];
commands: registries.CommandRegistration[] = []; commands: registries.CommandRegistration[] = [];
welcomeMenus: registries.WelcomeMenuRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = [];
welcomeBanners: registries.WelcomeBannerRegistration[] = []; welcomeBanners: WelcomeBannerRegistration[] = [];
catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = [];
topBarItems: registries.TopBarRegistration[] = []; topBarItems: TopBarRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) { async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation"); const { navigate } = await import("../renderer/navigation");

View File

@ -30,9 +30,6 @@ export * from "./kube-object-menu-registry";
export * from "./kube-object-status-registry"; export * from "./kube-object-status-registry";
export * from "./command-registry"; export * from "./command-registry";
export * from "./entity-setting-registry"; export * from "./entity-setting-registry";
export * from "./welcome-menu-registry";
export * from "./welcome-banner-registry";
export * from "./catalog-entity-detail-registry"; export * from "./catalog-entity-detail-registry";
export * from "./workloads-overview-detail-registry"; export * from "./workloads-overview-detail-registry";
export * from "./topbar-registry";
export * from "./protocol-handler"; export * from "./protocol-handler";

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { IComputedValue } from "mobx";
import extensionsInjectable from "./extensions.injectable";
import type { LensRendererExtension } from "./lens-renderer-extension";
const rendererExtensionsInjectable = getInjectable({
lifecycle: lifecycleEnum.singleton,
instantiate: (di) =>
di.inject(extensionsInjectable) as IComputedValue<LensRendererExtension[]>,
});
export default rendererExtensionsInjectable;

View File

@ -55,28 +55,23 @@ import { WeblinkStore } from "../common/weblink-store";
import { SentryInit } from "../common/sentry"; import { SentryInit } from "../common/sentry";
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import { initMenu } from "./menu/menu"; import { initMenu } from "./menu/menu";
import { initTray } from "./tray";
import { kubeApiRequest } from "./proxy-functions"; import { kubeApiRequest } from "./proxy-functions";
import { initTray } from "./tray/tray";
import { ShellSession } from "./shell-session/shell-session"; import { ShellSession } from "./shell-session/shell-session";
import { getDi } from "./getDi"; import { getDi } from "./getDi";
import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable";
import lensProtocolRouterMainInjectable from "./protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable"; import lensProtocolRouterMainInjectable from "./protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable";
import extensionDiscoveryInjectable import extensionDiscoveryInjectable from "../extensions/extension-discovery/extension-discovery.injectable";
from "../extensions/extension-discovery/extension-discovery.injectable"; import directoryForExesInjectable from "../common/app-paths/directory-for-exes/directory-for-exes.injectable";
import directoryForExesInjectable import initIpcMainHandlersInjectable from "./initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable";
from "../common/app-paths/directory-for-exes/directory-for-exes.injectable";
import initIpcMainHandlersInjectable
from "./initializers/init-ipc-main-handlers/init-ipc-main-handlers.injectable";
import electronMenuItemsInjectable from "./menu/electron-menu-items.injectable"; import electronMenuItemsInjectable from "./menu/electron-menu-items.injectable";
import directoryForKubeConfigsInjectable import directoryForKubeConfigsInjectable from "../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable";
from "../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; import kubeconfigSyncManagerInjectable from "./catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable";
import kubeconfigSyncManagerInjectable
from "./catalog-sources/kubeconfig-sync-manager/kubeconfig-sync-manager.injectable";
import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable"; import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable";
import routerInjectable from "./router/router.injectable"; import routerInjectable from "./router/router.injectable";
import shellApiRequestInjectable import shellApiRequestInjectable from "./proxy-functions/shell-api-request/shell-api-request.injectable";
from "./proxy-functions/shell-api-request/shell-api-request.injectable";
import userStoreInjectable from "../common/user-store/user-store.injectable"; import userStoreInjectable from "../common/user-store/user-store.injectable";
import trayMenuItemsInjectable from "./tray/tray-menu-items.injectable";
const di = getDi(); const di = getDi();
@ -255,10 +250,11 @@ di.runSetups().then(() => {
const windowManager = WindowManager.createInstance(); const windowManager = WindowManager.createInstance();
const menuItems = di.inject(electronMenuItemsInjectable); const menuItems = di.inject(electronMenuItemsInjectable);
const trayMenuItems = di.inject(trayMenuItemsInjectable);
onQuitCleanup.push( onQuitCleanup.push(
initMenu(windowManager, menuItems), initMenu(windowManager, menuItems),
initTray(windowManager), initTray(windowManager, trayMenuItems),
() => ShellSession.cleanup(), () => ShellSession.cleanup(),
); );

View File

@ -29,8 +29,7 @@ const electronMenuItemsInjectable = getInjectable({
const extensions = di.inject(mainExtensionsInjectable); const extensions = di.inject(mainExtensionsInjectable);
return computed(() => return computed(() =>
extensions.get().flatMap((extension) => extension.appMenus), extensions.get().flatMap((extension) => extension.appMenus));
);
}, },
}); });

View File

@ -38,7 +38,7 @@ export class PrometheusOperator extends PrometheusProvider {
case "cluster": case "cluster":
switch (queryName) { switch (queryName) {
case "memoryUsage": case "memoryUsage":
return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes{node=~"${opts.nodes}"}`); return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}`);
case "workloadMemoryUsage": case "workloadMemoryUsage":
return `sum(container_memory_working_set_bytes{container!="", instance=~"${opts.nodes}"}) by (component)`; return `sum(container_memory_working_set_bytes{container!="", instance=~"${opts.nodes}"}) by (component)`;
case "memoryRequests": case "memoryRequests":
@ -50,7 +50,7 @@ export class PrometheusOperator extends PrometheusProvider {
case "memoryAllocatableCapacity": case "memoryAllocatableCapacity":
return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"})`; return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"})`;
case "cpuUsage": case "cpuUsage":
return `sum(rate(node_cpu_seconds_total{node=~"${opts.nodes}", mode=~"user|system"}[${this.rateAccuracy}]))`; return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])* on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`;
case "cpuRequests": case "cpuRequests":
return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`; return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`;
case "cpuLimits": case "cpuLimits":
@ -66,31 +66,31 @@ export class PrometheusOperator extends PrometheusProvider {
case "podAllocatableCapacity": case "podAllocatableCapacity":
return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"})`; return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"})`;
case "fsSize": case "fsSize":
return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`; return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`;
case "fsUsage": case "fsUsage":
return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`; return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`;
} }
break; break;
case "nodes": case "nodes":
switch (queryName) { switch (queryName) {
case "memoryUsage": case "memoryUsage":
return `sum (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (node)`; return `sum((node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) * on (pod, namespace) group_left(node) kube_pod_info) by (node)`;
case "workloadMemoryUsage": case "workloadMemoryUsage":
return `sum(container_memory_working_set_bytes{container!=""}) by (node)`; return `sum(container_memory_working_set_bytes{container!="POD", container!=""}) by (node)`;
case "memoryCapacity": case "memoryCapacity":
return `sum(kube_node_status_capacity{resource="memory"}) by (node)`; return `sum(kube_node_status_capacity{resource="memory"}) by (node)`;
case "memoryAllocatableCapacity": case "memoryAllocatableCapacity":
return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`; return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`;
case "cpuUsage": case "cpuUsage":
return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])) by(node)`; return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}]) * on (pod, namespace) group_left(node) kube_pod_info) by (node)`;
case "cpuCapacity": case "cpuCapacity":
return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
case "cpuAllocatableCapacity": case "cpuAllocatableCapacity":
return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
case "fsSize": case "fsSize":
return `sum(node_filesystem_size_bytes{mountpoint="/"}) by (node)`; return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info) by (node)`;
case "fsUsage": case "fsUsage":
return `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (node)`; return `sum((node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) * on (pod, namespace) group_left(node) kube_pod_info) by (node)`;
} }
break; break;
case "pods": case "pods":

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed } from "mobx";
import mainExtensionsInjectable from "../../extensions/main-extensions.injectable";
const trayItemsInjectable = getInjectable({
lifecycle: lifecycleEnum.singleton,
instantiate: (di) => {
const extensions = di.inject(mainExtensionsInjectable);
return computed(() =>
extensions.get().flatMap(extension => extension.trayMenus));
},
});
export default trayItemsInjectable;

View File

@ -0,0 +1,136 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { ConfigurableDependencyInjectionContainer } 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: ConfigurableDependencyInjectionContainer;
let trayMenuItems: IComputedValue<TrayMenuRegistration[]>;
let extensionsStub: ObservableMap<string, LensMainExtension>;
beforeEach(() => {
di = getDiForUnitTesting();
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" },
manifestPath: "irrelevant",
});
this.trayMenus = trayMenus;
}
}

View File

@ -19,17 +19,12 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { catalogURL } from "../../common/routes"; export interface TrayMenuRegistration {
import { WelcomeMenuRegistry } from "../../extensions/registries"; label?: string;
import { navigate } from "../navigation"; click?: (menuItem: TrayMenuRegistration) => void;
id?: string;
export function initWelcomeMenuRegistry() { type?: "normal" | "separator" | "submenu"
WelcomeMenuRegistry.getInstance() toolTip?: string;
.add([ enabled?: boolean;
{ submenu?: TrayMenuRegistration[]
title: "Browse Clusters in Catalog",
icon: "view_list",
click: () => navigate(catalogURL({ params: { group: "entity.k8slens.dev", kind: "KubernetesCluster" }} )),
},
]);
} }

View File

@ -20,16 +20,18 @@
*/ */
import path from "path"; import path from "path";
import packageInfo from "../../package.json"; import packageInfo from "../../../package.json";
import { Menu, Tray } from "electron"; import { Menu, Tray } from "electron";
import { autorun } from "mobx"; import { autorun, IComputedValue } from "mobx";
import { showAbout } from "./menu/menu"; import { showAbout } from "../menu/menu";
import { checkForUpdates, isAutoUpdateEnabled } from "./app-updater"; import { checkForUpdates, isAutoUpdateEnabled } from "../app-updater";
import type { WindowManager } from "./window-manager"; import type { WindowManager } from "../window-manager";
import logger from "./logger"; import logger from "../logger";
import { isDevelopment, isWindows, productName } from "../common/vars"; import { isDevelopment, isWindows, productName } from "../../common/vars";
import { exitApp } from "./exit-app"; import { exitApp } from "../exit-app";
import { preferencesURL } from "../common/routes"; import { preferencesURL } from "../../common/routes";
import { toJS } from "../../common/utils";
import type { TrayMenuRegistration } from "./tray-menu-registration";
const TRAY_LOG_PREFIX = "[TRAY]"; const TRAY_LOG_PREFIX = "[TRAY]";
@ -44,7 +46,10 @@ export function getTrayIcon(): string {
); );
} }
export function initTray(windowManager: WindowManager) { export function initTray(
windowManager: WindowManager,
trayMenuItems: IComputedValue<TrayMenuRegistration[]>,
) {
const icon = getTrayIcon(); const icon = getTrayIcon();
tray = new Tray(icon); tray = new Tray(icon);
@ -62,7 +67,7 @@ export function initTray(windowManager: WindowManager) {
const disposers = [ const disposers = [
autorun(() => { autorun(() => {
try { try {
const menu = createTrayMenu(windowManager); const menu = createTrayMenu(windowManager, toJS(trayMenuItems.get()));
tray.setContextMenu(menu); tray.setContextMenu(menu);
} catch (error) { } catch (error) {
@ -78,8 +83,21 @@ export function initTray(windowManager: WindowManager) {
}; };
} }
function createTrayMenu(windowManager: WindowManager): Menu { function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions {
const template: Electron.MenuItemConstructorOptions[] = [ return {
...trayItem,
submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined,
click: trayItem.click ? () => {
trayItem.click(trayItem);
} : undefined,
};
}
function createTrayMenu(
windowManager: WindowManager,
extensionTrayItems: TrayMenuRegistration[],
): Menu {
let template: Electron.MenuItemConstructorOptions[] = [
{ {
label: `Open ${productName}`, label: `Open ${productName}`,
click() { click() {
@ -108,6 +126,8 @@ function createTrayMenu(windowManager: WindowManager): Menu {
}); });
} }
template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions));
return Menu.buildFromTemplate(template.concat([ return Menu.buildFromTemplate(template.concat([
{ {
label: `About ${productName}`, label: `About ${productName}`,

View File

@ -107,9 +107,6 @@ export async function bootstrap(di: DependencyInjectionContainer) {
logger.info(`${logPrefix} initializing KubeObjectDetailRegistry`); logger.info(`${logPrefix} initializing KubeObjectDetailRegistry`);
initializers.initKubeObjectDetailRegistry(); initializers.initKubeObjectDetailRegistry();
logger.info(`${logPrefix} initializing WelcomeMenuRegistry`);
initializers.initWelcomeMenuRegistry();
logger.info(`${logPrefix} initializing WorkloadsOverviewDetailRegistry`); logger.info(`${logPrefix} initializing WorkloadsOverviewDetailRegistry`);
initializers.initWorkloadsOverviewDetailRegistry(); initializers.initWorkloadsOverviewDetailRegistry();

View File

@ -27,7 +27,7 @@ import { ThemeStore } from "../../theme.store";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { Input } from "../input"; import { Input } from "../input";
import { isWindows } from "../../../common/vars"; import { isWindows } from "../../../common/vars";
import { FormSwitch, Switcher } from "../switch"; import { Switch } from "../switch";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { CONSTANTS, defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../common/user-store/preferences-helpers"; import { CONSTANTS, defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../common/user-store/preferences-helpers";
import { action } from "mobx"; import { action } from "mobx";
@ -86,16 +86,12 @@ export const Application = observer(() => {
<section id="terminalSelection"> <section id="terminalSelection">
<SubTitle title="Terminal copy & paste" /> <SubTitle title="Terminal copy & paste" />
<FormSwitch <Switch
label="Copy on select and paste on right-click" checked={userStore.terminalCopyOnSelect}
control={ onChange={() => userStore.terminalCopyOnSelect = !userStore.terminalCopyOnSelect}
<Switcher >
checked={userStore.terminalCopyOnSelect} Copy on select and paste on right-click
onChange={v => userStore.terminalCopyOnSelect = v.target.checked} </Switch>
name="terminalCopyOnSelect"
/>
}
/>
</section> </section>
<hr/> <hr/>
@ -135,16 +131,9 @@ export const Application = observer(() => {
<section id="other"> <section id="other">
<SubTitle title="Start-up"/> <SubTitle title="Start-up"/>
<FormSwitch <Switch checked={userStore.openAtLogin} onChange={() => userStore.openAtLogin = !userStore.openAtLogin}>
control={ Automatically start Lens on login
<Switcher </Switch>
checked={userStore.openAtLogin}
onChange={v => userStore.openAtLogin = v.target.checked}
name="startup"
/>
}
label="Automatically start Lens on login"
/>
</section> </section>
<hr /> <hr />

View File

@ -21,7 +21,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import React from "react"; import React from "react";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { FormSwitch, Switcher } from "../switch"; import { Switch } from "../switch";
import { Select } from "../select"; import { Select } from "../select";
import { SubTitle } from "../layout/sub-title"; import { SubTitle } from "../layout/sub-title";
import { SubHeader } from "../layout/sub-header"; import { SubHeader } from "../layout/sub-header";
@ -45,15 +45,12 @@ export const Editor = observer(() => {
<section> <section>
<div className="flex gaps justify-space-between"> <div className="flex gaps justify-space-between">
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<FormSwitch <Switch
label={<SubHeader compact>Show minimap</SubHeader>} checked={editorConfiguration.minimap.enabled}
control={ onChange={() => editorConfiguration.minimap.enabled = !editorConfiguration.minimap.enabled}
<Switcher >
checked={editorConfiguration.minimap.enabled} Show minimap
onChange={(evt, checked) => editorConfiguration.minimap.enabled = checked} </Switch>
/>
}
/>
</div> </div>
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<SubHeader compact>Position</SubHeader> <SubHeader compact>Position</SubHeader>

View File

@ -25,7 +25,7 @@ import { SubTitle } from "../layout/sub-title";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { bundledKubectlPath } from "../../../main/kubectl/kubectl"; import { bundledKubectlPath } from "../../../main/kubectl/kubectl";
import { SelectOption, Select } from "../select"; import { SelectOption, Select } from "../select";
import { FormSwitch, Switcher } from "../switch"; import { Switch } from "../switch";
import { packageMirrors } from "../../../common/user-store/preferences-helpers"; import { packageMirrors } from "../../../common/user-store/preferences-helpers";
import directoryForBinariesInjectable import directoryForBinariesInjectable
from "../../../common/app-paths/directory-for-binaries/directory-for-binaries.injectable"; from "../../../common/app-paths/directory-for-binaries/directory-for-binaries.injectable";
@ -54,16 +54,12 @@ const NonInjectedKubectlBinaries: React.FC<Dependencies> = (({ defaultPathForKub
<> <>
<section> <section>
<SubTitle title="Kubectl binary download"/> <SubTitle title="Kubectl binary download"/>
<FormSwitch <Switch
control={ checked={userStore.downloadKubectlBinaries}
<Switcher onChange={() => userStore.downloadKubectlBinaries = !userStore.downloadKubectlBinaries}
checked={userStore.downloadKubectlBinaries} >
onChange={v => userStore.downloadKubectlBinaries = v.target.checked} Download kubectl binaries matching the Kubernetes cluster version
name="kubectl-download" </Switch>
/>
}
label="Download kubectl binaries matching the Kubernetes cluster version"
/>
</section> </section>
<section> <section>

View File

@ -24,10 +24,11 @@ import React from "react";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { Input } from "../input"; import { Input } from "../input";
import { SubTitle } from "../layout/sub-title"; import { SubTitle } from "../layout/sub-title";
import { FormSwitch, Switcher } from "../switch"; import { Switch } from "../switch";
export const LensProxy = observer(() => { export const LensProxy = observer(() => {
const [proxy, setProxy] = React.useState(UserStore.getInstance().httpsProxy || ""); const [proxy, setProxy] = React.useState(UserStore.getInstance().httpsProxy || "");
const store = UserStore.getInstance();
return ( return (
<section id="proxy"> <section id="proxy">
@ -50,16 +51,9 @@ export const LensProxy = observer(() => {
<section className="small"> <section className="small">
<SubTitle title="Certificate Trust"/> <SubTitle title="Certificate Trust"/>
<FormSwitch <Switch checked={store.allowUntrustedCAs} onChange={() => store.allowUntrustedCAs = !store.allowUntrustedCAs}>
control={ Allow untrusted Certificate Authorities
<Switcher </Switch>
checked={UserStore.getInstance().allowUntrustedCAs}
onChange={v => UserStore.getInstance().allowUntrustedCAs = v.target.checked}
name="startup"
/>
}
label="Allow untrusted Certificate Authorities"
/>
<small className="hint"> <small className="hint">
This will make Lens to trust ANY certificate authority without any validations.{" "} This will make Lens to trust ANY certificate authority without any validations.{" "}
Needed with some corporate proxies that do certificate re-writing.{" "} Needed with some corporate proxies that do certificate re-writing.{" "}

View File

@ -20,45 +20,55 @@
*/ */
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { Welcome } from "../welcome"; import { defaultWidth, Welcome } from "../welcome";
import { TopBarRegistry, WelcomeMenuRegistry, WelcomeBannerRegistry } from "../../../../extensions/registries"; import { computed } from "mobx";
import { defaultWidth } from "../welcome"; import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { DiRender } from "../../test-utils/renderFor";
import { renderFor } from "../../test-utils/renderFor";
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
import { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import type { WelcomeBannerRegistration } from "../welcome-banner-items/welcome-banner-registration";
jest.mock( jest.mock("electron", () => ({
"electron", ipcRenderer: {
() => ({ on: jest.fn(),
ipcRenderer: { },
on: jest.fn(), app: {
}, getPath: () => "tmp",
app: { },
getPath: () => "tmp", }));
},
}),
);
describe("<Welcome/>", () => { describe("<Welcome/>", () => {
beforeEach(() => { let render: DiRender;
TopBarRegistry.createInstance(); let di: ConfigurableDependencyInjectionContainer;
WelcomeMenuRegistry.createInstance(); let welcomeBannersStub: WelcomeBannerRegistration[];
WelcomeBannerRegistry.createInstance();
});
afterEach(() => { beforeEach(() => {
TopBarRegistry.resetInstance(); di = getDiForUnitTesting();
WelcomeMenuRegistry.resetInstance();
WelcomeBannerRegistry.resetInstance(); render = renderFor(di);
welcomeBannersStub = [];
di.override(rendererExtensionsInjectable, () =>
computed(() => [
new TestExtension({
id: "some-id",
welcomeBanners: welcomeBannersStub,
}),
]),
);
}); });
it("renders <Banner /> registered in WelcomeBannerRegistry and hide logo", async () => { it("renders <Banner /> registered in WelcomeBannerRegistry and hide logo", async () => {
const testId = "testId"; const testId = "testId";
WelcomeBannerRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ welcomeBannersStub.push({
{ Banner: () => <div data-testid={testId} />,
Banner: () => <div data-testid={testId} />, });
},
]);
const { container } = render(<Welcome />); const { container } = render(<Welcome />);
@ -67,16 +77,15 @@ describe("<Welcome/>", () => {
}); });
it("calculates max width from WelcomeBanner.width registered in WelcomeBannerRegistry", async () => { it("calculates max width from WelcomeBanner.width registered in WelcomeBannerRegistry", async () => {
WelcomeBannerRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ welcomeBannersStub.push({
{ width: 100,
width: 100, Banner: () => <div />,
Banner: () => <div />, });
},
{ welcomeBannersStub.push({
width: 800, width: 800,
Banner: () => <div />, Banner: () => <div />,
}, });
]);
render(<Welcome />); render(<Welcome />);
@ -92,3 +101,25 @@ describe("<Welcome/>", () => {
}); });
}); });
}); });
class TestExtension extends LensRendererExtension {
constructor({
id,
welcomeBanners,
}: {
id: string;
welcomeBanners: WelcomeBannerRegistration[];
}) {
super({
id,
absolutePath: "irrelevant",
isBundled: false,
isCompatible: false,
isEnabled: false,
manifest: { name: id, version: "some-version" },
manifestPath: "irrelevant",
});
this.welcomeBanners = welcomeBanners;
}
}

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
import { computed } from "mobx";
const welcomeBannerItemsInjectable = getInjectable({
instantiate: (di) => {
const extensions = di.inject(rendererExtensionsInjectable);
return computed(() => [
...extensions.get().flatMap((extension) => extension.welcomeBanners),
]);
},
lifecycle: lifecycleEnum.singleton,
});
export default welcomeBannerItemsInjectable;

View File

@ -19,8 +19,6 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { BaseRegistry } from "./base-registry";
/** /**
* WelcomeBannerRegistration is for an extension to register * WelcomeBannerRegistration is for an extension to register
* Provide a Banner component to be renderered in the welcome screen. * Provide a Banner component to be renderered in the welcome screen.
@ -35,5 +33,3 @@ export interface WelcomeBannerRegistration {
*/ */
width?: number width?: number
} }
export class WelcomeBannerRegistry extends BaseRegistry<WelcomeBannerRegistration> { }

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { computed, IComputedValue } from "mobx";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import { navigate } from "../../../navigation";
import { catalogURL } from "../../../../common/routes";
interface Dependencies {
extensions: IComputedValue<LensRendererExtension[]>;
}
export const getWelcomeMenuItems = ({ extensions }: Dependencies) => {
const browseClusters = {
title: "Browse Clusters in Catalog",
icon: "view_list",
click: () =>
navigate(
catalogURL({
params: { group: "entity.k8slens.dev", kind: "KubernetesCluster" },
}),
),
};
return computed(() => [
browseClusters,
...extensions.get().flatMap((extension) => extension.welcomeMenus),
]);
};

View File

@ -0,0 +1,34 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
import { getWelcomeMenuItems } from "./get-welcome-menu-items";
const welcomeMenuItemsInjectable = getInjectable({
instantiate: (di) =>
getWelcomeMenuItems({
extensions: di.inject(rendererExtensionsInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
export default welcomeMenuItemsInjectable;

View File

@ -19,12 +19,8 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { BaseRegistry } from "./base-registry";
export interface WelcomeMenuRegistration { export interface WelcomeMenuRegistration {
title: string | (() => string); title: string | (() => string);
icon: string; icon: string;
click: () => void | Promise<void>; click: () => void | Promise<void>;
} }
export class WelcomeMenuRegistry extends BaseRegistry<WelcomeMenuRegistration> {}

View File

@ -22,78 +22,129 @@
import "./welcome.scss"; import "./welcome.scss";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { IComputedValue } from "mobx";
import Carousel from "react-material-ui-carousel"; import Carousel from "react-material-ui-carousel";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { productName, slackUrl } from "../../../common/vars"; import { productName, slackUrl } from "../../../common/vars";
import { WelcomeMenuRegistry } from "../../../extensions/registries"; import { withInjectables } from "@ogre-tools/injectable-react";
import { WelcomeBannerRegistry } from "../../../extensions/registries"; import welcomeMenuItemsInjectable from "./welcome-menu-items/welcome-menu-items.injectable";
import type { WelcomeMenuRegistration } from "./welcome-menu-items/welcome-menu-registration";
import welcomeBannerItemsInjectable from "./welcome-banner-items/welcome-banner-items.injectable";
import type { WelcomeBannerRegistration } from "./welcome-banner-items/welcome-banner-registration";
export const defaultWidth = 320; export const defaultWidth = 320;
@observer interface Dependencies {
export class Welcome extends React.Component { welcomeMenuItems: IComputedValue<WelcomeMenuRegistration[]>
render() { welcomeBannerItems: IComputedValue<WelcomeBannerRegistration[]>
const welcomeBanner = WelcomeBannerRegistry.getInstance().getItems(); }
// if there is banner with specified width, use it to calculate the width of the container const NonInjectedWelcome: React.FC<Dependencies> = ({ welcomeMenuItems, welcomeBannerItems }) => {
const maxWidth = welcomeBanner.reduce((acc, curr) => { const welcomeBanners = welcomeBannerItems.get();
const currWidth = curr.width ?? 0;
if (acc > currWidth) { // if there is banner with specified width, use it to calculate the width of the container
return acc; const maxWidth = welcomeBanners.reduce((acc, curr) => {
} const currWidth = curr.width ?? 0;
return currWidth; if (acc > currWidth) {
}, defaultWidth); return acc;
}
return ( return currWidth;
<div className="flex justify-center Welcome align-center"> }, defaultWidth);
<div style={{ width: `${maxWidth}px` }} data-testid="welcome-banner-container">
{welcomeBanner.length > 0 ? ( return (
<Carousel <div className="flex justify-center Welcome align-center">
stopAutoPlayOnHover={true} <div
indicators={welcomeBanner.length > 1} style={{ width: `${maxWidth}px` }}
autoPlay={true} data-testid="welcome-banner-container"
navButtonsAlwaysInvisible={true} >
indicatorIconButtonProps={{ {welcomeBanners.length > 0 ? (
style: { <Carousel
color: "var(--iconActiveBackground)", stopAutoPlayOnHover={true}
}, indicators={welcomeBanners.length > 1}
}} autoPlay={true}
activeIndicatorIconButtonProps={{ navButtonsAlwaysInvisible={true}
style: { indicatorIconButtonProps={{
color: "var(--iconActiveColor)", style: {
}, color: "var(--iconActiveBackground)",
}} },
interval={8000} }}
activeIndicatorIconButtonProps={{
style: {
color: "var(--iconActiveColor)",
},
}}
interval={8000}
>
{welcomeBanners.map((item, index) => (
<item.Banner key={index} />
))}
</Carousel>
) : (
<Icon svg="logo-lens" className="logo" />
)}
<div className="flex justify-center">
<div
style={{ width: `${defaultWidth}px` }}
data-testid="welcome-text-container"
>
<h2>Welcome to {productName} 5!</h2>
<p>
To get you started we have auto-detected your clusters in your
kubeconfig file and added them to the catalog, your centralized
view for managing all your cloud-native resources.
<br />
<br />
If you have any questions or feedback, please join our{" "}
<a
href={slackUrl}
target="_blank"
rel="noreferrer"
className="link"
>
Lens Community slack channel
</a>
.
</p>
<ul
className="block"
style={{ width: `${defaultWidth}px` }}
data-testid="welcome-menu-container"
> >
{welcomeBanner.map((item, index) => {welcomeMenuItems.get().map((item, index) => (
<item.Banner key={index} />, <li
)} key={index}
</Carousel> className="flex grid-12"
) : <Icon svg="logo-lens" className="logo" />} onClick={() => item.click()}
>
<div className="flex justify-center"> <Icon material={item.icon} className="box col-1" />
<div style={{ width: `${defaultWidth}px` }} data-testid="welcome-text-container"> <a className="box col-10">
<h2>Welcome to {productName} 5!</h2> {typeof item.title === "string"
? item.title
<p> : item.title()}
To get you started we have auto-detected your clusters in your kubeconfig file and added them to the catalog, your centralized view for managing all your cloud-native resources. </a>
<br /><br /> <Icon material="navigate_next" className="box col-1" />
If you have any questions or feedback, please join our <a href={slackUrl} target="_blank" rel="noreferrer" className="link">Lens Community slack channel</a>. </li>
</p> ))}
</ul>
<ul className="block" style={{ width: `${defaultWidth}px` }} data-testid="welcome-menu-container">
{WelcomeMenuRegistry.getInstance().getItems().map((item, index) => (
<li key={index} className="flex grid-12" onClick={() => item.click()}>
<Icon material={item.icon} className="box col-1" /> <a className="box col-10">{typeof item.title === "string" ? item.title : item.title()}</a> <Icon material="navigate_next" className="box col-1" />
</li>
))}
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
); </div>
} );
} };
export const Welcome = withInjectables<Dependencies>(
observer(NonInjectedWelcome),
{
getProps: (di) => ({
welcomeMenuItems: di.inject(welcomeMenuItemsInjectable),
welcomeBannerItems: di.inject(welcomeBannerItemsInjectable),
}),
},
);

View File

@ -91,6 +91,15 @@ html, body {
overflow: hidden; overflow: hidden;
} }
#terminal-init {
position: absolute;
top: 0;
left: 0;
height: 0;
visibility: hidden;
overflow: hidden;
}
#app { #app {
height: 100%; height: 100%;
min-height: 100%; min-height: 100%;

View File

@ -39,11 +39,10 @@ import { DeleteClusterDialog } from "../delete-cluster-dialog";
import { reaction } from "mobx"; import { reaction } from "mobx";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync"; import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync";
import { TopBar } from "../layout/topbar";
import { catalogURL, getPreviousTabUrl } from "../../../common/routes"; import { catalogURL, getPreviousTabUrl } from "../../../common/routes";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import catalogPreviousActiveTabStorageInjectable import { TopBar } from "../layout/top-bar/top-bar";
from "../+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable"; import catalogPreviousActiveTabStorageInjectable from "../+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
interface Dependencies { interface Dependencies {
catalogPreviousActiveTabStorage: { get: () => string } catalogPreviousActiveTabStorage: { get: () => string }

View File

@ -38,17 +38,9 @@ interface Dependencies {
} }
export class Terminal { export class Terminal {
public static readonly spawningPool = (() => { public static get spawningPool() {
// terminal element must be in DOM before attaching via xterm.open(elem) return document.getElementById("terminal-init");
// https://xtermjs.org/docs/api/terminal/classes/terminal/#open }
const pool = document.createElement("div");
pool.className = "terminal-init";
pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden";
document.body.appendChild(pool);
return pool;
})();
static async preloadFonts() { static async preloadFonts() {
const fontPath = require("../../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires const fontPath = require("../../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires

View File

@ -2,10 +2,6 @@
exports[`kube-object-menu given kube object renders 1`] = ` exports[`kube-object-menu given kube object renders 1`] = `
<body> <body>
<div
class="terminal-init"
style="position: absolute; top: 0px; left: 0px; height: 0px; visibility: hidden; overflow: hidden;"
/>
<div> <div>
<div> <div>
<ul <ul
@ -22,8 +18,8 @@ exports[`kube-object-menu given kube object renders 1`] = `
> >
<i <i
class="Icon material interactive focusable" class="Icon material interactive focusable"
id="tooltip_target_2"
tabindex="0" tabindex="0"
tooltip="Delete"
> >
<span <span
class="icon" class="icon"
@ -31,7 +27,6 @@ exports[`kube-object-menu given kube object renders 1`] = `
> >
delete delete
</span> </span>
<div />
</i> </i>
<span <span
class="title" class="title"
@ -46,20 +41,11 @@ exports[`kube-object-menu given kube object renders 1`] = `
class="Animate opacity-scale Dialog flex center ConfirmDialog modal" class="Animate opacity-scale Dialog flex center ConfirmDialog modal"
style="--enter-duration: 100ms; --leave-duration: 100ms;" style="--enter-duration: 100ms; --leave-duration: 100ms;"
/> />
<div
class="Tooltip narrow invisible formatter"
>
Delete
</div>
</body> </body>
`; `;
exports[`kube-object-menu given kube object when removing kube object renders 1`] = ` exports[`kube-object-menu given kube object when removing kube object renders 1`] = `
<body> <body>
<div
class="terminal-init"
style="position: absolute; top: 0px; left: 0px; height: 0px; visibility: hidden; overflow: hidden;"
/>
<div> <div>
<div> <div>
<ul <ul
@ -76,8 +62,8 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
> >
<i <i
class="Icon material interactive focusable" class="Icon material interactive focusable"
id="tooltip_target_4"
tabindex="0" tabindex="0"
tooltip="Delete"
> >
<span <span
class="icon" class="icon"
@ -85,7 +71,6 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
> >
delete delete
</span> </span>
<div />
</i> </i>
<span <span
class="title" class="title"
@ -153,20 +138,11 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`
</div> </div>
</div> </div>
</div> </div>
<div
class="Tooltip narrow invisible formatter"
>
Delete
</div>
</body> </body>
`; `;
exports[`kube-object-menu given kube object with namespace when removing kube object, renders confirmation dialog with namespace 1`] = ` exports[`kube-object-menu given kube object with namespace when removing kube object, renders confirmation dialog with namespace 1`] = `
<body> <body>
<div
class="terminal-init"
style="position: absolute; top: 0px; left: 0px; height: 0px; visibility: hidden; overflow: hidden;"
/>
<div> <div>
<div> <div>
<ul <ul
@ -183,8 +159,8 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
> >
<i <i
class="Icon material interactive focusable" class="Icon material interactive focusable"
id="tooltip_target_12"
tabindex="0" tabindex="0"
tooltip="Delete"
> >
<span <span
class="icon" class="icon"
@ -192,7 +168,6 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
> >
delete delete
</span> </span>
<div />
</i> </i>
<span <span
class="title" class="title"
@ -260,20 +235,11 @@ exports[`kube-object-menu given kube object with namespace when removing kube ob
</div> </div>
</div> </div>
</div> </div>
<div
class="Tooltip narrow invisible formatter"
>
Delete
</div>
</body> </body>
`; `;
exports[`kube-object-menu given kube object without namespace when removing kube object, renders confirmation dialog without namespace 1`] = ` exports[`kube-object-menu given kube object without namespace when removing kube object, renders confirmation dialog without namespace 1`] = `
<body> <body>
<div
class="terminal-init"
style="position: absolute; top: 0px; left: 0px; height: 0px; visibility: hidden; overflow: hidden;"
/>
<div> <div>
<div> <div>
<ul <ul
@ -290,8 +256,8 @@ exports[`kube-object-menu given kube object without namespace when removing kube
> >
<i <i
class="Icon material interactive focusable" class="Icon material interactive focusable"
id="tooltip_target_17"
tabindex="0" tabindex="0"
tooltip="Delete"
> >
<span <span
class="icon" class="icon"
@ -299,7 +265,6 @@ exports[`kube-object-menu given kube object without namespace when removing kube
> >
delete delete
</span> </span>
<div />
</i> </i>
<span <span
class="title" class="title"
@ -367,20 +332,11 @@ exports[`kube-object-menu given kube object without namespace when removing kube
</div> </div>
</div> </div>
</div> </div>
<div
class="Tooltip narrow invisible formatter"
>
Delete
</div>
</body> </body>
`; `;
exports[`kube-object-menu given no kube object, renders 1`] = ` exports[`kube-object-menu given no kube object, renders 1`] = `
<body> <body>
<div
class="terminal-init"
style="position: absolute; top: 0px; left: 0px; height: 0px; visibility: hidden; overflow: hidden;"
/>
<div> <div>
<ul <ul
class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom" class="Animate opacity Menu MenuActions flex KubeObjectMenu toolbar gaps right bottom"

View File

@ -42,6 +42,9 @@ 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";
// TODO: Make tooltips free of side effects by making it deterministic
jest.mock("../tooltip");
describe("kube-object-menu", () => { describe("kube-object-menu", () => {
let di: ConfigurableDependencyInjectionContainer; let di: ConfigurableDependencyInjectionContainer;
let render: DiRender; let render: DiRender;
@ -66,8 +69,7 @@ describe("kube-object-menu", () => {
getStore: api => undefined, getStore: api => undefined,
}) as ApiManager); }) as ApiManager);
di.override(hideDetailsInjectable, () => () => { di.override(hideDetailsInjectable, () => () => {});
});
di.override(editResourceTabInjectable, () => () => ({ di.override(editResourceTabInjectable, () => () => ({
id: "irrelevant", id: "irrelevant",

View File

@ -0,0 +1,52 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.closeButton {
width: 35px;
height: 35px;
display: grid;
place-items: center;
cursor: pointer;
border: 2px solid var(--textColorDimmed);
border-radius: 50%;
&:hover {
background-color: #72767d25;
}
&:active {
transform: translateY(1px);
}
}
.icon {
color: var(--textColorAccent);
opacity: 0.6;
}
.esc {
text-align: center;
margin-top: var(--margin);
font-weight: bold;
user-select: none;
color: var(--textColorDimmed);
pointer-events: none;
}

View File

@ -0,0 +1,41 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import styles from "./close-button.module.scss";
import React, { HTMLAttributes } from "react";
import { Icon } from "../icon";
interface Props extends HTMLAttributes<HTMLDivElement> {
}
export function CloseButton(props: Props) {
return (
<div {...props}>
<div className={styles.closeButton} role="button" aria-label="Close">
<Icon material="close" className={styles.icon}/>
</div>
<div className={styles.esc} aria-hidden="true">
ESC
</div>
</div>
);
}

View File

@ -138,41 +138,7 @@
} }
> .toolsRegion { > .toolsRegion {
.fixedTools { width: 45px;
position: fixed;
top: 60px;
.closeBtn {
width: 35px;
height: 35px;
display: grid;
place-items: center;
border: 2px solid var(--textColorDimmed);
border-radius: 50%;
cursor: pointer;
&:hover {
background-color: #72767d4d;
}
&:active {
transform: translateY(1px);
}
.Icon {
color: var(--textColorTertiary);
}
}
.esc {
text-align: center;
margin-top: 4px;
font-weight: 600;
font-size: 14px;
color: var(--textColorDimmed);
pointer-events: none;
}
}
} }
} }

View File

@ -25,8 +25,8 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { cssNames, IClassName } from "../../utils"; import { cssNames, IClassName } from "../../utils";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { Icon } from "../icon";
import { catalogURL } from "../../../common/routes"; import { catalogURL } from "../../../common/routes";
import { CloseButton } from "./close-button";
export interface SettingLayoutProps extends React.DOMAttributes<any> { export interface SettingLayoutProps extends React.DOMAttributes<any> {
className?: IClassName; className?: IClassName;
@ -104,13 +104,8 @@ export class SettingLayout extends React.Component<SettingLayoutProps> {
<div className="toolsRegion"> <div className="toolsRegion">
{ {
this.props.provideBackButtonNavigation && ( this.props.provideBackButtonNavigation && (
<div className="fixedTools"> <div className="fixed top-[60px]">
<div className="closeBtn" role="button" aria-label="Close" onClick={back}> <CloseButton onClick={back}/>
<Icon material="close" />
</div>
<div className="esc" aria-hidden="true">
ESC
</div>
</div> </div>
) )
} }

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed } from "mobx";
import rendererExtensionsInjectable from "../../../../../extensions/renderer-extensions.injectable";
const topBarItemsInjectable = getInjectable({
instantiate: (di) => {
const extensions = di.inject(rendererExtensionsInjectable);
return computed(() =>
extensions.get().flatMap((extension) => extension.topBarItems),
);
},
lifecycle: lifecycleEnum.singleton,
});
export default topBarItemsInjectable;

View File

@ -18,10 +18,6 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type React from "react";
import { BaseRegistry } from "./base-registry";
interface TopBarComponents { interface TopBarComponents {
Item: React.ComponentType; Item: React.ComponentType;
} }
@ -29,6 +25,3 @@ interface TopBarComponents {
export interface TopBarRegistration { export interface TopBarRegistration {
components: TopBarComponents; components: TopBarComponents;
} }
export class TopBarRegistry extends BaseRegistry<TopBarRegistration> {
}

View File

@ -20,23 +20,32 @@
*/ */
import React from "react"; import React from "react";
import { render, fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { TopBar } from "../topbar"; import { TopBar } from "./top-bar";
import { TopBarRegistry } from "../../../../extensions/registries";
import { IpcMainWindowEvents } from "../../../../main/window-manager"; import { IpcMainWindowEvents } from "../../../../main/window-manager";
import { broadcastMessage } from "../../../../common/ipc"; import { broadcastMessage } from "../../../../common/ipc";
import * as vars from "../../../../common/vars"; import * as vars from "../../../../common/vars";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import { DiRender, renderFor } from "../../test-utils/renderFor";
import directoryForUserDataInjectable
from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import mockFs from "mock-fs";
const mockConfig = vars as { isWindows: boolean, isLinux: boolean }; const mockConfig = vars as { isWindows: boolean; isLinux: boolean };
jest.mock("../../../../common/ipc"); jest.mock("../../../../common/ipc");
jest.mock("../../../../common/vars", () => { jest.mock("../../../../common/vars", () => {
const SemVer = require("semver").SemVer;
const versionStub = new SemVer("1.0.0");
return { return {
__esModule: true, __esModule: true,
isWindows: null, isWindows: null,
isLinux: null, isLinux: null,
appSemVer: versionStub,
}; };
}); });
@ -57,20 +66,30 @@ jest.mock("@electron/remote", () => {
}; };
}); });
describe("<Tobar/> in Windows and Linux", () => { describe("<TopBar/> in Windows and Linux", () => {
beforeEach(() => { let render: DiRender;
TopBarRegistry.createInstance();
beforeEach(async () => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
mockFs();
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
await di.runSetups();
render = renderFor(di);
}); });
afterEach(() => { afterEach(() => {
TopBarRegistry.resetInstance(); mockFs.restore();
}); });
it("shows window controls on Windows", () => { it("shows window controls on Windows", () => {
mockConfig.isWindows = true; mockConfig.isWindows = true;
mockConfig.isLinux = false; mockConfig.isLinux = false;
const { getByTestId } = render(<TopBar/>); const { getByTestId } = render(<TopBar />);
expect(getByTestId("window-menu")).toBeInTheDocument(); expect(getByTestId("window-menu")).toBeInTheDocument();
expect(getByTestId("window-minimize")).toBeInTheDocument(); expect(getByTestId("window-minimize")).toBeInTheDocument();
@ -82,7 +101,7 @@ describe("<Tobar/> in Windows and Linux", () => {
mockConfig.isWindows = false; mockConfig.isWindows = false;
mockConfig.isLinux = true; mockConfig.isLinux = true;
const { getByTestId } = render(<TopBar/>); const { getByTestId } = render(<TopBar />);
expect(getByTestId("window-menu")).toBeInTheDocument(); expect(getByTestId("window-menu")).toBeInTheDocument();
expect(getByTestId("window-minimize")).toBeInTheDocument(); expect(getByTestId("window-minimize")).toBeInTheDocument();
@ -93,7 +112,7 @@ describe("<Tobar/> in Windows and Linux", () => {
it("triggers ipc events on click", () => { it("triggers ipc events on click", () => {
mockConfig.isWindows = true; mockConfig.isWindows = true;
const { getByTestId } = render(<TopBar/>); const { getByTestId } = render(<TopBar />);
const menu = getByTestId("window-menu"); const menu = getByTestId("window-menu");
const minimize = getByTestId("window-minimize"); const minimize = getByTestId("window-minimize");

View File

@ -20,14 +20,26 @@
*/ */
import React from "react"; import React from "react";
import { render, fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { TopBar } from "../topbar"; import { TopBar } from "./top-bar";
import { TopBarRegistry } from "../../../../extensions/registries"; import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
import { DiRender, renderFor } from "../../test-utils/renderFor";
import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable";
import { computed } from "mobx";
import directoryForUserDataInjectable
from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import mockFs from "mock-fs";
jest.mock("../../../../common/vars", () => { jest.mock("../../../../common/vars", () => {
const SemVer = require("semver").SemVer;
const versionStub = new SemVer("1.0.0");
return { return {
isMac: true, isMac: true,
appSemVer: versionStub,
}; };
}); });
@ -47,9 +59,6 @@ jest.mock(
}, },
), ),
}, },
app: {
getPath: () => "tmp",
},
}), }),
); );
@ -76,12 +85,23 @@ jest.mock("@electron/remote", () => {
}); });
describe("<TopBar/>", () => { describe("<TopBar/>", () => {
beforeEach(() => { let di: ConfigurableDependencyInjectionContainer;
TopBarRegistry.createInstance(); let render: DiRender;
beforeEach(async () => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
mockFs();
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
await di.runSetups();
render = renderFor(di);
}); });
afterEach(() => { afterEach(() => {
TopBarRegistry.resetInstance(); mockFs.restore();
}); });
it("renders w/o errors", () => { it("renders w/o errors", () => {
@ -129,13 +149,13 @@ describe("<TopBar/>", () => {
const testId = "testId"; const testId = "testId";
const text = "an item"; const text = "an item";
TopBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ di.override(topBarItemsInjectable, () => computed(() => [
{ {
components: { components: {
Item: () => <span data-testid={testId}>{text}</span>, Item: () => <span data-testid={testId}>{text}</span>,
}, },
}, },
]); ]));
const { getByTestId } = render(<TopBar/>); const { getByTestId } = render(<TopBar/>);

View File

@ -19,22 +19,28 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import styles from "./topbar.module.scss"; import styles from "./top-bar.module.scss";
import React, { useEffect, useMemo, useRef } from "react"; import React, { useEffect, useMemo, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { TopBarRegistry } from "../../../extensions/registries"; import type { IComputedValue } from "mobx";
import { Icon } from "../icon"; import { Icon } from "../../icon";
import { webContents, getCurrentWindow } from "@electron/remote"; import { webContents, getCurrentWindow } from "@electron/remote";
import { observable } from "mobx"; import { observable } from "mobx";
import { broadcastMessage, ipcRendererOn } from "../../../common/ipc"; import { broadcastMessage, ipcRendererOn } from "../../../../common/ipc";
import { watchHistoryState } from "../../remote-helpers/history-updater"; import { watchHistoryState } from "../../../remote-helpers/history-updater";
import { isActiveRoute, navigate } from "../../navigation"; import { isActiveRoute, navigate } from "../../../navigation";
import { catalogRoute, catalogURL } from "../../../common/routes"; import { catalogRoute, catalogURL } from "../../../../common/routes";
import { IpcMainWindowEvents } from "../../../main/window-manager"; import { IpcMainWindowEvents } from "../../../../main/window-manager";
import { isLinux, isWindows } from "../../../common/vars"; import { isLinux, isWindows } from "../../../../common/vars";
import { cssNames } from "../../utils"; import { cssNames } from "../../../utils";
import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { TopBarRegistration } from "./top-bar-registration";
interface Props extends React.HTMLAttributes<any> { interface Props extends React.HTMLAttributes<any> {}
interface Dependencies {
items: IComputedValue<TopBarRegistration[]>;
} }
const prevEnabled = observable.box(false); const prevEnabled = observable.box(false);
@ -48,34 +54,10 @@ ipcRendererOn("history:can-go-forward", (event, state: boolean) => {
nextEnabled.set(state); nextEnabled.set(state);
}); });
export const TopBar = observer(({ children, ...rest }: Props) => { const NonInjectedTopBar = (({ items, children, ...rest }: Props & Dependencies) => {
const elem = useRef<HTMLDivElement>(); const elem = useRef<HTMLDivElement>();
const window = useMemo(() => getCurrentWindow(), []); const window = useMemo(() => getCurrentWindow(), []);
const renderRegisteredItems = () => {
const items = TopBarRegistry.getInstance().getItems();
if (!Array.isArray(items)) {
return null;
}
return (
<div>
{items.map((registration, index) => {
if (!registration?.components?.Item) {
return null;
}
return (
<div key={index}>
<registration.components.Item />
</div>
);
})}
</div>
);
};
const openContextMenu = () => { const openContextMenu = () => {
broadcastMessage(IpcMainWindowEvents.OPEN_CONTEXT_MENU); broadcastMessage(IpcMainWindowEvents.OPEN_CONTEXT_MENU);
}; };
@ -156,7 +138,7 @@ export const TopBar = observer(({ children, ...rest }: Props) => {
/> />
</div> </div>
<div className={styles.controls}> <div className={styles.controls}>
{renderRegisteredItems()} {renderRegisteredItems(items.get())}
{children} {children}
{(isWindows || isLinux) && ( {(isWindows || isLinux) && (
<div className={cssNames(styles.windowButtons, { [styles.linuxButtons]: isLinux })}> <div className={cssNames(styles.windowButtons, { [styles.linuxButtons]: isLinux })}>
@ -174,3 +156,29 @@ export const TopBar = observer(({ children, ...rest }: Props) => {
</div> </div>
); );
}); });
const renderRegisteredItems = (items: TopBarRegistration[]) => (
<div>
{items.map((registration, index) => {
if (!registration?.components?.Item) {
return null;
}
return (
<div key={index}>
<registration.components.Item />
</div>
);
})}
</div>
);
export const TopBar = withInjectables(observer(NonInjectedTopBar), {
getProps: (di, props) => ({
items: di.inject(topBarItemsInjectable),
...props,
}),
});

View File

@ -228,10 +228,15 @@ html {
} }
.Select { .Select {
&__value-container {
margin-top: 2px;
margin-bottom: 2px;
}
&__control { &__control {
box-shadow: 0 0 0 1px var(--inputControlBorder); box-shadow: 0 0 0 1px var(--inputControlBorder);
background: var(--inputControlBackground); background: var(--inputControlBackground);
border-radius: 5px; border-radius: var(--border-radius);
} }
&__single-value { &__single-value {

View File

@ -0,0 +1,67 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { Switch } from "..";
describe("<Switch/>", () => {
it("renders w/o errors", () => {
const { container } = render(<Switch />);
expect(container).toBeInstanceOf(HTMLElement);
});
it("render label text", () => {
const { getByLabelText } = render(<Switch>Test label</Switch>);
expect(getByLabelText("Test label")).toBeTruthy();
});
it("passes disabled and checked attributes to input", () => {
const { container } = render(<Switch checked disabled/>);
const checkbox = container.querySelector("input[type=checkbox]");
expect(checkbox).toHaveAttribute("disabled");
expect(checkbox).toHaveAttribute("checked");
});
it("onClick event fired", () => {
const onClick = jest.fn();
const { getByTestId } = render(<Switch onClick={onClick}/>);
const switcher = getByTestId("switch");
fireEvent.click(switcher);
expect(onClick).toHaveBeenCalled();
});
it("onClick event not fired for disabled item", () => {
const onClick = jest.fn();
const { getByTestId } = render(<Switch onClick={onClick} disabled/>);
const switcher = getByTestId("switch");
fireEvent.click(switcher);
expect(onClick).not.toHaveBeenCalled();
});
});

View File

@ -35,6 +35,9 @@ const useStyles = makeStyles({
}, },
}); });
/**
* @deprecated Use <Switch/> instead from "../switch.tsx".
*/
export function FormSwitch(props: FormControlLabelProps) { export function FormSwitch(props: FormControlLabelProps) {
const classes = useStyles(); const classes = useStyles();

View File

@ -21,3 +21,4 @@
export * from "./switcher"; export * from "./switcher";
export * from "./form-switcher"; export * from "./form-switcher";
export * from "./switch";

View File

@ -0,0 +1,121 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.Switch {
--thumb-size: 2rem;
--thumb-color: hsl(0 0% 100%);
--thumb-color-highlight: hsl(0 0% 100% / 25%);
--track-size: calc(var(--thumb-size) * 2);
--track-padding: 2px;
--track-color-inactive: hsl(80 0% 35%);
--track-color-active: hsl(110, 60%, 60%);
--thumb-position: 0%;
--thumb-transition-duration: .25s;
--hover-highlight-size: 0;
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
cursor: pointer;
user-select: none;
color: var(--textColorAccent);
font-weight: 500;
& > input {
padding: var(--track-padding);
background: var(--track-color-inactive);
inline-size: var(--track-size);
block-size: var(--thumb-size);
border-radius: var(--track-size);
appearance: none;
pointer-events: none;
border: none;
outline-offset: 5px;
box-sizing: content-box;
flex-shrink: 0;
display: grid;
align-items: center;
grid: [track] 1fr / [track] 1fr;
transition: background-color .25s ease;
&::before {
content: "";
cursor: pointer;
pointer-events: auto;
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
background: var(--thumb-color);
box-shadow: 0 0 0 var(--hover-highlight-size) var(--thumb-color-highlight);
border-radius: 50%;
transform: translateX(var(--thumb-position));
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}
&:not(:disabled):hover::before {
--hover-highlight-size: .5rem;
}
&:checked {
background: var(--track-color-active);
--thumb-position: 100%;
}
&:disabled {
--track-color-inactive: hsl(80 0% 30%);
--thumb-color: transparent;
&::before {
cursor: not-allowed;
box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 40%);
}
}
&:focus-visible {
box-shadow: 0 0 0 2px var(--blue);
}
}
&.disabled {
cursor: not-allowed;
}
}
@include theme-light {
.Switch {
--thumb-color-highlight: hsl(0 0% 0% / 25%);
& > input {
&:disabled {
--track-color-inactive: hsl(80 0% 80%);
}
}
}
}

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import styles from "./switch.module.scss";
import React, { ChangeEvent, HTMLProps } from "react";
import { cssNames } from "../../utils";
interface Props extends Omit<HTMLProps<HTMLInputElement>, "onChange"> {
onChange?: (checked: boolean, event: ChangeEvent<HTMLInputElement>) => void;
}
export function Switch({ children, disabled, onChange, ...props }: Props) {
return (
<label className={cssNames(styles.Switch, { [styles.disabled]: disabled })} data-testid="switch">
{children}
<input type="checkbox" role="switch" disabled={disabled} onChange={(event) => onChange?.(props.checked, event)} {...props}/>
</label>
);
}

View File

@ -31,6 +31,9 @@ interface Props extends SwitchProps {
classes: Styles; classes: Styles;
} }
/**
* @deprecated Use <Switch/> instead from "../switch.tsx".
*/
export const Switcher = withStyles((theme: Theme) => export const Switcher = withStyles((theme: Theme) =>
createStyles({ createStyles({
root: { root: {

View File

@ -27,7 +27,6 @@ export * from "./ipc";
export * from "./kube-object-detail-registry"; export * from "./kube-object-detail-registry";
export * from "./kube-object-menu-registry"; export * from "./kube-object-menu-registry";
export * from "./registries"; export * from "./registries";
export * from "./welcome-menu-registry";
export * from "./workloads-overview-detail-registry"; export * from "./workloads-overview-detail-registry";
export * from "./catalog-category-registry"; export * from "./catalog-category-registry";
export * from "./status-bar-registry"; export * from "./status-bar-registry";

View File

@ -33,8 +33,5 @@ export function initRegistries() {
registries.KubeObjectMenuRegistry.createInstance(); registries.KubeObjectMenuRegistry.createInstance();
registries.KubeObjectStatusRegistry.createInstance(); registries.KubeObjectStatusRegistry.createInstance();
registries.StatusBarRegistry.createInstance(); registries.StatusBarRegistry.createInstance();
registries.WelcomeMenuRegistry.createInstance();
registries.WelcomeBannerRegistry.createInstance();
registries.WorkloadsOverviewDetailRegistry.createInstance(); registries.WorkloadsOverviewDetailRegistry.createInstance();
registries.TopBarRegistry.createInstance();
} }

View File

@ -6,6 +6,7 @@
<body> <body>
<div id="app"></div> <div id="app"></div>
<div id="terminal-init"></div>
</body> </body>
</html> </html>

View File

@ -26,7 +26,14 @@ module.exports = {
fontFamily: { fontFamily: {
sans: ["Roboto", "Helvetica", "Arial", "sans-serif"], sans: ["Roboto", "Helvetica", "Arial", "sans-serif"],
}, },
extend: {}, extend: {
colors: {
textAccent: "var(--textColorAccent)",
textPrimary: "var(--textColorPrimary)",
textTertiary: "var(--textColorTertiary)",
textDimmed: "var(--textColorDimmed)",
},
},
}, },
variants: { variants: {
extend: {}, extend: {},