1import { ApolloError } from '@apollo/client';
2import { pipe } from '@arrows/composition';
3import { useState, useEffect, useRef } from 'react';
4import * as React from 'react';
5import { useLocation, useHistory, Link } from 'react-router-dom';
6
7import { Button, FormControl, Menu, MenuItem } from '@material-ui/core';
8import IconButton from '@material-ui/core/IconButton';
9import InputBase from '@material-ui/core/InputBase';
10import Paper from '@material-ui/core/Paper';
11import { makeStyles, Theme } from '@material-ui/core/styles';
12import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
13import ErrorOutline from '@material-ui/icons/ErrorOutline';
14import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
15import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
16import Skeleton from '@material-ui/lab/Skeleton';
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.hint,
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="circle"
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 history = useHistory();
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 history.push(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 getContentAnchorEl={null}
331 anchorEl={filterButtonRef.current}
332 anchorOrigin={{
333 vertical: 'bottom',
334 horizontal: 'left',
335 }}
336 transformOrigin={{
337 vertical: 'top',
338 horizontal: 'left',
339 }}
340 >
341 <MenuItem
342 component={Link}
343 to={pipe(
344 replaceParam('author', user.displayName),
345 replaceParam('sort', 'creation'),
346 loc
347 )(qparams)}
348 onClick={() => setFilterMenuIsOpen(false)}
349 >
350 Your newest issues
351 </MenuItem>
352 </Menu>
353 </FormControl>
354 <InputBase
355 id="issuefilter"
356 placeholder="Filter"
357 value={input}
358 onInput={(e: any) => setInput(e.target.value)}
359 classes={{
360 root: classes.search,
361 focused: classes.searchFocused,
362 }}
363 />
364 <button type="submit" hidden>
365 Search
366 </button>
367 </form>
368 <IfLoggedIn>
369 {() => (
370 <Button
371 className={classes.greenButton}
372 variant="contained"
373 component={Link}
374 to="/new"
375 >
376 New bug
377 </Button>
378 )}
379 </IfLoggedIn>
380 </header>
381 <FilterToolbar query={query} queryLocation={queryLocation} />
382 {content}
383 <div className={classes.pagination}>
384 {previousPage ? (
385 <IconButton component={Link} to={previousPage}>
386 <KeyboardArrowLeft />
387 </IconButton>
388 ) : (
389 <IconButton disabled>
390 <KeyboardArrowLeft />
391 </IconButton>
392 )}
393 <div>{loading ? 'Loading' : `Total: ${count}`}</div>
394 {nextPage ? (
395 <IconButton component={Link} to={nextPage}>
396 <KeyboardArrowRight />
397 </IconButton>
398 ) : (
399 <IconButton disabled>
400 <KeyboardArrowRight />
401 </IconButton>
402 )}
403 </div>
404 </Paper>
405 );
406}
407
408export default ListQuery;