aboutsummaryrefslogtreecommitdiffstats
path: root/webui
diff options
context:
space:
mode:
Diffstat (limited to 'webui')
-rw-r--r--webui/src/components/Label.tsx49
-rw-r--r--webui/src/pages/bug/Bug.tsx19
-rw-r--r--webui/src/pages/bug/LabelChange.tsx9
-rw-r--r--webui/src/pages/bug/labels/LabelMenu.tsx309
-rw-r--r--webui/src/pages/bug/labels/SetLabel.graphql13
-rw-r--r--webui/src/pages/list/BugRow.tsx47
-rw-r--r--webui/src/pages/list/Filter.tsx46
-rw-r--r--webui/src/pages/list/FilterToolbar.tsx1
-rw-r--r--webui/src/pages/list/ListLabels.graphql3
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) {
&nbsp;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}
}
}
}