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