webui: implement filtering

Quentin Gliech created

Change summary

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(-)

Detailed changes

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'
+  );
+});

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 };

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;

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>