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

Fix install-helm-chart-from-previously-opened-tab tests

- Split out storage initialization to a runnable

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2023-01-05 10:21:42 -05:00
parent f52e79fdaa
commit 0a8df39824
14 changed files with 400 additions and 333 deletions

View File

@ -11,8 +11,6 @@ import asyncFn from "@async-fn/jest";
import type { CallForResource } from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable"; import type { CallForResource } from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable";
import callForResourceInjectable from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable"; import callForResourceInjectable from "../../../renderer/components/dock/edit-resource/edit-resource-model/call-for-resource/call-for-resource.injectable";
import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable";
import hostedClusterIdInjectable from "../../../renderer/cluster-frame-context/hosted-cluster-id.injectable";
import { controlWhenStoragesAreReady } from "../../../renderer/utils/create-storage/storages-are-ready";
import writeJsonFileInjectable from "../../../common/fs/write-json-file.injectable"; import writeJsonFileInjectable from "../../../common/fs/write-json-file.injectable";
import { TabKind } from "../../../renderer/components/dock/dock/store"; import { TabKind } from "../../../renderer/components/dock/dock/store";
import { Namespace } from "../../../common/k8s-api/endpoints"; import { Namespace } from "../../../common/k8s-api/endpoints";
@ -20,7 +18,6 @@ import { Namespace } from "../../../common/k8s-api/endpoints";
describe("cluster/namespaces - edit namespaces from previously opened tab", () => { describe("cluster/namespaces - edit namespaces from previously opened tab", () => {
let builder: ApplicationBuilder; let builder: ApplicationBuilder;
let callForNamespaceMock: AsyncFnMock<CallForResource>; let callForNamespaceMock: AsyncFnMock<CallForResource>;
let storagesAreReady: () => Promise<void>;
beforeEach(() => { beforeEach(() => {
builder = getApplicationBuilder(); builder = getApplicationBuilder();
@ -35,10 +32,6 @@ describe("cluster/namespaces - edit namespaces from previously opened tab", () =
() => "/some-directory-for-lens-local-storage", () => "/some-directory-for-lens-local-storage",
); );
windowDi.override(hostedClusterIdInjectable, () => "some-cluster-id");
storagesAreReady = controlWhenStoragesAreReady(windowDi);
windowDi.override(callForResourceInjectable, () => callForNamespaceMock); windowDi.override(callForResourceInjectable, () => callForNamespaceMock);
}); });
@ -83,8 +76,6 @@ describe("cluster/namespaces - edit namespaces from previously opened tab", () =
}); });
rendered = await builder.render(); rendered = await builder.render();
await storagesAreReady();
}); });
it("renders", () => { it("renders", () => {

View File

@ -4,7 +4,7 @@ exports[`installing helm chart from previously opened tab given tab for installi
<body> <body>
<div> <div>
<div <div
class="Animate slide-right Drawer KubeObjectDetails flex column right enter leave" class="Animate slide-right Drawer KubeObjectDetails flex column right enter"
style="--size: 725px; --enter-duration: 100ms; --leave-duration: 100ms;" style="--size: 725px; --enter-duration: 100ms; --leave-duration: 100ms;"
> >
<div <div
@ -1109,9 +1109,222 @@ exports[`installing helm chart from previously opened tab given tab for installi
style="flex-basis: 300px;" style="flex-basis: 300px;"
> >
<div <div
class="Spinner singleColor center" class="InstallChart flex column"
data-testid="install-chart-tab-spinner" >
/> <div
class="InfoPanel flex gaps align-center"
>
<div
class="controls"
>
<div
class="install-controls flex gaps align-center"
>
<span>
Chart
</span>
<div
class="badge"
title="Repo/Name"
>
some-repository/some-name
</div>
<span>
Version
</span>
<div
class="Select theme-outlined chart-version css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-install-chart-version-select-for-some-first-tab-id-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class="Select__control css-13cymwt-control"
>
<div
class="Select__value-container Select__value-container--has-value css-1fdsijx-ValueContainer"
>
<div
class="Select__single-value css-1dimb5e-singleValue"
>
some-other-version
</div>
<div
class="Select__input-container css-qbdosj-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="Select__input"
id="install-chart-version-select-for-some-first-tab-id"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class="Select__indicators css-1hb7zxy-IndicatorsContainer"
>
<span
class="Select__indicator-separator css-1u9des2-indicatorSeparator"
/>
<div
aria-hidden="true"
class="Select__indicator Select__dropdown-indicator css-1xc3v61-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
>
<path
d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
/>
</svg>
</div>
</div>
</div>
</div>
<span>
Namespace
</span>
<div
class="Select theme-outlined NamespaceSelect css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-install-chart-namespace-select-for-some-first-tab-id-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class="Select__control css-13cymwt-control"
>
<div
class="Select__value-container Select__value-container--has-value css-1fdsijx-ValueContainer"
>
<div
class="Select__single-value css-1dimb5e-singleValue"
>
some-other-namespace
</div>
<div
class="Select__input-container css-qbdosj-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="Select__input"
id="install-chart-namespace-select-for-some-first-tab-id"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class="Select__indicators css-1hb7zxy-IndicatorsContainer"
>
<span
class="Select__indicator-separator css-1u9des2-indicatorSeparator"
/>
<div
aria-hidden="true"
class="Select__indicator Select__dropdown-indicator css-1xc3v61-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
>
<path
d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
/>
</svg>
</div>
</div>
</div>
</div>
<div
class="Input"
>
<label
class="input-area flex gaps align-center"
id=""
>
<input
class="input box grow"
data-testid="install-chart-custom-name-input-for-some-first-tab-id"
placeholder="Name (optional)"
spellcheck="false"
title="Release name"
value="some-stored-custom-name"
/>
</label>
<div
class="input-info flex gaps"
/>
</div>
</div>
</div>
<div
class="flex gaps align-center"
/>
<button
class="Button plain"
data-testid="cancel-install-chart-from-tab-for-some-first-tab-id"
type="button"
>
Cancel
</button>
<button
class="Button primary active"
data-testid="install-chart-from-tab-for-some-first-tab-id"
type="button"
>
Install
</button>
</div>
<textarea
data-testid="monaco-editor-for-some-first-tab-id"
>
some-stored-configuration
</textarea>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,13 +9,9 @@ import type { ApplicationBuilder } from "../../../renderer/components/test-utils
import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder";
import { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api"; import { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api";
import getRandomInstallChartTabIdInjectable from "../../../renderer/components/dock/install-chart/get-random-install-chart-tab-id.injectable"; import getRandomInstallChartTabIdInjectable from "../../../renderer/components/dock/install-chart/get-random-install-chart-tab-id.injectable";
import namespaceStoreInjectable from "../../../renderer/components/+namespaces/store.injectable";
import type { NamespaceStore } from "../../../renderer/components/+namespaces/store";
import writeJsonFileInjectable from "../../../common/fs/write-json-file.injectable"; import writeJsonFileInjectable from "../../../common/fs/write-json-file.injectable";
import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable";
import hostedClusterIdInjectable from "../../../renderer/cluster-frame-context/hosted-cluster-id.injectable";
import { TabKind } from "../../../renderer/components/dock/dock/store"; import { TabKind } from "../../../renderer/components/dock/dock/store";
import { controlWhenStoragesAreReady } from "../../../renderer/utils/create-storage/storages-are-ready";
import requestCreateHelmReleaseInjectable from "../../../common/k8s-api/endpoints/helm-releases.api/request-create.injectable"; import requestCreateHelmReleaseInjectable from "../../../common/k8s-api/endpoints/helm-releases.api/request-create.injectable";
import type { RequestHelmChartVersions } from "../../../common/k8s-api/endpoints/helm-charts.api/request-versions.injectable"; import type { RequestHelmChartVersions } from "../../../common/k8s-api/endpoints/helm-charts.api/request-versions.injectable";
import requestHelmChartVersionsInjectable from "../../../common/k8s-api/endpoints/helm-charts.api/request-versions.injectable"; import requestHelmChartVersionsInjectable from "../../../common/k8s-api/endpoints/helm-charts.api/request-versions.injectable";
@ -26,7 +22,6 @@ describe("installing helm chart from previously opened tab", () => {
let builder: ApplicationBuilder; let builder: ApplicationBuilder;
let requestHelmChartVersionsMock: AsyncFnMock<RequestHelmChartVersions>; let requestHelmChartVersionsMock: AsyncFnMock<RequestHelmChartVersions>;
let requestHelmChartValuesMock: AsyncFnMock<RequestHelmChartValues>; let requestHelmChartValuesMock: AsyncFnMock<RequestHelmChartValues>;
let storagesAreReady: () => Promise<void>;
beforeEach(() => { beforeEach(() => {
builder = getApplicationBuilder(); builder = getApplicationBuilder();
@ -36,29 +31,15 @@ describe("installing helm chart from previously opened tab", () => {
requestHelmChartVersionsMock = asyncFn(); requestHelmChartVersionsMock = asyncFn();
requestHelmChartValuesMock = asyncFn(); requestHelmChartValuesMock = asyncFn();
builder.beforeWindowStart((windowDi) => { builder.namespaces.add("default");
storagesAreReady = controlWhenStoragesAreReady(windowDi); builder.namespaces.add("some-other-namespace");
builder.beforeWindowStart((windowDi) => {
windowDi.override(directoryForLensLocalStorageInjectable, () => "/some-directory-for-lens-local-storage"); windowDi.override(directoryForLensLocalStorageInjectable, () => "/some-directory-for-lens-local-storage");
windowDi.override(hostedClusterIdInjectable, () => "some-cluster-id");
windowDi.override(requestHelmChartVersionsInjectable, () => requestHelmChartVersionsMock); windowDi.override(requestHelmChartVersionsInjectable, () => requestHelmChartVersionsMock);
windowDi.override(requestHelmChartValuesInjectable, () => requestHelmChartValuesMock); windowDi.override(requestHelmChartValuesInjectable, () => requestHelmChartValuesMock);
windowDi.override(requestCreateHelmReleaseInjectable, () => jest.fn()); windowDi.override(requestCreateHelmReleaseInjectable, () => jest.fn());
// TODO: Replace store mocking with mock for the actual side-effect (where the namespaces are coming from)
windowDi.override(
namespaceStoreInjectable,
() =>
({
contextNamespaces: [],
items: [
{ getName: () => "default" },
{ getName: () => "some-other-namespace" },
],
selectNamespaces: () => {},
} as unknown as NamespaceStore),
);
windowDi.override(getRandomInstallChartTabIdInjectable, () => windowDi.override(getRandomInstallChartTabIdInjectable, () =>
jest jest
.fn(() => "some-irrelevant-tab-id") .fn(() => "some-irrelevant-tab-id")
@ -106,8 +87,6 @@ describe("installing helm chart from previously opened tab", () => {
}); });
rendered = await builder.render(); rendered = await builder.render();
await storagesAreReady();
}); });
it("renders", () => { it("renders", () => {

View File

@ -6,7 +6,7 @@
import { action, observable, reaction } from "mobx"; import { action, observable, reaction } from "mobx";
import type { StorageLayer } from "../../../utils"; import type { StorageLayer } from "../../../utils";
import { autoBind, toJS } from "../../../utils"; import { autoBind, toJS } from "../../../utils";
import type { CreateStorage } from "../../../utils/create-storage/create-storage"; import type { CreateStorage } from "../../../utils/create-storage/create-storage.injectable";
import type { TabId } from "../dock/store"; import type { TabId } from "../dock/store";
export interface DockTabStoreOptions { export interface DockTabStoreOptions {
@ -33,10 +33,8 @@ export class DockTabStore<T> {
if (autoInit && storageKey) { if (autoInit && storageKey) {
const storage = this.storage = this.dependencies.createStorage(storageKey, {}); const storage = this.storage = this.dependencies.createStorage(storageKey, {});
storage.whenReady.then(() => { this.data.replace(storage.get());
this.data.replace(storage.value); reaction(() => this.toJSON(), data => storage.set(data));
reaction(() => this.toJSON(), data => storage.set(data));
});
} }
} }

View File

@ -108,19 +108,25 @@ export class DockStore implements DockStorageState {
constructor(private readonly dependencies: Dependencies) { constructor(private readonly dependencies: Dependencies) {
makeObservable(this); makeObservable(this);
autoBind(this); autoBind(this);
this.init();
// adjust terminal height if window size changes
window.addEventListener("resize", throttle(this.adjustHeight, 250));
for (const tab of this.tabs) {
const tabDataIsValid = this.dependencies.tabDataValidator[tab.kind] ?? (() => true);
if (!tabDataIsValid(tab.id)) {
this.closeTab(tab.id);
}
}
} }
readonly minHeight = 100; readonly minHeight = 100;
@observable fullSize = false; @observable fullSize = false;
get whenReady() {
return this.dependencies.storage.whenReady;
}
@computed @computed
get isOpen(): boolean { get isOpen(): boolean {
return this.dependencies.storage.value.isOpen; return this.dependencies.storage.get().isOpen;
} }
set isOpen(isOpen: boolean) { set isOpen(isOpen: boolean) {
@ -129,7 +135,7 @@ export class DockStore implements DockStorageState {
@computed @computed
get height(): number { get height(): number {
return this.dependencies.storage.value.height; return this.dependencies.storage.get().height;
} }
set height(height: number) { set height(height: number) {
@ -140,7 +146,7 @@ export class DockStore implements DockStorageState {
@computed @computed
get tabs(): DockTab[] { get tabs(): DockTab[] {
return this.dependencies.storage.value.tabs; return this.dependencies.storage.get().tabs;
} }
set tabs(tabs: DockTab[]) { set tabs(tabs: DockTab[]) {
@ -149,7 +155,7 @@ export class DockStore implements DockStorageState {
@computed @computed
get selectedTabId(): TabId | undefined { get selectedTabId(): TabId | undefined {
const storageData = this.dependencies.storage.value; const storageData = this.dependencies.storage.get();
return ( return (
storageData.selectedTabId || storageData.selectedTabId ||
@ -171,21 +177,6 @@ export class DockStore implements DockStorageState {
return this.tabs.find(tab => tab.id === this.selectedTabId); return this.tabs.find(tab => tab.id === this.selectedTabId);
} }
private init() {
// adjust terminal height if window size changes
window.addEventListener("resize", throttle(this.adjustHeight, 250));
this.whenReady.then(action(() => {
for (const tab of this.tabs) {
const validator = this.dependencies.tabDataValidator[tab.kind];
if (validator && !validator(tab.id)) {
this.closeTab(tab.id);
}
}
}));
}
get maxHeight() { get maxHeight() {
const mainLayoutHeader = 40; const mainLayoutHeader = 40;
const mainLayoutTabs = 33; const mainLayoutTabs = 33;

View File

@ -4,9 +4,11 @@
*/ */
import { observable, reaction } from "mobx"; import { observable, reaction } from "mobx";
import { StorageHelper } from "../storageHelper"; import type { StorageHelper } from "../storage-helper";
import { delay } from "../../../common/utils/delay"; import { toJS } from "../../../common/utils";
import { noop, toJS } from "../../../common/utils"; import type { CreateStorageHelper } from "../create-storage-helper.injectable";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import createStorageHelperInjectable from "../create-storage-helper.injectable";
interface StorageModel { interface StorageModel {
[prop: string]: any /*json-serializable*/; [prop: string]: any /*json-serializable*/;
@ -15,27 +17,25 @@ interface StorageModel {
} }
describe("renderer/utils/StorageHelper", () => { describe("renderer/utils/StorageHelper", () => {
let createStorageHelper: CreateStorageHelper;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
createStorageHelper = di.inject(createStorageHelperInjectable);
});
describe("Using custom StorageAdapter", () => { describe("Using custom StorageAdapter", () => {
const storageKey = "ui-settings"; const storageKey = "ui-settings";
const remoteStorageMock = observable.map<string, StorageModel>(); const remoteStorageMock = observable.map<string, StorageModel>();
let storageHelper: StorageHelper<StorageModel>; let storageHelper: StorageHelper<StorageModel>;
let storageHelperAsync: StorageHelper<StorageModel>;
beforeEach(() => { beforeEach(() => {
remoteStorageMock.set(storageKey, { remoteStorageMock.set(storageKey, {
message: "saved-before", // pretending as previously saved data message: "saved-before", // pretending as previously saved data
}); });
storageHelper = new StorageHelper<StorageModel>({ storageHelper = createStorageHelper<StorageModel>(storageKey, {
logger: {
debug: noop,
error: noop,
info: noop,
silly: noop,
warn: noop,
},
}, storageKey, {
autoInit: false,
defaultValue: { defaultValue: {
message: "blabla", message: "blabla",
description: "default", description: "default",
@ -55,46 +55,14 @@ describe("renderer/utils/StorageHelper", () => {
}, },
}, },
}); });
storageHelperAsync = new StorageHelper({
logger: {
debug: noop,
error: noop,
info: noop,
silly: noop,
warn: noop,
},
}, storageKey, {
autoInit: false,
defaultValue: storageHelper.defaultValue,
storage: {
...storageHelper.storage,
async getItem(key: string): Promise<StorageModel> {
await delay(500); // fake loading timeout
return storageHelper.storage.getItem(key);
},
},
});
}); });
it("initialized with default value", async () => { it("initialized with default value", async () => {
storageHelper.init();
expect(storageHelper.key).toBe(storageKey); expect(storageHelper.key).toBe(storageKey);
expect(storageHelper.get()).toEqual(storageHelper.defaultValue); expect(storageHelper.get()).toEqual(storageHelper.defaultValue);
}); });
it("async loading from storage supported too", async () => {
expect(storageHelperAsync.initialized).toBeFalsy();
storageHelperAsync.init();
await delay(300);
expect(storageHelperAsync.get()).toEqual(storageHelper.defaultValue);
await delay(200);
expect(storageHelperAsync.get().message).toBe("saved-before");
});
it("set() fully replaces data in storage", () => { it("set() fully replaces data in storage", () => {
storageHelper.init();
storageHelper.set({ message: "msg" }); storageHelper.set({ message: "msg" });
storageHelper.get().description = "desc"; storageHelper.get().description = "desc";
expect(storageHelper.get().message).toBe("msg"); expect(storageHelper.get().message).toBe("msg");
@ -106,7 +74,6 @@ describe("renderer/utils/StorageHelper", () => {
}); });
it("merge() does partial data tree updates", () => { it("merge() does partial data tree updates", () => {
storageHelper.init();
storageHelper.merge({ message: "updated" }); storageHelper.merge({ message: "updated" });
expect(storageHelper.get()).toEqual({ ...storageHelper.defaultValue, message: "updated" }); expect(storageHelper.get()).toEqual({ ...storageHelper.defaultValue, message: "updated" });
@ -134,16 +101,7 @@ describe("renderer/utils/StorageHelper", () => {
beforeEach(() => { beforeEach(() => {
observedChanges.length = 0; observedChanges.length = 0;
storageHelper = new StorageHelper<typeof defaultValue>({ storageHelper = createStorageHelper<typeof defaultValue>("some-key", {
logger: {
debug: noop,
error: noop,
info: noop,
silly: noop,
warn: noop,
},
}, "some-key", {
autoInit: true,
defaultValue, defaultValue,
storage: { storage: {
getItem: jest.fn(), getItem: jest.fn(),

View File

@ -0,0 +1,23 @@
/**
* 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 loggerInjectable from "../../common/logger.injectable";
import type { StorageHelperDependencies, StorageHelperOptions } from "./storage-helper";
import { StorageHelper } from "./storage-helper";
export type CreateStorageHelper = <T>(key: string, options: StorageHelperOptions<T>) => StorageHelper<T>;
const createStorageHelperInjectable = getInjectable({
id: "create-storage-helper",
instantiate: (di): CreateStorageHelper => {
const deps: StorageHelperDependencies = {
logger: di.inject(loggerInjectable),
};
return (key, options) => new StorageHelper(deps, key, options);
},
});
export default createStorageHelperInjectable;

View File

@ -3,33 +3,29 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; import { action } from "mobx";
import { createStorage } from "./create-storage"; import lensLocalStorageStateInjectable from "./state.injectable";
import readJsonFileInjectable from "../../../common/fs/read-json-file.injectable"; import createStorageHelperInjectable from "../create-storage-helper.injectable";
import writeJsonFileInjectable from "../../../common/fs/write-json-file.injectable"; import type { StorageLayer } from "../storage-helper";
import { observable } from "mobx";
import loggerInjectable from "../../../common/logger.injectable"; export type CreateStorage = <T>(key: string, defaultValue: T) => StorageLayer<T>;
import hostedClusterIdInjectable from "../../cluster-frame-context/hosted-cluster-id.injectable";
import storageSaveDelayInjectable from "./storage-save-delay.injectable";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
const createStorageInjectable = getInjectable({ const createStorageInjectable = getInjectable({
id: "create-storage", id: "create-storage",
instantiate: (di) => createStorage({ instantiate: (di): CreateStorage => {
storage: observable({ const lensLocalStorageState = di.inject(lensLocalStorageStateInjectable);
initialized: false, const createStorageHelper = di.inject(createStorageHelperInjectable);
loaded: false,
data: {}, return <T>(key: string, defaultValue: T) => createStorageHelper<T>(key, {
}), defaultValue,
readJsonFile: di.inject(readJsonFileInjectable), storage: {
writeJsonFile: di.inject(writeJsonFileInjectable), getItem: (key) => lensLocalStorageState[key] as T,
logger: di.inject(loggerInjectable), setItem: action((key, value) => lensLocalStorageState[key] = value),
directoryForLensLocalStorage: di.inject(directoryForLensLocalStorageInjectable), removeItem: action((key) => delete lensLocalStorageState[key]),
joinPaths: di.inject(joinPathsInjectable), },
hostedClusterId: di.inject(hostedClusterIdInjectable), });
saveDelay: di.inject(storageSaveDelayInjectable), },
}),
}); });
export default createStorageInjectable; export default createStorageInjectable;

View File

@ -1,98 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// Keeps window.localStorage state in external JSON-files.
// Because app creates random port between restarts => storage session wiped out each time.
import { comparer, reaction, toJS, when } from "mobx";
import type { StorageLayer } from "../storageHelper";
import { storageHelperLogPrefix, StorageHelper } from "../storageHelper";
import type { JsonObject } from "type-fest";
import type { Logger } from "../../../common/logger";
import type { JoinPaths } from "../../../common/path/join-paths.injectable";
import type { WriteJson } from "../../../common/fs/write-json-file.injectable";
import type { ReadJson } from "../../../common/fs/read-json-file.injectable";
interface Dependencies {
storage: { initialized: boolean; loaded: boolean; data: Partial<Record<string, unknown>> };
logger: Logger;
directoryForLensLocalStorage: string;
readJsonFile: ReadJson;
writeJsonFile: WriteJson;
joinPaths: JoinPaths;
hostedClusterId: string | undefined;
saveDelay: number;
}
export type CreateStorage = <T>(key: string, defaultValue: T) => StorageLayer<T>;
/**
* Creates a helper for saving data under the "key" intended for window.localStorage
*/
export const createStorage = ({
storage,
joinPaths,
logger,
directoryForLensLocalStorage,
readJsonFile,
writeJsonFile,
hostedClusterId,
saveDelay,
}: Dependencies): CreateStorage => <T>(key: string, defaultValue: T) => {
if (!storage.initialized) {
storage.initialized = true;
(async () => {
const filePath = joinPaths(directoryForLensLocalStorage, `${hostedClusterId || "app"}.json`);
try {
storage.data = (await readJsonFile(filePath)) as JsonObject;
} catch {
// do nothing
} finally {
logger.info(`${storageHelperLogPrefix} loading finished for ${filePath}`);
storage.loaded = true;
}
// bind auto-saving data changes to %storage-file.json
reaction(() => toJS(storage.data), saveFile, {
delay: saveDelay, // lazy, avoid excessive writes to fs
equals: comparer.structural, // save only when something really changed
});
async function saveFile(state: Record<string, any> = {}) {
logger.info(`${storageHelperLogPrefix} saving ${filePath}`);
try {
await writeJsonFile(filePath, state);
} catch (error) {
logger.error(`${storageHelperLogPrefix} saving failed: ${error}`, {
json: state, jsonFilePath: filePath,
});
}
}
})()
.catch(error => logger.error(`${storageHelperLogPrefix} Failed to initialize storage: ${error}`));
}
return new StorageHelper({
logger,
}, key, {
autoInit: true,
defaultValue,
storage: {
async getItem(key: string) {
await when(() => storage.loaded);
return storage.data[key] as T;
},
setItem(key: string, value: T) {
storage.data[key] = value;
},
removeItem(key: string) {
delete storage.data[key];
},
},
});
};

View File

@ -0,0 +1,75 @@
/**
* 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 AwaitLock from "await-lock";
import { comparer, reaction, runInAction, toJS } from "mobx";
import directoryForLensLocalStorageInjectable from "../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable";
import readJsonFileInjectable from "../../../common/fs/read-json-file.injectable";
import writeJsonFileInjectable from "../../../common/fs/write-json-file.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
import setupAppPathsInjectable from "../../app-paths/setup-app-paths.injectable";
import { beforeFrameStartsFirstInjectionToken } from "../../before-frame-starts/tokens";
import hostedClusterIdInjectable from "../../cluster-frame-context/hosted-cluster-id.injectable";
import { storageHelperLogPrefix } from "../storage-helper";
import lensLocalStorageStateInjectable from "./state.injectable";
import storageSaveDelayInjectable from "./storage-save-delay.injectable";
const initializeStateInjectable = getInjectable({
id: "initialize-lens-local-storage-state",
instantiate: (di) => ({
id: "initialize-lens-local-storage-state",
run: async () => {
const joinPaths = di.inject(joinPathsInjectable);
const directoryForLensLocalStorage = di.inject(directoryForLensLocalStorageInjectable);
const hostedClusterId = di.inject(hostedClusterIdInjectable);
const lensLocalStorageState = di.inject(lensLocalStorageStateInjectable);
const readJsonFile = di.inject(readJsonFileInjectable);
const writeJsonFile = di.inject(writeJsonFileInjectable);
const logger = di.inject(loggerInjectable);
const storageSaveDelay = di.inject(storageSaveDelayInjectable);
const lock = new AwaitLock();
const filePath = joinPaths(directoryForLensLocalStorage, `${hostedClusterId || "app"}.json`);
try {
const localFile = await readJsonFile(filePath);
if (typeof localFile === "object") {
runInAction(() => {
Object.assign(lensLocalStorageState, localFile);
});
}
} catch {
// do nothing
} finally {
logger.info(`${storageHelperLogPrefix} loading finished for ${filePath}`);
}
reaction(() => toJS(lensLocalStorageState), saveFile, {
delay: storageSaveDelay, // lazy, avoid excessive writes to fs
equals: comparer.structural, // save only when something really changed
});
async function saveFile(state: Record<string, unknown>) {
try {
await lock.acquireAsync();
logger.info(`${storageHelperLogPrefix} saving ${filePath}`);
await writeJsonFile(filePath, state);
} catch (error) {
logger.error(`${storageHelperLogPrefix} saving failed: ${error}`, {
json: state, jsonFilePath: filePath,
});
} finally {
lock.release();
}
}
},
runAfter: di.inject(setupAppPathsInjectable),
}),
injectionToken: beforeFrameStartsFirstInjectionToken,
});
export default initializeStateInjectable;

View File

@ -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";
const lensLocalStorageStateInjectable = getInjectable({
id: "lens-local-storage-state",
instantiate: () => observable.object({} as Record<string, unknown>),
});
export default lensLocalStorageStateInjectable;

View File

@ -1,31 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { DiContainer } from "@ogre-tools/injectable";
import { runInAction } from "mobx";
import type { CreateStorage } from "./create-storage";
import createStorageInjectable from "./create-storage.injectable";
export const controlWhenStoragesAreReady = (di: DiContainer) => {
const storagesAreReady: Promise<void>[] = [];
const decorated =
(toBeDecorated: CreateStorage) =>
(key: string, defaultValue: any) => {
const storage = toBeDecorated(key, defaultValue);
storagesAreReady.push(storage.whenReady);
return storage;
};
runInAction(() => {
// TODO: Remove when typing is added to the library
(di as any).decorateFunction(createStorageInjectable, decorated);
});
return async () => {
await Promise.all(storagesAreReady);
};
};

View File

@ -17,4 +17,4 @@ export * from "./metricUnitsToNumber";
export * from "./name-parts"; export * from "./name-parts";
export * from "./prevDefault"; export * from "./prevDefault";
export * from "./saveFile"; export * from "./saveFile";
export * from "./storageHelper"; export * from "./storage-helper";

View File

@ -4,7 +4,7 @@
*/ */
// Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.) // Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.)
import { action, comparer, computed, makeObservable, observable, observe, toJS, when } from "mobx"; import { action, comparer, computed, makeObservable, observable, observe, toJS } from "mobx";
import type { Draft } from "immer"; import type { Draft } from "immer";
import { produce, isDraft } from "immer"; import { produce, isDraft } from "immer";
import { isEqual, isPlainObject } from "lodash"; import { isEqual, isPlainObject } from "lodash";
@ -19,14 +19,13 @@ export interface StorageChange<T> {
export interface StorageAdapter<T> { export interface StorageAdapter<T> {
[metadata: string]: unknown; [metadata: string]: unknown;
getItem(key: string): T | Promise<T>; getItem(key: string): T;
setItem(key: string, value: T): void; setItem(key: string, value: T): void;
removeItem(key: string): void; removeItem(key: string): void;
onChange?(change: StorageChange<T>): void; onChange?(change: StorageChange<T>): void;
} }
export interface StorageHelperOptions<T> { export interface StorageHelperOptions<T> {
readonly autoInit?: boolean; // start preloading data immediately, default: true
readonly storage: StorageAdapter<T>; readonly storage: StorageAdapter<T>;
readonly defaultValue: T; readonly defaultValue: T;
} }
@ -34,8 +33,6 @@ export interface StorageHelperOptions<T> {
export interface StorageLayer<T> { export interface StorageLayer<T> {
isDefaultValue(val: T): boolean; isDefaultValue(val: T): boolean;
get(): T; get(): T;
readonly value: T;
readonly whenReady: Promise<void>;
set(value: T): void; set(value: T): void;
reset(): void; reset(): void;
merge(value: Partial<T> | ((draft: Draft<T>) => Partial<T> | void)): void; merge(value: Partial<T> | ((draft: Draft<T>) => Partial<T> | void)): void;
@ -43,76 +40,44 @@ export interface StorageLayer<T> {
export const storageHelperLogPrefix = "[STORAGE-HELPER]:"; export const storageHelperLogPrefix = "[STORAGE-HELPER]:";
interface Dependencies { export interface StorageHelperDependencies {
readonly logger: Logger; readonly logger: Logger;
} }
export class StorageHelper<T> implements StorageLayer<T> { export class StorageHelper<T> implements StorageLayer<T> {
readonly storage: StorageAdapter<T>; readonly storage: StorageAdapter<T>;
private readonly data = observable.box<T | undefined>(undefined, { private readonly data = observable.box<T>(undefined, {
deep: true, deep: true,
equals: comparer.structural, equals: comparer.structural,
}); });
@observable initialized = false; private readonly value = computed(() => this.data.get() ?? this.defaultValue);
get whenReady() {
return when(() => this.initialized);
}
get defaultValue(): T { get defaultValue(): T {
// return as-is since options.defaultValue might be a getter too // return as-is since options.defaultValue might be a getter too
return this.options.defaultValue; return this.options.defaultValue;
} }
constructor(private readonly dependencies: Dependencies, readonly key: string, private readonly options: StorageHelperOptions<T>) { constructor(private readonly dependencies: StorageHelperDependencies, readonly key: string, private readonly options: StorageHelperOptions<T>) {
makeObservable(this); makeObservable(this);
const { storage, autoInit = true } = options; this.storage = this.options.storage;
this.storage = storage;
observe(this.data, (change) => { observe(this.data, (change) => {
this.onChange(change.newValue as T | undefined, change.oldValue as T | undefined); this.onChange(change.newValue as T | undefined, change.oldValue as T | undefined);
}); });
if (autoInit) {
this.init();
}
}
private onData = (data: T): void => {
const notEmpty = data != null;
const notDefault = !this.isDefaultValue(data);
if (notEmpty && notDefault) {
this.set(data);
}
this.initialized = true;
};
private onError = (error: any): void => {
this.dependencies.logger.error(`${storageHelperLogPrefix} loading error: ${error}`, this);
};
@action
init({ force = false } = {}) {
if (this.initialized && !force) {
return;
}
try { try {
const data = this.storage.getItem(this.key); const data = this.storage.getItem(this.key);
const notEmpty = data != null;
const notDefault = !this.isDefaultValue(data);
if (data instanceof Promise) { if (notEmpty && notDefault) {
data.then(this.onData, this.onError); this.set(data);
} else {
this.onData(data);
} }
} catch (error) { } catch (error) {
this.onError(error); this.dependencies.logger.error(`${storageHelperLogPrefix} loading error: ${error}`, this);
} }
} }
@ -120,9 +85,7 @@ export class StorageHelper<T> implements StorageLayer<T> {
return isEqual(value, this.defaultValue); return isEqual(value, this.defaultValue);
} }
protected onChange(value: T | undefined, oldValue: T | undefined) { private onChange(value: T | undefined, oldValue: T | undefined) {
if (!this.initialized) return;
try { try {
if (value == null) { if (value == null) {
this.storage.removeItem(this.key); this.storage.removeItem(this.key);
@ -137,18 +100,13 @@ export class StorageHelper<T> implements StorageLayer<T> {
} }
get(): T { get(): T {
return this.value; return this.value.get();
}
@computed
get value(): T {
return this.data.get() ?? this.defaultValue;
} }
@action @action
set(value: T) { set(value: T) {
if (this.isDefaultValue(value)) { if (this.isDefaultValue(value)) {
this.reset(); this.data.set(undefined);
} else { } else {
this.data.set(value); this.data.set(value);
} }