aboutsummaryrefslogtreecommitdiffstats
path: root/webui/src
diff options
context:
space:
mode:
authorQuentin Gliech <quentingliech@gmail.com>2020-01-25 11:40:08 +0100
committerQuentin Gliech <quentingliech@gmail.com>2020-01-25 11:40:08 +0100
commit4d97e3a19a96e2361b35a0ccc0be74e0ba887214 (patch)
treed226f1a4f7181ff5d106c8bb65f6048c66f38a40 /webui/src
parentfa13550115144a6f39888960a80cc24890f83536 (diff)
downloadgit-bug-4d97e3a19a96e2361b35a0ccc0be74e0ba887214.tar.gz
webui: implement filtering
Diffstat (limited to 'webui/src')
-rw-r--r--webui/src/__tests__/query.js62
-rw-r--r--webui/src/list/Filter.js73
-rw-r--r--webui/src/list/FilterToolbar.js60
-rw-r--r--webui/src/list/ListQuery.js140
4 files changed, 298 insertions, 37 deletions
diff --git a/webui/src/__tests__/query.js b/webui/src/__tests__/query.js
new file mode 100644
index 00000000..1415af02
--- /dev/null
+++ b/webui/src/__tests__/query.js
@@ -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:foobar')).toEqual({
+ foo: ['bar'],
+ baz: ['foobar'],
+ });
+});
+
+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'
+ );
+});
diff --git a/webui/src/list/Filter.js b/webui/src/list/Filter.js
index ce457d03..c93b2d35 100644
--- a/webui/src/list/Filter.js
+++ b/webui/src/list/Filter.js
@@ -1,6 +1,58 @@
import React from 'react';
+import { Link } from 'react-router-dom';
import { makeStyles } from '@material-ui/styles';
+function parse(query) {
+ // TODO: extract the rest of the query?
+ const params = {};
+
+ // TODO: support escaping without quotes
+ const re = /(\w+):(\w+|(["'])(([^\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,
@@ -18,15 +70,30 @@ const useStyles = makeStyles(theme => ({
},
}));
-function Filter({ active, children, icon: Icon, end, ...props }) {
+function Filter({ active, to, children, icon: Icon, end, ...props }) {
const classes = useStyles({ active, end });
- return (
- <button {...props} className={classes.element}>
+ const content = (
+ <>
{Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
<div>{children}</div>
+ </>
+ );
+
+ if (to) {
+ return (
+ <Link to={to} {...props} className={classes.element}>
+ {content}
+ </Link>
+ );
+ }
+
+ return (
+ <button {...props} className={classes.element}>
+ {content}
</button>
);
}
export default Filter;
+export { parse, stringify, quote };
diff --git a/webui/src/list/FilterToolbar.js b/webui/src/list/FilterToolbar.js
new file mode 100644
index 00000000..e6d6f4ed
--- /dev/null
+++ b/webui/src/list/FilterToolbar.js
@@ -0,0 +1,60 @@
+import { makeStyles } from '@material-ui/styles';
+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';
+
+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,
+ },
+}));
+
+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 replaceParam = (key, value) => {
+ const p = {
+ ...params,
+ [key]: [value],
+ };
+ return queryLocation(stringify(p));
+ };
+
+ // TODO: open/closed count
+ // TODO: author/label/sort filters
+ return (
+ <Toolbar className={classes.toolbar}>
+ <Filter
+ active={hasValue('status', 'open')}
+ to={replaceParam('status', 'open')}
+ icon={ErrorOutline}
+ >
+ open
+ </Filter>
+ <Filter
+ active={hasValue('status', 'closed')}
+ to={replaceParam('status', 'closed')}
+ icon={CheckCircleOutline}
+ >
+ closed
+ </Filter>
+ <div className={classes.spacer} />
+ <Filter active={hasKey('author')}>Author</Filter>
+ <Filter active={hasKey('label')}>Label</Filter>
+ <Filter active={hasKey('sort')}>Sort</Filter>
+ </Toolbar>
+ );
+}
+
+export default FilterToolbar;
diff --git a/webui/src/list/ListQuery.js b/webui/src/list/ListQuery.js
index 9cbfab67..b6a29702 100644
--- a/webui/src/list/ListQuery.js
+++ b/webui/src/list/ListQuery.js
@@ -1,19 +1,18 @@
-import { makeStyles } from '@material-ui/styles';
+import { fade, makeStyles } from '@material-ui/core/styles';
import IconButton from '@material-ui/core/IconButton';
-import Toolbar from '@material-ui/core/Toolbar';
import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
-import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
import Paper from '@material-ui/core/Paper';
-import Filter from './Filter';
+import InputBase from '@material-ui/core/InputBase';
import Skeleton from '@material-ui/lab/Skeleton';
import gql from 'graphql-tag';
-import React from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import { useQuery } from '@apollo/react-hooks';
-import { useLocation, Link } from 'react-router-dom';
+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: {
@@ -29,19 +28,28 @@ const useStyles = makeStyles(theme => ({
alignItems: 'center',
justifyContent: 'center',
},
- toolbar: {
- backgroundColor: theme.palette.grey['100'],
- borderColor: theme.palette.grey['300'],
- borderWidth: '1px 0',
- borderStyle: 'solid',
- margin: theme.spacing(0, -1),
- },
header: {
- ...theme.typography.h6,
- padding: theme.spacing(2, 4),
+ display: 'flex',
+ padding: theme.spacing(2),
+ '& > h1': {
+ ...theme.typography.h6,
+ margin: theme.spacing(0, 2),
+ },
+ alignItems: 'center',
+ justifyContent: 'space-between',
},
- spacer: {
- flex: 1,
+ 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),
+ ':focus': {
+ // TODO
+ borderColor: fade(theme.palette.primary.main, 0.4),
+ backgroundColor: theme.palette.background.paper,
+ },
},
placeholderRow: {
padding: theme.spacing(1),
@@ -57,7 +65,7 @@ const useStyles = makeStyles(theme => ({
placeholderRowText: {
flex: 1,
},
- noBug: {
+ message: {
...theme.typography.h5,
padding: theme.spacing(8),
textAlign: 'center',
@@ -68,6 +76,17 @@ const useStyles = makeStyles(theme => ({
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`
@@ -139,18 +158,47 @@ const Placeholder = ({ count }) => {
const NoBug = () => {
const classes = useStyles();
return (
- <div className={classes.noBug}>
+ <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 classes = useStyles();
const location = useLocation();
+ const history = useHistory();
const params = new URLSearchParams(location.search);
const query = params.get('q');
+
+ const [input, setInput] = useState(query);
+
+ // 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'),
@@ -204,11 +252,24 @@ function ListQuery() {
};
}
+ // 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 = <p>Error: {JSON.stringify(error)}</p>;
+ content = <Error error={error} />;
} else {
const bugs = data.defaultRepository.bugs;
@@ -219,31 +280,42 @@ function ListQuery() {
}
}
+ const formSubmit = e => {
+ e.preventDefault();
+ history.push(queryLocation(input));
+ };
+
return (
<Paper className={classes.main}>
- <header className={classes.header}>Issues</header>
- <Toolbar className={classes.toolbar}>
- {/* TODO */}
- <Filter active icon={ErrorOutline}>
- 123 open
- </Filter>
- <Filter icon={CheckCircleOutline}>456 closed</Filter>
- <div className={classes.spacer} />
- <Filter>Author</Filter>
- <Filter>Label</Filter>
- <Filter>Sort</Filter>
- </Toolbar>
+ <header className={classes.header}>
+ <h1>Issues</h1>
+ <form onSubmit={formSubmit}>
+ <InputBase
+ value={input}
+ onInput={e => setInput(e.target.value)}
+ className={classes.search}
+ />
+ <button type="submit" hidden>
+ Search
+ </button>
+ </form>
+ </header>
+ <FilterToolbar query={query} queryLocation={queryLocation} />
{content}
<div className={classes.pagination}>
<IconButton
- component={Link}
+ component={hasPreviousPage ? Link : 'button'}
to={previousPage}
disabled={!hasPreviousPage}
>
<KeyboardArrowLeft />
</IconButton>
<div>{loading ? 'Loading' : `Total: ${count}`}</div>
- <IconButton component={Link} to={nextPage} disabled={!hasNextPage}>
+ <IconButton
+ component={hasNextPage ? Link : 'button'}
+ to={nextPage}
+ disabled={!hasNextPage}
+ >
<KeyboardArrowRight />
</IconButton>
</div>