ListQuery.tsx

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