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