ListQuery.js

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