From 0956760626d296c4c74ca14235b9354fdb08474c Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 26 Jan 2022 12:33:10 -0500 Subject: [PATCH] Refactor templates for create resource tab for ease of testing Signed-off-by: Sebastian Malton --- src/common/utils/index.ts | 1 + src/common/utils/wait-for-path.ts | 36 ++++++++ .../create-resource-templates.injectable.ts | 33 +++++++ .../create-resource/has-correct-extension.ts | 18 ++++ .../lens-templates.injectable.ts | 38 ++++++++ .../components/dock/create-resource/store.ts | 51 +---------- .../user-templates.injectable.ts | 88 +++++++++++++++++++ .../components/dock/create-resource/view.tsx | 71 +++++---------- src/renderer/components/dock/info-panel.tsx | 4 +- 9 files changed, 237 insertions(+), 103 deletions(-) create mode 100644 src/common/utils/wait-for-path.ts create mode 100644 src/renderer/components/dock/create-resource/create-resource-templates.injectable.ts create mode 100644 src/renderer/components/dock/create-resource/has-correct-extension.ts create mode 100644 src/renderer/components/dock/create-resource/lens-templates.injectable.ts create mode 100644 src/renderer/components/dock/create-resource/user-templates.injectable.ts diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 1400608aab..1eb50e2857 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -49,6 +49,7 @@ export * from "./toggle-set"; export * from "./toJS"; export * from "./type-narrowing"; export * from "./types"; +export * from "./wait-for-path"; import * as iter from "./iter"; import * as array from "./array"; diff --git a/src/common/utils/wait-for-path.ts b/src/common/utils/wait-for-path.ts new file mode 100644 index 0000000000..3b4ea80f70 --- /dev/null +++ b/src/common/utils/wait-for-path.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { watch } from "chokidar"; +import path from "path"; + +/** + * Wait for `filePath` and all parent directories to exist. + * @param filePath The file path to wait until it exists + */ +export async function waitForPath(filePath: string): Promise { + console.log("waiting for", filePath); + const dirOfPath = path.dirname(filePath); + + if (dirOfPath === filePath) { + // The root of this filesystem, assume it exists + console.log("found", filePath); + + return; + } else { + await waitForPath(dirOfPath); + } + + return new Promise(resolve => { + watch(dirOfPath, { + depth: 0, + }).on("all", (event, path) => { + if ((event === "add" || event === "addDir") && path === filePath) { + console.log("found", filePath); + resolve(); + } + }); + }); +} diff --git a/src/renderer/components/dock/create-resource/create-resource-templates.injectable.ts b/src/renderer/components/dock/create-resource/create-resource-templates.injectable.ts new file mode 100644 index 0000000000..eac0bb4a1d --- /dev/null +++ b/src/renderer/components/dock/create-resource/create-resource-templates.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, lifecycleEnum } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import type { GroupSelectOption, SelectOption } from "../../select"; +import lensCreateResourceTemplatesInjectable from "./lens-templates.injectable"; +import userCreateResourceTemplatesInjectable from "./user-templates.injectable"; + +export type RawTemplates = [group: string, items: [file: string, contents: string][]]; + +const createResourceTemplatesInjectable = getInjectable({ + instantiate: (di) => { + const lensResourceTemplates = 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>); + }); + }, + lifecycle: lifecycleEnum.singleton, +}); + +export default createResourceTemplatesInjectable; diff --git a/src/renderer/components/dock/create-resource/has-correct-extension.ts b/src/renderer/components/dock/create-resource/has-correct-extension.ts new file mode 100644 index 0000000000..47768161d6 --- /dev/null +++ b/src/renderer/components/dock/create-resource/has-correct-extension.ts @@ -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)); +} diff --git a/src/renderer/components/dock/create-resource/lens-templates.injectable.ts b/src/renderer/components/dock/create-resource/lens-templates.injectable.ts new file mode 100644 index 0000000000..85fbc82977 --- /dev/null +++ b/src/renderer/components/dock/create-resource/lens-templates.injectable.ts @@ -0,0 +1,38 @@ +/** + * 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 { readdir, readFile } from "fs/promises"; +import { hasCorrectExtension } from "./has-correct-extension"; +import type { RawTemplates } from "./create-resource-templates.injectable"; + +const templatesFolder = path.resolve(__static, "../templates/create-resource"); + +async function getTemplates() { + /** + * 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; +} + +let lensTemplatePaths: RawTemplates; + +const lensCreateResourceTemplatesInjectable = getInjectable({ + setup: async () => { + lensTemplatePaths = ["lens", await getTemplates()]; + }, + instantiate: () => lensTemplatePaths, + lifecycle: lifecycleEnum.singleton, +}); + +export default lensCreateResourceTemplatesInjectable; diff --git a/src/renderer/components/dock/create-resource/store.ts b/src/renderer/components/dock/create-resource/store.ts index 1a3663301d..8fc1340594 100644 --- a/src/renderer/components/dock/create-resource/store.ts +++ b/src/renderer/components/dock/create-resource/store.ts @@ -3,13 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import fs from "fs-extra"; -import path from "path"; -import os from "os"; -import groupBy from "lodash/groupBy"; -import filehound from "filehound"; -import { watch } from "chokidar"; -import { autoBind, StorageHelper } from "../../../utils"; +import type { StorageHelper } from "../../../utils"; import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; interface Dependencies { @@ -21,48 +15,5 @@ export class CreateResourceTabStore extends DockTabStore { super(dependencies, { storageKey: "create_resource", }); - - autoBind(this); - fs.ensureDirSync(this.userTemplatesFolder); - } - - get lensTemplatesFolder():string { - return path.resolve(__static, "../templates/create-resource"); - } - - get userTemplatesFolder():string { - return path.join(os.homedir(), ".k8slens", "templates"); - } - - async getTemplates(templatesPath: string, defaultGroup: string) { - const templates = await filehound.create().path(templatesPath).ext(["yaml", "json"]).depth(1).find(); - - return templates ? this.groupTemplates(templates, templatesPath, defaultGroup) : {}; - } - - groupTemplates(templates: string[], templatesPath: string, defaultGroup: string) { - return groupBy(templates, (v:string) => - path.relative(templatesPath, v).split(path.sep).length>1 - ? path.parse(path.relative(templatesPath, v)).dir - : defaultGroup); - } - - async getMergedTemplates() { - const userTemplates = await this.getTemplates(this.userTemplatesFolder, "ungrouped"); - const lensTemplates = await this.getTemplates(this.lensTemplatesFolder, "lens"); - - return { ...userTemplates, ...lensTemplates }; - } - - async watchUserTemplates(callback: ()=> void){ - watch(this.userTemplatesFolder, { - depth: 1, - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 500, - }, - }).on("all", () => { - callback(); - }); } } diff --git a/src/renderer/components/dock/create-resource/user-templates.injectable.ts b/src/renderer/components/dock/create-resource/user-templates.injectable.ts new file mode 100644 index 0000000000..ac0bd290ed --- /dev/null +++ b/src/renderer/components/dock/create-resource/user-templates.injectable.ts @@ -0,0 +1,88 @@ +/** + * 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 { 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): RawTemplates[] { + const res = new Map(); + + 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 { + /** + * Map between filePaths and template contents + */ + const templates = observable.map(); + + 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); + }; + + waitForPath(userTemplatesFolder) + .then(() => { + console.log("watching", userTemplatesFolder); + 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); + }); + + return computed(() => groupTemplates(templates)); +} + +const userCreateResourceTemplatesInjectable = getInjectable({ + instantiate: () => watchUserCreateResourceTemplates(), + lifecycle: lifecycleEnum.singleton, +}); + +export default userCreateResourceTemplatesInjectable; diff --git a/src/renderer/components/dock/create-resource/view.tsx b/src/renderer/components/dock/create-resource/view.tsx index 4c6b3035b6..6d8351ee11 100644 --- a/src/renderer/components/dock/create-resource/view.tsx +++ b/src/renderer/components/dock/create-resource/view.tsx @@ -4,11 +4,9 @@ */ import React from "react"; -import path from "path"; -import fs from "fs-extra"; import { GroupSelectOption, Select, SelectOption } from "../../select"; import yaml from "js-yaml"; -import { makeObservable, observable } from "mobx"; +import { IComputedValue, makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; import type { CreateResourceTabStore } from "./store"; import type { DockTab } from "../dock/store"; @@ -23,44 +21,27 @@ 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 createResourceTabStoreInjectable from "./store.injectable"; +import createResourceTemplatesInjectable from "./create-resource-templates.injectable"; interface Props { tab: DockTab; } interface Dependencies { + createResourceTemplates: IComputedValue[]>; createResourceTabStore: CreateResourceTabStore; } @observer class NonInjectedCreateResource extends React.Component { - @observable currentTemplates: Map = new Map(); @observable error = ""; - @observable templates: GroupSelectOption[] = []; constructor(props: Props & Dependencies) { super(props); makeObservable(this); } - componentDidMount() { - this.props.createResourceTabStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v)); - this.props.createResourceTabStore.watchUserTemplates(() => this.props.createResourceTabStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v))); - } - - updateGroupSelectOptions(templates: Record) { - this.templates = Object.entries(templates) - .map(([name, grouping]) => this.convertToGroup(name, grouping)); - } - - convertToGroup(group: string, items: string[]): GroupSelectOption { - const options = items.map(v => ({ label: path.parse(v).name, value: v })); - - return { label: group, options }; - } - get tabId() { return this.props.tab.id; } @@ -69,10 +50,6 @@ class NonInjectedCreateResource extends React.Component { return this.props.createResourceTabStore.getData(this.tabId); } - get currentTemplate() { - return this.currentTemplates.get(this.tabId) ?? null; - } - onChange = (value: string) => { this.error = ""; // reset first, validation goes later this.props.createResourceTabStore.setData(this.tabId, value); @@ -82,17 +59,14 @@ class NonInjectedCreateResource extends React.Component { this.error = error.toString(); }; - onSelectTemplate = (item: SelectOption) => { - this.currentTemplates.set(this.tabId, item); - fs.readFile(item.value, "utf8").then(v => { - this.props.createResourceTabStore.setData(this.tabId, v); - }); + onSelectTemplate = (item: SelectOption) => { + this.props.createResourceTabStore.setData(this.tabId, item.value); }; - create = async (): Promise => { + create = async (): Promise => { if (this.error || !this.data.trim()) { // do not save when field is empty or there is an error - return null; + return; } // skip empty documents @@ -104,11 +78,12 @@ class NonInjectedCreateResource extends React.Component { const creatingResources = resources.map(async (resource: string) => { try { - const data: KubeJsonApiData = await resourceApplierApi.update(resource); + const data = await resourceApplierApi.update(resource) as KubeJsonApiData; const { kind, apiVersion, metadata: { name, namespace }} = data; - const resourceLink = apiManager.lookupApiLink({ kind, apiVersion, name, namespace }); const showDetails = () => { + const resourceLink = apiManager.lookupApiLink({ kind, apiVersion, name, namespace }); + navigate(getDetailsUrl(resourceLink)); close(); }; @@ -124,8 +99,6 @@ class NonInjectedCreateResource extends React.Component { }); await Promise.allSettled(creatingResources); - - return undefined; }; renderControls() { @@ -136,11 +109,10 @@ class NonInjectedCreateResource extends React.Component { controlShouldRenderValue={false} // always keep initial placeholder className="TemplateSelect" placeholder="Select Template ..." - options={this.templates} + options={this.props.createResourceTemplates.get()} menuPlacement="top" themeName="outlined" - onChange={v => this.onSelectTemplate(v)} - value={this.currentTemplate} + onChange={ this.onSelectTemplate} /> ); @@ -170,13 +142,10 @@ class NonInjectedCreateResource extends React.Component { } } -export const CreateResource = withInjectables( - NonInjectedCreateResource, - - { - getProps: (di, props) => ({ - createResourceTabStore: di.inject(createResourceTabStoreInjectable), - ...props, - }), - }, -); +export const CreateResource = withInjectables(NonInjectedCreateResource, { + getProps: (di, props) => ({ + createResourceTabStore: di.inject(createResourceTabStoreInjectable), + createResourceTemplates: di.inject(createResourceTemplatesInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/dock/info-panel.tsx b/src/renderer/components/dock/info-panel.tsx index 27358dc90c..91a6d329f8 100644 --- a/src/renderer/components/dock/info-panel.tsx +++ b/src/renderer/components/dock/info-panel.tsx @@ -19,7 +19,7 @@ import dockStoreInjectable from "./dock/store.injectable"; interface Props extends OptionalProps { tabId: TabId; - submit?: () => Promise; + submit?: () => Promise; } interface OptionalProps { @@ -80,7 +80,7 @@ class NonInjectedInfoPanel extends Component { try { const result = await this.props.submit(); - if (showNotifications) Notifications.ok(result); + if (showNotifications && result) Notifications.ok(result); } catch (error) { if (showNotifications) Notifications.error(error.toString()); } finally {