ListQuery.tsx

  1import { ApolloError } from '@apollo/client';
  2import React, { useState, useEffect, useRef } from 'react';
  3import { useLocation, useHistory, Link } from 'react-router-dom';
  4
  5import { Button } from '@material-ui/core';
  6import IconButton from '@material-ui/core/IconButton';
  7import InputBase from '@material-ui/core/InputBase';
  8import Paper from '@material-ui/core/Paper';
  9import { makeStyles, Theme } from '@material-ui/core/styles';
 10import ErrorOutline from '@material-ui/icons/ErrorOutline';
 11import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
 12import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
 13import Skeleton from '@material-ui/lab/Skeleton';
 14
 15import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
 16
 17import FilterToolbar from './FilterToolbar';
 18import List from './List';
 19import { useListBugsQuery } from './ListQuery.generated';
 20
 21type StylesProps = { searching?: boolean };
 22const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
 23  main: {
 24    maxWidth: 800,
 25    margin: 'auto',
 26    marginTop: theme.spacing(4),
 27    marginBottom: theme.spacing(4),
 28    overflow: 'hidden',
 29  },
 30  pagination: {
 31    ...theme.typography.overline,
 32    display: 'flex',
 33    alignItems: 'center',
 34    justifyContent: 'center',
 35  },
 36  header: {
 37    display: 'flex',
 38    padding: theme.spacing(2),
 39    '& > h1': {
 40      ...theme.typography.h6,
 41      margin: theme.spacing(0, 2),
 42    },
 43    alignItems: 'center',
 44    justifyContent: 'space-between',
 45  },
 46  filterissueLabel: {
 47    fontSize: '14px',
 48    fontWeight: 'bold',
 49    paddingRight: '12px',
 50  },
 51  filterissueContainer: {
 52    display: 'flex',
 53    flexDirection: 'row',
 54    alignItems: 'flex-start',
 55    justifyContents: 'left',
 56  },
 57  search: {
 58    borderRadius: theme.shape.borderRadius,
 59    color: theme.palette.text.secondary,
 60    borderColor: theme.palette.divider,
 61    borderStyle: 'solid',
 62    borderWidth: '1px',
 63    backgroundColor: theme.palette.primary.light,
 64    padding: theme.spacing(0, 1),
 65    width: ({ searching }) => (searching ? '20rem' : '15rem'),
 66    transition: theme.transitions.create([
 67      'width',
 68      'borderColor',
 69      'backgroundColor',
 70    ]),
 71  },
 72  searchFocused: {
 73    backgroundColor: theme.palette.background.paper,
 74  },
 75  placeholderRow: {
 76    padding: theme.spacing(1),
 77    borderBottomColor: theme.palette.divider,
 78    borderBottomWidth: '1px',
 79    borderBottomStyle: 'solid',
 80    display: 'flex',
 81    alignItems: 'center',
 82  },
 83  placeholderRowStatus: {
 84    margin: theme.spacing(1, 2),
 85  },
 86  placeholderRowText: {
 87    flex: 1,
 88  },
 89  message: {
 90    ...theme.typography.h5,
 91    padding: theme.spacing(8),
 92    textAlign: 'center',
 93    color: theme.palette.text.hint,
 94    borderBottomColor: theme.palette.divider,
 95    borderBottomWidth: '1px',
 96    borderBottomStyle: 'solid',
 97    '& > p': {
 98      margin: '0',
 99    },
100  },
101  errorBox: {
102    color: theme.palette.error.dark,
103    '& > pre': {
104      fontSize: '1rem',
105      textAlign: 'left',
106      borderColor: theme.palette.divider,
107      borderWidth: '1px',
108      borderRadius: theme.shape.borderRadius,
109      borderStyle: 'solid',
110      color: theme.palette.text.primary,
111      marginTop: theme.spacing(4),
112      padding: theme.spacing(2, 3),
113    },
114  },
115  greenButton: {
116    backgroundColor: '#2ea44fd9',
117    color: '#fff',
118    '&:hover': {
119      backgroundColor: '#2ea44f',
120    },
121  },
122}));
123
124function editParams(
125  params: URLSearchParams,
126  callback: (params: URLSearchParams) => void
127) {
128  const cloned = new URLSearchParams(params.toString());
129  callback(cloned);
130  return cloned;
131}
132
133// TODO: factor this out
134type PlaceholderProps = { count: number };
135const Placeholder: React.FC<PlaceholderProps> = ({
136  count,
137}: PlaceholderProps) => {
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
170type ErrorProps = { error: ApolloError };
171const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
172  const classes = useStyles({});
173  return (
174    <div className={[classes.errorBox, classes.message].join(' ')}>
175      <ErrorOutline fontSize="large" />
176      <p>There was an error while fetching bug.</p>
177      <p>
178        <em>{error.message}</em>
179      </p>
180      <pre>
181        <code>{JSON.stringify(error, null, 2)}</code>
182      </pre>
183    </div>
184  );
185};
186
187function ListQuery() {
188  const location = useLocation();
189  const history = useHistory();
190  const params = new URLSearchParams(location.search);
191  const query = params.has('q') ? params.get('q') || '' : 'status:open';
192
193  const [input, setInput] = useState(query);
194
195  const classes = useStyles({ searching: !!input });
196
197  // TODO is this the right way to do it?
198  const lastQuery = useRef<string | null>(null);
199  useEffect(() => {
200    if (query !== lastQuery.current) {
201      setInput(query);
202    }
203    lastQuery.current = query;
204  }, [query, input, lastQuery]);
205
206  const num = (param: string | null) => (param ? parseInt(param) : null);
207  const page = {
208    first: num(params.get('first')),
209    last: num(params.get('last')),
210    after: params.get('after'),
211    before: params.get('before'),
212  };
213
214  // If nothing set, show the first 10 items
215  if (!page.first && !page.last) {
216    page.first = 10;
217  }
218
219  const perPage = (page.first || page.last || 10).toString();
220
221  const { loading, error, data } = useListBugsQuery({
222    variables: {
223      ...page,
224      query,
225    },
226  });
227
228  let nextPage = null;
229  let previousPage = null;
230  let count = 0;
231  if (!loading && !error && data?.repository?.bugs) {
232    const bugs = data.repository.bugs;
233    count = bugs.totalCount;
234    // This computes the URL for the next page
235    if (bugs.pageInfo.hasNextPage) {
236      nextPage = {
237        ...location,
238        search: editParams(params, (p) => {
239          p.delete('last');
240          p.delete('before');
241          p.set('first', perPage);
242          p.set('after', bugs.pageInfo.endCursor);
243        }).toString(),
244      };
245    }
246    // and this for the previous page
247    if (bugs.pageInfo.hasPreviousPage) {
248      previousPage = {
249        ...location,
250        search: editParams(params, (p) => {
251          p.delete('first');
252          p.delete('after');
253          p.set('last', perPage);
254          p.set('before', bugs.pageInfo.startCursor);
255        }).toString(),
256      };
257    }
258  }
259
260  // Prepare params without paging for editing filters
261  const paramsWithoutPaging = editParams(params, (p) => {
262    p.delete('first');
263    p.delete('last');
264    p.delete('before');
265    p.delete('after');
266  });
267  // Returns a new location with the `q` param edited
268  const queryLocation = (query: string) => ({
269    ...location,
270    search: editParams(paramsWithoutPaging, (p) =>
271      p.set('q', query)
272    ).toString(),
273  });
274
275  let content;
276  if (loading) {
277    content = <Placeholder count={10} />;
278  } else if (error) {
279    content = <Error error={error} />;
280  } else if (data?.repository) {
281    const bugs = data.repository.bugs;
282
283    if (bugs.totalCount === 0) {
284      content = <NoBug />;
285    } else {
286      content = <List bugs={bugs} />;
287    }
288  }
289
290  const formSubmit = (e: React.FormEvent) => {
291    e.preventDefault();
292    history.push(queryLocation(input));
293  };
294
295  return (
296    <Paper className={classes.main}>
297      <header className={classes.header}>
298        <div className="filterissueContainer">
299          <form onSubmit={formSubmit}>
300            <label className={classes.filterissueLabel} htmlFor="issuefilter">
301              Filter
302            </label>
303            <InputBase
304              id="issuefilter"
305              placeholder="Filter"
306              value={input}
307              onInput={(e: any) => setInput(e.target.value)}
308              classes={{
309                root: classes.search,
310                focused: classes.searchFocused,
311              }}
312            />
313            <button type="submit" hidden>
314              Search
315            </button>
316          </form>
317        </div>
318        <IfLoggedIn>
319          {() => (
320            <Button
321              className={classes.greenButton}
322              variant="contained"
323              href="/new"
324            >
325              New issue
326            </Button>
327          )}
328        </IfLoggedIn>
329      </header>
330      <FilterToolbar query={query} queryLocation={queryLocation} />
331      {content}
332      <div className={classes.pagination}>
333        {previousPage ? (
334          <IconButton component={Link} to={previousPage}>
335            <KeyboardArrowLeft />
336          </IconButton>
337        ) : (
338          <IconButton disabled>
339            <KeyboardArrowLeft />
340          </IconButton>
341        )}
342        <div>{loading ? 'Loading' : `Total: ${count}`}</div>
343        {nextPage ? (
344          <IconButton component={Link} to={nextPage}>
345            <KeyboardArrowRight />
346          </IconButton>
347        ) : (
348          <IconButton disabled>
349            <KeyboardArrowRight />
350          </IconButton>
351        )}
352      </div>
353    </Paper>
354  );
355}
356
357export default ListQuery;