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