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 newIssueButton: {
116 backgroundColor: theme.palette.success.main,
117 color: theme.palette.success.contrastText,
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.newIssueButton}
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;