diff options
Diffstat (limited to 'webui/src')
-rw-r--r-- | webui/src/__tests__/query.js | 62 | ||||
-rw-r--r-- | webui/src/list/Filter.js | 73 | ||||
-rw-r--r-- | webui/src/list/FilterToolbar.js | 60 | ||||
-rw-r--r-- | webui/src/list/ListQuery.js | 140 |
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> |