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