mirror of
https://github.com/lensapp/lens.git
synced 2024-09-20 05:47:24 +03: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-updater": "^4.3.1",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"file-type": "^14.7.1",
|
||||
"filenamify": "^4.1.0",
|
||||
"fs-extra": "^9.0.1",
|
||||
"handlebars": "^4.7.6",
|
||||
|
@ -3,6 +3,7 @@ import request from "request";
|
||||
export interface DownloadFileOptions {
|
||||
url: string;
|
||||
gzip?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface DownloadFileTicket {
|
||||
@ -11,9 +12,9 @@ export interface DownloadFileTicket {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
||||
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
||||
const fileChunks: Buffer[] = [];
|
||||
const req = request(url, { gzip });
|
||||
const req = request(url, { gzip, timeout });
|
||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
fileChunks.push(chunk);
|
||||
|
@ -2,8 +2,7 @@ import React from "react";
|
||||
import { observable, autorun } from "mobx";
|
||||
import { observer, disposeOnUnmount } from "mobx-react";
|
||||
import { Cluster } from "../../../../main/cluster";
|
||||
import { Input } from "../../input";
|
||||
import { isUrl } from "../../input/input_validators";
|
||||
import { Input, InputValidators } from "../../input";
|
||||
import { SubTitle } from "../../layout/sub-title";
|
||||
|
||||
interface Props {
|
||||
@ -41,7 +40,7 @@ export class ClusterProxySetting extends React.Component<Props> {
|
||||
onChange={this.onChange}
|
||||
onBlur={this.save}
|
||||
placeholder="http://<address>:<port>"
|
||||
validators={isUrl}
|
||||
validators={this.proxy ? InputValidators.isUrl : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -1,61 +1,36 @@
|
||||
.Extensions {
|
||||
.PageLayout.Extensions {
|
||||
$spacing: $padding * 2;
|
||||
--width: 100%;
|
||||
--max-width: auto;
|
||||
--width: 50%;
|
||||
|
||||
.extensions-list {
|
||||
.extension {
|
||||
--flex-gap: $padding / 3;
|
||||
padding: $padding $spacing;
|
||||
background: $colorVague;
|
||||
border-radius: $radius;
|
||||
h2 {
|
||||
margin-bottom: $padding;
|
||||
}
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: $spacing;
|
||||
}
|
||||
.no-extensions {
|
||||
--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.gaps {
|
||||
--flex-gap: #{$padding};
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.extension {
|
||||
padding: $padding $spacing;
|
||||
background: $layoutBackground;
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
--spacing: #{$padding};
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.WizardLayout {
|
||||
padding: 0;
|
||||
|
||||
.info-col {
|
||||
flex: 0.6;
|
||||
align-self: flex-start;
|
||||
}
|
||||
--spacing: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,8 +9,7 @@ import { observer } from "mobx-react";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { Button } from "../button";
|
||||
import { WizardLayout } from "../layout/wizard-layout";
|
||||
import { DropFileInput, Input, InputValidators, SearchInput } from "../input";
|
||||
import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } from "../input";
|
||||
import { Icon } from "../icon";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { PageLayout } from "../layout/page-layout";
|
||||
@ -21,6 +20,8 @@ import { LensExtensionManifest, sanitizeExtensionName } from "../../../extension
|
||||
import { Notifications } from "../notifications";
|
||||
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
||||
import { docsUrl } from "../../../common/vars";
|
||||
import { prevDefault } from "../../utils";
|
||||
import { TooltipPosition } from "../tooltip";
|
||||
|
||||
interface InstallRequest {
|
||||
fileName: string;
|
||||
@ -40,8 +41,16 @@ interface InstallRequestValidated extends InstallRequestPreloaded {
|
||||
@observer
|
||||
export class Extensions extends React.Component {
|
||||
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 downloadUrl = "";
|
||||
@observable installPath = "";
|
||||
|
||||
@computed get extensions() {
|
||||
const searchText = this.search.toLowerCase();
|
||||
@ -87,25 +96,25 @@ export class Extensions extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
addExtensions = () => {
|
||||
const { downloadUrl } = this;
|
||||
if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) {
|
||||
this.installFromUrl(downloadUrl);
|
||||
} else {
|
||||
this.installFromSelectFileDialog();
|
||||
}
|
||||
};
|
||||
|
||||
installFromUrl = async (url: string) => {
|
||||
installFromUrlOrPath = async () => {
|
||||
const { installPath } = this;
|
||||
if (!installPath) return;
|
||||
const fileName = path.basename(installPath);
|
||||
try {
|
||||
const { promise: filePromise } = downloadFile({ url });
|
||||
this.requestInstall([{
|
||||
fileName: path.basename(url),
|
||||
data: await filePromise,
|
||||
}]);
|
||||
// install via url
|
||||
// fixme: improve error messages for non-tar-file URLs
|
||||
if (InputValidators.isUrl.validate(installPath)) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
async requestInstall(requests: InstallRequest[]) {
|
||||
async requestInstall(init: InstallRequest | InstallRequest[]) {
|
||||
const requests = Array.isArray(init) ? init : [init];
|
||||
const preloadedRequests = await this.preloadExtensions(requests);
|
||||
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() {
|
||||
const { extensions, extensionsPath, search } = this;
|
||||
if (!extensions.length) {
|
||||
return (
|
||||
<div className="flex align-center box grow justify-center gaps">
|
||||
{search && <Trans>No search results found</Trans>}
|
||||
{!search && <p><Trans>There are no extensions in</Trans> <code>{extensionsPath}</code></p>}
|
||||
<div className="no-extensions flex box gaps justify-center">
|
||||
<Icon material="info"/>
|
||||
<div>
|
||||
{search && <p>No search results found</p>}
|
||||
{!search && <p>There are no extensions in <code>{extensionsPath}</code></p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -316,11 +293,11 @@ export class Extensions extends React.Component {
|
||||
const { name, description } = manifest;
|
||||
return (
|
||||
<div key={extId} className="extension flex gaps align-center">
|
||||
<div className="box grow flex column gaps">
|
||||
<div className="package">
|
||||
<div className="box grow">
|
||||
<div className="name">
|
||||
Name: <code className="name">{name}</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="description">
|
||||
Description: <span className="text-secondary">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -336,21 +313,64 @@ export class Extensions extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const topHeader = <h2>Manage Lens Extensions</h2>;
|
||||
const { installPath } = this;
|
||||
return (
|
||||
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
|
||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||
<WizardLayout infoPanel={this.renderInfo()}>
|
||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||
<PageLayout showOnTop className="Extensions flex column gaps" header={topHeader} contentGaps={false}>
|
||||
<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
|
||||
placeholder={_i18n._(t`Search installed extensions`)}
|
||||
placeholder="Search extensions by name or description"
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
<div className="extensions-list">
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
</DropFileInput>
|
||||
</PageLayout>
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</DropFileInput>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { isPath } from '../input/input_validators';
|
||||
import { Checkbox } from '../checkbox';
|
||||
import { Input } from '../input';
|
||||
import { Input, InputValidators } from '../input';
|
||||
import { SubTitle } from '../layout/sub-title';
|
||||
import { UserPreferences, userStore } from '../../../common/user-store';
|
||||
import { observer } from 'mobx-react';
|
||||
@ -12,6 +11,7 @@ import { SelectOption, Select } from '../select';
|
||||
export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => {
|
||||
const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || "");
|
||||
const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || "");
|
||||
const pathValidator = downloadPath ? InputValidators.isPath : undefined;
|
||||
|
||||
const downloadMirrorOptions: SelectOption<string>[] = [
|
||||
{ value: "default", label: "Default (Google)" },
|
||||
@ -47,7 +47,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre
|
||||
theme="round-black"
|
||||
value={downloadPath}
|
||||
placeholder={userStore.getDefaultKubectlPath()}
|
||||
validators={isPath}
|
||||
validators={pathValidator}
|
||||
onChange={setDownloadPath}
|
||||
onBlur={save}
|
||||
disabled={!preferences.downloadKubectlBinaries}
|
||||
@ -60,7 +60,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre
|
||||
theme="round-black"
|
||||
placeholder={bundledKubectlPath()}
|
||||
value={binariesPath}
|
||||
validators={isPath}
|
||||
validators={pathValidator}
|
||||
onChange={setBinariesPath}
|
||||
onBlur={save}
|
||||
disabled={preferences.downloadKubectlBinaries}
|
||||
|
@ -60,7 +60,7 @@ export const PodLogSearch = observer((props: PodLogSearchProps) => {
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearch}
|
||||
closeIcon={false}
|
||||
showClearIcon={false}
|
||||
contentRight={totalFinds > 0 && findCounts}
|
||||
onClear={onClear}
|
||||
onKeyDown={onKeyDown}
|
||||
|
@ -61,7 +61,7 @@ export class DropFileInput<T extends HTMLElement = any> extends React.Component<
|
||||
const isValidContentElem = React.isValidElement(contentElem);
|
||||
if (isValidContentElem) {
|
||||
const contentElemProps: React.HTMLProps<HTMLElement> = {
|
||||
className: cssNames("DropFileInput", className, {
|
||||
className: cssNames("DropFileInput", contentElem.props.className, className, {
|
||||
droppable: this.dropAreaActive,
|
||||
}),
|
||||
onDragEnter,
|
||||
|
@ -89,8 +89,10 @@
|
||||
|
||||
&.theme {
|
||||
&.round-black {
|
||||
&.invalid label {
|
||||
border-color: $colorSoftError !important;
|
||||
&.invalid.dirty {
|
||||
label {
|
||||
border-color: $colorSoftError;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
|
@ -3,13 +3,13 @@ import "./input.scss";
|
||||
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
import { autobind, cssNames, debouncePromise, getRandId } from "../../utils";
|
||||
import { Icon } from "../icon";
|
||||
import { Tooltip, TooltipProps } from "../tooltip";
|
||||
import * as Validators from "./input_validators";
|
||||
import { InputValidator } from "./input_validators";
|
||||
import isString from "lodash/isString";
|
||||
import isFunction from "lodash/isFunction";
|
||||
import isBoolean from "lodash/isBoolean";
|
||||
import uniqueId from "lodash/uniqueId";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
const { conditionalValidators, ...InputValidators } = Validators;
|
||||
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
|
||||
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
||||
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
|
||||
iconRight?: string | React.ReactNode;
|
||||
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
||||
@ -63,6 +63,10 @@ export class Input extends React.Component<InputProps, State> {
|
||||
errors: [],
|
||||
};
|
||||
|
||||
isValid() {
|
||||
return this.state.valid;
|
||||
}
|
||||
|
||||
setValue(value: string) {
|
||||
if (value !== this.getValue()) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
|
||||
@ -268,7 +272,8 @@ export class Input extends React.Component<InputProps, State> {
|
||||
render() {
|
||||
const {
|
||||
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
|
||||
} = this.props;
|
||||
const { focused, dirty, valid, validating, errors } = this.state;
|
||||
@ -294,29 +299,35 @@ export class Input extends React.Component<InputProps, State> {
|
||||
ref: this.bindRef,
|
||||
spellCheck: "false",
|
||||
});
|
||||
const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
|
||||
const showErrors = errors.length > 0 && !valid && dirty;
|
||||
const errorsInfo = (
|
||||
<div className="errors box grow">
|
||||
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
||||
</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 (
|
||||
<div id={tooltipId} className={className}>
|
||||
<div id={componentId} className={className}>
|
||||
{tooltipError}
|
||||
<label className="input-area flex gaps align-center" id="">
|
||||
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
||||
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
||||
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
||||
{contentRight}
|
||||
</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">
|
||||
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
||||
{this.showMaxLenIndicator && (
|
||||
|
@ -39,13 +39,13 @@ export const isNumber: InputValidator = {
|
||||
export const isUrl: InputValidator = {
|
||||
condition: ({ type }) => type === "url",
|
||||
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 = {
|
||||
condition: ({ type }) => type === "text",
|
||||
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 = {
|
||||
|
@ -10,13 +10,15 @@ import { Input, InputProps } from "./input";
|
||||
|
||||
interface Props extends InputProps {
|
||||
compact?: boolean; // show only search-icon when not focused
|
||||
closeIcon?: boolean;
|
||||
onClear?: () => void;
|
||||
bindGlobalFocusHotkey?: boolean;
|
||||
showClearIcon?: boolean;
|
||||
onClear?(): void;
|
||||
}
|
||||
|
||||
const defaultProps: Partial<Props> = {
|
||||
autoFocus: true,
|
||||
closeIcon: true,
|
||||
bindGlobalFocusHotkey: true,
|
||||
showClearIcon: true,
|
||||
get placeholder() {
|
||||
return _i18n._(t`Search...`);
|
||||
},
|
||||
@ -26,27 +28,27 @@ const defaultProps: Partial<Props> = {
|
||||
export class SearchInput extends React.Component<Props> {
|
||||
static defaultProps = defaultProps as object;
|
||||
|
||||
private input = createRef<Input>();
|
||||
private inputRef = createRef<Input>();
|
||||
|
||||
componentDidMount() {
|
||||
addEventListener("keydown", this.focus);
|
||||
if (!this.props.bindGlobalFocusHotkey) return;
|
||||
window.addEventListener("keydown", this.onGlobalKey);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
removeEventListener("keydown", this.focus);
|
||||
window.removeEventListener("keydown", this.onGlobalKey);
|
||||
}
|
||||
|
||||
clear = () => {
|
||||
if (this.props.onClear) {
|
||||
this.props.onClear();
|
||||
@autobind()
|
||||
onGlobalKey(evt: KeyboardEvent) {
|
||||
const meta = evt.metaKey || evt.ctrlKey;
|
||||
if (meta && evt.key === "f") {
|
||||
this.inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onChange = (val: string, evt: React.ChangeEvent<any>) => {
|
||||
this.props.onChange(val, evt);
|
||||
};
|
||||
|
||||
onKeyDown = (evt: React.KeyboardEvent<any>) => {
|
||||
@autobind()
|
||||
onKeyDown(evt: React.KeyboardEvent<any>) {
|
||||
if (this.props.onKeyDown) {
|
||||
this.props.onKeyDown(evt);
|
||||
}
|
||||
@ -56,29 +58,31 @@ export class SearchInput extends React.Component<Props> {
|
||||
this.clear();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@autobind()
|
||||
focus(evt: KeyboardEvent) {
|
||||
const meta = evt.metaKey || evt.ctrlKey;
|
||||
if (meta && evt.key == "f") {
|
||||
this.input.current.focus();
|
||||
clear() {
|
||||
if (this.props.onClear) {
|
||||
this.props.onClear();
|
||||
} else {
|
||||
this.inputRef.current.setValue("");
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, compact, closeIcon, onClear, ...inputProps } = this.props;
|
||||
const icon = this.props.value
|
||||
? closeIcon ? <Icon small material="close" onClick={this.clear}/> : null
|
||||
: <Icon small material="search"/>;
|
||||
const { className, compact, onClear, showClearIcon, bindGlobalFocusHotkey, value, ...inputProps } = this.props;
|
||||
let rightIcon = <Icon small material="search"/>;
|
||||
if (showClearIcon && value) {
|
||||
rightIcon = <Icon small material="close" onClick={this.clear}/>;
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...inputProps}
|
||||
className={cssNames("SearchInput", className, { compact })}
|
||||
onChange={this.onChange}
|
||||
value={value}
|
||||
onKeyDown={this.onKeyDown}
|
||||
iconRight={icon}
|
||||
ref={this.input}
|
||||
iconRight={rightIcon}
|
||||
ref={this.inputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
44
yarn.lock
44
yarn.lock
@ -1724,11 +1724,6 @@
|
||||
"@babel/runtime" "^7.11.2"
|
||||
"@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@*":
|
||||
version "1.3.1"
|
||||
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"
|
||||
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:
|
||||
version "1.0.0"
|
||||
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:
|
||||
harmony-reflect "^1.4.6"
|
||||
|
||||
ieee754@^1.1.13, ieee754@^1.1.4:
|
||||
ieee754@^1.1.4:
|
||||
version "1.1.13"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
|
||||
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
||||
@ -11272,11 +11257,6 @@ pbkdf2@^3.0.3:
|
||||
safe-buffer "^5.0.1"
|
||||
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:
|
||||
version "1.2.0"
|
||||
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"
|
||||
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:
|
||||
version "1.1.0"
|
||||
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:
|
||||
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:
|
||||
version "1.2.1"
|
||||
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"
|
||||
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:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
|
||||
|
Loading…
Reference in New Issue
Block a user