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