aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMichael Muré <batolettre@gmail.com>2021-04-07 21:29:50 +0200
committerGitHub <noreply@github.com>2021-04-07 21:29:50 +0200
commitabbed0ff129755386ccbf89409ff8c3877f86d20 (patch)
tree01d1a61d5f775eaef585130be15ea8a26d32ede4
parentc7a441bad6a7ca247d955b63d2ba604435fdaab6 (diff)
parentccdaa16052a1da7ea5c87fc2104363377df98b95 (diff)
downloadgit-bug-abbed0ff129755386ccbf89409ff8c3877f86d20.tar.gz
Merge pull request #623 from GlancingMind/upstream-additional-filters-for-bug-list
WebUI: Additional filters for bug list
-rw-r--r--webui/.eslintrc.js1
-rw-r--r--webui/src/pages/list/Filter.tsx74
-rw-r--r--webui/src/pages/list/FilterToolbar.tsx69
-rw-r--r--webui/src/pages/list/ListIdentities.graphql13
-rw-r--r--webui/src/pages/list/ListLabels.graphql9
-rw-r--r--webui/src/pages/list/ListQuery.tsx112
6 files changed, 230 insertions, 48 deletions
diff --git a/webui/.eslintrc.js b/webui/.eslintrc.js
index 2dfa7543..125fe801 100644
--- a/webui/.eslintrc.js
+++ b/webui/.eslintrc.js
@@ -38,4 +38,5 @@ module.exports = {
settings: {
'import/internal-regex': '^src/',
},
+ ignorePatterns: ['**/*.generated.tsx'],
};
diff --git a/webui/src/pages/list/Filter.tsx b/webui/src/pages/list/Filter.tsx
index 66702078..2e99eedf 100644
--- a/webui/src/pages/list/Filter.tsx
+++ b/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();
diff --git a/webui/src/pages/list/FilterToolbar.tsx b/webui/src/pages/list/FilterToolbar.tsx
index 74eefe4c..979bf530 100644
--- a/webui/src/pages/list/FilterToolbar.tsx
+++ b/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]: [],
@@ -116,6 +163,22 @@ function FilterToolbar({ query, queryLocation }: Props) {
<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'],
['creation', 'Newest'],
@@ -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>
diff --git a/webui/src/pages/list/ListIdentities.graphql b/webui/src/pages/list/ListIdentities.graphql
new file mode 100644
index 00000000..73073ae8
--- /dev/null
+++ b/webui/src/pages/list/ListIdentities.graphql
@@ -0,0 +1,13 @@
+query ListIdentities {
+ repository {
+ allIdentities {
+ nodes {
+ id
+ humanId
+ name
+ email
+ displayName
+ }
+ }
+ }
+}
diff --git a/webui/src/pages/list/ListLabels.graphql b/webui/src/pages/list/ListLabels.graphql
new file mode 100644
index 00000000..dcb44b67
--- /dev/null
+++ b/webui/src/pages/list/ListLabels.graphql
@@ -0,0 +1,9 @@
+query ListLabels {
+ repository {
+ validLabels {
+ nodes {
+ name
+ }
+ }
+ }
+}
diff --git a/webui/src/pages/list/ListQuery.tsx b/webui/src/pages/list/ListQuery.tsx
index 500ccf77..4cd75c8d 100644
--- a/webui/src/pages/list/ListQuery.tsx
+++ b/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