diff options
author | Michael Muré <batolettre@gmail.com> | 2021-03-24 20:28:06 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-03-24 20:28:06 +0100 |
commit | fb6b2d873e1bbc653aa84ddce56c842202e00c33 (patch) | |
tree | f722d8ac4381dd770b2cb96bf66478feabc94678 /webui | |
parent | 01b949091290a3bfd5870c6457c941db1abf3d67 (diff) | |
parent | bd6159a25b548f8f939f137b70dcf77a722e70dc (diff) | |
download | git-bug-fb6b2d873e1bbc653aa84ddce56c842202e00c33.tar.gz |
Merge pull request #602 from GlancingMind/upstream-4-interface-to-edit-issues
WebUI: Edit comments and inspect edit history
Diffstat (limited to 'webui')
-rw-r--r-- | webui/src/components/CommentInput/CommentInput.tsx | 5 | ||||
-rw-r--r-- | webui/src/pages/bug/Bug.tsx | 3 | ||||
-rw-r--r-- | webui/src/pages/bug/CommentForm.tsx | 1 | ||||
-rw-r--r-- | webui/src/pages/bug/EditCommentForm.graphql | 16 | ||||
-rw-r--r-- | webui/src/pages/bug/EditCommentForm.tsx | 123 | ||||
-rw-r--r-- | webui/src/pages/bug/Message.tsx | 131 | ||||
-rw-r--r-- | webui/src/pages/bug/MessageCommentFragment.graphql | 5 | ||||
-rw-r--r-- | webui/src/pages/bug/MessageCreateFragment.graphql | 5 | ||||
-rw-r--r-- | webui/src/pages/bug/MessageHistory.graphql | 15 | ||||
-rw-r--r-- | webui/src/pages/bug/MessageHistoryDialog.tsx | 235 | ||||
-rw-r--r-- | webui/src/pages/bug/Timeline.tsx | 8 | ||||
-rw-r--r-- | webui/src/pages/bug/TimelineQuery.tsx | 9 |
12 files changed, 535 insertions, 21 deletions
diff --git a/webui/src/components/CommentInput/CommentInput.tsx b/webui/src/components/CommentInput/CommentInput.tsx index 86cc7dbb..c574538e 100644 --- a/webui/src/components/CommentInput/CommentInput.tsx +++ b/webui/src/components/CommentInput/CommentInput.tsx @@ -51,6 +51,7 @@ const a11yProps = (index: number) => ({ type Props = { inputProps?: any; + inputText?: string; loading: boolean; onChange: (comment: string) => void; }; @@ -62,8 +63,8 @@ type Props = { * @param loading Disable input when component not ready yet * @param onChange Callback to return input value changes */ -function CommentInput({ inputProps, loading, onChange }: Props) { - const [input, setInput] = useState<string>(''); +function CommentInput({ inputProps, inputText, loading, onChange }: Props) { + const [input, setInput] = useState<string>(inputText ? inputText : ''); const [tab, setTab] = useState(0); const classes = useStyles(); diff --git a/webui/src/pages/bug/Bug.tsx b/webui/src/pages/bug/Bug.tsx index 3b6b61e0..25281f96 100644 --- a/webui/src/pages/bug/Bug.tsx +++ b/webui/src/pages/bug/Bug.tsx @@ -65,6 +65,7 @@ const useStyles = makeStyles((theme) => ({ ...theme.typography.body2, }, commentForm: { + marginTop: theme.spacing(2), marginLeft: 48, }, })); @@ -83,7 +84,7 @@ function Bug({ bug }: Props) { </div> <div className={classes.container}> <div className={classes.timeline}> - <TimelineQuery id={bug.id} /> + <TimelineQuery bug={bug} /> <IfLoggedIn> {() => ( <div className={classes.commentForm}> diff --git a/webui/src/pages/bug/CommentForm.tsx b/webui/src/pages/bug/CommentForm.tsx index 6d27e398..e70348a6 100644 --- a/webui/src/pages/bug/CommentForm.tsx +++ b/webui/src/pages/bug/CommentForm.tsx @@ -15,7 +15,6 @@ import { TimelineDocument } from './TimelineQuery.generated'; type StyleProps = { loading: boolean }; const useStyles = makeStyles<Theme, StyleProps>((theme) => ({ container: { - margin: theme.spacing(2, 0), padding: theme.spacing(0, 2, 2, 2), }, textarea: {}, diff --git a/webui/src/pages/bug/EditCommentForm.graphql b/webui/src/pages/bug/EditCommentForm.graphql new file mode 100644 index 00000000..4765b75c --- /dev/null +++ b/webui/src/pages/bug/EditCommentForm.graphql @@ -0,0 +1,16 @@ +#import "./MessageCommentFragment.graphql" +#import "./MessageCreateFragment.graphql" + +mutation EditComment($input: EditCommentInput!) { + editComment(input: $input) { + bug { + id + timeline { + comments: nodes { + ...Create + ...AddComment + } + } + } + } +} diff --git a/webui/src/pages/bug/EditCommentForm.tsx b/webui/src/pages/bug/EditCommentForm.tsx new file mode 100644 index 00000000..8fa659b3 --- /dev/null +++ b/webui/src/pages/bug/EditCommentForm.tsx @@ -0,0 +1,123 @@ +import React, { useState, useRef } from 'react'; + +import Button from '@material-ui/core/Button'; +import Paper from '@material-ui/core/Paper'; +import { makeStyles, Theme } from '@material-ui/core/styles'; + +import CommentInput from '../../components/CommentInput/CommentInput'; + +import { BugFragment } from './Bug.generated'; +import { useEditCommentMutation } from './EditCommentForm.generated'; +import { AddCommentFragment } from './MessageCommentFragment.generated'; +import { CreateFragment } from './MessageCreateFragment.generated'; + +type StyleProps = { loading: boolean }; +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', + justifyContent: 'flex-end', + }, + greenButton: { + marginLeft: '8px', + backgroundColor: theme.palette.success.main, + color: theme.palette.success.contrastText, + '&:hover': { + backgroundColor: theme.palette.success.dark, + color: theme.palette.success.contrastText, + }, + }, +})); + +type Props = { + bug: BugFragment; + comment: AddCommentFragment | CreateFragment; + onCancel?: () => void; + onPostSubmit?: (comments: any) => void; +}; + +function EditCommentForm({ bug, comment, onCancel, onPostSubmit }: Props) { + const [editComment, { loading }] = useEditCommentMutation(); + const [message, setMessage] = useState<string>(comment.message); + const [inputProp, setInputProp] = useState<any>(''); + const classes = useStyles({ loading }); + const form = useRef<HTMLFormElement>(null); + + const submit = () => { + editComment({ + variables: { + input: { + prefix: bug.id, + message: message, + target: comment.id, + }, + }, + }).then((result) => { + const comments = result.data?.editComment.bug.timeline.comments as ( + | AddCommentFragment + | CreateFragment + )[]; + // NOTE Searching for the changed comment could be dropped if GraphQL get + // filter by id argument for timelineitems + const modifiedComment = comments.find((elem) => elem.id === comment.id); + if (onPostSubmit) onPostSubmit(modifiedComment); + }); + resetForm(); + }; + + function resetForm() { + setInputProp({ + value: '', + }); + } + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + if (message.length > 0) submit(); + }; + + function getCancelButton() { + return ( + <Button onClick={onCancel} variant="contained"> + Cancel + </Button> + ); + } + + return ( + <Paper className={classes.container}> + <form onSubmit={handleSubmit} ref={form}> + <CommentInput + inputProps={inputProp} + loading={loading} + onChange={(message: string) => setMessage(message)} + inputText={comment.message} + /> + <div className={classes.actions}> + {onCancel && getCancelButton()} + <Button + className={classes.greenButton} + variant="contained" + color="primary" + type="submit" + disabled={loading || message.length === 0} + > + Update Comment + </Button> + </div> + </form> + </Paper> + ); +} + +export default EditCommentForm; diff --git a/webui/src/pages/bug/Message.tsx b/webui/src/pages/bug/Message.tsx index faff5356..2f4cbc59 100644 --- a/webui/src/pages/bug/Message.tsx +++ b/webui/src/pages/bug/Message.tsx @@ -1,14 +1,22 @@ -import React from 'react'; +import React, { useState } from 'react'; +import IconButton from '@material-ui/core/IconButton'; import Paper from '@material-ui/core/Paper'; +import Tooltip from '@material-ui/core/Tooltip/Tooltip'; import { makeStyles } from '@material-ui/core/styles'; +import EditIcon from '@material-ui/icons/Edit'; +import HistoryIcon from '@material-ui/icons/History'; import Author, { Avatar } from 'src/components/Author'; import Content from 'src/components/Content'; import Date from 'src/components/Date'; +import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn'; +import { BugFragment } from './Bug.generated'; +import EditCommentForm from './EditCommentForm'; import { AddCommentFragment } from './MessageCommentFragment.generated'; import { CreateFragment } from './MessageCreateFragment.generated'; +import MessageHistoryDialog from './MessageHistoryDialog'; const useStyles = makeStyles((theme) => ({ author: { @@ -51,30 +59,133 @@ const useStyles = makeStyles((theme) => ({ ...theme.typography.body2, padding: '0.5rem', }, + headerActions: { + color: theme.palette.info.contrastText, + padding: '0rem', + marginLeft: theme.spacing(1), + fontSize: '0.75rem', + '&:hover': { + backgroundColor: 'inherit', + }, + }, })); +type HistBtnProps = { + bugId: string; + commentId: string; +}; +function HistoryMenuToggleButton({ bugId, commentId }: HistBtnProps) { + const classes = useStyles(); + const [open, setOpen] = React.useState(false); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + return ( + <div> + <IconButton + aria-label="more" + aria-controls="long-menu" + aria-haspopup="true" + onClick={handleClickOpen} + className={classes.headerActions} + > + <HistoryIcon /> + </IconButton> + { + // Render CustomizedDialogs on open to prevent fetching the history + // before opening the history menu. + open && ( + <MessageHistoryDialog + bugId={bugId} + commentId={commentId} + open={open} + onClose={handleClose} + /> + ) + } + </div> + ); +} + type Props = { + bug: BugFragment; op: AddCommentFragment | CreateFragment; }; - -function Message({ op }: Props) { +function Message({ bug, op }: Props) { const classes = useStyles(); - return ( - <article className={classes.container}> - <Avatar author={op.author} className={classes.avatar} /> + const [editMode, switchToEditMode] = useState(false); + const [comment, setComment] = useState(op); + + const editComment = (id: String) => { + switchToEditMode(true); + }; + + function readMessageView() { + return ( <Paper elevation={1} className={classes.bubble}> <header className={classes.header}> <div className={classes.title}> - <Author className={classes.author} author={op.author} /> + <Author className={classes.author} author={comment.author} /> <span> commented </span> - <Date date={op.createdAt} /> + <Date date={comment.createdAt} /> </div> - {op.edited && <div className={classes.tag}>Edited</div>} + {comment.edited && ( + <HistoryMenuToggleButton bugId={bug.id} commentId={comment.id} /> + )} + <IfLoggedIn> + {() => ( + <Tooltip title="Edit Message" placement="top" arrow={true}> + <IconButton + disableRipple + className={classes.headerActions} + aria-label="edit message" + onClick={() => editComment(comment.id)} + > + <EditIcon /> + </IconButton> + </Tooltip> + )} + </IfLoggedIn> </header> <section className={classes.body}> - <Content markdown={op.message} /> + <Content markdown={comment.message} /> </section> </Paper> + ); + } + + function editMessageView() { + const cancelEdition = () => { + switchToEditMode(false); + }; + + const onPostSubmit = (comment: AddCommentFragment | CreateFragment) => { + setComment(comment); + switchToEditMode(false); + }; + + return ( + <div className={classes.bubble}> + <EditCommentForm + bug={bug} + onCancel={cancelEdition} + onPostSubmit={onPostSubmit} + comment={comment} + /> + </div> + ); + } + + return ( + <article className={classes.container}> + <Avatar author={comment.author} className={classes.avatar} /> + {editMode ? editMessageView() : readMessageView()} </article> ); } diff --git a/webui/src/pages/bug/MessageCommentFragment.graphql b/webui/src/pages/bug/MessageCommentFragment.graphql index 00f8342d..c852b4b0 100644 --- a/webui/src/pages/bug/MessageCommentFragment.graphql +++ b/webui/src/pages/bug/MessageCommentFragment.graphql @@ -1,8 +1,13 @@ #import "../../components/fragments.graphql" fragment AddComment on AddCommentTimelineItem { + id createdAt ...authored edited message + history { + message + date + } } diff --git a/webui/src/pages/bug/MessageCreateFragment.graphql b/webui/src/pages/bug/MessageCreateFragment.graphql index 4cae819d..1f4647b6 100644 --- a/webui/src/pages/bug/MessageCreateFragment.graphql +++ b/webui/src/pages/bug/MessageCreateFragment.graphql @@ -1,8 +1,13 @@ #import "../../components/fragments.graphql" fragment Create on CreateTimelineItem { + id createdAt ...authored edited message + history { + message + date + } } diff --git a/webui/src/pages/bug/MessageHistory.graphql b/webui/src/pages/bug/MessageHistory.graphql new file mode 100644 index 00000000..e90eb459 --- /dev/null +++ b/webui/src/pages/bug/MessageHistory.graphql @@ -0,0 +1,15 @@ +#import "./MessageCommentFragment.graphql" +#import "./MessageCreateFragment.graphql" + +query MessageHistory($bugIdPrefix: String!) { + repository { + bug(prefix: $bugIdPrefix) { + timeline { + comments: nodes { + ...Create + ...AddComment + } + } + } + } +} diff --git a/webui/src/pages/bug/MessageHistoryDialog.tsx b/webui/src/pages/bug/MessageHistoryDialog.tsx new file mode 100644 index 00000000..0ed33642 --- /dev/null +++ b/webui/src/pages/bug/MessageHistoryDialog.tsx @@ -0,0 +1,235 @@ +import moment from 'moment'; +import React from 'react'; +import Moment from 'react-moment'; + +import MuiAccordion from '@material-ui/core/Accordion'; +import MuiAccordionDetails from '@material-ui/core/AccordionDetails'; +import MuiAccordionSummary from '@material-ui/core/AccordionSummary'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import Dialog from '@material-ui/core/Dialog'; +import MuiDialogContent from '@material-ui/core/DialogContent'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; +import Grid from '@material-ui/core/Grid'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip/Tooltip'; +import Typography from '@material-ui/core/Typography'; +import { + createStyles, + Theme, + withStyles, + WithStyles, +} from '@material-ui/core/styles'; +import CloseIcon from '@material-ui/icons/Close'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; + +import { AddCommentFragment } from './MessageCommentFragment.generated'; +import { CreateFragment } from './MessageCreateFragment.generated'; +import { useMessageHistoryQuery } from './MessageHistory.generated'; + +const styles = (theme: Theme) => + createStyles({ + root: { + margin: 0, + padding: theme.spacing(2), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + }, + }); + +export interface DialogTitleProps extends WithStyles<typeof styles> { + id: string; + children: React.ReactNode; + onClose: () => void; +} + +const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { + const { children, classes, onClose, ...other } = props; + return ( + <MuiDialogTitle disableTypography className={classes.root} {...other}> + <Typography variant="h6">{children}</Typography> + {onClose ? ( + <IconButton + aria-label="close" + className={classes.closeButton} + onClick={onClose} + > + <CloseIcon /> + </IconButton> + ) : null} + </MuiDialogTitle> + ); +}); + +const DialogContent = withStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiDialogContent); + +const Accordion = withStyles({ + root: { + border: '1px solid rgba(0, 0, 0, .125)', + boxShadow: 'none', + '&:not(:last-child)': { + borderBottom: 0, + }, + '&:before': { + display: 'none', + }, + '&$expanded': { + margin: 'auto', + }, + }, + expanded: {}, +})(MuiAccordion); + +const AccordionSummary = withStyles((theme) => ({ + root: { + backgroundColor: theme.palette.primary.light, + borderBottomWidth: '1px', + borderBottomStyle: 'solid', + borderBottomColor: theme.palette.divider, + marginBottom: -1, + minHeight: 56, + '&$expanded': { + minHeight: 56, + }, + }, + content: { + '&$expanded': { + margin: '12px 0', + }, + }, + expanded: {}, +}))(MuiAccordionSummary); + +const AccordionDetails = withStyles((theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiAccordionDetails); + +type Props = { + bugId: string; + commentId: string; + open: boolean; + onClose: () => void; +}; +function MessageHistoryDialog({ bugId, commentId, open, onClose }: Props) { + const [expanded, setExpanded] = React.useState<string | false>('panel0'); + + const { loading, error, data } = useMessageHistoryQuery({ + variables: { bugIdPrefix: bugId }, + }); + if (loading) { + return ( + <Dialog + onClose={onClose} + aria-labelledby="customized-dialog-title" + open={open} + fullWidth + maxWidth="sm" + > + <DialogTitle id="customized-dialog-title" onClose={onClose}> + Loading... + </DialogTitle> + <DialogContent dividers> + <Grid container justify="center"> + <CircularProgress /> + </Grid> + </DialogContent> + </Dialog> + ); + } + if (error) { + return ( + <Dialog + onClose={onClose} + aria-labelledby="customized-dialog-title" + open={open} + fullWidth + maxWidth="sm" + > + <DialogTitle id="customized-dialog-title" onClose={onClose}> + Something went wrong... + </DialogTitle> + <DialogContent dividers> + <p>Error: {error}</p> + </DialogContent> + </Dialog> + ); + } + + const comments = data?.repository?.bug?.timeline.comments as ( + | AddCommentFragment + | CreateFragment + )[]; + // NOTE Searching for the changed comment could be dropped if GraphQL get + // filter by id argument for timelineitems + const comment = comments.find((elem) => elem.id === commentId); + // Sort by most recent edit. Must create a copy of constant history as + // reverse() modifies inplace. + const history = comment?.history.slice().reverse(); + const editCount = history?.length === undefined ? 0 : history?.length - 1; + + const handleChange = (panel: string) => ( + event: React.ChangeEvent<{}>, + newExpanded: boolean + ) => { + setExpanded(newExpanded ? panel : false); + }; + + const getSummary = (index: number, date: Date) => { + const desc = + index === editCount ? 'Created ' : `#${editCount - index} • Edited `; + const mostRecent = index === 0 ? ' (most recent)' : ''; + return ( + <> + <Tooltip title={moment(date).format('LLLL')}> + <span> + {desc} + <Moment date={date} format="on ll" /> + {mostRecent} + </span> + </Tooltip> + </> + ); + }; + + return ( + <Dialog + onClose={onClose} + aria-labelledby="customized-dialog-title" + open={open} + fullWidth + maxWidth="md" + > + <DialogTitle id="customized-dialog-title" onClose={onClose}> + {`Edited ${editCount} ${editCount > 1 ? 'times' : 'time'}.`} + </DialogTitle> + <DialogContent dividers> + {history?.map((edit, index) => ( + <Accordion + square + expanded={expanded === 'panel' + index} + onChange={handleChange('panel' + index)} + > + <AccordionSummary + expandIcon={<ExpandMoreIcon />} + aria-controls="panel1d-content" + id="panel1d-header" + > + <Typography>{getSummary(index, edit.date)}</Typography> + </AccordionSummary> + <AccordionDetails>{edit.message}</AccordionDetails> + </Accordion> + ))} + </DialogContent> + </Dialog> + ); +} + +export default MessageHistoryDialog; diff --git a/webui/src/pages/bug/Timeline.tsx b/webui/src/pages/bug/Timeline.tsx index 6e1d242e..60459a53 100644 --- a/webui/src/pages/bug/Timeline.tsx +++ b/webui/src/pages/bug/Timeline.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; +import { BugFragment } from './Bug.generated'; import LabelChange from './LabelChange'; import Message from './Message'; import SetStatus from './SetStatus'; @@ -18,9 +19,10 @@ const useStyles = makeStyles((theme) => ({ type Props = { ops: Array<TimelineItemFragment>; + bug: BugFragment; }; -function Timeline({ ops }: Props) { +function Timeline({ bug, ops }: Props) { const classes = useStyles(); return ( @@ -28,9 +30,9 @@ function Timeline({ ops }: Props) { {ops.map((op, index) => { switch (op.__typename) { case 'CreateTimelineItem': - return <Message key={index} op={op} />; + return <Message key={index} op={op} bug={bug} />; case 'AddCommentTimelineItem': - return <Message key={index} op={op} />; + return <Message key={index} op={op} bug={bug} />; case 'LabelChangeTimelineItem': return <LabelChange key={index} op={op} />; case 'SetTitleTimelineItem': diff --git a/webui/src/pages/bug/TimelineQuery.tsx b/webui/src/pages/bug/TimelineQuery.tsx index 74eed52b..d66c665b 100644 --- a/webui/src/pages/bug/TimelineQuery.tsx +++ b/webui/src/pages/bug/TimelineQuery.tsx @@ -2,17 +2,18 @@ import React from 'react'; import CircularProgress from '@material-ui/core/CircularProgress'; +import { BugFragment } from './Bug.generated'; import Timeline from './Timeline'; import { useTimelineQuery } from './TimelineQuery.generated'; type Props = { - id: string; + bug: BugFragment; }; -const TimelineQuery = ({ id }: Props) => { +const TimelineQuery = ({ bug }: Props) => { const { loading, error, data } = useTimelineQuery({ variables: { - id, + id: bug.id, first: 100, }, }); @@ -25,7 +26,7 @@ const TimelineQuery = ({ id }: Props) => { return null; } - return <Timeline ops={nodes} />; + return <Timeline ops={nodes} bug={bug} />; }; export default TimelineQuery; |