diff options
Diffstat (limited to 'webui/src/pages')
-rw-r--r-- | webui/src/pages/bug/Bug.tsx | 19 | ||||
-rw-r--r-- | webui/src/pages/bug/LabelChange.tsx | 9 | ||||
-rw-r--r-- | webui/src/pages/bug/labels/LabelMenu.tsx | 309 | ||||
-rw-r--r-- | webui/src/pages/bug/labels/SetLabel.graphql | 13 | ||||
-rw-r--r-- | webui/src/pages/list/BugRow.tsx | 47 | ||||
-rw-r--r-- | webui/src/pages/list/Filter.tsx | 116 | ||||
-rw-r--r-- | webui/src/pages/list/FilterToolbar.tsx | 70 | ||||
-rw-r--r-- | webui/src/pages/list/ListIdentities.graphql | 13 | ||||
-rw-r--r-- | webui/src/pages/list/ListLabels.graphql | 10 | ||||
-rw-r--r-- | webui/src/pages/list/ListQuery.tsx | 115 |
10 files changed, 642 insertions, 79 deletions
diff --git a/webui/src/pages/bug/Bug.tsx b/webui/src/pages/bug/Bug.tsx index 25281f96..b32b0948 100644 --- a/webui/src/pages/bug/Bug.tsx +++ b/webui/src/pages/bug/Bug.tsx @@ -9,6 +9,7 @@ import Label from 'src/components/Label'; import { BugFragment } from './Bug.generated'; import CommentForm from './CommentForm'; import TimelineQuery from './TimelineQuery'; +import LabelMenu from './labels/LabelMenu'; /** * Css in JS Styles @@ -53,13 +54,15 @@ const useStyles = makeStyles((theme) => ({ listStyle: 'none', padding: 0, margin: 0, + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', }, label: { - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1), - '& > *': { - display: 'block', - }, + marginTop: theme.spacing(0.1), + marginBottom: theme.spacing(0.1), + marginLeft: theme.spacing(0.25), + marginRight: theme.spacing(0.25), }, noLabel: { ...theme.typography.body2, @@ -94,14 +97,16 @@ function Bug({ bug }: Props) { </IfLoggedIn> </div> <div className={classes.rightSidebar}> - <span className={classes.rightSidebarTitle}>Labels</span> + <span className={classes.rightSidebarTitle}> + <LabelMenu bug={bug} /> + </span> <ul className={classes.labelList}> {bug.labels.length === 0 && ( <span className={classes.noLabel}>None yet</span> )} {bug.labels.map((l) => ( <li className={classes.label} key={l.name}> - <Label label={l} key={l.name} /> + <Label label={l} key={l.name} maxWidth="25ch" /> </li> ))} </ul> diff --git a/webui/src/pages/bug/LabelChange.tsx b/webui/src/pages/bug/LabelChange.tsx index c40636c1..712c33fa 100644 --- a/webui/src/pages/bug/LabelChange.tsx +++ b/webui/src/pages/bug/LabelChange.tsx @@ -16,6 +16,11 @@ const useStyles = makeStyles((theme) => ({ author: { fontWeight: 'bold', }, + label: { + maxWidth: '50ch', + marginLeft: theme.spacing(0.25), + marginRight: theme.spacing(0.25), + }, })); type Props = { @@ -30,12 +35,12 @@ function LabelChange({ op }: Props) { <Author author={op.author} className={classes.author} /> {added.length > 0 && <span> added the </span>} {added.map((label, index) => ( - <Label key={index} label={label} /> + <Label key={index} label={label} className={classes.label} /> ))} {added.length > 0 && removed.length > 0 && <span> and</span>} {removed.length > 0 && <span> removed the </span>} {removed.map((label, index) => ( - <Label key={index} label={label} /> + <Label key={index} label={label} className={classes.label} /> ))} <span> {' '} diff --git a/webui/src/pages/bug/labels/LabelMenu.tsx b/webui/src/pages/bug/labels/LabelMenu.tsx new file mode 100644 index 00000000..645f472c --- /dev/null +++ b/webui/src/pages/bug/labels/LabelMenu.tsx @@ -0,0 +1,309 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { IconButton } from '@material-ui/core'; +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import TextField from '@material-ui/core/TextField'; +import { makeStyles, withStyles } from '@material-ui/core/styles'; +import { darken } from '@material-ui/core/styles/colorManipulator'; +import CheckIcon from '@material-ui/icons/Check'; +import SettingsIcon from '@material-ui/icons/Settings'; + +import { Color } from '../../../gqlTypes'; +import { + ListLabelsDocument, + useListLabelsQuery, +} from '../../list/ListLabels.generated'; +import { BugFragment } from '../Bug.generated'; +import { GetBugDocument } from '../BugQuery.generated'; + +import { useSetLabelMutation } from './SetLabel.generated'; + +type DropdownTuple = [string, string, Color]; + +type FilterDropdownProps = { + children: React.ReactNode; + dropdown: DropdownTuple[]; + hasFilter?: boolean; + itemActive: (key: string) => boolean; + onClose: () => void; + toggleLabel: (key: string, active: boolean) => void; + onNewItem: (name: string) => void; +} & React.ButtonHTMLAttributes<HTMLButtonElement>; + +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; + +const useStyles = makeStyles((theme) => ({ + gearBtn: { + ...theme.typography.body2, + color: theme.palette.text.secondary, + padding: theme.spacing(0, 1), + fontWeight: 400, + textDecoration: 'none', + display: 'flex', + background: 'none', + border: 'none', + '&:hover': { + backgroundColor: 'transparent', + color: theme.palette.text.primary, + }, + }, + menu: { + '& .MuiMenu-paper': { + //somehow using "width" won't override the default width... + minWidth: '35ch', + }, + }, + labelcolor: { + minWidth: '0.5rem', + display: 'flex', + borderRadius: '0.25rem', + marginRight: '5px', + marginLeft: '3px', + }, + labelsheader: { + display: 'flex', + flexDirection: 'row', + }, + menuRow: { + display: 'flex', + alignItems: 'initial', + }, +})); + +const _rgb = (color: Color) => + 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')'; + +// Create a style object from the label RGB colors +const createStyle = (color: Color) => ({ + backgroundColor: _rgb(color), + borderBottomColor: darken(_rgb(color), 0.2), +}); + +function FilterDropdown({ + children, + dropdown, + hasFilter, + itemActive, + onClose, + toggleLabel, + onNewItem, +}: 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]); + + return ( + <> + <div className={classes.labelsheader}> + Labels + <IconButton + ref={buttonRef} + onClick={() => setOpen(!open)} + className={classes.gearBtn} + disableRipple + > + <SettingsIcon fontSize={'small'} /> + </IconButton> + </div> + + <Menu + className={classes.menu} + getContentAnchorEl={null} + ref={searchRef} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + open={open} + onClose={() => { + setOpen(false); + onClose(); + }} + onExited={() => setFilter('')} + anchorEl={buttonRef.current} + PaperProps={{ + style: { + maxHeight: ITEM_HEIGHT * 4.5, + width: '25ch', + }, + }} + > + {hasFilter && ( + <CustomTextField + onChange={(e) => { + const { value } = e.target; + setFilter(value); + }} + onKeyDown={(e) => e.stopPropagation()} + value={filter} + label={`Filter ${children}`} + /> + )} + {filter !== '' && + dropdown.filter((d) => d[1].toLowerCase() === filter.toLowerCase()) + .length <= 0 && ( + <MenuItem + style={{ whiteSpace: 'normal', wordBreak: 'break-all' }} + onClick={() => { + onNewItem(filter); + setFilter(''); + setOpen(false); + }} + > + Create new label '{filter}' + </MenuItem> + )} + {dropdown + .sort(function (x, y) { + // true values first + return itemActive(x[1]) === itemActive(y[1]) ? 0 : x ? -1 : 1; + }) + .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase())) + .map(([key, value, color]) => ( + <MenuItem + style={{ whiteSpace: 'normal', wordBreak: 'break-word' }} + onClick={() => { + toggleLabel(key, itemActive(key)); + }} + key={key} + selected={itemActive(key)} + > + <div className={classes.menuRow}> + {itemActive(key) && <CheckIcon />} + <div + className={classes.labelcolor} + style={createStyle(color)} + /> + {value} + </div> + </MenuItem> + ))} + </Menu> + </> + ); +} + +type Props = { + bug: BugFragment; +}; +function LabelMenu({ bug }: Props) { + const { data: labelsData } = useListLabelsQuery(); + const [bugLabelNames, setBugLabelNames] = useState( + bug.labels.map((l) => l.name) + ); + const [selectedLabels, setSelectedLabels] = useState( + bug.labels.map((l) => l.name) + ); + + const [setLabelMutation] = useSetLabelMutation(); + + function toggleLabel(key: string, active: boolean) { + const labels: string[] = active + ? selectedLabels.filter((label) => label !== key) + : selectedLabels.concat([key]); + setSelectedLabels(labels); + } + + function diff(oldState: string[], newState: string[]) { + const added = newState.filter((x) => !oldState.includes(x)); + const removed = oldState.filter((x) => !newState.includes(x)); + return { + added: added, + removed: removed, + }; + } + + const changeBugLabels = (selectedLabels: string[]) => { + const labels = diff(bugLabelNames, selectedLabels); + if (labels.added.length > 0 || labels.removed.length > 0) { + setLabelMutation({ + variables: { + input: { + prefix: bug.id, + added: labels.added, + Removed: labels.removed, + }, + }, + refetchQueries: [ + // TODO: update the cache instead of refetching + { + query: GetBugDocument, + variables: { id: bug.id }, + }, + { + query: ListLabelsDocument, + }, + ], + awaitRefetchQueries: true, + }) + .then((res) => { + setSelectedLabels(selectedLabels); + setBugLabelNames(selectedLabels); + }) + .catch((e) => console.log(e)); + } + }; + + function isActive(key: string) { + return selectedLabels.includes(key); + } + + function createNewLabel(name: string) { + changeBugLabels(selectedLabels.concat([name])); + } + + let labels: any = []; + if ( + labelsData?.repository && + labelsData.repository.validLabels && + labelsData.repository.validLabels.nodes + ) { + labels = labelsData.repository.validLabels.nodes.map((node) => [ + node.name, + node.name, + node.color, + ]); + } + + return ( + <FilterDropdown + onClose={() => changeBugLabels(selectedLabels)} + itemActive={isActive} + toggleLabel={toggleLabel} + dropdown={labels} + onNewItem={createNewLabel} + hasFilter + > + Labels + </FilterDropdown> + ); +} + +export default LabelMenu; diff --git a/webui/src/pages/bug/labels/SetLabel.graphql b/webui/src/pages/bug/labels/SetLabel.graphql new file mode 100644 index 00000000..44dfae11 --- /dev/null +++ b/webui/src/pages/bug/labels/SetLabel.graphql @@ -0,0 +1,13 @@ +mutation SetLabel($input: ChangeLabelInput) { + changeLabels(input: $input) { + results{ + status, + label{ + name, + color{R}, + color{G}, + color{B} + } + } + } +} diff --git a/webui/src/pages/list/BugRow.tsx b/webui/src/pages/list/BugRow.tsx index 1f5d22aa..87e45581 100644 --- a/webui/src/pages/list/BugRow.tsx +++ b/webui/src/pages/list/BugRow.tsx @@ -59,28 +59,36 @@ const useStyles = makeStyles((theme) => ({ width: '100%', lineHeight: '20px', }, + bugTitleWrapper: { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + //alignItems: 'center', + }, title: { display: 'inline', color: theme.palette.text.primary, fontSize: '1.3rem', fontWeight: 500, + marginBottom: theme.spacing(1), + }, + label: { + maxWidth: '40ch', + marginLeft: theme.spacing(0.25), + marginRight: theme.spacing(0.25), }, details: { lineHeight: '1.5rem', color: theme.palette.text.secondary, }, - labels: { - paddingLeft: theme.spacing(1), - '& > *': { - display: 'inline-block', - }, - }, commentCount: { fontSize: '1rem', marginLeft: theme.spacing(0.5), }, commentCountCell: { display: 'inline-flex', + minWidth: theme.spacing(5), + marginLeft: theme.spacing(0.5), }, })); @@ -98,15 +106,12 @@ function BugRow({ bug }: Props) { <BugStatus status={bug.status} className={classes.status} /> <div className={classes.expand}> <Link to={'bug/' + bug.humanId}> - <div className={classes.expand}> + <div className={classes.bugTitleWrapper}> <span className={classes.title}>{bug.title}</span> - {bug.labels.length > 0 && ( - <span className={classes.labels}> - {bug.labels.map((l) => ( - <Label key={l.name} label={l} /> - ))} - </span> - )} + {bug.labels.length > 0 && + bug.labels.map((l) => ( + <Label key={l.name} label={l} className={classes.label} /> + ))} </div> </Link> <div className={classes.details}> @@ -115,12 +120,14 @@ function BugRow({ bug }: Props) { by {bug.author.displayName} </div> </div> - {commentCount > 0 && ( - <span className={classes.commentCountCell}> - <CommentOutlinedIcon aria-label="Comment count" /> - <span className={classes.commentCount}>{commentCount}</span> - </span> - )} + <span className={classes.commentCountCell}> + {commentCount > 0 && ( + <> + <CommentOutlinedIcon aria-label="Comment count" /> + <span className={classes.commentCount}>{commentCount}</span> + </> + )} + </span> </TableCell> </TableRow> ); diff --git a/webui/src/pages/list/Filter.tsx b/webui/src/pages/list/Filter.tsx index 66702078..3559b3ce 100644 --- a/webui/src/pages/list/Filter.tsx +++ b/webui/src/pages/list/Filter.tsx @@ -1,13 +1,36 @@ 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 { darken } from '@material-ui/core/styles/colorManipulator'; import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; +import CheckIcon from '@material-ui/icons/Check'; + +import { Color } from '../../gqlTypes'; + +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[] }; @@ -80,9 +103,36 @@ const useStyles = makeStyles((theme) => ({ icon: { paddingRight: theme.spacing(0.5), }, + labelMenu: { + '& .MuiMenu-paper': { + //somehow using "width" won't override the default width... + minWidth: '35ch', + }, + }, + labelMenuItem: { + whiteSpace: 'normal', + wordBreak: 'break-word', + display: 'flex', + alignItems: 'initial', + }, + labelcolor: { + minWidth: '0.5rem', + display: 'flex', + borderRadius: '0.25rem', + marginRight: '5px', + marginLeft: '3px', + }, })); +const _rgb = (color: Color) => + 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')'; + +// Create a style object from the label RGB colors +const createStyle = (color: Color) => ({ + backgroundColor: _rgb(color), + borderBottomColor: darken(_rgb(color), 0.2), +}); -type DropdownTuple = [string, string]; +type DropdownTuple = [string, string, Color?]; type FilterDropdownProps = { children: React.ReactNode; @@ -90,6 +140,7 @@ type FilterDropdownProps = { itemActive: (key: string) => boolean; icon?: React.ComponentType<SvgIconProps>; to: (key: string) => LocationDescriptor; + hasFilter?: boolean; } & React.ButtonHTMLAttributes<HTMLButtonElement>; function FilterDropdown({ @@ -98,12 +149,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 }} />} @@ -123,7 +181,9 @@ function FilterDropdown({ <ArrowDropDown fontSize="small" /> </button> <Menu + className={classes.labelMenu} getContentAnchorEl={null} + ref={searchRef} anchorOrigin={{ vertical: 'bottom', horizontal: 'left', @@ -135,18 +195,45 @@ 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, color]) => ( + <MenuItem + component={Link} + to={to(key)} + className={classes.labelMenuItem} + selected={itemActive(key)} + onClick={() => setOpen(false)} + key={key} + > + {itemActive(key) && <CheckIcon />} + {color && ( + <div + className={classes.labelcolor} + style={createStyle(color)} + /> + )} + {value} + </MenuItem> + ))} </Menu> </> ); @@ -158,6 +245,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..e109578d 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,45 @@ 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, + node.color, + ]); + } 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 +112,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 +164,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 +188,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..8b2f561a --- /dev/null +++ b/webui/src/pages/list/ListLabels.graphql @@ -0,0 +1,10 @@ +query ListLabels { + repository { + validLabels { + nodes { + name, + color{R,G,B} + } + } + } +} diff --git a/webui/src/pages/list/ListQuery.tsx b/webui/src/pages/list/ListQuery.tsx index 500ccf77..2b46dca5 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,35 +292,85 @@ 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 className={classes.greenButton} variant="contained" - href="/new" + component={Link} + to="/new" > New bug </Button> |