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