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