ListQuery.tsx

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