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 { 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 IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
16
17import FilterToolbar from './FilterToolbar';
18import List from './List';
19import { useListBugsQuery } from './ListQuery.generated';
20
21type StylesProps = { searching?: boolean };
22const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
23 main: {
24 maxWidth: 800,
25 margin: 'auto',
26 marginTop: theme.spacing(4),
27 marginBottom: theme.spacing(4),
28 overflow: 'hidden',
29 },
30 pagination: {
31 ...theme.typography.overline,
32 display: 'flex',
33 alignItems: 'center',
34 justifyContent: 'center',
35 },
36 header: {
37 display: 'flex',
38 padding: theme.spacing(2),
39 '& > h1': {
40 ...theme.typography.h6,
41 margin: theme.spacing(0, 2),
42 },
43 alignItems: 'center',
44 justifyContent: 'space-between',
45 },
46 filterissueLabel: {
47 fontSize: '14px',
48 fontWeight: 'bold',
49 paddingRight: '12px',
50 },
51 filterissueContainer: {
52 display: 'flex',
53 flexDirection: 'row',
54 alignItems: 'flex-start',
55 justifyContents: 'left',
56 },
57 search: {
58 borderRadius: theme.shape.borderRadius,
59 color: theme.palette.text.secondary,
60 borderColor: theme.palette.divider,
61 borderStyle: 'solid',
62 borderWidth: '1px',
63 backgroundColor: theme.palette.primary.light,
64 padding: theme.spacing(0, 1),
65 width: ({ searching }) => (searching ? '20rem' : '15rem'),
66 transition: theme.transitions.create([
67 'width',
68 'borderColor',
69 'backgroundColor',
70 ]),
71 },
72 searchFocused: {
73 backgroundColor: theme.palette.background.paper,
74 },
75 placeholderRow: {
76 padding: theme.spacing(1),
77 borderBottomColor: theme.palette.divider,
78 borderBottomWidth: '1px',
79 borderBottomStyle: 'solid',
80 display: 'flex',
81 alignItems: 'center',
82 },
83 placeholderRowStatus: {
84 margin: theme.spacing(1, 2),
85 },
86 placeholderRowText: {
87 flex: 1,
88 },
89 message: {
90 ...theme.typography.h5,
91 padding: theme.spacing(8),
92 textAlign: 'center',
93 color: theme.palette.text.hint,
94 borderBottomColor: theme.palette.divider,
95 borderBottomWidth: '1px',
96 borderBottomStyle: 'solid',
97 '& > p': {
98 margin: '0',
99 },
100 },
101 errorBox: {
102 color: theme.palette.error.dark,
103 '& > pre': {
104 fontSize: '1rem',
105 textAlign: 'left',
106 borderColor: theme.palette.divider,
107 borderWidth: '1px',
108 borderRadius: theme.shape.borderRadius,
109 borderStyle: 'solid',
110 color: theme.palette.text.primary,
111 marginTop: theme.spacing(4),
112 padding: theme.spacing(2, 3),
113 },
114 },
115 greenButton: {
116 backgroundColor: theme.palette.success.main,
117 color: theme.palette.success.contrastText,
118 '&:hover': {
119 backgroundColor: theme.palette.success.dark,
120 color: theme.palette.primary.contrastText,
121 },
122 },
123}));
124
125function editParams(
126 params: URLSearchParams,
127 callback: (params: URLSearchParams) => void
128) {
129 const cloned = new URLSearchParams(params.toString());
130 callback(cloned);
131 return cloned;
132}
133
134// TODO: factor this out
135type PlaceholderProps = { count: number };
136const Placeholder: React.FC<PlaceholderProps> = ({
137 count,
138}: PlaceholderProps) => {
139 const classes = useStyles({});
140 return (
141 <>
142 {new Array(count).fill(null).map((_, i) => (
143 <div key={i} className={classes.placeholderRow}>
144 <Skeleton
145 className={classes.placeholderRowStatus}
146 variant="circle"
147 width={20}
148 height={20}
149 />
150 <div className={classes.placeholderRowText}>
151 <Skeleton height={22} />
152 <Skeleton height={24} width="60%" />
153 </div>
154 </div>
155 ))}
156 </>
157 );
158};
159
160// TODO: factor this out
161const NoBug = () => {
162 const classes = useStyles({});
163 return (
164 <div className={classes.message}>
165 <ErrorOutline fontSize="large" />
166 <p>No results matched your search.</p>
167 </div>
168 );
169};
170
171type ErrorProps = { error: ApolloError };
172const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
173 const classes = useStyles({});
174 return (
175 <div className={[classes.errorBox, classes.message].join(' ')}>
176 <ErrorOutline fontSize="large" />
177 <p>There was an error while fetching bug.</p>
178 <p>
179 <em>{error.message}</em>
180 </p>
181 <pre>
182 <code>{JSON.stringify(error, null, 2)}</code>
183 </pre>
184 </div>
185 );
186};
187
188function ListQuery() {
189 const location = useLocation();
190 const history = useHistory();
191 const params = new URLSearchParams(location.search);
192 const query = params.has('q') ? params.get('q') || '' : 'status:open';
193
194 const [input, setInput] = useState(query);
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 return (
297 <Paper className={classes.main}>
298 <header className={classes.header}>
299 <div className="filterissueContainer">
300 <form onSubmit={formSubmit}>
301 <label className={classes.filterissueLabel} htmlFor="issuefilter">
302 Filter
303 </label>
304 <InputBase
305 id="issuefilter"
306 placeholder="Filter"
307 value={input}
308 onInput={(e: any) => setInput(e.target.value)}
309 classes={{
310 root: classes.search,
311 focused: classes.searchFocused,
312 }}
313 />
314 <button type="submit" hidden>
315 Search
316 </button>
317 </form>
318 </div>
319 <IfLoggedIn>
320 {() => (
321 <Button
322 className={classes.greenButton}
323 variant="contained"
324 href="/new"
325 >
326 New bug
327 </Button>
328 )}
329 </IfLoggedIn>
330 </header>
331 <FilterToolbar query={query} queryLocation={queryLocation} />
332 {content}
333 <div className={classes.pagination}>
334 {previousPage ? (
335 <IconButton component={Link} to={previousPage}>
336 <KeyboardArrowLeft />
337 </IconButton>
338 ) : (
339 <IconButton disabled>
340 <KeyboardArrowLeft />
341 </IconButton>
342 )}
343 <div>{loading ? 'Loading' : `Total: ${count}`}</div>
344 {nextPage ? (
345 <IconButton component={Link} to={nextPage}>
346 <KeyboardArrowRight />
347 </IconButton>
348 ) : (
349 <IconButton disabled>
350 <KeyboardArrowRight />
351 </IconButton>
352 )}
353 </div>
354 </Paper>
355 );
356}
357
358export default ListQuery;