diff options
author | Michael Muré <batolettre@gmail.com> | 2021-04-12 19:17:26 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-12 19:17:26 +0200 |
commit | 491f0ea9513e8080f6b38885b1a05f47fb56d9dc (patch) | |
tree | ed858dde9305130a9fd6a8a32e172f9637531f34 /webui | |
parent | c71d26d513e4e5e36e6983e05dee4ad28ec664c7 (diff) | |
parent | c37d3d771806f9709c0c9fc77b5bbf8806a2ec23 (diff) | |
download | git-bug-491f0ea9513e8080f6b38885b1a05f47fb56d9dc.tar.gz |
Merge pull request #627 from GlancingMind/upstream-set-bug-labels
WebUI: Set bug labels
Diffstat (limited to 'webui')
-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 | 46 | ||||
-rw-r--r-- | webui/src/pages/list/FilterToolbar.tsx | 1 | ||||
-rw-r--r-- | webui/src/pages/list/ListLabels.graphql | 3 |
9 files changed, 434 insertions, 62 deletions
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 2e99eedf..3559b3ce 100644 --- a/webui/src/pages/list/Filter.tsx +++ b/webui/src/pages/list/Filter.tsx @@ -8,7 +8,11 @@ import MenuItem from '@material-ui/core/MenuItem'; import { SvgIconProps } from '@material-ui/core/SvgIcon'; 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: { @@ -99,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; @@ -150,6 +181,7 @@ function FilterDropdown({ <ArrowDropDown fontSize="small" /> </button> <Menu + className={classes.labelMenu} getContentAnchorEl={null} ref={searchRef} anchorOrigin={{ @@ -183,14 +215,22 @@ function FilterDropdown({ )} {dropdown .filter((d) => d[1].toLowerCase().includes(filter.toLowerCase())) - .map(([key, value]) => ( + .map(([key, value, color]) => ( <MenuItem component={Link} to={to(key)} - className={itemActive(key) ? classes.itemActive : undefined} + className={classes.labelMenuItem} + selected={itemActive(key)} onClick={() => setOpen(false)} key={key} > + {itemActive(key) && <CheckIcon />} + {color && ( + <div + className={classes.labelcolor} + style={createStyle(color)} + /> + )} {value} </MenuItem> ))} diff --git a/webui/src/pages/list/FilterToolbar.tsx b/webui/src/pages/list/FilterToolbar.tsx index 979bf530..e109578d 100644 --- a/webui/src/pages/list/FilterToolbar.tsx +++ b/webui/src/pages/list/FilterToolbar.tsx @@ -89,6 +89,7 @@ function FilterToolbar({ query, queryLocation }: Props) { labels = labelsData.repository.validLabels.nodes.map((node) => [ node.name, node.name, + node.color, ]); } diff --git a/webui/src/pages/list/ListLabels.graphql b/webui/src/pages/list/ListLabels.graphql index dcb44b67..8b2f561a 100644 --- a/webui/src/pages/list/ListLabels.graphql +++ b/webui/src/pages/list/ListLabels.graphql @@ -2,7 +2,8 @@ query ListLabels { repository { validLabels { nodes { - name + name, + color{R,G,B} } } } |