1
0
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:
Roman 2020-11-25 16:42:19 +02:00 committed by GitHub
parent 73724b5a54
commit 7243dfdce4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 186 additions and 217 deletions

View File

@ -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",

View File

@ -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);

View File

@ -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}
/> />
</> </>
); );

View File

@ -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) {
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-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;
}
} }
} }

View File

@ -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}>
<WizardLayout infoPanel={this.renderInfo()}> <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 <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>
</WizardLayout>
</DropFileInput>
</PageLayout> </PageLayout>
</DropFileInput>
); );
} }
} }

View File

@ -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}

View File

@ -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}

View File

@ -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,

View File

@ -89,8 +89,10 @@
&.theme { &.theme {
&.round-black { &.round-black {
&.invalid label { &.invalid.dirty {
border-color: $colorSoftError !important; label {
border-color: $colorSoftError;
}
} }
label { label {

View File

@ -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 && (

View File

@ -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 = {

View File

@ -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}
/> />
); );
} }

View File

@ -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"