0
Fork 0
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:
Ayush Sharma 2018-11-24 22:08:50 +01:00 committed by Juan Picado @jotadeveloper
parent e712eb4460
commit 77199531ee
12 changed files with 300 additions and 270 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;

View 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;

View file

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

View file

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

View file

@ -51,6 +51,9 @@ class API {
} else {
reject(body);
}
})
.catch(error => {
reject(error);
});
});
}