Add dynamic loading

This commit is contained in:
dominik-zeglen 2019-10-14 16:17:03 +02:00
parent 0bf7594ce0
commit d99321be84
6 changed files with 156 additions and 55 deletions

View file

@ -6,10 +6,14 @@ import { maybe } from "@saleor/misc";
import CardDecorator from "@saleor/storybook/CardDecorator"; import CardDecorator from "@saleor/storybook/CardDecorator";
import Decorator from "@saleor/storybook/Decorator"; import Decorator from "@saleor/storybook/Decorator";
import { ChoiceProvider } from "@saleor/storybook/mock"; import { ChoiceProvider } from "@saleor/storybook/mock";
import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler";
import { countries } from "./fixtures"; import { countries } from "./fixtures";
import SingleAutocompleteSelectField, { import SingleAutocompleteSelectField, {
SingleAutocompleteSelectFieldProps SingleAutocompleteSelectFieldProps
} from "./SingleAutocompleteSelectField"; } from "./SingleAutocompleteSelectField";
import SingleAutocompleteSelectFieldContent, {
SingleAutocompleteSelectFieldContentProps
} from "./SingleAutocompleteSelectFieldContent";
const suggestions = countries.map(c => ({ label: c.name, value: c.code })); const suggestions = countries.map(c => ({ label: c.name, value: c.code }));
@ -20,11 +24,16 @@ const props: SingleAutocompleteSelectFieldProps = {
loading: false, loading: false,
name: "country", name: "country",
onChange: () => undefined, onChange: () => undefined,
placeholder: "Select country" placeholder: "Select country",
value: suggestions[0].value
}; };
const Story: React.FC< const Story: React.FC<
Partial<SingleAutocompleteSelectFieldProps> Partial<
SingleAutocompleteSelectFieldProps & {
enableLoadMore: boolean;
}
>
> = storyProps => { > = storyProps => {
const [displayValue, setDisplayValue] = React.useState(suggestions[0].label); const [displayValue, setDisplayValue] = React.useState(suggestions[0].label);
@ -32,14 +41,12 @@ const Story: React.FC<
<Form initial={{ country: suggestions[0].value }}> <Form initial={{ country: suggestions[0].value }}>
{({ change, data }) => ( {({ change, data }) => (
<ChoiceProvider choices={suggestions}> <ChoiceProvider choices={suggestions}>
{({ choices, loading, fetchChoices }) => { {({ choices, fetchChoices, fetchMore, hasMore, loading }) => {
const handleSelect = (event: React.ChangeEvent<any>) => { const handleSelect = createSingleAutocompleteSelectHandler(
const value: string = event.target.value; change,
const match = choices.find(choice => choice.value === value); setDisplayValue,
const label = maybe(() => match.label, value); choices
setDisplayValue(label); );
change(event);
};
return ( return (
<SingleAutocompleteSelectField <SingleAutocompleteSelectField
@ -48,9 +55,11 @@ const Story: React.FC<
choices={choices} choices={choices}
fetchChoices={fetchChoices} fetchChoices={fetchChoices}
helperText={`Value: ${data.country}`} helperText={`Value: ${data.country}`}
loading={loading}
onChange={handleSelect} onChange={handleSelect}
value={data.country} value={data.country}
loading={loading} hasMore={storyProps.enableLoadMore ? hasMore : false}
onFetchMore={storyProps.enableLoadMore ? fetchMore : undefined}
{...storyProps} {...storyProps}
/> />
); );
@ -61,9 +70,35 @@ const Story: React.FC<
); );
}; };
storiesOf("Generics / SingleAutocompleteSelectField", module) const contentProps: SingleAutocompleteSelectFieldContentProps = {
choices: suggestions.slice(0, 10),
displayCustomValue: false,
emptyOption: false,
getItemProps: () => undefined,
hasMore: false,
highlightedIndex: 0,
inputValue: suggestions[0].label,
isCustomValueSelected: false,
loading: false,
onFetchMore: () => undefined,
selectedItem: suggestions[0].value
};
storiesOf("Generics / Select with autocomplete", module)
.addDecorator(CardDecorator) .addDecorator(CardDecorator)
.addDecorator(Decorator) .addDecorator(Decorator)
.add("with loaded data", () => <Story />) .add("default", () => (
.add("with loading data", () => <Story loading={true} />) <SingleAutocompleteSelectFieldContent {...contentProps} />
.add("with custom option", () => <Story allowCustomValues={true} />); ))
.add("can load more", () => (
<SingleAutocompleteSelectFieldContent {...contentProps} hasMore={true} />
))
.add("no data", () => (
<SingleAutocompleteSelectFieldContent {...contentProps} choices={[]} />
))
.add("interactive", () => <Story />)
.add("interactive with custom option", () => (
<Story allowCustomValues={true} />
))
.add("interactive with empty option", () => <Story emptyOption={true} />)
.add("interactive with load more", () => <Story enableLoadMore={true} />);

View file

@ -1,5 +1,4 @@
import { Omit } from "@material-ui/core"; import { Omit } from "@material-ui/core";
import CircularProgress from "@material-ui/core/CircularProgress";
import { InputProps } from "@material-ui/core/Input"; import { InputProps } from "@material-ui/core/Input";
import { createStyles, withStyles, WithStyles } from "@material-ui/core/styles"; import { createStyles, withStyles, WithStyles } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField"; import TextField from "@material-ui/core/TextField";
@ -11,6 +10,7 @@ import SingleAutocompleteSelectFieldContent, {
} from "./SingleAutocompleteSelectFieldContent"; } from "./SingleAutocompleteSelectFieldContent";
import useStateFromProps from "@saleor/hooks/useStateFromProps"; import useStateFromProps from "@saleor/hooks/useStateFromProps";
import { FetchMoreProps } from "@saleor/types";
import ArrowDropdownIcon from "../../icons/ArrowDropdown"; import ArrowDropdownIcon from "../../icons/ArrowDropdown";
import Debounce, { DebounceProps } from "../Debounce"; import Debounce, { DebounceProps } from "../Debounce";
@ -21,15 +21,15 @@ const styles = createStyles({
} }
}); });
export interface SingleAutocompleteSelectFieldProps { export interface SingleAutocompleteSelectFieldProps
extends Partial<FetchMoreProps> {
error?: boolean; error?: boolean;
name: string; name: string;
displayValue: string; displayValue: string;
emptyOption?: boolean; emptyOption?: boolean;
choices: SingleAutocompleteChoiceType[]; choices: SingleAutocompleteChoiceType[];
value?: string; value: string;
disabled?: boolean; disabled?: boolean;
loading?: boolean;
placeholder?: string; placeholder?: string;
allowCustomValues?: boolean; allowCustomValues?: boolean;
helperText?: string; helperText?: string;
@ -61,6 +61,7 @@ const SingleAutocompleteSelectFieldComponent = withStyles(styles, {
displayValue, displayValue,
emptyOption, emptyOption,
error, error,
hasMore,
helperText, helperText,
label, label,
loading, loading,
@ -70,6 +71,7 @@ const SingleAutocompleteSelectFieldComponent = withStyles(styles, {
InputProps, InputProps,
fetchChoices, fetchChoices,
onChange, onChange,
onFetchMore,
...props ...props
}: SingleAutocompleteSelectFieldProps & WithStyles<typeof styles>) => { }: SingleAutocompleteSelectFieldProps & WithStyles<typeof styles>) => {
const [prevDisplayValue] = useStateFromProps(displayValue); const [prevDisplayValue] = useStateFromProps(displayValue);
@ -132,11 +134,7 @@ const SingleAutocompleteSelectFieldComponent = withStyles(styles, {
}), }),
endAdornment: ( endAdornment: (
<div> <div>
{loading ? ( <ArrowDropdownIcon onClick={toggleMenu} />
<CircularProgress size={20} />
) : (
<ArrowDropdownIcon onClick={toggleMenu} />
)}
</div> </div>
), ),
error, error,
@ -156,10 +154,13 @@ const SingleAutocompleteSelectFieldComponent = withStyles(styles, {
displayCustomValue={displayCustomValue} displayCustomValue={displayCustomValue}
emptyOption={emptyOption} emptyOption={emptyOption}
getItemProps={getItemProps} getItemProps={getItemProps}
hasMore={hasMore}
highlightedIndex={highlightedIndex} highlightedIndex={highlightedIndex}
loading={loading}
inputValue={inputValue} inputValue={inputValue}
isCustomValueSelected={isCustomValueSelected} isCustomValueSelected={isCustomValueSelected}
selectedItem={selectedItem} selectedItem={selectedItem}
onFetchMore={onFetchMore}
/> />
)} )}
</div> </div>
@ -208,4 +209,5 @@ export class SingleAutocompleteSelectField extends React.Component<
); );
} }
} }
export default SingleAutocompleteSelectField; export default SingleAutocompleteSelectField;

View file

@ -1,3 +1,4 @@
import CircularProgress from "@material-ui/core/CircularProgress";
import MenuItem from "@material-ui/core/MenuItem"; import MenuItem from "@material-ui/core/MenuItem";
import Paper from "@material-ui/core/Paper"; import Paper from "@material-ui/core/Paper";
import { Theme } from "@material-ui/core/styles"; import { Theme } from "@material-ui/core/styles";
@ -9,16 +10,19 @@ import React from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import useElementScroll from "@saleor/hooks/useElementScroll"; import useElementScroll from "@saleor/hooks/useElementScroll";
import { FetchMoreProps } from "@saleor/types";
import Hr from "../Hr"; import Hr from "../Hr";
const menuItemHeight = 46; const menuItemHeight = 46;
const maxMenuItems = 5; const maxMenuItems = 5;
const offset = 24;
export interface SingleAutocompleteChoiceType { export interface SingleAutocompleteChoiceType {
label: string; label: string;
value: any; value: any;
} }
interface SingleAutocompleteSelectFieldContentProps { export interface SingleAutocompleteSelectFieldContentProps
extends Partial<FetchMoreProps> {
choices: SingleAutocompleteChoiceType[]; choices: SingleAutocompleteChoiceType[];
displayCustomValue: boolean; displayCustomValue: boolean;
emptyOption: boolean; emptyOption: boolean;
@ -43,6 +47,11 @@ const useStyles = makeStyles(
height: "auto", height: "auto",
whiteSpace: "normal" whiteSpace: "normal"
}, },
progress: {},
progressContainer: {
display: "flex",
justifyContent: "center"
},
root: { root: {
borderRadius: 4, borderRadius: 4,
left: 0, left: 0,
@ -52,7 +61,9 @@ const useStyles = makeStyles(
zIndex: 22 zIndex: 22
}, },
shadow: { shadow: {
boxShadow: `0px -5px 10px 0px ${theme.palette.grey[800]}` "&$shadowLine": {
boxShadow: `0px -5px 10px 0px ${theme.palette.grey[800]}`
}
}, },
shadowLine: { shadowLine: {
boxShadow: `0px 0px 0px 0px ${theme.palette.grey[50]}`, boxShadow: `0px 0px 0px 0px ${theme.palette.grey[50]}`,
@ -89,21 +100,38 @@ const SingleAutocompleteSelectFieldContent: React.FC<
displayCustomValue, displayCustomValue,
emptyOption, emptyOption,
getItemProps, getItemProps,
hasMore,
highlightedIndex, highlightedIndex,
loading,
inputValue, inputValue,
isCustomValueSelected, isCustomValueSelected,
selectedItem selectedItem,
onFetchMore
} = props; } = props;
const classes = useStyles(props); const classes = useStyles(props);
const anchor = React.useRef<HTMLDivElement>(); const anchor = React.useRef<HTMLDivElement>();
const scrollPosition = useElementScroll(anchor); const scrollPosition = useElementScroll(anchor);
const [calledForMore, setCalledForMore] = React.useState(false);
const dropShadow = anchor.current const scrolledToBottom = anchor.current
? scrollPosition.y + anchor.current.clientHeight < ? scrollPosition.y + anchor.current.clientHeight + offset >=
anchor.current.scrollHeight anchor.current.scrollHeight
: false; : false;
React.useEffect(() => {
if (!calledForMore && onFetchMore && scrolledToBottom) {
onFetchMore();
setCalledForMore(true);
}
}, [scrolledToBottom]);
React.useEffect(() => {
if (calledForMore && !loading) {
setCalledForMore(false);
}
}, [loading]);
return ( return (
<Paper className={classes.root} square> <Paper className={classes.root} square>
<div className={classes.content} ref={anchor}> <div className={classes.content} ref={anchor}>
@ -172,6 +200,14 @@ const SingleAutocompleteSelectFieldContent: React.FC<
</MenuItem> </MenuItem>
); );
})} })}
{hasMore && (
<>
<Hr className={classes.hr} />
<div className={classes.progressContainer}>
<CircularProgress className={classes.progress} size={24} />
</div>
</>
)}
</> </>
) : ( ) : (
<MenuItem <MenuItem
@ -185,7 +221,7 @@ const SingleAutocompleteSelectFieldContent: React.FC<
</div> </div>
<div <div
className={classNames(classes.shadowLine, { className={classNames(classes.shadowLine, {
[classes.shadow]: dropShadow [classes.shadow]: !scrolledToBottom
})} })}
/> />
</Paper> </Paper>

View file

@ -1,2 +1,3 @@
export { default } from "./SingleAutocompleteSelectField"; export { default } from "./SingleAutocompleteSelectField";
export * from "./SingleAutocompleteSelectField"; export * from "./SingleAutocompleteSelectField";
export * from "./SingleAutocompleteSelectFieldContent";

View file

@ -7,6 +7,7 @@ const CardDecorator = storyFn => (
style={{ style={{
margin: "auto", margin: "auto",
overflow: "visible", overflow: "visible",
position: "relative",
width: 400 width: 400
}} }}
> >

View file

@ -1,36 +1,41 @@
import React from "react"; import React from "react";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField/SingleAutocompleteSelectFieldContent";
interface ChoiceProviderProps { interface ChoiceProviderProps {
children: (props: { children: (props: {
choices: Array<{ choices: SingleAutocompleteChoiceType[];
label: string; hasMore: boolean;
value: string;
}>;
loading: boolean; loading: boolean;
fetchChoices(value: string); fetchChoices: (value: string) => void;
fetchMore: () => void;
}) => React.ReactElement<any>; }) => React.ReactElement<any>;
choices: Array<{ choices: SingleAutocompleteChoiceType[];
label: string;
value: string;
}>;
} }
interface ChoiceProviderState { interface ChoiceProviderState {
choices: Array<{ choices: SingleAutocompleteChoiceType[];
label: string; filteredChoices: SingleAutocompleteChoiceType[];
value: string; first: number;
}>;
loading: boolean; loading: boolean;
timeout: any; timeout: any;
} }
const step = 5;
export class ChoiceProvider extends React.Component< export class ChoiceProvider extends React.Component<
ChoiceProviderProps, ChoiceProviderProps,
ChoiceProviderState ChoiceProviderState
> { > {
state = { choices: [], loading: false, timeout: null }; state = {
choices: [],
filteredChoices: [],
first: step,
loading: false,
timeout: null
};
handleChange = (inputValue: string) => { handleChange = (inputValue: string) => {
if (this.state.loading) { if (!!this.state.timeout) {
clearTimeout(this.state.timeout); clearTimeout(this.state.timeout);
} }
const timeout = setTimeout(() => this.fetchChoices(inputValue), 500); const timeout = setTimeout(() => this.fetchChoices(inputValue), 500);
@ -40,16 +45,35 @@ export class ChoiceProvider extends React.Component<
}); });
}; };
fetchChoices = (inputValue: string) => { handleFetchMore = () => {
if (!!this.state.timeout) {
clearTimeout(this.state.timeout);
}
const timeout = setTimeout(this.fetchMore, 500);
this.setState({ this.setState({
choices: this.props.choices loading: true,
.filter( timeout
suggestion => });
!inputValue || };
suggestion.label.toLowerCase().indexOf(inputValue.toLowerCase()) !==
-1 fetchMore = () =>
) this.setState(prevState => ({
.slice(0, 10), filteredChoices: prevState.choices.slice(0, prevState.first + step),
first: prevState.first + step,
loading: false,
timeout: null
}));
fetchChoices = (inputValue: string) => {
const choices = this.props.choices.filter(
suggestion =>
!inputValue ||
suggestion.label.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1
);
this.setState({
choices,
filteredChoices: choices.slice(0, step),
first: step,
loading: false, loading: false,
timeout: null timeout: null
}); });
@ -57,8 +81,10 @@ export class ChoiceProvider extends React.Component<
render() { render() {
return this.props.children({ return this.props.children({
choices: this.state.choices, choices: this.state.filteredChoices,
fetchChoices: this.handleChange, fetchChoices: this.handleChange,
fetchMore: this.handleFetchMore,
hasMore: this.state.choices.length > this.state.filteredChoices.length,
loading: this.state.loading loading: this.state.loading
}); });
} }