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(2),
 43    '& > h1': {
 44      ...theme.typography.h6,
 45      margin: theme.spacing(0, 2),
 46    },
 47    alignItems: 'center',
 48    justifyContent: 'space-between',
 49  },
 50  filterissueLabel: {
 51    fontSize: '14px',
 52    fontWeight: 'bold',
 53    paddingRight: '12px',
 54  },
 55  filterissueContainer: {
 56    display: 'flex',
 57    flexDirection: 'row',
 58    alignItems: 'flex-start',
 59    justifyContents: 'left',
 60  },
 61  search: {
 62    borderRadius: theme.shape.borderRadius,
 63    color: theme.palette.text.secondary,
 64    borderColor: theme.palette.divider,
 65    borderStyle: 'solid',
 66    borderWidth: '1px',
 67    backgroundColor: theme.palette.primary.light,
 68    padding: theme.spacing(0, 1),
 69    width: ({ searching }) => (searching ? '20rem' : '15rem'),
 70    transition: theme.transitions.create([
 71      'width',
 72      'borderColor',
 73      'backgroundColor',
 74    ]),
 75  },
 76  searchFocused: {
 77    backgroundColor: theme.palette.background.paper,
 78  },
 79  placeholderRow: {
 80    padding: theme.spacing(1),
 81    borderBottomColor: theme.palette.divider,
 82    borderBottomWidth: '1px',
 83    borderBottomStyle: 'solid',
 84    display: 'flex',
 85    alignItems: 'center',
 86  },
 87  placeholderRowStatus: {
 88    margin: theme.spacing(1, 2),
 89  },
 90  placeholderRowText: {
 91    flex: 1,
 92  },
 93  message: {
 94    ...theme.typography.h5,
 95    padding: theme.spacing(8),
 96    textAlign: 'center',
 97    color: theme.palette.text.hint,
 98    borderBottomColor: theme.palette.divider,
 99    borderBottomWidth: '1px',
100    borderBottomStyle: 'solid',
101    '& > p': {
102      margin: '0',
103    },
104  },
105  errorBox: {
106    color: theme.palette.error.dark,
107    '& > pre': {
108      fontSize: '1rem',
109      textAlign: 'left',
110      borderColor: theme.palette.divider,
111      borderWidth: '1px',
112      borderRadius: theme.shape.borderRadius,
113      borderStyle: 'solid',
114      color: theme.palette.text.primary,
115      marginTop: theme.spacing(4),
116      padding: theme.spacing(2, 3),
117    },
118  },
119  greenButton: {
120    backgroundColor: theme.palette.success.main,
121    color: theme.palette.success.contrastText,
122    '&:hover': {
123      backgroundColor: theme.palette.success.dark,
124      color: theme.palette.primary.contrastText,
125    },
126  },
127}));
128
129function editParams(
130  params: URLSearchParams,
131  callback: (params: URLSearchParams) => void
132) {
133  const cloned = new URLSearchParams(params.toString());
134  callback(cloned);
135  return cloned;
136}
137
138// TODO: factor this out
139type PlaceholderProps = { count: number };
140const Placeholder: React.FC<PlaceholderProps> = ({
141  count,
142}: PlaceholderProps) => {
143  const classes = useStyles({});
144  return (
145    <>
146      {new Array(count).fill(null).map((_, i) => (
147        <div key={i} className={classes.placeholderRow}>
148          <Skeleton
149            className={classes.placeholderRowStatus}
150            variant="circle"
151            width={20}
152            height={20}
153          />
154          <div className={classes.placeholderRowText}>
155            <Skeleton height={22} />
156            <Skeleton height={24} width="60%" />
157          </div>
158        </div>
159      ))}
160    </>
161  );
162};
163
164// TODO: factor this out
165const NoBug = () => {
166  const classes = useStyles({});
167  return (
168    <div className={classes.message}>
169      <ErrorOutline fontSize="large" />
170      <p>No results matched your search.</p>
171    </div>
172  );
173};
174
175type ErrorProps = { error: ApolloError };
176const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
177  const classes = useStyles({});
178  return (
179    <div className={[classes.errorBox, classes.message].join(' ')}>
180      <ErrorOutline fontSize="large" />
181      <p>There was an error while fetching bug.</p>
182      <p>
183        <em>{error.message}</em>
184      </p>
185      <pre>
186        <code>{JSON.stringify(error, null, 2)}</code>
187      </pre>
188    </div>
189  );
190};
191
192function ListQuery() {
193  const location = useLocation();
194  const history = useHistory();
195  const params = new URLSearchParams(location.search);
196  const query = params.has('q') ? params.get('q') || '' : 'status:open';
197
198  const [input, setInput] = useState(query);
199  const [filterMenuIsOpen, setFilterMenuIsOpen] = useState(false);
200  const filterButtonRef = useRef<HTMLButtonElement>(null);
201
202  const classes = useStyles({ searching: !!input });
203
204  // TODO is this the right way to do it?
205  const lastQuery = useRef<string | null>(null);
206  useEffect(() => {
207    if (query !== lastQuery.current) {
208      setInput(query);
209    }
210    lastQuery.current = query;
211  }, [query, input, lastQuery]);
212
213  const num = (param: string | null) => (param ? parseInt(param) : null);
214  const page = {
215    first: num(params.get('first')),
216    last: num(params.get('last')),
217    after: params.get('after'),
218    before: params.get('before'),
219  };
220
221  // If nothing set, show the first 10 items
222  if (!page.first && !page.last) {
223    page.first = 10;
224  }
225
226  const perPage = (page.first || page.last || 10).toString();
227
228  const { loading, error, data } = useListBugsQuery({
229    variables: {
230      ...page,
231      query,
232    },
233  });
234
235  let nextPage = null;
236  let previousPage = null;
237  let count = 0;
238  if (!loading && !error && data?.repository?.bugs) {
239    const bugs = data.repository.bugs;
240    count = bugs.totalCount;
241    // This computes the URL for the next page
242    if (bugs.pageInfo.hasNextPage) {
243      nextPage = {
244        ...location,
245        search: editParams(params, (p) => {
246          p.delete('last');
247          p.delete('before');
248          p.set('first', perPage);
249          p.set('after', bugs.pageInfo.endCursor);
250        }).toString(),
251      };
252    }
253    // and this for the previous page
254    if (bugs.pageInfo.hasPreviousPage) {
255      previousPage = {
256        ...location,
257        search: editParams(params, (p) => {
258          p.delete('first');
259          p.delete('after');
260          p.set('last', perPage);
261          p.set('before', bugs.pageInfo.startCursor);
262        }).toString(),
263      };
264    }
265  }
266
267  // Prepare params without paging for editing filters
268  const paramsWithoutPaging = editParams(params, (p) => {
269    p.delete('first');
270    p.delete('last');
271    p.delete('before');
272    p.delete('after');
273  });
274  // Returns a new location with the `q` param edited
275  const queryLocation = (query: string) => ({
276    ...location,
277    search: editParams(paramsWithoutPaging, (p) =>
278      p.set('q', query)
279    ).toString(),
280  });
281
282  let content;
283  if (loading) {
284    content = <Placeholder count={10} />;
285  } else if (error) {
286    content = <Error error={error} />;
287  } else if (data?.repository) {
288    const bugs = data.repository.bugs;
289
290    if (bugs.totalCount === 0) {
291      content = <NoBug />;
292    } else {
293      content = <List bugs={bugs} />;
294    }
295  }
296
297  const formSubmit = (e: React.FormEvent) => {
298    e.preventDefault();
299    history.push(queryLocation(input));
300  };
301
302  const {
303    loading: ciqLoading,
304    error: ciqError,
305    data: ciqData,
306  } = useCurrentIdentityQuery();
307  if (ciqError || ciqLoading || !ciqData?.repository?.userIdentity) {
308    return null;
309  }
310  const user = ciqData.repository.userIdentity;
311
312  const loc = pipe(stringify, queryLocation);
313  const qparams: Query = parse(query);
314  const replaceParam = (key: string, value: string) => (
315    params: Query
316  ): Query => ({
317    ...params,
318    [key]: [value],
319  });
320
321  return (
322    <Paper className={classes.main}>
323      <header className={classes.header}>
324        <div className="filterissueContainer">
325          <form onSubmit={formSubmit}>
326            <FormControl>
327              <Button
328                aria-haspopup="true"
329                ref={filterButtonRef}
330                onClick={(e) => setFilterMenuIsOpen(true)}
331              >
332                Filter <ArrowDropDownIcon />
333              </Button>
334              <Menu
335                open={filterMenuIsOpen}
336                onClose={() => setFilterMenuIsOpen(false)}
337                getContentAnchorEl={null}
338                anchorEl={filterButtonRef.current}
339                anchorOrigin={{
340                  vertical: 'bottom',
341                  horizontal: 'left',
342                }}
343                transformOrigin={{
344                  vertical: 'top',
345                  horizontal: 'left',
346                }}
347              >
348                <MenuItem
349                  component={Link}
350                  to={pipe(
351                    replaceParam('author', user.displayName),
352                    replaceParam('sort', 'creation'),
353                    loc
354                  )(qparams)}
355                  onClick={() => setFilterMenuIsOpen(false)}
356                >
357                  Your newest issues
358                </MenuItem>
359              </Menu>
360            </FormControl>
361            <InputBase
362              id="issuefilter"
363              placeholder="Filter"
364              value={input}
365              onInput={(e: any) => setInput(e.target.value)}
366              classes={{
367                root: classes.search,
368                focused: classes.searchFocused,
369              }}
370            />
371            <button type="submit" hidden>
372              Search
373            </button>
374          </form>
375        </div>
376        <IfLoggedIn>
377          {() => (
378            <Button
379              className={classes.greenButton}
380              variant="contained"
381              href="/new"
382            >
383              New bug
384            </Button>
385          )}
386        </IfLoggedIn>
387      </header>
388      <FilterToolbar query={query} queryLocation={queryLocation} />
389      {content}
390      <div className={classes.pagination}>
391        {previousPage ? (
392          <IconButton component={Link} to={previousPage}>
393            <KeyboardArrowLeft />
394          </IconButton>
395        ) : (
396          <IconButton disabled>
397            <KeyboardArrowLeft />
398          </IconButton>
399        )}
400        <div>{loading ? 'Loading' : `Total: ${count}`}</div>
401        {nextPage ? (
402          <IconButton component={Link} to={nextPage}>
403            <KeyboardArrowRight />
404          </IconButton>
405        ) : (
406          <IconButton disabled>
407            <KeyboardArrowRight />
408          </IconButton>
409        )}
410      </div>
411    </Paper>
412  );
413}
414
415export default ListQuery;