ListQuery.tsx

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