ListQuery.tsx

  1import { ApolloError } from 'apollo-boost';
  2import React, { useState, useEffect, useRef } from 'react';
  3import { useLocation, useHistory, Link } from 'react-router-dom';
  4
  5import IconButton from '@material-ui/core/IconButton';
  6import InputBase from '@material-ui/core/InputBase';
  7import Paper from '@material-ui/core/Paper';
  8import { fade, makeStyles, Theme } from '@material-ui/core/styles';
  9import ErrorOutline from '@material-ui/icons/ErrorOutline';
 10import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
 11import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
 12import Skeleton from '@material-ui/lab/Skeleton';
 13
 14import FilterToolbar from './FilterToolbar';
 15import List from './List';
 16import { useListBugsQuery } from './ListQuery.generated';
 17
 18type StylesProps = { searching?: boolean };
 19const useStyles = makeStyles<Theme, StylesProps>(theme => ({
 20  main: {
 21    maxWidth: 800,
 22    margin: 'auto',
 23    marginTop: theme.spacing(4),
 24    marginBottom: theme.spacing(4),
 25    overflow: 'hidden',
 26  },
 27  pagination: {
 28    ...theme.typography.overline,
 29    display: 'flex',
 30    alignItems: 'center',
 31    justifyContent: 'center',
 32  },
 33  header: {
 34    display: 'flex',
 35    padding: theme.spacing(2),
 36    '& > h1': {
 37      ...theme.typography.h6,
 38      margin: theme.spacing(0, 2),
 39    },
 40    alignItems: 'center',
 41    justifyContent: 'space-between',
 42  },
 43  search: {
 44    borderRadius: theme.shape.borderRadius,
 45    borderColor: fade(theme.palette.primary.main, 0.2),
 46    borderStyle: 'solid',
 47    borderWidth: '1px',
 48    backgroundColor: fade(theme.palette.primary.main, 0.05),
 49    padding: theme.spacing(0, 1),
 50    width: ({ searching }) => (searching ? '20rem' : '15rem'),
 51    transition: theme.transitions.create([
 52      'width',
 53      'borderColor',
 54      'backgroundColor',
 55    ]),
 56  },
 57  searchFocused: {
 58    borderColor: fade(theme.palette.primary.main, 0.4),
 59    backgroundColor: theme.palette.background.paper,
 60    width: '20rem!important',
 61  },
 62  placeholderRow: {
 63    padding: theme.spacing(1),
 64    borderBottomColor: theme.palette.grey['300'],
 65    borderBottomWidth: '1px',
 66    borderBottomStyle: 'solid',
 67    display: 'flex',
 68    alignItems: 'center',
 69  },
 70  placeholderRowStatus: {
 71    margin: theme.spacing(1, 2),
 72  },
 73  placeholderRowText: {
 74    flex: 1,
 75  },
 76  message: {
 77    ...theme.typography.h5,
 78    padding: theme.spacing(8),
 79    textAlign: 'center',
 80    borderBottomColor: theme.palette.grey['300'],
 81    borderBottomWidth: '1px',
 82    borderBottomStyle: 'solid',
 83    '& > p': {
 84      margin: '0',
 85    },
 86  },
 87  errorBox: {
 88    color: theme.palette.error.main,
 89    '& > pre': {
 90      fontSize: '1rem',
 91      textAlign: 'left',
 92      backgroundColor: theme.palette.grey['900'],
 93      color: theme.palette.common.white,
 94      marginTop: theme.spacing(4),
 95      padding: theme.spacing(2, 3),
 96    },
 97  },
 98}));
 99
100function editParams(
101  params: URLSearchParams,
102  callback: (params: URLSearchParams) => void
103) {
104  const cloned = new URLSearchParams(params.toString());
105  callback(cloned);
106  return cloned;
107}
108
109// TODO: factor this out
110type PlaceholderProps = { count: number };
111const Placeholder: React.FC<PlaceholderProps> = ({
112  count,
113}: PlaceholderProps) => {
114  const classes = useStyles({});
115  return (
116    <>
117      {new Array(count).fill(null).map((_, i) => (
118        <div key={i} className={classes.placeholderRow}>
119          <Skeleton
120            className={classes.placeholderRowStatus}
121            variant="circle"
122            width={20}
123            height={20}
124          />
125          <div className={classes.placeholderRowText}>
126            <Skeleton height={22} />
127            <Skeleton height={24} width="60%" />
128          </div>
129        </div>
130      ))}
131    </>
132  );
133};
134
135// TODO: factor this out
136const NoBug = () => {
137  const classes = useStyles({});
138  return (
139    <div className={classes.message}>
140      <ErrorOutline fontSize="large" />
141      <p>No results matched your search.</p>
142    </div>
143  );
144};
145
146type ErrorProps = { error: ApolloError };
147const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
148  const classes = useStyles({});
149  return (
150    <div className={[classes.errorBox, classes.message].join(' ')}>
151      <ErrorOutline fontSize="large" />
152      <p>There was an error while fetching bug.</p>
153      <p>
154        <em>{error.message}</em>
155      </p>
156      <pre>
157        <code>{JSON.stringify(error, null, 2)}</code>
158      </pre>
159    </div>
160  );
161};
162
163function ListQuery() {
164  const location = useLocation();
165  const history = useHistory();
166  const params = new URLSearchParams(location.search);
167  const query = params.get('q') || '';
168
169  const [input, setInput] = useState(query);
170
171  const classes = useStyles({ searching: !!input });
172
173  // TODO is this the right way to do it?
174  const lastQuery = useRef<string | null>(null);
175  useEffect(() => {
176    if (query !== lastQuery.current) {
177      setInput(query);
178    }
179    lastQuery.current = query;
180  }, [query, input, lastQuery]);
181
182  const num = (param: string | null) => (param ? parseInt(param) : null);
183  const page = {
184    first: num(params.get('first')),
185    last: num(params.get('last')),
186    after: params.get('after'),
187    before: params.get('before'),
188  };
189
190  // If nothing set, show the first 10 items
191  if (!page.first && !page.last) {
192    page.first = 10;
193  }
194
195  const perPage = (page.first || page.last || 10).toString();
196
197  const { loading, error, data } = useListBugsQuery({
198    variables: {
199      ...page,
200      query,
201    },
202  });
203
204  let nextPage = null;
205  let previousPage = null;
206  let count = 0;
207  if (!loading && !error && data?.repository?.bugs) {
208    const bugs = data.repository.bugs;
209    count = bugs.totalCount;
210    // This computes the URL for the next page
211    if (bugs.pageInfo.hasNextPage) {
212      nextPage = {
213        ...location,
214        search: editParams(params, p => {
215          p.delete('last');
216          p.delete('before');
217          p.set('first', perPage);
218          p.set('after', bugs.pageInfo.endCursor);
219        }).toString(),
220      };
221    }
222    // and this for the previous page
223    if (bugs.pageInfo.hasPreviousPage) {
224      previousPage = {
225        ...location,
226        search: editParams(params, p => {
227          p.delete('first');
228          p.delete('after');
229          p.set('last', perPage);
230          p.set('before', bugs.pageInfo.startCursor);
231        }).toString(),
232      };
233    }
234  }
235
236  // Prepare params without paging for editing filters
237  const paramsWithoutPaging = editParams(params, p => {
238    p.delete('first');
239    p.delete('last');
240    p.delete('before');
241    p.delete('after');
242  });
243  // Returns a new location with the `q` param edited
244  const queryLocation = (query: string) => ({
245    ...location,
246    search: editParams(paramsWithoutPaging, p => p.set('q', query)).toString(),
247  });
248
249  let content;
250  if (loading) {
251    content = <Placeholder count={10} />;
252  } else if (error) {
253    content = <Error error={error} />;
254  } else if (data?.repository) {
255    const bugs = data.repository.bugs;
256
257    if (bugs.totalCount === 0) {
258      content = <NoBug />;
259    } else {
260      content = <List bugs={bugs} />;
261    }
262  }
263
264  const formSubmit = (e: React.FormEvent) => {
265    e.preventDefault();
266    history.push(queryLocation(input));
267  };
268
269  return (
270    <Paper className={classes.main}>
271      <header className={classes.header}>
272        <h1>Issues</h1>
273        <form onSubmit={formSubmit}>
274          <InputBase
275            placeholder="Filter"
276            value={input}
277            onInput={(e: any) => setInput(e.target.value)}
278            classes={{
279              root: classes.search,
280              focused: classes.searchFocused,
281            }}
282          />
283          <button type="submit" hidden>
284            Search
285          </button>
286        </form>
287      </header>
288      <FilterToolbar query={query} queryLocation={queryLocation} />
289      {content}
290      <div className={classes.pagination}>
291        {previousPage ? (
292          <IconButton component={Link} to={previousPage}>
293            <KeyboardArrowLeft />
294          </IconButton>
295        ) : (
296          <IconButton disabled>
297            <KeyboardArrowLeft />
298          </IconButton>
299        )}
300        <div>{loading ? 'Loading' : `Total: ${count}`}</div>
301        {nextPage ? (
302          <IconButton component={Link} to={nextPage}>
303            <KeyboardArrowRight />
304          </IconButton>
305        ) : (
306          <IconButton disabled>
307            <KeyboardArrowRight />
308          </IconButton>
309        )}
310      </div>
311    </Paper>
312  );
313}
314
315export default ListQuery;