diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000000..0034429ff9 --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/lens.iml b/.idea/lens.iml index 079203f10b..fe491887dd 100644 --- a/.idea/lens.iml +++ b/.idea/lens.iml @@ -15,5 +15,6 @@ + \ No newline at end of file diff --git a/.idea/libraries/ogre_tools.xml b/.idea/libraries/ogre_tools.xml new file mode 100644 index 0000000000..6deeb167ce --- /dev/null +++ b/.idea/libraries/ogre_tools.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index 2d24504590..f062889b35 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,12 @@ "setupFiles": [ "/src/jest.setup.ts", "jest-canvas-mock" - ] + ], + "globals": { + "ts-jest": { + "isolatedModules": true + } + } }, "build": { "generateUpdatesFilesForAllChannels": true, @@ -191,10 +196,10 @@ "@hapi/call": "^8.0.1", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.16.1", + "@ogre-tools/injectable": "^1.3.0", + "@ogre-tools/injectable-react": "^1.3.1", "@sentry/electron": "^2.5.4", "@sentry/integrations": "^6.15.0", - "@ogre-tools/injectable": "^1.2.1", - "@ogre-tools/injectable-react": "^1.2.1", "abort-controller": "^3.0.0", "auto-bind": "^4.0.0", "autobind-decorator": "^2.4.0", @@ -225,9 +230,9 @@ "mobx": "^6.3.7", "mobx-observable-history": "^2.0.3", "mobx-react": "^7.2.1", - "mock-fs": "^4.14.0", + "mock-fs": "^5.1.2", "moment": "^2.29.1", - "moment-timezone": "^0.5.33", + "moment-timezone": "^0.5.34", "monaco-editor": "^0.29.1", "monaco-editor-webpack-plugin": "^5.0.0", "node-fetch": "lensapp/node-fetch#2.x", @@ -238,7 +243,7 @@ "proper-lockfile": "^4.1.2", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-material-ui-carousel": "^2.3.5", + "react-material-ui-carousel": "^2.3.8", "react-router": "^5.2.0", "react-virtualized-auto-sizer": "^1.0.6", "readable-stream": "^3.6.0", @@ -260,12 +265,13 @@ "ws": "^7.5.5" }, "devDependencies": { + "@async-fn/jest": "^1.5.0", "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@sentry/types": "^6.14.1", - "@testing-library/jest-dom": "^5.15.0", + "@testing-library/jest-dom": "^5.16.1", "@testing-library/react": "^11.2.7", "@testing-library/user-event": "^13.5.0", "@types/byline": "^4.2.33", @@ -280,7 +286,7 @@ "@types/html-webpack-plugin": "^3.2.6", "@types/http-proxy": "^1.17.7", "@types/jest": "^26.0.24", - "@types/js-yaml": "^4.0.4", + "@types/js-yaml": "^4.0.5", "@types/jsdom": "^16.2.13", "@types/jsonpath": "^0.2.0", "@types/lodash": "^4.14.177", @@ -312,9 +318,9 @@ "@types/tcp-port-used": "^1.0.0", "@types/tempy": "^0.3.0", "@types/triple-beam": "^1.3.2", - "@types/url-parse": "^1.4.4", + "@types/url-parse": "^1.4.5", "@types/uuid": "^8.3.3", - "@types/webpack": "^4.41.31", + "@types/webpack": "^4.41.32", "@types/webpack-dev-server": "^3.11.6", "@types/webpack-env": "^1.16.3", "@types/webpack-node-externals": "^1.7.1", @@ -353,7 +359,7 @@ "node-gyp": "7.1.2", "node-loader": "^1.0.3", "nodemon": "^2.0.14", - "playwright": "^1.15.2", + "playwright": "^1.17.1", "postcss": "^8.3.11", "postcss-loader": "4.3.0", "postinstall-postinstall": "^2.1.0", @@ -367,9 +373,9 @@ "react-select-event": "^5.1.0", "react-table": "^7.7.0", "react-window": "^1.8.6", - "sass": "^1.43.4", + "sass": "^1.44.0", "sass-loader": "^8.0.2", - "sharp": "^0.29.2", + "sharp": "^0.29.3", "style-loader": "^2.0.0", "tailwindcss": "^2.2.19", "ts-jest": "26.5.6", @@ -385,9 +391,9 @@ "url-loader": "^4.1.1", "webpack": "^4.46.0", "webpack-cli": "^3.3.12", - "webpack-dev-server": "^3.11.2", + "webpack-dev-server": "^3.11.3", "webpack-node-externals": "^1.7.2", - "xterm": "^4.14.1", + "xterm": "^4.15.0", "xterm-addon-fit": "^0.5.0" } } diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index 10fd82b696..322423d683 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -26,6 +26,12 @@ import { KubeObject } from "../kube-object"; import AbortController from "abort-controller"; import { delay } from "../../utils/delay"; import { PassThrough } from "stream"; +import { ApiManager, apiManager } from "../api-manager"; +import { Ingress, Pod } from "../endpoints"; + +jest.mock("../api-manager"); + +const mockApiManager = apiManager as jest.Mocked; class TestKubeObject extends KubeObject { static kind = "Pod"; @@ -33,7 +39,11 @@ class TestKubeObject extends KubeObject { static apiBase = "/api/v1/pods"; } -class TestKubeApi extends KubeApi { } +class TestKubeApi extends KubeApi { + public async checkPreferredVersion() { + return super.checkPreferredVersion(); + } +} describe("forRemoteCluster", () => { it("builds api client for KubeObject", async () => { @@ -184,6 +194,94 @@ describe("KubeApi", () => { expect(kubeApi.apiGroup).toEqual("extensions"); }); + describe("checkPreferredVersion", () => { + it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred", async () => { + expect.hasAssertions(); + + const api = new TestKubeApi({ + objectConstructor: Ingress, + checkPreferredVersion: true, + fallbackApiBases: ["/apis/extensions/v1beta1/ingresses"], + request: { + get: jest.fn() + .mockImplementationOnce((path: string) => { + expect(path).toBe("/apis/networking.k8s.io/v1"); + + throw new Error("no"); + }) + .mockImplementationOnce((path: string) => { + expect(path).toBe("/apis/extensions/v1beta1"); + + return { + resources: [ + { + name: "ingresses", + }, + ], + }; + }) + .mockImplementationOnce((path: string) => { + expect(path).toBe("/apis/extensions"); + + return { + preferredVersion: { + version: "v1beta1", + }, + }; + }), + } as any, + }); + + await api.checkPreferredVersion(); + + expect(api.apiVersionPreferred).toBe("v1beta1"); + expect(mockApiManager.registerApi).toBeCalledWith("/apis/extensions/v1beta1/ingresses", expect.anything()); + }); + + it("registers with apiManager if checkPreferredVersion changes apiVersionPreferred with non-grouped apis", async () => { + expect.hasAssertions(); + + const api = new TestKubeApi({ + objectConstructor: Pod, + checkPreferredVersion: true, + fallbackApiBases: ["/api/v1beta1/pods"], + request: { + get: jest.fn() + .mockImplementationOnce((path: string) => { + expect(path).toBe("/api/v1"); + + throw new Error("no"); + }) + .mockImplementationOnce((path: string) => { + expect(path).toBe("/api/v1beta1"); + + return { + resources: [ + { + name: "pods", + }, + ], + }; + }) + .mockImplementationOnce((path: string) => { + expect(path).toBe("/api"); + + return { + preferredVersion: { + version: "v1beta1", + }, + }; + }), + } as any, + }); + + await api.checkPreferredVersion(); + + expect(api.apiVersionPreferred).toBe("v1beta1"); + expect(mockApiManager.registerApi).toBeCalledWith("/api/v1beta1/pods", expect.anything()); + }); + }); + describe("patch", () => { let api: TestKubeApi; diff --git a/src/common/k8s-api/kube-api-parse.ts b/src/common/k8s-api/kube-api-parse.ts index 57c835ed6b..a5d137be02 100644 --- a/src/common/k8s-api/kube-api-parse.ts +++ b/src/common/k8s-api/kube-api-parse.ts @@ -37,7 +37,7 @@ export interface IKubeApiLinkRef { apiPrefix?: string; apiVersion: string; resource: string; - name: string; + name?: string; namespace?: string; } @@ -145,15 +145,18 @@ function _parseKubeApi(path: string): IKubeApiParsed { }; } -export function createKubeApiURL(ref: IKubeApiLinkRef): string { - const { apiPrefix = "/apis", resource, apiVersion, name } = ref; - let { namespace } = ref; +export function createKubeApiURL({ apiPrefix = "/apis", resource, apiVersion, name, namespace }: IKubeApiLinkRef): string { + const parts = [apiPrefix, apiVersion]; if (namespace) { - namespace = `namespaces/${namespace}`; + parts.push("namespaces", namespace); } - return [apiPrefix, apiVersion, namespace, resource, name] - .filter(v => v) - .join("/"); + parts.push(resource); + + if (name) { + parts.push(name); + } + + return parts.join("/"); } diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index a8b19d81b5..0f31758ab2 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -38,9 +38,14 @@ import AbortController from "abort-controller"; import { Agent, AgentOptions } from "https"; import type { Patch } from "rfc6902"; +/** + * The options used for creating a `KubeApi` + */ export interface IKubeApiOptions { /** * base api-path for listing all resources, e.g. "/api/v1/pods" + * + * If not specified then will be the one on the `objectConstructor` */ apiBase?: string; @@ -52,11 +57,33 @@ export interface IKubeApiOptions { */ fallbackApiBases?: string[]; - objectConstructor: KubeObjectConstructor; - request?: KubeJsonApi; - isNamespaced?: boolean; - kind?: string; + /** + * If `true` then will check all declared apiBases against the kube api server + * for the first accepted one. + */ checkPreferredVersion?: boolean; + + /** + * The constructor for the kube objects returned from the API + */ + objectConstructor: KubeObjectConstructor; + + /** + * The api instance to use for making requests + * + * @default apiKube + */ + request?: KubeJsonApi; + + /** + * @deprecated should be specified by `objectConstructor` + */ + isNamespaced?: boolean; + + /** + * @deprecated should be specified by `objectConstructor` + */ + kind?: string; } export interface IKubeApiQueryParams { @@ -249,11 +276,11 @@ export interface DeleteResourceDescriptor extends ResourceDescriptor { export class KubeApi { readonly kind: string; - readonly apiBase: string; - readonly apiPrefix: string; - readonly apiGroup: string; readonly apiVersion: string; - readonly apiVersionPreferred?: string; + apiBase: string; + apiPrefix: string; + apiGroup: string; + apiVersionPreferred?: string; readonly apiResource: string; readonly isNamespaced: boolean; @@ -264,23 +291,18 @@ export class KubeApi { private watchId = 1; constructor(protected options: IKubeApiOptions) { - const { - objectConstructor, - request = apiKube, - kind = options.objectConstructor?.kind, - isNamespaced = options.objectConstructor?.namespaced, - } = options || {}; - + const { objectConstructor, request, kind, isNamespaced } = options; const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase || objectConstructor.apiBase); - this.kind = kind; - this.isNamespaced = isNamespaced; + this.options = options; + this.kind = kind ?? objectConstructor.kind; + this.isNamespaced = isNamespaced ?? objectConstructor.namespaced ?? false; this.apiBase = apiBase; this.apiPrefix = apiPrefix; this.apiGroup = apiGroup; this.apiVersion = apiVersion; this.apiResource = resource; - this.request = request; + this.request = request ?? apiKube; this.objectConstructor = objectConstructor; this.parseResponse = this.parseResponse.bind(this); @@ -353,21 +375,16 @@ export class KubeApi { const { apiPrefix, apiGroup } = await this.getPreferredVersionPrefixGroup(); // The apiPrefix and apiGroup might change due to fallbackApiBases, so we must override them - Object.defineProperty(this, "apiPrefix", { - value: apiPrefix, - }); - Object.defineProperty(this, "apiGroup", { - value: apiGroup, - }); + this.apiPrefix = apiPrefix; + this.apiGroup = apiGroup; - const res = await this.request.get(`${this.apiPrefix}/${this.apiGroup}`); + const url = [apiPrefix, apiGroup].filter(Boolean).join("/"); + const res = await this.request.get(url); - Object.defineProperty(this, "apiVersionPreferred", { - value: res?.preferredVersion?.version ?? null, - }); + this.apiVersionPreferred = res?.preferredVersion?.version ?? null; if (this.apiVersionPreferred) { - Object.defineProperty(this, "apiBase", { value: this.getUrl() }); + this.apiBase = this.computeApiBase(); apiManager.registerApi(this.apiBase, this); } } @@ -385,7 +402,15 @@ export class KubeApi { return this.list(params, { limit: 1 }); } - getUrl({ name, namespace = "default" }: Partial = {}, query?: Partial) { + private computeApiBase(): string { + return createKubeApiURL({ + apiPrefix: this.apiPrefix, + apiVersion: this.apiVersionWithGroup, + resource: this.apiResource, + }); + } + + getUrl({ name, namespace }: Partial = {}, query?: Partial) { const resourcePath = createKubeApiURL({ apiPrefix: this.apiPrefix, apiVersion: this.apiVersionWithGroup, diff --git a/src/common/protocol-handler/error.ts b/src/common/protocol-handler/error.ts index c08d867766..263d5bfe2f 100644 --- a/src/common/protocol-handler/error.ts +++ b/src/common/protocol-handler/error.ts @@ -30,13 +30,13 @@ export enum RoutingErrorType { MISSING_EXTENSION = "missing-ext", } -export class RoutingError extends Error { +export class RoutingError extends Error { /** * Will be set if the routing error originated in an extension route table */ public extensionName?: string; - constructor(public type: RoutingErrorType, public url: Url) { + constructor(public type: RoutingErrorType, public url: Url) { super("routing error"); } diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index c387b18a55..6aea2037d1 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -91,7 +91,7 @@ export abstract class LensProtocolRouter extends Singleton { * @param url the parsed URL that initiated the `lens://` protocol * @returns true if a route has been found */ - protected _routeToInternal(url: Url): RouteAttempt { + protected _routeToInternal(url: Url>): RouteAttempt { return this._route(this.internalRoutes.entries(), url); } @@ -101,7 +101,7 @@ export abstract class LensProtocolRouter extends Singleton { * @param routes the array of path schemas, handler pairs to match against * @param url the url (in its current state) */ - protected _findMatchingRoute(routes: Iterable<[string, RouteHandler]>, url: Url): null | [match>, RouteHandler] { + protected _findMatchingRoute(routes: Iterable<[string, RouteHandler]>, url: Url>): null | [match>, RouteHandler] { const matches: [match>, RouteHandler][] = []; for (const [schema, handler] of routes) { @@ -128,7 +128,7 @@ export abstract class LensProtocolRouter extends Singleton { * @param routes the array of (path schemas, handler) pairs to match against * @param url the url (in its current state) */ - protected _route(routes: Iterable<[string, RouteHandler]>, url: Url, extensionName?: string): RouteAttempt { + protected _route(routes: Iterable<[string, RouteHandler]>, url: Url>, extensionName?: string): RouteAttempt { const route = this._findMatchingRoute(routes, url); if (!route) { @@ -166,7 +166,7 @@ export abstract class LensProtocolRouter extends Singleton { * @param url the protocol request URI that was "open"-ed * @returns either the found name or the instance of `LensExtension` */ - protected async _findMatchingExtensionByName(url: Url): Promise { + protected async _findMatchingExtensionByName(url: Url>): Promise { interface ExtensionUrlMatch { [EXTENSION_PUBLISHER_MATCH]: string; [EXTENSION_NAME_MATCH]: string; @@ -222,7 +222,7 @@ export abstract class LensProtocolRouter extends Singleton { * Note: this function modifies its argument, do not reuse * @param url the protocol request URI that was "open"-ed */ - protected async _routeToExtension(url: Url): Promise { + protected async _routeToExtension(url: Url>): Promise { const extension = await this._findMatchingExtensionByName(url); if (typeof extension === "string") { diff --git a/src/extensions/registries/status-bar-registry.ts b/src/extensions/registries/status-bar-registry.ts index 2afdee76a6..e4864b5173 100644 --- a/src/extensions/registries/status-bar-registry.ts +++ b/src/extensions/registries/status-bar-registry.ts @@ -26,6 +26,12 @@ import { BaseRegistry } from "./base-registry"; interface StatusBarComponents { Item?: React.ComponentType; + /** + * The side of the bottom bar to place this component. + * + * @default "right" + */ + position?: "left" | "right"; } interface StatusBarRegistrationV2 { diff --git a/src/main/protocol-handler/router.ts b/src/main/protocol-handler/router.ts index fe178705e7..ffcfd49d3e 100644 --- a/src/main/protocol-handler/router.ts +++ b/src/main/protocol-handler/router.ts @@ -39,7 +39,7 @@ export interface FallbackHandler { * @returns `true` if it should be routed internally to Lens, `false` if to an extension * @throws if `host` is not valid */ -function checkHost(url: URLParse): boolean { +function checkHost(url: URLParse): boolean { switch (url.host) { case "app": return true; @@ -112,7 +112,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { return false; } - protected async _findMatchingExtensionByName(url: URLParse): Promise { + protected async _findMatchingExtensionByName(url: URLParse>): Promise { const firstAttempt = await super._findMatchingExtensionByName(url); if (typeof firstAttempt !== "string") { @@ -126,7 +126,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { return ""; } - protected _routeToInternal(url: URLParse): RouteAttempt { + protected _routeToInternal(url: URLParse>): RouteAttempt { const rawUrl = url.toString(); // for sending to renderer const attempt = super._routeToInternal(url); @@ -135,7 +135,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { return attempt; } - protected async _routeToExtension(url: URLParse): Promise { + protected async _routeToExtension(url: URLParse>): Promise { const rawUrl = url.toString(); // for sending to renderer /** diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index c55fe4c972..1a8d10995b 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -104,13 +104,13 @@ export async function bootstrap(comp: () => Promise, di: Dependenc logger.info(`${logPrefix} initializing WelcomeMenuRegistry`); initializers.initWelcomeMenuRegistry(); - logger.info(`${logPrefix} initializing WorkloadsOverviewDetailRegist`); + logger.info(`${logPrefix} initializing WorkloadsOverviewDetailRegistry`); initializers.initWorkloadsOverviewDetailRegistry(); logger.info(`${logPrefix} initializing CatalogEntityDetailRegistry`); initializers.initCatalogEntityDetailRegistry(); - logger.info(`${logPrefix} initializing CatalogCategoryRegistryEntrie`); + logger.info(`${logPrefix} initializing CatalogCategoryRegistryEntries`); initializers.initCatalogCategoryRegistryEntries(); logger.info(`${logPrefix} initializing Catalog`); @@ -119,6 +119,9 @@ export async function bootstrap(comp: () => Promise, di: Dependenc logger.info(`${logPrefix} initializing IpcRendererListeners`); initializers.initIpcRendererListeners(); + logger.info(`${logPrefix} initializing StatusBarRegistry`); + initializers.initStatusBarRegistry(); + ExtensionLoader.createInstance().init(); ExtensionDiscovery.createInstance().init(); diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index d0eb4779c7..1c8f55aaab 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -57,9 +57,7 @@ export class CrdResources extends React.Component { } @computed get store() { - if (!this.crd) return null; - - return apiManager.getStore(this.crd.getResourceApiBase()); + return apiManager.getStore(this.crd?.getResourceApiBase()); } render() { diff --git a/src/renderer/components/+custom-resources/crd.store.ts b/src/renderer/components/+custom-resources/crd.store.ts index 83c8b5cdb6..2cdcbee55b 100644 --- a/src/renderer/components/+custom-resources/crd.store.ts +++ b/src/renderer/components/+custom-resources/crd.store.ts @@ -29,15 +29,14 @@ import { CRDResourceStore } from "./crd-resource.store"; import { KubeObject } from "../../../common/k8s-api/kube-object"; function initStore(crd: CustomResourceDefinition) { - const apiBase = crd.getResourceApiBase(); - const kind = crd.getResourceKind(); - const isNamespaced = crd.isNamespaced(); - const api = apiManager.getApi(apiBase) ?? new KubeApi({ - objectConstructor: KubeObject, - apiBase, - kind, - isNamespaced, - }); + const objectConstructor = class extends KubeObject { + static readonly kind = crd.getResourceKind(); + static readonly namespaced = crd.isNamespaced(); + static readonly apiBase = crd.getResourceApiBase(); + }; + + const api = apiManager.getApi(objectConstructor.apiBase) + ?? new KubeApi({ objectConstructor }); if (!apiManager.getStore(api)) { apiManager.registerStore(new CRDResourceStore(api)); diff --git a/src/renderer/components/cluster-manager/active-hotbar-name.tsx b/src/renderer/components/cluster-manager/active-hotbar-name.tsx new file mode 100644 index 0000000000..63f8b583b9 --- /dev/null +++ b/src/renderer/components/cluster-manager/active-hotbar-name.tsx @@ -0,0 +1,40 @@ +/** + * 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 { observer } from "mobx-react"; +import { Icon } from "../icon"; +import { HotbarStore } from "../../../common/hotbar-store"; +import { CommandOverlay } from "../command-palette"; +import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; + +export const ActiveHotbarName = observer(() => { + return ( +
CommandOverlay.open()} + > + + {HotbarStore.getInstance().getActive()?.name} +
+ ); +}); diff --git a/src/renderer/components/cluster-manager/bottom-bar.scss b/src/renderer/components/cluster-manager/bottom-bar.module.css similarity index 80% rename from src/renderer/components/cluster-manager/bottom-bar.scss rename to src/renderer/components/cluster-manager/bottom-bar.module.css index aca8272489..7eeb1c5ef4 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.scss +++ b/src/renderer/components/cluster-manager/bottom-bar.module.css @@ -20,22 +20,26 @@ */ .BottomBar { - $spacing: $padding * 0.5; - --flex-gap: #{$spacing}; + @apply flex px-2 text-white; + grid-area: bottom-bar; background-color: var(--blue); - padding: 0 2px; height: var(--bottom-bar-height); + font-size: var(--font-size-small); +} - .extensions { - font-size: var(--font-size-small); - color: white; - .item { - padding: $padding * 0.25 $padding * 0.5; - &:hover { - background-color: #ffffff33; - cursor: pointer; - } - } +.item { + @apply flex items-center mr-2 h-full px-2 last:mr-0; + + &:hover { + @apply cursor-pointer; + + background-color: #ffffff33; } } + +.onLeft + .onRight { + @apply ml-auto; +} + +.onRight {} \ No newline at end of file diff --git a/src/renderer/components/cluster-manager/bottom-bar.test.tsx b/src/renderer/components/cluster-manager/bottom-bar.test.tsx index d9f683fe66..4390175889 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.test.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.test.tsx @@ -20,25 +20,58 @@ */ import React from "react"; -import { render } from "@testing-library/react"; +import mockFs from "mock-fs"; +import { render, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; +import { BottomBar } from "./bottom-bar"; +import { StatusBarRegistry } from "../../../extensions/registries"; +import { HotbarStore } from "../../../common/hotbar-store"; +import { AppPaths } from "../../../common/app-paths"; +import { CommandOverlay } from "../command-palette"; +import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; +import { ActiveHotbarName } from "./active-hotbar-name"; -jest.mock("electron", () => ({ - app: { - getPath: () => "/foo", +jest.mock("../command-palette", () => ({ + CommandOverlay: { + open: jest.fn(), }, })); -import { BottomBar } from "./bottom-bar"; -import { StatusBarRegistry } from "../../../extensions/registries"; +AppPaths.init(); + +jest.mock("electron", () => ({ + app: { + getName: () => "lens", + setName: jest.fn(), + setPath: jest.fn(), + getPath: () => "tmp", + }, + ipcMain: { + handle: jest.fn(), + on: jest.fn(), + removeAllListeners: jest.fn(), + off: jest.fn(), + send: jest.fn(), + }, +})); describe("", () => { beforeEach(() => { + const mockOpts = { + "tmp": { + "test-store.json": JSON.stringify({}), + }, + }; + + mockFs(mockOpts); StatusBarRegistry.createInstance(); + HotbarStore.createInstance(); }); afterEach(() => { StatusBarRegistry.resetInstance(); + HotbarStore.resetInstance(); + mockFs.restore(); }); it("renders w/o errors", () => { @@ -87,4 +120,73 @@ describe("", () => { expect(await getByTestId(testId)).toHaveTextContent(text); }); + + it("show default hotbar name", () => { + StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ + { item: () => }, + ]); + const { getByTestId } = render(); + + expect(getByTestId("current-hotbar-name")).toHaveTextContent("default"); + }); + + it("show active hotbar name", () => { + StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ + { item: () => }, + ]); + const { getByTestId } = render(); + + HotbarStore.getInstance().add({ + id: "new", + name: "new", + }, { setActive: true }); + + expect(getByTestId("current-hotbar-name")).toHaveTextContent("new"); + }); + + it("opens command palette on click", () => { + StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ + { item: () => }, + ]); + const { getByTestId } = render(); + const activeHotbar = getByTestId("current-hotbar-name"); + + fireEvent.click(activeHotbar); + + expect(CommandOverlay.open).toHaveBeenCalledWith(); + }); + + it("sort positioned items properly", () => { + StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ + { + components: { + Item: () =>
right
, + }, + }, + { + components: { + Item: () =>
right
, + position: "right", + }, + }, + { + components: { + Item: () =>
left
, + position: "left", + }, + }, + { + components: { + Item: () =>
left
, + position: "left", + }, + }, + ]); + + const { getAllByTestId } = render(); + const elems = getAllByTestId("sortedElem"); + const positions = elems.map(elem => elem.textContent); + + expect(positions).toEqual(["left", "left", "right", "right"]); + }); }); diff --git a/src/renderer/components/cluster-manager/bottom-bar.tsx b/src/renderer/components/cluster-manager/bottom-bar.tsx index 0f61a03833..27b5a10f3f 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.tsx @@ -19,11 +19,12 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./bottom-bar.scss"; +import styles from "./bottom-bar.module.css"; import React from "react"; import { observer } from "mobx-react"; import { StatusBarRegistration, StatusBarRegistry } from "../../../extensions/registries"; +import { cssNames } from "../../utils"; @observer export class BottomBar extends React.Component { @@ -44,26 +45,36 @@ export class BottomBar extends React.Component { return null; } + items.sort(function sortLeftPositionFirst(a, b) { + return a.components?.position?.localeCompare(b.components?.position); + }); + return ( -
+ <> {items.map((registration, index) => { if (!registration?.item && !registration?.components?.Item) { return null; } return ( -
+
{this.renderRegisteredItem(registration)}
); })} -
+ ); } render() { return ( -
+
{this.renderRegisteredItems()}
); diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index 3b9e74c1ac..8cdce19d82 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -20,7 +20,7 @@ */ .ClusterManager { - --bottom-bar-height: 22px; + --bottom-bar-height: 21px; --hotbar-width: 75px; display: grid; diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.tsx b/src/renderer/components/confirm-dialog/confirm-dialog.tsx index 3704dd96dc..e8450cc232 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.tsx +++ b/src/renderer/components/confirm-dialog/confirm-dialog.tsx @@ -140,6 +140,7 @@ export class ConfirmDialog extends React.Component { isOpen={dialogState.isOpen} onClose={this.onClose} close={this.close} + {...(dialogState.isOpen ? { "data-testid":"confirmation-dialog" } : {})} >
{icon} {message} @@ -158,6 +159,7 @@ export class ConfirmDialog extends React.Component { label={labelOk} onClick={prevDefault(this.ok)} waiting={this.isSaving} + data-testid="confirm" {...okButtonProps} />
diff --git a/src/renderer/components/dialog/dialog.tsx b/src/renderer/components/dialog/dialog.tsx index be28789eff..83a49608cb 100644 --- a/src/renderer/components/dialog/dialog.tsx +++ b/src/renderer/components/dialog/dialog.tsx @@ -41,6 +41,7 @@ export interface DialogProps { modal?: boolean; pinned?: boolean; animated?: boolean; + "data-testid"?: string; } interface DialogState { @@ -149,7 +150,7 @@ export class Dialog extends React.PureComponent { }; render() { - const { modal, animated, pinned } = this.props; + const { modal, animated, pinned, "data-testid": testId } = this.props; let { className } = this.props; className = cssNames("Dialog flex center", className, { modal, pinned }); @@ -158,6 +159,7 @@ export class Dialog extends React.PureComponent { className={className} onClick={stopPropagation} ref={this.ref} + data-testid={testId} >
this.contentElem = e}> {this.props.children} diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx index b7a94c868a..513b7134bd 100644 --- a/src/renderer/components/dock/create-resource.tsx +++ b/src/renderer/components/dock/create-resource.tsx @@ -35,7 +35,7 @@ import { InfoPanel } from "./info-panel"; import * as resourceApplierApi from "../../../common/k8s-api/endpoints/resource-applier.api"; import { Notifications } from "../notifications"; import logger from "../../../common/logger"; -import type { KubeJsonApiData, KubeJsonApiError } from "../../../common/k8s-api/kube-json-api"; +import type { KubeJsonApiData } from "../../../common/k8s-api/kube-json-api"; import { getDetailsUrl } from "../kube-detail-params"; import { apiManager } from "../../../common/k8s-api/api-manager"; import { prevDefault } from "../../utils"; @@ -130,9 +130,7 @@ export class CreateResource extends React.Component {

, ); } catch (error) { - const failureReason = (error as KubeJsonApiError).reason.toString(); - - Notifications.error(failureReason); + Notifications.error(error?.toString() ?? "Unknown error occured"); } }); diff --git a/src/renderer/components/hotbar/hotbar-selector.scss b/src/renderer/components/hotbar/hotbar-selector.scss index d0fe3fa57c..5eaeb33692 100644 --- a/src/renderer/components/hotbar/hotbar-selector.scss +++ b/src/renderer/components/hotbar/hotbar-selector.scss @@ -43,7 +43,8 @@ .Icon { --size: 16px; - padding: 0 4px; + padding: 0 4px 0 0px; + margin: 0px 4px; &:hover { box-shadow: none; diff --git a/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap new file mode 100644 index 0000000000..c7b3a53142 --- /dev/null +++ b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap @@ -0,0 +1,371 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`kube-object-menu given kube object renders 1`] = ` + +
+
+ +
+
+