diff options
Diffstat (limited to 'webui/src')
-rw-r--r-- | webui/src/__tests__/query.js | 62 | ||||
-rw-r--r-- | webui/src/list/BugRow.js | 3 | ||||
-rw-r--r-- | webui/src/list/Filter.js | 154 | ||||
-rw-r--r-- | webui/src/list/FilterToolbar.js | 125 | ||||
-rw-r--r-- | webui/src/list/List.js | 48 | ||||
-rw-r--r-- | webui/src/list/ListQuery.js | 314 |
6 files changed, 642 insertions, 64 deletions
diff --git a/webui/src/__tests__/query.js b/webui/src/__tests__/query.js new file mode 100644 index 00000000..5f4b58eb --- /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: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' + ); +}); diff --git a/webui/src/list/BugRow.js b/webui/src/list/BugRow.js index 23414a36..add5c12f 100644 --- a/webui/src/list/BugRow.js +++ b/webui/src/list/BugRow.js @@ -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> ); diff --git a/webui/src/list/Filter.js b/webui/src/list/Filter.js new file mode 100644 index 00000000..a6cf3633 --- /dev/null +++ b/webui/src/list/Filter.js @@ -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 }; diff --git a/webui/src/list/FilterToolbar.js b/webui/src/list/FilterToolbar.js new file mode 100644 index 00000000..4d0b52b1 --- /dev/null +++ b/webui/src/list/FilterToolbar.js @@ -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; diff --git a/webui/src/list/List.js b/webui/src/list/List.js index 54b2fe97..63b73545 100644 --- a/webui/src/list/List.js +++ b/webui/src/list/List.js @@ -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> ); } diff --git a/webui/src/list/ListQuery.js b/webui/src/list/ListQuery.js index 869bca79..8eeec240 100644 --- a/webui/src/list/ListQuery.js +++ b/webui/src/list/ListQuery.js @@ -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> ); } |