mirror of
https://github.com/MichaelMure/git-bug.git
synced 2024-12-15 02:01:43 +03:00
Merge pull request #623 from GlancingMind/upstream-additional-filters-for-bug-list
WebUI: Additional filters for bug list
This commit is contained in:
commit
abbed0ff12
@ -38,4 +38,5 @@ module.exports = {
|
||||
settings: {
|
||||
'import/internal-regex': '^src/',
|
||||
},
|
||||
ignorePatterns: ['**/*.generated.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();
|
||||
|
||||
|
@ -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>
|
||||
|
13
webui/src/pages/list/ListIdentities.graphql
Normal file
13
webui/src/pages/list/ListIdentities.graphql
Normal file
@ -0,0 +1,13 @@
|
||||
query ListIdentities {
|
||||
repository {
|
||||
allIdentities {
|
||||
nodes {
|
||||
id
|
||||
humanId
|
||||
name
|
||||
email
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
webui/src/pages/list/ListLabels.graphql
Normal file
9
webui/src/pages/list/ListLabels.graphql
Normal file
@ -0,0 +1,9 @@
|
||||
query ListLabels {
|
||||
repository {
|
||||
validLabels {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user