webui: typecheck remaining bug list components

Quentin Gliech created

Change summary

webui/package-lock.json              | 11 ++
webui/package.json                   |  2 
webui/src/CurrentIdentity.tsx        |  3 
webui/src/Label.tsx                  |  3 
webui/src/__tests__/query.ts         |  0 
webui/src/list/Filter.tsx            | 99 ++++++++++++++++++++---------
webui/src/list/FilterToolbar.graphql |  7 ++
webui/src/list/FilterToolbar.tsx     | 80 ++++++++++++-----------
8 files changed, 128 insertions(+), 77 deletions(-)

Detailed changes

webui/package-lock.json 🔗

@@ -127,6 +127,11 @@
         "uuid": "^3.1.0"
       }
     },
+    "@arrows/composition": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@arrows/composition/-/composition-1.2.2.tgz",
+      "integrity": "sha512-9fh1yHwrx32lundiB3SlZ/VwuStPB4QakPsSLrGJFH6rCXvdrd060ivAZ7/2vlqPnEjBkPRRXOcG1YOu19p2GQ=="
+    },
     "@babel/code-frame": {
       "version": "7.8.3",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz",
@@ -4536,9 +4541,9 @@
       }
     },
     "clsx": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.0.4.tgz",
-      "integrity": "sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg=="
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.0.tgz",
+      "integrity": "sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA=="
     },
     "co": {
       "version": "4.6.0",

webui/package.json 🔗

@@ -4,6 +4,7 @@
   "private": true,
   "dependencies": {
     "@apollo/react-hooks": "^3.1.3",
+    "@arrows/composition": "^1.2.2",
     "@material-ui/core": "^4.9.0",
     "@material-ui/icons": "^4.2.1",
     "@material-ui/lab": "^4.0.0-alpha.40",
@@ -13,6 +14,7 @@
     "@types/react-dom": "^16.9.5",
     "@types/react-router-dom": "^5.1.3",
     "apollo-boost": "^0.4.7",
+    "clsx": "^1.1.0",
     "graphql": "^14.6.0",
     "graphql.macro": "^1.4.2",
     "moment": "^2.24.0",

webui/src/CurrentIdentity.tsx 🔗

@@ -14,8 +14,7 @@ const CurrentIdentity = () => {
   const classes = useStyles();
   const { loading, error, data } = useCurrentIdentityQuery();
 
-  if (error || loading || !data?.defaultRepository?.userIdentity)
-    return null;
+  if (error || loading || !data?.defaultRepository?.userIdentity) return null;
 
   const user = data.defaultRepository.userIdentity;
   return (

webui/src/Label.tsx 🔗

@@ -18,7 +18,8 @@ const getTextColor = (background: string) =>
     ? common.white // White on dark backgrounds
     : common.black; // And black on light ones
 
-const _rgb = (color: Color) => 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
+const _rgb = (color: Color) =>
+  'rgb(' + color.R + ',' + color.G + ',' + color.B + ')';
 
 // Create a style object from the label RGB colors
 const createStyle = (color: Color) => ({

webui/src/list/Filter.js → webui/src/list/Filter.tsx 🔗

@@ -1,13 +1,18 @@
 import React, { useState, useRef } from 'react';
 import { Link } from 'react-router-dom';
-import { makeStyles } from '@material-ui/styles';
+import { LocationDescriptor } from 'history';
+import clsx from 'clsx';
+import { makeStyles } from '@material-ui/core/styles';
+import { SvgIconProps } from '@material-ui/core/SvgIcon';
 import Menu from '@material-ui/core/Menu';
 import MenuItem from '@material-ui/core/MenuItem';
 import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
 
-function parse(query) {
+export type Query = { [key: string]: Array<string> };
+
+function parse(query: string): Query {
   // TODO: extract the rest of the query?
-  const params = {};
+  const params: Query = {};
 
   // TODO: support escaping without quotes
   const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g;
@@ -29,7 +34,7 @@ function parse(query) {
   return params;
 }
 
-function quote(value) {
+function quote(value: string): string {
   const hasSingle = value.includes("'");
   const hasDouble = value.includes('"');
   const hasSpaces = value.includes(' ');
@@ -49,19 +54,19 @@ function quote(value) {
   return `"${value}"`;
 }
 
-function stringify(params) {
-  const parts = Object.entries(params).map(([key, values]) => {
+function stringify(params: Query): string {
+  const parts: string[][] = Object.entries(params).map(([key, values]) => {
     return values.map(value => `${key}:${quote(value)}`);
   });
-  return [].concat(...parts).join(' ');
+  return new Array<string>().concat(...parts).join(' ');
 }
 
 const useStyles = makeStyles(theme => ({
   element: {
     ...theme.typography.body2,
-    color: ({ active }) => (active ? '#333' : '#444'),
+    color: '#444',
     padding: theme.spacing(0, 1),
-    fontWeight: ({ active }) => (active ? 600 : 400),
+    fontWeight: 400,
     textDecoration: 'none',
     display: 'flex',
     background: 'none',
@@ -69,21 +74,51 @@ const useStyles = makeStyles(theme => ({
   },
   itemActive: {
     fontWeight: 600,
+    color: '#333',
   },
   icon: {
     paddingRight: theme.spacing(0.5),
   },
 }));
 
-function Dropdown({ children, dropdown, itemActive, to, ...props }) {
+type DropdownTuple = [string, string];
+
+type FilterDropdownProps = {
+  children: React.ReactNode;
+  dropdown: DropdownTuple[];
+  itemActive: (key: string) => boolean;
+  icon?: React.ComponentType<SvgIconProps>;
+  to: (key: string) => LocationDescriptor;
+} & React.ButtonHTMLAttributes<HTMLButtonElement>;
+
+function FilterDropdown({
+  children,
+  dropdown,
+  itemActive,
+  icon: Icon,
+  to,
+  ...props
+}: FilterDropdownProps) {
   const [open, setOpen] = useState(false);
-  const buttonRef = useRef();
-  const classes = useStyles();
+  const buttonRef = useRef<HTMLButtonElement>(null);
+  const classes = useStyles({ active: false });
+
+  const content = (
+    <>
+      {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
+      <div>{children}</div>
+    </>
+  );
 
   return (
     <>
-      <button ref={buttonRef} onClick={() => setOpen(!open)} {...props}>
-        {children}
+      <button
+        ref={buttonRef}
+        onClick={() => setOpen(!open)}
+        className={classes.element}
+        {...props}
+      >
+        {content}
         <ArrowDropDown fontSize="small" />
       </button>
       <Menu
@@ -104,7 +139,7 @@ function Dropdown({ children, dropdown, itemActive, to, ...props }) {
           <MenuItem
             component={Link}
             to={to(key)}
-            className={itemActive(key) ? classes.itemActive : null}
+            className={itemActive(key) ? classes.itemActive : undefined}
             onClick={() => setOpen(false)}
             key={key}
           >
@@ -116,8 +151,14 @@ function Dropdown({ children, dropdown, itemActive, to, ...props }) {
   );
 }
 
-function Filter({ active, to, children, icon: Icon, dropdown, ...props }) {
-  const classes = useStyles({ active });
+export type FilterProps = {
+  active: boolean;
+  to: LocationDescriptor;
+  icon?: React.ComponentType<SvgIconProps>;
+  children: React.ReactNode;
+};
+function Filter({ active, to, children, icon: Icon }: FilterProps) {
+  const classes = useStyles();
 
   const content = (
     <>
@@ -126,29 +167,23 @@ function Filter({ active, to, children, icon: Icon, dropdown, ...props }) {
     </>
   );
 
-  if (dropdown) {
+  if (to) {
     return (
-      <Dropdown
-        {...props}
+      <Link
         to={to}
-        dropdown={dropdown}
-        className={classes.element}
+        className={clsx(classes.element, active && classes.itemActive)}
       >
         {content}
-      </Dropdown>
-    );
-  }
-
-  if (to) {
-    return (
-      <Link to={to} {...props} className={classes.element}>
-        {content}
       </Link>
     );
   }
 
-  return <div className={classes.element}>{content}</div>;
+  return (
+    <div className={clsx(classes.element, active && classes.itemActive)}>
+      {content}
+    </div>
+  );
 }
 
 export default Filter;
-export { parse, stringify, quote };
+export { parse, stringify, quote, FilterDropdown, Filter };

webui/src/list/FilterToolbar.js → webui/src/list/FilterToolbar.tsx 🔗

@@ -1,16 +1,19 @@
-import { makeStyles } from '@material-ui/styles';
-import { useQuery } from '@apollo/react-hooks';
-import gql from 'graphql-tag';
+import { makeStyles } from '@material-ui/core/styles';
 import React from 'react';
+import { LocationDescriptor } from 'history';
+import { pipe } from '@arrows/composition';
 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);
+import {
+  FilterDropdown,
+  FilterProps,
+  Filter,
+  parse,
+  stringify,
+  Query,
+} from './Filter';
+import { useBugCountQuery } from './FilterToolbar.generated';
 
 const useStyles = makeStyles(theme => ({
   toolbar: {
@@ -25,25 +28,19 @@ const useStyles = makeStyles(theme => ({
   },
 }));
 
-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, {
+type CountingFilterProps = {
+  query: string;
+  children: React.ReactNode;
+} & FilterProps;
+function CountingFilter({ query, children, ...props }: CountingFilterProps) {
+  const { data, loading, error } = useBugCountQuery({
     variables: { query },
   });
 
   var prefix;
   if (loading) prefix = '...';
-  else if (error) prefix = '???';
+  else if (error || !data?.defaultRepository) prefix = '???';
   // TODO: better prefixes & error handling
   else prefix = data.defaultRepository.bugs.totalCount;
 
@@ -54,18 +51,26 @@ function CountingFilter({ query, children, ...props }) {
   );
 }
 
-function FilterToolbar({ query, queryLocation }) {
+type Props = {
+  query: string;
+  queryLocation: (query: string) => LocationDescriptor;
+};
+function FilterToolbar({ query, queryLocation }: Props) {
   const classes = useStyles();
-  const params = parse(query);
+  const params: Query = 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 => ({
+  const hasKey = (key: string): boolean =>
+    params[key] && params[key].length > 0;
+  const hasValue = (key: string, value: string): boolean =>
+    hasKey(key) && params[key].includes(value);
+  const loc = pipe(stringify, queryLocation);
+  const replaceParam = (key: string, value: string) => (
+    params: Query
+  ): Query => ({
     ...params,
     [key]: [value],
   });
-  const clearParam = key => params => ({
+  const clearParam = (key: string) => (params: Query): Query => ({
     ...params,
     [key]: [],
   });
@@ -76,12 +81,11 @@ function FilterToolbar({ query, queryLocation }) {
       <CountingFilter
         active={hasValue('status', 'open')}
         query={pipe(
-          params,
           replaceParam('status', 'open'),
           clearParam('sort'),
           stringify
-        )}
-        to={pipe(params, replaceParam('status', 'open'), loc)}
+        )(params)}
+        to={pipe(replaceParam('status', 'open'), loc)(params)}
         icon={ErrorOutline}
       >
         open
@@ -89,12 +93,11 @@ function FilterToolbar({ query, queryLocation }) {
       <CountingFilter
         active={hasValue('status', 'closed')}
         query={pipe(
-          params,
           replaceParam('status', 'closed'),
           clearParam('sort'),
           stringify
-        )}
-        to={pipe(params, replaceParam('status', 'closed'), loc)}
+        )(params)}
+        to={pipe(replaceParam('status', 'closed'), loc)(params)}
         icon={CheckCircleOutline}
       >
         closed
@@ -104,7 +107,7 @@ function FilterToolbar({ query, queryLocation }) {
       <Filter active={hasKey('author')}>Author</Filter>
       <Filter active={hasKey('label')}>Label</Filter>
       */}
-      <Filter
+      <FilterDropdown
         dropdown={[
           ['id', 'ID'],
           ['creation', 'Newest'],
@@ -112,12 +115,11 @@ function FilterToolbar({ query, queryLocation }) {
           ['edit', 'Recently updated'],
           ['edit-asc', 'Least recently updated'],
         ]}
-        active={hasKey('sort')}
         itemActive={key => hasValue('sort', key)}
-        to={key => pipe(params, replaceParam('sort', key), loc)}
+        to={key => pipe(replaceParam('sort', key), loc)(params)}
       >
         Sort
-      </Filter>
+      </FilterDropdown>
     </Toolbar>
   );
 }