mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Stop using HelmCli from Renderer (#4861)
* Introduce way for execute file Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Make typing of HelmRepo shared Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Introduce way to get Helm environment values Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Introduce function to read YAML file Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Introduce competition for listing active helm repositories in preferences Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Make sense in name of injectable Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Introduce helper for opening and selecting values of select Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Introduce competition for activating, deactivating public helm repositories in preferences Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Introduce competition for deactivating helm repository from list of active repositories Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Add missing global overrides Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Make some tests more deterministic by mocking tooltips Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Introduce competition for activating custom helm repository in preferences Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Update snapshots Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Remove old implementation made redundant with competition for preferences of helm repositories Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Add success notification for activating custom helm repository Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Introduce way to get single active helm repository Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Extract responsibilities from god-class and switch to getting helm repositories using competition instead of another god class Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Remove dead code Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Add TODO Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Tweak position of spinner Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Start handling errors when accessing helm repositories Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Handle error about no helm repositories when updating repositories Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Remove unwarranted function configuration Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Add missing global overrides Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Remove duplication how to acquire binary path for helm Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Remove redundant comment Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Consolidate naming to match Helm's internal Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Relocate file closer to it's relatives Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
1 parent
5420780ae0
commit
1393cc303d
99 files changed
+21409
-1039
No files matched your search
@@ -15,7 +15,6 @@ import * as LensExtensionsCommonApi from "../extensions/common-api";
|
||||
import * as LensExtensionsRendererApi from "../extensions/renderer-api";
|
||||
import { delay } from "../common/utils";
|
||||
import { isMac, isDevelopment } from "../common/vars";
|
||||
import { HelmRepoManager } from "../main/helm/helm-repo-manager";
|
||||
import { DefaultProps } from "./mui-base-theme";
|
||||
import configurePackages from "../common/configure-packages";
|
||||
import * as initializers from "./initializers";
|
||||
@@ -151,8 +150,6 @@ export async function bootstrap(di: DiContainer) {
|
||||
|
||||
extensionInstallationStateStore.bindIpcListeners();
|
||||
|
||||
HelmRepoManager.createInstance(); // initialize the manager
|
||||
|
||||
// Register additional store listeners
|
||||
clusterStore.registerIpcListener();
|
||||
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import "./add-helm-repo-dialog.scss";
|
||||
|
||||
import React from "react";
|
||||
import type { FileFilter } from "electron";
|
||||
import { observable, makeObservable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import type { DialogProps } from "../dialog";
|
||||
import { Dialog } from "../dialog";
|
||||
import { Wizard, WizardStep } from "../wizard";
|
||||
import { Input } from "../input";
|
||||
import { Checkbox } from "../checkbox";
|
||||
import { Button } from "../button";
|
||||
import { systemName, isUrl, isPath } from "../input/input_validators";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { Icon } from "../icon";
|
||||
import { Notifications } from "../notifications";
|
||||
import { type HelmRepo, HelmRepoManager } from "../../../main/helm/helm-repo-manager";
|
||||
import { requestOpenFilePickingDialog } from "../../ipc";
|
||||
|
||||
export interface AddHelmRepoDialogProps extends Partial<DialogProps> {
|
||||
onAddRepo: () => void;
|
||||
}
|
||||
|
||||
enum FileType {
|
||||
CaFile = "caFile",
|
||||
KeyFile = "keyFile",
|
||||
CertFile = "certFile",
|
||||
}
|
||||
|
||||
const dialogState = observable.object({
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
function getEmptyRepo(): HelmRepo {
|
||||
return { name: "", url: "", username: "", password: "", insecureSkipTlsVerify: false, caFile: "", keyFile: "", certFile: "" };
|
||||
}
|
||||
@observer
|
||||
export class AddHelmRepoDialog extends React.Component<AddHelmRepoDialogProps> {
|
||||
private static keyExtensions = ["key", "keystore", "jks", "p12", "pfx", "pem"];
|
||||
private static certExtensions = ["crt", "cer", "ca-bundle", "p7b", "p7c", "p7s", "p12", "pfx", "pem"];
|
||||
|
||||
constructor(props: AddHelmRepoDialogProps) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
static open() {
|
||||
dialogState.isOpen = true;
|
||||
}
|
||||
|
||||
static close() {
|
||||
dialogState.isOpen = false;
|
||||
}
|
||||
|
||||
@observable helmRepo = getEmptyRepo();
|
||||
@observable showOptions = false;
|
||||
|
||||
@action
|
||||
close = () => {
|
||||
AddHelmRepoDialog.close();
|
||||
this.helmRepo = getEmptyRepo();
|
||||
this.showOptions = false;
|
||||
};
|
||||
|
||||
setFilepath(type: FileType, value: string) {
|
||||
this.helmRepo[type] = value;
|
||||
}
|
||||
|
||||
getFilePath(type: FileType) {
|
||||
return this.helmRepo[type];
|
||||
}
|
||||
|
||||
async selectFileDialog(type: FileType, fileFilter: FileFilter) {
|
||||
const { canceled, filePaths } = await requestOpenFilePickingDialog({
|
||||
defaultPath: this.getFilePath(type),
|
||||
properties: ["openFile", "showHiddenFiles"],
|
||||
message: `Select file`,
|
||||
buttonLabel: `Use file`,
|
||||
filters: [
|
||||
fileFilter,
|
||||
{ name: "Any", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!canceled && filePaths.length) {
|
||||
this.setFilepath(type, filePaths[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async addCustomRepo() {
|
||||
try {
|
||||
await HelmRepoManager.getInstance().addRepo(this.helmRepo);
|
||||
Notifications.ok((
|
||||
<>
|
||||
{"Helm repository "}
|
||||
<b>{this.helmRepo.name}</b>
|
||||
{" has been added."}
|
||||
</>
|
||||
));
|
||||
this.props.onAddRepo();
|
||||
this.close();
|
||||
} catch (err) {
|
||||
Notifications.error((
|
||||
<>
|
||||
{"Adding helm branch "}
|
||||
<b>{this.helmRepo.name}</b>
|
||||
{" has failed: "}
|
||||
{String(err)}
|
||||
</>
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
renderFileInput(placeholder:string, fileType:FileType, fileExtensions:string[]){
|
||||
return (
|
||||
<div className="flex gaps align-center">
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
validators={isPath}
|
||||
className="box grow"
|
||||
value={this.getFilePath(fileType)}
|
||||
onChange={v => this.setFilepath(fileType, v)}
|
||||
/>
|
||||
<Icon
|
||||
material="folder"
|
||||
onClick={() => this.selectFileDialog(fileType, { name: placeholder, extensions: fileExtensions })}
|
||||
tooltip="Browse"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderOptions() {
|
||||
return (
|
||||
<>
|
||||
<SubTitle title="Security settings" />
|
||||
<Checkbox
|
||||
label="Skip TLS certificate checks for the repository"
|
||||
value={this.helmRepo.insecureSkipTlsVerify}
|
||||
onChange={v => this.helmRepo.insecureSkipTlsVerify = v}
|
||||
/>
|
||||
{this.renderFileInput("Key file", FileType.KeyFile, AddHelmRepoDialog.keyExtensions)}
|
||||
{this.renderFileInput("Ca file", FileType.CaFile, AddHelmRepoDialog.certExtensions)}
|
||||
{this.renderFileInput("Certificate file", FileType.CertFile, AddHelmRepoDialog.certExtensions)}
|
||||
<SubTitle title="Chart Repository Credentials" />
|
||||
<Input
|
||||
placeholder="Username"
|
||||
value={this.helmRepo.username}
|
||||
onChange= {v => this.helmRepo.username = v}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={this.helmRepo.password}
|
||||
onChange={v => this.helmRepo.password = v}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { ...dialogProps } = this.props;
|
||||
|
||||
const header = <h5>Add custom Helm Repo</h5>;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...dialogProps}
|
||||
className="AddHelmRepoDialog"
|
||||
isOpen={dialogState.isOpen}
|
||||
close={this.close}
|
||||
>
|
||||
<Wizard header={header} done={this.close}>
|
||||
<WizardStep
|
||||
contentClass="flow column"
|
||||
nextLabel="Add"
|
||||
next={() => this.addCustomRepo()}
|
||||
>
|
||||
<div className="flex column gaps">
|
||||
<Input
|
||||
autoFocus
|
||||
required
|
||||
placeholder="Helm repo name"
|
||||
trim
|
||||
validators={systemName}
|
||||
value={this.helmRepo.name}
|
||||
onChange={v => this.helmRepo.name = v}
|
||||
/>
|
||||
<Input
|
||||
required
|
||||
placeholder="URL"
|
||||
validators={isUrl}
|
||||
value={this.helmRepo.url}
|
||||
onChange={v => this.helmRepo.url = v}
|
||||
/>
|
||||
<Button
|
||||
plain
|
||||
className="accordion"
|
||||
onClick={() => this.showOptions = !this.showOptions}
|
||||
>
|
||||
More
|
||||
<Icon
|
||||
small
|
||||
tooltip="More"
|
||||
material={this.showOptions ? "remove" : "add"}
|
||||
/>
|
||||
</Button>
|
||||
{this.showOptions && this.renderOptions()}
|
||||
</div>
|
||||
</WizardStep>
|
||||
</Wizard>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./helm-charts.module.scss";
|
||||
|
||||
import React from "react";
|
||||
import { observable, makeObservable, computed } from "mobx";
|
||||
|
||||
import type { HelmRepo } from "../../../main/helm/helm-repo-manager";
|
||||
import { HelmRepoManager } from "../../../main/helm/helm-repo-manager";
|
||||
import { Button } from "../button";
|
||||
import { Icon } from "../icon";
|
||||
import { Notifications } from "../notifications";
|
||||
import type { SelectOption } from "../select";
|
||||
import { Select } from "../select";
|
||||
import { AddHelmRepoDialog } from "./add-helm-repo-dialog";
|
||||
import { observer } from "mobx-react";
|
||||
import { RemovableItem } from "./removable-item";
|
||||
import { Notice } from "../+extensions/notice";
|
||||
import { Spinner } from "../spinner";
|
||||
import { noop } from "../../utils";
|
||||
import type { SingleValue } from "react-select";
|
||||
|
||||
@observer
|
||||
export class HelmCharts extends React.Component {
|
||||
@observable loadingRepos = false;
|
||||
@observable loadingAvailableRepos = false;
|
||||
@observable repos: HelmRepo[] = [];
|
||||
@observable addedRepos = observable.map<string, HelmRepo>();
|
||||
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@computed get repoOptions() {
|
||||
return this.repos.map(repo => ({
|
||||
value: repo,
|
||||
label: repo.name,
|
||||
isSelected: this.addedRepos.has(repo.name),
|
||||
}));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadAvailableRepos().catch(noop);
|
||||
this.loadRepos().catch(noop);
|
||||
}
|
||||
|
||||
async loadAvailableRepos() {
|
||||
this.loadingAvailableRepos = true;
|
||||
|
||||
try {
|
||||
if (!this.repos.length) {
|
||||
this.repos = await HelmRepoManager.getInstance().loadAvailableRepos();
|
||||
}
|
||||
} catch (err) {
|
||||
Notifications.error(String(err));
|
||||
}
|
||||
|
||||
this.loadingAvailableRepos = false;
|
||||
}
|
||||
|
||||
async loadRepos() {
|
||||
this.loadingRepos = true;
|
||||
|
||||
try {
|
||||
const repos = await HelmRepoManager.getInstance().repositories(); // via helm-cli
|
||||
|
||||
this.addedRepos.replace(repos.map(repo => [repo.name, repo]));
|
||||
} catch (err) {
|
||||
Notifications.error(String(err));
|
||||
}
|
||||
|
||||
this.loadingRepos = false;
|
||||
}
|
||||
|
||||
async addRepo(repo: HelmRepo) {
|
||||
try {
|
||||
await HelmRepoManager.getInstance().addRepo(repo);
|
||||
this.addedRepos.set(repo.name, repo);
|
||||
} catch (err) {
|
||||
Notifications.error((
|
||||
<>
|
||||
{"Adding helm branch "}
|
||||
<b>{repo.name}</b>
|
||||
{" has failed: "}
|
||||
{String(err)}
|
||||
</>
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
async removeRepo(repo: HelmRepo) {
|
||||
try {
|
||||
await HelmRepoManager.getInstance().removeRepo(repo);
|
||||
this.addedRepos.delete(repo.name);
|
||||
} catch (err) {
|
||||
Notifications.error(
|
||||
<>
|
||||
{"Removing helm branch "}
|
||||
<b>{repo.name}</b>
|
||||
{" has failed: "}
|
||||
{String(err)}
|
||||
</>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onRepoSelect = async (option: SingleValue<{ value: HelmRepo }>): Promise<void> => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.addedRepos.has(option.value.name)) {
|
||||
return void Notifications.ok((
|
||||
<>
|
||||
{"Helm repo "}
|
||||
<b>{option.value.name}</b>
|
||||
{" already in use."}
|
||||
</>
|
||||
));
|
||||
}
|
||||
|
||||
await this.addRepo(option.value);
|
||||
};
|
||||
|
||||
formatOptionLabel = ({ value, isSelected }: SelectOption<HelmRepo>) => (
|
||||
<div className="flex gaps">
|
||||
<span>{value.name}</span>
|
||||
{isSelected && (
|
||||
<Icon
|
||||
small
|
||||
material="check"
|
||||
className="box right" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
renderRepositories() {
|
||||
const repos = Array.from(this.addedRepos);
|
||||
|
||||
if (this.loadingRepos) {
|
||||
return <div className="pt-5 relative"><Spinner center/></div>;
|
||||
}
|
||||
|
||||
if (!repos.length) {
|
||||
return (
|
||||
<Notice>
|
||||
<div className="flex-grow text-center">The repositories have not been added yet</div>
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
|
||||
return repos.map(([name, repo]) => {
|
||||
return (
|
||||
<RemovableItem
|
||||
key={name}
|
||||
onRemove={() => this.removeRepo(repo)}
|
||||
className="mt-3"
|
||||
>
|
||||
<div>
|
||||
<div data-testid="repository-name" className={styles.repoName}>{name}</div>
|
||||
<div className={styles.repoUrl}>{repo.url}</div>
|
||||
</div>
|
||||
</RemovableItem>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gaps">
|
||||
<Select
|
||||
id="HelmRepoSelect"
|
||||
placeholder="Repositories"
|
||||
isLoading={this.loadingAvailableRepos}
|
||||
isDisabled={this.loadingAvailableRepos}
|
||||
options={this.repoOptions}
|
||||
onChange={this.onRepoSelect}
|
||||
value={this.repos}
|
||||
formatOptionLabel={this.formatOptionLabel}
|
||||
controlShouldRenderValue={false}
|
||||
className="box grow"
|
||||
themeName="lens"
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
label="Add Custom Helm Repo"
|
||||
onClick={AddHelmRepoDialog.open}
|
||||
/>
|
||||
</div>
|
||||
<AddHelmRepoDialog onAddRepo={() => this.loadRepos()}/>
|
||||
<div className={styles.repos}>
|
||||
{this.renderRepositories()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
|
||||
import { HelmCharts } from "./helm-charts";
|
||||
import { HelmCharts } from "./kubernetes/helm-charts/helm-charts";
|
||||
import { KubeconfigSyncs } from "./kubeconfig-syncs";
|
||||
import { KubectlBinaries } from "./kubectl-binaries";
|
||||
import { Preferences } from "./preferences";
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 { asyncComputed } from "@ogre-tools/injectable-react";
|
||||
import getActiveHelmRepositoriesChannelInjectable from "../../../../../common/helm/get-active-helm-repositories-channel.injectable";
|
||||
import { requestFromChannelInjectionToken } from "../../../../../common/utils/channel/request-from-channel-injection-token";
|
||||
import showErrorNotificationInjectable from "../../../notifications/show-error-notification.injectable";
|
||||
import helmRepositoriesErrorStateInjectable from "./helm-repositories-error-state.injectable";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
const activeHelmRepositoriesInjectable = getInjectable({
|
||||
id: "active-helm-repositories",
|
||||
|
||||
instantiate: (di) => {
|
||||
const requestFromChannel = di.inject(requestFromChannelInjectionToken);
|
||||
const getHelmRepositoriesChannel = di.inject(getActiveHelmRepositoriesChannelInjectable);
|
||||
const showErrorNotification = di.inject(showErrorNotificationInjectable);
|
||||
const helmRepositoriesErrorState = di.inject(helmRepositoriesErrorStateInjectable);
|
||||
|
||||
return asyncComputed(async () => {
|
||||
const result = await requestFromChannel(getHelmRepositoriesChannel);
|
||||
|
||||
if (result.callWasSuccessful) {
|
||||
return result.response;
|
||||
} else {
|
||||
showErrorNotification(result.error);
|
||||
|
||||
runInAction(() =>
|
||||
helmRepositoriesErrorState.set({
|
||||
controlsAreShown: false,
|
||||
errorMessage: result.error,
|
||||
}),
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
});
|
||||
|
||||
export default activeHelmRepositoriesInjectable;
|
||||
File renamed without changes.
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import "./add-helm-repo-dialog.scss";
|
||||
|
||||
import React from "react";
|
||||
import { Wizard, WizardStep } from "../../../../wizard";
|
||||
import { Input } from "../../../../input";
|
||||
import { systemName, isUrl } from "../../../../input/input_validators";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import customHelmRepoInjectable from "./custom-helm-repo.injectable";
|
||||
import type { HelmRepo } from "../../../../../../common/helm/helm-repo";
|
||||
import { observer } from "mobx-react";
|
||||
import type { IObservableValue } from "mobx";
|
||||
import { action } from "mobx";
|
||||
import submitCustomHelmRepositoryInjectable from "./submit-custom-helm-repository.injectable";
|
||||
import hideDialogForAddingCustomHelmRepositoryInjectable from "./dialog-visibility/hide-dialog-for-adding-custom-helm-repository.injectable";
|
||||
import { Button } from "../../../../button";
|
||||
import { Icon } from "../../../../icon";
|
||||
import maximalCustomHelmRepoOptionsAreShownInjectable from "./maximal-custom-helm-repo-options-are-shown.injectable";
|
||||
import { SubTitle } from "../../../../layout/sub-title";
|
||||
import { Checkbox } from "../../../../checkbox";
|
||||
import { HelmFileInput } from "./helm-file-input/helm-file-input";
|
||||
|
||||
interface Dependencies {
|
||||
helmRepo: HelmRepo;
|
||||
hideDialog: () => void;
|
||||
submitCustomRepository: (repository: HelmRepo) => Promise<void>;
|
||||
maximalOptionsAreShown: IObservableValue<boolean>;
|
||||
}
|
||||
|
||||
const NonInjectedActivationOfCustomHelmRepositoryDialogContent = observer(({ helmRepo, submitCustomRepository, maximalOptionsAreShown, hideDialog } : Dependencies) => (
|
||||
<Wizard header={<h5>Add custom Helm Repo</h5>} done={hideDialog}>
|
||||
<WizardStep
|
||||
contentClass="flow column"
|
||||
nextLabel="Add"
|
||||
next={() => submitCustomRepository(helmRepo)}
|
||||
testIdForNext="custom-helm-repository-submit-button"
|
||||
testIdForPrev="custom-helm-repository-cancel-button"
|
||||
>
|
||||
<div className="flex column gaps" data-testid="add-custom-helm-repository-dialog">
|
||||
<Input
|
||||
autoFocus
|
||||
required
|
||||
placeholder="Helm repo name"
|
||||
trim
|
||||
validators={systemName}
|
||||
value={helmRepo.name}
|
||||
onChange={action(v => helmRepo.name = v)}
|
||||
data-testid="custom-helm-repository-name-input"
|
||||
/>
|
||||
<Input
|
||||
required
|
||||
placeholder="URL"
|
||||
validators={isUrl}
|
||||
value={helmRepo.url}
|
||||
onChange={action(v => helmRepo.url = v)}
|
||||
data-testid="custom-helm-repository-url-input"
|
||||
/>
|
||||
<Button
|
||||
plain
|
||||
className="accordion"
|
||||
data-testid="toggle-maximal-options-for-custom-helm-repository-button"
|
||||
onClick={action(() => maximalOptionsAreShown.set(!maximalOptionsAreShown.get()))}
|
||||
>
|
||||
More
|
||||
<Icon
|
||||
small
|
||||
tooltip="More"
|
||||
material={maximalOptionsAreShown.get() ? "remove" : "add"}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{maximalOptionsAreShown.get() && (
|
||||
<div data-testid="maximal-options-for-custom-helm-repository-dialog">
|
||||
<SubTitle title="Security settings" />
|
||||
|
||||
<Checkbox
|
||||
label="Skip TLS certificate checks for the repository"
|
||||
value={helmRepo.insecureSkipTlsVerify}
|
||||
onChange={action(v => {
|
||||
helmRepo.insecureSkipTlsVerify = v;
|
||||
})}
|
||||
data-testid="custom-helm-repository-verify-tls-input"
|
||||
/>
|
||||
|
||||
<HelmFileInput
|
||||
placeholder="Key file"
|
||||
value={helmRepo.keyFile || ""}
|
||||
setValue={action((value) => helmRepo.keyFile = value)}
|
||||
fileExtensions={keyExtensions}
|
||||
data-testid="custom-helm-repository-key-file-input"
|
||||
/>
|
||||
|
||||
<HelmFileInput
|
||||
placeholder="Ca file"
|
||||
value={helmRepo.caFile || ""}
|
||||
setValue={action((value) => helmRepo.caFile = value)}
|
||||
fileExtensions={certExtensions}
|
||||
data-testid="custom-helm-repository-ca-cert-file-input"
|
||||
/>
|
||||
|
||||
<HelmFileInput
|
||||
placeholder="Certificate file"
|
||||
value={helmRepo.certFile || ""}
|
||||
setValue={action((value) => helmRepo.certFile = value)}
|
||||
fileExtensions={certExtensions}
|
||||
data-testid="custom-helm-repository-cert-file-input"
|
||||
/>
|
||||
|
||||
<SubTitle title="Chart Repository Credentials" />
|
||||
|
||||
<Input
|
||||
placeholder="Username"
|
||||
value={helmRepo.username}
|
||||
onChange= {action(v => helmRepo.username = v)}
|
||||
data-testid="custom-helm-repository-username-input"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={helmRepo.password}
|
||||
onChange={action(v => helmRepo.password = v)}
|
||||
data-testid="custom-helm-repository-password-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WizardStep>
|
||||
</Wizard>
|
||||
));
|
||||
|
||||
|
||||
|
||||
export const AddingOfCustomHelmRepositoryDialogContent = withInjectables<Dependencies>(
|
||||
NonInjectedActivationOfCustomHelmRepositoryDialogContent,
|
||||
|
||||
{
|
||||
getProps: (di) => ({
|
||||
helmRepo: di.inject(customHelmRepoInjectable),
|
||||
hideDialog: di.inject(hideDialogForAddingCustomHelmRepositoryInjectable),
|
||||
submitCustomRepository: di.inject(submitCustomHelmRepositoryInjectable),
|
||||
maximalOptionsAreShown: di.inject(maximalCustomHelmRepoOptionsAreShownInjectable),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const keyExtensions = ["key", "keystore", "jks", "p12", "pfx", "pem"];
|
||||
const certExtensions = ["crt", "cer", "ca-bundle", "p7b", "p7c", "p7s", "p12", "pfx", "pem"];
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import "./add-helm-repo-dialog.scss";
|
||||
|
||||
import React from "react";
|
||||
import { Dialog } from "../../../../dialog";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import { AddingOfCustomHelmRepositoryDialogContent } from "./adding-of-custom-helm-repository-dialog-content";
|
||||
import addingOfCustomHelmRepositoryDialogIsVisibleInjectable from "./dialog-visibility/adding-of-custom-helm-repository-dialog-is-visible.injectable";
|
||||
import type { IObservableValue } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import hideDialogForAddingCustomHelmRepositoryInjectable from "./dialog-visibility/hide-dialog-for-adding-custom-helm-repository.injectable";
|
||||
|
||||
interface Dependencies {
|
||||
contentIsVisible: IObservableValue<boolean>;
|
||||
hideDialog: () => void;
|
||||
}
|
||||
|
||||
const NonInjectedActivationOfCustomHelmRepositoryDialog = observer(({
|
||||
contentIsVisible,
|
||||
hideDialog,
|
||||
}: Dependencies) => (
|
||||
<div>
|
||||
<Dialog
|
||||
className="AddHelmRepoDialog"
|
||||
isOpen={contentIsVisible.get()}
|
||||
close={hideDialog}
|
||||
>
|
||||
{contentIsVisible.get() && <AddingOfCustomHelmRepositoryDialogContent />}
|
||||
</Dialog>
|
||||
</div>
|
||||
));
|
||||
|
||||
|
||||
export const AddingOfCustomHelmRepositoryDialog = withInjectables<Dependencies>(
|
||||
NonInjectedActivationOfCustomHelmRepositoryDialog,
|
||||
|
||||
{
|
||||
getProps: (di) => ({
|
||||
contentIsVisible: di.inject(addingOfCustomHelmRepositoryDialogIsVisibleInjectable),
|
||||
hideDialog: di.inject(hideDialogForAddingCustomHelmRepositoryInjectable),
|
||||
}),
|
||||
},
|
||||
);
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import React from "react";
|
||||
import { Button } from "../../../../button";
|
||||
import showDialogForAddingCustomHelmRepositoryInjectable from "./dialog-visibility/show-dialog-for-adding-custom-helm-repository.injectable";
|
||||
|
||||
interface Dependencies {
|
||||
showDialog: () => void;
|
||||
}
|
||||
|
||||
const NonInjectedActivationOfCustomHelmRepositoryOpenButton = ({ showDialog }: Dependencies) => (
|
||||
<Button
|
||||
primary
|
||||
label="Add Custom Helm Repo"
|
||||
onClick={showDialog}
|
||||
data-testid="add-custom-helm-repo-button"
|
||||
/>
|
||||
);
|
||||
|
||||
export const AddingOfCustomHelmRepositoryOpenButton = withInjectables<Dependencies>(
|
||||
NonInjectedActivationOfCustomHelmRepositoryOpenButton,
|
||||
|
||||
{
|
||||
getProps: (di) => ({
|
||||
showDialog: di.inject(showDialogForAddingCustomHelmRepositoryInjectable),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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 { observable } from "mobx";
|
||||
|
||||
const customHelmRepoInjectable = getInjectable({
|
||||
id: "custom-helm-repo",
|
||||
|
||||
instantiate: () => observable({
|
||||
name: "",
|
||||
url: "",
|
||||
username: "",
|
||||
password: "",
|
||||
insecureSkipTlsVerify: false,
|
||||
caFile: "",
|
||||
keyFile: "",
|
||||
certFile: "",
|
||||
}),
|
||||
|
||||
lifecycle: lifecycleEnum.transient,
|
||||
});
|
||||
|
||||
export default customHelmRepoInjectable;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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 addingOfCustomHelmRepositoryDialogIsVisibleInjectable = getInjectable({
|
||||
id: "adding-of-custom-helm-repository-dialog-is-visible",
|
||||
instantiate: () => observable.box(false),
|
||||
});
|
||||
|
||||
export default addingOfCustomHelmRepositoryDialogIsVisibleInjectable;
|
||||
+21
@@ -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 } from "@ogre-tools/injectable";
|
||||
import { action } from "mobx";
|
||||
import addingOfCustomHelmRepositoryDialogIsVisibleInjectable from "./adding-of-custom-helm-repository-dialog-is-visible.injectable";
|
||||
|
||||
const hideDialogForAddingCustomHelmRepositoryInjectable = getInjectable({
|
||||
id: "hide-dialog-for-adding-custom-helm-repository",
|
||||
|
||||
instantiate: (di) => {
|
||||
const state = di.inject(addingOfCustomHelmRepositoryDialogIsVisibleInjectable);
|
||||
|
||||
return action(() => {
|
||||
state.set(false);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default hideDialogForAddingCustomHelmRepositoryInjectable;
|
||||
+21
@@ -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 } from "@ogre-tools/injectable";
|
||||
import { action } from "mobx";
|
||||
import addingOfCustomHelmRepositoryDialogIsVisibleInjectable from "./adding-of-custom-helm-repository-dialog-is-visible.injectable";
|
||||
|
||||
const showDialogForAddingCustomHelmRepositoryInjectable = getInjectable({
|
||||
id: "show-dialog-for-adding-custom-helm-repository",
|
||||
|
||||
instantiate: (di) => {
|
||||
const state = di.inject(addingOfCustomHelmRepositoryDialogIsVisibleInjectable);
|
||||
|
||||
return action(() => {
|
||||
state.set(true);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default showDialogForAddingCustomHelmRepositoryInjectable;
|
||||
+23
@@ -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 type { FileFilter } from "electron";
|
||||
import { requestOpenFilePickingDialog } from "../../../../../../ipc";
|
||||
|
||||
const getFilePathsInjectable = getInjectable({
|
||||
id: "get-file-paths",
|
||||
|
||||
instantiate: () => async (fileFilter: FileFilter) =>
|
||||
await requestOpenFilePickingDialog({
|
||||
properties: ["openFile", "showHiddenFiles"],
|
||||
message: `Select file`,
|
||||
buttonLabel: `Use file`,
|
||||
filters: [fileFilter, { name: "Any", extensions: ["*"] }],
|
||||
}),
|
||||
|
||||
causesSideEffects: true,
|
||||
});
|
||||
|
||||
export default getFilePathsInjectable;
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import type { InputValidator } from "../../../../../input";
|
||||
import { Input } from "../../../../../input";
|
||||
import { Icon } from "../../../../../icon";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import React from "react";
|
||||
import getFilePathsInjectable from "./get-file-paths.injectable";
|
||||
import type { FileFilter } from "electron";
|
||||
import isPathInjectable from "../../../../../input/validators/is-path.injectable";
|
||||
|
||||
interface HelmFileInputProps {
|
||||
placeholder: string;
|
||||
fileExtensions: string[];
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
getFilePaths: (fileFilter: FileFilter) => Promise<{ canceled: boolean; filePaths: string[] }>;
|
||||
isPath: InputValidator<true>;
|
||||
}
|
||||
|
||||
const NonInjectedHelmFileInput = ({
|
||||
placeholder,
|
||||
value,
|
||||
setValue,
|
||||
fileExtensions,
|
||||
getFilePaths,
|
||||
isPath,
|
||||
"data-testid": testId,
|
||||
}: Dependencies & HelmFileInputProps) => (
|
||||
<div className="flex gaps align-center">
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
validators={isPath}
|
||||
className="box grow"
|
||||
value={value}
|
||||
onChange={(v) => setValue(v)}
|
||||
data-testid={testId}
|
||||
/>
|
||||
<Icon
|
||||
material="folder"
|
||||
|
||||
onClick={async () => {
|
||||
const { canceled, filePaths } = await getFilePaths({
|
||||
name: placeholder,
|
||||
extensions: fileExtensions,
|
||||
});
|
||||
|
||||
if (!canceled && filePaths.length) {
|
||||
setValue(filePaths[0]);
|
||||
}
|
||||
}}
|
||||
|
||||
tooltip="Browse"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const HelmFileInput = withInjectables<Dependencies, HelmFileInputProps>(
|
||||
NonInjectedHelmFileInput,
|
||||
|
||||
{
|
||||
getProps: (di, props) => ({
|
||||
getFilePaths: di.inject(getFilePathsInjectable),
|
||||
isPath: di.inject(isPathInjectable),
|
||||
...props,
|
||||
}),
|
||||
},
|
||||
);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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 maximalCustomHelmRepoOptionsAreShownInjectable = getInjectable({
|
||||
id: "maximal-custom-helm-repo-options-are-shown",
|
||||
instantiate: () => observable.box(false),
|
||||
});
|
||||
|
||||
export default maximalCustomHelmRepoOptionsAreShownInjectable;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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 type { HelmRepo } from "../../../../../../common/helm/helm-repo";
|
||||
import addHelmRepositoryInjectable from "../adding-of-public-helm-repository/select-helm-repository/add-helm-repository.injectable";
|
||||
import hideDialogForAddingCustomHelmRepositoryInjectable from "./dialog-visibility/hide-dialog-for-adding-custom-helm-repository.injectable";
|
||||
|
||||
const submitCustomHelmRepositoryInjectable = getInjectable({
|
||||
id: "submit-custom-helm-repository",
|
||||
|
||||
instantiate: (di) => {
|
||||
const addHelmRepository = di.inject(addHelmRepositoryInjectable);
|
||||
const hideDialog = di.inject(hideDialogForAddingCustomHelmRepositoryInjectable);
|
||||
|
||||
return async (repository: HelmRepo) => {
|
||||
await addHelmRepository(repository);
|
||||
|
||||
hideDialog();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default submitCustomHelmRepositoryInjectable;
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import React from "react";
|
||||
import publicHelmRepositoriesInjectable from "./public-helm-repositories/public-helm-repositories.injectable";
|
||||
import type { HelmRepo } from "../../../../../../common/helm/helm-repo";
|
||||
import type { SelectOption } from "../../../../select";
|
||||
import { Select } from "../../../../select";
|
||||
import { Icon } from "../../../../icon";
|
||||
import { observer } from "mobx-react";
|
||||
import type { SingleValue } from "react-select";
|
||||
import selectHelmRepositoryInjectable from "./select-helm-repository/select-helm-repository.injectable";
|
||||
import { matches } from "lodash/fp";
|
||||
import activeHelmRepositoriesInjectable from "../active-helm-repositories.injectable";
|
||||
|
||||
interface Dependencies {
|
||||
publicRepositories: IAsyncComputed<HelmRepo[]>;
|
||||
activeRepositories: IAsyncComputed<HelmRepo[]>;
|
||||
selectRepository: (value: SingleValue<SelectOption<HelmRepo>>) => void;
|
||||
}
|
||||
|
||||
const NonInjectedAddingOfPublicHelmRepository = observer(({
|
||||
publicRepositories,
|
||||
activeRepositories,
|
||||
selectRepository,
|
||||
}: Dependencies) => {
|
||||
const dereferencesPublicRepositories = publicRepositories.value.get();
|
||||
const dereferencesActiveRepositories = activeRepositories.value.get();
|
||||
|
||||
const valuesAreLoading = publicRepositories.pending.get() || activeRepositories.pending.get();
|
||||
|
||||
const repositoryOptions = dereferencesPublicRepositories.map(repository => ({
|
||||
value: repository,
|
||||
label: repository.name,
|
||||
isSelected: !!dereferencesActiveRepositories.find(matches({ name: repository.name })),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
id="selection-of-active-public-helm-repository"
|
||||
placeholder="Repositories"
|
||||
isLoading={valuesAreLoading}
|
||||
isDisabled={valuesAreLoading}
|
||||
options={repositoryOptions}
|
||||
onChange={selectRepository}
|
||||
value={dereferencesPublicRepositories}
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
controlShouldRenderValue={false}
|
||||
className="box grow"
|
||||
themeName="lens"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const AddingOfPublicHelmRepository = withInjectables<Dependencies>(
|
||||
NonInjectedAddingOfPublicHelmRepository,
|
||||
|
||||
{
|
||||
getProps: (di) => ({
|
||||
publicRepositories: di.inject(publicHelmRepositoriesInjectable),
|
||||
activeRepositories: di.inject(activeHelmRepositoriesInjectable),
|
||||
selectRepository: di.inject(selectHelmRepositoryInjectable),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const formatOptionLabel = ({ value, isSelected }: SelectOption<HelmRepo>) => (
|
||||
<div className="flex gaps">
|
||||
<span>{value.name}</span>
|
||||
{isSelected && (
|
||||
<Icon
|
||||
small
|
||||
material="check"
|
||||
className="box right" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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 { sortBy } from "lodash/fp";
|
||||
import type { HelmRepo } from "../../../../../../../common/helm/helm-repo";
|
||||
import { customRequestPromise } from "../../../../../../../common/request";
|
||||
|
||||
const callForPublicHelmRepositoriesInjectable = getInjectable({
|
||||
id: "call-for-public-helm-repositories",
|
||||
|
||||
instantiate: () => async (): Promise<HelmRepo[]> => {
|
||||
const res = await customRequestPromise({
|
||||
uri: "https://github.com/lensapp/artifact-hub-repositories/releases/download/latest/repositories.json",
|
||||
json: true,
|
||||
resolveWithFullResponse: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const repositories = res.body as HelmRepo[];
|
||||
|
||||
return sortBy(repo => repo.name, repositories);
|
||||
},
|
||||
|
||||
causesSideEffects: true,
|
||||
});
|
||||
|
||||
export default callForPublicHelmRepositoriesInjectable;
|
||||
+21
@@ -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 } from "@ogre-tools/injectable";
|
||||
import { asyncComputed } from "@ogre-tools/injectable-react";
|
||||
import callForPublicHelmRepositoriesInjectable from "./call-for-public-helm-repositories.injectable";
|
||||
|
||||
const publicHelmRepositoriesInjectable = getInjectable({
|
||||
id: "public-helm-repositories",
|
||||
|
||||
instantiate: (di) => {
|
||||
const callForPublicHelmRepositories = di.inject(callForPublicHelmRepositoriesInjectable);
|
||||
|
||||
return asyncComputed(async () => {
|
||||
return await callForPublicHelmRepositories();
|
||||
}, []);
|
||||
},
|
||||
});
|
||||
|
||||
export default publicHelmRepositoriesInjectable;
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 addHelmRepositoryChannelInjectable from "../../../../../../../common/helm/add-helm-repository-channel.injectable";
|
||||
import type { HelmRepo } from "../../../../../../../common/helm/helm-repo";
|
||||
import { requestFromChannelInjectionToken } from "../../../../../../../common/utils/channel/request-from-channel-injection-token";
|
||||
import activeHelmRepositoriesInjectable from "../../active-helm-repositories.injectable";
|
||||
import showErrorNotificationInjectable from "../../../../../notifications/show-error-notification.injectable";
|
||||
import showSuccessNotificationInjectable from "../../../../../notifications/show-success-notification.injectable";
|
||||
|
||||
const addHelmRepositoryInjectable = getInjectable({
|
||||
id: "add-public-helm-repository",
|
||||
|
||||
instantiate: (di) => {
|
||||
const requestFromChannel = di.inject(requestFromChannelInjectionToken);
|
||||
const addHelmRepositoryChannel = di.inject(addHelmRepositoryChannelInjectable);
|
||||
const activeHelmRepositories = di.inject(activeHelmRepositoriesInjectable);
|
||||
const showErrorNotification = di.inject(showErrorNotificationInjectable);
|
||||
const showSuccessNotification = di.inject(showSuccessNotificationInjectable);
|
||||
|
||||
return async (repository: HelmRepo) => {
|
||||
const result = await requestFromChannel(
|
||||
addHelmRepositoryChannel,
|
||||
repository,
|
||||
);
|
||||
|
||||
if (result.callWasSuccessful) {
|
||||
showSuccessNotification(
|
||||
`Helm repository ${repository.name} has been added.`,
|
||||
);
|
||||
|
||||
activeHelmRepositories.invalidate();
|
||||
} else {
|
||||
showErrorNotification(result.error);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default addHelmRepositoryInjectable;
|
||||
+33
@@ -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 } from "@ogre-tools/injectable";
|
||||
import addHelmRepositoryInjectable from "./add-helm-repository.injectable";
|
||||
import type { SelectOption } from "../../../../../select";
|
||||
import type { HelmRepo } from "../../../../../../../common/helm/helm-repo";
|
||||
import type { SingleValue } from "react-select";
|
||||
import removeHelmRepositoryInjectable from "../../remove-helm-repository.injectable";
|
||||
|
||||
const selectHelmRepositoryInjectable = getInjectable({
|
||||
id: "select-helm-repository",
|
||||
|
||||
instantiate: (di) => {
|
||||
const addHelmRepository = di.inject(addHelmRepositoryInjectable);
|
||||
const removeHelmRepository = di.inject(removeHelmRepositoryInjectable);
|
||||
|
||||
return (selected: SingleValue<SelectOption<HelmRepo>>) => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selected.isSelected) {
|
||||
addHelmRepository(selected.value);
|
||||
} else {
|
||||
removeHelmRepository(selected.value);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default selectHelmRepositoryInjectable;
|
||||
File renamed without changes.
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { HelmRepositories } from "./helm-repositories";
|
||||
import { AddingOfPublicHelmRepository } from "./adding-of-public-helm-repository/adding-of-public-helm-repository";
|
||||
import { AddingOfCustomHelmRepositoryOpenButton } from "./adding-of-custom-helm-repository/adding-of-custom-helm-repository-open-button";
|
||||
import { AddingOfCustomHelmRepositoryDialog } from "./adding-of-custom-helm-repository/adding-of-custom-helm-repository-dialog";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import type { HelmRepositoriesErrorState } from "./helm-repositories-error-state.injectable";
|
||||
import helmRepositoriesErrorStateInjectable from "./helm-repositories-error-state.injectable";
|
||||
import type { IObservableValue } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { Notice } from "../../../+extensions/notice";
|
||||
|
||||
interface Dependencies {
|
||||
helmRepositoriesErrorState: IObservableValue<HelmRepositoriesErrorState>;
|
||||
}
|
||||
|
||||
const NonInjectedHelmCharts = observer(
|
||||
({ helmRepositoriesErrorState }: Dependencies) => {
|
||||
const state = helmRepositoriesErrorState.get();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!state.controlsAreShown && (
|
||||
<Notice>
|
||||
<div className="flex-grow text-center">{state.errorMessage}</div>
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{state.controlsAreShown && (
|
||||
<div data-testid="helm-controls">
|
||||
<div className="flex gaps">
|
||||
<AddingOfPublicHelmRepository />
|
||||
|
||||
<AddingOfCustomHelmRepositoryOpenButton />
|
||||
</div>
|
||||
|
||||
<HelmRepositories />
|
||||
|
||||
<AddingOfCustomHelmRepositoryDialog />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const HelmCharts = withInjectables<Dependencies>(
|
||||
NonInjectedHelmCharts,
|
||||
|
||||
{
|
||||
getProps: (di) => ({
|
||||
helmRepositoriesErrorState: di.inject(helmRepositoriesErrorStateInjectable),
|
||||
}),
|
||||
},
|
||||
);
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
export type HelmRepositoriesErrorState =
|
||||
| { controlsAreShown: true }
|
||||
| { controlsAreShown: false; errorMessage: string };
|
||||
|
||||
const helmRepositoriesErrorStateInjectable = getInjectable({
|
||||
id: "helm-repositories-error-state",
|
||||
|
||||
instantiate: () =>
|
||||
observable.box<HelmRepositoriesErrorState>({ controlsAreShown: true }),
|
||||
});
|
||||
|
||||
export default helmRepositoriesErrorStateInjectable;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./helm-charts.module.scss";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import activeHelmRepositoriesInjectable from "./active-helm-repositories.injectable";
|
||||
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import { Spinner } from "../../../spinner";
|
||||
import type { HelmRepo } from "../../../../../common/helm/helm-repo";
|
||||
import { RemovableItem } from "../../removable-item";
|
||||
import removeHelmRepositoryInjectable from "./remove-helm-repository.injectable";
|
||||
|
||||
interface Dependencies {
|
||||
activeHelmRepositories: IAsyncComputed<HelmRepo[]>;
|
||||
removeRepository: (repository: HelmRepo) => Promise<void>;
|
||||
}
|
||||
|
||||
const NonInjectedActiveHelmRepositories = observer(({ activeHelmRepositories, removeRepository }: Dependencies) => {
|
||||
if (activeHelmRepositories.pending.get()) {
|
||||
return (
|
||||
<div className={styles.repos}>
|
||||
<div className="pt-5 relative">
|
||||
<Spinner center data-testid="helm-repositories-are-loading" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const repositories = activeHelmRepositories.value.get();
|
||||
|
||||
return (
|
||||
<div className={styles.repos}>
|
||||
{repositories.map((repository) => (
|
||||
<RemovableItem
|
||||
key={repository.name}
|
||||
onRemove={() => removeRepository(repository)}
|
||||
className="mt-3"
|
||||
data-testid={`remove-helm-repository-${repository.name}`}
|
||||
>
|
||||
<div data-testid={`helm-repository-${repository.name}`} className={styles.repoName}>
|
||||
{repository.name}
|
||||
</div>
|
||||
|
||||
<div className={styles.repoUrl}>{repository.url}</div>
|
||||
</RemovableItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
export const HelmRepositories = withInjectables<Dependencies>(
|
||||
NonInjectedActiveHelmRepositories,
|
||||
|
||||
{
|
||||
getProps: (di) => ({
|
||||
activeHelmRepositories: di.inject(activeHelmRepositoriesInjectable),
|
||||
removeRepository: di.inject(removeHelmRepositoryInjectable),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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 type { HelmRepo } from "../../../../../common/helm/helm-repo";
|
||||
import { requestFromChannelInjectionToken } from "../../../../../common/utils/channel/request-from-channel-injection-token";
|
||||
import activeHelmRepositoriesInjectable from "./active-helm-repositories.injectable";
|
||||
import removeHelmRepositoryChannelInjectable from "../../../../../common/helm/remove-helm-repository-channel.injectable";
|
||||
|
||||
const removePublicHelmRepositoryInjectable = getInjectable({
|
||||
id: "remove-public-helm-repository",
|
||||
|
||||
instantiate: (di) => {
|
||||
const requestFromChannel = di.inject(requestFromChannelInjectionToken);
|
||||
const removeHelmRepositoryChannel = di.inject(removeHelmRepositoryChannelInjectable);
|
||||
const activeHelmRepositories = di.inject(activeHelmRepositoriesInjectable);
|
||||
|
||||
return async (repository: HelmRepo) => {
|
||||
await requestFromChannel(removeHelmRepositoryChannel, repository);
|
||||
|
||||
activeHelmRepositories.invalidate();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default removePublicHelmRepositoryInjectable;
|
||||
@@ -14,9 +14,10 @@ export interface RemovableItemProps extends DOMAttributes<any>{
|
||||
icon?: string;
|
||||
onRemove: () => void;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function RemovableItem({ icon, onRemove, children, className, ...rest }: RemovableItemProps) {
|
||||
export function RemovableItem({ icon, onRemove, children, className, "data-testid": testId, ...rest }: RemovableItemProps) {
|
||||
return (
|
||||
<div className={cssNames(styles.item, "flex gaps align-center justify-space-between", className)} {...rest}>
|
||||
{icon && (
|
||||
@@ -27,6 +28,7 @@ export function RemovableItem({ icon, onRemove, children, className, ...rest }:
|
||||
material="delete"
|
||||
onClick={onRemove}
|
||||
tooltip="Remove"
|
||||
data-testid={testId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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 { AsyncInputValidationError, inputValidator } from "../input_validators";
|
||||
import pathExistsInjectable from "../../../../common/fs/path-exists.injectable";
|
||||
|
||||
const isPathInjectable = getInjectable({
|
||||
id: "is-path",
|
||||
|
||||
instantiate: (di) => {
|
||||
const pathExists = di.inject(pathExistsInjectable);
|
||||
|
||||
return inputValidator<true>({
|
||||
debounce: 100,
|
||||
condition: ({ type }) => type === "text",
|
||||
|
||||
validate: async (value) => {
|
||||
try {
|
||||
await pathExists(value);
|
||||
} catch {
|
||||
throw new AsyncInputValidationError(
|
||||
`${value} is not a valid file path`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default isPathInjectable;
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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 type { NotificationMessage, Notification } from "./notifications.store";
|
||||
import { NotificationStatus } from "./notifications.store";
|
||||
import notificationsStoreInjectable from "./notifications-store.injectable";
|
||||
|
||||
const showErrorNotificationInjectable = getInjectable({
|
||||
id: "show-error-notification",
|
||||
|
||||
instantiate: (di) => {
|
||||
const notificationsStore = di.inject(notificationsStoreInjectable);
|
||||
|
||||
return (message: NotificationMessage, customOpts: Partial<Omit<Notification, "message">> = {}) =>
|
||||
notificationsStore.add({
|
||||
status: NotificationStatus.ERROR,
|
||||
timeout: 5000,
|
||||
message,
|
||||
...customOpts,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default showErrorNotificationInjectable;
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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 type { NotificationMessage, Notification } from "./notifications.store";
|
||||
import { NotificationStatus } from "./notifications.store";
|
||||
import notificationsStoreInjectable from "./notifications-store.injectable";
|
||||
|
||||
const showSuccessNotificationInjectable = getInjectable({
|
||||
id: "show-success-notification",
|
||||
|
||||
instantiate: (di) => {
|
||||
const notificationsStore = di.inject(notificationsStoreInjectable);
|
||||
|
||||
return (message: NotificationMessage, customOpts: Partial<Omit<Notification, "message">> = {}) =>
|
||||
notificationsStore.add({
|
||||
status: NotificationStatus.OK,
|
||||
timeout: 5000,
|
||||
message,
|
||||
...customOpts,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default showSuccessNotificationInjectable;
|
||||
@@ -233,7 +233,9 @@ class NonInjectedSelect<
|
||||
Menu: ({ className, ...props }) => (
|
||||
<WrappedMenu
|
||||
{...props}
|
||||
className={cssNames(menuClass, this.themeClass, className)}
|
||||
className={cssNames(menuClass, this.themeClass, className, {
|
||||
[`${inputId}-options`]: !!inputId,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Observer } from "mobx-react";
|
||||
import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable";
|
||||
import allowedResourcesInjectable from "../../../common/cluster-store/allowed-resources.injectable";
|
||||
import type { RenderResult } from "@testing-library/react";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { getByText, fireEvent } from "@testing-library/react";
|
||||
import type { KubeResource } from "../../../common/rbac";
|
||||
import { Sidebar } from "../layout/sidebar";
|
||||
import type { DiContainer } from "@ogre-tools/injectable";
|
||||
@@ -52,6 +52,9 @@ import { getDiForUnitTesting as getMainDi } from "../../../main/getDiForUnitTest
|
||||
import { overrideChannels } from "../../../test-utils/channel-fakes/override-channels";
|
||||
import type { TrayMenuItem } from "../../../main/tray/tray-menu-item/tray-menu-item-injection-token";
|
||||
import trayIconPathsInjectable from "../../../main/tray/tray-icon-path.injectable";
|
||||
import assert from "assert";
|
||||
import { openMenu } from "react-select-event";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
type Callback = (dis: DiContainers) => void | Promise<void>;
|
||||
|
||||
@@ -85,6 +88,11 @@ export interface ApplicationBuilder {
|
||||
helmCharts: {
|
||||
navigate: () => void;
|
||||
};
|
||||
|
||||
select: {
|
||||
openMenu: (id: string) => void;
|
||||
selectOption: (menuId: string, labelText: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface DiContainers {
|
||||
@@ -433,6 +441,30 @@ export const getApplicationBuilder = () => {
|
||||
|
||||
return rendered;
|
||||
},
|
||||
|
||||
select: {
|
||||
openMenu: (menuId) => {
|
||||
const selector = rendered.container.querySelector<HTMLElement>(
|
||||
`#${menuId}`,
|
||||
);
|
||||
|
||||
assert(selector);
|
||||
|
||||
openMenu(selector);
|
||||
},
|
||||
|
||||
selectOption: (menuId, labelText) => {
|
||||
const menuOptions = rendered.baseElement.querySelector<HTMLElement>(
|
||||
`.${menuId}-options`,
|
||||
);
|
||||
|
||||
assert(menuOptions);
|
||||
|
||||
const option = getByText(menuOptions, labelText);
|
||||
|
||||
userEvent.click(option);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -129,6 +129,8 @@ export interface WizardStepProps<D> extends WizardCommonProps<D> {
|
||||
skip?: boolean; // don't render the step
|
||||
scrollable?: boolean;
|
||||
children?: React.ReactNode | React.ReactNode[];
|
||||
testIdForNext?: string;
|
||||
testIdForPrev?: string;
|
||||
}
|
||||
|
||||
interface WizardStepState {
|
||||
@@ -214,7 +216,7 @@ export class WizardStep<D> extends React.Component<WizardStepProps<D>, WizardSte
|
||||
step, isFirst, isLast, children,
|
||||
loading, customButtons, disabledNext, scrollable,
|
||||
hideNextBtn, hideBackBtn, beforeContent, afterContent, noValidate, skip, moreButtons,
|
||||
waiting, className, contentClass, prevLabel, nextLabel,
|
||||
waiting, className, contentClass, prevLabel, nextLabel, testIdForNext, testIdForPrev,
|
||||
} = this.props;
|
||||
|
||||
if (skip) {
|
||||
@@ -242,6 +244,7 @@ export class WizardStep<D> extends React.Component<WizardStepProps<D>, WizardSte
|
||||
label={prevLabel || (isFirst?.() ? "Cancel" : "Back")}
|
||||
hidden={hideBackBtn}
|
||||
onClick={this.prev}
|
||||
data-testid={testIdForPrev}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
@@ -250,6 +253,7 @@ export class WizardStep<D> extends React.Component<WizardStepProps<D>, WizardSte
|
||||
hidden={hideNextBtn}
|
||||
waiting={waiting ?? this.state.waiting}
|
||||
disabled={disabledNext}
|
||||
data-testid={testIdForNext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
import glob from "glob";
|
||||
import { memoize, noop } from "lodash/fp";
|
||||
import { createContainer } from "@ogre-tools/injectable";
|
||||
import type {
|
||||
DiContainer,
|
||||
Injectable } from "@ogre-tools/injectable";
|
||||
import {
|
||||
createContainer,
|
||||
} from "@ogre-tools/injectable";
|
||||
import { Environments, setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
|
||||
import requestFromChannelInjectable from "./utils/channel/request-from-channel.injectable";
|
||||
import loggerInjectable from "../common/logger.injectable";
|
||||
@@ -45,6 +50,8 @@ import appVersionInjectable from "../common/get-configuration-file-model/app-ver
|
||||
import provideInitialValuesForSyncBoxesInjectable from "./utils/sync-box/provide-initial-values-for-sync-boxes.injectable";
|
||||
import requestAnimationFrameInjectable from "./components/animate/request-animation-frame.injectable";
|
||||
import getRandomIdInjectable from "../common/utils/get-random-id.injectable";
|
||||
import getFilePathsInjectable from "./components/+preferences/kubernetes/helm-charts/adding-of-custom-helm-repository/helm-file-input/get-file-paths.injectable";
|
||||
import callForPublicHelmRepositoriesInjectable from "./components/+preferences/kubernetes/helm-charts/adding-of-public-helm-repository/public-helm-repositories/call-for-public-helm-repositories.injectable";
|
||||
|
||||
export const getDiForUnitTesting = (opts: { doGeneralOverrides?: boolean } = {}) => {
|
||||
const {
|
||||
@@ -91,9 +98,11 @@ export const getDiForUnitTesting = (opts: { doGeneralOverrides?: boolean } = {})
|
||||
on: () => {},
|
||||
}) as unknown as IpcRenderer);
|
||||
|
||||
di.override(broadcastMessageInjectable, () => () => {
|
||||
throw new Error("Tried to broadcast message over IPC without explicit override.");
|
||||
});
|
||||
overrideFunctionalInjectables(di, [
|
||||
broadcastMessageInjectable,
|
||||
getFilePathsInjectable,
|
||||
callForPublicHelmRepositoriesInjectable,
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars-ts
|
||||
di.override(extensionsStoreInjectable, () => ({ isEnabled: ({ id, isBundled }) => false }) as ExtensionsStore);
|
||||
@@ -147,3 +156,11 @@ const getInjectableFilePaths = memoize(() => [
|
||||
...glob.sync("../common/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
|
||||
...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
|
||||
]);
|
||||
|
||||
const overrideFunctionalInjectables = (di: DiContainer, injectables: Injectable<any, any, any>[]) => {
|
||||
injectables.forEach(injectable => {
|
||||
di.override(injectable, () => () => {
|
||||
throw new Error(`Tried to run "${injectable.id}" without explicit override.`);
|
||||
});
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user