Merge pull request #301 from MichaelMure/webui/issue-filtering

Michael Muré created

Issue list improvements and filtering

Change summary

webui/package-lock.json         |  67 ++++---
webui/package.json              |   6 
webui/src/__tests__/query.js    |  62 ++++++
webui/src/list/BugRow.js        |   3 
webui/src/list/Filter.js        | 154 +++++++++++++++++
webui/src/list/FilterToolbar.js | 125 +++++++++++++
webui/src/list/List.js          |  48 ----
webui/src/list/ListQuery.js     | 314 ++++++++++++++++++++++++++++++++--
8 files changed, 684 insertions(+), 95 deletions(-)

Detailed changes

webui/package-lock.json 🔗

@@ -2086,14 +2086,14 @@
       }
     },
     "@material-ui/core": {
-      "version": "4.8.3",
-      "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.8.3.tgz",
-      "integrity": "sha512-ZJbfJQmkuZCSQTf0nzpfZwizmDdCq8ruZxnPNFnhoKDqgJpMvV8TJRi8vdI9ls1tMuTqxlhyhw8556fxOpWpFQ==",
+      "version": "4.9.0",
+      "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.9.0.tgz",
+      "integrity": "sha512-zrrr8mPU5DDBYaVil4uJYauW41PjSn5otn7cqGsmWOY0t90fypr9nNgM7rRJaPz2AP6oRSDx1kBQt2igf5uelg==",
       "requires": {
         "@babel/runtime": "^7.4.4",
-        "@material-ui/styles": "^4.8.2",
+        "@material-ui/styles": "^4.9.0",
         "@material-ui/system": "^4.7.1",
-        "@material-ui/types": "^4.1.1",
+        "@material-ui/types": "^5.0.0",
         "@material-ui/utils": "^4.7.1",
         "@types/react-transition-group": "^4.2.0",
         "clsx": "^1.0.2",
@@ -2114,26 +2114,38 @@
         "@babel/runtime": "^7.4.4"
       }
     },
+    "@material-ui/lab": {
+      "version": "4.0.0-alpha.40",
+      "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.40.tgz",
+      "integrity": "sha512-VwXCNFJKfctu9Ot9XP5u2SSzXpm2Fn7F/o08bUfrJDkMCuRc8MCGVnNhT+guZRZa35rR97uWKc3SGQ/LAv8yEg==",
+      "requires": {
+        "@babel/runtime": "^7.4.4",
+        "@material-ui/utils": "^4.7.1",
+        "clsx": "^1.0.4",
+        "prop-types": "^15.7.2",
+        "react-is": "^16.8.0"
+      }
+    },
     "@material-ui/styles": {
-      "version": "4.8.2",
-      "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.8.2.tgz",
-      "integrity": "sha512-r5U+93pkpwQOmHTmwyn2sqTio6PHd873xvSHiKP6fdybAXXX6CZgVvh3W8saZNbYr/QXsS8OHmFv7sYJLt5Yfg==",
+      "version": "4.9.0",
+      "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.9.0.tgz",
+      "integrity": "sha512-nJHum4RqYBPWsjL/9JET8Z02FZ9gSizlg/7LWVFpIthNzpK6OQ5OSRR4T4x9/p+wK3t1qNn3b1uI4XpnZaPxOA==",
       "requires": {
         "@babel/runtime": "^7.4.4",
         "@emotion/hash": "^0.7.4",
-        "@material-ui/types": "^4.1.1",
+        "@material-ui/types": "^5.0.0",
         "@material-ui/utils": "^4.7.1",
         "clsx": "^1.0.2",
         "csstype": "^2.5.2",
         "hoist-non-react-statics": "^3.2.1",
-        "jss": "^10.0.0",
-        "jss-plugin-camel-case": "^10.0.0",
-        "jss-plugin-default-unit": "^10.0.0",
-        "jss-plugin-global": "^10.0.0",
-        "jss-plugin-nested": "^10.0.0",
-        "jss-plugin-props-sort": "^10.0.0",
-        "jss-plugin-rule-value-function": "^10.0.0",
-        "jss-plugin-vendor-prefixer": "^10.0.0",
+        "jss": "^10.0.3",
+        "jss-plugin-camel-case": "^10.0.3",
+        "jss-plugin-default-unit": "^10.0.3",
+        "jss-plugin-global": "^10.0.3",
+        "jss-plugin-nested": "^10.0.3",
+        "jss-plugin-props-sort": "^10.0.3",
+        "jss-plugin-rule-value-function": "^10.0.3",
+        "jss-plugin-vendor-prefixer": "^10.0.3",
         "prop-types": "^15.7.2"
       }
     },
@@ -2148,12 +2160,9 @@
       }
     },
     "@material-ui/types": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-4.1.1.tgz",
-      "integrity": "sha512-AN+GZNXytX9yxGi0JOfxHrRTbhFybjUJ05rnsBVjcB+16e466Z0Xe5IxawuOayVZgTBNDxmPKo5j4V6OnMtaSQ==",
-      "requires": {
-        "@types/react": "*"
-      }
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.0.0.tgz",
+      "integrity": "sha512-UeH2BuKkwDndtMSS0qgx1kCzSMw+ydtj0xx/XbFtxNSTlXydKwzs5gVW5ZKsFlAkwoOOQ9TIsyoCC8hq18tOwg=="
     },
     "@material-ui/utils": {
       "version": "4.7.1",
@@ -2471,9 +2480,9 @@
       "integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw=="
     },
     "@types/react": {
-      "version": "16.9.18",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.18.tgz",
-      "integrity": "sha512-MvjiKX/kUE8o49ipppg49RDZ97p4XfW1WWksp/UlTUSJpisyhzd62pZAMXxAscFLoxfYOflkGANAnGkSeHTFQg==",
+      "version": "16.9.19",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.19.tgz",
+      "integrity": "sha512-LJV97//H+zqKWMms0kvxaKYJDG05U2TtQB3chRLF8MPNs+MQh/H1aGlyDUxjaHvu08EAGerdX2z4LTBc7ns77A==",
       "requires": {
         "@types/prop-types": "*",
         "csstype": "^2.2.0"
@@ -12382,9 +12391,9 @@
       }
     },
     "popper.js": {
-      "version": "1.16.0",
-      "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.0.tgz",
-      "integrity": "sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw=="
+      "version": "1.16.1",
+      "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
+      "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="
     },
     "portfinder": {
       "version": "1.0.25",

webui/package.json 🔗

@@ -3,9 +3,11 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
-    "@material-ui/core": "^4.3.3",
+    "@apollo/react-hooks": "^3.1.3",
+    "@material-ui/core": "^4.9.0",
     "@material-ui/icons": "^4.2.1",
-    "@material-ui/styles": "^4.3.3",
+    "@material-ui/lab": "^4.0.0-alpha.40",
+    "@material-ui/styles": "^4.9.0",
     "apollo-boost": "^0.4.7",
     "graphql": "^14.3.0",
     "moment": "^2.24.0",

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

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

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

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;

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

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