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 { fade, 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 borderColor: fade(theme.palette.primary.main, 0.2),
60 borderStyle: 'solid',
61 borderWidth: '1px',
62 backgroundColor: fade(theme.palette.primary.main, 0.05),
63 padding: theme.spacing(0, 1),
64 width: ({ searching }) => (searching ? '20rem' : '15rem'),
65 transition: theme.transitions.create([
66 'width',
67 'borderColor',
68 'backgroundColor',
69 ]),
70 },
71 searchFocused: {
72 borderColor: fade(theme.palette.primary.main, 0.4),
73 backgroundColor: theme.palette.background.paper,
74 width: '20rem!important',
75 },
76 placeholderRow: {
77 padding: theme.spacing(1),
78 borderBottomColor: theme.palette.grey['300'],
79 borderBottomWidth: '1px',
80 borderBottomStyle: 'solid',
81 display: 'flex',
82 alignItems: 'center',
83 },
84 placeholderRowStatus: {
85 margin: theme.spacing(1, 2),
86 },
87 placeholderRowText: {
88 flex: 1,
89 },
90 message: {
91 ...theme.typography.h5,
92 padding: theme.spacing(8),
93 textAlign: 'center',
94 borderBottomColor: theme.palette.grey['300'],
95 borderBottomWidth: '1px',
96 borderBottomStyle: 'solid',
97 '& > p': {
98 margin: '0',
99 },
100 },
101 errorBox: {
102 color: theme.palette.error.main,
103 '& > pre': {
104 fontSize: '1rem',
105 textAlign: 'left',
106 backgroundColor: theme.palette.grey['900'],
107 color: theme.palette.common.white,
108 marginTop: theme.spacing(4),
109 padding: theme.spacing(2, 3),
110 },
111 },
112 greenButton: {
113 backgroundColor: '#2ea44fd9',
114 color: '#fff',
115 '&:hover': {
116 backgroundColor: '#2ea44f',
117 },
118 },
119}));
120
121function editParams(
122 params: URLSearchParams,
123 callback: (params: URLSearchParams) => void
124) {
125 const cloned = new URLSearchParams(params.toString());
126 callback(cloned);
127 return cloned;
128}
129
130// TODO: factor this out
131type PlaceholderProps = { count: number };
132const Placeholder: React.FC<PlaceholderProps> = ({
133 count,
134}: PlaceholderProps) => {
135 const classes = useStyles({});
136 return (
137 <>
138 {new Array(count).fill(null).map((_, i) => (
139 <div key={i} className={classes.placeholderRow}>
140 <Skeleton
141 className={classes.placeholderRowStatus}
142 variant="circle"
143 width={20}
144 height={20}
145 />
146 <div className={classes.placeholderRowText}>
147 <Skeleton height={22} />
148 <Skeleton height={24} width="60%" />
149 </div>
150 </div>
151 ))}
152 </>
153 );
154};
155
156// TODO: factor this out
157const NoBug = () => {
158 const classes = useStyles({});
159 return (
160 <div className={classes.message}>
161 <ErrorOutline fontSize="large" />
162 <p>No results matched your search.</p>
163 </div>
164 );
165};
166
167type ErrorProps = { error: ApolloError };
168const Error: React.FC<ErrorProps> = ({ error }: ErrorProps) => {
169 const classes = useStyles({});
170 return (
171 <div className={[classes.errorBox, classes.message].join(' ')}>
172 <ErrorOutline fontSize="large" />
173 <p>There was an error while fetching bug.</p>
174 <p>
175 <em>{error.message}</em>
176 </p>
177 <pre>
178 <code>{JSON.stringify(error, null, 2)}</code>
179 </pre>
180 </div>
181 );
182};
183
184function ListQuery() {
185 const location = useLocation();
186 const history = useHistory();
187 const params = new URLSearchParams(location.search);
188 const query = params.has('q') ? params.get('q') || '' : 'status:open';
189
190 const [input, setInput] = useState(query);
191
192 const classes = useStyles({ searching: !!input });
193
194 // TODO is this the right way to do it?
195 const lastQuery = useRef<string | null>(null);
196 useEffect(() => {
197 if (query !== lastQuery.current) {
198 setInput(query);
199 }
200 lastQuery.current = query;
201 }, [query, input, lastQuery]);
202
203 const num = (param: string | null) => (param ? parseInt(param) : null);
204 const page = {
205 first: num(params.get('first')),
206 last: num(params.get('last')),
207 after: params.get('after'),
208 before: params.get('before'),
209 };
210
211 // If nothing set, show the first 10 items
212 if (!page.first && !page.last) {
213 page.first = 10;
214 }
215
216 const perPage = (page.first || page.last || 10).toString();
217
218 const { loading, error, data } = useListBugsQuery({
219 variables: {
220 ...page,
221 query,
222 },
223 });
224
225 let nextPage = null;
226 let previousPage = null;
227 let count = 0;
228 if (!loading && !error && data?.repository?.bugs) {
229 const bugs = data.repository.bugs;
230 count = bugs.totalCount;
231 // This computes the URL for the next page
232 if (bugs.pageInfo.hasNextPage) {
233 nextPage = {
234 ...location,
235 search: editParams(params, (p) => {
236 p.delete('last');
237 p.delete('before');
238 p.set('first', perPage);
239 p.set('after', bugs.pageInfo.endCursor);
240 }).toString(),
241 };
242 }
243 // and this for the previous page
244 if (bugs.pageInfo.hasPreviousPage) {
245 previousPage = {
246 ...location,
247 search: editParams(params, (p) => {
248 p.delete('first');
249 p.delete('after');
250 p.set('last', perPage);
251 p.set('before', bugs.pageInfo.startCursor);
252 }).toString(),
253 };
254 }
255 }
256
257 // Prepare params without paging for editing filters
258 const paramsWithoutPaging = editParams(params, (p) => {
259 p.delete('first');
260 p.delete('last');
261 p.delete('before');
262 p.delete('after');
263 });
264 // Returns a new location with the `q` param edited
265 const queryLocation = (query: string) => ({
266 ...location,
267 search: editParams(paramsWithoutPaging, (p) =>
268 p.set('q', query)
269 ).toString(),
270 });
271
272 let content;
273 if (loading) {
274 content = <Placeholder count={10} />;
275 } else if (error) {
276 content = <Error error={error} />;
277 } else if (data?.repository) {
278 const bugs = data.repository.bugs;
279
280 if (bugs.totalCount === 0) {
281 content = <NoBug />;
282 } else {
283 content = <List bugs={bugs} />;
284 }
285 }
286
287 const formSubmit = (e: React.FormEvent) => {
288 e.preventDefault();
289 history.push(queryLocation(input));
290 };
291
292 return (
293 <Paper className={classes.main}>
294 <header className={classes.header}>
295 <div className="filterissueContainer">
296 <form onSubmit={formSubmit}>
297 <label className={classes.filterissueLabel} htmlFor="issuefilter">
298 Filter
299 </label>
300 <InputBase
301 id="issuefilter"
302 placeholder="Filter"
303 value={input}
304 onInput={(e: any) => setInput(e.target.value)}
305 classes={{
306 root: classes.search,
307 focused: classes.searchFocused,
308 }}
309 />
310 <button type="submit" hidden>
311 Search
312 </button>
313 </form>
314 </div>
315 <IfLoggedIn>
316 {() => (
317 <Button
318 className={classes.greenButton}
319 variant="contained"
320 href="/new"
321 >
322 New issue
323 </Button>
324 )}
325 </IfLoggedIn>
326 </header>
327 <FilterToolbar query={query} queryLocation={queryLocation} />
328 {content}
329 <div className={classes.pagination}>
330 {previousPage ? (
331 <IconButton component={Link} to={previousPage}>
332 <KeyboardArrowLeft />
333 </IconButton>
334 ) : (
335 <IconButton disabled>
336 <KeyboardArrowLeft />
337 </IconButton>
338 )}
339 <div>{loading ? 'Loading' : `Total: ${count}`}</div>
340 {nextPage ? (
341 <IconButton component={Link} to={nextPage}>
342 <KeyboardArrowRight />
343 </IconButton>
344 ) : (
345 <IconButton disabled>
346 <KeyboardArrowRight />
347 </IconButton>
348 )}
349 </div>
350 </Paper>
351 );
352}
353
354export default ListQuery;