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