ListQuery.tsx

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