aboutsummaryrefslogtreecommitdiffstats
path: root/webui/src
diff options
context:
space:
mode:
Diffstat (limited to 'webui/src')
-rw-r--r--webui/src/components/CommentInput/CommentInput.tsx5
-rw-r--r--webui/src/pages/bug/Bug.tsx3
-rw-r--r--webui/src/pages/bug/CommentForm.tsx1
-rw-r--r--webui/src/pages/bug/EditCommentForm.graphql16
-rw-r--r--webui/src/pages/bug/EditCommentForm.tsx123
-rw-r--r--webui/src/pages/bug/Message.tsx131
-rw-r--r--webui/src/pages/bug/MessageCommentFragment.graphql5
-rw-r--r--webui/src/pages/bug/MessageCreateFragment.graphql5
-rw-r--r--webui/src/pages/bug/MessageHistory.graphql15
-rw-r--r--webui/src/pages/bug/MessageHistoryDialog.tsx235
-rw-r--r--webui/src/pages/bug/Timeline.tsx8
-rw-r--r--webui/src/pages/bug/TimelineQuery.tsx9
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;