Full dependency inversion of <Dock> and all current tab kinds (#4757)

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>
Co-authored-by: Sebastian Malton <sebastian@malton.name>
Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
authored and GitHub committed 2022-01-31 09:49:36 -05:00
1 parent 65669f6a64
commit 0ce4e3d793
152 files changed
+3423 -2617

No files matched your search

@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { TabId } from "../dock/store";
import createResourceTabStoreInjectable from "./store.injectable";
const clearCreateResourceTabDataInjectable = getInjectable({
instantiate: (di) => {
const createResourceTabStore = di.inject(createResourceTabStoreInjectable);
return (tabId: TabId): void => {
createResourceTabStore.clearData(tabId);
};
},
lifecycle: lifecycleEnum.singleton,
});
export default clearCreateResourceTabDataInjectable;
@@ -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, lifecycleEnum } from "@ogre-tools/injectable";
import dockStoreInjectable from "../dock/store.injectable";
import { DockTabCreateSpecific, TabKind } from "../dock/store";
const createResourceTabInjectable = getInjectable({
instantiate: (di) => {
const dockStore = di.inject(dockStoreInjectable);
return (tabParams: DockTabCreateSpecific = {}) =>
dockStore.createTab({
title: "Create resource",
...tabParams,
kind: TabKind.CREATE_RESOURCE,
});
},
lifecycle: lifecycleEnum.singleton,
});
export default createResourceTabInjectable;
@@ -0,0 +1,34 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed } from "mobx";
import type { GroupSelectOption, SelectOption } from "../../select";
import userCreateResourceTemplatesInjectable from "./user-templates.injectable";
import lensCreateResourceTemplatesInjectable from "./lens-templates.injectable";
export type RawTemplates = [group: string, items: [file: string, contents: string][]];
const createResourceTemplatesInjectable = getInjectable({
instantiate: async (di) => {
const lensResourceTemplates = await di.inject(lensCreateResourceTemplatesInjectable);
const userResourceTemplates = di.inject(userCreateResourceTemplatesInjectable);
return computed(() => {
const res = [
...userResourceTemplates.get(),
lensResourceTemplates,
];
return res.map(([group, items]) => ({
label: group,
options: items.map(([label, value]) => ({ label, value })),
}) as GroupSelectOption<SelectOption<string>>);
});
},
lifecycle: lifecycleEnum.singleton,
});
export default createResourceTemplatesInjectable;
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
const extensionMatchers = [
/\.yaml$/,
/\.yml$/,
/\.json$/,
];
/**
* Check if a fileName matches a yaml or json file name structure
* @param fileName The fileName to check
*/
export function hasCorrectExtension(fileName: string): boolean {
return extensionMatchers.some(matcher => matcher.test(fileName));
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import path from "path";
import { hasCorrectExtension } from "./has-correct-extension";
import "../../../../common/vars";
import readFileInjectable from "../../../../common/fs/read-file.injectable";
import readDirInjectable from "../../../../common/fs/read-dir.injectable";
import type { RawTemplates } from "./create-resource-templates.injectable";
interface Dependencies {
readDir: (dirPath: string) => Promise<string[]>;
readFile: (filePath: string, encoding: "utf-8") => Promise<string>;
}
async function getTemplates({ readDir, readFile }: Dependencies) {
const templatesFolder = path.resolve(__static, "../templates/create-resource");
/**
* Mapping between file names and their contents
*/
const templates: [file: string, contents: string][] = [];
for (const dirEntry of await readDir(templatesFolder)) {
if (hasCorrectExtension(dirEntry)) {
templates.push([path.parse(dirEntry).name, await readFile(path.join(templatesFolder, dirEntry), "utf-8")]);
}
}
return templates;
}
const lensCreateResourceTemplatesInjectable = getInjectable({
instantiate: async (di): Promise<RawTemplates> => {
const templates = await getTemplates({
readFile: di.inject(readFileInjectable),
readDir: di.inject(readDirInjectable),
});
return ["lens", templates];
},
lifecycle: lifecycleEnum.singleton,
});
export default lensCreateResourceTemplatesInjectable;
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { CreateResourceTabStore } from "./store";
import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable";
const createResourceTabStoreInjectable = getInjectable({
instantiate: (di) => new CreateResourceTabStore({
createStorage: di.inject(createStorageInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
export default createResourceTabStoreInjectable;
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { StorageHelper } from "../../../utils";
import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store";
interface Dependencies {
createStorage:<T> (storageKey: string, options: DockTabStorageState<T>) => StorageHelper<DockTabStorageState<T>>;
}
export class CreateResourceTabStore extends DockTabStore<string> {
constructor(protected dependencies: Dependencies) {
super(dependencies, {
storageKey: "create_resource",
});
}
}
@@ -0,0 +1,102 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed, IComputedValue, observable } from "mobx";
import path from "path";
import os from "os";
import { delay, getOrInsert, waitForPath } from "../../../utils";
import { watch } from "chokidar";
import { readFile } from "fs/promises";
import logger from "../../../../common/logger";
import { hasCorrectExtension } from "./has-correct-extension";
import type { RawTemplates } from "./create-resource-templates.injectable";
const userTemplatesFolder = path.join(os.homedir(), ".k8slens", "templates");
function groupTemplates(templates: Map<string, string>): RawTemplates[] {
const res = new Map<string, [string, string][]>();
for (const [filePath, contents] of templates) {
const rawRelative = path.dirname(path.relative(userTemplatesFolder, filePath));
const title = rawRelative === "."
? "ungrouped"
: rawRelative;
getOrInsert(res, title, []).push([path.parse(filePath).name, contents]);
}
return [...res.entries()];
}
function watchUserCreateResourceTemplates(): IComputedValue<RawTemplates[]> {
/**
* Map between filePaths and template contents
*/
const templates = observable.map<string, string>();
const onAddOrChange = async (filePath: string) => {
if (!hasCorrectExtension(filePath)) {
// ignore non yaml or json files
return;
}
try {
const contents = await readFile(filePath, "utf-8");
templates.set(filePath, contents);
} catch (error) {
if (error?.code === "ENOENT") {
// ignore, file disappeared
} else {
logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while reading ${filePath}`, error);
}
}
};
const onUnlink = (filePath: string) => {
templates.delete(filePath);
};
(async () => {
for (let i = 1;; i *= 2) {
try {
await waitForPath(userTemplatesFolder);
break;
} catch (error) {
logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while waiting for ${userTemplatesFolder} to exist, waiting and trying again`, error);
await delay(i * 1000); // exponential backoff in seconds
}
}
/**
* NOTE: There is technically a race condition here of the form "time-of-check to time-of-use"
*/
watch(userTemplatesFolder, {
disableGlobbing: true,
ignorePermissionErrors: true,
usePolling: false,
awaitWriteFinish: {
pollInterval: 100,
stabilityThreshold: 1000,
},
ignoreInitial: false,
atomic: 150, // for "atomic writes"
})
.on("add", onAddOrChange)
.on("change", onAddOrChange)
.on("unlink", onUnlink)
.on("error", error => {
logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while watching files under ${userTemplatesFolder}`, error);
});
})();
return computed(() => groupTemplates(templates));
}
const userCreateResourceTemplatesInjectable = getInjectable({
instantiate: () => watchUserCreateResourceTemplates(),
lifecycle: lifecycleEnum.singleton,
});
export default userCreateResourceTemplatesInjectable;
@@ -0,0 +1,154 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { GroupSelectOption, Select, SelectOption } from "../../select";
import yaml from "js-yaml";
import { IComputedValue, makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import type { CreateResourceTabStore } from "./store";
import type { DockTab } from "../dock/store";
import { EditorPanel } from "../editor-panel";
import { InfoPanel } from "../info-panel";
import * as resourceApplierApi from "../../../../common/k8s-api/endpoints/resource-applier.api";
import { Notifications } from "../../notifications";
import logger from "../../../../common/logger";
import type { KubeJsonApiData } from "../../../../common/k8s-api/kube-json-api";
import { getDetailsUrl } from "../../kube-detail-params";
import { apiManager } from "../../../../common/k8s-api/api-manager";
import { prevDefault } from "../../../utils";
import { navigate } from "../../../navigation";
import { withInjectables } from "@ogre-tools/injectable-react";
import createResourceTabStoreInjectable from "./store.injectable";
import createResourceTemplatesInjectable from "./create-resource-templates.injectable";
import { Spinner } from "../../spinner";
interface Props {
tab: DockTab;
}
interface Dependencies {
createResourceTemplates: IComputedValue<GroupSelectOption<SelectOption>[]>;
createResourceTabStore: CreateResourceTabStore;
}
@observer
class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
@observable error = "";
constructor(props: Props & Dependencies) {
super(props);
makeObservable(this);
}
get tabId() {
return this.props.tab.id;
}
get data() {
return this.props.createResourceTabStore.getData(this.tabId);
}
onChange = (value: string) => {
this.error = ""; // reset first, validation goes later
this.props.createResourceTabStore.setData(this.tabId, value);
};
onError = (error: Error | string) => {
this.error = error.toString();
};
onSelectTemplate = (item: SelectOption<string>) => {
this.props.createResourceTabStore.setData(this.tabId, item.value);
};
create = async (): Promise<void> => {
if (this.error || !this.data.trim()) {
// do not save when field is empty or there is an error
return;
}
// skip empty documents
const resources = yaml.loadAll(this.data).filter(Boolean);
if (resources.length === 0) {
return void logger.info("Nothing to create");
}
const creatingResources = resources.map(async (resource: string) => {
try {
const data = await resourceApplierApi.update(resource) as KubeJsonApiData;
const { kind, apiVersion, metadata: { name, namespace }} = data;
const showDetails = () => {
const resourceLink = apiManager.lookupApiLink({ kind, apiVersion, name, namespace });
navigate(getDetailsUrl(resourceLink));
close();
};
const close = Notifications.ok(
<p>
{kind} <a onClick={prevDefault(showDetails)}>{name}</a> successfully created.
</p>,
);
} catch (error) {
Notifications.error(error?.toString() ?? "Unknown error occured");
}
});
await Promise.allSettled(creatingResources);
};
renderControls() {
return (
<div className="flex gaps align-center">
<Select
autoConvertOptions={false}
controlShouldRenderValue={false} // always keep initial placeholder
className="TemplateSelect"
placeholder="Select Template ..."
options={this.props.createResourceTemplates.get()}
menuPlacement="top"
themeName="outlined"
onChange={ this.onSelectTemplate}
/>
</div>
);
}
render() {
const { tabId, data, error } = this;
return (
<div className="CreateResource flex column">
<InfoPanel
tabId={tabId}
error={error}
controls={this.renderControls()}
submit={this.create}
submitLabel="Create"
showNotifications={false}
/>
<EditorPanel
tabId={tabId}
value={data}
onChange={this.onChange}
onError={this.onError}
/>
</div>
);
}
}
export const CreateResource = withInjectables<Dependencies, Props>(NonInjectedCreateResource, {
getPlaceholder: () => <Spinner center />,
getProps: async (di, props) => ({
createResourceTabStore: di.inject(createResourceTabStoreInjectable),
createResourceTemplates: await di.inject(createResourceTemplatesInjectable),
...props,
}),
});