From 4d97e3a19a96e2361b35a0ccc0be74e0ba887214 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Sat, 25 Jan 2020 11:40:08 +0100 Subject: [PATCH] webui: implement filtering --- webui/src/__tests__/query.js | 62 ++++++++++++++ webui/src/list/Filter.js | 73 ++++++++++++++++- webui/src/list/FilterToolbar.js | 60 ++++++++++++++ webui/src/list/ListQuery.js | 140 ++++++++++++++++++++++++-------- 4 files changed, 298 insertions(+), 37 deletions(-) create mode 100644 webui/src/__tests__/query.js create mode 100644 webui/src/list/FilterToolbar.js diff --git a/webui/src/__tests__/query.js b/webui/src/__tests__/query.js new file mode 100644 index 0000000000000000000000000000000000000000..1415af02643a1a6e889cd0964aeb24639dc4d0ab --- /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 ce457d03bb8dd0dfc36b455f99f0484826d4f93e..c93b2d356d6687e071a48af413be73b4941d9cf2 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 ( - ); } 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 0000000000000000000000000000000000000000..e6d6f4edfdcad9b7dfb53cccd37b7544a8427143 --- /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 ( + + + open + + + closed + +
+ Author + Label + Sort + + ); +} + +export default FilterToolbar; diff --git a/webui/src/list/ListQuery.js b/webui/src/list/ListQuery.js index 9cbfab67c47abefb2664e623b1ec6982658bf83b..b6a297029a0c008db51035b249d6490d839b63cc 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 ( -
+

No results matched your search.

); }; +const Error = ({ error }) => { + const classes = useStyles(); + return ( +
+ +

There was an error while fetching bug.

+

+ {error.message} +

+
+        {JSON.stringify(error, null, 2)}
+      
+
+ ); +}; + 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 = ; } else if (error) { - content =

Error: {JSON.stringify(error)}

; + content = ; } else { const bugs = data.defaultRepository.bugs; @@ -219,31 +280,42 @@ function ListQuery() { } } + const formSubmit = e => { + e.preventDefault(); + history.push(queryLocation(input)); + }; + return ( -
Issues
- - {/* TODO */} - - 123 open - - 456 closed -
- Author - Label - Sort - +
+

Issues

+
+ setInput(e.target.value)} + className={classes.search} + /> + + +
+ {content}
{loading ? 'Loading' : `Total: ${count}`}
- +