-
-
Save AugustoCalaca/4c5216425b2cfa4208da648f62666ceb to your computer and use it in GitHub Desktop.
@material-ui Autocomplete lab with react-window + infinite-loader for GraphQL/Relay connections
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useRef, useState } from 'react'; | |
import { Typography } from '@material-ui/core'; | |
import TextField from '@material-ui/core/TextField'; | |
import CircularProgress from '@material-ui/core/CircularProgress'; | |
import Autocomplete, { | |
AutocompleteChangeDetails, | |
AutocompleteChangeReason, | |
AutocompleteProps | |
} from '@material-ui/lab/Autocomplete'; | |
import { makeStyles } from '@material-ui/core/styles'; | |
import { Disposable } from 'relay-runtime'; | |
import { useTranslation } from 'react-i18next'; | |
import { useDebouncedCallback } from 'use-debounce'; | |
import { RelayRefetchProp } from 'react-relay'; | |
type PageInfo = { | |
hasNextPage: boolean; | |
startCursor: string | undefined; | |
endCursor: string | undefined; | |
}; | |
type Edge<T> = { | |
cursor: string; | |
node: T; | |
}; | |
export type Connection<T> = { | |
count: number; | |
totalCount: number; | |
endCursorOffset: number; | |
startCursorOffset: number; | |
pageInfo: PageInfo; | |
edges: Edge<T>[]; | |
}; | |
import ListboxComponent from './Listbox'; | |
const DEBOUNCE_DELAY = 500; | |
const TOTAL_REFETCH_ITEMS = 10; | |
const useStyles = makeStyles({ | |
listbox: { | |
'& ul': { | |
padding: 0, | |
margin: 0, | |
}, | |
}, | |
}); | |
type Props<Optino> = { | |
connection: Connection<Optino>; | |
filters: object; | |
label: string; | |
relay: RelayRefetchProp; | |
} & AutocompleteProps<Optino>; | |
const AutocompleteRelay = <Option extends object>(props: Props<Option>) => { | |
const { label, connection, filters = {}, relay, ...other } = props; | |
const classes = useStyles(); | |
const { t } = useTranslation(); | |
const [isLoading, setIsLoading] = useState<boolean>(false); | |
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false); | |
const [inputValue, setInputValue] = useState<string>(''); | |
const loadMoreDisposable = useRef<Disposable | null>(); | |
const newSearchDisposable = useRef<Disposable | null>(); | |
const { edges, pageInfo, count } = connection; | |
const options = edges.filter(({ node }) => !!node).map(({ node }) => node); | |
const getRefetchVariables = (search?: string) => (fragmentVariables) => { | |
return { | |
...fragmentVariables, | |
filters: { | |
...filters, | |
search: search ?? inputValue, | |
}, | |
}; | |
}; | |
const getRenderVariables = () => { | |
return { | |
filters: { | |
...filters, | |
search: inputValue, | |
}, | |
}; | |
}; | |
const [onInputChange] = useDebouncedCallback( | |
(event: React.ChangeEvent<{}>, value: string, reason: AutocompleteInputChangeReason) => { | |
// TODO - improve onInputChange reason changes | |
switch (reason) { | |
case 'clear': { | |
break; | |
} | |
case 'reset': { | |
break; | |
} | |
case 'input': { | |
break; | |
} | |
} | |
if (newSearchDisposable.current) { | |
newSearchDisposable.current.dispose(); | |
newSearchDisposable.current = null; | |
} | |
if (loadMoreDisposable.current) { | |
loadMoreDisposable.current.dispose(); | |
loadMoreDisposable.current = null; | |
} | |
setInputValue(value); | |
setIsLoading(true); | |
// TODO - timeout | |
newSearchDisposable.current = relay.refetch( | |
getRefetchVariables(value), | |
getRenderVariables, | |
() => { | |
setIsLoading(false); | |
setIsLoadingMore(false); | |
newSearchDisposable.current = null; | |
}, | |
{ force: true }, | |
); | |
}, | |
DEBOUNCE_DELAY, | |
); | |
const onChange = ( | |
event: React.ChangeEvent<{}>, | |
value: Option[], | |
reason: AutocompleteChangeReason, | |
details?: AutocompleteChangeDetails<Option>, | |
) => { | |
// TODO - improve onChange handling | |
// eslint-disable-next-line | |
console.log('onChange: ', event, value, reason, details); | |
}; | |
const isItemLoaded = (index: number): boolean => { | |
if (!pageInfo.hasNextPage) { | |
return true; | |
} | |
return !!edges[index]; | |
}; | |
const handleLoadMore = () => { | |
if (newSearchDisposable.current) { | |
// eslint-disable-next-line | |
console.log('new search in flight do not load more yet'); | |
return; | |
} | |
if (loadMoreDisposable.current) { | |
// eslint-disable-next-line | |
console.log('loadMore in flight do not load more yet'); | |
return; | |
} | |
if (!pageInfo.hasNextPage) { | |
// eslint-disable-next-line | |
console.log('loadMore hasNextPage false'); | |
return; | |
} | |
setIsLoadingMore(true); | |
const total = edges.length + TOTAL_REFETCH_ITEMS; | |
const refetchVariables = (fragmentVariables) => ({ | |
...getRefetchVariables()(fragmentVariables), | |
first: TOTAL_REFETCH_ITEMS, | |
after: pageInfo.endCursor, | |
}); | |
const renderVariables = { | |
first: total, | |
...getRenderVariables(), | |
}; | |
loadMoreDisposable.current = relay.refetch( | |
refetchVariables, | |
renderVariables, | |
() => { | |
setIsLoadingMore(false); | |
loadMoreDisposable.current = null; | |
}, | |
{ force: true }, | |
); | |
}; | |
// eslint-disable-next-line | |
const loadMoreItems = (startIndex: number, stopIndex: number) => { | |
if (!pageInfo.hasNextPage) { | |
return; | |
} | |
handleLoadMore(); | |
}; | |
const getItemCount = () => { | |
if (count) { | |
return count; | |
} | |
if (!pageInfo.hasNextPage) { | |
return edges.length; | |
} | |
return edges.length + 1; | |
}; | |
const itemCount = getItemCount(); | |
const ListboxProps = { | |
isItemLoaded, | |
loadMoreItems, | |
itemCount, | |
isLoadingMore, | |
}; | |
const loading = isLoading || isLoadingMore; | |
return ( | |
<Autocomplete<T> | |
style={{ width: 300 }} | |
disableListWrap | |
classes={classes} | |
ListboxComponent={ListboxComponent} | |
ListboxProps={ListboxProps} | |
options={options} | |
getOptionLabel={(option) => option.name} | |
getOptionSelected={(option, value) => option?.id === value?.id} | |
renderOption={(option) => <Typography noWrap>{option.name}</Typography>} | |
openOnFocus={true} | |
blurOnSelect={true} | |
fullWidth={true} | |
loading={loading} | |
loadingText={t('Loading...')} | |
noOptionsText={t('No items found')} | |
onInputChange={onInputChange} | |
onChange={onChange} | |
renderInput={(params) => ( | |
<TextField | |
{...params} | |
label={label} | |
InputProps={{ | |
...params.InputProps, | |
endAdornment: ( | |
<> | |
{isLoading ? <CircularProgress color='inherit' size={20} /> : null} | |
{params.InputProps.endAdornment} | |
</> | |
), | |
}} | |
/> | |
)} | |
{...other} | |
/> | |
); | |
}; | |
export default AutocompleteRelay; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { forwardRef } from 'react'; | |
import { useTheme } from '@material-ui/core/styles'; | |
import useMediaQuery from '@material-ui/core/useMediaQuery'; | |
import ListSubheader from '@material-ui/core/ListSubheader'; | |
import { FixedSizeList } from 'react-window'; | |
import InfiniteLoader from 'react-window-infinite-loader'; | |
const LISTBOX_PADDING = 8; // px | |
const OuterElementContext = React.createContext({}); | |
const OuterElementType = React.forwardRef((props, ref) => { | |
const outerProps = React.useContext(OuterElementContext); | |
return <div ref={ref} {...props} {...outerProps} />; | |
}); | |
// Adapter for react-window | |
const ListboxComponent = forwardRef(function ListboxComponent(props, ref) { | |
const { | |
children, | |
// itemCount, | |
isItemLoaded, | |
loadMoreItems, | |
itemCount, | |
isLoadingMore, | |
...other | |
} = props; | |
const itemData = React.Children.toArray(children); | |
const theme = useTheme(); | |
const smUp = useMediaQuery(theme.breakpoints.up('sm'), { noSsr: true }); | |
// itemCount is based on connection | |
// const itemCount = itemData.length; | |
const itemSize = smUp ? 36 : 48; | |
const getChildSize = (child) => { | |
if (React.isValidElement(child) && child.type === ListSubheader) { | |
return 48; | |
} | |
return itemSize; | |
}; | |
const getHeight = (): number => { | |
if (itemCount > 8) { | |
return 8 * itemSize; | |
} | |
return itemData.map(getChildSize).reduce((a, b) => a + b, 0); | |
}; | |
const renderRow = (props) => { | |
const { data, index, style } = props; | |
if (!isItemLoaded(index)) { | |
// TODO - improve loading state | |
return null; | |
// return <li style={style}>Loading...</li>; | |
} | |
if (!data[index]) { | |
// eslint-disable-next-line | |
console.log('isLoaded but no data', { data, index }); | |
return null; | |
} | |
return React.cloneElement(data[index], { | |
style: { | |
...style, | |
top: style.top + LISTBOX_PADDING, | |
}, | |
}); | |
}; | |
return ( | |
<div ref={ref}> | |
<OuterElementContext.Provider value={other}> | |
<InfiniteLoader isItemLoaded={isItemLoaded} itemCount={itemCount} loadMoreItems={loadMoreItems}> | |
{({ onItemsRendered, ref: refList }) => ( | |
<FixedSizeList | |
ref={refList} | |
itemData={itemData} | |
height={getHeight() + 2 * LISTBOX_PADDING} | |
width='100%' | |
key={itemCount} | |
outerElementType={OuterElementType} | |
innerElementType='ul' | |
itemSize={itemSize} | |
overscanCount={5} | |
itemCount={itemCount} | |
onItemsRendered={onItemsRendered} | |
> | |
{renderRow} | |
</FixedSizeList> | |
)} | |
</InfiniteLoader> | |
</OuterElementContext.Provider> | |
</div> | |
); | |
}); | |
export default ListboxComponent; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment