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:
parent
7451869c25
commit
b3fd2232b5
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,7 +372,9 @@ 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
|
||||||
this.extensionState.delete(extension.id);
|
if (this.extensionState.get(extension.id)?.state === "uninstalling") {
|
||||||
|
this.extensionState.delete(extension.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user