diff options
Diffstat (limited to 'webui')
26 files changed, 798 insertions, 146 deletions
diff --git a/webui/.eslintrc.js b/webui/.eslintrc.js index 2dfa7543..125fe801 100644 --- a/webui/.eslintrc.js +++ b/webui/.eslintrc.js @@ -38,4 +38,5 @@ module.exports = { settings: { 'import/internal-regex': '^src/', }, + ignorePatterns: ['**/*.generated.tsx'], }; diff --git a/webui/package-lock.json b/webui/package-lock.json index 12dea8b9..b3b2a490 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -12325,6 +12325,11 @@ "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=" }, + "gemoji": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gemoji/-/gemoji-6.1.0.tgz", + "integrity": "sha512-MOlX3doQ1fsfzxQX8Y+u6bC5Ssc1pBUBIPVyrS69EzKt+5LIZAOm0G5XGVNhwXFgkBF3r+Yk88ONyrFHo8iNFA==" + }, "generic-names": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-2.0.1.tgz", @@ -12572,7 +12577,8 @@ "growly": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", - "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=" + "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", + "optional": true }, "gzip-size": { "version": "5.1.1", @@ -16118,6 +16124,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -16131,6 +16138,7 @@ "version": "7.3.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -16138,12 +16146,14 @@ "uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "optional": true }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -19227,6 +19237,15 @@ "fbjs": "^1.0.0" } }, + "remark-gemoji": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-gemoji/-/remark-gemoji-6.0.0.tgz", + "integrity": "sha512-LDW2h6QqNzAbAcOjscgfkJW9/8TGBasBe/ji+3mCxHlJdhF2IEXFSmm/3tdEPP1JJDZ4y+Ea+xlFQ4tOIU9WvA==", + "requires": { + "gemoji": "^6.0.0", + "unist-util-visit": "^2.0.0" + } + }, "remark-html": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/remark-html/-/remark-html-12.0.0.tgz", @@ -20219,7 +20238,8 @@ "shellwords": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", - "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==" + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "optional": true }, "side-channel": { "version": "1.0.3", diff --git a/webui/package.json b/webui/package.json index 39696a25..47cdf8d0 100644 --- a/webui/package.json +++ b/webui/package.json @@ -22,6 +22,7 @@ "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "react-scripts": "^4.0.0-next.98", + "remark-gemoji": "^6.0.0", "remark-html": "^12.0.0", "remark-parse": "^8.0.3", "remark-react": "^7.0.1", 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/CommentInput/CommentInput.tsx b/webui/src/components/CommentInput/CommentInput.tsx index c574538e..f12ee8d8 100644 --- a/webui/src/components/CommentInput/CommentInput.tsx +++ b/webui/src/components/CommentInput/CommentInput.tsx @@ -5,7 +5,7 @@ import Tabs from '@material-ui/core/Tabs'; import TextField from '@material-ui/core/TextField'; import { makeStyles } from '@material-ui/core/styles'; -import Content from 'src/components/Content'; +import Content from '../Content'; /** * Styles diff --git a/webui/src/components/Content/AnchorTag.tsx b/webui/src/components/Content/AnchorTag.tsx new file mode 100644 index 00000000..47d5e2fa --- /dev/null +++ b/webui/src/components/Content/AnchorTag.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles((theme) => ({ + tag: { + color: theme.palette.text.secondary, + }, +})); + +const AnchorTag = ({ children, href }: React.HTMLProps<HTMLAnchorElement>) => { + const classes = useStyles(); + const origin = window.location.origin; + const destination = href === undefined ? '' : href; + const isInternalLink = + destination.startsWith('/') || destination.startsWith(origin); + const internalDestination = destination.replace(origin, ''); + const internalLink = ( + <Link className={classes.tag} to={internalDestination}> + {children} + </Link> + ); + const externalLink = ( + <a + className={classes.tag} + href={destination} + target="_blank" + rel="noopener noreferrer" + > + {children} + </a> + ); + + return isInternalLink ? internalLink : externalLink; +}; + +export default AnchorTag; diff --git a/webui/src/components/Content/BlockQuoteTag.tsx b/webui/src/components/Content/BlockQuoteTag.tsx new file mode 100644 index 00000000..18c34d8a --- /dev/null +++ b/webui/src/components/Content/BlockQuoteTag.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles((theme) => ({ + tag: { + color: theme.palette.text.secondary, + borderLeftWidth: '0.5ch', + borderLeftStyle: 'solid', + borderLeftColor: theme.palette.text.secondary, + marginLeft: 0, + paddingLeft: '0.5rem', + }, +})); + +const BlockQuoteTag = (props: React.HTMLProps<HTMLPreElement>) => { + const classes = useStyles(); + return <blockquote className={classes.tag} {...props} />; +}; + +export default BlockQuoteTag; diff --git a/webui/src/components/Content/ImageTag.tsx b/webui/src/components/Content/ImageTag.tsx index 70ee1bc0..29b01da3 100644 --- a/webui/src/components/Content/ImageTag.tsx +++ b/webui/src/components/Content/ImageTag.tsx @@ -14,9 +14,12 @@ const ImageTag = ({ }: React.ImgHTMLAttributes<HTMLImageElement>) => { const classes = useStyles(); return ( - <a href={props.src} target="_blank" rel="noopener noreferrer nofollow"> - <img className={classes.tag} alt={alt} {...props} /> - </a> + <> + <a href={props.src} target="_blank" rel="noopener noreferrer nofollow"> + <img className={classes.tag} alt={alt} {...props} /> + </a> + <br /> + </> ); }; diff --git a/webui/src/components/Content/index.tsx b/webui/src/components/Content/index.tsx index 56e52e1e..e4018809 100644 --- a/webui/src/components/Content/index.tsx +++ b/webui/src/components/Content/index.tsx @@ -1,26 +1,32 @@ import React from 'react'; +import gemoji from 'remark-gemoji'; import html from 'remark-html'; import parse from 'remark-parse'; import remark2react from 'remark-react'; import unified from 'unified'; +import AnchorTag from './AnchorTag'; +import BlockQuoteTag from './BlockQuoteTag'; import ImageTag from './ImageTag'; import PreTag from './PreTag'; type Props = { markdown: string }; const Content: React.FC<Props> = ({ markdown }: Props) => { - const processor = unified() + const content = unified() .use(parse) + .use(gemoji) .use(html) .use(remark2react, { remarkReactComponents: { img: ImageTag, pre: PreTag, + a: AnchorTag, + blockquote: BlockQuoteTag, }, - }); + }) + .processSync(markdown).result; - const contents: React.ReactNode = processor.processSync(markdown).contents; - return <>{contents}</>; + return <>{content}</>; }; export default Content; 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/CommentForm.tsx b/webui/src/pages/bug/CommentForm.tsx index e70348a6..a8ce4319 100644 --- a/webui/src/pages/bug/CommentForm.tsx +++ b/webui/src/pages/bug/CommentForm.tsx @@ -17,14 +17,6 @@ const useStyles = makeStyles<Theme, StyleProps>((theme) => ({ container: { padding: theme.spacing(0, 2, 2, 2), }, - textarea: {}, - tabContent: { - margin: theme.spacing(2, 0), - }, - preview: { - borderBottom: `solid 3px ${theme.palette.grey['200']}`, - minHeight: '5rem', - }, actions: { display: 'flex', gap: '1em', 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/Message.tsx b/webui/src/pages/bug/Message.tsx index 2f4cbc59..39b11ccd 100644 --- a/webui/src/pages/bug/Message.tsx +++ b/webui/src/pages/bug/Message.tsx @@ -57,7 +57,8 @@ const useStyles = makeStyles((theme) => ({ }, body: { ...theme.typography.body2, - padding: '0.5rem', + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), }, headerActions: { color: theme.palette.info.contrastText, diff --git a/webui/src/pages/bug/MessageHistoryDialog.tsx b/webui/src/pages/bug/MessageHistoryDialog.tsx index 0ed33642..5879a373 100644 --- a/webui/src/pages/bug/MessageHistoryDialog.tsx +++ b/webui/src/pages/bug/MessageHistoryDialog.tsx @@ -22,6 +22,8 @@ import { import CloseIcon from '@material-ui/icons/Close'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import Content from '../../components/Content'; + import { AddCommentFragment } from './MessageCommentFragment.generated'; import { CreateFragment } from './MessageCreateFragment.generated'; import { useMessageHistoryQuery } from './MessageHistory.generated'; @@ -108,6 +110,7 @@ const AccordionSummary = withStyles((theme) => ({ const AccordionDetails = withStyles((theme) => ({ root: { + display: 'block', padding: theme.spacing(2), }, }))(MuiAccordionDetails); @@ -214,6 +217,7 @@ function MessageHistoryDialog({ bugId, commentId, open, onClose }: Props) { {history?.map((edit, index) => ( <Accordion square + key={index} expanded={expanded === 'panel' + index} onChange={handleChange('panel' + index)} > @@ -224,7 +228,9 @@ function MessageHistoryDialog({ bugId, commentId, open, onClose }: Props) { > <Typography>{getSummary(index, edit.date)}</Typography> </AccordionSummary> - <AccordionDetails>{edit.message}</AccordionDetails> + <AccordionDetails> + <Content markdown={edit.message} /> + </AccordionDetails> </Accordion> ))} </DialogContent> 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> diff --git a/webui/types/remark-gemoji/index.d.ts b/webui/types/remark-gemoji/index.d.ts new file mode 100644 index 00000000..f15725b2 --- /dev/null +++ b/webui/types/remark-gemoji/index.d.ts @@ -0,0 +1,6 @@ +declare module 'remark-gemoji' { + import { Plugin } from 'unified'; + + const plugin: Plugin; + export default plugin; +} |