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;