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

Refactor templates for create resource tab for ease of testing

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-01-26 12:33:10 -05:00
parent 3e045305ef
commit 0956760626
9 changed files with 237 additions and 103 deletions

View File

@ -49,6 +49,7 @@ export * from "./toggle-set";
export * from "./toJS"; export * from "./toJS";
export * from "./type-narrowing"; export * from "./type-narrowing";
export * from "./types"; export * from "./types";
export * from "./wait-for-path";
import * as iter from "./iter"; import * as iter from "./iter";
import * as array from "./array"; import * as array from "./array";

View File

@ -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<void> {
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();
}
});
});
}

View File

@ -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<SelectOption<string>>);
});
},
lifecycle: lifecycleEnum.singleton,
});
export default createResourceTemplatesInjectable;

View File

@ -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));
}

View File

@ -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;

View File

@ -3,13 +3,7 @@
* 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 fs from "fs-extra"; import type { StorageHelper } from "../../../utils";
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 { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store";
interface Dependencies { interface Dependencies {
@ -21,48 +15,5 @@ export class CreateResourceTabStore extends DockTabStore<string> {
super(dependencies, { super(dependencies, {
storageKey: "create_resource", 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();
});
} }
} }

View File

@ -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<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);
};
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;

View File

@ -4,11 +4,9 @@
*/ */
import React from "react"; import React from "react";
import path from "path";
import fs from "fs-extra";
import { GroupSelectOption, Select, SelectOption } from "../../select"; import { GroupSelectOption, Select, SelectOption } from "../../select";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { makeObservable, observable } from "mobx"; import { IComputedValue, makeObservable, observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { CreateResourceTabStore } from "./store"; import type { CreateResourceTabStore } from "./store";
import type { DockTab } from "../dock/store"; import type { DockTab } from "../dock/store";
@ -23,44 +21,27 @@ import { apiManager } from "../../../../common/k8s-api/api-manager";
import { prevDefault } from "../../../utils"; import { prevDefault } from "../../../utils";
import { navigate } from "../../../navigation"; import { navigate } from "../../../navigation";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import createResourceTabStoreInjectable import createResourceTabStoreInjectable from "./store.injectable";
from "./store.injectable"; import createResourceTemplatesInjectable from "./create-resource-templates.injectable";
interface Props { interface Props {
tab: DockTab; tab: DockTab;
} }
interface Dependencies { interface Dependencies {
createResourceTemplates: IComputedValue<GroupSelectOption<SelectOption>[]>;
createResourceTabStore: CreateResourceTabStore; createResourceTabStore: CreateResourceTabStore;
} }
@observer @observer
class NonInjectedCreateResource extends React.Component<Props & Dependencies> { class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
@observable currentTemplates: Map<string, SelectOption> = new Map();
@observable error = ""; @observable error = "";
@observable templates: GroupSelectOption<SelectOption>[] = [];
constructor(props: Props & Dependencies) { constructor(props: Props & Dependencies) {
super(props); super(props);
makeObservable(this); 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<string, string[]>) {
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() { get tabId() {
return this.props.tab.id; return this.props.tab.id;
} }
@ -69,10 +50,6 @@ class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
return this.props.createResourceTabStore.getData(this.tabId); return this.props.createResourceTabStore.getData(this.tabId);
} }
get currentTemplate() {
return this.currentTemplates.get(this.tabId) ?? null;
}
onChange = (value: string) => { onChange = (value: string) => {
this.error = ""; // reset first, validation goes later this.error = ""; // reset first, validation goes later
this.props.createResourceTabStore.setData(this.tabId, value); this.props.createResourceTabStore.setData(this.tabId, value);
@ -82,17 +59,14 @@ class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
this.error = error.toString(); this.error = error.toString();
}; };
onSelectTemplate = (item: SelectOption) => { onSelectTemplate = (item: SelectOption<string>) => {
this.currentTemplates.set(this.tabId, item); this.props.createResourceTabStore.setData(this.tabId, item.value);
fs.readFile(item.value, "utf8").then(v => {
this.props.createResourceTabStore.setData(this.tabId, v);
});
}; };
create = async (): Promise<undefined> => { create = async (): Promise<void> => {
if (this.error || !this.data.trim()) { if (this.error || !this.data.trim()) {
// do not save when field is empty or there is an error // do not save when field is empty or there is an error
return null; return;
} }
// skip empty documents // skip empty documents
@ -104,11 +78,12 @@ class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
const creatingResources = resources.map(async (resource: string) => { const creatingResources = resources.map(async (resource: string) => {
try { try {
const data: KubeJsonApiData = await resourceApplierApi.update(resource); const data = await resourceApplierApi.update(resource) as KubeJsonApiData;
const { kind, apiVersion, metadata: { name, namespace }} = data; const { kind, apiVersion, metadata: { name, namespace }} = data;
const resourceLink = apiManager.lookupApiLink({ kind, apiVersion, name, namespace });
const showDetails = () => { const showDetails = () => {
const resourceLink = apiManager.lookupApiLink({ kind, apiVersion, name, namespace });
navigate(getDetailsUrl(resourceLink)); navigate(getDetailsUrl(resourceLink));
close(); close();
}; };
@ -124,8 +99,6 @@ class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
}); });
await Promise.allSettled(creatingResources); await Promise.allSettled(creatingResources);
return undefined;
}; };
renderControls() { renderControls() {
@ -136,11 +109,10 @@ class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
controlShouldRenderValue={false} // always keep initial placeholder controlShouldRenderValue={false} // always keep initial placeholder
className="TemplateSelect" className="TemplateSelect"
placeholder="Select Template ..." placeholder="Select Template ..."
options={this.templates} options={this.props.createResourceTemplates.get()}
menuPlacement="top" menuPlacement="top"
themeName="outlined" themeName="outlined"
onChange={v => this.onSelectTemplate(v)} onChange={ this.onSelectTemplate}
value={this.currentTemplate}
/> />
</div> </div>
); );
@ -170,13 +142,10 @@ class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
} }
} }
export const CreateResource = withInjectables<Dependencies, Props>( export const CreateResource = withInjectables<Dependencies, Props>(NonInjectedCreateResource, {
NonInjectedCreateResource, getProps: (di, props) => ({
createResourceTabStore: di.inject(createResourceTabStoreInjectable),
{ createResourceTemplates: di.inject(createResourceTemplatesInjectable),
getProps: (di, props) => ({ ...props,
createResourceTabStore: di.inject(createResourceTabStoreInjectable), }),
...props, });
}),
},
);

View File

@ -19,7 +19,7 @@ import dockStoreInjectable from "./dock/store.injectable";
interface Props extends OptionalProps { interface Props extends OptionalProps {
tabId: TabId; tabId: TabId;
submit?: () => Promise<ReactNode | string>; submit?: () => Promise<ReactNode | string | void>;
} }
interface OptionalProps { interface OptionalProps {
@ -80,7 +80,7 @@ class NonInjectedInfoPanel extends Component<Props & Dependencies> {
try { try {
const result = await this.props.submit(); const result = await this.props.submit();
if (showNotifications) Notifications.ok(result); if (showNotifications && result) Notifications.ok(result);
} catch (error) { } catch (error) {
if (showNotifications) Notifications.error(error.toString()); if (showNotifications) Notifications.error(error.toString());
} finally { } finally {