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