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 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}>
385 <KeyboardArrowLeft />
386 </IconButton>
387 ) : (
388 <IconButton disabled>
389 <KeyboardArrowLeft />
390 </IconButton>
391 )}
392 <div>{loading ? 'Loading' : `Total: ${count}`}</div>
393 {nextPage ? (
394 <IconButton component={Link} to={nextPage}>
395 <KeyboardArrowRight />
396 </IconButton>
397 ) : (
398 <IconButton disabled>
399 <KeyboardArrowRight />
400 </IconButton>
401 )}
402 </div>
403 </Paper>
404 );
405}
406
407export default ListQuery;