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:
parent
d56dbc2342
commit
b2088f3c53
@ -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";
|
||||
|
||||
36
src/common/utils/wait-for-path.ts
Normal file
36
src/common/utils/wait-for-path.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
@ -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,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;
|
||||
@ -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<string> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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<GroupSelectOption<SelectOption>[]>;
|
||||
createResourceTabStore: CreateResourceTabStore;
|
||||
}
|
||||
|
||||
@observer
|
||||
class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
|
||||
@observable currentTemplates: Map<string, SelectOption> = new Map();
|
||||
@observable error = "";
|
||||
@observable templates: GroupSelectOption<SelectOption>[] = [];
|
||||
|
||||
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<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() {
|
||||
return this.props.tab.id;
|
||||
}
|
||||
@ -69,10 +50,6 @@ class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
|
||||
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<Props & Dependencies> {
|
||||
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<string>) => {
|
||||
this.props.createResourceTabStore.setData(this.tabId, item.value);
|
||||
};
|
||||
|
||||
create = async (): Promise<undefined> => {
|
||||
create = async (): Promise<void> => {
|
||||
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<Props & Dependencies> {
|
||||
|
||||
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<Props & Dependencies> {
|
||||
});
|
||||
|
||||
await Promise.allSettled(creatingResources);
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
renderControls() {
|
||||
@ -136,11 +109,10 @@ class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -170,13 +142,10 @@ class NonInjectedCreateResource extends React.Component<Props & Dependencies> {
|
||||
}
|
||||
}
|
||||
|
||||
export const CreateResource = withInjectables<Dependencies, Props>(
|
||||
NonInjectedCreateResource,
|
||||
|
||||
{
|
||||
getProps: (di, props) => ({
|
||||
createResourceTabStore: di.inject(createResourceTabStoreInjectable),
|
||||
...props,
|
||||
}),
|
||||
},
|
||||
);
|
||||
export const CreateResource = withInjectables<Dependencies, Props>(NonInjectedCreateResource, {
|
||||
getProps: (di, props) => ({
|
||||
createResourceTabStore: di.inject(createResourceTabStoreInjectable),
|
||||
createResourceTemplates: di.inject(createResourceTemplatesInjectable),
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -19,7 +19,7 @@ import dockStoreInjectable from "./dock/store.injectable";
|
||||
|
||||
interface Props extends OptionalProps {
|
||||
tabId: TabId;
|
||||
submit?: () => Promise<ReactNode | string>;
|
||||
submit?: () => Promise<ReactNode | string | void>;
|
||||
}
|
||||
|
||||
interface OptionalProps {
|
||||
@ -80,7 +80,7 @@ class NonInjectedInfoPanel extends Component<Props & Dependencies> {
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user