Merge pull request #623 from GlancingMind/upstream-additional-filters-for-bug-list

Michael Muré created

WebUI: Additional filters for bug list

Change summary

webui/.eslintrc.js                          |   1 
webui/src/pages/list/Filter.tsx             |  74 ++++++++++++--
webui/src/pages/list/FilterToolbar.tsx      |  69 +++++++++++++
webui/src/pages/list/ListIdentities.graphql |  13 ++
webui/src/pages/list/ListLabels.graphql     |   9 +
webui/src/pages/list/ListQuery.tsx          | 112 ++++++++++++++++------
6 files changed, 230 insertions(+), 48 deletions(-)

Detailed changes

webui/.eslintrc.js 🔗

@@ -38,4 +38,5 @@ module.exports = {
   settings: {
     'import/internal-regex': '^src/',
   },
+  ignorePatterns: ['**/*.generated.tsx'],
 };

webui/src/pages/list/Filter.tsx 🔗

@@ -1,14 +1,33 @@
 import clsx from 'clsx';
 import { LocationDescriptor } from 'history';
-import React, { useState, useRef } from 'react';
+import React, { useRef, useState, useEffect } 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 TextField from '@material-ui/core/TextField';
+import { makeStyles, withStyles } from '@material-ui/core/styles';
 import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
 
+const CustomTextField = withStyles((theme) => ({
+  root: {
+    margin: '0 8px 12px 8px',
+    '& label.Mui-focused': {
+      margin: '0 2px',
+      color: theme.palette.text.secondary,
+    },
+    '& .MuiInput-underline::before': {
+      borderBottomColor: theme.palette.divider,
+    },
+    '& .MuiInput-underline::after': {
+      borderBottomColor: theme.palette.divider,
+    },
+  },
+}))(TextField);
+
+const ITEM_HEIGHT = 48;
+
 export type Query = { [key: string]: string[] };
 
 function parse(query: string): Query {
@@ -90,6 +109,7 @@ type FilterDropdownProps = {
   itemActive: (key: string) => boolean;
   icon?: React.ComponentType<SvgIconProps>;
   to: (key: string) => LocationDescriptor;
+  hasFilter?: boolean;
 } & React.ButtonHTMLAttributes<HTMLButtonElement>;
 
 function FilterDropdown({
@@ -98,12 +118,19 @@ function FilterDropdown({
   itemActive,
   icon: Icon,
   to,
+  hasFilter,
   ...props
 }: FilterDropdownProps) {
   const [open, setOpen] = useState(false);
+  const [filter, setFilter] = useState<string>('');
   const buttonRef = useRef<HTMLButtonElement>(null);
+  const searchRef = useRef<HTMLButtonElement>(null);
   const classes = useStyles({ active: false });
 
+  useEffect(() => {
+    searchRef && searchRef.current && searchRef.current.focus();
+  }, [filter]);
+
   const content = (
     <>
       {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />}
@@ -124,6 +151,7 @@ function FilterDropdown({
       </button>
       <Menu
         getContentAnchorEl={null}
+        ref={searchRef}
         anchorOrigin={{
           vertical: 'bottom',
           horizontal: 'left',
@@ -135,18 +163,37 @@ function FilterDropdown({
         open={open}
         onClose={() => setOpen(false)}
         anchorEl={buttonRef.current}
+        PaperProps={{
+          style: {
+            maxHeight: ITEM_HEIGHT * 4.5,
+            width: '25ch',
+          },
+        }}
       >
-        {dropdown.map(([key, value]) => (
-          <MenuItem
-            component={Link}
-            to={to(key)}
-            className={itemActive(key) ? classes.itemActive : undefined}
-            onClick={() => setOpen(false)}
-            key={key}
-          >
-            {value}
-          </MenuItem>
-        ))}
+        {hasFilter && (
+          <CustomTextField
+            onChange={(e) => {
+              const { value } = e.target;
+              setFilter(value);
+            }}
+            onKeyDown={(e) => e.stopPropagation()}
+            value={filter}
+            label={`Filter ${children}`}
+          />
+        )}
+        {dropdown
+          .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase()))
+          .map(([key, value]) => (
+            <MenuItem
+              component={Link}
+              to={to(key)}
+              className={itemActive(key) ? classes.itemActive : undefined}
+              onClick={() => setOpen(false)}
+              key={key}
+            >
+              {value}
+            </MenuItem>
+          ))}
       </Menu>
     </>
   );
@@ -158,6 +205,7 @@ export type FilterProps = {
   icon?: React.ComponentType<SvgIconProps>;
   children: React.ReactNode;
 };
+
 function Filter({ active, to, children, icon: Icon }: FilterProps) {
   const classes = useStyles();
 

webui/src/pages/list/FilterToolbar.tsx 🔗

@@ -8,14 +8,16 @@ import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
 import ErrorOutline from '@material-ui/icons/ErrorOutline';
 
 import {
+  Filter,
   FilterDropdown,
   FilterProps,
-  Filter,
   parse,
-  stringify,
   Query,
+  stringify,
 } from './Filter';
 import { useBugCountQuery } from './FilterToolbar.generated';
+import { useListIdentitiesQuery } from './ListIdentities.generated';
+import { useListLabelsQuery } from './ListLabels.generated';
 
 const useStyles = makeStyles((theme) => ({
   toolbar: {
@@ -35,6 +37,7 @@ type CountingFilterProps = {
   query: string; // the query used as a source to count the number of element
   children: React.ReactNode;
 } & FilterProps;
+
 function CountingFilter({ query, children, ...props }: CountingFilterProps) {
   const { data, loading, error } = useBugCountQuery({
     variables: { query },
@@ -57,14 +60,44 @@ type Props = {
   query: string;
   queryLocation: (query: string) => LocationDescriptor;
 };
+
 function FilterToolbar({ query, queryLocation }: Props) {
   const classes = useStyles();
   const params: Query = parse(query);
+  const { data: identitiesData } = useListIdentitiesQuery();
+  const { data: labelsData } = useListLabelsQuery();
+
+  let identities: any = [];
+  let labels: any = [];
+
+  if (
+    identitiesData?.repository &&
+    identitiesData.repository.allIdentities &&
+    identitiesData.repository.allIdentities.nodes
+  ) {
+    identities = identitiesData.repository.allIdentities.nodes.map((node) => [
+      node.name,
+      node.name,
+    ]);
+  }
+
+  if (
+    labelsData?.repository &&
+    labelsData.repository.validLabels &&
+    labelsData.repository.validLabels.nodes
+  ) {
+    labels = labelsData.repository.validLabels.nodes.map((node) => [
+      node.name,
+      node.name,
+    ]);
+  }
 
   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 containsValue = (key: string, value: string): boolean =>
+    hasKey(key) && params[key].indexOf(value) !== -1;
   const loc = pipe(stringify, queryLocation);
   const replaceParam = (key: string, value: string) => (
     params: Query
@@ -78,6 +111,20 @@ function FilterToolbar({ query, queryLocation }: Props) {
     ...params,
     [key]: params[key] && params[key].includes(value) ? [] : [value],
   });
+  const toggleOrAddParam = (key: string, value: string) => (
+    params: Query
+  ): Query => {
+    const values = params[key];
+    return {
+      ...params,
+      [key]:
+        params[key] && params[key].includes(value)
+          ? values.filter((v) => v !== value)
+          : values
+          ? [...values, value]
+          : [value],
+    };
+  };
   const clearParam = (key: string) => (params: Query): Query => ({
     ...params,
     [key]: [],
@@ -115,6 +162,22 @@ function FilterToolbar({ query, queryLocation }: Props) {
       <Filter active={hasKey('author')}>Author</Filter>
       <Filter active={hasKey('label')}>Label</Filter>
       */}
+      <FilterDropdown
+        dropdown={identities}
+        itemActive={(key) => hasValue('author', key)}
+        to={(key) => pipe(toggleOrAddParam('author', key), loc)(params)}
+        hasFilter
+      >
+        Author
+      </FilterDropdown>
+      <FilterDropdown
+        dropdown={labels}
+        itemActive={(key) => containsValue('label', key)}
+        to={(key) => pipe(toggleOrAddParam('label', key), loc)(params)}
+        hasFilter
+      >
+        Labels
+      </FilterDropdown>
       <FilterDropdown
         dropdown={[
           ['id', 'ID'],
@@ -124,7 +187,7 @@ function FilterToolbar({ query, queryLocation }: Props) {
           ['edit-asc', 'Least recently updated'],
         ]}
         itemActive={(key) => hasValue('sort', key)}
-        to={(key) => pipe(replaceParam('sort', key), loc)(params)}
+        to={(key) => pipe(toggleParam('sort', key), loc)(params)}
       >
         Sort
       </FilterDropdown>

webui/src/pages/list/ListQuery.tsx 🔗

@@ -1,19 +1,23 @@
 import { ApolloError } from '@apollo/client';
+import { pipe } from '@arrows/composition';
 import React, { useState, useEffect, useRef } from 'react';
 import { useLocation, useHistory, Link } from 'react-router-dom';
 
-import { Button } from '@material-ui/core';
+import { Button, FormControl, Menu, MenuItem } from '@material-ui/core';
 import IconButton from '@material-ui/core/IconButton';
 import InputBase from '@material-ui/core/InputBase';
 import Paper from '@material-ui/core/Paper';
 import { makeStyles, Theme } from '@material-ui/core/styles';
+import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
 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 { useCurrentIdentityQuery } from '../../components/CurrentIdentity/CurrentIdentity.generated';
 import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
 
+import { parse, Query, stringify } from './Filter';
 import FilterToolbar from './FilterToolbar';
 import List from './List';
 import { useListBugsQuery } from './ListQuery.generated';
@@ -35,24 +39,17 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
   },
   header: {
     display: 'flex',
-    padding: theme.spacing(2),
-    '& > h1': {
-      ...theme.typography.h6,
-      margin: theme.spacing(0, 2),
-    },
-    alignItems: 'center',
-    justifyContent: 'space-between',
+    padding: theme.spacing(1),
   },
   filterissueLabel: {
     fontSize: '14px',
     fontWeight: 'bold',
     paddingRight: '12px',
   },
-  filterissueContainer: {
+  form: {
     display: 'flex',
-    flexDirection: 'row',
-    alignItems: 'flex-start',
-    justifyContents: 'left',
+    flexGrow: 1,
+    marginRight: theme.spacing(1),
   },
   search: {
     borderRadius: theme.shape.borderRadius,
@@ -62,7 +59,7 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
     borderWidth: '1px',
     backgroundColor: theme.palette.primary.light,
     padding: theme.spacing(0, 1),
-    width: ({ searching }) => (searching ? '20rem' : '15rem'),
+    width: '100%',
     transition: theme.transitions.create([
       'width',
       'borderColor',
@@ -192,6 +189,8 @@ function ListQuery() {
   const query = params.has('q') ? params.get('q') || '' : 'status:open';
 
   const [input, setInput] = useState(query);
+  const [filterMenuIsOpen, setFilterMenuIsOpen] = useState(false);
+  const filterButtonRef = useRef<HTMLButtonElement>(null);
 
   const classes = useStyles({ searching: !!input });
 
@@ -293,29 +292,78 @@ function ListQuery() {
     history.push(queryLocation(input));
   };
 
+  const {
+    loading: ciqLoading,
+    error: ciqError,
+    data: ciqData,
+  } = useCurrentIdentityQuery();
+  if (ciqError || ciqLoading || !ciqData?.repository?.userIdentity) {
+    return null;
+  }
+  const user = ciqData.repository.userIdentity;
+
+  const loc = pipe(stringify, queryLocation);
+  const qparams: Query = parse(query);
+  const replaceParam = (key: string, value: string) => (
+    params: Query
+  ): Query => ({
+    ...params,
+    [key]: [value],
+  });
+
   return (
     <Paper className={classes.main}>
       <header className={classes.header}>
-        <div className="filterissueContainer">
-          <form onSubmit={formSubmit}>
-            <label className={classes.filterissueLabel} htmlFor="issuefilter">
-              Filter
-            </label>
-            <InputBase
-              id="issuefilter"
-              placeholder="Filter"
-              value={input}
-              onInput={(e: any) => setInput(e.target.value)}
-              classes={{
-                root: classes.search,
-                focused: classes.searchFocused,
+        <form className={classes.form} onSubmit={formSubmit}>
+          <FormControl>
+            <Button
+              aria-haspopup="true"
+              ref={filterButtonRef}
+              onClick={(e) => setFilterMenuIsOpen(true)}
+            >
+              Filter <ArrowDropDownIcon />
+            </Button>
+            <Menu
+              open={filterMenuIsOpen}
+              onClose={() => setFilterMenuIsOpen(false)}
+              getContentAnchorEl={null}
+              anchorEl={filterButtonRef.current}
+              anchorOrigin={{
+                vertical: 'bottom',
+                horizontal: 'left',
               }}
-            />
-            <button type="submit" hidden>
-              Search
-            </button>
-          </form>
-        </div>
+              transformOrigin={{
+                vertical: 'top',
+                horizontal: 'left',
+              }}
+            >
+              <MenuItem
+                component={Link}
+                to={pipe(
+                  replaceParam('author', user.displayName),
+                  replaceParam('sort', 'creation'),
+                  loc
+                )(qparams)}
+                onClick={() => setFilterMenuIsOpen(false)}
+              >
+                Your newest issues
+              </MenuItem>
+            </Menu>
+          </FormControl>
+          <InputBase
+            id="issuefilter"
+            placeholder="Filter"
+            value={input}
+            onInput={(e: any) => setInput(e.target.value)}
+            classes={{
+              root: classes.search,
+              focused: classes.searchFocused,
+            }}
+          />
+          <button type="submit" hidden>
+            Search
+          </button>
+        </form>
         <IfLoggedIn>
           {() => (
             <Button