import { NoticeType } from '../../types/users';
import { getComponentForSourceViewer, getSources } from '../components';
import {
+ addIssueComment,
+ deleteIssueComment,
+ editIssueComment,
getIssueFlowSnippets,
searchIssues,
searchIssueTags,
(setIssueSeverity as jest.Mock).mockImplementation(this.handleSetIssueSeverity);
(setIssueTransition as jest.Mock).mockImplementation(this.handleSetIssueTransition);
(setIssueTags as jest.Mock).mockImplementation(this.handleSetIssueTags);
+ (addIssueComment as jest.Mock).mockImplementation(this.handleAddComment);
+ (editIssueComment as jest.Mock).mockImplementation(this.handleEditComment);
+ (deleteIssueComment as jest.Mock).mockImplementation(this.handleDeleteComment);
(searchUsers as jest.Mock).mockImplementation(this.handleSearchUsers);
(searchIssueTags as jest.Mock).mockImplementation(this.handleSearchIssueTags);
}
return this.getActionsResponse({ tags: tagsArr }, data.issue);
};
+ handleAddComment = (data: { issue: string; text: string }) => {
+ // For comment its little more complex to get comment Id
+ return this.getActionsResponse(
+ {
+ comments: [
+ {
+ createdAt: '2022-07-28T11:30:04+0200',
+ htmlText: data.text,
+ key: '1234',
+ login: 'admin',
+ markdown: data.text,
+ updatable: true
+ }
+ ]
+ },
+ data.issue
+ );
+ };
+
+ handleEditComment = (data: { comment: string; text: string }) => {
+ // For comment its little more complex to get comment Id
+ return this.getActionsResponse(
+ {
+ comments: [
+ {
+ createdAt: '2022-07-28T11:30:04+0200',
+ htmlText: data.text,
+ key: '1234',
+ login: 'admin',
+ markdown: data.text,
+ updatable: true
+ }
+ ]
+ },
+ 'issue2'
+ );
+ };
+
+ handleDeleteComment = () => {
+ // For comment its little more complex to get comment Id
+ return this.getActionsResponse(
+ {
+ comments: []
+ },
+ 'issue2'
+ );
+ };
+
handleSearchUsers = () => {
return this.reply({ users: [mockLoggedInUser()] });
};
await user.keyboard('{ArrowUp}{enter}');
expect(screen.getByText('luke')).toBeInTheDocument();
+ // adding comment to the issue
+ expect(
+ screen.getByRole('button', {
+ name: `issue.comment.add_comment`
+ })
+ ).toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole('button', {
+ name: `issue.comment.add_comment`
+ })
+ );
+ expect(screen.getByText('issue.comment.submit')).toBeInTheDocument();
+ await user.keyboard('comment');
+ await user.click(screen.getByText('issue.comment.submit'));
+ expect(screen.getByText('comment')).toBeInTheDocument();
+
+ // editing the comment
+ expect(screen.getByRole('button', { name: 'issue.comment.edit' })).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'issue.comment.edit' }));
+ await user.keyboard('New ');
+ await user.click(screen.getByText('save'));
+ expect(screen.getByText('New comment')).toBeInTheDocument();
+
+ // deleting the comment
+ expect(screen.getByRole('button', { name: 'issue.comment.delete' })).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'issue.comment.delete' }));
+ expect(screen.queryByText('New comment')).not.toBeInTheDocument();
+
+ // adding comment using keyboard
+ await user.click(screen.getByRole('textbox'));
+ await user.keyboard('comment');
+ await user.keyboard('{Control>}{enter}{/Control}');
+ expect(screen.getByText('comment')).toBeInTheDocument();
+
+ // editing the comment using keyboard
+ await user.click(screen.getByRole('button', { name: 'issue.comment.edit' }));
+ await user.keyboard('New ');
+ await user.keyboard('{Control>}{enter}{/Control}');
+ expect(screen.getByText('New comment')).toBeInTheDocument();
+
// changing tags
expect(screen.getByText('issue.no_tag')).toBeInTheDocument();
await user.click(screen.getByText('issue.no_tag'));
*/
import * as React from 'react';
import { Link } from 'react-router-dom';
-import { setIssueAssignee } from '../../../api/issues';
+import { deleteIssueComment, editIssueComment, setIssueAssignee } from '../../../api/issues';
import LinkIcon from '../../../components/icons/LinkIcon';
import { updateIssue } from '../../../components/issue/actions';
import IssueActionsBar from '../../../components/issue/components/IssueActionsBar';
this.setState({ issuePopupName: name });
};
+ deleteComment = (comment: string) => {
+ updateIssue(this.props.onIssueChange, deleteIssueComment({ comment }));
+ };
+
+ editComment = (comment: string, text: string) => {
+ updateIssue(this.props.onIssueChange, editIssueComment({ comment, text }));
+ };
+
handleAssignement = (login: string) => {
const { issue } = this.props;
if (issue.assignee !== login) {
onAssign={this.handleAssignement}
onChange={this.props.onIssueChange}
togglePopup={this.handleIssuePopupToggle}
+ deleteComment={this.deleteComment}
+ onEdit={this.editComment}
+ showCommentsInPopup={true}
/>
</>
);
.issue-get-perma-link {
flex-shrink: 0;
}
+
+.issue-comment-list-wrapper {
+ max-height: 400px;
+ overflow-y: scroll;
+}
+
+.issue-comment-tile {
+ background-color: var(--barBackgroundColor);
+}
onAssign: (login: string) => void;
onChange: (issue: Issue) => void;
togglePopup: (popup: string, show?: boolean) => void;
+ deleteComment?: (comment: string) => void;
+ onEdit?: (comment: string, text: string) => void;
className?: string;
+ showCommentsInPopup?: boolean;
}
interface State {
};
render() {
- const { issue, className } = this.props;
+ const { issue, className, showCommentsInPopup } = this.props;
const canAssign = issue.actions.includes('assign');
const canComment = issue.actions.includes('comment');
const canSetSeverity = issue.actions.includes('set_severity');
issueKey={issue.key}
onChange={this.props.onChange}
toggleComment={this.toggleComment}
+ comments={issue.comments}
+ deleteComment={this.props.deleteComment}
+ onEdit={this.props.onEdit}
+ showCommentsInPopup={showCommentsInPopup}
/>
)}
</div>
import { ButtonLink } from '../../../components/controls/buttons';
import Toggler from '../../../components/controls/Toggler';
import { translate } from '../../../helpers/l10n';
-import { Issue } from '../../../types/types';
+import { Issue, IssueComment } from '../../../types/types';
import { updateIssue } from '../actions';
import CommentPopup from '../popups/CommentPopup';
issueKey: string;
onChange: (issue: Issue) => void;
toggleComment: (open?: boolean, placeholder?: string, autoTriggered?: boolean) => void;
+ deleteComment?: (comment: string) => void;
+ onEdit?: (comment: string, text: string) => void;
+ comments?: IssueComment[];
+ showCommentsInPopup?: boolean;
}
export default class IssueCommentAction extends React.PureComponent<Props> {
addComment = (text: string) => {
+ const { showCommentsInPopup } = this.props;
updateIssue(this.props.onChange, addIssueComment({ issue: this.props.issueKey, text }));
- this.props.toggleComment(false);
+ if (!showCommentsInPopup) {
+ this.props.toggleComment(false);
+ }
};
handleCommentClick = () => {
};
render() {
+ const { comments, showCommentsInPopup } = this.props;
return (
<div className="issue-meta dropdown">
<Toggler
onComment={this.addComment}
placeholder={this.props.commentPlaceholder}
toggleComment={this.props.toggleComment}
+ comments={comments}
+ deleteComment={this.props.deleteComment}
+ onEdit={this.props.onEdit}
+ showCommentsInPopup={showCommentsInPopup}
/>
}>
<ButtonLink
aria-label={translate('issue.comment.add_comment')}
className="issue-action js-issue-comment"
onClick={this.handleCommentClick}>
- <span className="issue-meta-label">{translate('issue.comment.formlink')}</span>
+ <span className="issue-meta-label">
+ {showCommentsInPopup && comments && comments.length > 0 && (
+ <span className="little-spacer-right">{comments.length}</span>
+ )}
+ {translate('issue.comment.formlink')}
+ </span>
</ButtonLink>
</Toggler>
</div>
onAssign={jest.fn()}
onChange={jest.fn()}
togglePopup={jest.fn()}
+ deleteComment={jest.fn()}
+ onEdit={jest.fn()}
/>
);
expect(element).toMatchSnapshot();
onAssign={jest.fn()}
onChange={jest.fn()}
togglePopup={jest.fn()}
+ deleteComment={jest.fn()}
+ onEdit={jest.fn()}
/>
);
expect(element).toMatchSnapshot();
onAssign={jest.fn()}
onChange={jest.fn()}
togglePopup={jest.fn()}
+ deleteComment={jest.fn()}
+ onEdit={jest.fn()}
/>
);
expect(element).toMatchSnapshot();
onAssign={jest.fn()}
onChange={jest.fn()}
togglePopup={jest.fn()}
+ deleteComment={jest.fn()}
+ onEdit={jest.fn()}
/>
);
expect(element).toMatchSnapshot();
onAssign={jest.fn()}
onChange={onChangeMock}
togglePopup={togglePopupMock}
+ deleteComment={jest.fn()}
+ onEdit={jest.fn()}
/>
);
issueKey="issue-key"
onChange={jest.fn()}
toggleComment={jest.fn()}
+ deleteComment={jest.fn()}
+ onEdit={jest.fn()}
{...props}
/>
);
<IssueCommentAction
commentAutoTriggered={false}
commentPlaceholder=""
+ deleteComment={[MockFunction]}
issueKey="AVsae-CQS-9G3txfbFN2"
onChange={[MockFunction]}
+ onEdit={[MockFunction]}
toggleComment={[Function]}
/>
</div>
open={true}
overlay={
<CommentPopup
+ deleteComment={[MockFunction]}
onComment={[Function]}
+ onEdit={[MockFunction]}
placeholder=""
toggleComment={
[MockFunction] {
open={false}
overlay={
<CommentPopup
+ deleteComment={[MockFunction]}
onComment={[Function]}
+ onEdit={[MockFunction]}
placeholder=""
toggleComment={[MockFunction]}
/>
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import { IssueComment } from '../../../types/types';
+import CommentTile from './CommentTile';
+
+interface CommentListProps {
+ comments?: IssueComment[];
+ deleteComment: (comment: string) => void;
+ onEdit: (comment: string, text: string) => void;
+}
+
+export default function CommentList(props: CommentListProps) {
+ const { comments } = props;
+ // sorting comment i.e showing newest on top
+ const sortedComments = comments?.sort(
+ (com1, com2) =>
+ new Date(com2.createdAt || '').getTime() - new Date(com1.createdAt || '').getTime()
+ );
+ return (
+ <div className="issue-comment-list-wrapper">
+ {sortedComments?.map(c => (
+ <CommentTile
+ comment={c}
+ key={c.key}
+ handleDelete={props.deleteComment}
+ onEdit={props.onEdit}
+ />
+ ))}
+ </div>
+ );
+}
import { translate } from '../../../helpers/l10n';
import { IssueComment } from '../../../types/types';
import FormattingTips from '../../common/FormattingTips';
+import CommentList from './CommentList';
export interface CommentPopupProps {
comment?: Pick<IssueComment, 'markdown'>;
onComment: (text: string) => void;
toggleComment: (visible: boolean) => void;
+ deleteComment?: (comment: string) => void;
+ onEdit?: (comment: string, text: string) => void;
placeholder: string;
placement?: PopupPlacement;
autoTriggered?: boolean;
+ comments?: IssueComment[];
+ showCommentsInPopup?: boolean;
}
interface State {
handleCommentClick = () => {
if (this.state.textComment.trim().length > 0) {
this.props.onComment(this.state.textComment);
+ this.setState({ textComment: '' });
}
};
};
render() {
- const { comment, autoTriggered } = this.props;
+ const { comment, autoTriggered, comments, showCommentsInPopup } = this.props;
+
return (
<DropdownOverlay placement={this.props.placement}>
<div className="issue-comment-bubble-popup">
+ {showCommentsInPopup && this.props.deleteComment && this.props.onEdit && (
+ <CommentList
+ comments={comments}
+ deleteComment={this.props.deleteComment}
+ onEdit={this.props.onEdit}
+ />
+ )}
<div className="issue-comment-form-text">
<textarea
autoFocus={true}
{!comment && translate('issue.comment.submit')}
</Button>
<ResetButtonLink className="js-issue-comment-cancel" onClick={this.handleCancelClick}>
- {autoTriggered ? translate('skip') : translate('cancel')}
+ {autoTriggered && !showCommentsInPopup ? translate('skip') : translate('cancel')}
</ResetButtonLink>
</div>
<div className="issue-comment-form-tips">
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import { KeyboardKeys } from '../../../helpers/keycodes';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { sanitizeString } from '../../../helpers/sanitize';
+import { IssueComment } from '../../../types/types';
+import { Button, DeleteButton, EditButton, ResetButtonLink } from '../../controls/buttons';
+import DateTimeFormatter from '../../intl/DateTimeFormatter';
+import Avatar from '../../ui/Avatar';
+
+interface CommentTileProps {
+ comment: IssueComment;
+ handleDelete: (commentKey: string) => void;
+ onEdit: (comment: string, text: string) => void;
+}
+
+interface CommentTileState {
+ showEditArea: boolean;
+ editedComment: string;
+}
+
+export default class CommentTile extends React.PureComponent<CommentTileProps, CommentTileState> {
+ state = {
+ showEditArea: false,
+ editedComment: ''
+ };
+
+ handleEditClick = () => {
+ const { comment } = this.props;
+ const { showEditArea } = this.state;
+ const editedComment = !showEditArea ? comment.markdown : '';
+ this.setState({ showEditArea: !showEditArea, editedComment });
+ };
+
+ handleSaveClick = () => {
+ const { comment } = this.props;
+ const { editedComment } = this.state;
+ this.props.onEdit(comment.key, editedComment);
+ this.setState({ showEditArea: false, editedComment: '' });
+ };
+
+ handleCancelClick = () => {
+ this.setState({ showEditArea: false });
+ };
+
+ handleEditCommentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
+ this.setState({ editedComment: event.target.value });
+ };
+
+ handleKeyboard = (event: React.KeyboardEvent) => {
+ if (event.nativeEvent.key === KeyboardKeys.Enter && (event.metaKey || event.ctrlKey)) {
+ this.handleSaveClick();
+ }
+ };
+
+ render() {
+ const { comment } = this.props;
+ const { showEditArea, editedComment } = this.state;
+ const author = comment.authorName || comment.author;
+ const displayName =
+ comment.authorActive === false && author
+ ? translateWithParameters('user.x_deleted', author)
+ : author;
+ return (
+ <div className="issue-comment-tile spacer-bottom padded">
+ <div className="display-flex-center">
+ <div className="issue-comment-author display-flex-center" title={displayName}>
+ <Avatar
+ className="little-spacer-right"
+ hash={comment.authorAvatar}
+ name={author}
+ size={24}
+ />
+ {displayName}
+ </div>
+ <span className="little-spacer-left little-spacer-right">-</span>
+ <DateTimeFormatter date={comment.createdAt} />
+ </div>
+ <div className="spacer-top display-flex-space-between">
+ {!showEditArea && (
+ <div
+ className="flex-1 markdown"
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: sanitizeString(comment.htmlText) }}
+ />
+ )}
+ {showEditArea && (
+ <div className="edit-form flex-1">
+ <div className="issue-comment-form-text">
+ <textarea
+ autoFocus={true}
+ onChange={this.handleEditCommentChange}
+ onKeyDown={this.handleKeyboard}
+ rows={2}
+ value={editedComment}
+ />
+ </div>
+ <div className="issue-comment-form-footer">
+ <div className="issue-comment-form-actions little-padded-left">
+ <Button
+ className="js-issue-comment-submit little-spacer-right"
+ disabled={editedComment.trim().length < 1}
+ onClick={this.handleSaveClick}>
+ {translate('save')}
+ </Button>
+ <ResetButtonLink
+ className="js-issue-comment-cancel"
+ onClick={this.handleCancelClick}>
+ {translate('cancel')}
+ </ResetButtonLink>
+ </div>
+ </div>
+ </div>
+ )}
+ {comment.updatable && (
+ <div>
+ <EditButton
+ aria-label={translate('issue.comment.edit')}
+ className="js-issue-comment-edit button-small"
+ onClick={this.handleEditClick}
+ />
+ <DeleteButton
+ aria-label={translate('issue.comment.delete')}
+ className="js-issue-comment-delete button-small"
+ onClick={() => {
+ this.props.handleDelete(comment.key);
+ }}
+ />
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ }
+}
onComment={jest.fn()}
placeholder="placeholder test"
toggleComment={jest.fn()}
+ deleteComment={jest.fn()}
+ onEdit={jest.fn()}
{...overrides}
/>
);