diff --git a/packages/core/src/common/__tests__/hotbar-store.test.ts b/packages/core/src/common/__tests__/hotbar-store.test.ts deleted file mode 100644 index 474ebc1618..0000000000 --- a/packages/core/src/common/__tests__/hotbar-store.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { anyObject } from "jest-mock-extended"; -import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../catalog"; -import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; -import type { DiContainer } from "@ogre-tools/injectable"; -import hotbarStoreInjectable from "../hotbars/store.injectable"; -import type { HotbarStore } from "../hotbars/store"; -import catalogEntityRegistryInjectable from "../../main/catalog/entity-registry.injectable"; -import { computed } from "mobx"; -import hasCategoryForEntityInjectable from "../catalog/has-category-for-entity.injectable"; -import catalogCatalogEntityInjectable from "../catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; -import loggerInjectable from "../logger.injectable"; -import type { Logger } from "../logger"; -import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; -import writeJsonSyncInjectable from "../fs/write-json-sync.injectable"; - -function getMockCatalogEntity(data: Partial & CatalogEntityKindData): CatalogEntity { - return { - getName: jest.fn(() => data.metadata?.name), - getId: jest.fn(() => data.metadata?.uid), - getSource: jest.fn(() => data.metadata?.source ?? "unknown"), - isEnabled: jest.fn(() => data.status?.enabled ?? true), - onContextMenuOpen: jest.fn(), - onSettingsOpen: jest.fn(), - metadata: {}, - spec: {}, - status: {}, - ...data, - } as CatalogEntity; -} - -describe("HotbarStore", () => { - let di: DiContainer; - let hotbarStore: HotbarStore; - let testCluster: CatalogEntity; - let minikubeCluster: CatalogEntity; - let awsCluster: CatalogEntity; - let loggerMock: jest.Mocked; - - beforeEach(async () => { - di = getDiForUnitTesting(); - - testCluster = getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - status: { - phase: "Running", - }, - metadata: { - uid: "some-test-id", - name: "my-test-cluster", - source: "local", - labels: {}, - }, - }); - minikubeCluster = getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - status: { - phase: "Running", - }, - metadata: { - uid: "some-minikube-id", - name: "my-minikube-cluster", - source: "local", - labels: {}, - }, - }); - awsCluster = getMockCatalogEntity({ - apiVersion: "v1", - kind: "Cluster", - status: { - phase: "Running", - }, - metadata: { - uid: "some-aws-id", - name: "my-aws-cluster", - source: "local", - labels: {}, - }, - }); - - di.override(hasCategoryForEntityInjectable, () => () => true); - - loggerMock = { - warn: jest.fn(), - debug: jest.fn(), - error: jest.fn(), - info: jest.fn(), - silly: jest.fn(), - }; - - di.override(loggerInjectable, () => loggerMock); - - di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); - - const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); - const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); - - catalogEntityRegistry.addComputedSource("some-id", computed(() => [ - testCluster, - minikubeCluster, - awsCluster, - catalogCatalogEntity, - ])); - }); - - describe("given no previous data in store, running all migrations", () => { - beforeEach(() => { - hotbarStore = di.inject(hotbarStoreInjectable); - - hotbarStore.load(); - }); - - describe("load", () => { - it("loads one hotbar by default", () => { - expect(hotbarStore.hotbars.length).toEqual(1); - }); - }); - - describe("add", () => { - it("adds a hotbar", () => { - hotbarStore.add({ name: "hottest" }); - expect(hotbarStore.hotbars.length).toEqual(2); - }); - }); - - describe("hotbar items", () => { - it("initially creates 12 empty cells", () => { - expect(hotbarStore.getActive().items.length).toEqual(12); - }); - - it("initially adds catalog entity as first item", () => { - expect(hotbarStore.getActive().items[0]?.entity.name).toEqual("Catalog"); - }); - - it("adds items", () => { - hotbarStore.addToHotbar(testCluster); - const items = hotbarStore.getActive().items.filter(Boolean); - - expect(items.length).toEqual(2); - }); - - it("removes items", () => { - hotbarStore.addToHotbar(testCluster); - hotbarStore.removeFromHotbar("some-test-id"); - hotbarStore.removeFromHotbar("catalog-entity"); - const items = hotbarStore.getActive().items.filter(Boolean); - - expect(items).toStrictEqual([]); - }); - - it("does nothing if removing with invalid uid", () => { - hotbarStore.addToHotbar(testCluster); - hotbarStore.removeFromHotbar("invalid uid"); - const items = hotbarStore.getActive().items.filter(Boolean); - - expect(items.length).toEqual(2); - }); - - it("moves item to empty cell", () => { - hotbarStore.addToHotbar(testCluster); - hotbarStore.addToHotbar(minikubeCluster); - hotbarStore.addToHotbar(awsCluster); - - expect(hotbarStore.getActive().items[6]).toBeNull(); - - hotbarStore.restackItems(1, 5); - - expect(hotbarStore.getActive().items[5]).toBeTruthy(); - expect(hotbarStore.getActive().items[5]?.entity.uid).toEqual("some-test-id"); - }); - - it("moves items down", () => { - hotbarStore.addToHotbar(testCluster); - hotbarStore.addToHotbar(minikubeCluster); - hotbarStore.addToHotbar(awsCluster); - - // aws -> catalog - hotbarStore.restackItems(3, 0); - - const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - - expect(items.slice(0, 4)).toEqual(["some-aws-id", "catalog-entity", "some-test-id", "some-minikube-id"]); - }); - - it("moves items up", () => { - hotbarStore.addToHotbar(testCluster); - hotbarStore.addToHotbar(minikubeCluster); - hotbarStore.addToHotbar(awsCluster); - - // test -> aws - hotbarStore.restackItems(1, 3); - - const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - - expect(items.slice(0, 4)).toEqual(["catalog-entity", "some-minikube-id", "some-aws-id", "some-test-id"]); - }); - - it("logs an error if cellIndex is out of bounds", () => { - hotbarStore.add({ name: "hottest", id: "hottest" }); - hotbarStore.setActiveHotbar("hottest"); - - hotbarStore.addToHotbar(testCluster, -1); - expect(loggerMock.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); - - hotbarStore.addToHotbar(testCluster, 12); - expect(loggerMock.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); - - hotbarStore.addToHotbar(testCluster, 13); - expect(loggerMock.error).toBeCalledWith("[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range", anyObject()); - }); - - it("throws an error if getId is invalid or returns not a string", () => { - 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", () => { - 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", () => { - hotbarStore.addToHotbar(testCluster); - hotbarStore.restackItems(1, 1); - - expect(hotbarStore.getActive().items[1]?.entity.uid).toEqual("some-test-id"); - }); - - it("new items takes first empty cell", () => { - hotbarStore.addToHotbar(testCluster); - hotbarStore.addToHotbar(awsCluster); - hotbarStore.restackItems(0, 3); - hotbarStore.addToHotbar(minikubeCluster); - - expect(hotbarStore.getActive().items[0]?.entity.uid).toEqual("some-minikube-id"); - }); - - it("throws if invalid arguments provided", () => { - hotbarStore.addToHotbar(testCluster); - - expect(() => hotbarStore.restackItems(-5, 0)).toThrow(); - expect(() => hotbarStore.restackItems(2, -1)).toThrow(); - expect(() => hotbarStore.restackItems(14, 1)).toThrow(); - expect(() => hotbarStore.restackItems(11, 112)).toThrow(); - }); - - it("checks if entity already pinned to hotbar", () => { - hotbarStore.addToHotbar(testCluster); - - expect(hotbarStore.isAddedToActive(testCluster)).toBeTruthy(); - expect(hotbarStore.isAddedToActive(awsCluster)).toBeFalsy(); - }); - }); - }); - - describe("given data from 5.0.0-beta.3 and version being 5.0.0-beta.10", () => { - beforeEach(() => { - const writeJsonSync = di.inject(writeJsonSyncInjectable); - - writeJsonSync("/some-directory-for-user-data/lens-hotbar-store.json", { - __internal__: { - migrations: { - version: "5.0.0-beta.3", - }, - }, - hotbars: [ - { - id: "3caac17f-aec2-4723-9694-ad204465d935", - name: "myhotbar", - items: [ - { - entity: { - uid: "some-aws-id", - }, - }, - { - entity: { - uid: "55b42c3c7ba3b04193416cda405269a5", - }, - }, - { - entity: { - uid: "176fd331968660832f62283219d7eb6e", - }, - }, - { - entity: { - uid: "61c4fb45528840ebad1badc25da41d14", - name: "user1-context", - source: "local", - }, - }, - { - entity: { - uid: "27d6f99fe9e7548a6e306760bfe19969", - name: "foo2", - source: "local", - }, - }, - null, - { - entity: { - uid: "c0b20040646849bb4dcf773e43a0bf27", - name: "multinode-demo", - source: "local", - }, - }, - null, - null, - null, - null, - null, - ], - }, - ], - }); - - di.override(storeMigrationVersionInjectable, () => "5.0.0-beta.10"); - - hotbarStore = di.inject(hotbarStoreInjectable); - - hotbarStore.load(); - }); - - it("allows to retrieve a hotbar", () => { - const hotbar = hotbarStore.findById("3caac17f-aec2-4723-9694-ad204465d935"); - - expect(hotbar?.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); - }); - - it("clears cells without entity", () => { - const items = hotbarStore.hotbars[0].items; - - expect(items[2]).toBeNull(); - }); - - it("adds extra data to cells with according entity", () => { - const items = hotbarStore.hotbars[0].items; - - expect(items[0]).toEqual({ - entity: { - name: "my-aws-cluster", - source: "local", - uid: "some-aws-id", - }, - }); - }); - }); -}); diff --git a/packages/core/src/common/hotbars/add-hotbar.injectable.ts b/packages/core/src/common/hotbars/add-hotbar.injectable.ts deleted file mode 100644 index 25ee0f588a..0000000000 --- a/packages/core/src/common/hotbars/add-hotbar.injectable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import hotbarStoreInjectable from "./store.injectable"; -import type { CreateHotbarData, CreateHotbarOptions } from "./types"; - -export type AddHotbar = (data: CreateHotbarData, opts?: CreateHotbarOptions) => void; - -const addHotbarInjectable = getInjectable({ - id: "add-hotbar", - instantiate: (di): AddHotbar => { - const store = di.inject(hotbarStoreInjectable); - - return (data, opts) => store.add(data, opts); - }, -}); - -export default addHotbarInjectable; diff --git a/packages/core/src/common/hotbars/store.injectable.ts b/packages/core/src/common/hotbars/store.injectable.ts deleted file mode 100644 index 1ffea43b69..0000000000 --- a/packages/core/src/common/hotbars/store.injectable.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import catalogCatalogEntityInjectable from "../catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; -import { HotbarStore } from "./store"; -import loggerInjectable from "../logger.injectable"; -import persistentStorageMigrationsInjectable from "../persistent-storage/migrations.injectable"; -import { hotbarStoreMigrationInjectionToken } from "./migrations-token"; -import createPersistentStorageInjectable from "../persistent-storage/create.injectable"; -import storeMigrationVersionInjectable from "../vars/store-migration-version.injectable"; - -const hotbarStoreInjectable = getInjectable({ - id: "hotbar-store", - - instantiate: (di) => new HotbarStore({ - catalogCatalogEntity: di.inject(catalogCatalogEntityInjectable), - logger: di.inject(loggerInjectable), - storeMigrationVersion: di.inject(storeMigrationVersionInjectable), - migrations: di.inject(persistentStorageMigrationsInjectable, hotbarStoreMigrationInjectionToken), - createPersistentStorage: di.inject(createPersistentStorageInjectable), - }), -}); - -export default hotbarStoreInjectable; diff --git a/packages/core/src/common/hotbars/store.ts b/packages/core/src/common/hotbars/store.ts deleted file mode 100644 index bddf070b34..0000000000 --- a/packages/core/src/common/hotbars/store.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { IObservableValue } from "mobx"; -import { runInAction, action, comparer, observable } from "mobx"; -import type { CatalogEntity } from "../catalog"; -import { broadcastMessage } from "../ipc"; -import type { Hotbar, CreateHotbarData, CreateHotbarOptions } from "./types"; -import { defaultHotbarCells, getEmptyHotbar } from "./types"; -import { hotbarTooManyItemsChannel } from "../ipc/hotbar"; -import type { GeneralEntity } from "../catalog-entities"; -import type { Logger } from "../logger"; -import assert from "assert"; -import { getShortName } from "../catalog/helpers"; -import type { Migrations } from "conf/dist/source/types"; -import type { CreatePersistentStorage, PersistentStorage } from "../persistent-storage/create.injectable"; - -export interface HotbarStoreModel { - hotbars: Hotbar[]; - activeHotbarId: string; -} - -interface Dependencies { - readonly catalogCatalogEntity: GeneralEntity; - readonly logger: Logger; - readonly storeMigrationVersion: string; - readonly migrations: Migrations>; - createPersistentStorage: CreatePersistentStorage; -} - -export class HotbarStore { - private readonly store: PersistentStorage; - - readonly hotbars = observable.array(); - - readonly activeHotbarId = observable.box() as IObservableValue; - - constructor(protected readonly dependencies: Dependencies) { - this.store = this.dependencies.createPersistentStorage({ - configName: "lens-hotbar-store", - accessPropertiesByDotNotation: false, // To make dots safe in cluster context names - syncOptions: { - equals: comparer.structural, - }, - projectVersion: this.dependencies.storeMigrationVersion, - migrations: this.dependencies.migrations, - fromStore: action((data) => { - if (!data.hotbars || !data.hotbars.length) { - const hotbar = getEmptyHotbar("Default"); - const { - metadata: { - uid, - name, - source, - }, - } = this.dependencies.catalogCatalogEntity; - - hotbar.items[0] = { - entity: { - uid, - name, - source, - }, - }; - this.hotbars.replace([hotbar]); - } else { - this.hotbars.replace(data.hotbars); - } - - for (const hotbar of this.hotbars) { - ensureExactHotbarItemLength(hotbar); - } - - if (data.activeHotbarId) { - this.activeHotbarId.set(data.activeHotbarId); - } - - if (!this.activeHotbarId.get()) { - this.activeHotbarId.set(this.hotbars[0].id); - } - - const activeHotbarExists = this.hotbars.findIndex(hotbar => hotbar.id === this.activeHotbarId.get()) >= 0; - - if (!activeHotbarExists) { - this.activeHotbarId.set(this.hotbars[0].id); - } - }), - toJSON: () => ({ - hotbars: this.hotbars.toJSON(), - activeHotbarId: this.activeHotbarId.get(), - }), - }); - } - - load() { - this.store.loadAndStartSyncing(); - } - - /** - * 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) { - runInAction(() => { - if (typeof hotbar === "number") { - if (hotbar >= 0 && hotbar < this.hotbars.length) { - this.activeHotbarId.set(this.hotbars[hotbar].id); - } - } else if (typeof hotbar === "string") { - if (this.findById(hotbar)) { - this.activeHotbarId.set(hotbar); - } - } else { - if (this.hotbars.indexOf(hotbar) >= 0) { - this.activeHotbarId.set(hotbar.id); - } - } - }); - } - - private getActiveHotbarIndex() { - return this.hotbars.findIndex((hotbar) => hotbar.id === this.activeHotbarId.get()); - } - - getActive(): Hotbar { - const hotbar = this.findById(this.activeHotbarId.get()); - - assert(hotbar, "There MUST always be an active hotbar"); - - return hotbar; - } - - findByName(name: string) { - return this.hotbars.find((hotbar) => hotbar.name === name); - } - - findById(id: string) { - return this.hotbars.find((hotbar) => hotbar.id === id); - } - - add(data: CreateHotbarData, { setActive = false }: CreateHotbarOptions = {}) { - runInAction(() => { - const hotbar = getEmptyHotbar(data.name, data.id); - - this.hotbars.push(hotbar); - - if (setActive) { - this.activeHotbarId.set(hotbar.id); - } - }); - } - - setHotbarName(id: string, name: string): void { - runInAction(() => { - const index = this.hotbars.findIndex((hotbar) => hotbar.id === id); - - if (index < 0) { - return this.dependencies.logger.warn( - `[HOTBAR-STORE]: cannot setHotbarName: unknown id`, - { id }, - ); - } - - this.hotbars[index].name = name; - }); - } - - remove(hotbar: Hotbar) { - runInAction(() => { - assert(this.hotbars.length >= 2, "Cannot remove the last hotbar"); - - this.hotbars.replace(this.hotbars.filter((h) => h.id !== hotbar.id)); - - if (this.activeHotbarId.get() === hotbar.id) { - this.activeHotbarId.set(this.hotbars[0].id); - } - }); - } - - addToHotbar(item: CatalogEntity, cellIndex?: number) { - runInAction(() => { - - const hotbar = this.getActive(); - const uid = item.getId(); - const name = item.getName(); - const shortName = getShortName(item); - - 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 (typeof shortName !== "string") { - throw new TypeError("CatalogEntity's SHORT_NAME must be a string"); - } - - if (this.isAddedToActive(item)) { - return; - } - - const entity = { - uid, - name, - source: item.metadata.source, - shortName, - }; - 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 { - this.dependencies.logger.error( - `[HOTBAR-STORE]: cannot pin entity to hotbar outside of index range`, - { entityId: uid, hotbarId: hotbar.id, cellIndex }, - ); - } - }); - } - - removeFromHotbar(uid: string): void { - runInAction(() => { - 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. - */ - removeAllHotbarItems(uid: string) { - runInAction(() => { - for (const hotbar of this.hotbars) { - const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); - - if (index >= 0) { - hotbar.items[index] = null; - } - } - }); - } - - private findClosestEmptyIndex(from: number, direction = 1) { - let index = from; - const hotbar = this.getActive(); - - while (hotbar.items[index] != null) { - index += direction; - } - - return index; - } - - restackItems(from: number, to: number): void { - runInAction(() => { - 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() { - runInAction(() => { - let index = this.getActiveHotbarIndex() - 1; - - if (index < 0) { - index = this.hotbars.length - 1; - } - - this.setActiveHotbar(index); - }); - } - - switchToNext() { - runInAction(() => { - let index = this.getActiveHotbarIndex() + 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; - } - - const indexInActiveHotbar = this.getActive().items.findIndex(item => item?.entity.uid === entity.getId()); - - return indexInActiveHotbar >= 0; - } - - getDisplayLabel(hotbar: Hotbar): string { - return `${this.getDisplayIndex(hotbar)}: ${hotbar.name}`; - } - - getDisplayIndex(hotbar: Hotbar): string { - const index = this.hotbars.indexOf(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; - } - } -} diff --git a/packages/core/src/features/hotbar/storage/common/active-hotbar-index.injectable.ts b/packages/core/src/features/hotbar/storage/common/active-hotbar-index.injectable.ts new file mode 100644 index 0000000000..4c97695b89 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/active-hotbar-index.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import activeHotbarIdInjectable from "./active-id.injectable"; +import computeHotbarIndexInjectable from "./compute-hotbar-index.injectable"; + +const activeHotbarIndexInjectable = getInjectable({ + id: "active-hotbar-index", + instantiate: (di) => { + const computeHotbarIndex = di.inject(computeHotbarIndexInjectable); + const activeHotbarId = di.inject(activeHotbarIdInjectable); + + return computed(() => { + const activeId = activeHotbarId.get(); + + return (activeId && computeHotbarIndex(activeId)) || 0; + }); + }, +}); + +export default activeHotbarIndexInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/active-id.injectable.ts b/packages/core/src/features/hotbar/storage/common/active-id.injectable.ts new file mode 100644 index 0000000000..2514bc834d --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/active-id.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 { observable } from "mobx"; + +const activeHotbarIdInjectable = getInjectable({ + id: "active-hotbar-id", + instantiate: () => observable.box(), +}); + +export default activeHotbarIdInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/active.injectable.ts b/packages/core/src/features/hotbar/storage/common/active.injectable.ts new file mode 100644 index 0000000000..433a6f4323 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/active.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import activeHotbarIdInjectable from "./active-id.injectable"; +import hotbarsStateInjectable from "./state.injectable"; + +const activeHotbarInjectable = getInjectable({ + id: "active-hotbar", + instantiate: (di) => { + const state = di.inject(hotbarsStateInjectable); + const activeHotbarId = di.inject(activeHotbarIdInjectable); + + return computed(() => { + const id = activeHotbarId.get(); + + return (id && state.get(id)) || undefined; + }); + }, +}); + +export default activeHotbarInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/add.injectable.ts b/packages/core/src/features/hotbar/storage/common/add.injectable.ts new file mode 100644 index 0000000000..19cbadc7c6 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/add.injectable.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 { getInjectable } from "@ogre-tools/injectable"; +import { action } from "mobx"; +import type { CreateHotbarData, CreateHotbarOptions } from "./types"; +import activeHotbarIdInjectable from "./active-id.injectable"; +import hotbarsStateInjectable from "./state.injectable"; +import createHotbarInjectable from "./create-hotbar.injectable"; + +export type AddHotbar = (data: CreateHotbarData, { setActive }?: CreateHotbarOptions) => void; + +const addHotbarInjectable = getInjectable({ + id: "add-hotbar", + instantiate: (di): AddHotbar => { + const state = di.inject(hotbarsStateInjectable); + const activeHotbarId = di.inject(activeHotbarIdInjectable); + const createHotbar = di.inject(createHotbarInjectable); + + return action((data, { setActive = false } = {}) => { + const hotbar = createHotbar(data); + + state.set(hotbar.id, hotbar); + + if (setActive) { + activeHotbarId.set(hotbar.id); + } + }); + }, +}); + +export default addHotbarInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/compute-display-index.injectable.ts b/packages/core/src/features/hotbar/storage/common/compute-display-index.injectable.ts new file mode 100644 index 0000000000..c67b75db39 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/compute-display-index.injectable.ts @@ -0,0 +1,19 @@ +/** + * 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 computeHotbarIndexInjectable from "./compute-hotbar-index.injectable"; + +export type ComputeDisplayIndex = (hotbarId: string) => string; + +const computeDisplayIndexInjectable = getInjectable({ + id: "compute-display-index", + instantiate: (di): ComputeDisplayIndex => { + const computeHotbarIndex = di.inject(computeHotbarIndexInjectable); + + return (hotbarId) => `${computeHotbarIndex(hotbarId) + 1}`; + }, +}); + +export default computeDisplayIndexInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/compute-display-label.injectable.ts b/packages/core/src/features/hotbar/storage/common/compute-display-label.injectable.ts new file mode 100644 index 0000000000..5900563cb2 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/compute-display-label.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Hotbar } from "./hotbar"; +import computeDisplayIndexInjectable from "./compute-display-index.injectable"; + +export type ComputeHotbarDisplayLabel = (hotbar: Hotbar) => string; + +const computeHotbarDisplayLabelInjectable = getInjectable({ + id: "compute-hotbar-display-label", + instantiate: (di): ComputeHotbarDisplayLabel => { + const computeDisplayIndex = di.inject(computeDisplayIndexInjectable); + + return (hotbar) => `${computeDisplayIndex(hotbar.id)}: ${hotbar.name}`; + }, +}); + +export default computeHotbarDisplayLabelInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/compute-hotbar-index.injectable.ts b/packages/core/src/features/hotbar/storage/common/compute-hotbar-index.injectable.ts new file mode 100644 index 0000000000..b54954c618 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/compute-hotbar-index.injectable.ts @@ -0,0 +1,31 @@ +/** + * 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 hotbarsStateInjectable from "./state.injectable"; + +export type ComputeHotbarIndex = (hotbarId: string) => number; + +const computeHotbarIndexInjectable = getInjectable({ + id: "compute-hotbar-index", + instantiate: (di): ComputeHotbarIndex => { + const state = di.inject(hotbarsStateInjectable); + + return (hotbarId) => { + let i = 0; + + for (const hotbar of state.values()) { + if (hotbar.id === hotbarId) { + return i; + } + + i += 1; + } + + return 0; + }; + }, +}); + +export default computeHotbarIndexInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/create-hotbar.injectable.ts b/packages/core/src/features/hotbar/storage/common/create-hotbar.injectable.ts new file mode 100644 index 0000000000..8d35af255b --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/create-hotbar.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { CreateHotbarData } from "./types"; +import prefixedLoggerInjectable from "../../../../common/logger/prefixed-logger.injectable"; +import type { HotbarDependencies } from "./hotbar"; +import { Hotbar } from "./hotbar"; + +export type CreateHotbar = (data: CreateHotbarData) => Hotbar; + +const createHotbarInjectable = getInjectable({ + id: "create-hotbar", + instantiate: (di): CreateHotbar => { + const deps: HotbarDependencies = { + logger: di.inject(prefixedLoggerInjectable, "HOTBAR"), + }; + + return (data) => new Hotbar(deps, data); + }, +}); + +export default createHotbarInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/find-by-name.injectable.ts b/packages/core/src/features/hotbar/storage/common/find-by-name.injectable.ts new file mode 100644 index 0000000000..321bc74b2e --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/find-by-name.injectable.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { iter } from "@k8slens/utilities"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { Hotbar } from "./hotbar"; +import hotbarsStateInjectable from "./state.injectable"; + +export type FindHotbarByName = (name: string) => Hotbar | undefined; + +const findHotbarByNameInjectable = getInjectable({ + id: "find-hotbar-by-name", + instantiate: (di): FindHotbarByName => { + const state = di.inject(hotbarsStateInjectable); + + return (name) => iter.find(state.values(), hotbar => hotbar.name.get() === name); + }, +}); + +export default findHotbarByNameInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/get-by-id.injectable.ts b/packages/core/src/features/hotbar/storage/common/get-by-id.injectable.ts new file mode 100644 index 0000000000..00fe43814f --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/get-by-id.injectable.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { Hotbar } from "./hotbar"; +import hotbarsStateInjectable from "./state.injectable"; + +export type GetHotbarById = (id: string) => Hotbar | undefined; + +const getHotbarByIdInjectable = getInjectable({ + id: "get-hotbar-by-id", + instantiate: (di): GetHotbarById => { + const state = di.inject(hotbarsStateInjectable); + + return (id) => state.get(id); + }, +}); + +export default getHotbarByIdInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/hotbar.ts b/packages/core/src/features/hotbar/storage/common/hotbar.ts new file mode 100644 index 0000000000..efb2e60579 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/hotbar.ts @@ -0,0 +1,175 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { type IObservableValue, type IObservableArray, observable, runInAction, toJS } from "mobx"; +import type { CatalogEntity } from "../../../../common/catalog"; +import { getShortName } from "../../../../common/catalog/helpers"; +import type { HotbarItem, CreateHotbarData } from "./types"; +import { defaultHotbarCells } from "./types"; +import { broadcastMessage } from "../../../../common/ipc"; +import { hotbarTooManyItemsChannel } from "../../../../common/ipc/hotbar"; +import * as uuid from "uuid"; +import type { Logger } from "../../../../common/logger"; +import { tuple } from "@k8slens/utilities"; + +export interface HotbarDependencies { + readonly logger: Logger; +} + +export interface HotbarData { + readonly id: string; + readonly name: string; + readonly items: (HotbarItem | null)[]; +} + +export class Hotbar { + readonly id: string; + readonly name: IObservableValue; + readonly items: IObservableArray; + + constructor(private readonly dependencies: HotbarDependencies, data: CreateHotbarData) { + this.id = data.id ?? uuid.v4(); + this.name = observable.box(data.name); + this.items = observable.array(data.items ?? tuple.filled(defaultHotbarCells, null)); + } + + isFull() { + for (const item of this.items) { + if (!item) { + return false; + } + } + + return true; + } + + hasEntity(entityId: string) { + return this.items.findIndex(item => item?.entity.uid === entityId) >= 0; + } + + private findClosestEmptyIndex(from: number, direction = 1) { + let index = from; + + while (this.items[index] != null) { + index += direction; + } + + return index; + } + + restack(from: number, to: number) { + runInAction(() => { + const source = this.items[from]; + const moveDown = from < to; + + if ( + from < 0 || + to < 0 || + from >= this.items.length || + to >= this.items.length || + isNaN(from) || + isNaN(to) + ) { + throw new Error("Invalid 'from' or 'to' arguments"); + } + + if (from == to) { + return; + } + + this.items.splice(from, 1, null); + + if (this.items[to] == null) { + this.items.splice(to, 1, source); + } else { + // Move cells up or down to closes empty cell + this.items.splice(this.findClosestEmptyIndex(to, moveDown ? -1 : 1), 1); + this.items.splice(to, 0, source); + } + }); + } + + toggleEntity(item: CatalogEntity) { + runInAction(() => { + if (this.hasEntity(item.getId())) { + this.removeEntity(item.getId()); + } else { + this.addEntity(item); + } + }); + } + + removeEntity(uid: string) { + runInAction(() => { + const index = this.items.findIndex((item) => item?.entity.uid === uid); + + if (index < 0) { + return; + } + + this.items[index] = null; + }); + } + + addEntity(item: CatalogEntity, cellIndex?: number) { + const uid = item.getId(); + const name = item.getName(); + const shortName = getShortName(item); + + 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 (typeof shortName !== "string") { + throw new TypeError("CatalogEntity's SHORT_NAME must be a string"); + } + + if (this.hasEntity(item.getId())) { + return; + } + + const entity = { + uid, + name, + source: item.metadata.source, + shortName, + }; + const newItem = { entity }; + + if (cellIndex === undefined) { + // Add item to empty cell + const emptyCellIndex = this.items.indexOf(null); + + if (emptyCellIndex >= 0) { + runInAction(() => { + this.items[emptyCellIndex] = newItem; + }); + } else { + broadcastMessage(hotbarTooManyItemsChannel); + } + } else if (0 <= cellIndex && cellIndex < this.items.length) { + runInAction(() => { + this.items[cellIndex] = newItem; + }); + } else { + this.dependencies.logger.error( + "cannot pin entity to hotbar outside of index range", + { entityId: uid, hotbarId: this.id, cellIndex }, + ); + } + } + + toJSON(): HotbarData { + return { + id: this.id, + items: toJS(this.items), + name: this.name.get(), + }; + } +} diff --git a/packages/core/src/features/hotbar/storage/common/hotbars.injectable.ts b/packages/core/src/features/hotbar/storage/common/hotbars.injectable.ts new file mode 100644 index 0000000000..2a13b4fc27 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/hotbars.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import hotbarsStateInjectable from "./state.injectable"; + +const hotbarsInjectable = getInjectable({ + id: "hotbars", + instantiate: (di) => { + const state = di.inject(hotbarsStateInjectable); + + return computed(() => [...state.values()]); + }, +}); + +export default hotbarsInjectable; diff --git a/packages/core/src/common/hotbars/migrations-token.ts b/packages/core/src/features/hotbar/storage/common/migrations-token.ts similarity index 76% rename from packages/core/src/common/hotbars/migrations-token.ts rename to packages/core/src/features/hotbar/storage/common/migrations-token.ts index 0d89f53a83..ede5cd1966 100644 --- a/packages/core/src/common/hotbars/migrations-token.ts +++ b/packages/core/src/features/hotbar/storage/common/migrations-token.ts @@ -4,7 +4,7 @@ */ import { getInjectionToken } from "@ogre-tools/injectable"; -import type { MigrationDeclaration } from "../persistent-storage/migrations.injectable"; +import type { MigrationDeclaration } from "../../../../common/persistent-storage/migrations.injectable"; export const hotbarStoreMigrationInjectionToken = getInjectionToken({ id: "hotbar-store-migration-token", diff --git a/packages/core/src/features/hotbar/storage/common/remove-entity-from-all.injectable.ts b/packages/core/src/features/hotbar/storage/common/remove-entity-from-all.injectable.ts new file mode 100644 index 0000000000..5890a0e995 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/remove-entity-from-all.injectable.ts @@ -0,0 +1,24 @@ +/** + * 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 { action } from "mobx"; +import hotbarsInjectable from "./hotbars.injectable"; + +export type RemoveEntityFromAllHotbars = (entityId: string) => void; + +const removeEntityFromAllHotbarsInjectable = getInjectable({ + id: "remove-entity-from-all-hotbars", + instantiate: (di): RemoveEntityFromAllHotbars => { + const hotbars = di.inject(hotbarsInjectable); + + return action((entityId) => { + for (const hotbar of hotbars.get()) { + hotbar.removeEntity(entityId); + } + }); + }, +}); + +export default removeEntityFromAllHotbarsInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/remove.injectable.ts b/packages/core/src/features/hotbar/storage/common/remove.injectable.ts new file mode 100644 index 0000000000..d06c19b2e0 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/remove.injectable.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 { iter } from "@k8slens/utilities"; +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import { action } from "mobx"; +import activeHotbarIdInjectable from "./active-id.injectable"; +import type { Hotbar } from "./hotbar"; +import hotbarsStateInjectable from "./state.injectable"; + +export type RemoveHotbar = (hotbar: Hotbar) => void; + +const removeHotbarInjectable = getInjectable({ + id: "remove-hotbar", + instantiate: (di): RemoveHotbar => { + const state = di.inject(hotbarsStateInjectable); + const activeHotbarId = di.inject(activeHotbarIdInjectable); + + return action((hotbar) => { + assert(state.size >= 2, "Cannot remove the last hotbar"); + + state.delete(hotbar.id); + + if (activeHotbarId.get() === hotbar.id) { + activeHotbarId.set(iter.first(state.values())?.id); + } + }); + }, +}); + +export default removeHotbarInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/set-as-active.injectable.ts b/packages/core/src/features/hotbar/storage/common/set-as-active.injectable.ts new file mode 100644 index 0000000000..226914fcae --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/set-as-active.injectable.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { iter } from "@k8slens/utilities"; +import { getInjectable } from "@ogre-tools/injectable"; +import { action } from "mobx"; +import activeHotbarIdInjectable from "./active-id.injectable"; +import type { Hotbar } from "./hotbar"; +import hotbarsStateInjectable from "./state.injectable"; + +export type SetAsActiveHotbar = (desc: Hotbar | number | string) => void; + +const setAsActiveHotbarInjectable = getInjectable({ + id: "set-as-active-hotbar", + instantiate: (di): SetAsActiveHotbar => { + const hotbarsState = di.inject(hotbarsStateInjectable); + const activeHotbarId = di.inject(activeHotbarIdInjectable); + + return action((desc) => { + if (typeof desc === "number") { + const hotbar = iter.nth(hotbarsState.values(), desc); + + if (hotbar) { + activeHotbarId.set(hotbar.id); + } + } else if (typeof desc === "string") { + if (hotbarsState.has(desc)) { + activeHotbarId.set(desc); + } + } else { + if (hotbarsState.has(desc.id)) { + activeHotbarId.set(desc.id); + } + } + }); + }, +}); + +export default setAsActiveHotbarInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/state.injectable.ts b/packages/core/src/features/hotbar/storage/common/state.injectable.ts new file mode 100644 index 0000000000..c544172d9a --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/state.injectable.ts @@ -0,0 +1,14 @@ +/** + * 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 { observable } from "mobx"; +import type { Hotbar } from "./hotbar"; + +const hotbarsStateInjectable = getInjectable({ + id: "hotbars-state", + instantiate: () => observable.map(), +}); + +export default hotbarsStateInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/storage.injectable.ts b/packages/core/src/features/hotbar/storage/common/storage.injectable.ts new file mode 100644 index 0000000000..3bdc2bd3b6 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/storage.injectable.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { iter } from "@k8slens/utilities"; +import { getInjectable } from "@ogre-tools/injectable"; +import { action, comparer } from "mobx"; +import catalogCatalogEntityInjectable from "../../../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import { hotbarStoreMigrationInjectionToken } from "./migrations-token"; +import { defaultHotbarCells } from "./types"; +import createPersistentStorageInjectable from "../../../../common/persistent-storage/create.injectable"; +import persistentStorageMigrationsInjectable from "../../../../common/persistent-storage/migrations.injectable"; +import storeMigrationVersionInjectable from "../../../../common/vars/store-migration-version.injectable"; +import activeHotbarIdInjectable from "./active-id.injectable"; +import createHotbarInjectable from "./create-hotbar.injectable"; +import type { Hotbar, HotbarData } from "./hotbar"; +import hotbarsStateInjectable from "./state.injectable"; + +export interface HotbarStoreModel { + hotbars: HotbarData[]; + activeHotbarId: string | undefined; +} + +const hotbarsPersistentStorageInjectable = getInjectable({ + id: "hotbars-persistent-storage", + instantiate: (di) => { + const state = di.inject(hotbarsStateInjectable); + const createPersistentStorage = di.inject(createPersistentStorageInjectable); + const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); + const activeHotbarId = di.inject(activeHotbarIdInjectable); + const createHotbar = di.inject(createHotbarInjectable); + + return createPersistentStorage({ + configName: "lens-hotbar-store", + accessPropertiesByDotNotation: false, // To make dots safe in cluster context names + syncOptions: { + equals: comparer.structural, + }, + projectVersion: di.inject(storeMigrationVersionInjectable), + migrations: di.inject(persistentStorageMigrationsInjectable, hotbarStoreMigrationInjectionToken), + fromStore: action((data) => { + if (!data.hotbars || !data.hotbars.length) { + const hotbar = createHotbar({ + name: "Default", + }); + const { + metadata: { + uid, + name, + source, + }, + } = catalogCatalogEntity; + + hotbar.items[0] = { + entity: { + uid, + name, + source, + }, + }; + state.replace([[hotbar.id, hotbar]]); + } else { + state.replace(data.hotbars.map((hotbar) => [hotbar.id, createHotbar(hotbar)])); + } + + for (const hotbar of state.values()) { + ensureExactHotbarItemLength(hotbar); + } + + if (data.activeHotbarId) { + activeHotbarId.set(data.activeHotbarId); + } + + const firstHotbarId = iter.first(state.values())?.id; + + if (!activeHotbarId.get()) { + activeHotbarId.set(firstHotbarId); + } else if (!iter.find(state.values(), hotbar => hotbar.id === activeHotbarId.get())) { + activeHotbarId.set(firstHotbarId); + } + }), + toJSON: () => ({ + hotbars: iter.chain(state.values()) + .map(hotbar => hotbar.toJSON()) + .toArray(), + activeHotbarId: activeHotbarId.get(), + }), + }); + }, +}); + +export default hotbarsPersistentStorageInjectable; + +/** + * 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; + } + } +} diff --git a/packages/core/src/features/hotbar/storage/common/switch-to-next.injectable.ts b/packages/core/src/features/hotbar/storage/common/switch-to-next.injectable.ts new file mode 100644 index 0000000000..5648c5bfc4 --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/switch-to-next.injectable.ts @@ -0,0 +1,32 @@ +/** + * 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 { action } from "mobx"; +import activeHotbarIndexInjectable from "./active-hotbar-index.injectable"; +import setAsActiveHotbarInjectable from "./set-as-active.injectable"; +import hotbarsStateInjectable from "./state.injectable"; + +export type SwitchToNextHotbar = () => void; + +const switchToNextHotbarInjectable = getInjectable({ + id: "switch-to-next-hotbar", + instantiate: (di): SwitchToNextHotbar => { + const setAsActiveHotbar = di.inject(setAsActiveHotbarInjectable); + const activeHotbarIndex = di.inject(activeHotbarIndexInjectable); + const state = di.inject(hotbarsStateInjectable); + + return action(() => { + const index = activeHotbarIndex.get() + 1; + + if (index >= state.size) { + setAsActiveHotbar(0); + } else { + setAsActiveHotbar(index); + } + }); + }, +}); + +export default switchToNextHotbarInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/switch-to-previous.injectable.ts b/packages/core/src/features/hotbar/storage/common/switch-to-previous.injectable.ts new file mode 100644 index 0000000000..d18a12c27c --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/switch-to-previous.injectable.ts @@ -0,0 +1,32 @@ +/** + * 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 { action } from "mobx"; +import activeHotbarIndexInjectable from "./active-hotbar-index.injectable"; +import setAsActiveHotbarInjectable from "./set-as-active.injectable"; +import hotbarsStateInjectable from "./state.injectable"; + +export type SwitchToPreviousHotbar = () => void; + +const switchToPreviousHotbarInjectable = getInjectable({ + id: "switch-to-previous-hotbar", + instantiate: (di): SwitchToPreviousHotbar => { + const setAsActiveHotbar = di.inject(setAsActiveHotbarInjectable); + const activeHotbarIndex = di.inject(activeHotbarIndexInjectable); + const state = di.inject(hotbarsStateInjectable); + + return action(() => { + const index = activeHotbarIndex.get() - 1; + + if (index < 0) { + setAsActiveHotbar(state.size - 1); + } else { + setAsActiveHotbar(index); + } + }); + }, +}); + +export default switchToPreviousHotbarInjectable; diff --git a/packages/core/src/features/hotbar/storage/common/toggling.injectable.ts b/packages/core/src/features/hotbar/storage/common/toggling.injectable.ts new file mode 100644 index 0000000000..4daf9ca41e --- /dev/null +++ b/packages/core/src/features/hotbar/storage/common/toggling.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 activeHotbarInjectable from "./active.injectable"; +import type { Hotbar } from "./hotbar"; + +export type ActiveHotbarModel = Pick; + +const activeHotbarModelInjectable = getInjectable({ + id: "active-hotbar-model", + instantiate: (di): ActiveHotbarModel => { + const activeHotbar = di.inject(activeHotbarInjectable); + + return { + hasEntity: (entityId) => activeHotbar.get()?.hasEntity(entityId) ?? false, + toggleEntity: (entity) => activeHotbar.get()?.toggleEntity(entity), + addEntity: (entity) => activeHotbar.get()?.addEntity(entity), + removeEntity: (entityId) => activeHotbar.get()?.removeEntity(entityId), + }; + }, +}); + +export default activeHotbarModelInjectable; diff --git a/packages/core/src/common/hotbars/types.ts b/packages/core/src/features/hotbar/storage/common/types.ts similarity index 56% rename from packages/core/src/common/hotbars/types.ts rename to packages/core/src/features/hotbar/storage/common/types.ts index 2925c785a0..3153fdd2fa 100644 --- a/packages/core/src/common/hotbars/types.ts +++ b/packages/core/src/features/hotbar/storage/common/types.ts @@ -3,9 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import * as uuid from "uuid"; -import type { Tuple } from "@k8slens/utilities"; -import { tuple } from "@k8slens/utilities"; export interface HotbarItem { entity: { @@ -18,12 +15,10 @@ export interface HotbarItem { }; } -export type Hotbar = Required; - export interface CreateHotbarData { id?: string; name: string; - items?: Tuple; + items?: (HotbarItem | null)[]; } export interface CreateHotbarOptions { @@ -31,11 +26,3 @@ export interface CreateHotbarOptions { } export const defaultHotbarCells = 12; // Number is chosen to easy hit any item with keyboard - -export function getEmptyHotbar(name: string, id: string = uuid.v4()): Hotbar { - return { - id, - items: tuple.filled(defaultHotbarCells, null), - name, - }; -} diff --git a/packages/core/src/features/hotbar/storage/main/5.0.0-alpha.0.injectable.ts b/packages/core/src/features/hotbar/storage/main/5.0.0-alpha.0.injectable.ts new file mode 100644 index 0000000000..9450b9528f --- /dev/null +++ b/packages/core/src/features/hotbar/storage/main/5.0.0-alpha.0.injectable.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Cleans up a store that had the state related data stored +import catalogCatalogEntityInjectable from "../../../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import { hotbarStoreMigrationInjectionToken } from "../common/migrations-token"; +import createHotbarInjectable from "../common/create-hotbar.injectable"; + +const v500Alpha0HotbarStoreMigrationInjectable = getInjectable({ + id: "v5.0.0-alpha.0-hotbar-store-migration", + instantiate: (di) => ({ + version: "5.0.0-alpha.0", + run(store) { + const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); + const createHotbar = di.inject(createHotbarInjectable); + const hotbar = createHotbar({ name: "default" }); + + hotbar.addEntity(catalogCatalogEntity); + + store.set("hotbars", [hotbar.toJSON()]); + }, + }), + injectionToken: hotbarStoreMigrationInjectionToken, +}); + +export default v500Alpha0HotbarStoreMigrationInjectable; + diff --git a/packages/core/src/main/hotbar-store/migrations/5.0.0-alpha.2.injectable.ts b/packages/core/src/features/hotbar/storage/main/5.0.0-alpha.2.injectable.ts similarity index 76% rename from packages/core/src/main/hotbar-store/migrations/5.0.0-alpha.2.injectable.ts rename to packages/core/src/features/hotbar/storage/main/5.0.0-alpha.2.injectable.ts index 550f46a940..adf7ff4f30 100644 --- a/packages/core/src/main/hotbar-store/migrations/5.0.0-alpha.2.injectable.ts +++ b/packages/core/src/features/hotbar/storage/main/5.0.0-alpha.2.injectable.ts @@ -4,10 +4,10 @@ */ // Cleans up a store that had the state related data stored -import type { Hotbar } from "../../../common/hotbars/types"; import * as uuid from "uuid"; import { getInjectable } from "@ogre-tools/injectable"; -import { hotbarStoreMigrationInjectionToken } from "../../../common/hotbars/migrations-token"; +import { hotbarStoreMigrationInjectionToken } from "../common/migrations-token"; +import type { HotbarData } from "../common/hotbar"; const v500Alpha2HotbarStoreMigrationInjectable = getInjectable({ id: "v5.0.0-alpha.2-hotbar-store-migration", @@ -15,7 +15,7 @@ const v500Alpha2HotbarStoreMigrationInjectable = getInjectable({ version: "5.0.0-alpha.2", run(store) { const rawHotbars = store.get("hotbars"); - const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars : []; + const hotbars: HotbarData[] = Array.isArray(rawHotbars) ? rawHotbars : []; store.set("hotbars", hotbars.map(({ id, ...rest }) => ({ id: id || uuid.v4(), diff --git a/packages/core/src/features/hotbar/storage/main/5.0.0-beta.10.injectable.ts b/packages/core/src/features/hotbar/storage/main/5.0.0-beta.10.injectable.ts new file mode 100644 index 0000000000..a777a81b6a --- /dev/null +++ b/packages/core/src/features/hotbar/storage/main/5.0.0-beta.10.injectable.ts @@ -0,0 +1,168 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import * as uuid from "uuid"; +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import catalogCatalogEntityInjectable from "../../../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import { isDefined, isErrnoException } from "@k8slens/utilities"; +import joinPathsInjectable from "../../../../common/path/join-paths.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import { hotbarStoreMigrationInjectionToken } from "../common/migrations-token"; +import readJsonSyncInjectable from "../../../../common/fs/read-json-sync.injectable"; +import loggerInjectable from "../../../../common/logger.injectable"; +import { generateNewIdFor } from "../../../../common/utils/generate-new-id-for"; +import type { ClusterModel } from "../../../../common/cluster-types"; +import { defaultHotbarCells } from "../common/types"; +import type { HotbarData } from "../common/hotbar"; +import createHotbarInjectable from "../common/create-hotbar.injectable"; + +interface Pre500WorkspaceStoreModel { + workspaces: { + id: string; + name: string; + }[]; +} + +interface Pre500ClusterModel extends ClusterModel { + workspace?: string; + workspaces?: string[]; +} + +interface Pre500ClusterStoreModel { + clusters?: Pre500ClusterModel[]; +} + +const v500Beta10HotbarStoreMigrationInjectable = getInjectable({ + id: "v5.0.0-beta.10-hotbar-store-migration", + instantiate: (di) => ({ + version: "5.0.0-beta.10", + run(store) { + const userDataPath = di.inject(directoryForUserDataInjectable); + const joinPaths = di.inject(joinPathsInjectable); + const readJsonSync = di.inject(readJsonSyncInjectable); + const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); + const logger = di.inject(loggerInjectable); + const createHotbar = di.inject(createHotbarInjectable); + const rawHotbars = store.get("hotbars"); + const hotbars: HotbarData[] = Array.isArray(rawHotbars) ? rawHotbars.filter(h => h && typeof h === "object") : []; + + // Hotbars might be empty, if some of the previous migrations weren't run + if (hotbars.length === 0) { + const hotbar = createHotbar({ name: "default" }); + + hotbar.addEntity(catalogCatalogEntity); + hotbars.push(hotbar.toJSON()); + } + + try { + const workspaceStoreData: Pre500WorkspaceStoreModel = readJsonSync(joinPaths(userDataPath, "lens-workspace-store.json")); + const { clusters = [] }: Pre500ClusterStoreModel = readJsonSync(joinPaths(userDataPath, "lens-cluster-store.json")); + const workspaceHotbars = new Map(); // mapping from WorkspaceId to HotBar + + for (const { id, name } of workspaceStoreData.workspaces) { + logger.info(`Creating new hotbar for ${name}`); + workspaceHotbars.set(id, { + id: uuid.v4(), + items: [], + name: `Workspace: ${name}`, + }); + } + + { + // grab the default named hotbar or the first. + const defaultHotbarIndex = Math.max(0, hotbars.findIndex(hotbar => hotbar.name === "default")); + const [{ name, id, items }] = hotbars.splice(defaultHotbarIndex, 1); + + workspaceHotbars.set("default", { + name, + id, + items: items.filter(isDefined), + }); + } + + for (const cluster of clusters) { + const uid = generateNewIdFor(cluster); + + for (const workspaceId of cluster.workspaces ?? [cluster.workspace].filter(isDefined)) { + const workspaceHotbar = workspaceHotbars.get(workspaceId); + + if (!workspaceHotbar) { + logger.info(`Cluster ${uid} has unknown workspace ID, skipping`); + continue; + } + + logger.info(`Adding cluster ${uid} to ${workspaceHotbar.name}`); + + if (workspaceHotbar?.items.length < defaultHotbarCells) { + workspaceHotbar.items.push({ + entity: { + uid: generateNewIdFor(cluster), + name: cluster.preferences?.clusterName || cluster.contextName, + }, + }); + } + } + } + + for (const hotbar of workspaceHotbars.values()) { + if (hotbar.items.length === 0) { + logger.info(`Skipping ${hotbar.name} due to it being empty`); + continue; + } + + while (hotbar.items.length < defaultHotbarCells) { + hotbar.items.push(null); + } + + hotbars.push(hotbar); + } + + /** + * Finally, make sure that the catalog entity hotbar item is in place. + * Just in case something else removed it. + * + * if every hotbar has elements that all not the `catalog-entity` item + */ + if (hotbars.every(hotbar => hotbar.items.every(item => item?.entity?.uid !== "catalog-entity"))) { + // note, we will add a new whole hotbar here called "default" if that was previously removed + const defaultHotbarIndex = hotbars.findIndex(hotbar => hotbar.name === "default"); + + if (defaultHotbarIndex >= 0) { + const defaultHotbar = createHotbar(hotbars[defaultHotbarIndex]); + + if (defaultHotbar.isFull()) { + // making a new hotbar is less destructive if the first hotbar + // called "default" is full than overriding a hotbar item + const hotbar = createHotbar({ name: "initial" }); + + hotbar.addEntity(catalogCatalogEntity); + hotbars.unshift(hotbar.toJSON()); + } else { + defaultHotbar.addEntity(catalogCatalogEntity); + hotbars[defaultHotbarIndex] = defaultHotbar.toJSON(); + } + } else { + const hotbar = createHotbar({ name: "default" }); + + hotbar.addEntity(catalogCatalogEntity); + hotbars.unshift(hotbar.toJSON()); + } + } + + } catch (error) { + // ignore files being missing + if (isErrnoException(error) && error.code !== "ENOENT") { + throw error; + } + } + + store.set("hotbars", hotbars); + }, + }), + injectionToken: hotbarStoreMigrationInjectionToken, +}); + +export default v500Beta10HotbarStoreMigrationInjectable; + diff --git a/packages/core/src/features/hotbar/storage/main/5.0.0-beta.5.injectable.ts b/packages/core/src/features/hotbar/storage/main/5.0.0-beta.5.injectable.ts new file mode 100644 index 0000000000..75f3868d7b --- /dev/null +++ b/packages/core/src/features/hotbar/storage/main/5.0.0-beta.5.injectable.ts @@ -0,0 +1,51 @@ +/** + * 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 catalogEntityRegistryInjectable from "../../../../main/catalog/entity-registry.injectable"; +import type { HotbarData } from "../common/hotbar"; +import { hotbarStoreMigrationInjectionToken } from "../common/migrations-token"; + +const v500Beta5HotbarStoreMigrationInjectable = getInjectable({ + id: "v500-beta5-hotbar-store-migration", + instantiate: (di) => ({ + version: "5.0.0-beta.5", + run(store) { + const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); + const rawHotbars = store.get("hotbars"); + const hotbars: HotbarData[] = Array.isArray(rawHotbars) ? rawHotbars : []; + + for (const hotbar of hotbars) { + for (let i = 0; i < hotbar.items.length; i += 1) { + const item = hotbar.items[i]; + + if (!item) { + continue; + } + + const entity = catalogEntityRegistry.findById(item.entity.uid); + + if (!entity) { + // Clear disabled item + hotbar.items[i] = null; + } else { + // Save additional data + item.entity = { + ...item.entity, + name: entity.metadata.name, + source: entity.metadata.source, + }; + } + } + } + + store.set("hotbars", hotbars); + }, + }), + injectionToken: hotbarStoreMigrationInjectionToken, +}); + +export default v500Beta5HotbarStoreMigrationInjectable; + diff --git a/packages/core/src/features/hotbar/store/main/init.injectable.ts b/packages/core/src/features/hotbar/storage/main/load-storage.injectable.ts similarity index 66% rename from packages/core/src/features/hotbar/store/main/init.injectable.ts rename to packages/core/src/features/hotbar/storage/main/load-storage.injectable.ts index cb45c396ac..d541f80249 100644 --- a/packages/core/src/features/hotbar/store/main/init.injectable.ts +++ b/packages/core/src/features/hotbar/storage/main/load-storage.injectable.ts @@ -3,21 +3,21 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; import { onLoadOfApplicationInjectionToken } from "@k8slens/application"; import setupSyncingOfGeneralCatalogEntitiesInjectable from "../../../../main/start-main-application/runnables/setup-syncing-of-general-catalog-entities.injectable"; +import hotbarsPersistentStorageInjectable from "../common/storage.injectable"; -const initHotbarStoreInjectable = getInjectable({ - id: "init-hotbar-store", +const loadHotbarStorageInjectable = getInjectable({ + id: "load-hotbar-storage", instantiate: (di) => ({ run: () => { - const hotbarStore = di.inject(hotbarStoreInjectable); + const storage = di.inject(hotbarsPersistentStorageInjectable); - hotbarStore.load(); + storage.loadAndStartSyncing(); }, runAfter: setupSyncingOfGeneralCatalogEntitiesInjectable, }), injectionToken: onLoadOfApplicationInjectionToken, }); -export default initHotbarStoreInjectable; +export default loadHotbarStorageInjectable; diff --git a/packages/core/src/features/hotbar/store/renderer/init.injectable.ts b/packages/core/src/features/hotbar/storage/renderer/init.injectable.ts similarity index 64% rename from packages/core/src/features/hotbar/store/renderer/init.injectable.ts rename to packages/core/src/features/hotbar/storage/renderer/init.injectable.ts index bb6c2da259..ebc787bd4a 100644 --- a/packages/core/src/features/hotbar/store/renderer/init.injectable.ts +++ b/packages/core/src/features/hotbar/storage/renderer/init.injectable.ts @@ -3,21 +3,21 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; import { beforeFrameStartsSecondInjectionToken } from "../../../../renderer/before-frame-starts/tokens"; import initClusterStoreInjectable from "../../../cluster/storage/renderer/init.injectable"; +import hotbarsPersistentStorageInjectable from "../common/storage.injectable"; -const initHotbarStoreInjectable = getInjectable({ - id: "init-hotbar-store", +const loadHotbarStorageInjectable = getInjectable({ + id: "load-hotbar-storage", instantiate: (di) => ({ run: () => { - const hotbarStore = di.inject(hotbarStoreInjectable); + const storage = di.inject(hotbarsPersistentStorageInjectable); - hotbarStore.load(); + storage.loadAndStartSyncing(); }, runAfter: initClusterStoreInjectable, }), injectionToken: beforeFrameStartsSecondInjectionToken, }); -export default initHotbarStoreInjectable; +export default loadHotbarStorageInjectable; diff --git a/packages/core/src/features/hotbar/storage/storage-technical.test.ts b/packages/core/src/features/hotbar/storage/storage-technical.test.ts new file mode 100644 index 0000000000..54e6875c0a --- /dev/null +++ b/packages/core/src/features/hotbar/storage/storage-technical.test.ts @@ -0,0 +1,373 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { anyObject } from "jest-mock-extended"; +import type { CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../../../common/catalog"; +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import type { DiContainer } from "@ogre-tools/injectable"; +import catalogEntityRegistryInjectable from "../../../main/catalog/entity-registry.injectable"; +import type { IComputedValue } from "mobx"; +import { computed } from "mobx"; +import hasCategoryForEntityInjectable from "../../../common/catalog/has-category-for-entity.injectable"; +import catalogCatalogEntityInjectable from "../../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import type { Logger } from "../../../common/logger"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import storeMigrationVersionInjectable from "../../../common/vars/store-migration-version.injectable"; +import writeJsonSyncInjectable from "../../../common/fs/write-json-sync.injectable"; +import type { SetAsActiveHotbar } from "./common/set-as-active.injectable"; +import setAsActiveHotbarInjectable from "./common/set-as-active.injectable"; +import hotbarsPersistentStorageInjectable from "./common/storage.injectable"; +import type { Hotbar } from "./common/hotbar"; +import hotbarsInjectable from "./common/hotbars.injectable"; +import activeHotbarInjectable from "./common/active.injectable"; +import type { AddHotbar } from "./common/add.injectable"; +import type { GetHotbarById } from "./common/get-by-id.injectable"; +import getHotbarByIdInjectable from "./common/get-by-id.injectable"; +import addHotbarInjectable from "./common/add.injectable"; +import { defaultHotbarCells } from "./common/types"; + +function getMockCatalogEntity(data: Partial & CatalogEntityKindData): CatalogEntity { + return { + getName: jest.fn(() => data.metadata?.name), + getId: jest.fn(() => data.metadata?.uid), + getSource: jest.fn(() => data.metadata?.source ?? "unknown"), + isEnabled: jest.fn(() => data.status?.enabled ?? true), + onContextMenuOpen: jest.fn(), + onSettingsOpen: jest.fn(), + metadata: {}, + spec: {}, + status: {}, + ...data, + } as CatalogEntity; +} + +describe("Hotbars technical tests", () => { + let di: DiContainer; + let testCluster: CatalogEntity; + let minikubeCluster: CatalogEntity; + let awsCluster: CatalogEntity; + let loggerMock: jest.Mocked; + let setAsActiveHotbar: SetAsActiveHotbar; + let hotbars: IComputedValue; + let activeHotbar: IComputedValue; + let addHotbar: AddHotbar; + let getHotbarById: GetHotbarById; + + beforeEach(async () => { + di = getDiForUnitTesting(); + + testCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "some-test-id", + name: "my-test-cluster", + source: "local", + labels: {}, + }, + }); + minikubeCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "some-minikube-id", + name: "my-minikube-cluster", + source: "local", + labels: {}, + }, + }); + awsCluster = getMockCatalogEntity({ + apiVersion: "v1", + kind: "Cluster", + status: { + phase: "Running", + }, + metadata: { + uid: "some-aws-id", + name: "my-aws-cluster", + source: "local", + labels: {}, + }, + }); + + di.override(hasCategoryForEntityInjectable, () => () => true); + + loggerMock = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + silly: jest.fn(), + }; + + di.override(loggerInjectable, () => loggerMock); + + di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); + + const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); + const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); + + catalogEntityRegistry.addComputedSource("some-id", computed(() => [ + testCluster, + minikubeCluster, + awsCluster, + catalogCatalogEntity, + ])); + + setAsActiveHotbar = di.inject(setAsActiveHotbarInjectable); + hotbars = di.inject(hotbarsInjectable); + activeHotbar = di.inject(activeHotbarInjectable); + addHotbar = di.inject(addHotbarInjectable); + getHotbarById = di.inject(getHotbarByIdInjectable); + }); + + describe("given no previous data in store, running all migrations", () => { + beforeEach(() => { + di.override(storeMigrationVersionInjectable, () => "9999.0.0"); + di.inject(hotbarsPersistentStorageInjectable).loadAndStartSyncing(); + }); + + describe("load", () => { + it("loads one hotbar by default", () => { + expect(hotbars.get().length).toEqual(1); + }); + }); + + describe("add", () => { + it("adds a hotbar", () => { + addHotbar({ name: "hottest" }); + expect(hotbars.get().length).toEqual(2); + }); + }); + + describe("hotbar items", () => { + it("initially creates default number of empty cells", () => { + expect(activeHotbar.get()?.items?.length).toEqual(defaultHotbarCells); + }); + + it("initially adds catalog entity as first item", () => { + expect(activeHotbar.get()?.items[0]?.entity.name).toEqual("Catalog"); + }); + + it("adds items", () => { + activeHotbar.get()?.addEntity(testCluster); + const items = activeHotbar.get()?.items.filter(Boolean); + + expect(items?.length).toEqual(2); + }); + + it("removes items", () => { + activeHotbar.get()?.addEntity(testCluster); + activeHotbar.get()?.removeEntity("some-test-id"); + activeHotbar.get()?.removeEntity("catalog-entity"); + const items = activeHotbar.get()?.items.filter(Boolean); + + expect(items).toStrictEqual([]); + }); + + it("does nothing if removing with invalid uid", () => { + activeHotbar.get()?.addEntity(testCluster); + activeHotbar.get()?.removeEntity("invalid uid"); + const items = activeHotbar.get()?.items.filter(Boolean); + + expect(items?.length).toEqual(2); + }); + + it("moves item to empty cell", () => { + activeHotbar.get()?.addEntity(testCluster); + activeHotbar.get()?.addEntity(minikubeCluster); + activeHotbar.get()?.addEntity(awsCluster); + + expect(activeHotbar.get()?.items[6]).toBeNull(); + + activeHotbar.get()?.restack(1, 5); + + expect(activeHotbar.get()?.items[5]).toBeTruthy(); + expect(activeHotbar.get()?.items[5]?.entity.uid).toEqual("some-test-id"); + }); + + it("moves items down", () => { + activeHotbar.get()?.addEntity(testCluster); + activeHotbar.get()?.addEntity(minikubeCluster); + activeHotbar.get()?.addEntity(awsCluster); + + // aws -> catalog + activeHotbar.get()?.restack(3, 0); + + const items = activeHotbar.get()?.items.map(item => item?.entity.uid || null); + + expect(items?.slice(0, 4)).toEqual(["some-aws-id", "catalog-entity", "some-test-id", "some-minikube-id"]); + }); + + it("moves items up", () => { + activeHotbar.get()?.addEntity(testCluster); + activeHotbar.get()?.addEntity(minikubeCluster); + activeHotbar.get()?.addEntity(awsCluster); + + // test -> aws + activeHotbar.get()?.restack(1, 3); + + const items = activeHotbar.get()?.items.map(item => item?.entity.uid || null); + + expect(items?.slice(0, 4)).toEqual(["catalog-entity", "some-minikube-id", "some-aws-id", "some-test-id"]); + }); + + it("logs an error if cellIndex is out of bounds", () => { + addHotbar({ name: "hottest", id: "hottest" }); + setAsActiveHotbar("hottest"); + + activeHotbar.get()?.addEntity(testCluster, -1); + expect(loggerMock.error).toBeCalledWith("[HOTBAR]: cannot pin entity to hotbar outside of index range", anyObject()); + + activeHotbar.get()?.addEntity(testCluster, 12); + expect(loggerMock.error).toBeCalledWith("[HOTBAR]: cannot pin entity to hotbar outside of index range", anyObject()); + + activeHotbar.get()?.addEntity(testCluster, 13); + expect(loggerMock.error).toBeCalledWith("[HOTBAR]: cannot pin entity to hotbar outside of index range", anyObject()); + }); + + it("throws an error if getId is invalid or returns not a string", () => { + expect(() => activeHotbar.get()?.addEntity({} as any)).toThrowError(TypeError); + expect(() => activeHotbar.get()?.addEntity({ getId: () => true } as any)).toThrowError(TypeError); + }); + + it("throws an error if getName is invalid or returns not a string", () => { + expect(() => activeHotbar.get()?.addEntity({ getId: () => "" } as any)).toThrowError(TypeError); + expect(() => activeHotbar.get()?.addEntity({ getId: () => "", getName: () => 4 } as any)).toThrowError(TypeError); + }); + + it("does nothing when item moved to same cell", () => { + activeHotbar.get()?.addEntity(testCluster); + activeHotbar.get()?.restack(1, 1); + + expect(activeHotbar.get()?.items[1]?.entity.uid).toEqual("some-test-id"); + }); + + it("new items takes first empty cell", () => { + activeHotbar.get()?.addEntity(testCluster); + activeHotbar.get()?.addEntity(awsCluster); + activeHotbar.get()?.restack(0, 3); + activeHotbar.get()?.addEntity(minikubeCluster); + + expect(activeHotbar.get()?.items[0]?.entity.uid).toEqual("some-minikube-id"); + }); + + it("throws if invalid arguments provided", () => { + activeHotbar.get()?.addEntity(testCluster); + + expect(() => activeHotbar.get()?.restack(-5, 0)).toThrow(); + expect(() => activeHotbar.get()?.restack(2, -1)).toThrow(); + expect(() => activeHotbar.get()?.restack(14, 1)).toThrow(); + expect(() => activeHotbar.get()?.restack(11, 112)).toThrow(); + }); + + it("checks if entity already pinned to hotbar", () => { + activeHotbar.get()?.addEntity(testCluster); + + expect(activeHotbar.get()?.hasEntity(testCluster.getId())).toBeTruthy(); + expect(activeHotbar.get()?.hasEntity(awsCluster.getId())).toBeFalsy(); + }); + }); + }); + + describe("given data from 5.0.0-beta.3 and version being 5.0.0-beta.10", () => { + beforeEach(() => { + const writeJsonSync = di.inject(writeJsonSyncInjectable); + + writeJsonSync("/some-directory-for-user-data/lens-hotbar-store.json", { + __internal__: { + migrations: { + version: "5.0.0-beta.3", + }, + }, + hotbars: [ + { + id: "3caac17f-aec2-4723-9694-ad204465d935", + name: "myhotbar", + items: [ + { + entity: { + uid: "some-aws-id", + }, + }, + { + entity: { + uid: "55b42c3c7ba3b04193416cda405269a5", + }, + }, + { + entity: { + uid: "176fd331968660832f62283219d7eb6e", + }, + }, + { + entity: { + uid: "61c4fb45528840ebad1badc25da41d14", + name: "user1-context", + source: "local", + }, + }, + { + entity: { + uid: "27d6f99fe9e7548a6e306760bfe19969", + name: "foo2", + source: "local", + }, + }, + null, + { + entity: { + uid: "c0b20040646849bb4dcf773e43a0bf27", + name: "multinode-demo", + source: "local", + }, + }, + null, + null, + null, + null, + null, + ], + }, + ], + }); + + di.override(storeMigrationVersionInjectable, () => "5.0.0-beta.10"); + + di.inject(hotbarsPersistentStorageInjectable).loadAndStartSyncing(); + }); + + it("allows to retrieve a hotbar", () => { + const hotbar = getHotbarById("3caac17f-aec2-4723-9694-ad204465d935"); + + expect(hotbar?.id).toBe("3caac17f-aec2-4723-9694-ad204465d935"); + }); + + it("clears cells without entity", () => { + const items = hotbars.get()[0].items; + + expect(items[2]).toBeNull(); + }); + + it("adds extra data to cells with according entity", () => { + const items = hotbars.get()[0].items; + + expect(items[0]).toEqual({ + entity: { + name: "my-aws-cluster", + source: "local", + uid: "some-aws-id", + }, + }); + }); + }); +}); diff --git a/packages/core/src/main/hotbar-store/migrations/5.0.0-alpha.0.injectable.ts b/packages/core/src/main/hotbar-store/migrations/5.0.0-alpha.0.injectable.ts deleted file mode 100644 index 9143167b47..0000000000 --- a/packages/core/src/main/hotbar-store/migrations/5.0.0-alpha.0.injectable.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -// Cleans up a store that had the state related data stored -import { getEmptyHotbar } from "../../../common/hotbars/types"; -import catalogCatalogEntityInjectable from "../../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; -import { hotbarStoreMigrationInjectionToken } from "../../../common/hotbars/migrations-token"; - -const v500Alpha0HotbarStoreMigrationInjectable = getInjectable({ - id: "v5.0.0-alpha.0-hotbar-store-migration", - instantiate: (di) => { - const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); - - return { - version: "5.0.0-alpha.0", - run(store) { - const hotbar = getEmptyHotbar("default"); - - const { metadata: { uid, name, source }} = catalogCatalogEntity; - - hotbar.items[0] = { entity: { uid, name, source }}; - - store.set("hotbars", [hotbar]); - }, - }; - }, - injectionToken: hotbarStoreMigrationInjectionToken, -}); - -export default v500Alpha0HotbarStoreMigrationInjectable; - diff --git a/packages/core/src/main/hotbar-store/migrations/5.0.0-beta.10.injectable.ts b/packages/core/src/main/hotbar-store/migrations/5.0.0-beta.10.injectable.ts deleted file mode 100644 index b327085aa6..0000000000 --- a/packages/core/src/main/hotbar-store/migrations/5.0.0-beta.10.injectable.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import * as uuid from "uuid"; -import type { Hotbar, HotbarItem } from "../../../common/hotbars/types"; -import { defaultHotbarCells, getEmptyHotbar } from "../../../common/hotbars/types"; -import { getLegacyGlobalDiForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import catalogCatalogEntityInjectable from "../../../common/catalog-entities/general-catalog-entities/implementations/catalog-catalog-entity.injectable"; -import { isDefined, isErrnoException } from "@k8slens/utilities"; -import joinPathsInjectable from "../../../common/path/join-paths.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; -import { hotbarStoreMigrationInjectionToken } from "../../../common/hotbars/migrations-token"; -import readJsonSyncInjectable from "../../../common/fs/read-json-sync.injectable"; -import loggerInjectable from "../../../common/logger.injectable"; -import { generateNewIdFor } from "../../../common/utils/generate-new-id-for"; -import type { ClusterModel } from "../../../common/cluster-types"; - -interface Pre500WorkspaceStoreModel { - workspaces: { - id: string; - name: string; - }[]; -} - -interface PartialHotbar { - id: string; - name: string; - items: (null | HotbarItem)[]; -} - -interface Pre500ClusterModel extends ClusterModel { - workspace?: string; - workspaces?: string[]; -} - -interface Pre500ClusterStoreModel { - clusters?: Pre500ClusterModel[]; -} - -const v500Beta10HotbarStoreMigrationInjectable = getInjectable({ - id: "v5.0.0-beta.10-hotbar-store-migration", - instantiate: (di) => { - const userDataPath = di.inject(directoryForUserDataInjectable); - const joinPaths = di.inject(joinPathsInjectable); - const readJsonSync = di.inject(readJsonSyncInjectable); - const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); - const logger = di.inject(loggerInjectable); - - return { - version: "5.0.0-beta.10", - run(store) { - const rawHotbars = store.get("hotbars"); - const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars.filter(h => h && typeof h === "object") : []; - - - // Hotbars might be empty, if some of the previous migrations weren't run - if (hotbars.length === 0) { - const hotbar = getEmptyHotbar("default"); - const { metadata: { uid, name, source }} = catalogCatalogEntity; - - hotbar.items[0] = { entity: { uid, name, source }}; - - hotbars.push(hotbar); - } - - try { - const workspaceStoreData: Pre500WorkspaceStoreModel = readJsonSync(joinPaths(userDataPath, "lens-workspace-store.json")); - const { clusters = [] }: Pre500ClusterStoreModel = readJsonSync(joinPaths(userDataPath, "lens-cluster-store.json")); - const workspaceHotbars = new Map(); // mapping from WorkspaceId to HotBar - - for (const { id, name } of workspaceStoreData.workspaces) { - logger.info(`Creating new hotbar for ${name}`); - workspaceHotbars.set(id, { - id: uuid.v4(), // don't use the old IDs as they aren't necessarily UUIDs - items: [], - name: `Workspace: ${name}`, - }); - } - - { - // grab the default named hotbar or the first. - const defaultHotbarIndex = Math.max(0, hotbars.findIndex(hotbar => hotbar.name === "default")); - const [{ name, id, items }] = hotbars.splice(defaultHotbarIndex, 1); - - workspaceHotbars.set("default", { - name, - id, - items: items.filter(isDefined), - }); - } - - for (const cluster of clusters) { - const uid = generateNewIdFor(cluster); - - for (const workspaceId of cluster.workspaces ?? [cluster.workspace].filter(isDefined)) { - const workspaceHotbar = workspaceHotbars.get(workspaceId); - - if (!workspaceHotbar) { - logger.info(`Cluster ${uid} has unknown workspace ID, skipping`); - continue; - } - - logger.info(`Adding cluster ${uid} to ${workspaceHotbar.name}`); - - if (workspaceHotbar?.items.length < defaultHotbarCells) { - workspaceHotbar.items.push({ - entity: { - uid: generateNewIdFor(cluster), - name: cluster.preferences?.clusterName || cluster.contextName, - }, - }); - } - } - } - - for (const hotbar of workspaceHotbars.values()) { - if (hotbar.items.length === 0) { - logger.info(`Skipping ${hotbar.name} due to it being empty`); - continue; - } - - while (hotbar.items.length < defaultHotbarCells) { - hotbar.items.push(null); - } - - hotbars.push(hotbar as Hotbar); - } - - /** - * Finally, make sure that the catalog entity hotbar item is in place. - * Just in case something else removed it. - * - * if every hotbar has elements that all not the `catalog-entity` item - */ - if (hotbars.every(hotbar => hotbar.items.every(item => item?.entity?.uid !== "catalog-entity"))) { - // note, we will add a new whole hotbar here called "default" if that was previously removed - const di = getLegacyGlobalDiForExtensionApi(); - const catalogCatalogEntity = di.inject(catalogCatalogEntityInjectable); - - const defaultHotbar = hotbars.find(hotbar => hotbar.name === "default"); - const { metadata: { uid, name, source }} = catalogCatalogEntity; - - if (defaultHotbar) { - const freeIndex = defaultHotbar.items.findIndex(i => i === null); - - if (freeIndex === -1) { - // making a new hotbar is less destructive if the first hotbar - // called "default" is full than overriding a hotbar item - const hotbar = getEmptyHotbar("initial"); - - hotbar.items[0] = { entity: { uid, name, source }}; - hotbars.unshift(hotbar); - } else { - defaultHotbar.items[freeIndex] = { entity: { uid, name, source }}; - } - } else { - const hotbar = getEmptyHotbar("default"); - - hotbar.items[0] = { entity: { uid, name, source }}; - hotbars.unshift(hotbar); - } - } - - } catch (error) { - // ignore files being missing - if (isErrnoException(error) && error.code !== "ENOENT") { - throw error; - } - } - - store.set("hotbars", hotbars); - }, - }; - }, - injectionToken: hotbarStoreMigrationInjectionToken, -}); - -export default v500Beta10HotbarStoreMigrationInjectable; - diff --git a/packages/core/src/main/hotbar-store/migrations/5.0.0-beta.5.injectable.ts b/packages/core/src/main/hotbar-store/migrations/5.0.0-beta.5.injectable.ts deleted file mode 100644 index eef10f7033..0000000000 --- a/packages/core/src/main/hotbar-store/migrations/5.0.0-beta.5.injectable.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { Hotbar } from "../../../common/hotbars/types"; -import catalogEntityRegistryInjectable from "../../catalog/entity-registry.injectable"; -import { getInjectable } from "@ogre-tools/injectable"; -import { hotbarStoreMigrationInjectionToken } from "../../../common/hotbars/migrations-token"; - -const v500Beta5HotbarStoreMigrationInjectable = getInjectable({ - id: "v500-beta5-hotbar-store-migration", - instantiate: (di) => { - const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); - - return { - version: "5.0.0-beta.5", - run(store) { - const rawHotbars = store.get("hotbars"); - const hotbars: Hotbar[] = Array.isArray(rawHotbars) ? rawHotbars : []; - - for (const hotbar of hotbars) { - for (let i = 0; i < hotbar.items.length; i += 1) { - const item = hotbar.items[i]; - - if (!item) { - continue; - } - - const entity = catalogEntityRegistry.findById(item.entity.uid); - - if (!entity) { - // Clear disabled item - hotbar.items[i] = null; - } else { - // Save additional data - item.entity = { - ...item.entity, - name: entity.metadata.name, - source: entity.metadata.source, - }; - } - } - } - - store.set("hotbars", hotbars); - }, - }; - }, - injectionToken: hotbarStoreMigrationInjectionToken, -}); - -export default v500Beta5HotbarStoreMigrationInjectable; - diff --git a/packages/core/src/renderer/components/+catalog/__tests__/custom-columns.test.ts b/packages/core/src/renderer/components/+catalog/__tests__/custom-columns.test.ts index 8b5ed60a36..381f9541ac 100644 --- a/packages/core/src/renderer/components/+catalog/__tests__/custom-columns.test.ts +++ b/packages/core/src/renderer/components/+catalog/__tests__/custom-columns.test.ts @@ -11,7 +11,6 @@ import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns"; import type { CategoryColumns, GetCategoryColumnsParams } from "../columns/get.injectable"; import getCategoryColumnsInjectable from "../columns/get.injectable"; -import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; import extensionInjectable from "../../../../extensions/extension-loader/extension/extension.injectable"; import currentlyInClusterFrameInjectable from "../../../routes/currently-in-cluster-frame.injectable"; @@ -46,7 +45,6 @@ describe("Custom Category Columns", () => { beforeEach(() => { di = getDiForUnitTesting(); - di.override(hotbarStoreInjectable, () => ({})); di.override(currentlyInClusterFrameInjectable, () => false); getCategoryColumns = di.inject(getCategoryColumnsInjectable); diff --git a/packages/core/src/renderer/components/+catalog/catalog.tsx b/packages/core/src/renderer/components/+catalog/catalog.tsx index 1d385d78c4..0d11d070ab 100644 --- a/packages/core/src/renderer/components/+catalog/catalog.tsx +++ b/packages/core/src/renderer/components/+catalog/catalog.tsx @@ -28,8 +28,6 @@ import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entit import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable"; import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; -import type { HotbarStore } from "../../../common/hotbars/store"; -import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; import type { Logger } from "../../../common/logger"; import loggerInjectable from "../../../common/logger.injectable"; import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable"; @@ -51,6 +49,8 @@ import type { OnCatalogEntityListClick } from "./entity-details/on-catalog-click import onCatalogEntityListClickInjectable from "./entity-details/on-catalog-click.injectable"; import type { ShowEntityDetails } from "./entity-details/show.injectable"; import showEntityDetailsInjectable from "./entity-details/show.injectable"; +import type { Hotbar } from "../../../features/hotbar/storage/common/hotbar"; +import activeHotbarInjectable from "../../../features/hotbar/storage/common/active.injectable"; interface Dependencies { catalogPreviousActiveTabStorage: StorageLayer; @@ -65,13 +65,13 @@ interface Dependencies { kind: IComputedValue; }; navigateToCatalog: NavigateToCatalog; - hotbarStore: HotbarStore; catalogCategoryRegistry: CatalogCategoryRegistry; visitEntityContextMenu: VisitEntityContextMenu; navigate: Navigate; normalizeMenuItem: NormalizeCatalogEntityContextMenu; showErrorNotification: ShowNotification; logger: Logger; + activeHotbar: IComputedValue; } @observer @@ -156,11 +156,11 @@ class NonInjectedCatalog extends React.Component { } addToHotbar(entity: CatalogEntity): void { - this.props.hotbarStore.addToHotbar(entity); + this.props.activeHotbar.get()?.addEntity(entity); } removeFromHotbar(entity: CatalogEntity): void { - this.props.hotbarStore.removeFromHotbar(entity.getId()); + this.props.activeHotbar.get()?.removeEntity(entity.getId()); } onTabChange = action((tabId: string | null) => { @@ -323,7 +323,7 @@ export const Catalog = withInjectables(NonInjectedCatalog, { routeParameters: di.inject(catalogRouteParametersInjectable), navigateToCatalog: di.inject(navigateToCatalogInjectable), emitEvent: di.inject(emitAppEventInjectable), - hotbarStore: di.inject(hotbarStoreInjectable), + activeHotbar: di.inject(activeHotbarInjectable), catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable), visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable), navigate: di.inject(navigateInjectable), diff --git a/packages/core/src/renderer/components/+catalog/columns/named-category.injectable.ts b/packages/core/src/renderer/components/+catalog/columns/named-category.injectable.ts new file mode 100644 index 0000000000..433d263b79 --- /dev/null +++ b/packages/core/src/renderer/components/+catalog/columns/named-category.injectable.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import styles from "../catalog.module.scss"; +import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns"; +import renderNamedCategoryColumnCellInjectable from "./render-named-category-column-cell.injectable"; + +const namedCategoryColumnInjectable = getInjectable({ + id: "name-category-column", + instantiate: (di): RegisteredAdditionalCategoryColumn => ({ + id: "name", + priority: 0, + renderCell: di.inject(renderNamedCategoryColumnCellInjectable), + titleProps: { + title: "Name", + className: styles.entityName, + id: "name", + sortBy: "name", + }, + searchFilter: (entity) => entity.getName(), + sortCallback: (entity) => `name=${entity.getName()}`, + }), +}); + +export default namedCategoryColumnInjectable; diff --git a/packages/core/src/renderer/components/+catalog/columns/named-category.injectable.tsx b/packages/core/src/renderer/components/+catalog/columns/named-category.injectable.tsx deleted file mode 100644 index 0f7544ffc1..0000000000 --- a/packages/core/src/renderer/components/+catalog/columns/named-category.injectable.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import styles from "../catalog.module.scss"; -import type { CatalogEntity } from "../../../../common/catalog"; -import { prevDefault } from "@k8slens/utilities"; -import { Avatar } from "../../avatar"; -import { Icon } from "../../icon"; -import React from "react"; -import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns"; -import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; -import type { HotbarStore } from "../../../../common/hotbars/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 namedCategoryColumnInjectable = 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 namedCategoryColumnInjectable; diff --git a/packages/core/src/renderer/components/+catalog/columns/render-named-category-column-cell.injectable.tsx b/packages/core/src/renderer/components/+catalog/columns/render-named-category-column-cell.injectable.tsx new file mode 100644 index 0000000000..21fd683ffb --- /dev/null +++ b/packages/core/src/renderer/components/+catalog/columns/render-named-category-column-cell.injectable.tsx @@ -0,0 +1,59 @@ +/** + * 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 React from "react"; +import activeHotbarInjectable from "../../../../features/hotbar/storage/common/active.injectable"; +import { Avatar } from "../../avatar"; +import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns"; +import { Icon } from "../../icon"; +import { prevDefault } from "@k8slens/utilities"; + +const renderNamedCategoryColumnCellInjectable = getInjectable({ + id: "render-named-category-column-cell", + instantiate: (di): RegisteredAdditionalCategoryColumn["renderCell"] => { + const activeHotbar = di.inject(activeHotbarInjectable); + + return (entity) => { + const hotbar = activeHotbar.get(); + + if (!hotbar) { + return null; + } + + const isItemInHotbar = hotbar.hasEntity(entity.getId()); + const onClick = prevDefault(( + isItemInHotbar + ? () => hotbar.removeEntity(entity.getId()) + : () => hotbar.addEntity(entity) + )); + + return ( + <> + + {entity.spec.icon?.material && } + + {entity.getName()} + + + ); + }; + }, +}); + +export default renderNamedCategoryColumnCellInjectable; diff --git a/packages/core/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx b/packages/core/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx index bae8895ff7..bab9b79eb5 100644 --- a/packages/core/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx +++ b/packages/core/src/renderer/components/+catalog/hotbar-toggle-menu-item.tsx @@ -9,11 +9,12 @@ import { MenuItem } from "../menu"; import type { CatalogEntity } from "../../api/catalog-entity"; import { withInjectables } from "@ogre-tools/injectable-react"; -import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; -import type { HotbarStore } from "../../../common/hotbars/store"; +import type { IComputedValue } from "mobx"; +import type { Hotbar } from "../../../features/hotbar/storage/common/hotbar"; +import activeHotbarInjectable from "../../../features/hotbar/storage/common/active.injectable"; interface Dependencies { - hotbarStore: HotbarStore; + activeHotbar: IComputedValue; } interface HotbarToggleMenuItemProps { @@ -25,19 +26,19 @@ interface HotbarToggleMenuItemProps { function NonInjectedHotbarToggleMenuItem({ addContent, entity, - hotbarStore, + activeHotbar, removeContent, }: Dependencies & HotbarToggleMenuItemProps) { - const [itemInHotbar, setItemInHotbar] = useState(hotbarStore.isAddedToActive(entity)); + const [itemInHotbar, setItemInHotbar] = useState(activeHotbar.get()?.hasEntity(entity.getId()) ?? false); return ( { if (itemInHotbar) { - hotbarStore.removeFromHotbar(entity.getId()); + activeHotbar.get()?.removeEntity(entity.getId()); setItemInHotbar(false); } else { - hotbarStore.addToHotbar(entity); + activeHotbar.get()?.addEntity(entity); setItemInHotbar(true); } }} @@ -47,14 +48,10 @@ function NonInjectedHotbarToggleMenuItem({ ); } -export const HotbarToggleMenuItem = withInjectables( - NonInjectedHotbarToggleMenuItem, - - { - getProps: (di, props) => ({ - hotbarStore: di.inject(hotbarStoreInjectable), - ...props, - }), - }, -); +export const HotbarToggleMenuItem = withInjectables(NonInjectedHotbarToggleMenuItem, { + getProps: (di, props) => ({ + ...props, + activeHotbar: di.inject(activeHotbarInjectable), + }), +}); diff --git a/packages/core/src/renderer/components/delete-cluster-dialog/view.tsx b/packages/core/src/renderer/components/delete-cluster-dialog/view.tsx index b1548354fd..69666efede 100644 --- a/packages/core/src/renderer/components/delete-cluster-dialog/view.tsx +++ b/packages/core/src/renderer/components/delete-cluster-dialog/view.tsx @@ -15,9 +15,7 @@ import { Dialog } from "../dialog"; import { Icon } from "../icon"; import { Select } from "../select"; import { Checkbox } from "../checkbox"; -import type { HotbarStore } from "../../../common/hotbars/store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; import type { DeleteClusterDialogState } from "./state.injectable"; import deleteClusterDialogStateInjectable from "./state.injectable"; import type { RequestSetClusterAsDeleting } from "../../../features/cluster/delete-dialog/renderer/request-set-as-deleting.injectable"; @@ -32,16 +30,18 @@ import showErrorNotificationInjectable from "../notifications/show-error-notific import { isCurrentContext } from "./is-current-context"; import type { IsInLocalKubeconfig } from "./is-in-local-kubeconfig.injectable"; import isInLocalKubeconfigInjectable from "./is-in-local-kubeconfig.injectable"; +import type { RemoveEntityFromAllHotbars } from "../../../features/hotbar/storage/common/remove-entity-from-all.injectable"; +import removeEntityFromAllHotbarsInjectable from "../../../features/hotbar/storage/common/remove-entity-from-all.injectable"; interface Dependencies { state: IObservableValue; - hotbarStore: HotbarStore; requestSetClusterAsDeleting: RequestSetClusterAsDeleting; requestDeleteCluster: RequestDeleteCluster; requestClearClusterAsDeleting: RequestClearClusterAsDeleting; showErrorNotification: ShowNotification; saveKubeconfig: SaveKubeconfig; isInLocalKubeconfig: IsInLocalKubeconfig; + removeEntityFromAllHotbars: RemoveEntityFromAllHotbars; } @observer @@ -65,7 +65,7 @@ class NonInjectedDeleteClusterDialog extends React.Component { try { await this.props.saveKubeconfig(config, cluster.kubeConfigPath.get()); - this.props.hotbarStore.removeAllHotbarItems(cluster.id); + this.props.removeEntityFromAllHotbars(cluster.id); await this.props.requestDeleteCluster(cluster.id); } catch(error) { this.props.showErrorNotification(`Cannot remove cluster, failed to process config file. ${error}`); @@ -267,7 +267,6 @@ class NonInjectedDeleteClusterDialog extends React.Component { export const DeleteClusterDialog = withInjectables(NonInjectedDeleteClusterDialog, { getProps: (di) => ({ - hotbarStore: di.inject(hotbarStoreInjectable), state: di.inject(deleteClusterDialogStateInjectable), requestSetClusterAsDeleting: di.inject(requestSetClusterAsDeletingInjectable), requestClearClusterAsDeleting: di.inject(requestClearClusterAsDeletingInjectable), @@ -275,5 +274,6 @@ export const DeleteClusterDialog = withInjectables(NonInjectedDele saveKubeconfig: di.inject(saveKubeconfigInjectable), showErrorNotification: di.inject(showErrorNotificationInjectable), isInLocalKubeconfig: di.inject(isInLocalKubeconfigInjectable), + removeEntityFromAllHotbars: di.inject(removeEntityFromAllHotbarsInjectable), }), }); diff --git a/packages/core/src/renderer/components/hotbar/__tests__/__snapshots__/hotbar-remove-command.test.tsx.snap b/packages/core/src/renderer/components/hotbar/__tests__/__snapshots__/hotbar-remove-command.test.tsx.snap new file mode 100644 index 0000000000..89eacf6e48 --- /dev/null +++ b/packages/core/src/renderer/components/hotbar/__tests__/__snapshots__/hotbar-remove-command.test.tsx.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders w/o errors 1`] = ` +
+
+ + + option , selected. + + + 2 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu. + + + +
+
+
+ Remove hotbar +
+
+ +
+
+
+
+
+
+
+ 1: default +
+
+ 2: non-default +
+
+
+
+
+`; diff --git a/packages/core/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx b/packages/core/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx index bb370562f3..98b007206c 100644 --- a/packages/core/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx +++ b/packages/core/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -5,75 +5,56 @@ import "@testing-library/jest-dom/extend-expect"; import { HotbarRemoveCommand } from "../hotbar-remove-command"; +import type { RenderResult } from "@testing-library/react"; import { fireEvent } from "@testing-library/react"; import React from "react"; -import type { DiContainer } from "@ogre-tools/injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; -import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; import { ConfirmDialog } from "../../confirm-dialog"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; -import type { HotbarStore } from "../../../../common/hotbars/store"; -import storesAndApisCanBeCreatedInjectable from "../../../stores-apis-can-be-created.injectable"; - -const mockHotbars: Partial> = { - "1": { - id: "1", - name: "Default", - items: [], - }, -}; +import hotbarsStateInjectable from "../../../../features/hotbar/storage/common/state.injectable"; +import type { CreateHotbar } from "../../../../features/hotbar/storage/common/create-hotbar.injectable"; +import createHotbarInjectable from "../../../../features/hotbar/storage/common/create-hotbar.injectable"; +import type { IComputedValue } from "mobx"; +import type { Hotbar } from "../../../../features/hotbar/storage/common/hotbar"; +import hotbarsInjectable from "../../../../features/hotbar/storage/common/hotbars.injectable"; describe("", () => { - let di: DiContainer; - let render: DiRender; + let result: RenderResult; + let createHotbar: CreateHotbar; + let hotbars: IComputedValue; beforeEach(() => { - di = getDiForUnitTesting(); + const di = getDiForUnitTesting(); + const render = renderFor(di); - di.override(storesAndApisCanBeCreatedInjectable, () => true); di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data"); - render = renderFor(di); - }); + createHotbar = di.inject(createHotbarInjectable); + hotbars = di.inject(hotbarsInjectable); - it("renders w/o errors", () => { - di.override(hotbarStoreInjectable, () => ({ - hotbars: [mockHotbars["1"]], - getById: (id: string) => mockHotbars[id], - remove: () => { - }, - hotbarIndex: () => 0, - getDisplayLabel: () => "1: Default", - }) as unknown as HotbarStore); + const hotbarsState = di.inject(hotbarsStateInjectable); + const defaultHotbar = createHotbar({ name: "default" }); + const nonDefaultHotbar = createHotbar({ name: "non-default" }); - const { container } = render(); + hotbarsState.set(defaultHotbar.id, defaultHotbar); + hotbarsState.set(nonDefaultHotbar.id, nonDefaultHotbar); - expect(container).toBeInstanceOf(HTMLElement); - }); - - it("calls remove if you click on the entry", () => { - const removeMock = jest.fn(); - - di.override(hotbarStoreInjectable, () => ({ - hotbars: [mockHotbars["1"]], - getById: (id: string) => mockHotbars[id], - remove: removeMock, - hotbarIndex: () => 0, - getDisplayLabel: () => "1: Default", - }) as unknown as HotbarStore); - - const { getByText } = render( + result = render(( <> - , - ); + + )); + }); - fireEvent.click(getByText("1: Default")); - fireEvent.click(getByText("Remove Hotbar")); + it("renders w/o errors", () => { + expect(result.container).toMatchSnapshot(); + }); - expect(removeMock).toHaveBeenCalled(); + it("calls remove if you click on the entry", () => { + fireEvent.click(result.getByText("1: default")); + fireEvent.click(result.getByText("Remove Hotbar")); + expect(hotbars.get().length).toBe(1); }); }); diff --git a/packages/core/src/renderer/components/hotbar/hotbar-add-command.tsx b/packages/core/src/renderer/components/hotbar/hotbar-add-command.tsx index 3fd261b213..cb97f638e9 100644 --- a/packages/core/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/packages/core/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -7,15 +7,15 @@ import React from "react"; import { observer } from "mobx-react"; import type { InputValidator } from "../input"; import { Input } from "../input"; -import type { CreateHotbarData, CreateHotbarOptions } from "../../../common/hotbars/types"; import { withInjectables } from "@ogre-tools/injectable-react"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable"; -import addHotbarInjectable from "../../../common/hotbars/add-hotbar.injectable"; +import type { AddHotbar } from "../../../features/hotbar/storage/common/add.injectable"; +import addHotbarInjectable from "../../../features/hotbar/storage/common/add.injectable"; interface Dependencies { closeCommandOverlay: () => void; - addHotbar: (data: CreateHotbarData, opts: CreateHotbarOptions) => void; + addHotbar: AddHotbar; uniqueHotbarName: InputValidator; } diff --git a/packages/core/src/renderer/components/hotbar/hotbar-menu.tsx b/packages/core/src/renderer/components/hotbar/hotbar-menu.tsx index e83848cdfd..b9ba43b711 100644 --- a/packages/core/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/packages/core/src/renderer/components/hotbar/hotbar-menu.tsx @@ -5,7 +5,7 @@ import "./hotbar-menu.scss"; -import React from "react"; +import React, { useState } from "react"; import { observer } from "mobx-react"; import { HotbarEntityIcon } from "./hotbar-entity-icon"; import type { IClassName } from "@k8slens/utilities"; @@ -16,57 +16,46 @@ import { DragDropContext, Draggable, Droppable, type DropResult } from "react-be import { HotbarSelector } from "./hotbar-selector"; import { HotbarCell } from "./hotbar-cell"; import { HotbarIcon } from "./hotbar-icon"; -import type { HotbarItem } from "../../../common/hotbars/types"; -import { defaultHotbarCells } from "../../../common/hotbars/types"; -import { action, makeObservable, observable } from "mobx"; -import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; +import type { HotbarItem } from "../../../features/hotbar/storage/common/types"; +import { defaultHotbarCells } from "../../../features/hotbar/storage/common/types"; +import type { IComputedValue } from "mobx"; import { withInjectables } from "@ogre-tools/injectable-react"; -import type { HotbarStore } from "../../../common/hotbars/store"; import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable"; +import type { Hotbar } from "../../../features/hotbar/storage/common/hotbar"; +import activeHotbarInjectable from "../../../features/hotbar/storage/common/active.injectable"; export interface HotbarMenuProps { className?: IClassName; } interface Dependencies { - hotbarStore: HotbarStore; + activeHotbar: IComputedValue; entityRegistry: CatalogEntityRegistry; } -@observer -class NonInjectedHotbarMenu extends React.Component { - @observable draggingOver = false; +const NonInjectedHotbarMenu = observer((props: Dependencies & HotbarMenuProps) => { + const { + activeHotbar, + entityRegistry, + className, + } = props; - constructor(props: Dependencies & HotbarMenuProps) { - super(props); - makeObservable(this); - } + const [draggingOver, setDraggingOver] = useState(false); + const hotbar = activeHotbar.get(); - get hotbar() { - return this.props.hotbarStore.getActive(); - } - - getEntity(item: HotbarItem | null) { - const hotbar = this.props.hotbarStore.getActive(); - - if (!hotbar || !item) { - return null; + const getEntity = (item: HotbarItem | null) => { + if (!item) { + return undefined; } - return this.props.entityRegistry.getById(item.entity.uid) ?? null; - } + return entityRegistry.getById(item.entity.uid); + }; + const onDragStart = () => setDraggingOver(true); + const onDragEnd = (result: DropResult) => { + setDraggingOver(false); - @action - onDragStart() { - this.draggingOver = true; - } - - @action - onDragEnd(result: DropResult) { const { source, destination } = result; - this.draggingOver = false; - if (!destination) { // Dropped outside of the list return; } @@ -74,135 +63,115 @@ class NonInjectedHotbarMenu extends React.Component { - const hotbar = this.props.hotbarStore; - - hotbar.removeFromHotbar(uid); + hotbar?.restack(from, to); }; - - addItem = (entity: CatalogEntity, index = -1) => { - const hotbar = this.props.hotbarStore; - - hotbar.addToHotbar(entity, index); + const removeItem = (entityId: string) => { + hotbar?.removeEntity(entityId); }; - - getMoveAwayDirection(entityId: string | undefined | null, cellIndex: number) { - if (!entityId) { + const addItem = (entity: CatalogEntity) => { + hotbar?.addEntity(entity); + }; + const getMoveAwayDirection = (entityId: string | undefined | null, cellIndex: number) => { + if (!entityId || !hotbar) { return "animateDown"; } - const draggableItemIndex = this.hotbar.items.findIndex(item => item?.entity.uid == entityId); + const draggableItemIndex = hotbar.items.findIndex(item => item?.entity.uid == entityId); return draggableItemIndex > cellIndex ? "animateDown" : "animateUp"; - } + }; - renderGrid() { - return this.hotbar.items.map((item, index) => { - const entity = this.getEntity(item); - - return ( - - {(provided, snapshot) => ( - - {item && ( - - {(provided, snapshot) => { - const style = { - zIndex: defaultHotbarCells - index, - position: "absolute", - ...provided.draggableProps.style, - } as React.CSSProperties; - - return ( -
- {entity ? ( - this.props.entityRegistry.onRun(entity)} - className={cssNames({ isDragging: snapshot.isDragging })} - remove={this.removeItem} - add={this.addItem} - size={40} - /> - ) : ( - this.removeItem(item.entity.uid), - }, - ]} - disabled - size={40} - /> - )} -
- ); - }} -
- )} - {provided.placeholder} -
- )} -
- ); - }); - } - - render() { - const { className, hotbarStore } = this.props; - const hotbar = hotbarStore.getActive(); + const renderGrid = () => hotbar?.items.map((item, index) => { + const entity = getEntity(item); return ( -
-
- this.onDragStart()} - onDragEnd={(result) => this.onDragEnd(result)}> - {this.renderGrid()} - -
- -
+ + {(provided, snapshot) => ( + + {item && ( + + {(provided, snapshot) => ( +
+ {entity ? ( + entityRegistry.onRun(entity)} + className={cssNames({ isDragging: snapshot.isDragging })} + remove={removeItem} + add={addItem} + size={40} /> + ) : ( + removeItem(item.entity.uid), + }, + ]} + disabled + size={40} /> + )} +
+ )} +
+ )} + {provided.placeholder} +
+ )} +
); - } -} + }); + + return ( +
+
+ onDragStart()} + onDragEnd={(result) => onDragEnd(result)}> + {renderGrid()} + +
+ +
+ ); +}); export const HotbarMenu = withInjectables(NonInjectedHotbarMenu, { getProps: (di, props) => ({ ...props, - hotbarStore: di.inject(hotbarStoreInjectable), entityRegistry: di.inject(catalogEntityRegistryInjectable), + activeHotbar: di.inject(activeHotbarInjectable), }), }); diff --git a/packages/core/src/renderer/components/hotbar/hotbar-remove-command.tsx b/packages/core/src/renderer/components/hotbar/hotbar-remove-command.tsx index 0199b7f739..4f2b1a4e17 100644 --- a/packages/core/src/renderer/components/hotbar/hotbar-remove-command.tsx +++ b/packages/core/src/renderer/components/hotbar/hotbar-remove-command.tsx @@ -6,23 +6,32 @@ import React from "react"; import { observer } from "mobx-react"; import { Select } from "../select"; -import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; -import type { HotbarStore } from "../../../common/hotbars/store"; import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable"; import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; +import type { IComputedValue } from "mobx"; +import type { Hotbar } from "../../../features/hotbar/storage/common/hotbar"; +import type { ComputeHotbarDisplayLabel } from "../../../features/hotbar/storage/common/compute-display-label.injectable"; +import computeHotbarDisplayLabelInjectable from "../../../features/hotbar/storage/common/compute-display-label.injectable"; +import hotbarsInjectable from "../../../features/hotbar/storage/common/hotbars.injectable"; +import type { RemoveHotbar } from "../../../features/hotbar/storage/common/remove.injectable"; +import removeHotbarInjectable from "../../../features/hotbar/storage/common/remove.injectable"; interface Dependencies { closeCommandOverlay: () => void; openConfirmDialog: OpenConfirmDialog; - hotbarStore: HotbarStore; + hotbars: IComputedValue; + computeHotbarDisplayLabel: ComputeHotbarDisplayLabel; + removeHotbar: RemoveHotbar; } const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, - hotbarStore, openConfirmDialog, + hotbars, + computeHotbarDisplayLabel, + removeHotbar, }: Dependencies) => ( ); } } else { - hotbarStore.setActiveHotbar(option.value); + setAsActiveHotbar(option.value); commandOverlay.close(); } }} components={{ DropdownIndicator: null, IndicatorSeparator: null }} menuIsOpen={true} options={[ - ...hotbarStore.hotbars.map(hotbar => ({ + ...hotbars.get().map(hotbar => ({ value: hotbar, - label: hotbarStore.getDisplayLabel(hotbar), + label: computeHotbarDisplayLabel(hotbar), })), { value: hotbarAddAction, label: "Add hotbar ...", }, - ...ignoreIf(hotbarStore.hotbars.length > 1, [ + ...ignoreIf(hotbars.get().length > 1, [ { value: hotbarRemoveAction, label: "Remove hotbar ...", @@ -85,8 +94,10 @@ const NonInjectedHotbarSwitchCommand = observer(({ export const HotbarSwitchCommand = withInjectables(NonInjectedHotbarSwitchCommand, { getProps: (di, props) => ({ - hotbarStore: di.inject(hotbarStoreInjectable), - commandOverlay: di.inject(commandOverlayInjectable), ...props, + commandOverlay: di.inject(commandOverlayInjectable), + computeHotbarDisplayLabel: di.inject(computeHotbarDisplayLabelInjectable), + hotbars: di.inject(hotbarsInjectable), + setAsActiveHotbar: di.inject(setAsActiveHotbarInjectable), }), }); diff --git a/packages/core/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts b/packages/core/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts index e4d68e9f29..48c1fb6fc1 100644 --- a/packages/core/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts +++ b/packages/core/src/renderer/components/input/validators/unique-hotbar-name.injectable.ts @@ -4,19 +4,19 @@ */ import { getInjectable } from "@ogre-tools/injectable"; -import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; +import findHotbarByNameInjectable from "../../../../features/hotbar/storage/common/find-by-name.injectable"; import { inputValidator } from "../input_validators"; const uniqueHotbarNameInjectable = getInjectable({ id: "unique-hotbar-name", instantiate: di => { - const store = di.inject(hotbarStoreInjectable); + const findHotbarByName = di.inject(findHotbarByNameInjectable); return inputValidator({ condition: ({ required }) => required, message: () => "Hotbar with this name already exists", - validate: value => !store.findByName(value), + validate: value => !findHotbarByName(value), }); }, }); diff --git a/packages/core/src/renderer/components/layout/__tests__/__snapshots__/sidebar-cluster.test.tsx.snap b/packages/core/src/renderer/components/layout/__tests__/__snapshots__/sidebar-cluster.test.tsx.snap new file mode 100644 index 0000000000..26aab5675c --- /dev/null +++ b/packages/core/src/renderer/components/layout/__tests__/__snapshots__/sidebar-cluster.test.tsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders w/o errors 1`] = ` +
+ +
+`; diff --git a/packages/core/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx b/packages/core/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx index f9d447dd5e..69bfe5458c 100644 --- a/packages/core/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx +++ b/packages/core/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx @@ -5,56 +5,47 @@ import React from "react"; import "@testing-library/jest-dom/extend-expect"; +import type { RenderResult } from "@testing-library/react"; import { fireEvent } from "@testing-library/react"; import { SidebarCluster } from "../sidebar-cluster"; import { KubernetesCluster } from "../../../../common/catalog-entities"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; -import type { DiRender } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor"; -import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; -import type { HotbarStore } from "../../../../common/hotbars/store"; - -const clusterEntity = new KubernetesCluster({ - metadata: { - uid: "test-uid", - name: "test-cluster", - source: "local", - labels: {}, - }, - spec: { - kubeconfigPath: "", - kubeconfigContext: "", - }, - status: { - phase: "connected", - }, -}); describe("", () => { - let render: DiRender; + let result: RenderResult; beforeEach(() => { const di = getDiForUnitTesting(); + const render = renderFor(di); - di.override(hotbarStoreInjectable, () => ({ - isAddedToActive: () => {}, - }) as unknown as HotbarStore); + const clusterEntity = new KubernetesCluster({ + metadata: { + uid: "test-uid", + name: "test-cluster", + source: "local", + labels: {}, + }, + spec: { + kubeconfigPath: "", + kubeconfigContext: "", + }, + status: { + phase: "connected", + }, + }); - render = renderFor(di); + result = render(); }); it("renders w/o errors", () => { - const { container } = render(); - - expect(container).toBeInstanceOf(HTMLElement); + expect(result.container).toMatchSnapshot(); }); it("renders cluster avatar and name", () => { - const { getByText, getAllByText } = render(); + expect(result.getByText("tc")).toBeInTheDocument(); - expect(getByText("tc")).toBeInTheDocument(); - - const v = getAllByText("test-cluster"); + const v = result.getAllByText("test-cluster"); expect(v.length).toBeGreaterThan(0); @@ -64,11 +55,8 @@ describe("", () => { }); it("renders cluster menu", () => { - const { getByTestId, getByText } = render(); - const link = getByTestId("sidebar-cluster-dropdown"); - - fireEvent.click(link); - expect(getByText("Add to Hotbar")).toBeInTheDocument(); + fireEvent.click(result.getByTestId("sidebar-cluster-dropdown")); + expect(result.getByText("Add to Hotbar")).toBeInTheDocument(); }); }); diff --git a/packages/core/src/renderer/components/layout/sidebar-cluster.tsx b/packages/core/src/renderer/components/layout/sidebar-cluster.tsx index ba5ae7e387..7ae6ee9405 100644 --- a/packages/core/src/renderer/components/layout/sidebar-cluster.tsx +++ b/packages/core/src/renderer/components/layout/sidebar-cluster.tsx @@ -14,8 +14,6 @@ import { Icon } from "../icon"; import { Menu, MenuItem } from "../menu"; import { Tooltip } from "../tooltip"; import { withInjectables } from "@ogre-tools/injectable-react"; -import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; -import type { HotbarStore } from "../../../common/hotbars/store"; import { observer } from "mobx-react"; import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable"; import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable"; @@ -23,6 +21,8 @@ import type { Navigate } from "../../navigation/navigate.injectable"; import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable"; import navigateInjectable from "../../navigation/navigate.injectable"; import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable"; +import type { ActiveHotbarModel } from "../../../features/hotbar/storage/common/toggling.injectable"; +import activeHotbarInjectable from "../../../features/hotbar/storage/common/toggling.injectable"; export interface SidebarClusterProps { clusterEntity: CatalogEntity | null | undefined; @@ -31,16 +31,16 @@ export interface SidebarClusterProps { interface Dependencies { navigate: Navigate; normalizeMenuItem: NormalizeCatalogEntityContextMenu; - hotbarStore: HotbarStore; visitEntityContextMenu: VisitEntityContextMenu; + entityInActiveHotbar: ActiveHotbarModel; } const NonInjectedSidebarCluster = observer(({ clusterEntity, - hotbarStore, visitEntityContextMenu: onContextMenuOpen, navigate, normalizeMenuItem, + entityInActiveHotbar, }: Dependencies & SidebarClusterProps) => { const [menuItems] = useState(observable.array()); const [opened, setOpened] = useState(false ); @@ -61,13 +61,10 @@ const NonInjectedSidebarCluster = observer(({ } const onMenuOpen = () => { - const isAddedToActive = hotbarStore.isAddedToActive(clusterEntity); - const title = isAddedToActive + const title = entityInActiveHotbar.hasEntity(clusterEntity.getId()) ? "Remove from Hotbar" : "Add to Hotbar"; - const onClick = isAddedToActive - ? () => hotbarStore.removeFromHotbar(clusterEntity.getId()) - : () => hotbarStore.addToHotbar(clusterEntity); + const onClick = () => entityInActiveHotbar.toggleEntity(clusterEntity); menuItems.replace([{ title, onClick }]); onContextMenuOpen(clusterEntity, { @@ -148,9 +145,9 @@ const NonInjectedSidebarCluster = observer(({ export const SidebarCluster = withInjectables(NonInjectedSidebarCluster, { getProps: (di, props) => ({ ...props, - hotbarStore: di.inject(hotbarStoreInjectable), visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable), navigate: di.inject(navigateInjectable), normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable), + entityInActiveHotbar: di.inject(activeHotbarInjectable), }), }); diff --git a/packages/core/src/renderer/ipc/register-ipc-listeners.injectable.ts b/packages/core/src/renderer/ipc/register-ipc-listeners.injectable.ts index 0c2d407d59..0a80abb9e6 100644 --- a/packages/core/src/renderer/ipc/register-ipc-listeners.injectable.ts +++ b/packages/core/src/renderer/ipc/register-ipc-listeners.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { defaultHotbarCells } from "../../common/hotbars/types"; +import { defaultHotbarCells } from "../../features/hotbar/storage/common/types"; import { clusterListNamespaceForbiddenChannel } from "../../common/ipc/cluster"; import { hotbarTooManyItemsChannel } from "../../common/ipc/hotbar"; import showErrorNotificationInjectable from "../components/notifications/show-error-notification.injectable";