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