diff --git a/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectField.stories.tsx b/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectField.stories.tsx index e332f3647..8a4435bd6 100644 --- a/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectField.stories.tsx +++ b/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectField.stories.tsx @@ -6,10 +6,14 @@ import { maybe } from "@saleor/misc"; import CardDecorator from "@saleor/storybook/CardDecorator"; import Decorator from "@saleor/storybook/Decorator"; import { ChoiceProvider } from "@saleor/storybook/mock"; +import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; import { countries } from "./fixtures"; import SingleAutocompleteSelectField, { SingleAutocompleteSelectFieldProps } from "./SingleAutocompleteSelectField"; +import SingleAutocompleteSelectFieldContent, { + SingleAutocompleteSelectFieldContentProps +} from "./SingleAutocompleteSelectFieldContent"; const suggestions = countries.map(c => ({ label: c.name, value: c.code })); @@ -20,11 +24,16 @@ const props: SingleAutocompleteSelectFieldProps = { loading: false, name: "country", onChange: () => undefined, - placeholder: "Select country" + placeholder: "Select country", + value: suggestions[0].value }; const Story: React.FC< - Partial + Partial< + SingleAutocompleteSelectFieldProps & { + enableLoadMore: boolean; + } + > > = storyProps => { const [displayValue, setDisplayValue] = React.useState(suggestions[0].label); @@ -32,14 +41,12 @@ const Story: React.FC<
{({ change, data }) => ( - {({ choices, loading, fetchChoices }) => { - const handleSelect = (event: React.ChangeEvent) => { - const value: string = event.target.value; - const match = choices.find(choice => choice.value === value); - const label = maybe(() => match.label, value); - setDisplayValue(label); - change(event); - }; + {({ choices, fetchChoices, fetchMore, hasMore, loading }) => { + const handleSelect = createSingleAutocompleteSelectHandler( + change, + setDisplayValue, + choices + ); return ( ); @@ -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(Decorator) - .add("with loaded data", () => ) - .add("with loading data", () => ) - .add("with custom option", () => ); + .add("default", () => ( + + )) + .add("can load more", () => ( + + )) + .add("no data", () => ( + + )) + .add("interactive", () => ) + .add("interactive with custom option", () => ( + + )) + .add("interactive with empty option", () => ) + .add("interactive with load more", () => ); diff --git a/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectField.tsx b/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectField.tsx index 742a67fb2..b48793371 100644 --- a/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectField.tsx +++ b/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectField.tsx @@ -1,5 +1,4 @@ import { Omit } from "@material-ui/core"; -import CircularProgress from "@material-ui/core/CircularProgress"; import { InputProps } from "@material-ui/core/Input"; import { createStyles, withStyles, WithStyles } from "@material-ui/core/styles"; import TextField from "@material-ui/core/TextField"; @@ -11,6 +10,7 @@ import SingleAutocompleteSelectFieldContent, { } from "./SingleAutocompleteSelectFieldContent"; import useStateFromProps from "@saleor/hooks/useStateFromProps"; +import { FetchMoreProps } from "@saleor/types"; import ArrowDropdownIcon from "../../icons/ArrowDropdown"; import Debounce, { DebounceProps } from "../Debounce"; @@ -21,15 +21,15 @@ const styles = createStyles({ } }); -export interface SingleAutocompleteSelectFieldProps { +export interface SingleAutocompleteSelectFieldProps + extends Partial { error?: boolean; name: string; displayValue: string; emptyOption?: boolean; choices: SingleAutocompleteChoiceType[]; - value?: string; + value: string; disabled?: boolean; - loading?: boolean; placeholder?: string; allowCustomValues?: boolean; helperText?: string; @@ -61,6 +61,7 @@ const SingleAutocompleteSelectFieldComponent = withStyles(styles, { displayValue, emptyOption, error, + hasMore, helperText, label, loading, @@ -70,6 +71,7 @@ const SingleAutocompleteSelectFieldComponent = withStyles(styles, { InputProps, fetchChoices, onChange, + onFetchMore, ...props }: SingleAutocompleteSelectFieldProps & WithStyles) => { const [prevDisplayValue] = useStateFromProps(displayValue); @@ -132,11 +134,7 @@ const SingleAutocompleteSelectFieldComponent = withStyles(styles, { }), endAdornment: (
- {loading ? ( - - ) : ( - - )} +
), error, @@ -156,10 +154,13 @@ const SingleAutocompleteSelectFieldComponent = withStyles(styles, { displayCustomValue={displayCustomValue} emptyOption={emptyOption} getItemProps={getItemProps} + hasMore={hasMore} highlightedIndex={highlightedIndex} + loading={loading} inputValue={inputValue} isCustomValueSelected={isCustomValueSelected} selectedItem={selectedItem} + onFetchMore={onFetchMore} /> )} @@ -208,4 +209,5 @@ export class SingleAutocompleteSelectField extends React.Component< ); } } + export default SingleAutocompleteSelectField; diff --git a/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectFieldContent.tsx b/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectFieldContent.tsx index 4775e063e..517852007 100644 --- a/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectFieldContent.tsx +++ b/src/components/SingleAutocompleteSelectField/SingleAutocompleteSelectFieldContent.tsx @@ -1,3 +1,4 @@ +import CircularProgress from "@material-ui/core/CircularProgress"; import MenuItem from "@material-ui/core/MenuItem"; import Paper from "@material-ui/core/Paper"; import { Theme } from "@material-ui/core/styles"; @@ -9,16 +10,19 @@ import React from "react"; import { FormattedMessage } from "react-intl"; import useElementScroll from "@saleor/hooks/useElementScroll"; +import { FetchMoreProps } from "@saleor/types"; import Hr from "../Hr"; const menuItemHeight = 46; const maxMenuItems = 5; +const offset = 24; export interface SingleAutocompleteChoiceType { label: string; value: any; } -interface SingleAutocompleteSelectFieldContentProps { +export interface SingleAutocompleteSelectFieldContentProps + extends Partial { choices: SingleAutocompleteChoiceType[]; displayCustomValue: boolean; emptyOption: boolean; @@ -43,6 +47,11 @@ const useStyles = makeStyles( height: "auto", whiteSpace: "normal" }, + progress: {}, + progressContainer: { + display: "flex", + justifyContent: "center" + }, root: { borderRadius: 4, left: 0, @@ -52,7 +61,9 @@ const useStyles = makeStyles( zIndex: 22 }, shadow: { - boxShadow: `0px -5px 10px 0px ${theme.palette.grey[800]}` + "&$shadowLine": { + boxShadow: `0px -5px 10px 0px ${theme.palette.grey[800]}` + } }, shadowLine: { boxShadow: `0px 0px 0px 0px ${theme.palette.grey[50]}`, @@ -89,21 +100,38 @@ const SingleAutocompleteSelectFieldContent: React.FC< displayCustomValue, emptyOption, getItemProps, + hasMore, highlightedIndex, + loading, inputValue, isCustomValueSelected, - selectedItem + selectedItem, + onFetchMore } = props; const classes = useStyles(props); const anchor = React.useRef(); const scrollPosition = useElementScroll(anchor); + const [calledForMore, setCalledForMore] = React.useState(false); - const dropShadow = anchor.current - ? scrollPosition.y + anchor.current.clientHeight < + const scrolledToBottom = anchor.current + ? scrollPosition.y + anchor.current.clientHeight + offset >= anchor.current.scrollHeight : false; + React.useEffect(() => { + if (!calledForMore && onFetchMore && scrolledToBottom) { + onFetchMore(); + setCalledForMore(true); + } + }, [scrolledToBottom]); + + React.useEffect(() => { + if (calledForMore && !loading) { + setCalledForMore(false); + } + }, [loading]); + return (
@@ -172,6 +200,14 @@ const SingleAutocompleteSelectFieldContent: React.FC< ); })} + {hasMore && ( + <> +
+
+ +
+ + )} ) : (
diff --git a/src/components/SingleAutocompleteSelectField/index.ts b/src/components/SingleAutocompleteSelectField/index.ts index c1cdf064d..56328d1d5 100644 --- a/src/components/SingleAutocompleteSelectField/index.ts +++ b/src/components/SingleAutocompleteSelectField/index.ts @@ -1,2 +1,3 @@ export { default } from "./SingleAutocompleteSelectField"; export * from "./SingleAutocompleteSelectField"; +export * from "./SingleAutocompleteSelectFieldContent"; diff --git a/src/storybook/CardDecorator.tsx b/src/storybook/CardDecorator.tsx index f94b6ea87..6f113a514 100644 --- a/src/storybook/CardDecorator.tsx +++ b/src/storybook/CardDecorator.tsx @@ -7,6 +7,7 @@ const CardDecorator = storyFn => ( style={{ margin: "auto", overflow: "visible", + position: "relative", width: 400 }} > diff --git a/src/storybook/mock.tsx b/src/storybook/mock.tsx index 7e40ca5da..0e6b83f0f 100644 --- a/src/storybook/mock.tsx +++ b/src/storybook/mock.tsx @@ -1,36 +1,41 @@ import React from "react"; +import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField/SingleAutocompleteSelectFieldContent"; + interface ChoiceProviderProps { children: (props: { - choices: Array<{ - label: string; - value: string; - }>; + choices: SingleAutocompleteChoiceType[]; + hasMore: boolean; loading: boolean; - fetchChoices(value: string); + fetchChoices: (value: string) => void; + fetchMore: () => void; }) => React.ReactElement; - choices: Array<{ - label: string; - value: string; - }>; + choices: SingleAutocompleteChoiceType[]; } interface ChoiceProviderState { - choices: Array<{ - label: string; - value: string; - }>; + choices: SingleAutocompleteChoiceType[]; + filteredChoices: SingleAutocompleteChoiceType[]; + first: number; loading: boolean; timeout: any; } +const step = 5; + export class ChoiceProvider extends React.Component< ChoiceProviderProps, ChoiceProviderState > { - state = { choices: [], loading: false, timeout: null }; + state = { + choices: [], + filteredChoices: [], + first: step, + loading: false, + timeout: null + }; handleChange = (inputValue: string) => { - if (this.state.loading) { + if (!!this.state.timeout) { clearTimeout(this.state.timeout); } 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({ - choices: this.props.choices - .filter( - suggestion => - !inputValue || - suggestion.label.toLowerCase().indexOf(inputValue.toLowerCase()) !== - -1 - ) - .slice(0, 10), + loading: true, + timeout + }); + }; + + fetchMore = () => + this.setState(prevState => ({ + 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, timeout: null }); @@ -57,8 +81,10 @@ export class ChoiceProvider extends React.Component< render() { return this.props.children({ - choices: this.state.choices, + choices: this.state.filteredChoices, fetchChoices: this.handleChange, + fetchMore: this.handleFetchMore, + hasMore: this.state.choices.length > this.state.filteredChoices.length, loading: this.state.loading }); }