mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Installing extensions UI improvements (#1522)
Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
parent
73724b5a54
commit
7243dfdce4
@ -225,7 +225,6 @@
|
|||||||
"electron-devtools-installer": "^3.1.1",
|
"electron-devtools-installer": "^3.1.1",
|
||||||
"electron-updater": "^4.3.1",
|
"electron-updater": "^4.3.1",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"file-type": "^14.7.1",
|
|
||||||
"filenamify": "^4.1.0",
|
"filenamify": "^4.1.0",
|
||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^9.0.1",
|
||||||
"handlebars": "^4.7.6",
|
"handlebars": "^4.7.6",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import request from "request";
|
|||||||
export interface DownloadFileOptions {
|
export interface DownloadFileOptions {
|
||||||
url: string;
|
url: string;
|
||||||
gzip?: boolean;
|
gzip?: boolean;
|
||||||
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadFileTicket {
|
export interface DownloadFileTicket {
|
||||||
@ -11,9 +12,9 @@ export interface DownloadFileTicket {
|
|||||||
cancel(): void;
|
cancel(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
||||||
const fileChunks: Buffer[] = [];
|
const fileChunks: Buffer[] = [];
|
||||||
const req = request(url, { gzip });
|
const req = request(url, { gzip, timeout });
|
||||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
||||||
req.on("data", (chunk: Buffer) => {
|
req.on("data", (chunk: Buffer) => {
|
||||||
fileChunks.push(chunk);
|
fileChunks.push(chunk);
|
||||||
|
|||||||
@ -2,8 +2,7 @@ import React from "react";
|
|||||||
import { observable, autorun } from "mobx";
|
import { observable, autorun } from "mobx";
|
||||||
import { observer, disposeOnUnmount } from "mobx-react";
|
import { observer, disposeOnUnmount } from "mobx-react";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
import { Cluster } from "../../../../main/cluster";
|
||||||
import { Input } from "../../input";
|
import { Input, InputValidators } from "../../input";
|
||||||
import { isUrl } from "../../input/input_validators";
|
|
||||||
import { SubTitle } from "../../layout/sub-title";
|
import { SubTitle } from "../../layout/sub-title";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -41,7 +40,7 @@ export class ClusterProxySetting extends React.Component<Props> {
|
|||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
onBlur={this.save}
|
onBlur={this.save}
|
||||||
placeholder="http://<address>:<port>"
|
placeholder="http://<address>:<port>"
|
||||||
validators={isUrl}
|
validators={this.proxy ? InputValidators.isUrl : undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,61 +1,36 @@
|
|||||||
.Extensions {
|
.PageLayout.Extensions {
|
||||||
$spacing: $padding * 2;
|
$spacing: $padding * 2;
|
||||||
--width: 100%;
|
--width: 50%;
|
||||||
--max-width: auto;
|
|
||||||
|
|
||||||
.extensions-list {
|
h2 {
|
||||||
.extension {
|
margin-bottom: $padding;
|
||||||
--flex-gap: $padding / 3;
|
}
|
||||||
padding: $padding $spacing;
|
|
||||||
background: $colorVague;
|
|
||||||
border-radius: $radius;
|
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
.no-extensions {
|
||||||
margin-top: $spacing;
|
--flex-gap: #{$padding};
|
||||||
}
|
padding: $padding;
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: $font-size-small;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.extensions-info {
|
.install-extension {
|
||||||
|
margin: $spacing * 2 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.installed-extensions {
|
||||||
--flex-gap: #{$spacing};
|
--flex-gap: #{$spacing};
|
||||||
|
|
||||||
> .flex.gaps {
|
.extension {
|
||||||
--flex-gap: #{$padding};
|
padding: $padding $spacing;
|
||||||
}
|
background: $layoutBackground;
|
||||||
}
|
border-radius: $radius;
|
||||||
|
|
||||||
.extensions-path {
|
|
||||||
word-break: break-all;
|
|
||||||
|
|
||||||
&:hover code {
|
|
||||||
color: $textColorSecondary;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.Clipboard {
|
|
||||||
display: inline;
|
|
||||||
vertical-align: baseline;
|
|
||||||
font-size: $font-size-small;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $textColorSecondary;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.SearchInput {
|
.SearchInput {
|
||||||
--spacing: #{$padding};
|
--spacing: 10px;
|
||||||
width: 100%;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WizardLayout {
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.info-col {
|
|
||||||
flex: 0.6;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,7 @@ import { observer } from "mobx-react";
|
|||||||
import { t, Trans } from "@lingui/macro";
|
import { t, Trans } from "@lingui/macro";
|
||||||
import { _i18n } from "../../i18n";
|
import { _i18n } from "../../i18n";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { WizardLayout } from "../layout/wizard-layout";
|
import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } from "../input";
|
||||||
import { DropFileInput, Input, InputValidators, SearchInput } from "../input";
|
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import { SubTitle } from "../layout/sub-title";
|
import { SubTitle } from "../layout/sub-title";
|
||||||
import { PageLayout } from "../layout/page-layout";
|
import { PageLayout } from "../layout/page-layout";
|
||||||
@ -21,6 +20,8 @@ import { LensExtensionManifest, sanitizeExtensionName } from "../../../extension
|
|||||||
import { Notifications } from "../notifications";
|
import { Notifications } from "../notifications";
|
||||||
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
||||||
import { docsUrl } from "../../../common/vars";
|
import { docsUrl } from "../../../common/vars";
|
||||||
|
import { prevDefault } from "../../utils";
|
||||||
|
import { TooltipPosition } from "../tooltip";
|
||||||
|
|
||||||
interface InstallRequest {
|
interface InstallRequest {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -40,8 +41,16 @@ interface InstallRequestValidated extends InstallRequestPreloaded {
|
|||||||
@observer
|
@observer
|
||||||
export class Extensions extends React.Component {
|
export class Extensions extends React.Component {
|
||||||
private supportedFormats = [".tar", ".tgz"];
|
private supportedFormats = [".tar", ".tgz"];
|
||||||
|
|
||||||
|
private installPathValidator: InputValidator = {
|
||||||
|
message: <Trans>Invalid URL or absolute path</Trans>,
|
||||||
|
validate(value: string) {
|
||||||
|
return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@observable search = "";
|
@observable search = "";
|
||||||
@observable downloadUrl = "";
|
@observable installPath = "";
|
||||||
|
|
||||||
@computed get extensions() {
|
@computed get extensions() {
|
||||||
const searchText = this.search.toLowerCase();
|
const searchText = this.search.toLowerCase();
|
||||||
@ -87,25 +96,25 @@ export class Extensions extends React.Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
addExtensions = () => {
|
installFromUrlOrPath = async () => {
|
||||||
const { downloadUrl } = this;
|
const { installPath } = this;
|
||||||
if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) {
|
if (!installPath) return;
|
||||||
this.installFromUrl(downloadUrl);
|
const fileName = path.basename(installPath);
|
||||||
} else {
|
|
||||||
this.installFromSelectFileDialog();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
installFromUrl = async (url: string) => {
|
|
||||||
try {
|
try {
|
||||||
const { promise: filePromise } = downloadFile({ url });
|
// install via url
|
||||||
this.requestInstall([{
|
// fixme: improve error messages for non-tar-file URLs
|
||||||
fileName: path.basename(url),
|
if (InputValidators.isUrl.validate(installPath)) {
|
||||||
data: await filePromise,
|
const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ });
|
||||||
}]);
|
const data = await filePromise;
|
||||||
|
this.requestInstall({ fileName, data });
|
||||||
|
}
|
||||||
|
// otherwise installing from system path
|
||||||
|
else if (InputValidators.isPath.validate(installPath)) {
|
||||||
|
this.requestInstall({ fileName, filePath: installPath });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Notifications.error(
|
Notifications.error(
|
||||||
<p>Installation via URL has failed: <b>{String(err)}</b></p>
|
<p>Installation has failed: <b>{String(err)}</b></p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -198,7 +207,8 @@ export class Extensions extends React.Component {
|
|||||||
return validatedRequests;
|
return validatedRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestInstall(requests: InstallRequest[]) {
|
async requestInstall(init: InstallRequest | InstallRequest[]) {
|
||||||
|
const requests = Array.isArray(init) ? init : [init];
|
||||||
const preloadedRequests = await this.preloadExtensions(requests);
|
const preloadedRequests = await this.preloadExtensions(requests);
|
||||||
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
||||||
|
|
||||||
@ -265,49 +275,16 @@ export class Extensions extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderInfo() {
|
|
||||||
return (
|
|
||||||
<div className="extensions-info flex column gaps">
|
|
||||||
<h2>Lens Extensions</h2>
|
|
||||||
<div>
|
|
||||||
The features that Lens includes out-of-the-box are just the start.
|
|
||||||
Lens extensions let you add new features to your installation to support your workflow.
|
|
||||||
Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself.
|
|
||||||
Check out documentation to <a href={`${docsUrl}/latest/extensions/usage/`} target="_blank">learn more</a>.
|
|
||||||
</div>
|
|
||||||
<div className="install-extension flex column gaps">
|
|
||||||
<SubTitle title="Install extension:"/>
|
|
||||||
<Input
|
|
||||||
showErrorsAsTooltip={true}
|
|
||||||
className="box grow"
|
|
||||||
theme="round-black"
|
|
||||||
iconLeft="link"
|
|
||||||
placeholder={`URL to an extension package (${this.supportedFormats.join(", ")})`}
|
|
||||||
validators={InputValidators.isUrl}
|
|
||||||
value={this.downloadUrl}
|
|
||||||
onChange={v => this.downloadUrl = v}
|
|
||||||
onSubmit={this.addExtensions}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
primary
|
|
||||||
label="Install"
|
|
||||||
onClick={this.addExtensions}
|
|
||||||
/>
|
|
||||||
<p className="hint">
|
|
||||||
<Trans><b>Pro-Tip</b>: you can drag & drop extension's tarball here to request installation</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderExtensions() {
|
renderExtensions() {
|
||||||
const { extensions, extensionsPath, search } = this;
|
const { extensions, extensionsPath, search } = this;
|
||||||
if (!extensions.length) {
|
if (!extensions.length) {
|
||||||
return (
|
return (
|
||||||
<div className="flex align-center box grow justify-center gaps">
|
<div className="no-extensions flex box gaps justify-center">
|
||||||
{search && <Trans>No search results found</Trans>}
|
<Icon material="info"/>
|
||||||
{!search && <p><Trans>There are no extensions in</Trans> <code>{extensionsPath}</code></p>}
|
<div>
|
||||||
|
{search && <p>No search results found</p>}
|
||||||
|
{!search && <p>There are no extensions in <code>{extensionsPath}</code></p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -316,11 +293,11 @@ export class Extensions extends React.Component {
|
|||||||
const { name, description } = manifest;
|
const { name, description } = manifest;
|
||||||
return (
|
return (
|
||||||
<div key={extId} className="extension flex gaps align-center">
|
<div key={extId} className="extension flex gaps align-center">
|
||||||
<div className="box grow flex column gaps">
|
<div className="box grow">
|
||||||
<div className="package">
|
<div className="name">
|
||||||
Name: <code className="name">{name}</code>
|
Name: <code className="name">{name}</code>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="description">
|
||||||
Description: <span className="text-secondary">{description}</span>
|
Description: <span className="text-secondary">{description}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -336,21 +313,64 @@ export class Extensions extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const topHeader = <h2>Manage Lens Extensions</h2>;
|
||||||
|
const { installPath } = this;
|
||||||
return (
|
return (
|
||||||
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
|
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
<PageLayout showOnTop className="Extensions flex column gaps" header={topHeader} contentGaps={false}>
|
||||||
<WizardLayout infoPanel={this.renderInfo()}>
|
<h2>Lens Extensions</h2>
|
||||||
|
<div>
|
||||||
|
The features that Lens includes out-of-the-box are just the start.
|
||||||
|
Lens extensions let you add new features to your installation to support your workflow.
|
||||||
|
Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself.
|
||||||
|
Check out documentation to <a href={`${docsUrl}/latest/extensions/usage/`} target="_blank">learn more</a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="install-extension flex column gaps">
|
||||||
|
<SubTitle title={<Trans>Install Extension:</Trans>}/>
|
||||||
|
<div className="extension-input flex box gaps align-center">
|
||||||
|
<Input
|
||||||
|
className="box grow"
|
||||||
|
theme="round-black"
|
||||||
|
placeholder={`Path or URL to an extension package (${this.supportedFormats.join(", ")})`}
|
||||||
|
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
|
||||||
|
validators={installPath ? this.installPathValidator : undefined}
|
||||||
|
value={installPath}
|
||||||
|
onChange={v => this.installPath = v}
|
||||||
|
onSubmit={this.installFromUrlOrPath}
|
||||||
|
iconLeft="link"
|
||||||
|
iconRight={
|
||||||
|
<Icon
|
||||||
|
interactive
|
||||||
|
material="folder"
|
||||||
|
onMouseDown={prevDefault(this.installFromSelectFileDialog)}
|
||||||
|
tooltip={<Trans>Browse</Trans>}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
label="Install"
|
||||||
|
disabled={!this.installPathValidator.validate(installPath)}
|
||||||
|
onClick={this.installFromUrlOrPath}
|
||||||
|
/>
|
||||||
|
<small className="hint">
|
||||||
|
<Trans><b>Pro-Tip</b>: you can drag & drop extension's tarball-file to install</Trans>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Installed Extensions</h2>
|
||||||
|
<div className="installed-extensions flex column gaps">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
placeholder={_i18n._(t`Search installed extensions`)}
|
placeholder="Search extensions by name or description"
|
||||||
value={this.search}
|
value={this.search}
|
||||||
onChange={(value) => this.search = value}
|
onChange={(value) => this.search = value}
|
||||||
/>
|
/>
|
||||||
<div className="extensions-list">
|
{this.renderExtensions()}
|
||||||
{this.renderExtensions()}
|
</div>
|
||||||
</div>
|
</PageLayout>
|
||||||
</WizardLayout>
|
</DropFileInput>
|
||||||
</DropFileInput>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { isPath } from '../input/input_validators';
|
|
||||||
import { Checkbox } from '../checkbox';
|
import { Checkbox } from '../checkbox';
|
||||||
import { Input } from '../input';
|
import { Input, InputValidators } from '../input';
|
||||||
import { SubTitle } from '../layout/sub-title';
|
import { SubTitle } from '../layout/sub-title';
|
||||||
import { UserPreferences, userStore } from '../../../common/user-store';
|
import { UserPreferences, userStore } from '../../../common/user-store';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
@ -12,6 +11,7 @@ import { SelectOption, Select } from '../select';
|
|||||||
export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => {
|
export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => {
|
||||||
const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || "");
|
const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || "");
|
||||||
const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || "");
|
const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || "");
|
||||||
|
const pathValidator = downloadPath ? InputValidators.isPath : undefined;
|
||||||
|
|
||||||
const downloadMirrorOptions: SelectOption<string>[] = [
|
const downloadMirrorOptions: SelectOption<string>[] = [
|
||||||
{ value: "default", label: "Default (Google)" },
|
{ value: "default", label: "Default (Google)" },
|
||||||
@ -47,7 +47,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre
|
|||||||
theme="round-black"
|
theme="round-black"
|
||||||
value={downloadPath}
|
value={downloadPath}
|
||||||
placeholder={userStore.getDefaultKubectlPath()}
|
placeholder={userStore.getDefaultKubectlPath()}
|
||||||
validators={isPath}
|
validators={pathValidator}
|
||||||
onChange={setDownloadPath}
|
onChange={setDownloadPath}
|
||||||
onBlur={save}
|
onBlur={save}
|
||||||
disabled={!preferences.downloadKubectlBinaries}
|
disabled={!preferences.downloadKubectlBinaries}
|
||||||
@ -60,7 +60,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre
|
|||||||
theme="round-black"
|
theme="round-black"
|
||||||
placeholder={bundledKubectlPath()}
|
placeholder={bundledKubectlPath()}
|
||||||
value={binariesPath}
|
value={binariesPath}
|
||||||
validators={isPath}
|
validators={pathValidator}
|
||||||
onChange={setBinariesPath}
|
onChange={setBinariesPath}
|
||||||
onBlur={save}
|
onBlur={save}
|
||||||
disabled={preferences.downloadKubectlBinaries}
|
disabled={preferences.downloadKubectlBinaries}
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export const PodLogSearch = observer((props: PodLogSearchProps) => {
|
|||||||
<SearchInput
|
<SearchInput
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
closeIcon={false}
|
showClearIcon={false}
|
||||||
contentRight={totalFinds > 0 && findCounts}
|
contentRight={totalFinds > 0 && findCounts}
|
||||||
onClear={onClear}
|
onClear={onClear}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export class DropFileInput<T extends HTMLElement = any> extends React.Component<
|
|||||||
const isValidContentElem = React.isValidElement(contentElem);
|
const isValidContentElem = React.isValidElement(contentElem);
|
||||||
if (isValidContentElem) {
|
if (isValidContentElem) {
|
||||||
const contentElemProps: React.HTMLProps<HTMLElement> = {
|
const contentElemProps: React.HTMLProps<HTMLElement> = {
|
||||||
className: cssNames("DropFileInput", className, {
|
className: cssNames("DropFileInput", contentElem.props.className, className, {
|
||||||
droppable: this.dropAreaActive,
|
droppable: this.dropAreaActive,
|
||||||
}),
|
}),
|
||||||
onDragEnter,
|
onDragEnter,
|
||||||
|
|||||||
@ -89,8 +89,10 @@
|
|||||||
|
|
||||||
&.theme {
|
&.theme {
|
||||||
&.round-black {
|
&.round-black {
|
||||||
&.invalid label {
|
&.invalid.dirty {
|
||||||
border-color: $colorSoftError !important;
|
label {
|
||||||
|
border-color: $colorSoftError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import "./input.scss";
|
|||||||
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||||
import { autobind, cssNames, debouncePromise, getRandId } from "../../utils";
|
import { autobind, cssNames, debouncePromise, getRandId } from "../../utils";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
|
import { Tooltip, TooltipProps } from "../tooltip";
|
||||||
import * as Validators from "./input_validators";
|
import * as Validators from "./input_validators";
|
||||||
import { InputValidator } from "./input_validators";
|
import { InputValidator } from "./input_validators";
|
||||||
import isString from "lodash/isString";
|
import isString from "lodash/isString";
|
||||||
import isFunction from "lodash/isFunction";
|
import isFunction from "lodash/isFunction";
|
||||||
import isBoolean from "lodash/isBoolean";
|
import isBoolean from "lodash/isBoolean";
|
||||||
import uniqueId from "lodash/uniqueId";
|
import uniqueId from "lodash/uniqueId";
|
||||||
import { Tooltip } from "../tooltip";
|
|
||||||
|
|
||||||
const { conditionalValidators, ...InputValidators } = Validators;
|
const { conditionalValidators, ...InputValidators } = Validators;
|
||||||
export { InputValidators, InputValidator };
|
export { InputValidators, InputValidator };
|
||||||
@ -26,7 +26,7 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
|
|||||||
maxRows?: number; // when multiLine={true} define max rows size
|
maxRows?: number; // when multiLine={true} define max rows size
|
||||||
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
||||||
showValidationLine?: boolean; // show animated validation line for async validators
|
showValidationLine?: boolean; // show animated validation line for async validators
|
||||||
showErrorsAsTooltip?: boolean; // show validation errors as a tooltip :hover (instead of block below)
|
showErrorsAsTooltip?: boolean | Omit<TooltipProps, "targetId">; // show validation errors as a tooltip :hover (instead of block below)
|
||||||
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
|
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
|
||||||
iconRight?: string | React.ReactNode;
|
iconRight?: string | React.ReactNode;
|
||||||
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
||||||
@ -63,6 +63,10 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
isValid() {
|
||||||
|
return this.state.valid;
|
||||||
|
}
|
||||||
|
|
||||||
setValue(value: string) {
|
setValue(value: string) {
|
||||||
if (value !== this.getValue()) {
|
if (value !== this.getValue()) {
|
||||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
|
||||||
@ -268,7 +272,8 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip,
|
multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip,
|
||||||
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight,
|
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id,
|
||||||
|
dirty: _dirty, // excluded from passing to input-element
|
||||||
...inputProps
|
...inputProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { focused, dirty, valid, validating, errors } = this.state;
|
const { focused, dirty, valid, validating, errors } = this.state;
|
||||||
@ -294,29 +299,35 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
ref: this.bindRef,
|
ref: this.bindRef,
|
||||||
spellCheck: "false",
|
spellCheck: "false",
|
||||||
});
|
});
|
||||||
const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
|
|
||||||
const showErrors = errors.length > 0 && !valid && dirty;
|
const showErrors = errors.length > 0 && !valid && dirty;
|
||||||
const errorsInfo = (
|
const errorsInfo = (
|
||||||
<div className="errors box grow">
|
<div className="errors box grow">
|
||||||
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
|
||||||
|
let tooltipError: React.ReactNode;
|
||||||
|
if (showErrorsAsTooltip && showErrors) {
|
||||||
|
const tooltipProps = typeof showErrorsAsTooltip === "object" ? showErrorsAsTooltip : {};
|
||||||
|
tooltipProps.className = cssNames("InputTooltipError", tooltipProps.className);
|
||||||
|
tooltipError = (
|
||||||
|
<Tooltip targetId={componentId} {...tooltipProps}>
|
||||||
|
<div className="flex gaps align-center">
|
||||||
|
<Icon material="error_outline"/>
|
||||||
|
{errorsInfo}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div id={tooltipId} className={className}>
|
<div id={componentId} className={className}>
|
||||||
|
{tooltipError}
|
||||||
<label className="input-area flex gaps align-center" id="">
|
<label className="input-area flex gaps align-center" id="">
|
||||||
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
||||||
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
||||||
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
||||||
{contentRight}
|
{contentRight}
|
||||||
</label>
|
</label>
|
||||||
{showErrorsAsTooltip && showErrors && (
|
|
||||||
<Tooltip targetId={tooltipId} className="InputTooltipError">
|
|
||||||
<div className="flex gaps align-center">
|
|
||||||
<Icon material="error_outline"/>
|
|
||||||
{errorsInfo}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<div className="input-info flex gaps">
|
<div className="input-info flex gaps">
|
||||||
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
||||||
{this.showMaxLenIndicator && (
|
{this.showMaxLenIndicator && (
|
||||||
|
|||||||
@ -39,13 +39,13 @@ export const isNumber: InputValidator = {
|
|||||||
export const isUrl: InputValidator = {
|
export const isUrl: InputValidator = {
|
||||||
condition: ({ type }) => type === "url",
|
condition: ({ type }) => type === "url",
|
||||||
message: () => _i18n._(t`Wrong url format`),
|
message: () => _i18n._(t`Wrong url format`),
|
||||||
validate: value => !!value.match(/^$|^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
|
validate: value => !!value.match(/^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isPath: InputValidator = {
|
export const isPath: InputValidator = {
|
||||||
condition: ({ type }) => type === "text",
|
condition: ({ type }) => type === "text",
|
||||||
message: () => _i18n._(t`This field must be a valid path`),
|
message: () => _i18n._(t`This field must be a valid path`),
|
||||||
validate: value => !value || fse.pathExistsSync(value),
|
validate: value => value && fse.pathExistsSync(value),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const minLength: InputValidator = {
|
export const minLength: InputValidator = {
|
||||||
|
|||||||
@ -10,13 +10,15 @@ import { Input, InputProps } from "./input";
|
|||||||
|
|
||||||
interface Props extends InputProps {
|
interface Props extends InputProps {
|
||||||
compact?: boolean; // show only search-icon when not focused
|
compact?: boolean; // show only search-icon when not focused
|
||||||
closeIcon?: boolean;
|
bindGlobalFocusHotkey?: boolean;
|
||||||
onClear?: () => void;
|
showClearIcon?: boolean;
|
||||||
|
onClear?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProps: Partial<Props> = {
|
const defaultProps: Partial<Props> = {
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
closeIcon: true,
|
bindGlobalFocusHotkey: true,
|
||||||
|
showClearIcon: true,
|
||||||
get placeholder() {
|
get placeholder() {
|
||||||
return _i18n._(t`Search...`);
|
return _i18n._(t`Search...`);
|
||||||
},
|
},
|
||||||
@ -26,27 +28,27 @@ const defaultProps: Partial<Props> = {
|
|||||||
export class SearchInput extends React.Component<Props> {
|
export class SearchInput extends React.Component<Props> {
|
||||||
static defaultProps = defaultProps as object;
|
static defaultProps = defaultProps as object;
|
||||||
|
|
||||||
private input = createRef<Input>();
|
private inputRef = createRef<Input>();
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
addEventListener("keydown", this.focus);
|
if (!this.props.bindGlobalFocusHotkey) return;
|
||||||
|
window.addEventListener("keydown", this.onGlobalKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
removeEventListener("keydown", this.focus);
|
window.removeEventListener("keydown", this.onGlobalKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear = () => {
|
@autobind()
|
||||||
if (this.props.onClear) {
|
onGlobalKey(evt: KeyboardEvent) {
|
||||||
this.props.onClear();
|
const meta = evt.metaKey || evt.ctrlKey;
|
||||||
|
if (meta && evt.key === "f") {
|
||||||
|
this.inputRef.current.focus();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
onChange = (val: string, evt: React.ChangeEvent<any>) => {
|
@autobind()
|
||||||
this.props.onChange(val, evt);
|
onKeyDown(evt: React.KeyboardEvent<any>) {
|
||||||
};
|
|
||||||
|
|
||||||
onKeyDown = (evt: React.KeyboardEvent<any>) => {
|
|
||||||
if (this.props.onKeyDown) {
|
if (this.props.onKeyDown) {
|
||||||
this.props.onKeyDown(evt);
|
this.props.onKeyDown(evt);
|
||||||
}
|
}
|
||||||
@ -56,29 +58,31 @@ export class SearchInput extends React.Component<Props> {
|
|||||||
this.clear();
|
this.clear();
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
@autobind()
|
@autobind()
|
||||||
focus(evt: KeyboardEvent) {
|
clear() {
|
||||||
const meta = evt.metaKey || evt.ctrlKey;
|
if (this.props.onClear) {
|
||||||
if (meta && evt.key == "f") {
|
this.props.onClear();
|
||||||
this.input.current.focus();
|
} else {
|
||||||
|
this.inputRef.current.setValue("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { className, compact, closeIcon, onClear, ...inputProps } = this.props;
|
const { className, compact, onClear, showClearIcon, bindGlobalFocusHotkey, value, ...inputProps } = this.props;
|
||||||
const icon = this.props.value
|
let rightIcon = <Icon small material="search"/>;
|
||||||
? closeIcon ? <Icon small material="close" onClick={this.clear}/> : null
|
if (showClearIcon && value) {
|
||||||
: <Icon small material="search"/>;
|
rightIcon = <Icon small material="close" onClick={this.clear}/>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
className={cssNames("SearchInput", className, { compact })}
|
className={cssNames("SearchInput", className, { compact })}
|
||||||
onChange={this.onChange}
|
value={value}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
iconRight={icon}
|
iconRight={rightIcon}
|
||||||
ref={this.input}
|
ref={this.inputRef}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
44
yarn.lock
44
yarn.lock
@ -1724,11 +1724,6 @@
|
|||||||
"@babel/runtime" "^7.11.2"
|
"@babel/runtime" "^7.11.2"
|
||||||
"@testing-library/dom" "^7.26.0"
|
"@testing-library/dom" "^7.26.0"
|
||||||
|
|
||||||
"@tokenizer/token@^0.1.0", "@tokenizer/token@^0.1.1":
|
|
||||||
version "0.1.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.1.1.tgz#f0d92c12f87079ddfd1b29f614758b9696bc29e3"
|
|
||||||
integrity sha512-XO6INPbZCxdprl+9qa/AAbFFOMzzwqYxpjPgLICrMD6C2FCw6qfJOPcBk6JqqPLSaZ/Qx87qn4rpPmPMwaAK6w==
|
|
||||||
|
|
||||||
"@types/anymatch@*":
|
"@types/anymatch@*":
|
||||||
version "1.3.1"
|
version "1.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a"
|
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a"
|
||||||
@ -6281,16 +6276,6 @@ file-loader@^6.0.0:
|
|||||||
loader-utils "^2.0.0"
|
loader-utils "^2.0.0"
|
||||||
schema-utils "^2.6.5"
|
schema-utils "^2.6.5"
|
||||||
|
|
||||||
file-type@^14.7.1:
|
|
||||||
version "14.7.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/file-type/-/file-type-14.7.1.tgz#f748732b3e70478bff530e1cf0ec2fe33608b1bb"
|
|
||||||
integrity sha512-sXAMgFk67fQLcetXustxfKX+PZgHIUFn96Xld9uH8aXPdX3xOp0/jg9OdouVTvQrf7mrn+wAa4jN/y9fUOOiRA==
|
|
||||||
dependencies:
|
|
||||||
readable-web-to-node-stream "^2.0.0"
|
|
||||||
strtok3 "^6.0.3"
|
|
||||||
token-types "^2.0.0"
|
|
||||||
typedarray-to-buffer "^3.1.5"
|
|
||||||
|
|
||||||
file-uri-to-path@1.0.0:
|
file-uri-to-path@1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
|
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
|
||||||
@ -7409,7 +7394,7 @@ identity-obj-proxy@^3.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
harmony-reflect "^1.4.6"
|
harmony-reflect "^1.4.6"
|
||||||
|
|
||||||
ieee754@^1.1.13, ieee754@^1.1.4:
|
ieee754@^1.1.4:
|
||||||
version "1.1.13"
|
version "1.1.13"
|
||||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
|
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
|
||||||
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
||||||
@ -11272,11 +11257,6 @@ pbkdf2@^3.0.3:
|
|||||||
safe-buffer "^5.0.1"
|
safe-buffer "^5.0.1"
|
||||||
sha.js "^2.4.8"
|
sha.js "^2.4.8"
|
||||||
|
|
||||||
peek-readable@^3.1.0:
|
|
||||||
version "3.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-3.1.0.tgz#250b08b7de09db8573d7fd8ea475215bbff14348"
|
|
||||||
integrity sha512-KGuODSTV6hcgdZvDrIDBUkN0utcAVj1LL7FfGbM0viKTtCHmtZcuEJ+lGqsp0fTFkGqesdtemV2yUSMeyy3ddA==
|
|
||||||
|
|
||||||
pend@~1.2.0:
|
pend@~1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
|
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
|
||||||
@ -12119,11 +12099,6 @@ readable-stream@~1.1.10:
|
|||||||
isarray "0.0.1"
|
isarray "0.0.1"
|
||||||
string_decoder "~0.10.x"
|
string_decoder "~0.10.x"
|
||||||
|
|
||||||
readable-web-to-node-stream@^2.0.0:
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-2.0.0.tgz#751e632f466552ac0d5c440cc01470352f93c4b7"
|
|
||||||
integrity sha512-+oZJurc4hXpaaqsN68GoZGQAQIA3qr09Or4fqEsargABnbe5Aau8hFn6ISVleT3cpY/0n/8drn7huyyEvTbghA==
|
|
||||||
|
|
||||||
readdir-scoped-modules@^1.0.0, readdir-scoped-modules@^1.1.0:
|
readdir-scoped-modules@^1.0.0, readdir-scoped-modules@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
|
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
|
||||||
@ -13581,15 +13556,6 @@ strip-outer@^1.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp "^1.0.2"
|
escape-string-regexp "^1.0.2"
|
||||||
|
|
||||||
strtok3@^6.0.3:
|
|
||||||
version "6.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.0.4.tgz#ede0d20fde5aa9fda56417c3558eaafccc724694"
|
|
||||||
integrity sha512-rqWMKwsbN9APU47bQTMEYTPcwdpKDtmf1jVhHzNW2cL1WqAxaM9iBb9t5P2fj+RV2YsErUWgQzHD5JwV0uCTEQ==
|
|
||||||
dependencies:
|
|
||||||
"@tokenizer/token" "^0.1.1"
|
|
||||||
"@types/debug" "^4.1.5"
|
|
||||||
peek-readable "^3.1.0"
|
|
||||||
|
|
||||||
style-loader@^1.2.1:
|
style-loader@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.2.1.tgz#c5cbbfbf1170d076cfdd86e0109c5bba114baa1a"
|
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.2.1.tgz#c5cbbfbf1170d076cfdd86e0109c5bba114baa1a"
|
||||||
@ -13979,14 +13945,6 @@ toidentifier@1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
|
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
|
||||||
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
||||||
|
|
||||||
token-types@^2.0.0:
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/token-types/-/token-types-2.0.0.tgz#b23618af744818299c6fbf125e0fdad98bab7e85"
|
|
||||||
integrity sha512-WWvu8sGK8/ZmGusekZJJ5NM6rRVTTDO7/bahz4NGiSDb/XsmdYBn6a1N/bymUHuWYTWeuLUg98wUzvE4jPdCZw==
|
|
||||||
dependencies:
|
|
||||||
"@tokenizer/token" "^0.1.0"
|
|
||||||
ieee754 "^1.1.13"
|
|
||||||
|
|
||||||
touch@^3.1.0:
|
touch@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
|
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user