Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
@material-ui Autocomplete lab with react-window + infinite-loader for GraphQL/Relay connections
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;
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