mirror of
https://github.com/MichaelMure/git-bug.git
synced 2024-12-15 02:01:43 +03:00
Merge pull request #301 from MichaelMure/webui/issue-filtering
Issue list improvements and filtering
This commit is contained in:
commit
6343c8e611
67
webui/package-lock.json
generated
67
webui/package-lock.json
generated
@ -2086,14 +2086,14 @@
|
||||
}
|
||||
},
|
||||
"@material-ui/core": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.8.3.tgz",
|
||||
"integrity": "sha512-ZJbfJQmkuZCSQTf0nzpfZwizmDdCq8ruZxnPNFnhoKDqgJpMvV8TJRi8vdI9ls1tMuTqxlhyhw8556fxOpWpFQ==",
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.9.0.tgz",
|
||||
"integrity": "sha512-zrrr8mPU5DDBYaVil4uJYauW41PjSn5otn7cqGsmWOY0t90fypr9nNgM7rRJaPz2AP6oRSDx1kBQt2igf5uelg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@material-ui/styles": "^4.8.2",
|
||||
"@material-ui/styles": "^4.9.0",
|
||||
"@material-ui/system": "^4.7.1",
|
||||
"@material-ui/types": "^4.1.1",
|
||||
"@material-ui/types": "^5.0.0",
|
||||
"@material-ui/utils": "^4.7.1",
|
||||
"@types/react-transition-group": "^4.2.0",
|
||||
"clsx": "^1.0.2",
|
||||
@ -2114,26 +2114,38 @@
|
||||
"@babel/runtime": "^7.4.4"
|
||||
}
|
||||
},
|
||||
"@material-ui/lab": {
|
||||
"version": "4.0.0-alpha.40",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.40.tgz",
|
||||
"integrity": "sha512-VwXCNFJKfctu9Ot9XP5u2SSzXpm2Fn7F/o08bUfrJDkMCuRc8MCGVnNhT+guZRZa35rR97uWKc3SGQ/LAv8yEg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@material-ui/utils": "^4.7.1",
|
||||
"clsx": "^1.0.4",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-is": "^16.8.0"
|
||||
}
|
||||
},
|
||||
"@material-ui/styles": {
|
||||
"version": "4.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.8.2.tgz",
|
||||
"integrity": "sha512-r5U+93pkpwQOmHTmwyn2sqTio6PHd873xvSHiKP6fdybAXXX6CZgVvh3W8saZNbYr/QXsS8OHmFv7sYJLt5Yfg==",
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.9.0.tgz",
|
||||
"integrity": "sha512-nJHum4RqYBPWsjL/9JET8Z02FZ9gSizlg/7LWVFpIthNzpK6OQ5OSRR4T4x9/p+wK3t1qNn3b1uI4XpnZaPxOA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.4.4",
|
||||
"@emotion/hash": "^0.7.4",
|
||||
"@material-ui/types": "^4.1.1",
|
||||
"@material-ui/types": "^5.0.0",
|
||||
"@material-ui/utils": "^4.7.1",
|
||||
"clsx": "^1.0.2",
|
||||
"csstype": "^2.5.2",
|
||||
"hoist-non-react-statics": "^3.2.1",
|
||||
"jss": "^10.0.0",
|
||||
"jss-plugin-camel-case": "^10.0.0",
|
||||
"jss-plugin-default-unit": "^10.0.0",
|
||||
"jss-plugin-global": "^10.0.0",
|
||||
"jss-plugin-nested": "^10.0.0",
|
||||
"jss-plugin-props-sort": "^10.0.0",
|
||||
"jss-plugin-rule-value-function": "^10.0.0",
|
||||
"jss-plugin-vendor-prefixer": "^10.0.0",
|
||||
"jss": "^10.0.3",
|
||||
"jss-plugin-camel-case": "^10.0.3",
|
||||
"jss-plugin-default-unit": "^10.0.3",
|
||||
"jss-plugin-global": "^10.0.3",
|
||||
"jss-plugin-nested": "^10.0.3",
|
||||
"jss-plugin-props-sort": "^10.0.3",
|
||||
"jss-plugin-rule-value-function": "^10.0.3",
|
||||
"jss-plugin-vendor-prefixer": "^10.0.3",
|
||||
"prop-types": "^15.7.2"
|
||||
}
|
||||
},
|
||||
@ -2148,12 +2160,9 @@
|
||||
}
|
||||
},
|
||||
"@material-ui/types": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/types/-/types-4.1.1.tgz",
|
||||
"integrity": "sha512-AN+GZNXytX9yxGi0JOfxHrRTbhFybjUJ05rnsBVjcB+16e466Z0Xe5IxawuOayVZgTBNDxmPKo5j4V6OnMtaSQ==",
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.0.0.tgz",
|
||||
"integrity": "sha512-UeH2BuKkwDndtMSS0qgx1kCzSMw+ydtj0xx/XbFtxNSTlXydKwzs5gVW5ZKsFlAkwoOOQ9TIsyoCC8hq18tOwg=="
|
||||
},
|
||||
"@material-ui/utils": {
|
||||
"version": "4.7.1",
|
||||
@ -2471,9 +2480,9 @@
|
||||
"integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw=="
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "16.9.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.18.tgz",
|
||||
"integrity": "sha512-MvjiKX/kUE8o49ipppg49RDZ97p4XfW1WWksp/UlTUSJpisyhzd62pZAMXxAscFLoxfYOflkGANAnGkSeHTFQg==",
|
||||
"version": "16.9.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.19.tgz",
|
||||
"integrity": "sha512-LJV97//H+zqKWMms0kvxaKYJDG05U2TtQB3chRLF8MPNs+MQh/H1aGlyDUxjaHvu08EAGerdX2z4LTBc7ns77A==",
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^2.2.0"
|
||||
@ -12382,9 +12391,9 @@
|
||||
}
|
||||
},
|
||||
"popper.js": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.0.tgz",
|
||||
"integrity": "sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw=="
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="
|
||||
},
|
||||
"portfinder": {
|
||||
"version": "1.0.25",
|
||||
|
@ -3,9 +3,11 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.3.3",
|
||||
"@apollo/react-hooks": "^3.1.3",
|
||||
"@material-ui/core": "^4.9.0",
|
||||
"@material-ui/icons": "^4.2.1",
|
||||
"@material-ui/styles": "^4.3.3",
|
||||
"@material-ui/lab": "^4.0.0-alpha.40",
|
||||
"@material-ui/styles": "^4.9.0",
|
||||
"apollo-boost": "^0.4.7",
|
||||
"graphql": "^14.3.0",
|
||||
"moment": "^2.24.0",
|
||||
|
62
webui/src/__tests__/query.js
Normal file
62
webui/src/__tests__/query.js
Normal file
@ -0,0 +1,62 @@
|
||||
import { parse, stringify, quote } from '../list/Filter';
|
||||
|
||||
it('parses a simple query', () => {
|
||||
expect(parse('foo:bar')).toEqual({
|
||||
foo: ['bar'],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a query with multiple filters', () => {
|
||||
expect(parse('foo:bar baz:foo-bar')).toEqual({
|
||||
foo: ['bar'],
|
||||
baz: ['foo-bar'],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a quoted query', () => {
|
||||
expect(parse('foo:"bar"')).toEqual({
|
||||
foo: ['bar'],
|
||||
});
|
||||
|
||||
expect(parse("foo:'bar'")).toEqual({
|
||||
foo: ['bar'],
|
||||
});
|
||||
|
||||
expect(parse('foo:\'bar "nested" quotes\'')).toEqual({
|
||||
foo: ['bar "nested" quotes'],
|
||||
});
|
||||
|
||||
expect(parse("foo:'escaped\\' quotes'")).toEqual({
|
||||
foo: ["escaped' quotes"],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a query with repetitions', () => {
|
||||
expect(parse('foo:bar foo:baz')).toEqual({
|
||||
foo: ['bar', 'baz'],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a complex query', () => {
|
||||
expect(parse('foo:bar foo:baz baz:"foobar" idont:\'know\'')).toEqual({
|
||||
foo: ['bar', 'baz'],
|
||||
baz: ['foobar'],
|
||||
idont: ['know'],
|
||||
});
|
||||
});
|
||||
|
||||
it('quotes values', () => {
|
||||
expect(quote('foo')).toEqual('foo');
|
||||
expect(quote('foo bar')).toEqual('"foo bar"');
|
||||
expect(quote('foo "bar"')).toEqual(`'foo "bar"'`);
|
||||
expect(quote(`foo "bar" 'baz'`)).toEqual(`"foo \\"bar\\" 'baz'"`);
|
||||
});
|
||||
|
||||
it('stringifies params', () => {
|
||||
expect(stringify({ foo: ['bar'] })).toEqual('foo:bar');
|
||||
expect(stringify({ foo: ['bar baz'] })).toEqual('foo:"bar baz"');
|
||||
expect(stringify({ foo: ['bar', 'baz'] })).toEqual('foo:bar foo:baz');
|
||||
expect(stringify({ foo: ['bar'], baz: ['foobar'] })).toEqual(
|
||||
'foo:bar baz:foobar'
|
||||
);
|
||||
});
|
@ -3,6 +3,7 @@ import TableCell from '@material-ui/core/TableCell/TableCell';
|
||||
import TableRow from '@material-ui/core/TableRow/TableRow';
|
||||
import Tooltip from '@material-ui/core/Tooltip/Tooltip';
|
||||
import ErrorOutline from '@material-ui/icons/ErrorOutline';
|
||||
import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
|
||||
import gql from 'graphql-tag';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
@ -18,7 +19,7 @@ const Open = ({ className }) => (
|
||||
|
||||
const Closed = ({ className }) => (
|
||||
<Tooltip title="Closed">
|
||||
<ErrorOutline htmlColor="#cb2431" className={className} />
|
||||
<CheckCircleOutline htmlColor="#cb2431" className={className} />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
|
154
webui/src/list/Filter.js
Normal file
154
webui/src/list/Filter.js
Normal file
@ -0,0 +1,154 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import Menu from '@material-ui/core/Menu';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
|
||||
|
||||
function parse(query) {
|
||||
// TODO: extract the rest of the query?
|
||||
const params = {};
|
||||
|
||||
// TODO: support escaping without quotes
|
||||
const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g;
|
||||
let matches;
|
||||
while ((matches = re.exec(query)) !== null) {
|
||||
if (!params[matches[1]]) {
|
||||
params[matches[1]] = [];
|
||||
}
|
||||
|
||||
let value;
|
||||
if (matches[4]) {
|
||||
value = matches[4];
|
||||
} else {
|
||||
value = matches[2];
|
||||
}
|
||||
value = value.replace(/\\(.)/g, '$1');
|
||||
params[matches[1]].push(value);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function quote(value) {
|
||||
const hasSingle = value.includes("'");
|
||||
const hasDouble = value.includes('"');
|
||||
const hasSpaces = value.includes(' ');
|
||||
if (!hasSingle && !hasDouble && !hasSpaces) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!hasDouble) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
if (!hasSingle) {
|
||||
return `'${value}'`;
|
||||
}
|
||||
|
||||
value = value.replace(/"/g, '\\"');
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
function stringify(params) {
|
||||
const parts = Object.entries(params).map(([key, values]) => {
|
||||
return values.map(value => `${key}:${quote(value)}`);
|
||||
});
|
||||
return [].concat(...parts).join(' ');
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
element: {
|
||||
...theme.typography.body2,
|
||||
color: ({ active }) => (active ? '#333' : '#444'),
|
||||
padding: theme.spacing(0, 1),
|
||||
fontWeight: ({ active }) => (active ? 600 : 400),
|
||||
textDecoration: 'none',
|
||||
display: 'flex',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
},
|
||||
itemActive: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
icon: {
|
||||
paddingRight: theme.spacing(0.5),
|
||||
},
|
||||
}));
|
||||
|
||||
function Dropdown({ children, dropdown, itemActive, to, ...props }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef();
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={buttonRef} onClick={() => setOpen(!open)} {...props}>
|
||||
{children}
|
||||
<ArrowDropDown fontSize="small" />
|
||||
</button>
|
||||
<Menu
|
||||
getContentAnchorEl={null}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorEl={buttonRef.current}
|
||||
>
|
||||
{dropdown.map(([key, value]) => (
|
||||
<MenuItem
|
||||
component={Link}
|
||||
to={to(key)}
|
||||
className={itemActive(key) ? classes.itemActive : null}
|
||||
onClick={() => setOpen(false)}
|
||||
key={key}
|
||||
>
|
||||
{value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({ active, to, children, icon: Icon, dropdown, ...props }) {
|
||||
const classes = useStyles({ active });
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
|
||||
<div>{children}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (dropdown) {
|
||||
return (
|
||||
<Dropdown
|
||||
{...props}
|
||||
to={to}
|
||||
dropdown={dropdown}
|
||||
className={classes.element}
|
||||
>
|
||||
{content}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} {...props} className={classes.element}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={classes.element}>{content}</div>;
|
||||
}
|
||||
|
||||
export default Filter;
|
||||
export { parse, stringify, quote };
|
125
webui/src/list/FilterToolbar.js
Normal file
125
webui/src/list/FilterToolbar.js
Normal file
@ -0,0 +1,125 @@
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import { useQuery } from '@apollo/react-hooks';
|
||||
import gql from 'graphql-tag';
|
||||
import React from 'react';
|
||||
import Toolbar from '@material-ui/core/Toolbar';
|
||||
import ErrorOutline from '@material-ui/icons/ErrorOutline';
|
||||
import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
|
||||
import Filter, { parse, stringify } from './Filter';
|
||||
|
||||
// simple pipe operator
|
||||
// pipe(o, f, g, h) <=> h(g(f(o)))
|
||||
// TODO: move this out?
|
||||
const pipe = (initial, ...funcs) => funcs.reduce((acc, f) => f(acc), initial);
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
toolbar: {
|
||||
backgroundColor: theme.palette.grey['100'],
|
||||
borderColor: theme.palette.grey['300'],
|
||||
borderWidth: '1px 0',
|
||||
borderStyle: 'solid',
|
||||
margin: theme.spacing(0, -1),
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
const BUG_COUNT_QUERY = gql`
|
||||
query($query: String) {
|
||||
defaultRepository {
|
||||
bugs: allBugs(query: $query) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// This prepends the filter text with a count
|
||||
function CountingFilter({ query, children, ...props }) {
|
||||
const { data, loading, error } = useQuery(BUG_COUNT_QUERY, {
|
||||
variables: { query },
|
||||
});
|
||||
|
||||
var prefix;
|
||||
if (loading) prefix = '...';
|
||||
else if (error) prefix = '???';
|
||||
// TODO: better prefixes & error handling
|
||||
else prefix = data.defaultRepository.bugs.totalCount;
|
||||
|
||||
return (
|
||||
<Filter {...props}>
|
||||
{prefix} {children}
|
||||
</Filter>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterToolbar({ query, queryLocation }) {
|
||||
const classes = useStyles();
|
||||
const params = parse(query);
|
||||
|
||||
const hasKey = key => params[key] && params[key].length > 0;
|
||||
const hasValue = (key, value) => hasKey(key) && params[key].includes(value);
|
||||
const loc = params => pipe(params, stringify, queryLocation);
|
||||
const replaceParam = (key, value) => params => ({
|
||||
...params,
|
||||
[key]: [value],
|
||||
});
|
||||
const clearParam = key => params => ({
|
||||
...params,
|
||||
[key]: [],
|
||||
});
|
||||
|
||||
// TODO: author/label filters
|
||||
return (
|
||||
<Toolbar className={classes.toolbar}>
|
||||
<CountingFilter
|
||||
active={hasValue('status', 'open')}
|
||||
query={pipe(
|
||||
params,
|
||||
replaceParam('status', 'open'),
|
||||
clearParam('sort'),
|
||||
stringify
|
||||
)}
|
||||
to={pipe(params, replaceParam('status', 'open'), loc)}
|
||||
icon={ErrorOutline}
|
||||
>
|
||||
open
|
||||
</CountingFilter>
|
||||
<CountingFilter
|
||||
active={hasValue('status', 'closed')}
|
||||
query={pipe(
|
||||
params,
|
||||
replaceParam('status', 'closed'),
|
||||
clearParam('sort'),
|
||||
stringify
|
||||
)}
|
||||
to={pipe(params, replaceParam('status', 'closed'), loc)}
|
||||
icon={CheckCircleOutline}
|
||||
>
|
||||
closed
|
||||
</CountingFilter>
|
||||
<div className={classes.spacer} />
|
||||
{/*
|
||||
<Filter active={hasKey('author')}>Author</Filter>
|
||||
<Filter active={hasKey('label')}>Label</Filter>
|
||||
*/}
|
||||
<Filter
|
||||
dropdown={[
|
||||
['id', 'ID'],
|
||||
['creation', 'Newest'],
|
||||
['creation-asc', 'Oldest'],
|
||||
['edit', 'Recently updated'],
|
||||
['edit-asc', 'Least recently updated'],
|
||||
]}
|
||||
active={hasKey('sort')}
|
||||
itemActive={key => hasValue('sort', key)}
|
||||
to={key => pipe(params, replaceParam('sort', key), loc)}
|
||||
>
|
||||
Sort
|
||||
</Filter>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterToolbar;
|
@ -1,49 +1,17 @@
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import Table from '@material-ui/core/Table/Table';
|
||||
import TableBody from '@material-ui/core/TableBody/TableBody';
|
||||
import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
|
||||
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
|
||||
import React from 'react';
|
||||
import BugRow from './BugRow';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
main: {
|
||||
maxWidth: 600,
|
||||
margin: 'auto',
|
||||
marginTop: theme.spacing(4),
|
||||
},
|
||||
pagination: {
|
||||
...theme.typography.overline,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
}));
|
||||
|
||||
function List({ bugs, nextPage, prevPage }) {
|
||||
const classes = useStyles();
|
||||
const { hasNextPage, hasPreviousPage } = bugs.pageInfo;
|
||||
function List({ bugs }) {
|
||||
return (
|
||||
<main className={classes.main}>
|
||||
<Table className={classes.table}>
|
||||
<TableBody>
|
||||
{bugs.edges.map(({ cursor, node }) => (
|
||||
<BugRow bug={node} key={cursor} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className={classes.pagination}>
|
||||
<div>Total: {bugs.totalCount}</div>
|
||||
<IconButton onClick={prevPage} disabled={!hasPreviousPage}>
|
||||
<KeyboardArrowLeft />
|
||||
</IconButton>
|
||||
<IconButton onClick={nextPage} disabled={!hasNextPage}>
|
||||
<KeyboardArrowRight />
|
||||
</IconButton>
|
||||
</div>
|
||||
</main>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{bugs.edges.map(({ cursor, node }) => (
|
||||
<BugRow bug={node} key={cursor} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,19 +1,111 @@
|
||||
// @flow
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import { fade, makeStyles } from '@material-ui/core/styles';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
|
||||
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
|
||||
import ErrorOutline from '@material-ui/icons/ErrorOutline';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import InputBase from '@material-ui/core/InputBase';
|
||||
import Skeleton from '@material-ui/lab/Skeleton';
|
||||
import gql from 'graphql-tag';
|
||||
import React, { useState } from 'react';
|
||||
import { Query } from 'react-apollo';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery } from '@apollo/react-hooks';
|
||||
import { useLocation, useHistory, Link } from 'react-router-dom';
|
||||
import BugRow from './BugRow';
|
||||
import List from './List';
|
||||
import FilterToolbar from './FilterToolbar';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
main: {
|
||||
maxWidth: 800,
|
||||
margin: 'auto',
|
||||
marginTop: theme.spacing(4),
|
||||
marginBottom: theme.spacing(4),
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pagination: {
|
||||
...theme.typography.overline,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
padding: theme.spacing(2),
|
||||
'& > h1': {
|
||||
...theme.typography.h6,
|
||||
margin: theme.spacing(0, 2),
|
||||
},
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
search: {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderColor: fade(theme.palette.primary.main, 0.2),
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '1px',
|
||||
backgroundColor: fade(theme.palette.primary.main, 0.05),
|
||||
padding: theme.spacing(0, 1),
|
||||
width: ({ searching }) => (searching ? '20rem' : '15rem'),
|
||||
transition: theme.transitions.create(),
|
||||
},
|
||||
searchFocused: {
|
||||
borderColor: fade(theme.palette.primary.main, 0.4),
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
width: '20rem!important',
|
||||
},
|
||||
placeholderRow: {
|
||||
padding: theme.spacing(1),
|
||||
borderBottomColor: theme.palette.grey['300'],
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomStyle: 'solid',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderRowStatus: {
|
||||
margin: theme.spacing(1, 2),
|
||||
},
|
||||
placeholderRowText: {
|
||||
flex: 1,
|
||||
},
|
||||
message: {
|
||||
...theme.typography.h5,
|
||||
padding: theme.spacing(8),
|
||||
textAlign: 'center',
|
||||
borderBottomColor: theme.palette.grey['300'],
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomStyle: 'solid',
|
||||
'& > p': {
|
||||
margin: '0',
|
||||
},
|
||||
},
|
||||
errorBox: {
|
||||
color: theme.palette.error.main,
|
||||
'& > pre': {
|
||||
fontSize: '1rem',
|
||||
textAlign: 'left',
|
||||
backgroundColor: theme.palette.grey['900'],
|
||||
color: theme.palette.common.white,
|
||||
marginTop: theme.spacing(4),
|
||||
padding: theme.spacing(2, 3),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const QUERY = gql`
|
||||
query($first: Int, $last: Int, $after: String, $before: String) {
|
||||
query(
|
||||
$first: Int
|
||||
$last: Int
|
||||
$after: String
|
||||
$before: String
|
||||
$query: String
|
||||
) {
|
||||
defaultRepository {
|
||||
bugs: allBugs(
|
||||
first: $first
|
||||
last: $last
|
||||
after: $after
|
||||
before: $before
|
||||
query: $query
|
||||
) {
|
||||
totalCount
|
||||
edges {
|
||||
@ -35,30 +127,206 @@ const QUERY = gql`
|
||||
${BugRow.fragment}
|
||||
`;
|
||||
|
||||
function editParams(params, callback) {
|
||||
const cloned = new URLSearchParams(params.toString());
|
||||
callback(cloned);
|
||||
return cloned;
|
||||
}
|
||||
|
||||
// TODO: factor this out
|
||||
const Placeholder = ({ count }) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<>
|
||||
{new Array(count).fill(null).map((_, i) => (
|
||||
<div key={i} className={classes.placeholderRow}>
|
||||
<Skeleton
|
||||
className={classes.placeholderRowStatus}
|
||||
variant="circle"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<div className={classes.placeholderRowText}>
|
||||
<Skeleton height={22} />
|
||||
<Skeleton height={24} width="60%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: factor this out
|
||||
const NoBug = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<div className={classes.message}>
|
||||
<ErrorOutline fontSize="large" />
|
||||
<p>No results matched your search.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Error = ({ error }) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<div className={[classes.errorBox, classes.message].join(' ')}>
|
||||
<ErrorOutline fontSize="large" />
|
||||
<p>There was an error while fetching bug.</p>
|
||||
<p>
|
||||
<em>{error.message}</em>
|
||||
</p>
|
||||
<pre>
|
||||
<code>{JSON.stringify(error, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ListQuery() {
|
||||
const [page, setPage] = useState({ first: 10, after: null });
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const params = new URLSearchParams(location.search);
|
||||
const query = params.get('q') || '';
|
||||
|
||||
const [input, setInput] = useState(query);
|
||||
|
||||
const classes = useStyles({ searching: !!input });
|
||||
|
||||
// TODO is this the right way to do it?
|
||||
const lastQuery = useRef();
|
||||
useEffect(() => {
|
||||
if (query !== lastQuery.current) {
|
||||
setInput(query);
|
||||
}
|
||||
lastQuery.current = query;
|
||||
}, [query, input, lastQuery]);
|
||||
|
||||
const page = {
|
||||
first: params.get('first'),
|
||||
last: params.get('last'),
|
||||
after: params.get('after'),
|
||||
before: params.get('before'),
|
||||
};
|
||||
|
||||
// If nothing set, show the first 10 items
|
||||
if (!page.first && !page.last) {
|
||||
page.first = 10;
|
||||
}
|
||||
|
||||
const perPage = page.first || page.last;
|
||||
const nextPage = pageInfo =>
|
||||
setPage({ first: perPage, after: pageInfo.endCursor });
|
||||
const prevPage = pageInfo =>
|
||||
setPage({ last: perPage, before: pageInfo.startCursor });
|
||||
|
||||
const { loading, error, data } = useQuery(QUERY, {
|
||||
variables: {
|
||||
...page,
|
||||
query,
|
||||
},
|
||||
});
|
||||
|
||||
let nextPage = null;
|
||||
let previousPage = null;
|
||||
let hasNextPage = false;
|
||||
let hasPreviousPage = false;
|
||||
let count = 0;
|
||||
if (!loading && !error && data.defaultRepository.bugs) {
|
||||
const bugs = data.defaultRepository.bugs;
|
||||
hasNextPage = bugs.pageInfo.hasNextPage;
|
||||
hasPreviousPage = bugs.pageInfo.hasPreviousPage;
|
||||
count = bugs.totalCount;
|
||||
// This computes the URL for the next page
|
||||
nextPage = {
|
||||
...location,
|
||||
search: editParams(params, p => {
|
||||
p.delete('last');
|
||||
p.delete('before');
|
||||
p.set('first', perPage);
|
||||
p.set('after', bugs.pageInfo.endCursor);
|
||||
}).toString(),
|
||||
};
|
||||
// and this for the previous page
|
||||
previousPage = {
|
||||
...location,
|
||||
search: editParams(params, p => {
|
||||
p.delete('first');
|
||||
p.delete('after');
|
||||
p.set('last', perPage);
|
||||
p.set('before', bugs.pageInfo.startCursor);
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare params without paging for editing filters
|
||||
const paramsWithoutPaging = editParams(params, p => {
|
||||
p.delete('first');
|
||||
p.delete('last');
|
||||
p.delete('before');
|
||||
p.delete('after');
|
||||
});
|
||||
// Returns a new location with the `q` param edited
|
||||
const queryLocation = query => ({
|
||||
...location,
|
||||
search: editParams(paramsWithoutPaging, p => p.set('q', query)).toString(),
|
||||
});
|
||||
|
||||
let content;
|
||||
if (loading) {
|
||||
content = <Placeholder count={10} />;
|
||||
} else if (error) {
|
||||
content = <Error error={error} />;
|
||||
} else {
|
||||
const bugs = data.defaultRepository.bugs;
|
||||
|
||||
if (bugs.totalCount === 0) {
|
||||
content = <NoBug />;
|
||||
} else {
|
||||
content = <List bugs={bugs} />;
|
||||
}
|
||||
}
|
||||
|
||||
const formSubmit = e => {
|
||||
e.preventDefault();
|
||||
history.push(queryLocation(input));
|
||||
};
|
||||
|
||||
return (
|
||||
<Query query={QUERY} variables={page}>
|
||||
{({ loading, error, data }) => {
|
||||
if (loading) return <CircularProgress />;
|
||||
if (error) return <p>Error: {error}</p>;
|
||||
const bugs = data.defaultRepository.bugs;
|
||||
return (
|
||||
<List
|
||||
bugs={bugs}
|
||||
nextPage={() => nextPage(bugs.pageInfo)}
|
||||
prevPage={() => prevPage(bugs.pageInfo)}
|
||||
<Paper className={classes.main}>
|
||||
<header className={classes.header}>
|
||||
<h1>Issues</h1>
|
||||
<form onSubmit={formSubmit}>
|
||||
<InputBase
|
||||
placeholder="Filter"
|
||||
value={input}
|
||||
onInput={e => setInput(e.target.value)}
|
||||
classes={{
|
||||
root: classes.search,
|
||||
focused: classes.searchFocused,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Query>
|
||||
<button type="submit" hidden>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</header>
|
||||
<FilterToolbar query={query} queryLocation={queryLocation} />
|
||||
{content}
|
||||
<div className={classes.pagination}>
|
||||
<IconButton
|
||||
component={hasPreviousPage ? Link : 'button'}
|
||||
to={previousPage}
|
||||
disabled={!hasPreviousPage}
|
||||
>
|
||||
<KeyboardArrowLeft />
|
||||
</IconButton>
|
||||
<div>{loading ? 'Loading' : `Total: ${count}`}</div>
|
||||
<IconButton
|
||||
component={hasNextPage ? Link : 'button'}
|
||||
to={nextPage}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
<KeyboardArrowRight />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user