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