diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 788ecbdc94..9bc12d103d 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -7,11 +7,13 @@ import { anyObject } from "jest-mock-extended"; import mockFs from "mock-fs"; import logger from "../../main/logger"; import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../catalog"; -import { HotbarStore } from "../hotbar-store"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import getConfigurationFileModelInjectable from "../get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../get-configuration-file-model/app-version/app-version.injectable"; import type { DiContainer } from "@ogre-tools/injectable"; +import hotbarStoreInjectable from "../hotbar-store.injectable"; +import { HotbarStore } from "../hotbar-store"; +import catalogCatalogEntityInjectable from "../catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; jest.mock("../../main/catalog/catalog-entity-registry", () => ({ catalogEntityRegistry: { @@ -123,12 +125,21 @@ const awsCluster = getMockCatalogEntity({ describe("HotbarStore", () => { let di: DiContainer; + let hotbarStore: HotbarStore; beforeEach(async () => { di = getDiForUnitTesting({ doGeneralOverrides: true }); di.permitSideEffects(getConfigurationFileModelInjectable); di.permitSideEffects(appVersionInjectable); + + di.override(hotbarStoreInjectable, () => { + HotbarStore.resetInstance(); + + return HotbarStore.createInstance({ + catalogCatalogEntity: di.inject(catalogCatalogEntityInjectable), + }); + }); }); afterEach(() => { @@ -140,18 +151,18 @@ describe("HotbarStore", () => { mockFs(); await di.runSetups(); + + hotbarStore = di.inject(hotbarStoreInjectable); }); describe("load", () => { it("loads one hotbar by default", () => { - expect(HotbarStore.getInstance().hotbars.length).toEqual(1); + expect(hotbarStore.hotbars.length).toEqual(1); }); }); describe("add", () => { it("adds a hotbar", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.add({ name: "hottest" }); expect(hotbarStore.hotbars.length).toEqual(2); }); @@ -159,20 +170,14 @@ describe("HotbarStore", () => { describe("hotbar items", () => { it("initially creates 12 empty cells", () => { - const hotbarStore = HotbarStore.getInstance(); - expect(hotbarStore.getActive().items.length).toEqual(12); }); it("initially adds catalog entity as first item", () => { - const hotbarStore = HotbarStore.getInstance(); - expect(hotbarStore.getActive().items[0].entity.name).toEqual("Catalog"); }); it("adds items", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); const items = hotbarStore.getActive().items.filter(Boolean); @@ -180,8 +185,6 @@ describe("HotbarStore", () => { }); it("removes items", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); hotbarStore.removeFromHotbar("test"); hotbarStore.removeFromHotbar("catalog-entity"); @@ -191,8 +194,6 @@ describe("HotbarStore", () => { }); it("does nothing if removing with invalid uid", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); hotbarStore.removeFromHotbar("invalid uid"); const items = hotbarStore.getActive().items.filter(Boolean); @@ -201,8 +202,6 @@ describe("HotbarStore", () => { }); it("moves item to empty cell", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); @@ -216,8 +215,6 @@ describe("HotbarStore", () => { }); it("moves items down", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); @@ -231,8 +228,6 @@ describe("HotbarStore", () => { }); it("moves items up", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); @@ -246,8 +241,6 @@ describe("HotbarStore", () => { }); it("logs an error if cellIndex is out of bounds", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.add({ name: "hottest", id: "hottest" }); hotbarStore.setActiveHotbar("hottest"); @@ -269,22 +262,16 @@ describe("HotbarStore", () => { }); it("throws an error if getId is invalid or returns not a string", () => { - const hotbarStore = HotbarStore.getInstance(); - expect(() => hotbarStore.addToHotbar({} as any)).toThrowError(TypeError); expect(() => hotbarStore.addToHotbar({ getId: () => true } as any)).toThrowError(TypeError); }); it("throws an error if getName is invalid or returns not a string", () => { - const hotbarStore = HotbarStore.getInstance(); - expect(() => hotbarStore.addToHotbar({ getId: () => "" } as any)).toThrowError(TypeError); expect(() => hotbarStore.addToHotbar({ getId: () => "", getName: () => 4 } as any)).toThrowError(TypeError); }); it("does nothing when item moved to same cell", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); hotbarStore.restackItems(1, 1); @@ -292,8 +279,6 @@ describe("HotbarStore", () => { }); it("new items takes first empty cell", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(awsCluster); hotbarStore.restackItems(0, 3); @@ -309,8 +294,6 @@ describe("HotbarStore", () => { console.error = jest.fn(); console.warn = jest.fn(); - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); expect(() => hotbarStore.restackItems(-5, 0)).toThrow(); @@ -324,8 +307,6 @@ describe("HotbarStore", () => { }); it("checks if entity already pinned to hotbar", () => { - const hotbarStore = HotbarStore.getInstance(); - hotbarStore.addToHotbar(testCluster); expect(hotbarStore.isAddedToActive(testCluster)).toBeTruthy(); @@ -401,22 +382,24 @@ describe("HotbarStore", () => { mockFs(configurationToBeMigrated); await di.runSetups(); + + hotbarStore = di.inject(hotbarStoreInjectable); }); it("allows to retrieve a hotbar", () => { - const hotbar = HotbarStore.getInstance().getById("3caac17f-aec2-4723-9694-ad204465d935"); + const hotbar = hotbarStore.getById("3caac17f-aec2-4723-9694-ad204465d935"); expect(hotbar.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); }); it("clears cells without entity", () => { - const items = HotbarStore.getInstance().hotbars[0].items; + const items = hotbarStore.hotbars[0].items; expect(items[2]).toBeNull(); }); it("adds extra data to cells with according entity", () => { - const items = HotbarStore.getInstance().hotbars[0].items; + const items = hotbarStore.hotbars[0].items; expect(items[0]).toEqual({ entity: { diff --git a/src/common/hotbar-store.injectable.ts b/src/common/hotbar-store.injectable.ts index e2250d0510..e8a883cf0a 100644 --- a/src/common/hotbar-store.injectable.ts +++ b/src/common/hotbar-store.injectable.ts @@ -2,366 +2,9 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ - import { getInjectable } from "@ogre-tools/injectable"; import catalogCatalogEntityInjectable from "./catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; -import { action, comparer, observable, makeObservable, computed } from "mobx"; -import { BaseStore } from "./base-store"; -import migrations from "../migrations/hotbar-store"; -import { toJS } from "./utils"; -import { CatalogEntity } from "./catalog"; -import logger from "../main/logger"; -import { broadcastMessage } from "./ipc"; -import { - defaultHotbarCells, - getEmptyHotbar, - Hotbar, - CreateHotbarData, - CreateHotbarOptions, -} from "./hotbar-types"; -import { hotbarTooManyItemsChannel } from "./ipc/hotbar"; -import type { GeneralEntity } from "./catalog-entities"; - -export interface HotbarStoreModel { - hotbars: Hotbar[]; - activeHotbarId: string; -} - -interface Dependencies { - catalogCatalogEntity: GeneralEntity; -} - -export class HotbarStore extends BaseStore { - readonly displayName = "HotbarStore"; - @observable hotbars: Hotbar[] = []; - @observable private _activeHotbarId: string; - - constructor(private dependencies: Dependencies) { - super({ - configName: "lens-hotbar-store", - accessPropertiesByDotNotation: false, // To make dots safe in cluster context names - syncOptions: { - equals: comparer.structural, - }, - migrations, - }); - makeObservable(this); - this.load(); - } - - @computed get activeHotbarId() { - return this._activeHotbarId; - } - - /** - * If `hotbar` points to a known hotbar, make it active. Otherwise, ignore - * @param hotbar The hotbar instance, or the index, or its ID - */ - setActiveHotbar(hotbar: Hotbar | number | string) { - if (typeof hotbar === "number") { - if (hotbar >= 0 && hotbar < this.hotbars.length) { - this._activeHotbarId = this.hotbars[hotbar].id; - } - } else if (typeof hotbar === "string") { - if (this.getById(hotbar)) { - this._activeHotbarId = hotbar; - } - } else { - if (this.hotbars.indexOf(hotbar) >= 0) { - this._activeHotbarId = hotbar.id; - } - } - } - - private hotbarIndexById(id: string) { - return this.hotbars.findIndex((hotbar) => hotbar.id === id); - } - - private hotbarIndex(hotbar: Hotbar) { - return this.hotbars.indexOf(hotbar); - } - - @computed get activeHotbarIndex() { - return this.hotbarIndexById(this.activeHotbarId); - } - - @action - protected fromStore(data: Partial = {}) { - if (!data.hotbars || !data.hotbars.length) { - const hotbar = getEmptyHotbar("Default"); - const { - metadata: { uid, name, source }, - } = this.dependencies.catalogCatalogEntity; - const initialItem = { entity: { uid, name, source }}; - - hotbar.items[0] = initialItem; - - this.hotbars = [hotbar]; - } else { - this.hotbars = data.hotbars; - } - - this.hotbars.forEach(ensureExactHotbarItemLength); - - if (data.activeHotbarId) { - this.setActiveHotbar(data.activeHotbarId); - } - - if (!this.activeHotbarId) { - this.setActiveHotbar(0); - } - } - - toJSON(): HotbarStoreModel { - const model: HotbarStoreModel = { - hotbars: this.hotbars, - activeHotbarId: this.activeHotbarId, - }; - - return toJS(model); - } - - getActive() { - return this.getById(this.activeHotbarId); - } - - getByName(name: string) { - return this.hotbars.find((hotbar) => hotbar.name === name); - } - - getById(id: string) { - return this.hotbars.find((hotbar) => hotbar.id === id); - } - - add = action( - ( - data: CreateHotbarData, - { setActive = false }: CreateHotbarOptions = {}, - ) => { - const hotbar = getEmptyHotbar(data.name, data.id); - - this.hotbars.push(hotbar); - - if (setActive) { - this._activeHotbarId = hotbar.id; - } - }, - ); - - setHotbarName = action((id: string, name: string) => { - const index = this.hotbars.findIndex((hotbar) => hotbar.id === id); - - if (index < 0) { - return void console.warn( - `[HOTBAR-STORE]: cannot setHotbarName: unknown id`, - { id }, - ); - } - - this.hotbars[index].name = name; - }); - - remove = action((hotbar: Hotbar) => { - if (this.hotbars.length <= 1) { - throw new Error("Cannot remove the last hotbar"); - } - - this.hotbars = this.hotbars.filter((h) => h !== hotbar); - - if (this.activeHotbarId === hotbar.id) { - this.setActiveHotbar(0); - } - }); - - @action - addToHotbar(item: CatalogEntity, cellIndex?: number) { - const hotbar = this.getActive(); - const uid = item.getId(); - const name = item.getName(); - - if (typeof uid !== "string") { - throw new TypeError("CatalogEntity's ID must be a string"); - } - - if (typeof name !== "string") { - throw new TypeError("CatalogEntity's NAME must be a string"); - } - - if (this.isAddedToActive(item)) { - return; - } - - const entity = { - uid, - name, - source: item.metadata.source, - }; - const newItem = { entity }; - - if (cellIndex === undefined) { - // Add item to empty cell - const emptyCellIndex = hotbar.items.indexOf(null); - - if (emptyCellIndex != -1) { - hotbar.items[emptyCellIndex] = newItem; - } else { - broadcastMessage(hotbarTooManyItemsChannel); - } - } else if (0 <= cellIndex && cellIndex < hotbar.items.length) { - hotbar.items[cellIndex] = newItem; - } else { - logger.error( - `[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range`, - { entityId: uid, hotbarId: hotbar.id, cellIndex }, - ); - } - } - - @action - removeFromHotbar(uid: string): void { - const hotbar = this.getActive(); - const index = hotbar.items.findIndex((item) => item?.entity.uid === uid); - - if (index < 0) { - return; - } - - hotbar.items[index] = null; - } - - /** - * Remove all hotbar items that reference the `uid`. - * @param uid The `EntityId` that each hotbar item refers to - * @returns A function that will (in an action) undo the removing of the hotbar items. This function will not complete if the hotbar has changed. - */ - @action - removeAllHotbarItems(uid: string) { - for (const hotbar of this.hotbars) { - const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); - - if (index >= 0) { - hotbar.items[index] = null; - } - } - } - - findClosestEmptyIndex(from: number, direction = 1) { - let index = from; - - while (this.getActive().items[index] != null) { - index += direction; - } - - return index; - } - - @action - restackItems(from: number, to: number): void { - const { items } = this.getActive(); - const source = items[from]; - const moveDown = from < to; - - if ( - from < 0 || - to < 0 || - from >= items.length || - to >= items.length || - isNaN(from) || - isNaN(to) - ) { - throw new Error("Invalid 'from' or 'to' arguments"); - } - - if (from == to) { - return; - } - - items.splice(from, 1, null); - - if (items[to] == null) { - items.splice(to, 1, source); - } else { - // Move cells up or down to closes empty cell - items.splice(this.findClosestEmptyIndex(to, moveDown ? -1 : 1), 1); - items.splice(to, 0, source); - } - } - - switchToPrevious() { - const hotbarStore = HotbarStore.getInstance(); - let index = hotbarStore.activeHotbarIndex - 1; - - if (index < 0) { - index = hotbarStore.hotbars.length - 1; - } - - hotbarStore.setActiveHotbar(index); - } - - switchToNext() { - const hotbarStore = HotbarStore.getInstance(); - let index = hotbarStore.activeHotbarIndex + 1; - - if (index >= hotbarStore.hotbars.length) { - index = 0; - } - - hotbarStore.setActiveHotbar(index); - } - - /** - * Checks if entity already pinned to the active hotbar - */ - isAddedToActive(entity: CatalogEntity | null | undefined): boolean { - if (!entity) { - return false; - } - - return ( - this.getActive().items.findIndex( - (item) => item?.entity.uid === entity.getId(), - ) >= 0 - ); - } - - getDisplayLabel(hotbar: Hotbar): string { - return `${this.getDisplayIndex(hotbar)}: ${hotbar.name}`; - } - - getDisplayIndex(hotbar: Hotbar): string { - const index = this.hotbarIndex(hotbar); - - if (index < 0) { - return "??"; - } - - return `${index + 1}`; - } -} - -/** - * This function ensures that there are always exactly `defaultHotbarCells` - * worth of items in the hotbar. - * @param hotbar The hotbar to modify - */ -function ensureExactHotbarItemLength(hotbar: Hotbar) { - // if there are not enough items - while (hotbar.items.length < defaultHotbarCells) { - hotbar.items.push(null); - } - - // if for some reason the hotbar was overfilled before, remove as many entries - // as needed, but prefer empty slots and items at the end first. - while (hotbar.items.length > defaultHotbarCells) { - const lastNull = hotbar.items.lastIndexOf(null); - - if (lastNull >= 0) { - hotbar.items.splice(lastNull, 1); - } else { - hotbar.items.length = defaultHotbarCells; - } - } -} +import { HotbarStore } from "./hotbar-store"; const hotbarStoreInjectable = getInjectable({ id: "hotbar-store", @@ -373,6 +16,8 @@ const hotbarStoreInjectable = getInjectable({ catalogCatalogEntity: di.inject(catalogCatalogEntityInjectable), }); }, + + causesSideEffects: true, }); export default hotbarStoreInjectable; diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index da583b5aa6..7de8ff7617 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -3,13 +3,358 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import hotbarStoreInjectable from "./hotbar-store.injectable"; - +import { action, comparer, observable, makeObservable, computed } from "mobx"; +import { BaseStore } from "./base-store"; +import migrations from "../migrations/hotbar-store"; +import { toJS } from "./utils"; +import { CatalogEntity } from "./catalog"; +import logger from "../main/logger"; +import { broadcastMessage } from "./ipc"; import { - asLegacyGlobalSingletonForExtensionApi, -} from "../extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api"; + defaultHotbarCells, + getEmptyHotbar, + Hotbar, + CreateHotbarData, + CreateHotbarOptions, +} from "./hotbar-types"; +import { hotbarTooManyItemsChannel } from "./ipc/hotbar"; +import type { GeneralEntity } from "./catalog-entities"; + +export interface HotbarStoreModel { + hotbars: Hotbar[]; + activeHotbarId: string; +} + +interface Dependencies { + catalogCatalogEntity: GeneralEntity; +} + +export class HotbarStore extends BaseStore { + readonly displayName = "HotbarStore"; + @observable hotbars: Hotbar[] = []; + @observable private _activeHotbarId: string; + + constructor(private dependencies: Dependencies) { + super({ + configName: "lens-hotbar-store", + accessPropertiesByDotNotation: false, // To make dots safe in cluster context names + syncOptions: { + equals: comparer.structural, + }, + migrations, + }); + makeObservable(this); + this.load(); + } + + @computed get activeHotbarId() { + return this._activeHotbarId; + } + + /** + * If `hotbar` points to a known hotbar, make it active. Otherwise, ignore + * @param hotbar The hotbar instance, or the index, or its ID + */ + setActiveHotbar(hotbar: Hotbar | number | string) { + if (typeof hotbar === "number") { + if (hotbar >= 0 && hotbar < this.hotbars.length) { + this._activeHotbarId = this.hotbars[hotbar].id; + } + } else if (typeof hotbar === "string") { + if (this.getById(hotbar)) { + this._activeHotbarId = hotbar; + } + } else { + if (this.hotbars.indexOf(hotbar) >= 0) { + this._activeHotbarId = hotbar.id; + } + } + } + + private hotbarIndexById(id: string) { + return this.hotbars.findIndex((hotbar) => hotbar.id === id); + } + + private hotbarIndex(hotbar: Hotbar) { + return this.hotbars.indexOf(hotbar); + } + + @computed get activeHotbarIndex() { + return this.hotbarIndexById(this.activeHotbarId); + } + + @action + protected fromStore(data: Partial = {}) { + if (!data.hotbars || !data.hotbars.length) { + const hotbar = getEmptyHotbar("Default"); + const { + metadata: { uid, name, source }, + } = this.dependencies.catalogCatalogEntity; + const initialItem = { entity: { uid, name, source }}; + + hotbar.items[0] = initialItem; + + this.hotbars = [hotbar]; + } else { + this.hotbars = data.hotbars; + } + + this.hotbars.forEach(ensureExactHotbarItemLength); + + if (data.activeHotbarId) { + this.setActiveHotbar(data.activeHotbarId); + } + + if (!this.activeHotbarId) { + this.setActiveHotbar(0); + } + } + + toJSON(): HotbarStoreModel { + const model: HotbarStoreModel = { + hotbars: this.hotbars, + activeHotbarId: this.activeHotbarId, + }; + + return toJS(model); + } + + getActive() { + return this.getById(this.activeHotbarId); + } + + getByName(name: string) { + return this.hotbars.find((hotbar) => hotbar.name === name); + } + + getById(id: string) { + return this.hotbars.find((hotbar) => hotbar.id === id); + } + + add = action( + ( + data: CreateHotbarData, + { setActive = false }: CreateHotbarOptions = {}, + ) => { + const hotbar = getEmptyHotbar(data.name, data.id); + + this.hotbars.push(hotbar); + + if (setActive) { + this._activeHotbarId = hotbar.id; + } + }, + ); + + setHotbarName = action((id: string, name: string) => { + const index = this.hotbars.findIndex((hotbar) => hotbar.id === id); + + if (index < 0) { + return void console.warn( + `[HOTBAR-STORE]: cannot setHotbarName: unknown id`, + { id }, + ); + } + + this.hotbars[index].name = name; + }); + + remove = action((hotbar: Hotbar) => { + if (this.hotbars.length <= 1) { + throw new Error("Cannot remove the last hotbar"); + } + + this.hotbars = this.hotbars.filter((h) => h !== hotbar); + + if (this.activeHotbarId === hotbar.id) { + this.setActiveHotbar(0); + } + }); + + @action + addToHotbar(item: CatalogEntity, cellIndex?: number) { + const hotbar = this.getActive(); + const uid = item.getId(); + const name = item.getName(); + + if (typeof uid !== "string") { + throw new TypeError("CatalogEntity's ID must be a string"); + } + + if (typeof name !== "string") { + throw new TypeError("CatalogEntity's NAME must be a string"); + } + + if (this.isAddedToActive(item)) { + return; + } + + const entity = { + uid, + name, + source: item.metadata.source, + }; + const newItem = { entity }; + + if (cellIndex === undefined) { + // Add item to empty cell + const emptyCellIndex = hotbar.items.indexOf(null); + + if (emptyCellIndex != -1) { + hotbar.items[emptyCellIndex] = newItem; + } else { + broadcastMessage(hotbarTooManyItemsChannel); + } + } else if (0 <= cellIndex && cellIndex < hotbar.items.length) { + hotbar.items[cellIndex] = newItem; + } else { + logger.error( + `[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range`, + { entityId: uid, hotbarId: hotbar.id, cellIndex }, + ); + } + } + + @action + removeFromHotbar(uid: string): void { + const hotbar = this.getActive(); + const index = hotbar.items.findIndex((item) => item?.entity.uid === uid); + + if (index < 0) { + return; + } + + hotbar.items[index] = null; + } + + /** + * Remove all hotbar items that reference the `uid`. + * @param uid The `EntityId` that each hotbar item refers to + * @returns A function that will (in an action) undo the removing of the hotbar items. This function will not complete if the hotbar has changed. + */ + @action + removeAllHotbarItems(uid: string) { + for (const hotbar of this.hotbars) { + const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); + + if (index >= 0) { + hotbar.items[index] = null; + } + } + } + + findClosestEmptyIndex(from: number, direction = 1) { + let index = from; + + while (this.getActive().items[index] != null) { + index += direction; + } + + return index; + } + + @action + restackItems(from: number, to: number): void { + const { items } = this.getActive(); + const source = items[from]; + const moveDown = from < to; + + if ( + from < 0 || + to < 0 || + from >= items.length || + to >= items.length || + isNaN(from) || + isNaN(to) + ) { + throw new Error("Invalid 'from' or 'to' arguments"); + } + + if (from == to) { + return; + } + + items.splice(from, 1, null); + + if (items[to] == null) { + items.splice(to, 1, source); + } else { + // Move cells up or down to closes empty cell + items.splice(this.findClosestEmptyIndex(to, moveDown ? -1 : 1), 1); + items.splice(to, 0, source); + } + } + + switchToPrevious() { + let index = this.activeHotbarIndex - 1; + + if (index < 0) { + index = this.hotbars.length - 1; + } + + this.setActiveHotbar(index); + } + + switchToNext() { + let index = this.activeHotbarIndex + 1; + + if (index >= this.hotbars.length) { + index = 0; + } + + this.setActiveHotbar(index); + } + + /** + * Checks if entity already pinned to the active hotbar + */ + isAddedToActive(entity: CatalogEntity | null | undefined): boolean { + if (!entity) { + return false; + } + + return ( + this.getActive().items.findIndex( + (item) => item?.entity.uid === entity.getId(), + ) >= 0 + ); + } + + getDisplayLabel(hotbar: Hotbar): string { + return `${this.getDisplayIndex(hotbar)}: ${hotbar.name}`; + } + + getDisplayIndex(hotbar: Hotbar): string { + const index = this.hotbarIndex(hotbar); + + if (index < 0) { + return "??"; + } + + return `${index + 1}`; + } +} /** - * @deprecated use di.inject(hotbarStoreInjectable) instead. + * This function ensures that there are always exactly `defaultHotbarCells` + * worth of items in the hotbar. + * @param hotbar The hotbar to modify */ -export const HotbarStore = asLegacyGlobalSingletonForExtensionApi(hotbarStoreInjectable); +function ensureExactHotbarItemLength(hotbar: Hotbar) { + // if there are not enough items + while (hotbar.items.length < defaultHotbarCells) { + hotbar.items.push(null); + } + + // if for some reason the hotbar was overfilled before, remove as many entries + // as needed, but prefer empty slots and items at the end first. + while (hotbar.items.length > defaultHotbarCells) { + const lastNull = hotbar.items.lastIndexOf(null); + + if (lastNull >= 0) { + hotbar.items.splice(lastNull, 1); + } else { + hotbar.items.length = defaultHotbarCells; + } + } +} diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index 78a4b69f3c..b1306457c5 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -34,6 +34,7 @@ import getAbsolutePathInjectable from "../common/path/get-absolute-path.injectab import { getAbsolutePathFake } from "../common/test-utils/get-absolute-path-fake"; import joinPathsInjectable from "../common/path/join-paths.injectable"; import { joinPathsFake } from "../common/test-utils/join-paths-fake"; +import hotbarStoreInjectable from "../common/hotbar-store.injectable"; export const getDiForUnitTesting = ( { doGeneralOverrides } = { doGeneralOverrides: false }, @@ -65,6 +66,8 @@ export const getDiForUnitTesting = ( // eslint-disable-next-line unused-imports/no-unused-vars-ts di.override(extensionsStoreInjectable, () => ({ isEnabled: ({ id, isBundled }) => false }) as ExtensionsStore); + di.override(hotbarStoreInjectable, () => ({})); + di.override(fileSystemProvisionerStoreInjectable, () => ({}) as FileSystemProvisionerStore); // eslint-disable-next-line unused-imports/no-unused-vars-ts diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 5ea4726e98..b2a82c34ec 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -41,6 +41,8 @@ import navigateToAddClusterInjectable from "../common/front-end-routing/routes/ import addSyncEntriesInjectable from "./initializers/add-sync-entries.injectable"; import hotbarStoreInjectable from "../common/hotbar-store.injectable"; import { bindEvents } from "./navigation/events"; +import deleteClusterDialogModelInjectable + from "./components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.injectable"; if (process.isMainFrame) { SentryInit(); @@ -99,6 +101,7 @@ export async function bootstrap(di: DiContainer) { logger.info(`${logPrefix} initializing Catalog`); initializers.initCatalog({ openCommandDialog: di.inject(commandOverlayInjectable).open, + deleteClusterDialogModel: di.inject(deleteClusterDialogModelInjectable), }); const extensionLoader = di.inject(extensionLoaderInjectable); diff --git a/src/renderer/components/+catalog/__tests__/custom-columns.test.ts b/src/renderer/components/+catalog/__tests__/custom-columns.test.ts index 1ef7bef199..e95303f472 100644 --- a/src/renderer/components/+catalog/__tests__/custom-columns.test.ts +++ b/src/renderer/components/+catalog/__tests__/custom-columns.test.ts @@ -12,6 +12,7 @@ import { CatalogCategory } from "../../../api/catalog-entity"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns"; import getCategoryColumnsInjectable, { CategoryColumns, GetCategoryColumnsParams } from "../get-category-columns.injectable"; +import hotbarStoreInjectable from "../../../../common/hotbar-store.injectable"; class TestCategory extends CatalogCategory { apiVersion = "catalog.k8slens.dev/v1alpha1"; @@ -39,6 +40,8 @@ describe("Custom Category Columns", () => { beforeEach(() => { di = getDiForUnitTesting(); + + di.override(hotbarStoreInjectable, () => ({})); }); describe("without extensions", () => { diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index d948951f45..784aa1ba45 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -13,7 +13,6 @@ import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.s import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; -import { HotbarStore } from "../../../common/hotbar-store"; import { ConfirmDialog } from "../confirm-dialog"; import { catalogCategoryRegistry, CatalogEntity } from "../../../common/catalog"; import { CatalogAddButton } from "./catalog-add-button"; @@ -39,6 +38,8 @@ import catalogRouteParametersInjectable from "./catalog-route-parameters.injecta import { browseCatalogTab } from "./catalog-browse-tab"; import type { AppEvent } from "../../../common/app-event-bus/event-bus"; import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable"; +import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; +import type { HotbarStore } from "../../../common/hotbar-store"; interface Dependencies { catalogPreviousActiveTabStorage: { set: (value: string ) => void }; @@ -53,6 +54,7 @@ interface Dependencies { }; navigateToCatalog: NavigateToCatalog; + hotbarStore: HotbarStore; } @observer @@ -125,11 +127,11 @@ class NonInjectedCatalog extends React.Component { } addToHotbar(entity: CatalogEntity): void { - HotbarStore.getInstance().addToHotbar(entity); + this.props.hotbarStore.addToHotbar(entity); } removeFromHotbar(entity: CatalogEntity): void { - HotbarStore.getInstance().removeFromHotbar(entity.getId()); + this.props.hotbarStore.removeFromHotbar(entity.getId()); } onDetails = (entity: CatalogEntity) => { @@ -216,7 +218,7 @@ class NonInjectedCatalog extends React.Component { }; renderName(entity: CatalogEntity) { - const isItemInHotbar = HotbarStore.getInstance().isAddedToActive(entity); + const isItemInHotbar = this.props.hotbarStore.isAddedToActive(entity); return ( <> @@ -341,5 +343,6 @@ export const Catalog = withInjectables( NonInjectedCatalog, { routeParameters: di.inject(catalogRouteParametersInjectable), navigateToCatalog: di.inject(navigateToCatalogInjectable), emitEvent: di.inject(appEventBusInjectable).emit, + hotbarStore: di.inject(hotbarStoreInjectable), }), }); diff --git a/src/renderer/components/+catalog/get-category-columns.injectable.ts b/src/renderer/components/+catalog/get-category-columns.injectable.ts index ad6984d7c9..bf2cee1bb9 100644 --- a/src/renderer/components/+catalog/get-category-columns.injectable.ts +++ b/src/renderer/components/+catalog/get-category-columns.injectable.ts @@ -9,10 +9,12 @@ import type { CatalogCategory, CatalogEntity } from "../../../common/catalog"; import type { ItemListLayoutProps } from "../item-object-list"; import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; import categoryColumnsInjectable from "./custom-category-columns.injectable"; -import { defaultCategoryColumns, browseAllColumns, nameCategoryColumn } from "./internal-category-columns"; +import { defaultCategoryColumns, browseAllColumns } from "./internal-category-columns"; +import nameCategoryColumnInjectable from "./name-category-column.injectable"; interface Dependencies { extensionColumns: IComputedValue>>; + nameCategoryColumn: RegisteredAdditionalCategoryColumn; } export interface GetCategoryColumnsParams { @@ -21,7 +23,7 @@ export interface GetCategoryColumnsParams { export type CategoryColumns = Required, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>; -function getSpecificCategoryColumns(activeCategory: CatalogCategory, extensionColumns: IComputedValue>>): RegisteredAdditionalCategoryColumn[] { +function getSpecificCategoryColumns(activeCategory: CatalogCategory, extensionColumns: IComputedValue>>, nameCategoryColumn: RegisteredAdditionalCategoryColumn): RegisteredAdditionalCategoryColumn[] { const fromExtensions = ( extensionColumns .get() @@ -41,7 +43,7 @@ function getSpecificCategoryColumns(activeCategory: CatalogCategory, extensionCo ]; } -function getBrowseAllColumns(): RegisteredAdditionalCategoryColumn[] { +function getBrowseAllColumns(nameCategoryColumn: RegisteredAdditionalCategoryColumn): RegisteredAdditionalCategoryColumn[] { return [ ...browseAllColumns, nameCategoryColumn, @@ -49,11 +51,11 @@ function getBrowseAllColumns(): RegisteredAdditionalCategoryColumn[] { ]; } -const getCategoryColumns = ({ extensionColumns }: Dependencies) => ({ activeCategory }: GetCategoryColumnsParams): CategoryColumns => { +const getCategoryColumns = ({ extensionColumns, nameCategoryColumn }: Dependencies) => ({ activeCategory }: GetCategoryColumnsParams): CategoryColumns => { const allRegistrations = orderBy( activeCategory - ? getSpecificCategoryColumns(activeCategory, extensionColumns) - : getBrowseAllColumns(), + ? getSpecificCategoryColumns(activeCategory, extensionColumns, nameCategoryColumn) + : getBrowseAllColumns(nameCategoryColumn), "priority", "asc", ); @@ -89,6 +91,7 @@ const getCategoryColumnsInjectable = getInjectable({ instantiate: (di) => getCategoryColumns({ extensionColumns: di.inject(categoryColumnsInjectable), + nameCategoryColumn: di.inject(nameCategoryColumnInjectable), }), }); diff --git a/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx b/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx index b0c59b3f85..b48e629c94 100644 --- a/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx +++ b/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx @@ -4,26 +4,54 @@ */ import React, { ReactNode, useState } from "react"; -import { HotbarStore } from "../../../common/hotbar-store"; import { MenuItem } from "../menu"; import type { CatalogEntity } from "../../api/catalog-entity"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; +import type { HotbarStore } from "../../../common/hotbar-store"; -export function HotbarToggleMenuItem(props: { entity: CatalogEntity; addContent: ReactNode; removeContent: ReactNode }) { - const store = HotbarStore.getInstance(); - const [itemInHotbar, setItemInHotbar] = useState(store.isAddedToActive(props.entity)); +interface Dependencies { + hotbarStore: HotbarStore; +} + +interface HotbarToggleMenuItemProps { + entity: CatalogEntity; + addContent: ReactNode; + removeContent: ReactNode; +} + +function NonInjectedHotbarToggleMenuItem({ + addContent, + entity, + hotbarStore, + removeContent, +}: Dependencies & HotbarToggleMenuItemProps) { + const [itemInHotbar, setItemInHotbar] = useState(hotbarStore.isAddedToActive(entity)); return ( { if (itemInHotbar) { - store.removeFromHotbar(props.entity.getId()); + hotbarStore.removeFromHotbar(entity.getId()); setItemInHotbar(false); } else { - store.addToHotbar(props.entity); + hotbarStore.addToHotbar(entity); setItemInHotbar(true); } }}> - {itemInHotbar ? props.removeContent : props.addContent } + {itemInHotbar ? removeContent : addContent } ); } + +export const HotbarToggleMenuItem = withInjectables( + NonInjectedHotbarToggleMenuItem, + + { + getProps: (di, props) => ({ + hotbarStore: di.inject(hotbarStoreInjectable), + ...props, + }), + }, +); + diff --git a/src/renderer/components/+catalog/internal-category-columns.tsx b/src/renderer/components/+catalog/internal-category-columns.tsx index f820d40837..0aba81db0b 100644 --- a/src/renderer/components/+catalog/internal-category-columns.tsx +++ b/src/renderer/components/+catalog/internal-category-columns.tsx @@ -6,49 +6,10 @@ import styles from "./catalog.module.scss"; import React from "react"; -import { HotbarStore } from "../../../common/hotbar-store"; -import type { CatalogEntity } from "../../api/catalog-entity"; -import { Avatar } from "../avatar"; import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; -import { Icon } from "../icon"; -import { prevDefault } from "../../utils"; import { getLabelBadges } from "./helpers"; import { KubeObject } from "../../../common/k8s-api/kube-object"; -function renderEntityName(entity: CatalogEntity) { - const hotbarStore = HotbarStore.getInstance(); - const isItemInHotbar = hotbarStore.isAddedToActive(entity); - const onClick = prevDefault( - isItemInHotbar - ? () => hotbarStore.removeFromHotbar(entity.getId()) - : () => hotbarStore.addToHotbar(entity), - ); - - return ( - <> - - {entity.spec.icon?.material && } - - {entity.getName()} - - - ); -} - export const browseAllColumns: RegisteredAdditionalCategoryColumn[] = [ { id: "kind", @@ -63,20 +24,6 @@ export const browseAllColumns: RegisteredAdditionalCategoryColumn[] = [ }, ]; -export const nameCategoryColumn: RegisteredAdditionalCategoryColumn = { - id: "name", - priority: 0, - renderCell: renderEntityName, - titleProps: { - title: "Name", - className: styles.entityName, - id: "name", - sortBy: "name", - }, - searchFilter: entity => entity.getName(), - sortCallback: entity => `name=${entity.getName()}`, -}; - export const defaultCategoryColumns: RegisteredAdditionalCategoryColumn[] = [ { id: "source", diff --git a/src/renderer/components/+catalog/name-category-column.injectable.tsx b/src/renderer/components/+catalog/name-category-column.injectable.tsx new file mode 100644 index 0000000000..27fd4072a0 --- /dev/null +++ b/src/renderer/components/+catalog/name-category-column.injectable.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import styles from "./catalog.module.scss"; +import type { CatalogEntity } from "../../../common/catalog"; +import { prevDefault } from "../../utils"; +import { Avatar } from "../avatar"; +import { Icon } from "../icon"; +import React from "react"; +import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns"; +import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; +import type { HotbarStore } from "../../../common/hotbar-store"; + +const renderEntityName = (hotbarStore: HotbarStore) => (entity: CatalogEntity) => { + const isItemInHotbar = hotbarStore.isAddedToActive(entity); + const onClick = prevDefault( + isItemInHotbar + ? () => hotbarStore.removeFromHotbar(entity.getId()) + : () => hotbarStore.addToHotbar(entity), + ); + + return ( + <> + + {entity.spec.icon?.material && } + + {entity.getName()} + + + ); +}; + + +const nameCategoryColumnInjectable = getInjectable({ + id: "name-category-column", + instantiate: (di): RegisteredAdditionalCategoryColumn => ({ + id: "name", + priority: 0, + renderCell: renderEntityName(di.inject(hotbarStoreInjectable)), + titleProps: { + title: "Name", + className: styles.entityName, + id: "name", + sortBy: "name", + }, + searchFilter: (entity) => entity.getName(), + sortCallback: (entity) => `name=${entity.getName()}`, + }), +}); + +export default nameCategoryColumnInjectable; diff --git a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx index f341faa185..1fe1d6e339 100644 --- a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx +++ b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx @@ -4,7 +4,7 @@ */ import "@testing-library/jest-dom/extend-expect"; import { KubeConfig } from "@kubernetes/client-node"; -import { fireEvent, render } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import mockFs from "mock-fs"; import React from "react"; import * as selectEvent from "react-select-event"; @@ -16,6 +16,11 @@ import type { ClusterModel } from "../../../../common/cluster-types"; import { getDisForUnitTesting } from "../../../../test-utils/get-dis-for-unit-testing"; import { createClusterInjectionToken } from "../../../../common/cluster/create-cluster-injection-token"; import createContextHandlerInjectable from "../../../../main/context-handler/create-context-handler.injectable"; +import deleteClusterDialogModelInjectable from "../delete-cluster-dialog-model/delete-cluster-dialog-model.injectable"; +import type { DeleteClusterDialogModel } from "../delete-cluster-dialog-model/delete-cluster-dialog-model"; +import type { DiRender } from "../../test-utils/renderFor"; +import { renderFor } from "../../test-utils/renderFor"; +import hotbarStoreInjectable from "../../../../common/hotbar-store.injectable"; jest.mock("electron", () => ({ app: { @@ -88,16 +93,23 @@ let config: KubeConfig; describe("", () => { let createCluster: (model: ClusterModel) => Cluster; + let deleteClusterDialogModel: DeleteClusterDialogModel; + let render: DiRender; beforeEach(async () => { - const { mainDi, runSetups } = getDisForUnitTesting({ doGeneralOverrides: true }); + const { mainDi, rendererDi, runSetups } = getDisForUnitTesting({ doGeneralOverrides: true }); + + render = renderFor(rendererDi); mainDi.override(createContextHandlerInjectable, () => () => undefined); mockFs(); + rendererDi.override(hotbarStoreInjectable, () => ({})); + await runSetups(); + deleteClusterDialogModel = rendererDi.inject(deleteClusterDialogModelInjectable); createCluster = mainDi.inject(createClusterInjectionToken); }); @@ -137,7 +149,7 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + deleteClusterDialogModel.open({ cluster, config }); const { getByText } = render(); const message = "The contents of kubeconfig file will be changed!"; @@ -155,7 +167,7 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + deleteClusterDialogModel.open({ cluster, config }); const { getByTestId } = render(); @@ -172,7 +184,7 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + deleteClusterDialogModel.open({ cluster, config }); const { getByText } = render(); @@ -193,7 +205,7 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + deleteClusterDialogModel.open({ cluster, config }); const { getByText, getByTestId } = render(); const link = getByTestId("context-switch"); @@ -220,7 +232,7 @@ describe("", () => { const spy = jest.spyOn(cluster, "isInLocalKubeconfig").mockImplementation(() => true); - DeleteClusterDialog.open({ cluster, config }); + deleteClusterDialogModel.open({ cluster, config }); const { getByTestId } = render(); @@ -256,7 +268,7 @@ describe("", () => { kubeConfigPath: "./temp-kube-config", }); - DeleteClusterDialog.open({ cluster, config }); + deleteClusterDialogModel.open({ cluster, config }); const { getByTestId } = render(); diff --git a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.injectable.ts b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.injectable.ts new file mode 100644 index 0000000000..c5ddcfd43b --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { DeleteClusterDialogModel } from "./delete-cluster-dialog-model"; + +const deleteClusterDialogModelInjectable = getInjectable({ + id: "delete-cluster-dialog-model", + instantiate: () => new DeleteClusterDialogModel(), +}); + +export default deleteClusterDialogModelInjectable; diff --git a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.ts b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.ts new file mode 100644 index 0000000000..55be1e592a --- /dev/null +++ b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { KubeConfig } from "@kubernetes/client-node"; +import { observable, makeObservable, action } from "mobx"; +import type { Cluster } from "../../../../common/cluster/cluster"; + +export class DeleteClusterDialogModel { + isOpen = false; + + constructor() { + makeObservable(this, { + isOpen: observable, + open: action, + close: action, + }); + } + + cluster: Cluster; + config: KubeConfig; + + open = ({ cluster, config }: { cluster: Cluster; config: KubeConfig }) => { + this.isOpen = true; + + this.cluster = cluster; + this.config = config; + }; + + close = () => { + this.isOpen = false; + }; +} diff --git a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx index efb1a07d89..0fd44c48c2 100644 --- a/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx +++ b/src/renderer/components/delete-cluster-dialog/delete-cluster-dialog.tsx @@ -9,52 +9,35 @@ import { observer } from "mobx-react"; import React from "react"; import { Button } from "../button"; -import type { KubeConfig } from "@kubernetes/client-node"; -import type { Cluster } from "../../../common/cluster/cluster"; import { saveKubeconfig } from "./save-config"; import { Notifications } from "../notifications"; -import { HotbarStore } from "../../../common/hotbar-store"; import { boundMethod } from "autobind-decorator"; import { Dialog } from "../dialog"; import { Icon } from "../icon"; import { Select } from "../select"; import { Checkbox } from "../checkbox"; import { requestClearClusterAsDeleting, requestDeleteCluster, requestSetClusterAsDeleting } from "../../ipc"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; +import type { DeleteClusterDialogModel } from "./delete-cluster-dialog-model/delete-cluster-dialog-model"; +import deleteClusterDialogModelInjectable from "./delete-cluster-dialog-model/delete-cluster-dialog-model.injectable"; +import type { HotbarStore } from "../../../common/hotbar-store"; -interface DialogState { - isOpen: boolean; - config?: KubeConfig; - cluster?: Cluster; +interface Dependencies { + hotbarStore: HotbarStore; + model: DeleteClusterDialogModel; } -const dialogState: DialogState = observable({ - isOpen: false, -}); - -export interface DeleteClusterDialogProps {} - @observer -export class DeleteClusterDialog extends React.Component { +class NonInjectedDeleteClusterDialog extends React.Component { @observable showContextSwitch = false; @observable newCurrentContext = ""; - constructor(props: DeleteClusterDialogProps) { + constructor(props: Dependencies) { super(props); makeObservable(this); } - static open({ config, cluster }: Partial) { - dialogState.isOpen = true; - dialogState.config = config; - dialogState.cluster = cluster; - } - - static close() { - dialogState.isOpen = false; - dialogState.cluster = null; - dialogState.config = null; - } - @boundMethod onOpen() { this.newCurrentContext = ""; @@ -66,25 +49,25 @@ export class DeleteClusterDialog extends React.Component - item.name !== dialogState.cluster.contextName, + this.props.model.config.contexts = this.props.model.config.contexts.filter(item => + item.name !== this.props.model.cluster.contextName, ); } changeCurrentContext() { if (this.newCurrentContext && this.showContextSwitch) { - dialogState.config.currentContext = this.newCurrentContext; + this.props.model.config.currentContext = this.newCurrentContext; } } @boundMethod async onDelete() { - const { cluster, config } = dialogState; + const { cluster, config } = this.props.model; await requestSetClusterAsDeleting(cluster.id); this.removeContext(); @@ -92,7 +75,7 @@ export class DeleteClusterDialog extends React.Component context.name !== cluster.contextName).length == 0; const newContextNotSelected = this.newCurrentContext === ""; @@ -116,12 +99,12 @@ export class DeleteClusterDialog extends React.Component context.name !== cluster.contextName); const options = [ @@ -146,7 +129,7 @@ export class DeleteClusterDialog extends React.Component context.name !== cluster.contextName); if (!contexts.length) { @@ -206,7 +189,7 @@ export class DeleteClusterDialog extends React.Component( + NonInjectedDeleteClusterDialog, + + { + getProps: (di) => ({ + hotbarStore: di.inject(hotbarStoreInjectable), + model: di.inject(deleteClusterDialogModelInjectable), + }), + }, +); diff --git a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx index 8c37acd4d3..f51dc1e8b2 100644 --- a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx +++ b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -10,7 +10,7 @@ import React from "react"; import type { DiContainer } from "@ogre-tools/injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { DiRender, renderFor } from "../../test-utils/renderFor"; -import hotbarStoreInjectable, { HotbarStore } from "../../../../common/hotbar-store.injectable"; +import hotbarStoreInjectable from "../../../../common/hotbar-store.injectable"; import { ThemeStore } from "../../../theme.store"; import { ConfirmDialog } from "../../confirm-dialog"; import { UserStore } from "../../../../common/user-store"; @@ -18,6 +18,7 @@ import mockFs from "mock-fs"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import getConfigurationFileModelInjectable from "../../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import type { HotbarStore } from "../../../../common/hotbar-store"; const mockHotbars: { [id: string]: any } = { "1": { diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index d994d7536c..f40ddd8108 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -10,7 +10,6 @@ import { observer } from "mobx-react"; import { HotbarEntityIcon } from "./hotbar-entity-icon"; import { cssNames, IClassName } from "../../utils"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; -import { HotbarStore } from "../../../common/hotbar-store"; import type { CatalogEntity } from "../../api/catalog-entity"; import { DragDropContext, Draggable, Droppable, type DropResult } from "react-beautiful-dnd"; import { HotbarSelector } from "./hotbar-selector"; @@ -18,26 +17,33 @@ import { HotbarCell } from "./hotbar-cell"; import { HotbarIcon } from "./hotbar-icon"; import { defaultHotbarCells, HotbarItem } from "../../../common/hotbar-types"; import { action, makeObservable, observable } from "mobx"; +import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { HotbarStore } from "../../../common/hotbar-store"; export interface HotbarMenuProps { className?: IClassName; } +interface Dependencies { + hotbarStore: HotbarStore; +} + @observer -export class HotbarMenu extends React.Component { +class NonInjectedHotbarMenu extends React.Component { @observable draggingOver = false; - constructor(props: HotbarMenuProps) { + constructor(props: Dependencies & HotbarMenuProps) { super(props); makeObservable(this); } get hotbar() { - return HotbarStore.getInstance().getActive(); + return this.props.hotbarStore.getActive(); } getEntity(item: HotbarItem) { - const hotbar = HotbarStore.getInstance().getActive(); + const hotbar = this.props.hotbarStore.getActive(); if (!hotbar) { return null; @@ -64,17 +70,17 @@ export class HotbarMenu extends React.Component { const from = parseInt(source.droppableId); const to = parseInt(destination.droppableId); - HotbarStore.getInstance().restackItems(from, to); + this.props.hotbarStore.restackItems(from, to); } removeItem(uid: string) { - const hotbar = HotbarStore.getInstance(); + const hotbar = this.props.hotbarStore; hotbar.removeFromHotbar(uid); } addItem(entity: CatalogEntity, index = -1) { - const hotbar = HotbarStore.getInstance(); + const hotbar = this.props.hotbarStore; hotbar.addToHotbar(entity, index); } @@ -160,8 +166,7 @@ export class HotbarMenu extends React.Component { } render() { - const { className } = this.props; - const hotbarStore = HotbarStore.getInstance(); + const { className, hotbarStore } = this.props; const hotbar = hotbarStore.getActive(); return ( @@ -176,3 +181,14 @@ export class HotbarMenu extends React.Component { ); } } + +export const HotbarMenu = withInjectables( + NonInjectedHotbarMenu, + + { + getProps: (di, props) => ({ + hotbarStore: di.inject(hotbarStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/components/hotbar/hotbar-switch-command.tsx b/src/renderer/components/hotbar/hotbar-switch-command.tsx index 4ef904411e..c4100ba483 100644 --- a/src/renderer/components/hotbar/hotbar-switch-command.tsx +++ b/src/renderer/components/hotbar/hotbar-switch-command.tsx @@ -6,13 +6,14 @@ import React from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; -import hotbarStoreInjectable, { HotbarStore } from "../../../common/hotbar-store.injectable"; +import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; import type { CommandOverlay } from "../command-palette"; import { HotbarAddCommand } from "./hotbar-add-command"; import { HotbarRemoveCommand } from "./hotbar-remove-command"; import { HotbarRenameCommand } from "./hotbar-rename-command"; import { withInjectables } from "@ogre-tools/injectable-react"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; +import type { HotbarStore } from "../../../common/hotbar-store"; const addActionId = "__add__"; const removeActionId = "__remove__"; diff --git a/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx index 242f231774..d132967230 100644 --- a/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx +++ b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx @@ -5,17 +5,14 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; -import { render, fireEvent } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; import { SidebarCluster } from "../sidebar-cluster"; import { KubernetesCluster } from "../../../../common/catalog-entities"; - -jest.mock("../../../../common/hotbar-store", () => ({ - HotbarStore: { - getInstance: () => ({ - isAddedToActive: jest.fn(), - }), - }, -})); +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import type { DiRender } from "../../test-utils/renderFor"; +import { renderFor } from "../../test-utils/renderFor"; +import hotbarStoreInjectable from "../../../../common/hotbar-store.injectable"; +import type { HotbarStore } from "../../../../common/hotbar-store"; const clusterEntity = new KubernetesCluster({ metadata: { @@ -34,6 +31,18 @@ const clusterEntity = new KubernetesCluster({ }); describe("", () => { + let render: DiRender; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(hotbarStoreInjectable, () => ({ + isAddedToActive: () => {}, + }) as unknown as HotbarStore); + + render = renderFor(di); + }); + it("renders w/o errors", () => { const { container } = render(); diff --git a/src/renderer/components/layout/sidebar-cluster.tsx b/src/renderer/components/layout/sidebar-cluster.tsx index 34d9b733fc..2041d5dc32 100644 --- a/src/renderer/components/layout/sidebar-cluster.tsx +++ b/src/renderer/components/layout/sidebar-cluster.tsx @@ -6,7 +6,6 @@ import styles from "./sidebar-cluster.module.scss"; import { observable } from "mobx"; import React, { useState } from "react"; -import { HotbarStore } from "../../../common/hotbar-store"; import { broadcastMessage } from "../../../common/ipc"; import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; import { IpcRendererNavigationEvents } from "../../navigation/events"; @@ -16,6 +15,9 @@ import { navigate } from "../../navigation"; import { Menu, MenuItem } from "../menu"; import { ConfirmDialog } from "../confirm-dialog"; import { Tooltip } from "../tooltip"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import hotbarStoreInjectable from "../../../common/hotbar-store.injectable"; +import type { HotbarStore } from "../../../common/hotbar-store"; import { observer } from "mobx-react"; const contextMenu: CatalogEntityContextMenuContext = observable({ @@ -60,7 +62,15 @@ function renderLoadingSidebarCluster() { ); } -export const SidebarCluster = observer(({ clusterEntity }: { clusterEntity: CatalogEntity }) => { +interface Dependencies { + hotbarStore: HotbarStore; +} + +interface SidebarClusterProps { + clusterEntity: CatalogEntity; +} + +const NonInjectedSidebarCluster = observer(({ clusterEntity, hotbarStore }: Dependencies & SidebarClusterProps) => { const [opened, setOpened] = useState(false); if (!clusterEntity) { @@ -68,8 +78,7 @@ export const SidebarCluster = observer(({ clusterEntity }: { clusterEntity: Cata } const onMenuOpen = () => { - const hotbarStore = HotbarStore.getInstance(); - const isAddedToActive = HotbarStore.getInstance().isAddedToActive(clusterEntity); + const isAddedToActive = hotbarStore.isAddedToActive(clusterEntity); const title = isAddedToActive ? "Remove from Hotbar" : "Add to Hotbar"; @@ -140,3 +149,14 @@ export const SidebarCluster = observer(({ clusterEntity }: { clusterEntity: Cata ); }); + +export const SidebarCluster = withInjectables( + NonInjectedSidebarCluster, + + { + getProps: (di, props) => ({ + hotbarStore: di.inject(hotbarStoreInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/getDiForUnitTesting.tsx b/src/renderer/getDiForUnitTesting.tsx index 8c603525c8..6a752dfd85 100644 --- a/src/renderer/getDiForUnitTesting.tsx +++ b/src/renderer/getDiForUnitTesting.tsx @@ -32,6 +32,7 @@ import getAbsolutePathInjectable from "../common/path/get-absolute-path.injectab import { getAbsolutePathFake } from "../common/test-utils/get-absolute-path-fake"; import joinPathsInjectable from "../common/path/join-paths.injectable"; import { joinPathsFake } from "../common/test-utils/join-paths-fake"; +import hotbarStoreInjectable from "../common/hotbar-store.injectable"; export const getDiForUnitTesting = ( { doGeneralOverrides } = { doGeneralOverrides: false }, @@ -62,6 +63,8 @@ export const getDiForUnitTesting = ( // eslint-disable-next-line unused-imports/no-unused-vars-ts di.override(extensionsStoreInjectable, () => ({ isEnabled: ({ id, isBundled }) => false }) as ExtensionsStore); + di.override(hotbarStoreInjectable, () => ({})); + di.override(fileSystemProvisionerStoreInjectable, () => ({}) as FileSystemProvisionerStore); // eslint-disable-next-line unused-imports/no-unused-vars-ts diff --git a/src/renderer/initializers/catalog.tsx b/src/renderer/initializers/catalog.tsx index e55614d132..f287aa8360 100644 --- a/src/renderer/initializers/catalog.tsx +++ b/src/renderer/initializers/catalog.tsx @@ -10,9 +10,9 @@ import { ClusterStore } from "../../common/cluster-store/cluster-store"; import { catalogCategoryRegistry } from "../api/catalog-category-registry"; import { WeblinkAddCommand } from "../components/catalog-entities/weblink-add-command"; import { loadConfigFromString } from "../../common/kube-helpers"; -import { DeleteClusterDialog } from "../components/delete-cluster-dialog"; +import type { DeleteClusterDialogModel } from "../components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model"; -async function onClusterDelete(clusterId: string) { +async function onClusterDelete(clusterId: string, deleteClusterDialogModel: DeleteClusterDialogModel) { const cluster = ClusterStore.getInstance().getById(clusterId); if (!cluster) { @@ -25,14 +25,15 @@ async function onClusterDelete(clusterId: string) { throw error; } - DeleteClusterDialog.open({ cluster, config }); + deleteClusterDialogModel.open({ cluster, config }); } interface Dependencies { openCommandDialog: (component: React.ReactElement) => void; + deleteClusterDialogModel: DeleteClusterDialogModel; } -export function initCatalog({ openCommandDialog }: Dependencies) { +export function initCatalog({ openCommandDialog, deleteClusterDialogModel }: Dependencies) { catalogCategoryRegistry .getForGroupKind("entity.k8slens.dev", "WebLink") .on("catalogAddMenu", ctx => { @@ -50,7 +51,7 @@ export function initCatalog({ openCommandDialog }: Dependencies) { context.menuItems.push({ title: "Remove", icon: "delete", - onClick: () => onClusterDelete(entity.getId()), + onClick: () => onClusterDelete(entity.getId(), deleteClusterDialogModel), }); } });