diff options
Diffstat (limited to 'webui/src')
-rw-r--r-- | webui/src/components/BackToListButton.tsx | 4 | ||||
-rw-r--r-- | webui/src/components/BugTitleForm/BugTitleForm.tsx | 12 | ||||
-rw-r--r-- | webui/src/components/Header/Header.tsx | 21 | ||||
-rw-r--r-- | webui/src/components/Label.tsx | 49 | ||||
-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 |
14 files changed, 682 insertions, 125 deletions
diff --git a/webui/src/components/BackToListButton.tsx b/webui/src/components/BackToListButton.tsx index 7ca53ad0..41e1d68a 100644 --- a/webui/src/components/BackToListButton.tsx +++ b/webui/src/components/BackToListButton.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import Button from '@material-ui/core/Button'; import { makeStyles } from '@material-ui/core/styles'; @@ -25,7 +26,8 @@ function BackToListButton() { variant="contained" className={classes.backButton} aria-label="back to issue list" - href="/" + component={Link} + to="/" > <ArrowBackIcon /> Back to List diff --git a/webui/src/components/BugTitleForm/BugTitleForm.tsx b/webui/src/components/BugTitleForm/BugTitleForm.tsx index a7d5a820..665ecd4c 100644 --- a/webui/src/components/BugTitleForm/BugTitleForm.tsx +++ b/webui/src/components/BugTitleForm/BugTitleForm.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; import { Button, makeStyles, Typography } from '@material-ui/core'; @@ -78,6 +79,10 @@ function BugTitleForm({ bug }: Props) { function submitNewTitle() { if (!isFormValid()) return; + if (bug.title === issueTitleInput.value) { + cancelChange(); + return; + } setTitle({ variables: { input: { @@ -106,7 +111,7 @@ function BugTitleForm({ bug }: Props) { function editableBugTitle() { return ( - <form className={classes.headerTitle} onSubmit={submitNewTitle}> + <form className={classes.headerTitle}> <BugTitleInput inputRef={(node) => { issueTitleInput = node; @@ -123,7 +128,7 @@ function BugTitleForm({ bug }: Props) { className={classes.saveButton} size="small" variant="contained" - type="submit" + onClick={() => submitNewTitle()} disabled={issueTitle.length === 0} > Save @@ -157,7 +162,8 @@ function BugTitleForm({ bug }: Props) { className={classes.greenButton} size="small" variant="contained" - href="/new" + component={Link} + to="/new" > New bug </Button> diff --git a/webui/src/components/Header/Header.tsx b/webui/src/components/Header/Header.tsx index 3064f6e4..63146cc9 100644 --- a/webui/src/components/Header/Header.tsx +++ b/webui/src/components/Header/Header.tsx @@ -67,14 +67,14 @@ const DisabledTabWithTooltip = (props: TabProps) => { function Header() { const classes = useStyles(); const location = useLocation(); - const [selectedTab, setTab] = React.useState(location.pathname); - const handleTabClick = ( - event: React.ChangeEvent<{}>, - newTabValue: string - ) => { - setTab(newTabValue); - }; + // Prevents error of invalid tab selection in <Tabs> + // Will return a valid tab path or false if path is unkown. + function highlightTab() { + const validTabs = ['/', '/code', '/pulls', '/settings']; + const tab = validTabs.find((tabPath) => tabPath === location.pathname); + return tab === undefined ? false : tab; + } return ( <> @@ -92,12 +92,7 @@ function Header() { </Toolbar> </AppBar> <div className={classes.offset} /> - <Tabs - centered - value={selectedTab} - onChange={handleTabClick} - aria-label="nav tabs" - > + <Tabs centered value={highlightTab()} aria-label="nav tabs"> <DisabledTabWithTooltip label="Code" value="/code" {...a11yProps(1)} /> <Tab label="Bugs" value="/" component={Link} to="/" {...a11yProps(2)} /> <DisabledTabWithTooltip diff --git a/webui/src/components/Label.tsx b/webui/src/components/Label.tsx index 111f6d7f..a1d3c6f9 100644 --- a/webui/src/components/Label.tsx +++ b/webui/src/components/Label.tsx @@ -1,56 +1,47 @@ import React from 'react'; +import { Chip } from '@material-ui/core'; import { common } from '@material-ui/core/colors'; -import { makeStyles } from '@material-ui/core/styles'; import { - getContrastRatio, darken, + getContrastRatio, } from '@material-ui/core/styles/colorManipulator'; +import { Color } from '../gqlTypes'; import { LabelFragment } from '../graphql/fragments.generated'; -import { Color } from 'src/gqlTypes'; + +const _rgb = (color: Color) => + 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')'; // Minimum contrast between the background and the text color const contrastThreshold = 2.5; - // Guess the text color based on the background color const getTextColor = (background: string) => getContrastRatio(background, common.white) >= contrastThreshold ? common.white // White on dark backgrounds : common.black; // And black on light ones -const _rgb = (color: Color) => - 'rgb(' + color.R + ',' + color.G + ',' + color.B + ')'; - // Create a style object from the label RGB colors -const createStyle = (color: Color) => ({ +const createStyle = (color: Color, maxWidth?: string) => ({ backgroundColor: _rgb(color), color: getTextColor(_rgb(color)), borderBottomColor: darken(_rgb(color), 0.2), + maxWidth: maxWidth, }); -const useStyles = makeStyles((theme) => ({ - label: { - ...theme.typography.body1, - padding: '1px 6px 0.5px', - fontSize: '0.9em', - fontWeight: 500, - margin: '0.05em 1px calc(-1.5px + 0.05em)', - borderRadius: '3px', - display: 'inline-block', - borderBottom: 'solid 1.5px', - verticalAlign: 'bottom', - }, -})); - -type Props = { label: LabelFragment }; -function Label({ label }: Props) { - const classes = useStyles(); +type Props = { + label: LabelFragment; + maxWidth?: string; + className?: string; +}; +function Label({ label, maxWidth, className }: Props) { return ( - <span className={classes.label} style={createStyle(label.color)}> - {label.name} - </span> + <Chip + size={'small'} + label={label.name} + className={className} + style={createStyle(label.color, maxWidth)} + /> ); } - export default Label; 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> |