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

WebUI: Additional filters for bug list
This commit is contained in:
Michael Muré 2021-04-07 21:29:50 +02:00 committed by GitHub
commit abbed0ff12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 230 additions and 48 deletions

View File

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

View File

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

View File

@ -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>

View File

@ -0,0 +1,13 @@
query ListIdentities {
repository {
allIdentities {
nodes {
id
humanId
name
email
displayName
}
}
}
}

View File

@ -0,0 +1,9 @@
query ListLabels {
repository {
validLabels {
nodes {
name
}
}
}
}

View File

@ -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