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: '#2ea44fd9',
117 color: '#fff',
118 '&:hover': {
119 backgroundColor: '#2ea44f',
120 },
121 },
122}));
123
124function editParams(
125 params: URLSearchParams,
126 callback: (params: URLSearchParams) => void
127) {
128 const cloned = new URLSearchParams(params.toString());
129 callback(cloned);
130 return cloned;
131}
132
133// TODO: factor this out
134type PlaceholderProps = { count: number };
135const Placeholder: React.FC<PlaceholderProps> = ({
136 count,
137}: PlaceholderProps) => {
138 const classes = useStyles({});
139 return (
140 <>
141 {new Array(count).fill(null).map((_, i) => (
142 <div key={i} className={classes.placeholderRow}>
143 <Skeleton
144 className={classes.placeholderRowStatus}
145 variant="circle"
146 width={20}
147 height={20}
148 />
149 <div className={classes.placeholderRowText}>
150 <Skeleton height={22} />
151 <Skeleton height={24} width="60%" />
152 </div>
153 </div>
154 ))}
155 </>
156 );
157};
158
159// TODO: factor this out
160const NoBug = () => {
161 const classes = useStyles({});
162 return (
163 <div className={classes.message}>
164 <ErrorOutline fontSize="large" />
165 <p>No results matched your search.</p>
166 </div>
167 );
168};
169
170type ErrorProps = { error: ApolloError };
171const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
172 const classes = useStyles({});
173 return (
174 <div className={[classes.errorBox, classes.message].join(' ')}>
175 <ErrorOutline fontSize="large" />
176 <p>There was an error while fetching bug.</p>
177 <p>
178 <em>{error.message}</em>
179 </p>
180 <pre>
181 <code>{JSON.stringify(error, null, 2)}</code>
182 </pre>
183 </div>
184 );
185};
186
187function ListQuery() {
188 const location = useLocation();
189 const history = useHistory();
190 const params = new URLSearchParams(location.search);
191 const query = params.has('q') ? params.get('q') || '' : 'status:open';
192
193 const [input, setInput] = useState(query);
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 return (
296 <Paper className={classes.main}>
297 <header className={classes.header}>
298 <div className="filterissueContainer">
299 <form onSubmit={formSubmit}>
300 <label className={classes.filterissueLabel} htmlFor="issuefilter">
301 Filter
302 </label>
303 <InputBase
304 id="issuefilter"
305 placeholder="Filter"
306 value={input}
307 onInput={(e: any) => setInput(e.target.value)}
308 classes={{
309 root: classes.search,
310 focused: classes.searchFocused,
311 }}
312 />
313 <button type="submit" hidden>
314 Search
315 </button>
316 </form>
317 </div>
318 <IfLoggedIn>
319 {() => (
320 <Button
321 className={classes.greenButton}
322 variant="contained"
323 href="/new"
324 >
325 New issue
326 </Button>
327 )}
328 </IfLoggedIn>
329 </header>
330 <FilterToolbar query={query} queryLocation={queryLocation} />
331 {content}
332 <div className={classes.pagination}>
333 {previousPage ? (
334 <IconButton component={Link} to={previousPage}>
335 <KeyboardArrowLeft />
336 </IconButton>
337 ) : (
338 <IconButton disabled>
339 <KeyboardArrowLeft />
340 </IconButton>
341 )}
342 <div>{loading ? 'Loading' : `Total: ${count}`}</div>
343 {nextPage ? (
344 <IconButton component={Link} to={nextPage}>
345 <KeyboardArrowRight />
346 </IconButton>
347 ) : (
348 <IconButton disabled>
349 <KeyboardArrowRight />
350 </IconButton>
351 )}
352 </div>
353 </Paper>
354 );
355}
356
357export default ListQuery;