Merge pull request #331 from MichaelMure/webui/mutations

Michael MurΓ© created

Webui: add comments

Change summary

bridge/github/import.go                            |   2 
webui/.eslintrc.js                                 |   6 
webui/package-lock.json                            |   5 
webui/package.json                                 |   4 
webui/src/App.tsx                                  |  65 ------
webui/src/Date.tsx                                 |  12 -
webui/src/Label.graphql                            |   8 
webui/src/__tests__/query.ts                       |   2 
webui/src/apollo.ts                                |  18 +
webui/src/components/Author.tsx                    |   5 
webui/src/components/Content/ImageTag.tsx          |   3 
webui/src/components/Content/PreTag.tsx            |   3 
webui/src/components/Content/index.tsx             |   4 
webui/src/components/Date.tsx                      |  20 ++
webui/src/components/Label.tsx                     |   8 
webui/src/components/fragments.graphql             |  11 +
webui/src/index.tsx                                |  29 --
webui/src/layout/CurrentIdentity.graphql           |   0 
webui/src/layout/CurrentIdentity.tsx               |   3 
webui/src/layout/Header.tsx                        |  50 +++++
webui/src/layout/index.tsx                         |  18 +
webui/src/pages/bug/Bug.graphql                    |   3 
webui/src/pages/bug/Bug.tsx                        |  29 ++
webui/src/pages/bug/BugQuery.graphql               |   0 
webui/src/pages/bug/BugQuery.tsx                   |   3 
webui/src/pages/bug/CommentForm.graphql            |   5 
webui/src/pages/bug/CommentForm.tsx                | 146 ++++++++++++++++
webui/src/pages/bug/LabelChange.tsx                |  11 
webui/src/pages/bug/LabelChangeFragment.graphql    |   3 
webui/src/pages/bug/Message.tsx                    |  11 
webui/src/pages/bug/MessageCommentFragment.graphql |   2 
webui/src/pages/bug/MessageCreateFragment.graphql  |   2 
webui/src/pages/bug/SetStatus.tsx                  |  21 +
webui/src/pages/bug/SetStatusFragment.graphql      |   2 
webui/src/pages/bug/SetTitle.tsx                   |  24 +
webui/src/pages/bug/SetTitleFragment.graphql       |   2 
webui/src/pages/bug/Timeline.tsx                   |   3 
webui/src/pages/bug/TimelineQuery.graphql          |   0 
webui/src/pages/bug/TimelineQuery.tsx              |   3 
webui/src/pages/bug/index.tsx                      |   1 
webui/src/pages/list/BugRow.graphql                |   3 
webui/src/pages/list/BugRow.tsx                    |  15 
webui/src/pages/list/Filter.tsx                    |  11 
webui/src/pages/list/FilterToolbar.graphql         |   0 
webui/src/pages/list/FilterToolbar.tsx             |  17 +
webui/src/pages/list/List.tsx                      |   3 
webui/src/pages/list/ListQuery.graphql             |   0 
webui/src/pages/list/ListQuery.tsx                 |   9 
webui/src/pages/list/index.ts                      |   1 
webui/src/theme.ts                                 |  11 +
webui/tsconfig.json                                |  16 +
51 files changed, 450 insertions(+), 183 deletions(-)

Detailed changes

bridge/github/import.go πŸ”—

@@ -161,7 +161,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline
 				b, _, err = repo.NewBugRaw(
 					author,
 					issue.CreatedAt.Unix(),
-					issue.Title,
+					issue.Title, // TODO: this is the *current* title, not the original one
 					cleanText,
 					nil,
 					map[string]string{

webui/.eslintrc.js πŸ”—

@@ -29,9 +29,13 @@ module.exports = {
             position: 'after',
           },
         ],
-        groups: [['builtin', 'external'], 'parent', ['sibling', 'index']],
+        pathGroupsExcludedImportTypes: ["builtin"],
+        groups: [['builtin', 'external'], ['internal', 'parent'], ['sibling', 'index']],
         'newlines-between': 'always',
       },
     ],
   },
+  settings: {
+    'import/internal-regex': '^src/',
+  },
 };

webui/package-lock.json πŸ”—

@@ -14608,6 +14608,11 @@
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz",
       "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
     },
+    "react-moment": {
+      "version": "0.9.7",
+      "resolved": "https://registry.npmjs.org/react-moment/-/react-moment-0.9.7.tgz",
+      "integrity": "sha512-ifzUrUGF6KRsUN2pRG5k56kO0mJBr8kRkWb0wNvtFIsBIxOuPxhUpL1YlXwpbQCbHq23hUu6A0VEk64HsFxk9g=="
+    },
     "react-router": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz",

webui/package.json πŸ”—

@@ -21,6 +21,7 @@
     "react": "^16.8.6",
     "react-apollo": "^3.1.3",
     "react-dom": "^16.8.6",
+    "react-moment": "^0.9.7",
     "react-router": "^5.0.0",
     "react-router-dom": "^5.0.0",
     "react-scripts": "^3.3.1",
@@ -48,7 +49,8 @@
     "test": "react-scripts test --env=jsdom",
     "eject": "react-scripts eject",
     "generate": "graphql-codegen",
-    "lint": "eslint src --ext .ts --ext .tsx --ext .js --ext .jsx --ext .graphql"
+    "lint": "eslint src --ext .ts --ext .tsx --ext .js --ext .jsx --ext .graphql",
+    "clean": "rimraf src/**.generated.* src/schema.json src/gqlTypes.* src/fragmentTypes.*"
   },
   "proxy": "http://localhost:3001",
   "browserslist": [

webui/src/App.tsx πŸ”—

@@ -1,68 +1,17 @@
-import AppBar from '@material-ui/core/AppBar';
-import CssBaseline from '@material-ui/core/CssBaseline';
-import Toolbar from '@material-ui/core/Toolbar';
-import {
-  createMuiTheme,
-  ThemeProvider,
-  makeStyles,
-} from '@material-ui/core/styles';
 import React from 'react';
 import { Route, Switch } from 'react-router';
-import { Link } from 'react-router-dom';
 
-import CurrentIdentity from './CurrentIdentity';
-import BugQuery from './bug/BugQuery';
-import ListQuery from './list/ListQuery';
-
-const theme = createMuiTheme({
-  palette: {
-    primary: {
-      main: '#263238',
-    },
-  },
-});
-
-const useStyles = makeStyles(theme => ({
-  offset: {
-    ...theme.mixins.toolbar,
-  },
-  filler: {
-    flexGrow: 1,
-  },
-  appTitle: {
-    ...theme.typography.h6,
-    color: 'white',
-    textDecoration: 'none',
-    display: 'flex',
-    alignItems: 'center',
-  },
-  logo: {
-    height: '42px',
-    marginRight: theme.spacing(2),
-  },
-}));
+import Layout from './layout';
+import BugPage from './pages/bug';
+import ListPage from './pages/list';
 
 export default function App() {
-  const classes = useStyles();
-
   return (
-    <ThemeProvider theme={theme}>
-      <CssBaseline />
-      <AppBar position="fixed" color="primary">
-        <Toolbar>
-          <Link to="/" className={classes.appTitle}>
-            <img src="/logo.svg" className={classes.logo} alt="git-bug" />
-            git-bug
-          </Link>
-          <div className={classes.filler}></div>
-          <CurrentIdentity />
-        </Toolbar>
-      </AppBar>
-      <div className={classes.offset} />
+    <Layout>
       <Switch>
-        <Route path="/" exact component={ListQuery} />
-        <Route path="/bug/:id" exact component={BugQuery} />
+        <Route path="/" exact component={ListPage} />
+        <Route path="/bug/:id" exact component={BugPage} />
       </Switch>
-    </ThemeProvider>
+    </Layout>
   );
 }

webui/src/Date.tsx πŸ”—

@@ -1,12 +0,0 @@
-import Tooltip from '@material-ui/core/Tooltip/Tooltip';
-import moment from 'moment';
-import React from 'react';
-
-type Props = { date: string };
-const Date = ({ date }: Props) => (
-  <Tooltip title={moment(date).format('MMMM D, YYYY, h:mm a')}>
-    <span> {moment(date).fromNow()} </span>
-  </Tooltip>
-);
-
-export default Date;

webui/src/__tests__/query.ts πŸ”—

@@ -1,4 +1,4 @@
-import { parse, stringify, quote } from '../list/Filter';
+import { parse, stringify, quote } from 'src/pages/list/Filter';
 
 it('parses a simple query', () => {
   expect(parse('foo:bar')).toEqual({

webui/src/apollo.ts πŸ”—

@@ -0,0 +1,18 @@
+import ApolloClient from 'apollo-boost';
+import {
+  IntrospectionFragmentMatcher,
+  InMemoryCache,
+} from 'apollo-cache-inmemory';
+
+import introspectionQueryResultData from './fragmentTypes';
+
+const client = new ApolloClient({
+  uri: '/graphql',
+  cache: new InMemoryCache({
+    fragmentMatcher: new IntrospectionFragmentMatcher({
+      introspectionQueryResultData,
+    }),
+  }),
+});
+
+export default client;

webui/src/Author.tsx β†’ webui/src/components/Author.tsx πŸ”—

@@ -1,8 +1,9 @@
+import React from 'react';
+
 import MAvatar from '@material-ui/core/Avatar';
 import Tooltip from '@material-ui/core/Tooltip/Tooltip';
-import React from 'react';
 
-import { AuthoredFragment } from './Author.generated';
+import { AuthoredFragment } from './fragments.generated';
 
 type Props = AuthoredFragment & {
   className?: string;

webui/src/tag/ImageTag.tsx β†’ webui/src/components/Content/ImageTag.tsx πŸ”—

@@ -1,6 +1,7 @@
-import { makeStyles } from '@material-ui/styles';
 import React from 'react';
 
+import { makeStyles } from '@material-ui/styles';
+
 const useStyles = makeStyles({
   tag: {
     maxWidth: '100%',

webui/src/tag/PreTag.tsx β†’ webui/src/components/Content/PreTag.tsx πŸ”—

@@ -1,6 +1,7 @@
-import { makeStyles } from '@material-ui/styles';
 import React from 'react';
 
+import { makeStyles } from '@material-ui/styles';
+
 const useStyles = makeStyles({
   tag: {
     maxWidth: '100%',

webui/src/Content.tsx β†’ webui/src/components/Content/index.tsx πŸ”—

@@ -4,8 +4,8 @@ import parse from 'remark-parse';
 import remark2react from 'remark-react';
 import unified from 'unified';
 
-import ImageTag from './tag/ImageTag';
-import PreTag from './tag/PreTag';
+import ImageTag from './ImageTag';
+import PreTag from './PreTag';
 
 type Props = { markdown: string };
 const Content: React.FC<Props> = ({ markdown }: Props) => {

webui/src/components/Date.tsx πŸ”—

@@ -0,0 +1,20 @@
+import moment from 'moment';
+import React from 'react';
+import Moment from 'react-moment';
+
+import Tooltip from '@material-ui/core/Tooltip/Tooltip';
+
+const HOUR = 1000 * 3600;
+const DAY = 24 * HOUR;
+const WEEK = 7 * DAY;
+
+type Props = { date: string };
+const Date = ({ date }: Props) => (
+  <Tooltip title={moment(date).format('LLLL')}>
+    <span>
+      on <Moment date={date} format="ll" fromNowDuring={WEEK} />
+    </span>
+  </Tooltip>
+);
+
+export default Date;

webui/src/Label.tsx β†’ webui/src/components/Label.tsx πŸ”—

@@ -1,13 +1,15 @@
+import React from 'react';
+
 import { common } from '@material-ui/core/colors';
 import { makeStyles } from '@material-ui/core/styles';
 import {
   getContrastRatio,
   darken,
 } from '@material-ui/core/styles/colorManipulator';
-import React from 'react';
 
-import { LabelFragment } from './Label.generated';
-import { Color } from './gqlTypes';
+import { Color } from 'src/gqlTypes';
+
+import { LabelFragment } from './fragments.generated';
 
 // Minimum contrast between the background and the text color
 const contrastThreshold = 2.5;

webui/src/Author.graphql β†’ webui/src/components/fragments.graphql πŸ”—

@@ -1,3 +1,14 @@
+# Label.tsx
+fragment Label on Label {
+  name
+  color {
+    R
+    G
+    B
+  }
+}
+
+# Author.tsx
 fragment authored on Authored {
   author {
     name

webui/src/index.tsx πŸ”—

@@ -1,36 +1,19 @@
-import { createMuiTheme } from '@material-ui/core/styles';
-import ThemeProvider from '@material-ui/styles/ThemeProvider';
-import ApolloClient from 'apollo-boost';
-import {
-  IntrospectionFragmentMatcher,
-  InMemoryCache,
-} from 'apollo-cache-inmemory';
 import React from 'react';
 import { ApolloProvider } from 'react-apollo';
 import ReactDOM from 'react-dom';
 import { BrowserRouter } from 'react-router-dom';
 
-import App from './App';
-import introspectionQueryResultData from './fragmentTypes';
-
-const theme = createMuiTheme();
+import ThemeProvider from '@material-ui/styles/ThemeProvider';
 
-const client = new ApolloClient({
-  uri: '/graphql',
-  cache: new InMemoryCache({
-    fragmentMatcher: new IntrospectionFragmentMatcher({
-      introspectionQueryResultData,
-    }),
-  }),
-});
+import App from './App';
+import apolloClient from './apollo';
+import theme from './theme';
 
 ReactDOM.render(
-  <ApolloProvider client={client}>
+  <ApolloProvider client={apolloClient}>
     <BrowserRouter>
       <ThemeProvider theme={theme}>
-        <React.Suspense fallback={'Loading…'}>
-          <App />
-        </React.Suspense>
+        <App />
       </ThemeProvider>
     </BrowserRouter>
   </ApolloProvider>,

webui/src/CurrentIdentity.tsx β†’ webui/src/layout/CurrentIdentity.tsx πŸ”—

@@ -1,6 +1,7 @@
+import React from 'react';
+
 import Avatar from '@material-ui/core/Avatar';
 import { makeStyles } from '@material-ui/core/styles';
-import React from 'react';
 
 import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
 

webui/src/layout/Header.tsx πŸ”—

@@ -0,0 +1,50 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import AppBar from '@material-ui/core/AppBar';
+import Toolbar from '@material-ui/core/Toolbar';
+import { makeStyles } from '@material-ui/core/styles';
+
+import CurrentIdentity from './CurrentIdentity';
+
+const useStyles = makeStyles(theme => ({
+  offset: {
+    ...theme.mixins.toolbar,
+  },
+  filler: {
+    flexGrow: 1,
+  },
+  appTitle: {
+    ...theme.typography.h6,
+    color: 'white',
+    textDecoration: 'none',
+    display: 'flex',
+    alignItems: 'center',
+  },
+  logo: {
+    height: '42px',
+    marginRight: theme.spacing(2),
+  },
+}));
+
+function Header() {
+  const classes = useStyles();
+
+  return (
+    <>
+      <AppBar position="fixed" color="primary">
+        <Toolbar>
+          <Link to="/" className={classes.appTitle}>
+            <img src="/logo.svg" className={classes.logo} alt="git-bug" />
+            git-bug
+          </Link>
+          <div className={classes.filler}></div>
+          <CurrentIdentity />
+        </Toolbar>
+      </AppBar>
+      <div className={classes.offset} />
+    </>
+  );
+}
+
+export default Header;

webui/src/layout/index.tsx πŸ”—

@@ -0,0 +1,18 @@
+import React from 'react';
+
+import CssBaseline from '@material-ui/core/CssBaseline';
+
+import Header from './Header';
+
+type Props = { children: React.ReactNode };
+function Layout({ children }: Props) {
+  return (
+    <>
+      <CssBaseline />
+      <Header />
+      {children}
+    </>
+  );
+}
+
+export default Layout;

webui/src/bug/Bug.graphql β†’ webui/src/pages/bug/Bug.graphql πŸ”—

@@ -1,5 +1,4 @@
-#import "../Label.graphql"
-#import "../Author.graphql"
+#import "../components/fragments.graphql"
 
 fragment Bug on Bug {
   id

webui/src/bug/Bug.tsx β†’ webui/src/pages/bug/Bug.tsx πŸ”—

@@ -1,17 +1,19 @@
+import React from 'react';
+
 import Typography from '@material-ui/core/Typography/Typography';
 import { makeStyles } from '@material-ui/core/styles';
-import React from 'react';
 
-import Author from '../Author';
-import Date from '../Date';
-import Label from '../Label';
+import Author from 'src/components/Author';
+import Date from 'src/components/Date';
+import Label from 'src/components/Label';
 
 import { BugFragment } from './Bug.generated';
+import CommentForm from './CommentForm';
 import TimelineQuery from './TimelineQuery';
 
 const useStyles = makeStyles(theme => ({
   main: {
-    maxWidth: 800,
+    maxWidth: 1000,
     margin: 'auto',
     marginTop: theme.spacing(4),
   },
@@ -39,6 +41,9 @@ const useStyles = makeStyles(theme => ({
     marginTop: theme.spacing(2),
     flex: '0 0 200px',
   },
+  sidebarTitle: {
+    fontWeight: 'bold',
+  },
   labelList: {
     listStyle: 'none',
     padding: 0,
@@ -51,6 +56,12 @@ const useStyles = makeStyles(theme => ({
       display: 'block',
     },
   },
+  noLabel: {
+    ...theme.typography.body2,
+  },
+  commentForm: {
+    marginLeft: 48,
+  },
 }));
 
 type Props = {
@@ -75,10 +86,16 @@ function Bug({ bug }: Props) {
       <div className={classes.container}>
         <div className={classes.timeline}>
           <TimelineQuery id={bug.id} />
+          <div className={classes.commentForm}>
+            <CommentForm bugId={bug.id} />
+          </div>
         </div>
         <div className={classes.sidebar}>
-          <Typography variant={'subtitle1'}>Labels</Typography>
+          <span className={classes.sidebarTitle}>Labels</span>
           <ul className={classes.labelList}>
+            {bug.labels.length === 0 && (
+              <span className={classes.noLabel}>None yet</span>
+            )}
             {bug.labels.map(l => (
               <li className={classes.label} key={l.name}>
                 <Label label={l} key={l.name} />

webui/src/bug/BugQuery.tsx β†’ webui/src/pages/bug/BugQuery.tsx πŸ”—

@@ -1,7 +1,8 @@
-import CircularProgress from '@material-ui/core/CircularProgress';
 import React from 'react';
 import { RouteComponentProps } from 'react-router-dom';
 
+import CircularProgress from '@material-ui/core/CircularProgress';
+
 import Bug from './Bug';
 import { useGetBugQuery } from './BugQuery.generated';
 

webui/src/pages/bug/CommentForm.tsx πŸ”—

@@ -0,0 +1,146 @@
+import React, { useState, useRef } from 'react';
+
+import Button from '@material-ui/core/Button';
+import Paper from '@material-ui/core/Paper';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import { makeStyles, Theme } from '@material-ui/core/styles';
+
+import Content from 'src/components/Content';
+
+import { useAddCommentMutation } from './CommentForm.generated';
+import { TimelineDocument } from './TimelineQuery.generated';
+
+type StyleProps = { loading: boolean };
+const useStyles = makeStyles<Theme, StyleProps>(theme => ({
+  container: {
+    margin: theme.spacing(2, 0),
+    padding: theme.spacing(0, 2, 2, 2),
+  },
+  textarea: {},
+  tabContent: {
+    margin: theme.spacing(2, 0),
+  },
+  preview: {
+    borderBottom: `solid 3px ${theme.palette.grey['200']}`,
+    minHeight: '5rem',
+  },
+  actions: {
+    display: 'flex',
+    justifyContent: 'flex-end',
+  },
+}));
+
+type TabPanelProps = {
+  children: React.ReactNode;
+  value: number;
+  index: number;
+} & React.HTMLProps<HTMLDivElement>;
+function TabPanel({ children, value, index, ...props }: TabPanelProps) {
+  return (
+    <div
+      role="tabpanel"
+      hidden={value !== index}
+      id={`editor-tabpanel-${index}`}
+      aria-labelledby={`editor-tab-${index}`}
+      {...props}
+    >
+      {value === index && children}
+    </div>
+  );
+}
+
+const a11yProps = (index: number) => ({
+  id: `editor-tab-${index}`,
+  'aria-controls': `editor-tabpanel-${index}`,
+});
+
+type Props = {
+  bugId: string;
+};
+
+function CommentForm({ bugId }: Props) {
+  const [addComment, { loading }] = useAddCommentMutation();
+  const [input, setInput] = useState<string>('');
+  const [tab, setTab] = useState(0);
+  const classes = useStyles({ loading });
+  const form = useRef<HTMLFormElement>(null);
+
+  const submit = () => {
+    addComment({
+      variables: {
+        input: {
+          prefix: bugId,
+          message: input,
+        },
+      },
+      refetchQueries: [
+        // TODO: update the cache instead of refetching
+        {
+          query: TimelineDocument,
+          variables: {
+            id: bugId,
+            first: 100,
+          },
+        },
+      ],
+      awaitRefetchQueries: true,
+    }).then(() => setInput(''));
+  };
+
+  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    submit();
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
+    // Submit on cmd/ctrl+enter
+    if ((e.metaKey || e.altKey) && e.keyCode === 13) {
+      submit();
+    }
+  };
+
+  return (
+    <Paper className={classes.container}>
+      <form onSubmit={handleSubmit} ref={form}>
+        <Tabs value={tab} onChange={(_, t) => setTab(t)}>
+          <Tab label="Write" {...a11yProps(0)} />
+          <Tab label="Preview" {...a11yProps(1)} />
+        </Tabs>
+        <div className={classes.tabContent}>
+          <TabPanel value={tab} index={0}>
+            <TextField
+              onKeyDown={handleKeyDown}
+              fullWidth
+              label="Comment"
+              placeholder="Leave a comment"
+              className={classes.textarea}
+              multiline
+              value={input}
+              variant="filled"
+              rows="4" // TODO: rowsMin support
+              onChange={(e: any) => setInput(e.target.value)}
+              disabled={loading}
+            />
+          </TabPanel>
+          <TabPanel value={tab} index={1} className={classes.preview}>
+            <Content markdown={input} />
+          </TabPanel>
+        </div>
+        <div className={classes.actions}>
+          <Button
+            variant="contained"
+            color="primary"
+            type="submit"
+            disabled={loading}
+          >
+            Comment
+          </Button>
+        </div>
+      </form>
+    </Paper>
+  );
+}
+
+export default CommentForm;

webui/src/bug/LabelChange.tsx β†’ webui/src/pages/bug/LabelChange.tsx πŸ”—

@@ -1,15 +1,16 @@
-import { makeStyles } from '@material-ui/core/styles';
 import React from 'react';
 
-import Author from '../Author';
-import Date from '../Date';
-import Label from '../Label';
+import { makeStyles } from '@material-ui/core/styles';
+
+import Author from 'src/components/Author';
+import Date from 'src/components/Date';
+import Label from 'src/components/Label';
 
 import { LabelChangeFragment } from './LabelChangeFragment.generated';
 
 const useStyles = makeStyles(theme => ({
   main: {
-    ...theme.typography.body1,
+    ...theme.typography.body2,
     marginLeft: theme.spacing(1) + 40,
   },
   author: {

webui/src/bug/LabelChangeFragment.graphql β†’ webui/src/pages/bug/LabelChangeFragment.graphql πŸ”—

@@ -1,5 +1,4 @@
-#import "../Author.graphql"
-#import "../Label.graphql"
+#import "../../components/fragments.graphql"
 
 fragment LabelChange on LabelChangeTimelineItem {
   date

webui/src/bug/Message.tsx β†’ webui/src/pages/bug/Message.tsx πŸ”—

@@ -1,11 +1,11 @@
+import React from 'react';
+
 import Paper from '@material-ui/core/Paper';
 import { makeStyles } from '@material-ui/core/styles';
-import React from 'react';
 
-import Author from '../Author';
-import { Avatar } from '../Author';
-import Content from '../Content';
-import Date from '../Date';
+import Author, { Avatar } from 'src/components/Author';
+import Content from 'src/components/Content';
+import Date from 'src/components/Date';
 
 import { AddCommentFragment } from './MessageCommentFragment.generated';
 import { CreateFragment } from './MessageCreateFragment.generated';
@@ -31,6 +31,7 @@ const useStyles = makeStyles(theme => ({
     padding: '0.5rem 1rem',
     borderBottom: '1px solid #ddd',
     display: 'flex',
+    backgroundColor: '#e2f1ff',
   },
   title: {
     flex: 1,

webui/src/bug/SetStatus.tsx β†’ webui/src/pages/bug/SetStatus.tsx πŸ”—

@@ -1,16 +1,21 @@
-import { makeStyles } from '@material-ui/core/styles';
 import React from 'react';
 
-import Author from '../Author';
-import Date from '../Date';
+import { makeStyles } from '@material-ui/core/styles';
+
+import { Status } from '../../gqlTypes';
+import Author from 'src/components/Author';
+import Date from 'src/components/Date';
 
 import { SetStatusFragment } from './SetStatusFragment.generated';
 
 const useStyles = makeStyles(theme => ({
   main: {
-    ...theme.typography.body1,
+    ...theme.typography.body2,
     marginLeft: theme.spacing(1) + 40,
   },
+  author: {
+    fontWeight: 'bold',
+  },
 }));
 
 type Props = {
@@ -19,10 +24,14 @@ type Props = {
 
 function SetStatus({ op }: Props) {
   const classes = useStyles();
+  const status = { [Status.Open]: 'reopened', [Status.Closed]: 'closed' }[
+    op.status
+  ];
+
   return (
     <div className={classes.main}>
-      <Author author={op.author} bold />
-      <span> {op.status.toLowerCase()} this</span>
+      <Author author={op.author} className={classes.author} />
+      <span> {status} this </span>
       <Date date={op.date} />
     </div>
   );

webui/src/bug/SetTitle.tsx β†’ webui/src/pages/bug/SetTitle.tsx πŸ”—

@@ -1,17 +1,25 @@
-import { makeStyles } from '@material-ui/core/styles';
 import React from 'react';
 
-import Author from '../Author';
-import Date from '../Date';
+import { makeStyles } from '@material-ui/core/styles';
+
+import Author from 'src/components/Author';
+import Date from 'src/components/Date';
 
 import { SetTitleFragment } from './SetTitleFragment.generated';
 
 const useStyles = makeStyles(theme => ({
   main: {
-    ...theme.typography.body1,
+    ...theme.typography.body2,
     marginLeft: theme.spacing(1) + 40,
   },
-  bold: {
+  author: {
+    fontWeight: 'bold',
+  },
+  before: {
+    fontWeight: 'bold',
+    textDecoration: 'line-through',
+  },
+  after: {
     fontWeight: 'bold',
   },
 }));
@@ -24,11 +32,11 @@ function SetTitle({ op }: Props) {
   const classes = useStyles();
   return (
     <div className={classes.main}>
-      <Author author={op.author} className={classes.bold} />
+      <Author author={op.author} className={classes.author} />
       <span> changed the title from </span>
-      <span className={classes.bold}>{op.was}</span>
+      <span className={classes.before}>{op.was}</span>
       <span> to </span>
-      <span className={classes.bold}>{op.title}</span>
+      <span className={classes.after}>{op.title}</span>&nbsp;
       <Date date={op.date} />
     </div>
   );

webui/src/bug/Timeline.tsx β†’ webui/src/pages/bug/Timeline.tsx πŸ”—

@@ -1,6 +1,7 @@
-import { makeStyles } from '@material-ui/core/styles';
 import React from 'react';
 
+import { makeStyles } from '@material-ui/core/styles';
+
 import LabelChange from './LabelChange';
 import Message from './Message';
 import SetStatus from './SetStatus';

webui/src/bug/TimelineQuery.tsx β†’ webui/src/pages/bug/TimelineQuery.tsx πŸ”—

@@ -1,6 +1,7 @@
-import CircularProgress from '@material-ui/core/CircularProgress';
 import React from 'react';
 
+import CircularProgress from '@material-ui/core/CircularProgress';
+
 import Timeline from './Timeline';
 import { useTimelineQuery } from './TimelineQuery.generated';
 

webui/src/list/BugRow.graphql β†’ webui/src/pages/list/BugRow.graphql πŸ”—

@@ -1,5 +1,4 @@
-#import "../Author.graphql"
-#import "../Label.graphql"
+#import "../../components/fragments.graphql"
 
 fragment BugRow on Bug {
   id

webui/src/list/BugRow.tsx β†’ webui/src/pages/list/BugRow.tsx πŸ”—

@@ -1,15 +1,16 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
 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 { makeStyles } from '@material-ui/core/styles';
 import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
 import ErrorOutline from '@material-ui/icons/ErrorOutline';
-import React from 'react';
-import { Link } from 'react-router-dom';
 
-import Date from '../Date';
-import Label from '../Label';
-import { Status } from '../gqlTypes';
+import Date from 'src/components/Date';
+import Label from 'src/components/Label';
+import { Status } from 'src/gqlTypes';
 
 import { BugRowFragment } from './BugRow.generated';
 
@@ -99,9 +100,9 @@ function BugRow({ bug }: Props) {
             </div>
           </Link>
           <div className={classes.details}>
-            {bug.humanId} opened
+            {bug.humanId} opened&nbsp;
             <Date date={bug.createdAt} />
-            by {bug.author.displayName}
+            &nbsp;by {bug.author.displayName}
           </div>
         </div>
       </TableCell>

webui/src/list/Filter.tsx β†’ webui/src/pages/list/Filter.tsx πŸ”—

@@ -1,12 +1,13 @@
+import clsx from 'clsx';
+import { LocationDescriptor } from 'history';
+import React, { useState, useRef } from 'react';
+import { Link } from 'react-router-dom';
+
 import Menu from '@material-ui/core/Menu';
 import MenuItem from '@material-ui/core/MenuItem';
 import { SvgIconProps } from '@material-ui/core/SvgIcon';
 import { makeStyles } from '@material-ui/core/styles';
 import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
-import clsx from 'clsx';
-import { LocationDescriptor } from 'history';
-import React, { useState, useRef } from 'react';
-import { Link } from 'react-router-dom';
 
 export type Query = { [key: string]: Array<string> };
 
@@ -153,7 +154,7 @@ function FilterDropdown({
 
 export type FilterProps = {
   active: boolean;
-  to: LocationDescriptor;
+  to: LocationDescriptor; // the target on click
   icon?: React.ComponentType<SvgIconProps>;
   children: React.ReactNode;
 };

webui/src/list/FilterToolbar.tsx β†’ webui/src/pages/list/FilterToolbar.tsx πŸ”—

@@ -1,10 +1,11 @@
 import { pipe } from '@arrows/composition';
+import { LocationDescriptor } from 'history';
+import React from 'react';
+
 import Toolbar from '@material-ui/core/Toolbar';
 import { makeStyles } from '@material-ui/core/styles';
 import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
 import ErrorOutline from '@material-ui/icons/ErrorOutline';
-import { LocationDescriptor } from 'history';
-import React from 'react';
 
 import {
   FilterDropdown,
@@ -31,7 +32,7 @@ const useStyles = makeStyles(theme => ({
 
 // This prepends the filter text with a count
 type CountingFilterProps = {
-  query: string;
+  query: string; // the query used as a source to count the number of element
   children: React.ReactNode;
 } & FilterProps;
 function CountingFilter({ query, children, ...props }: CountingFilterProps) {
@@ -71,6 +72,12 @@ function FilterToolbar({ query, queryLocation }: Props) {
     ...params,
     [key]: [value],
   });
+  const toggleParam = (key: string, value: string) => (
+    params: Query
+  ): Query => ({
+    ...params,
+    [key]: params[key] && params[key].includes(value) ? [] : [value],
+  });
   const clearParam = (key: string) => (params: Query): Query => ({
     ...params,
     [key]: [],
@@ -86,7 +93,7 @@ function FilterToolbar({ query, queryLocation }: Props) {
           clearParam('sort'),
           stringify
         )(params)}
-        to={pipe(replaceParam('status', 'open'), loc)(params)}
+        to={pipe(toggleParam('status', 'open'), loc)(params)}
         icon={ErrorOutline}
       >
         open
@@ -98,7 +105,7 @@ function FilterToolbar({ query, queryLocation }: Props) {
           clearParam('sort'),
           stringify
         )(params)}
-        to={pipe(replaceParam('status', 'closed'), loc)(params)}
+        to={pipe(toggleParam('status', 'closed'), loc)(params)}
         icon={CheckCircleOutline}
       >
         closed

webui/src/list/List.tsx β†’ webui/src/pages/list/List.tsx πŸ”—

@@ -1,6 +1,7 @@
+import React from 'react';
+
 import Table from '@material-ui/core/Table/Table';
 import TableBody from '@material-ui/core/TableBody/TableBody';
-import React from 'react';
 
 import BugRow from './BugRow';
 import { BugListFragment } from './ListQuery.generated';

webui/src/list/ListQuery.tsx β†’ webui/src/pages/list/ListQuery.tsx πŸ”—

@@ -1,3 +1,7 @@
+import { ApolloError } from 'apollo-boost';
+import React, { useState, useEffect, useRef } from 'react';
+import { useLocation, useHistory, Link } from 'react-router-dom';
+
 import IconButton from '@material-ui/core/IconButton';
 import InputBase from '@material-ui/core/InputBase';
 import Paper from '@material-ui/core/Paper';
@@ -6,9 +10,6 @@ import ErrorOutline from '@material-ui/icons/ErrorOutline';
 import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
 import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
 import Skeleton from '@material-ui/lab/Skeleton';
-import { ApolloError } from 'apollo-boost';
-import React, { useState, useEffect, useRef } from 'react';
-import { useLocation, useHistory, Link } from 'react-router-dom';
 
 import FilterToolbar from './FilterToolbar';
 import List from './List';
@@ -163,7 +164,7 @@ function ListQuery() {
   const location = useLocation();
   const history = useHistory();
   const params = new URLSearchParams(location.search);
-  const query = params.get('q') || '';
+  const query = params.has('q') ? params.get('q') || '' : 'status:open';
 
   const [input, setInput] = useState(query);
 

webui/src/theme.ts πŸ”—

@@ -0,0 +1,11 @@
+import { createMuiTheme } from '@material-ui/core/styles';
+
+const theme = createMuiTheme({
+  palette: {
+    primary: {
+      main: '#263238',
+    },
+  },
+});
+
+export default theme;

webui/tsconfig.json πŸ”—

@@ -1,7 +1,11 @@
 {
   "compilerOptions": {
     "target": "es5",
-    "lib": ["dom", "dom.iterable", "esnext"],
+    "lib": [
+      "dom",
+      "dom.iterable",
+      "esnext"
+    ],
     "allowJs": true,
     "skipLibCheck": true,
     "esModuleInterop": true,
@@ -14,7 +18,13 @@
     "isolatedModules": true,
     "noEmit": true,
     "jsx": "react",
-    "typeRoots": ["node_modules/@types/", "types/"]
+    "typeRoots": [
+      "node_modules/@types/",
+      "types/"
+    ],
+    "baseUrl": "."
   },
-  "include": ["src"]
+  "include": [
+    "src"
+  ]
 }