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