mirror of
https://github.com/verdaccio/verdaccio.git
synced 2025-03-25 02:32:52 -05:00
refactor: <Search /> component (#1128)
* refactor: breaking components into <Search /> component * refactor: <Search/> component * refactor: <Search/> component * refactor: removes comments * refactor: implements cancel api feature for search * refactor: adds debounce to control search api calls * refactor: adds flow types to <Router/> <Search/> and <Autocomplete/> component * refactor: adds strict method to onChange method of <Search/> component * refactor: fixes <Search /> for mobile devices * refactor: adds flow types <Search /> component
This commit is contained in:
parent
e712eb4460
commit
77199531ee
12 changed files with 300 additions and 270 deletions
145
src/webui/app.js
145
src/webui/app.js
|
@ -1,12 +1,5 @@
|
|||
import React, { Component, Fragment } from 'react';
|
||||
import isNil from 'lodash/isNil';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import SnackbarContent from '@material-ui/core/SnackbarContent';
|
||||
import ErrorIcon from '@material-ui/icons/Error';
|
||||
|
||||
import storage from './utils/storage';
|
||||
import logo from './utils/logo';
|
||||
|
@ -19,10 +12,8 @@ import Header from './components/Header';
|
|||
import { Container, Content } from './components/Layout';
|
||||
import Route from './router';
|
||||
import API from './utils/api';
|
||||
import { getDetailPageURL } from './utils/url';
|
||||
|
||||
import './styles/main.scss';
|
||||
import classes from "./app.scss";
|
||||
import 'normalize.css';
|
||||
|
||||
export default class App extends Component {
|
||||
|
@ -34,16 +25,7 @@ export default class App extends Component {
|
|||
showLoginModal: false,
|
||||
isUserLoggedIn: false,
|
||||
packages: [],
|
||||
searchPackages: [],
|
||||
filteredPackages: [],
|
||||
search: '',
|
||||
isLoading: true,
|
||||
showAlertDialog: false,
|
||||
alertDialogContent: {
|
||||
title: '',
|
||||
message: '',
|
||||
packages: []
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -89,7 +71,6 @@ export default class App extends Component {
|
|||
}));
|
||||
this.setState({
|
||||
packages: transformedPackages,
|
||||
filteredPackages: transformedPackages,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
|
@ -149,31 +130,9 @@ export default class App extends Component {
|
|||
token,
|
||||
},
|
||||
isUserLoggedIn: true, // close login modal after successful login
|
||||
showLoginModal: false // set isUserLoggedin to true
|
||||
showLoginModal: false // set isUserLoggedIn to true
|
||||
});
|
||||
}
|
||||
|
||||
handleFetchPackages = async ({ value }) => {
|
||||
try {
|
||||
this.req = await API.request(`/search/${encodeURIComponent(value)}`, 'GET');
|
||||
const transformedPackages = this.req.map(({ name, ...others}) => ({
|
||||
label: name,
|
||||
...others
|
||||
}));
|
||||
// Implement cancel feature later
|
||||
if (this.state.search === value) {
|
||||
this.setState({
|
||||
searchPackages: transformedPackages
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleShowAlertDialog({
|
||||
title: 'Warning',
|
||||
message: `Unable to get search result: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logouts user
|
||||
* Required by: <Header />
|
||||
|
@ -187,112 +146,19 @@ export default class App extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
handlePackagesClearRequested = () => {
|
||||
this.setState({
|
||||
searchPackages: []
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
handleSearch = (_, { newValue }) => {
|
||||
const { filteredPackages, packages, search } = this.state;
|
||||
const value = newValue.trim();
|
||||
this.setState({
|
||||
search: value,
|
||||
filteredPackages: value.length < search.length ?
|
||||
packages.filter(pkg => pkg.label.match(value)) : filteredPackages
|
||||
});
|
||||
};
|
||||
|
||||
handleKeyDown = event => {
|
||||
if (event.key === 'Enter') {
|
||||
const { filteredPackages, packages } = this.state;
|
||||
const value = event.target.value.trim();
|
||||
this.setState({
|
||||
filteredPackages: value ?
|
||||
packages.filter(pkg => pkg.label.match(value)) : filteredPackages
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
handleClickSearch = (_, { suggestionValue, method }) => {
|
||||
switch(method) {
|
||||
case 'click':
|
||||
case 'enter':
|
||||
window.location.href = getDetailPageURL(suggestionValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleShowAlertDialog = content => {
|
||||
this.setState({
|
||||
showAlertDialog: true,
|
||||
alertDialogContent: content
|
||||
});
|
||||
}
|
||||
|
||||
handleDismissAlertDialog = () => {
|
||||
this.setState({
|
||||
showAlertDialog: false
|
||||
});
|
||||
};
|
||||
|
||||
renderHeader = () => {
|
||||
const { logoUrl, user, search, searchPackages } = this.state;
|
||||
const { logoUrl, user, scope } = this.state;
|
||||
return (
|
||||
<Header
|
||||
logo={logoUrl}
|
||||
username={user.username}
|
||||
toggleLoginModal={this.toggleLoginModal}
|
||||
onLogout={this.handleLogout}
|
||||
onSearch={this.handleSearch}
|
||||
onSuggestionsFetch={this.handleFetchPackages}
|
||||
onCleanSuggestions={this.handlePackagesClearRequested}
|
||||
onClick={this.handleClickSearch}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
packages={searchPackages}
|
||||
search={search}
|
||||
scope={scope}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderAlertDialog = () => (
|
||||
<Dialog
|
||||
open={this.state.showAlertDialog}
|
||||
onClose={this.handleDismissAlertDialog}
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">
|
||||
{this.state.alertDialogContent.title}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<SnackbarContent
|
||||
className={classes.alertError}
|
||||
message={
|
||||
<div
|
||||
id="client-snackbar"
|
||||
className={classes.alertErrorMsg}
|
||||
>
|
||||
<ErrorIcon className={classes.alertIcon} />
|
||||
<span>
|
||||
{this.state.alertDialogContent.message}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={this.handleDismissAlertDialog}
|
||||
color="primary"
|
||||
autoFocus
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
renderLoginModal = () => {
|
||||
const { error, showLoginModal } = this.state;
|
||||
return (
|
||||
|
@ -307,7 +173,7 @@ export default class App extends Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { isLoading, ...others } = this.state;
|
||||
const { isLoading, isUserLoggedIn, packages } = this.state;
|
||||
return (
|
||||
<Container isLoading={isLoading}>
|
||||
{isLoading ? (
|
||||
|
@ -316,12 +182,11 @@ export default class App extends Component {
|
|||
<Fragment>
|
||||
{this.renderHeader()}
|
||||
<Content>
|
||||
<Route {...others} />
|
||||
<Route isUserLoggedIn={isUserLoggedIn} packages={packages} />
|
||||
</Content>
|
||||
<Footer />
|
||||
</Fragment>
|
||||
)}
|
||||
{this.renderAlertDialog()}
|
||||
{this.renderLoginModal()}
|
||||
</Container>
|
||||
);
|
||||
|
|
|
@ -57,6 +57,20 @@ const renderSuggestion = (suggestion, { query, isHighlighted }): Node => {
|
|||
);
|
||||
};
|
||||
|
||||
const renderMessage = (message): Node => {
|
||||
return (
|
||||
<MenuItem selected={false} component="div">
|
||||
<div>{message}</div>
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const SUGGESTIONS_RESPONSE = {
|
||||
LOADING: 'Loading...',
|
||||
FAILURE: 'Something went wrong.',
|
||||
NO_RESULT: 'No results found.',
|
||||
};
|
||||
|
||||
const AutoComplete = ({
|
||||
suggestions,
|
||||
startAdornment,
|
||||
|
@ -69,6 +83,10 @@ const AutoComplete = ({
|
|||
color,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onBlur,
|
||||
suggestionsLoading = false,
|
||||
suggestionsLoaded = false,
|
||||
suggestionsError = false,
|
||||
}: IProps): Node => {
|
||||
const autosuggestProps = {
|
||||
renderInputComponent,
|
||||
|
@ -90,10 +108,14 @@ const AutoComplete = ({
|
|||
disableUnderline,
|
||||
color,
|
||||
onKeyDown,
|
||||
onBlur,
|
||||
}}
|
||||
renderSuggestionsContainer={options => (
|
||||
<Paper {...options.containerProps} square>
|
||||
{options.children}
|
||||
renderSuggestionsContainer={({ containerProps, children, query }) => (
|
||||
<Paper {...containerProps} square>
|
||||
{suggestionsLoaded && children === null && query && renderMessage(SUGGESTIONS_RESPONSE.NO_RESULT)}
|
||||
{suggestionsLoading && query && renderMessage(SUGGESTIONS_RESPONSE.LOADING)}
|
||||
{suggestionsError && renderMessage(SUGGESTIONS_RESPONSE.FAILURE)}
|
||||
{children}
|
||||
</Paper>
|
||||
)}
|
||||
onSuggestionSelected={onClick}
|
||||
|
|
|
@ -7,16 +7,21 @@ import { InputAdornmentProps } from '@material-ui/core/InputAdornment';
|
|||
|
||||
export interface IProps {
|
||||
suggestions: any[];
|
||||
suggestionsLoading?: boolean;
|
||||
suggestionsLoaded?: boolean;
|
||||
suggestionsError?: boolean;
|
||||
apiLoading?: boolean;
|
||||
color?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
startAdornment?: React.ComponentType<InputAdornmentProps>;
|
||||
disableUnderline?: boolean;
|
||||
onChange?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => void;
|
||||
onSuggestionsFetch?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => void;
|
||||
onChange?: (event: SyntheticKeyboardEvent<HTMLInputElement>, { newValue: string, method: string }) => void;
|
||||
onSuggestionsFetch?: ({ value: string }) => Promise<void>;
|
||||
onCleanSuggestions?: () => void;
|
||||
onClick?: () => void;
|
||||
onClick?: (event: SyntheticKeyboardEvent<HTMLInputElement>, { suggestionValue: any[], method: string }) => void;
|
||||
onKeyDown?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => void;
|
||||
onBlur?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export interface IInputField {
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
*/
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import type { Node } from 'react';
|
||||
|
||||
import Button from '@material-ui/core/Button/index';
|
||||
import IconButton from '@material-ui/core/IconButton/index';
|
||||
import MenuItem from '@material-ui/core/MenuItem/index';
|
||||
|
@ -12,7 +14,6 @@ import Info from '@material-ui/icons/Info';
|
|||
import Help from '@material-ui/icons/Help';
|
||||
import Tooltip from '@material-ui/core/Tooltip/index';
|
||||
import AccountCircle from '@material-ui/icons/AccountCircle';
|
||||
import InputAdornment from '@material-ui/core/InputAdornment';
|
||||
import { default as IconSearch } from '@material-ui/icons/Search';
|
||||
|
||||
import { getRegistryURL } from '../../utils/url';
|
||||
|
@ -20,13 +21,11 @@ import Link from '../Link';
|
|||
import Logo from '../Logo';
|
||||
import CopyToClipBoard from '../CopyToClipBoard/index';
|
||||
import RegistryInfoDialog from '../RegistryInfoDialog';
|
||||
import AutoComplete from '../AutoComplete';
|
||||
import Label from '../Label';
|
||||
import Search from '../Search';
|
||||
|
||||
import type { Node } from 'react';
|
||||
import { IProps, IState } from './types';
|
||||
import colors from '../../utils/styles/colors';
|
||||
import { Greetings, NavBar, InnerNavBar, MobileNavBar, InnerMobileNavBar, LeftSide, RightSide, Search, IconSearchButton } from './styles';
|
||||
import { Greetings, NavBar, InnerNavBar, MobileNavBar, InnerMobileNavBar, LeftSide, RightSide, IconSearchButton, SearchWrapper } from './styles';
|
||||
|
||||
class Header extends Component<IProps, IState> {
|
||||
handleLoggedInMenu: Function;
|
||||
|
@ -38,25 +37,13 @@ class Header extends Component<IProps, IState> {
|
|||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
const { packages = [] } = props;
|
||||
this.state = {
|
||||
openInfoDialog: false,
|
||||
registryUrl: '',
|
||||
packages,
|
||||
showMobileNavBar: false,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps: IProps, prevState: IState) {
|
||||
if (nextProps.packages !== prevState.packages) {
|
||||
return {
|
||||
packages: nextProps.packages,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const registryUrl = getRegistryURL();
|
||||
this.setState({
|
||||
|
@ -125,29 +112,16 @@ class Header extends Component<IProps, IState> {
|
|||
};
|
||||
|
||||
renderLeftSide = (): Node => {
|
||||
const { packages } = this.state;
|
||||
const { onSearch = () => {}, search = '', withoutSearch = false, ...others } = this.props;
|
||||
const { withoutSearch = false } = this.props;
|
||||
return (
|
||||
<LeftSide>
|
||||
<Link to="/" style={{ marginRight: '1em' }}>
|
||||
<Logo />
|
||||
</Link>
|
||||
{!withoutSearch && (
|
||||
<Search>
|
||||
<AutoComplete
|
||||
suggestions={packages}
|
||||
onChange={onSearch}
|
||||
value={search}
|
||||
placeholder="Search packages"
|
||||
color={colors.white}
|
||||
startAdornment={
|
||||
<InputAdornment position="start" style={{ color: colors.white }}>
|
||||
<IconSearch />
|
||||
</InputAdornment>
|
||||
}
|
||||
{...others}
|
||||
/>
|
||||
</Search>
|
||||
<SearchWrapper>
|
||||
<Search />
|
||||
</SearchWrapper>
|
||||
)}
|
||||
</LeftSide>
|
||||
);
|
||||
|
@ -238,8 +212,8 @@ class Header extends Component<IProps, IState> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const { packages, showMobileNavBar } = this.state;
|
||||
const { onSearch = () => {}, search = '', withoutSearch = false, ...others } = this.props;
|
||||
const { showMobileNavBar } = this.state;
|
||||
const { withoutSearch = false } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<NavBar position="static">
|
||||
|
@ -253,7 +227,7 @@ class Header extends Component<IProps, IState> {
|
|||
!withoutSearch && (
|
||||
<MobileNavBar>
|
||||
<InnerMobileNavBar>
|
||||
<AutoComplete suggestions={packages} onChange={onSearch} value={search} placeholder="Search packages" disableUnderline {...others} />
|
||||
<Search />
|
||||
</InnerMobileNavBar>
|
||||
<Button color="inherit" onClick={this.handleDismissMNav}>
|
||||
Cancel
|
||||
|
|
|
@ -80,7 +80,16 @@ export const InnerMobileNavBar = styled.div`
|
|||
}
|
||||
`;
|
||||
|
||||
export const Search = styled.div`
|
||||
export const IconSearchButton = styled(IconButton)`
|
||||
&& {
|
||||
display: block;
|
||||
${mq.medium(css`
|
||||
display: none;
|
||||
`)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const SearchWrapper = styled.div`
|
||||
&& {
|
||||
display: none;
|
||||
max-width: 393px;
|
||||
|
@ -91,12 +100,3 @@ export const Search = styled.div`
|
|||
`)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const IconSearchButton = styled(IconButton)`
|
||||
&& {
|
||||
display: block;
|
||||
${mq.medium(css`
|
||||
display: none;
|
||||
`)};
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -8,16 +8,12 @@ export interface IProps {
|
|||
onLogout?: Function;
|
||||
toggleLoginModal: Function;
|
||||
scope: string;
|
||||
search?: string;
|
||||
packages?: any[];
|
||||
withoutSearch?: boolean;
|
||||
onSearch?: (event: SyntheticKeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
anchorEl?: any;
|
||||
openInfoDialog: boolean;
|
||||
registryUrl: string;
|
||||
packages: any[];
|
||||
showMobileNavBar: boolean;
|
||||
}
|
||||
|
|
|
@ -3,8 +3,7 @@ import PropTypes from 'prop-types';
|
|||
|
||||
import Package from '../Package';
|
||||
import Help from '../Help';
|
||||
import NoItems from '../NoItems';
|
||||
import {formatAuthor, formatLicense} from '../../utils/package';
|
||||
import { formatAuthor, formatLicense } from '../../utils/package';
|
||||
|
||||
import classes from './packageList.scss';
|
||||
|
||||
|
@ -14,7 +13,7 @@ export default class PackageList extends React.Component {
|
|||
help: PropTypes.bool
|
||||
};
|
||||
|
||||
renderPackges = () => {
|
||||
renderPackages = () => {
|
||||
const { packages } = this.props;
|
||||
return (
|
||||
packages.length > 0 ? (
|
||||
|
@ -22,12 +21,7 @@ export default class PackageList extends React.Component {
|
|||
<h1 className={classes.listTitle}>Available Packages</h1>
|
||||
{this.renderList()}
|
||||
</Fragment>
|
||||
) : (
|
||||
<NoItems
|
||||
className="package-no-items"
|
||||
text={'No items were found with that query'}
|
||||
/>
|
||||
)
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -45,7 +39,7 @@ export default class PackageList extends React.Component {
|
|||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -54,9 +48,9 @@ export default class PackageList extends React.Component {
|
|||
return (
|
||||
<div className="package-list-items">
|
||||
<div className={classes.pkgContainer}>
|
||||
{help ? <Help /> : this.renderPackges()}
|
||||
{help ? <Help /> : this.renderPackages()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
185
src/webui/components/Search/index.js
Normal file
185
src/webui/components/Search/index.js
Normal file
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* @prettier
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import type { Node } from 'react';
|
||||
|
||||
import { default as IconSearch } from '@material-ui/icons/Search';
|
||||
import InputAdornment from '@material-ui/core/InputAdornment';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import API from '../../utils/api';
|
||||
import AutoComplete from '../AutoComplete';
|
||||
import colors from '../../utils/styles/colors';
|
||||
import { getDetailPageURL } from '../../utils/url';
|
||||
|
||||
import { IProps, IState } from './types';
|
||||
import type { cancelAllSearchRequests, handlePackagesClearRequested, handleSearch, handleClickSearch, handleFetchPackages, onBlur } from './types';
|
||||
|
||||
const CONSTANTS = {
|
||||
API_DELAY: 300,
|
||||
PLACEHOLDER_TEXT: 'Search Packages',
|
||||
ABORT_ERROR: 'AbortError',
|
||||
};
|
||||
|
||||
class Search extends Component<IProps, IState> {
|
||||
requestList: Array<any>;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
search: '',
|
||||
suggestions: [],
|
||||
// loading: A boolean value to indicate that request is in pending state.
|
||||
loading: false,
|
||||
// loaded: A boolean value to indicate that result has been loaded.
|
||||
loaded: false,
|
||||
// error: A boolean value to indicate API error.
|
||||
error: false,
|
||||
};
|
||||
this.requestList = [];
|
||||
this.handleFetchPackages = debounce(this.handleFetchPackages, CONSTANTS.API_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all the requests which are in pending state.
|
||||
*/
|
||||
cancelAllSearchRequests: cancelAllSearchRequests = () => {
|
||||
this.requestList.forEach(request => request.abort());
|
||||
this.requestList = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel all the request from list and make request list empty.
|
||||
*/
|
||||
handlePackagesClearRequested: handlePackagesClearRequested = () => {
|
||||
this.setState({
|
||||
suggestions: [],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* onChange method for the input element.
|
||||
*/
|
||||
handleSearch: handleSearch = (event, { newValue, method }) => {
|
||||
// stops event bubbling
|
||||
event.stopPropagation();
|
||||
if (method === 'type') {
|
||||
const value = newValue.trim();
|
||||
this.setState(
|
||||
{
|
||||
search: value,
|
||||
loading: true,
|
||||
loaded: false,
|
||||
error: false,
|
||||
},
|
||||
() => {
|
||||
/**
|
||||
* A use case where User keeps adding and removing value in input field,
|
||||
* so we cancel all the existing requests when input is empty.
|
||||
*/
|
||||
if (value.length === 0) {
|
||||
this.cancelAllSearchRequests();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When an user select any package by clicking or pressing return key.
|
||||
*/
|
||||
handleClickSearch: handleClickSearch = (event, { suggestionValue, method }) => {
|
||||
// stops event bubbling
|
||||
event.stopPropagation();
|
||||
switch (method) {
|
||||
case 'click':
|
||||
case 'enter':
|
||||
this.setState({ search: '' });
|
||||
window.location.href = getDetailPageURL(suggestionValue);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch packages from API.
|
||||
* For AbortController see: https://developer.mozilla.org/en-US/docs/Web/API/AbortController
|
||||
*/
|
||||
handleFetchPackages: handleFetchPackages = async ({ value }) => {
|
||||
try {
|
||||
const controller = new window.AbortController();
|
||||
const signal = controller.signal;
|
||||
// Keep track of search requests.
|
||||
this.requestList.push(controller);
|
||||
const response = await API.request(`search/${encodeURIComponent(value)}`, 'GET', { signal });
|
||||
this.setState({ loaded: true });
|
||||
const transformedPackages = response.map(({ name, ...others }) => ({
|
||||
label: name,
|
||||
...others,
|
||||
}));
|
||||
if (this.state.search === value) {
|
||||
this.setState({
|
||||
suggestions: transformedPackages,
|
||||
loaded: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
/**
|
||||
* AbortError is not the API error.
|
||||
* It means browser has cancelled the API request.
|
||||
*/
|
||||
if (error.name !== CONSTANTS.ABORT_ERROR) {
|
||||
this.setState({ error: true, loaded: false });
|
||||
}
|
||||
} finally {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* As user focuses out from input, we cancel all the request from requestList
|
||||
* and set the API state parameters to default boolean values.
|
||||
*/
|
||||
onBlur: onBlur = event => {
|
||||
// stops event bubbling
|
||||
event.stopPropagation();
|
||||
this.setState(
|
||||
{
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
},
|
||||
() => this.cancelAllSearchRequests()
|
||||
);
|
||||
};
|
||||
|
||||
render(): Node {
|
||||
const { suggestions, search, loaded, loading, error } = this.state;
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
suggestions={suggestions}
|
||||
suggestionsLoaded={loaded}
|
||||
suggestionsLoading={loading}
|
||||
suggestionsError={error}
|
||||
value={search}
|
||||
placeholder={CONSTANTS.PLACEHOLDER_TEXT}
|
||||
color={colors.white}
|
||||
startAdornment={
|
||||
<InputAdornment position="start" style={{ color: colors.white }}>
|
||||
<IconSearch />
|
||||
</InputAdornment>
|
||||
}
|
||||
onSuggestionsFetch={this.handleFetchPackages}
|
||||
onCleanSuggestions={this.handlePackagesClearRequested}
|
||||
onClick={this.handleClickSearch}
|
||||
onChange={this.handleSearch}
|
||||
onBlur={this.onBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Search;
|
21
src/webui/components/Search/types.js
Normal file
21
src/webui/components/Search/types.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @prettier
|
||||
* @flow
|
||||
*/
|
||||
|
||||
export interface IProps {}
|
||||
|
||||
export interface IState {
|
||||
search: string;
|
||||
suggestions: any[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export type cancelAllSearchRequests = () => void;
|
||||
export type handlePackagesClearRequested = () => void;
|
||||
export type handleSearch = (event: SyntheticKeyboardEvent<HTMLInputElement>, { newValue: string, method: string }) => void;
|
||||
export type handleClickSearch = (event: SyntheticKeyboardEvent<HTMLInputElement>, { suggestionValue: Array<Object>, method: string }) => void;
|
||||
export type handleFetchPackages = ({ value: string }) => Promise<void>;
|
||||
export type onBlur = (event: SyntheticKeyboardEvent<HTMLInputElement>) => void;
|
|
@ -5,40 +5,15 @@ import PackageList from '../../components/PackageList';
|
|||
|
||||
class Home extends Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.element,
|
||||
isUserLoggedIn: PropTypes.bool,
|
||||
packages: PropTypes.array,
|
||||
filteredPackages: PropTypes.array,
|
||||
isUserLoggedIn: PropTypes.bool.isRequired,
|
||||
packages: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
fistTime: true,
|
||||
packages: props.packages,
|
||||
filteredPackages: props.filteredPackages
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
if (nextProps.packages !== prevState.packages) {
|
||||
return {
|
||||
packages: nextProps.packages,
|
||||
};
|
||||
}
|
||||
if (nextProps.filteredPackages !== prevState.filteredPackages) {
|
||||
return {
|
||||
filteredPackages: nextProps.filteredPackages,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { filteredPackages, packages } = this.state;
|
||||
const { packages } = this.props;
|
||||
return (
|
||||
<div className="container content">
|
||||
<PackageList help={!packages.length > 0} packages={filteredPackages} />
|
||||
<PackageList help={packages.length < 1} packages={packages} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,43 +1,33 @@
|
|||
import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {HashRouter as Router, Route, Switch} from 'react-router-dom';
|
||||
/**
|
||||
* @prettier
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import {asyncComponent} from './utils/asyncComponent';
|
||||
import React, { Component } from 'react';
|
||||
import { HashRouter as Router, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { asyncComponent } from './utils/asyncComponent';
|
||||
|
||||
const DetailPackage = asyncComponent(() => import('./pages/detail'));
|
||||
import HomePage from './pages/home';
|
||||
const HomePage = asyncComponent(() => import('./pages/home'));
|
||||
|
||||
class RouterApp extends Component {
|
||||
static propTypes = {
|
||||
isUserLoggedIn: PropTypes.bool
|
||||
};
|
||||
interface IProps {
|
||||
isUserLoggedIn: boolean;
|
||||
packages: Array<Object>;
|
||||
}
|
||||
|
||||
interface IState {}
|
||||
|
||||
class RouterApp extends Component<IProps, IState> {
|
||||
render() {
|
||||
const { isUserLoggedIn, packages } = this.props;
|
||||
return (
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/"
|
||||
render={() => (
|
||||
<HomePage {...this.props} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/detail/@:scope/:package"
|
||||
render={(props) => (
|
||||
<DetailPackage {...props} {...this.props} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/detail/:package"
|
||||
render={(props) => (
|
||||
<DetailPackage {...props} {...this.props} />
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch>
|
||||
<Route exact path="/" render={() => <HomePage isUserLoggedIn={isUserLoggedIn} packages={packages} />} />
|
||||
<Route exact path="/detail/@:scope/:package" render={props => <DetailPackage {...props} isUserLoggedIn={isUserLoggedIn} />} />
|
||||
<Route exact path="/detail/:package" render={props => <DetailPackage {...props} isUserLoggedIn={isUserLoggedIn} />} />
|
||||
</Switch>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -51,6 +51,9 @@ class API {
|
|||
} else {
|
||||
reject(body);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue