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