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