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

Disable Install button while installing. Fix install notification. (#1551)

Signed-off-by: Panu Horsmalahti <phorsmalahti@mirantis.com>
This commit is contained in:
Panu Horsmalahti 2020-11-27 16:05:28 +02:00 committed by GitHub
parent 7451869c25
commit b3fd2232b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 99 additions and 24 deletions

View File

@ -1,11 +1,22 @@
import '@testing-library/jest-dom/extend-expect'; import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import fse from "fs-extra";
import React from 'react'; import React from 'react';
import { extensionDiscovery } from "../../../../extensions/extension-discovery"; import { extensionDiscovery } from "../../../../extensions/extension-discovery";
import { ConfirmDialog } from "../../confirm-dialog"; import { ConfirmDialog } from "../../confirm-dialog";
import { Notifications } from "../../notifications"; import { Notifications } from "../../notifications";
import { Extensions } from "../extensions"; import { Extensions } from "../extensions";
jest.mock("fs-extra");
jest.mock("../../../../common/utils", () => ({
...jest.requireActual("../../../../common/utils"),
downloadFile: jest.fn(() => ({
promise: Promise.resolve()
})),
extractTar: jest.fn(() => Promise.resolve())
}));
jest.mock("../../../../extensions/extension-discovery", () => ({ jest.mock("../../../../extensions/extension-discovery", () => ({
...jest.requireActual("../../../../extensions/extension-discovery"), ...jest.requireActual("../../../../extensions/extension-discovery"),
extensionDiscovery: { extensionDiscovery: {
@ -70,10 +81,30 @@ describe("Extensions", () => {
// Approve confirm dialog // Approve confirm dialog
fireEvent.click(screen.getByText("Yes")); fireEvent.click(screen.getByText("Yes"));
setTimeout(() => { waitFor(() => {
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
expect(Notifications.error).toHaveBeenCalledTimes(1); expect(Notifications.error).toHaveBeenCalledTimes(1);
}, 100); });
});
it("disables install button while installing", () => {
render(<Extensions />);
fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", {
exact: false
}), {
target: {
value: "https://test.extensionurl/package.tgz"
}
});
fireEvent.click(screen.getByText("Install"));
waitFor(() => {
expect(screen.getByText("Install").closest("button")).toBeDisabled();
expect(fse.move).toHaveBeenCalledWith("");
expect(Notifications.error).not.toHaveBeenCalled();
});
}); });
}); });

View File

@ -42,7 +42,7 @@ interface InstallRequestValidated extends InstallRequestPreloaded {
interface ExtensionState { interface ExtensionState {
displayName: string; displayName: string;
// Possible states the extension can be // Possible states the extension can be
state: "uninstalling"; state: "installing" | "uninstalling";
} }
@observer @observer
@ -71,19 +71,31 @@ export class Extensions extends React.Component {
).map(([id, extension]) => ({ ...extension, id })); ).map(([id, extension]) => ({ ...extension, id }));
} }
/**
* Extensions that were added to extensions but are still in "installing" state
*/
@computed get addedInstalling() {
return Array.from(this.extensionState.entries()).filter(([id, extension]) =>
extension.state === "installing" && this.extensions.find(extension => extension.id === id)
).map(([id, extension]) => ({ ...extension, id }));
}
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, disposeOnUnmount(this,
reaction(() => this.extensions, (extensions) => { reaction(() => this.extensions, () => {
const removedUninstalling = this.removedUninstalling; this.removedUninstalling.forEach(({ id, displayName }) => {
removedUninstalling.forEach(({ displayName }) => {
Notifications.ok( Notifications.ok(
<p>Extension <b>{displayName}</b> successfully uninstalled!</p> <p>Extension <b>{displayName}</b> successfully uninstalled!</p>
); );
this.extensionState.delete(id);
}); });
removedUninstalling.forEach(({ id }) => { this.addedInstalling.forEach(({ id, displayName }) => {
Notifications.ok(
<p>Extension <b>{displayName}</b> successfully installed!</p>
);
this.extensionState.delete(id); this.extensionState.delete(id);
this.installPath = "";
}); });
}) })
); );
@ -91,6 +103,7 @@ export class Extensions extends React.Component {
@computed get extensions() { @computed get extensions() {
const searchText = this.search.toLowerCase(); const searchText = this.search.toLowerCase();
return Array.from(extensionLoader.userExtensions.values()).filter(ext => { return Array.from(extensionLoader.userExtensions.values()).filter(ext => {
const { name, description } = ext.manifest; const { name, description } = ext.manifest;
return [ return [
@ -123,6 +136,7 @@ export class Extensions extends React.Component {
{ name: "tarball", extensions: this.supportedFormats } { name: "tarball", extensions: this.supportedFormats }
] ]
}); });
if (!canceled && filePaths.length) { if (!canceled && filePaths.length) {
this.requestInstall( this.requestInstall(
filePaths.map(filePath => ({ filePaths.map(filePath => ({
@ -137,6 +151,7 @@ export class Extensions extends React.Component {
const { installPath } = this; const { installPath } = this;
if (!installPath) return; if (!installPath) return;
const fileName = path.basename(installPath); const fileName = path.basename(installPath);
try { try {
// install via url // install via url
// fixme: improve error messages for non-tar-file URLs // fixme: improve error messages for non-tar-file URLs
@ -172,13 +187,13 @@ export class Extensions extends React.Component {
await Promise.all( await Promise.all(
requests requests
.filter(req => !req.data && req.filePath) .filter(req => !req.data && req.filePath)
.map(req => { .map(request => {
return fse.readFile(req.filePath).then(data => { return fse.readFile(request.filePath).then(data => {
req.data = data; request.data = data;
preloadedRequests.push(req); preloadedRequests.push(request);
}).catch(error => { }).catch(error => {
if (showError) { if (showError) {
Notifications.error(`Error while reading "${req.filePath}": ${String(error)}`); Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`);
} }
}); });
}) })
@ -198,11 +213,13 @@ export class Extensions extends React.Component {
if (!tarFiles.includes(manifestLocation)) { if (!tarFiles.includes(manifestLocation)) {
throw new Error(`invalid extension bundle, ${manifestFilename} not found`); throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
} }
const manifest = await readFileFromTar<LensExtensionManifest>({ const manifest = await readFileFromTar<LensExtensionManifest>({
tarPath: filePath, tarPath: filePath,
filePath: manifestLocation, filePath: manifestLocation,
parseJson: true, parseJson: true,
}); });
if (!manifest.lens && !manifest.renderer) { if (!manifest.lens && !manifest.renderer) {
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
} }
@ -214,6 +231,7 @@ export class Extensions extends React.Component {
// copy files to temp // copy files to temp
await fse.ensureDir(this.getExtensionPackageTemp()); await fse.ensureDir(this.getExtensionPackageTemp());
requests.forEach(req => { requests.forEach(req => {
const tempFile = this.getExtensionPackageTemp(req.fileName); const tempFile = this.getExtensionPackageTemp(req.fileName);
fse.writeFileSync(tempFile, req.data); fse.writeFileSync(tempFile, req.data);
@ -225,6 +243,7 @@ export class Extensions extends React.Component {
const tempFile = this.getExtensionPackageTemp(req.fileName); const tempFile = this.getExtensionPackageTemp(req.fileName);
try { try {
const manifest = await this.validatePackage(tempFile); const manifest = await this.validatePackage(tempFile);
validatedRequests.push({ validatedRequests.push({
...req, ...req,
manifest, manifest,
@ -232,6 +251,7 @@ export class Extensions extends React.Component {
}); });
} catch (error) { } catch (error) {
fse.unlink(tempFile).catch(() => null); // remove invalid temp package fse.unlink(tempFile).catch(() => null); // remove invalid temp package
if (showErrors) { if (showErrors) {
Notifications.error( Notifications.error(
<div className="flex column gaps"> <div className="flex column gaps">
@ -255,6 +275,7 @@ export class Extensions extends React.Component {
const { name, version, description } = install.manifest; const { name, version, description } = install.manifest;
const extensionFolder = this.getExtensionDestFolder(name); const extensionFolder = this.getExtensionDestFolder(name);
const folderExists = fse.existsSync(extensionFolder); const folderExists = fse.existsSync(extensionFolder);
if (!folderExists) { if (!folderExists) {
// auto-install extension if not yet exists // auto-install extension if not yet exists
this.unpackExtension(install); this.unpackExtension(install);
@ -280,10 +301,18 @@ export class Extensions extends React.Component {
} }
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) { async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
const extName = extensionDisplayName(name, version); const displayName = extensionDisplayName(name, version);
logger.info(`Unpacking extension ${extName}`, { fileName, tempFile });
const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked");
const extensionFolder = this.getExtensionDestFolder(name); const extensionFolder = this.getExtensionDestFolder(name);
const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked");
const extensionId = path.join(extensionDiscovery.nodeModulesPath, name, "package.json");
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
this.extensionState.set(extensionId, {
state: "installing",
displayName
});
try { try {
// extract to temp folder first // extract to temp folder first
await fse.remove(unpackingTempFolder).catch(Function); await fse.remove(unpackingTempFolder).catch(Function);
@ -293,20 +322,23 @@ export class Extensions extends React.Component {
// move contents to extensions folder // move contents to extensions folder
const unpackedFiles = await fse.readdir(unpackingTempFolder); const unpackedFiles = await fse.readdir(unpackingTempFolder);
let unpackedRootFolder = unpackingTempFolder; let unpackedRootFolder = unpackingTempFolder;
if (unpackedFiles.length === 1) { if (unpackedFiles.length === 1) {
// check if %extension.tgz was packed with single top folder, // check if %extension.tgz was packed with single top folder,
// e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball
unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]);
} }
await fse.ensureDir(extensionFolder); await fse.ensureDir(extensionFolder);
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
Notifications.ok(
<p>Extension <b>{extName}</b> successfully installed!</p>
);
} catch (error) { } catch (error) {
Notifications.error( Notifications.error(
<p>Installing extension <b>{extName}</b> has failed: <em>{error}</em></p> <p>Installing extension <b>{displayName}</b> has failed: <em>{error}</em></p>
); );
// Remove install state on install failure
if (this.extensionState.get(extensionId)?.state === "installing") {
this.extensionState.delete(extensionId);
}
} finally { } finally {
// clean up // clean up
fse.remove(unpackingTempFolder).catch(Function); fse.remove(unpackingTempFolder).catch(Function);
@ -340,9 +372,11 @@ export class Extensions extends React.Component {
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p> <p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>
); );
// Remove uninstall state on uninstall failure // Remove uninstall state on uninstall failure
if (this.extensionState.get(extension.id)?.state === "uninstalling") {
this.extensionState.delete(extension.id); this.extensionState.delete(extension.id);
} }
} }
}
renderExtensions() { renderExtensions() {
const { extensions, extensionsPath, search } = this; const { extensions, extensionsPath, search } = this;
@ -394,9 +428,17 @@ export class Extensions extends React.Component {
}); });
} }
/**
* True if at least one extension is in installing state
*/
@computed get isInstalling() {
return [...this.extensionState.values()].some(extension => extension.state === "installing");
}
render() { render() {
const topHeader = <h2>Manage Lens Extensions</h2>; const topHeader = <h2>Manage Lens Extensions</h2>;
const { installPath } = this; const { installPath } = this;
return ( return (
<DropFileInput onDropFiles={this.installOnDrop}> <DropFileInput onDropFiles={this.installOnDrop}>
<PageLayout showOnTop className="Extensions flex column gaps" header={topHeader} contentGaps={false}> <PageLayout showOnTop className="Extensions flex column gaps" header={topHeader} contentGaps={false}>
@ -414,11 +456,12 @@ export class Extensions extends React.Component {
<Input <Input
className="box grow" className="box grow"
theme="round-black" theme="round-black"
disabled={this.isInstalling}
placeholder={`Path or URL to an extension package (${this.supportedFormats.join(", ")})`} placeholder={`Path or URL to an extension package (${this.supportedFormats.join(", ")})`}
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }} showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
validators={installPath ? this.installPathValidator : undefined} validators={installPath ? this.installPathValidator : undefined}
value={installPath} value={installPath}
onChange={v => this.installPath = v} onChange={value => this.installPath = value}
onSubmit={this.installFromUrlOrPath} onSubmit={this.installFromUrlOrPath}
iconLeft="link" iconLeft="link"
iconRight={ iconRight={
@ -434,7 +477,8 @@ export class Extensions extends React.Component {
<Button <Button
primary primary
label="Install" label="Install"
disabled={!this.installPathValidator.validate(installPath)} disabled={this.isInstalling || !this.installPathValidator.validate(installPath)}
waiting={this.isInstalling}
onClick={this.installFromUrlOrPath} onClick={this.installFromUrlOrPath}
/> />
<small className="hint"> <small className="hint">