1import { ApolloError } from '@apollo/client';
  2import { pipe } from '@arrows/composition';
  3import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
  4import ErrorOutline from '@mui/icons-material/ErrorOutline';
  5import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft';
  6import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight';
  7import { Button, FormControl, Menu, MenuItem } from '@mui/material';
  8import IconButton from '@mui/material/IconButton';
  9import InputBase from '@mui/material/InputBase';
 10import Paper from '@mui/material/Paper';
 11import Skeleton from '@mui/material/Skeleton';
 12import { Theme } from '@mui/material/styles';
 13import makeStyles from '@mui/styles/makeStyles';
 14import * as React from 'react';
 15import { useState, useEffect, useRef } from 'react';
 16import { useLocation, useNavigate, Link } from 'react-router-dom';
 17
 18import { useCurrentIdentityQuery } from '../../components/Identity/CurrentIdentity.generated';
 19import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
 20
 21import { parse, Query, stringify } from './Filter';
 22import FilterToolbar from './FilterToolbar';
 23import List from './List';
 24import { useListBugsQuery } from './ListQuery.generated';
 25
 26type StylesProps = { searching?: boolean };
 27const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
 28  main: {
 29    maxWidth: 800,
 30    margin: 'auto',
 31    marginTop: theme.spacing(4),
 32    marginBottom: theme.spacing(4),
 33    overflow: 'hidden',
 34  },
 35  pagination: {
 36    ...theme.typography.overline,
 37    display: 'flex',
 38    alignItems: 'center',
 39    justifyContent: 'center',
 40  },
 41  header: {
 42    display: 'flex',
 43    padding: theme.spacing(1),
 44  },
 45  filterissueLabel: {
 46    fontSize: '14px',
 47    fontWeight: 'bold',
 48    paddingRight: '12px',
 49  },
 50  form: {
 51    display: 'flex',
 52    flexGrow: 1,
 53    marginRight: theme.spacing(1),
 54  },
 55  search: {
 56    borderRadius: theme.shape.borderRadius,
 57    color: theme.palette.text.secondary,
 58    borderColor: theme.palette.divider,
 59    borderStyle: 'solid',
 60    borderWidth: '1px',
 61    backgroundColor: theme.palette.primary.light,
 62    padding: theme.spacing(0, 1),
 63    width: '100%',
 64    transition: theme.transitions.create([
 65      'width',
 66      'borderColor',
 67      'backgroundColor',
 68    ]),
 69  },
 70  searchFocused: {
 71    backgroundColor: theme.palette.background.paper,
 72  },
 73  placeholderRow: {
 74    padding: theme.spacing(1),
 75    borderBottomColor: theme.palette.divider,
 76    borderBottomWidth: '1px',
 77    borderBottomStyle: 'solid',
 78    display: 'flex',
 79    alignItems: 'center',
 80  },
 81  placeholderRowStatus: {
 82    margin: theme.spacing(1, 2),
 83  },
 84  placeholderRowText: {
 85    flex: 1,
 86  },
 87  message: {
 88    ...theme.typography.h5,
 89    padding: theme.spacing(8),
 90    textAlign: 'center',
 91    color: theme.palette.text.primary,
 92    borderBottomColor: theme.palette.divider,
 93    borderBottomWidth: '1px',
 94    borderBottomStyle: 'solid',
 95    '& > p': {
 96      margin: '0',
 97    },
 98  },
 99  errorBox: {
100    color: theme.palette.error.dark,
101    '& > pre': {
102      fontSize: '1rem',
103      textAlign: 'left',
104      borderColor: theme.palette.divider,
105      borderWidth: '1px',
106      borderRadius: theme.shape.borderRadius,
107      borderStyle: 'solid',
108      color: theme.palette.text.primary,
109      marginTop: theme.spacing(4),
110      padding: theme.spacing(2, 3),
111    },
112  },
113  greenButton: {
114    backgroundColor: theme.palette.success.main,
115    color: theme.palette.success.contrastText,
116    '&:hover': {
117      backgroundColor: theme.palette.success.dark,
118      color: theme.palette.primary.contrastText,
119    },
120  },
121}));
122
123function editParams(
124  params: URLSearchParams,
125  callback: (params: URLSearchParams) => void
126) {
127  const cloned = new URLSearchParams(params.toString());
128  callback(cloned);
129  return cloned;
130}
131
132// TODO: factor this out
133type PlaceholderProps = { count: number };
134const Placeholder: React.FC<PlaceholderProps> = ({
135  count,
136}: PlaceholderProps) => {
137  const classes = useStyles({});
138  return (
139    <>
140      {new Array(count).fill(null).map((_, i) => (
141        <div key={i} className={classes.placeholderRow}>
142          <Skeleton
143            className={classes.placeholderRowStatus}
144            variant="circular"
145            width={20}
146            height={20}
147          />
148          <div className={classes.placeholderRowText}>
149            <Skeleton height={22} />
150            <Skeleton height={24} width="60%" />
151          </div>
152        </div>
153      ))}
154    </>
155  );
156};
157
158// TODO: factor this out
159const NoBug = () => {
160  const classes = useStyles({});
161  return (
162    <div className={classes.message}>
163      <ErrorOutline fontSize="large" />
164      <p>No results matched your search.</p>
165    </div>
166  );
167};
168
169type ErrorProps = { error: ApolloError };
170const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
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 navigate = useNavigate();
189  const params = new URLSearchParams(location.search);
190  const query = params.has('q') ? params.get('q') || '' : 'status:open';
191
192  const [input, setInput] = useState(query);
193  const [filterMenuIsOpen, setFilterMenuIsOpen] = useState(false);
194  const filterButtonRef = useRef<HTMLButtonElement>(null);
195
196  const classes = useStyles({ searching: !!input });
197
198  // TODO is this the right way to do it?
199  const lastQuery = useRef<string | null>(null);
200  useEffect(() => {
201    if (query !== lastQuery.current) {
202      setInput(query);
203    }
204    lastQuery.current = query;
205  }, [query, input, lastQuery]);
206
207  const num = (param: string | null) => (param ? parseInt(param) : null);
208  const page = {
209    first: num(params.get('first')),
210    last: num(params.get('last')),
211    after: params.get('after'),
212    before: params.get('before'),
213  };
214
215  // If nothing set, show the first 10 items
216  if (!page.first && !page.last) {
217    page.first = 10;
218  }
219
220  const perPage = (page.first || page.last || 10).toString();
221
222  const { loading, error, data } = useListBugsQuery({
223    variables: {
224      ...page,
225      query,
226    },
227  });
228
229  let nextPage = null;
230  let previousPage = null;
231  let count = 0;
232  if (!loading && !error && data?.repository?.bugs) {
233    const bugs = data.repository.bugs;
234    count = bugs.totalCount;
235    // This computes the URL for the next page
236    if (bugs.pageInfo.hasNextPage) {
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    }
247    // and this for the previous page
248    if (bugs.pageInfo.hasPreviousPage) {
249      previousPage = {
250        ...location,
251        search: editParams(params, (p) => {
252          p.delete('first');
253          p.delete('after');
254          p.set('last', perPage);
255          p.set('before', bugs.pageInfo.startCursor);
256        }).toString(),
257      };
258    }
259  }
260
261  // Prepare params without paging for editing filters
262  const paramsWithoutPaging = editParams(params, (p) => {
263    p.delete('first');
264    p.delete('last');
265    p.delete('before');
266    p.delete('after');
267  });
268  // Returns a new location with the `q` param edited
269  const queryLocation = (query: string) => ({
270    ...location,
271    search: editParams(paramsWithoutPaging, (p) =>
272      p.set('q', query)
273    ).toString(),
274  });
275
276  let content;
277  if (loading) {
278    content = <Placeholder count={10} />;
279  } else if (error) {
280    content = <Error error={error} />;
281  } else if (data?.repository) {
282    const bugs = data.repository.bugs;
283
284    if (bugs.totalCount === 0) {
285      content = <NoBug />;
286    } else {
287      content = <List bugs={bugs} />;
288    }
289  }
290
291  const formSubmit = (e: React.FormEvent) => {
292    e.preventDefault();
293    navigate(queryLocation(input));
294  };
295
296  const {
297    loading: ciqLoading,
298    error: ciqError,
299    data: ciqData,
300  } = useCurrentIdentityQuery();
301  if (ciqError || ciqLoading || !ciqData?.repository?.userIdentity) {
302    return null;
303  }
304  const user = ciqData.repository.userIdentity;
305
306  const loc = pipe(stringify, queryLocation);
307  const qparams: Query = parse(query);
308  const replaceParam =
309    (key: string, value: string) =>
310    (params: Query): Query => ({
311      ...params,
312      [key]: [value],
313    });
314
315  return (
316    <Paper className={classes.main}>
317      <header className={classes.header}>
318        <form className={classes.form} onSubmit={formSubmit}>
319          <FormControl>
320            <Button
321              aria-haspopup="true"
322              ref={filterButtonRef}
323              onClick={(e) => setFilterMenuIsOpen(true)}
324            >
325              Filter <ArrowDropDownIcon />
326            </Button>
327            <Menu
328              open={filterMenuIsOpen}
329              onClose={() => setFilterMenuIsOpen(false)}
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              component={Link}
373              to="/new"
374            >
375              New bug
376            </Button>
377          )}
378        </IfLoggedIn>
379      </header>
380      <FilterToolbar query={query} queryLocation={queryLocation} />
381      {content}
382      <div className={classes.pagination}>
383        {previousPage ? (
384          <IconButton component={Link} to={previousPage} size="large">
385            <KeyboardArrowLeft />
386          </IconButton>
387        ) : (
388          <IconButton disabled size="large">
389            <KeyboardArrowLeft />
390          </IconButton>
391        )}
392        <div>{loading ? 'Loading' : `Total: ${count}`}</div>
393        {nextPage ? (
394          <IconButton component={Link} to={nextPage} size="large">
395            <KeyboardArrowRight />
396          </IconButton>
397        ) : (
398          <IconButton disabled size="large">
399            <KeyboardArrowRight />
400          </IconButton>
401        )}
402      </div>
403    </Paper>
404  );
405}
406
407export default ListQuery;