ListQuery.tsx

  1import { ApolloError } from '@apollo/client';
  2import { pipe } from '@arrows/composition';
  3import React, { useState, useEffect, useRef } from 'react';
  4import { useLocation, useHistory, Link } from 'react-router-dom';
  5
  6import { Button, FormControl, Menu, MenuItem } from '@material-ui/core';
  7import IconButton from '@material-ui/core/IconButton';
  8import InputBase from '@material-ui/core/InputBase';
  9import Paper from '@material-ui/core/Paper';
 10import { makeStyles, Theme } from '@material-ui/core/styles';
 11import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
 12import ErrorOutline from '@material-ui/icons/ErrorOutline';
 13import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
 14import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
 15import Skeleton from '@material-ui/lab/Skeleton';
 16
 17import { useCurrentIdentityQuery } from '../../components/CurrentIdentity/CurrentIdentity.generated';
 18import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
 19
 20import { parse, Query, stringify } from './Filter';
 21import FilterToolbar from './FilterToolbar';
 22import List from './List';
 23import { useListBugsQuery } from './ListQuery.generated';
 24
 25type StylesProps = { searching?: boolean };
 26const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
 27  main: {
 28    maxWidth: 800,
 29    margin: 'auto',
 30    marginTop: theme.spacing(4),
 31    marginBottom: theme.spacing(4),
 32    overflow: 'hidden',
 33  },
 34  pagination: {
 35    ...theme.typography.overline,
 36    display: 'flex',
 37    alignItems: 'center',
 38    justifyContent: 'center',
 39  },
 40  header: {
 41    display: 'flex',
 42    padding: theme.spacing(1),
 43  },
 44  filterissueLabel: {
 45    fontSize: '14px',
 46    fontWeight: 'bold',
 47    paddingRight: '12px',
 48  },
 49  form: {
 50    display: 'flex',
 51    flexGrow: 1,
 52    marginRight: theme.spacing(1),
 53  },
 54  search: {
 55    borderRadius: theme.shape.borderRadius,
 56    color: theme.palette.text.secondary,
 57    borderColor: theme.palette.divider,
 58    borderStyle: 'solid',
 59    borderWidth: '1px',
 60    backgroundColor: theme.palette.primary.light,
 61    padding: theme.spacing(0, 1),
 62    width: '100%',
 63    transition: theme.transitions.create([
 64      'width',
 65      'borderColor',
 66      'backgroundColor',
 67    ]),
 68  },
 69  searchFocused: {
 70    backgroundColor: theme.palette.background.paper,
 71  },
 72  placeholderRow: {
 73    padding: theme.spacing(1),
 74    borderBottomColor: theme.palette.divider,
 75    borderBottomWidth: '1px',
 76    borderBottomStyle: 'solid',
 77    display: 'flex',
 78    alignItems: 'center',
 79  },
 80  placeholderRowStatus: {
 81    margin: theme.spacing(1, 2),
 82  },
 83  placeholderRowText: {
 84    flex: 1,
 85  },
 86  message: {
 87    ...theme.typography.h5,
 88    padding: theme.spacing(8),
 89    textAlign: 'center',
 90    color: theme.palette.text.hint,
 91    borderBottomColor: theme.palette.divider,
 92    borderBottomWidth: '1px',
 93    borderBottomStyle: 'solid',
 94    '& > p': {
 95      margin: '0',
 96    },
 97  },
 98  errorBox: {
 99    color: theme.palette.error.dark,
100    '& > pre': {
101      fontSize: '1rem',
102      textAlign: 'left',
103      borderColor: theme.palette.divider,
104      borderWidth: '1px',
105      borderRadius: theme.shape.borderRadius,
106      borderStyle: 'solid',
107      color: theme.palette.text.primary,
108      marginTop: theme.spacing(4),
109      padding: theme.spacing(2, 3),
110    },
111  },
112  greenButton: {
113    backgroundColor: theme.palette.success.main,
114    color: theme.palette.success.contrastText,
115    '&:hover': {
116      backgroundColor: theme.palette.success.dark,
117      color: theme.palette.primary.contrastText,
118    },
119  },
120}));
121
122function editParams(
123  params: URLSearchParams,
124  callback: (params: URLSearchParams) => void
125) {
126  const cloned = new URLSearchParams(params.toString());
127  callback(cloned);
128  return cloned;
129}
130
131// TODO: factor this out
132type PlaceholderProps = { count: number };
133const Placeholder: React.FC<PlaceholderProps> = ({
134  count,
135}: PlaceholderProps) => {
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
168type ErrorProps = { error: ApolloError };
169const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
170  const classes = useStyles({});
171  return (
172    <div className={[classes.errorBox, classes.message].join(' ')}>
173      <ErrorOutline fontSize="large" />
174      <p>There was an error while fetching bug.</p>
175      <p>
176        <em>{error.message}</em>
177      </p>
178      <pre>
179        <code>{JSON.stringify(error, null, 2)}</code>
180      </pre>
181    </div>
182  );
183};
184
185function ListQuery() {
186  const location = useLocation();
187  const history = useHistory();
188  const params = new URLSearchParams(location.search);
189  const query = params.has('q') ? params.get('q') || '' : 'status:open';
190
191  const [input, setInput] = useState(query);
192  const [filterMenuIsOpen, setFilterMenuIsOpen] = useState(false);
193  const filterButtonRef = useRef<HTMLButtonElement>(null);
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  const {
296    loading: ciqLoading,
297    error: ciqError,
298    data: ciqData,
299  } = useCurrentIdentityQuery();
300  if (ciqError || ciqLoading || !ciqData?.repository?.userIdentity) {
301    return null;
302  }
303  const user = ciqData.repository.userIdentity;
304
305  const loc = pipe(stringify, queryLocation);
306  const qparams: Query = parse(query);
307  const replaceParam = (key: string, value: string) => (
308    params: Query
309  ): Query => ({
310    ...params,
311    [key]: [value],
312  });
313
314  return (
315    <Paper className={classes.main}>
316      <header className={classes.header}>
317        <form className={classes.form} onSubmit={formSubmit}>
318          <FormControl>
319            <Button
320              aria-haspopup="true"
321              ref={filterButtonRef}
322              onClick={(e) => setFilterMenuIsOpen(true)}
323            >
324              Filter <ArrowDropDownIcon />
325            </Button>
326            <Menu
327              open={filterMenuIsOpen}
328              onClose={() => setFilterMenuIsOpen(false)}
329              getContentAnchorEl={null}
330              anchorEl={filterButtonRef.current}
331              anchorOrigin={{
332                vertical: 'bottom',
333                horizontal: 'left',
334              }}
335              transformOrigin={{
336                vertical: 'top',
337                horizontal: 'left',
338              }}
339            >
340              <MenuItem
341                component={Link}
342                to={pipe(
343                  replaceParam('author', user.displayName),
344                  replaceParam('sort', 'creation'),
345                  loc
346                )(qparams)}
347                onClick={() => setFilterMenuIsOpen(false)}
348              >
349                Your newest issues
350              </MenuItem>
351            </Menu>
352          </FormControl>
353          <InputBase
354            id="issuefilter"
355            placeholder="Filter"
356            value={input}
357            onInput={(e: any) => setInput(e.target.value)}
358            classes={{
359              root: classes.search,
360              focused: classes.searchFocused,
361            }}
362          />
363          <button type="submit" hidden>
364            Search
365          </button>
366        </form>
367        <IfLoggedIn>
368          {() => (
369            <Button
370              className={classes.greenButton}
371              variant="contained"
372              href="/new"
373            >
374              New bug
375            </Button>
376          )}
377        </IfLoggedIn>
378      </header>
379      <FilterToolbar query={query} queryLocation={queryLocation} />
380      {content}
381      <div className={classes.pagination}>
382        {previousPage ? (
383          <IconButton component={Link} to={previousPage}>
384            <KeyboardArrowLeft />
385          </IconButton>
386        ) : (
387          <IconButton disabled>
388            <KeyboardArrowLeft />
389          </IconButton>
390        )}
391        <div>{loading ? 'Loading' : `Total: ${count}`}</div>
392        {nextPage ? (
393          <IconButton component={Link} to={nextPage}>
394            <KeyboardArrowRight />
395          </IconButton>
396        ) : (
397          <IconButton disabled>
398            <KeyboardArrowRight />
399          </IconButton>
400        )}
401      </div>
402    </Paper>
403  );
404}
405
406export default ListQuery;