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  greenButton: {
116    backgroundColor: theme.palette.success.main,
117    color: theme.palette.success.contrastText,
118    '&:hover': {
119      backgroundColor: theme.palette.success.dark,
120      color: theme.palette.primary.contrastText,
121    },
122  },
123}));
124
125function editParams(
126  params: URLSearchParams,
127  callback: (params: URLSearchParams) => void
128) {
129  const cloned = new URLSearchParams(params.toString());
130  callback(cloned);
131  return cloned;
132}
133
134// TODO: factor this out
135type PlaceholderProps = { count: number };
136const Placeholder: React.FC<PlaceholderProps> = ({
137  count,
138}: PlaceholderProps) => {
139  const classes = useStyles({});
140  return (
141    <>
142      {new Array(count).fill(null).map((_, i) => (
143        <div key={i} className={classes.placeholderRow}>
144          <Skeleton
145            className={classes.placeholderRowStatus}
146            variant="circle"
147            width={20}
148            height={20}
149          />
150          <div className={classes.placeholderRowText}>
151            <Skeleton height={22} />
152            <Skeleton height={24} width="60%" />
153          </div>
154        </div>
155      ))}
156    </>
157  );
158};
159
160// TODO: factor this out
161const NoBug = () => {
162  const classes = useStyles({});
163  return (
164    <div className={classes.message}>
165      <ErrorOutline fontSize="large" />
166      <p>No results matched your search.</p>
167    </div>
168  );
169};
170
171type ErrorProps = { error: ApolloError };
172const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
173  const classes = useStyles({});
174  return (
175    <div className={[classes.errorBox, classes.message].join(' ')}>
176      <ErrorOutline fontSize="large" />
177      <p>There was an error while fetching bug.</p>
178      <p>
179        <em>{error.message}</em>
180      </p>
181      <pre>
182        <code>{JSON.stringify(error, null, 2)}</code>
183      </pre>
184    </div>
185  );
186};
187
188function ListQuery() {
189  const location = useLocation();
190  const history = useHistory();
191  const params = new URLSearchParams(location.search);
192  const query = params.has('q') ? params.get('q') || '' : 'status:open';
193
194  const [input, setInput] = useState(query);
195
196  const classes = useStyles({ searching: !!input });
197
198  // TODO is this the right way to do it?
199  const lastQuery = useRef<string | null>(null);
200  useEffect(() => {
201    if (query !== lastQuery.current) {
202      setInput(query);
203    }
204    lastQuery.current = query;
205  }, [query, input, lastQuery]);
206
207  const num = (param: string | null) => (param ? parseInt(param) : null);
208  const page = {
209    first: num(params.get('first')),
210    last: num(params.get('last')),
211    after: params.get('after'),
212    before: params.get('before'),
213  };
214
215  // If nothing set, show the first 10 items
216  if (!page.first && !page.last) {
217    page.first = 10;
218  }
219
220  const perPage = (page.first || page.last || 10).toString();
221
222  const { loading, error, data } = useListBugsQuery({
223    variables: {
224      ...page,
225      query,
226    },
227  });
228
229  let nextPage = null;
230  let previousPage = null;
231  let count = 0;
232  if (!loading && !error && data?.repository?.bugs) {
233    const bugs = data.repository.bugs;
234    count = bugs.totalCount;
235    // This computes the URL for the next page
236    if (bugs.pageInfo.hasNextPage) {
237      nextPage = {
238        ...location,
239        search: editParams(params, (p) => {
240          p.delete('last');
241          p.delete('before');
242          p.set('first', perPage);
243          p.set('after', bugs.pageInfo.endCursor);
244        }).toString(),
245      };
246    }
247    // and this for the previous page
248    if (bugs.pageInfo.hasPreviousPage) {
249      previousPage = {
250        ...location,
251        search: editParams(params, (p) => {
252          p.delete('first');
253          p.delete('after');
254          p.set('last', perPage);
255          p.set('before', bugs.pageInfo.startCursor);
256        }).toString(),
257      };
258    }
259  }
260
261  // Prepare params without paging for editing filters
262  const paramsWithoutPaging = editParams(params, (p) => {
263    p.delete('first');
264    p.delete('last');
265    p.delete('before');
266    p.delete('after');
267  });
268  // Returns a new location with the `q` param edited
269  const queryLocation = (query: string) => ({
270    ...location,
271    search: editParams(paramsWithoutPaging, (p) =>
272      p.set('q', query)
273    ).toString(),
274  });
275
276  let content;
277  if (loading) {
278    content = <Placeholder count={10} />;
279  } else if (error) {
280    content = <Error error={error} />;
281  } else if (data?.repository) {
282    const bugs = data.repository.bugs;
283
284    if (bugs.totalCount === 0) {
285      content = <NoBug />;
286    } else {
287      content = <List bugs={bugs} />;
288    }
289  }
290
291  const formSubmit = (e: React.FormEvent) => {
292    e.preventDefault();
293    history.push(queryLocation(input));
294  };
295
296  return (
297    <Paper className={classes.main}>
298      <header className={classes.header}>
299        <div className="filterissueContainer">
300          <form onSubmit={formSubmit}>
301            <label className={classes.filterissueLabel} htmlFor="issuefilter">
302              Filter
303            </label>
304            <InputBase
305              id="issuefilter"
306              placeholder="Filter"
307              value={input}
308              onInput={(e: any) => setInput(e.target.value)}
309              classes={{
310                root: classes.search,
311                focused: classes.searchFocused,
312              }}
313            />
314            <button type="submit" hidden>
315              Search
316            </button>
317          </form>
318        </div>
319        <IfLoggedIn>
320          {() => (
321            <Button
322              className={classes.greenButton}
323              variant="contained"
324              href="/new"
325            >
326              New bug
327            </Button>
328          )}
329        </IfLoggedIn>
330      </header>
331      <FilterToolbar query={query} queryLocation={queryLocation} />
332      {content}
333      <div className={classes.pagination}>
334        {previousPage ? (
335          <IconButton component={Link} to={previousPage}>
336            <KeyboardArrowLeft />
337          </IconButton>
338        ) : (
339          <IconButton disabled>
340            <KeyboardArrowLeft />
341          </IconButton>
342        )}
343        <div>{loading ? 'Loading' : `Total: ${count}`}</div>
344        {nextPage ? (
345          <IconButton component={Link} to={nextPage}>
346            <KeyboardArrowRight />
347          </IconButton>
348        ) : (
349          <IconButton disabled>
350            <KeyboardArrowRight />
351          </IconButton>
352        )}
353      </div>
354    </Paper>
355  );
356}
357
358export default ListQuery;