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