diff --git a/pontoon/base/tests/views/test_comment.py b/pontoon/base/tests/views/test_comment.py index 4ef5fc5caf..1ff1b922cb 100644 --- a/pontoon/base/tests/views/test_comment.py +++ b/pontoon/base/tests/views/test_comment.py @@ -3,6 +3,9 @@ from django.contrib.auth.models import Permission from django.urls import reverse +from pontoon.base.models import Comment +from pontoon.test.factories import TranslationCommentFactory + @pytest.mark.django_db def test_pin_comment(member, client, comment_a): @@ -60,3 +63,73 @@ def test_unpin_comment(member, client, team_comment_a): team_comment_a.refresh_from_db() assert team_comment_a.pinned is False + + +@pytest.mark.django_db +def test_edit_comment(member, client, comment_a): + url = reverse("pontoon.edit_comment") + + # a user cannot edit someone else's comment + response = member.client.post( + url, + {"comment_id": comment_a.pk, "content": "edited"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + assert response.status_code == 403 + + # The author can edit their own comment + comment_a.author = member.user + comment_a.save() + + response = member.client.post( + url, + {"comment_id": comment_a.pk, "content": "edited content"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + assert response.status_code == 200 + comment_a.refresh_from_db() + assert comment_a.content == "edited content" + + +@pytest.mark.django_db +def test_delete_comment(member, client, comment_a): + url = reverse("pontoon.delete_comment") + + # a user cannot delete someone elses comment + response = member.client.post( + url, + {"comment_id": comment_a.pk, "content": "deleted"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + assert response.status_code == 403 + + # A user with can_manage_project permission can delete someone else's comment + permission = Permission.objects.get(codename="can_manage_project") + member.user.user_permissions.add(permission) + member.user.refresh_from_db() + + response = member.client.post( + url, + {"comment_id": comment_a.pk}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + assert response.status_code == 200 + assert not Comment.objects.filter(pk=comment_a.pk).exists() + + # the author can delete their comment + member.user.user_permissions.remove(permission) + member.user.refresh_from_db() + + own_comment = TranslationCommentFactory(author=member.user) + + response = member.client.post( + url, + {"comment_id": own_comment.pk}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + assert response.status_code == 200 + assert not Comment.objects.filter(pk=own_comment.pk).exists() diff --git a/pontoon/base/urls.py b/pontoon/base/urls.py index aff50e6ac5..178fed25a3 100644 --- a/pontoon/base/urls.py +++ b/pontoon/base/urls.py @@ -83,6 +83,16 @@ views.unpin_comment, name="pontoon.unpin_comment", ), + path( + "edit-comment/", + views.edit_comment, + name="pontoon.edit_comment", + ), + path( + "delete-comment/", + views.delete_comment, + name="pontoon.delete_comment", + ), path( "other-locales/", views.get_translations_from_other_locales, diff --git a/pontoon/base/views.py b/pontoon/base/views.py index 7001e6abb0..f1d8fd15c7 100755 --- a/pontoon/base/views.py +++ b/pontoon/base/views.py @@ -751,6 +751,63 @@ def unpin_comment(request): return JsonResponse({"status": True}) +@login_required(redirect_field_name="", login_url="/403") +@require_POST +@transaction.atomic +def edit_comment(request): + """Edit a comment""" + comment_id = request.POST.get("comment_id", None) + + if not comment_id: + return JsonResponse({"status": False, "message": "Bad Request"}, status=400) + + comment = get_object_or_404(Comment, id=comment_id) + + if request.user != comment.author: + return JsonResponse( + {"status": False, "message": "Forbidden: You can't edit this comment"}, + status=403, + ) + + content = request.POST.get("content", None) + + if not content: + return JsonResponse( + { + "status": False, + "message": "Bad Request", + }, + status=400, + ) + comment.content = content + comment.save() + + return JsonResponse({"status": True}) + + +@login_required(redirect_field_name="", login_url="/403") +@require_POST +@transaction.atomic +def delete_comment(request): + """Delete a comment""" + comment_id = request.POST.get("comment_id", None) + if not comment_id: + return JsonResponse({"status": False, "message": "Bad Request"}, status=400) + + comment = get_object_or_404(Comment, id=comment_id) + + if request.user != comment.author and not request.user.has_perm( + "base.can_manage_project" + ): + return JsonResponse( + {"status": False, "message": "Forbidden: You can't delete this comment"}, + status=403, + ) + + comment.delete() + return JsonResponse({"status": True}) + + @utils.require_AJAX @login_required(redirect_field_name="", login_url="/403") def get_users(request): diff --git a/translate/public/locale/en-US/translate.ftl b/translate/public/locale/en-US/translate.ftl index a0cc482269..07b2333877 100644 --- a/translate/public/locale/en-US/translate.ftl +++ b/translate/public/locale/en-US/translate.ftl @@ -101,7 +101,9 @@ comments-Comment--unpin-button = UNPIN comments-Comment--pinned = PINNED comments-CommentsList--pinned-comments = PINNED COMMENTS comments-CommentsList--all-comments = ALL COMMENTS - +comments-Comment--edit-button = EDIT +comments-Comment--delete-button = DELETE +comments-Comment--cancel-button = CANCEL ## Editor Menu ## Allows contributors to modify or propose a translation @@ -583,6 +585,8 @@ notification--ftl-not-supported-rich-editor = Translation not supported in rich notification--entity-not-found = Can’t load specified string notification--string-link-copied = Link copied to clipboard notification--comment-added = Comment added +notification--comment-edited = Comment edited +notification--comment-deleted = Comment deleted ## OtherLocales Translation diff --git a/translate/src/api/comment.ts b/translate/src/api/comment.ts index 3472b4c699..b7317133e5 100644 --- a/translate/src/api/comment.ts +++ b/translate/src/api/comment.ts @@ -59,3 +59,23 @@ export function setCommentPinned( const headers = new Headers({ 'X-CSRFToken': getCSRFToken() }); return POST(url, payload, { headers }); } + +export function editComment(commentId: number, content: string): Promise { + const payload = new URLSearchParams({ + comment_id: String(commentId), + content, + }); + const url = '/edit-comment/'; + const headers = new Headers({ 'X-CSRFToken': getCSRFToken() }); + return POST(url, payload, { headers }); +} + +export function deleteComment(commentId: number): Promise { + const payload = new URLSearchParams({ + comment_id: String(commentId), + }); + + const url = '/delete-comment/'; + const headers = new Headers({ 'X-CSRFToken': getCSRFToken() }); + return POST(url, payload, { headers }); +} diff --git a/translate/src/modules/comments/components/AddComment.tsx b/translate/src/modules/comments/components/AddComment.tsx index e7a0b6e891..7cd56290a8 100644 --- a/translate/src/modules/comments/components/AddComment.tsx +++ b/translate/src/modules/comments/components/AddComment.tsx @@ -40,6 +40,7 @@ import { MentionList } from './MentionList'; type Props = { contactPerson?: string; initFocus: boolean; + initialContent?: string; onAddComment(comment: string): void; resetContactPerson?: () => void; user: UserState; @@ -81,6 +82,7 @@ function isEditable(elem: Element | null): boolean { export function AddComment({ contactPerson, initFocus, + initialContent, onAddComment, resetContactPerson, user: { gravatarURLSmall, username }, @@ -94,7 +96,10 @@ export function AddComment({ const { initMentions, mentionUsers } = useContext(MentionUsers); const [slateKey, resetValue] = useReducer((key) => key + 1, 0); const initialValue = useMemo( - () => [{ type: 'paragraph', children: [{ text: '' }] }], + () => + initialContent + ? deserialize(initialContent) + : [{ type: 'paragraph', children: [{ text: '' }] }], [slateKey], ); @@ -340,6 +345,31 @@ function serialize(node: Descendant, users: MentionUser[]): string { } } +function deserialize(html: string): Paragraph[] { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const paragraphs = Array.from(doc.body.querySelectorAll('p')); + + if (!paragraphs.length) { + return [{ type: 'paragraph', children: [{ text: html }] }]; + } + + return paragraphs.map((p) => ({ + type: 'paragraph', + children: Array.from(p.childNodes).map((node) => { + if (node.nodeName === 'A') { + const a = node as HTMLAnchorElement; + return { + type: 'mention', + character: a.textContent ?? '', + url: a.href, + children: [{ text: a.textContent ?? '' }], + } as Mention; + } + return { text: node.textContent ?? '' }; + }), + })); +} + const RenderElement = ({ attributes, children, diff --git a/translate/src/modules/comments/components/Comment.css b/translate/src/modules/comments/components/Comment.css index 7e43215f2b..55f6f79124 100644 --- a/translate/src/modules/comments/components/Comment.css +++ b/translate/src/modules/comments/components/Comment.css @@ -62,6 +62,7 @@ .comments-list .comment .info .pin-button:before { content: '•'; padding: 0 3px 0 3px; + color: var(--light-grey-7); } .comments-list .comment .info .pin-button { @@ -73,6 +74,25 @@ padding: 0; } +.comments-list .comment .info .delete-button:before { + content: '•'; + padding: 0 3px 0 3px; + color: var(--light-grey-7); +} + +.comments-list .comment .info .delete-button { + background-color: transparent; + border: none; + color: var(--light-grey-7); + font-size: 11px; + font-weight: 300; + padding: 0; +} + +.comments-list .comment .info .delete-button:hover { + color: var(--status-error); +} + .comments-list .comment .info .pin-button:hover { color: var(--status-translated); } diff --git a/translate/src/modules/comments/components/Comment.test.jsx b/translate/src/modules/comments/components/Comment.test.jsx index ed27b93b08..0a2e1e7be4 100644 --- a/translate/src/modules/comments/components/Comment.test.jsx +++ b/translate/src/modules/comments/components/Comment.test.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Comment } from './Comment'; import { expect, vi } from 'vitest'; -import { render } from '@testing-library/react'; +import { getByTitle, queryByTitle, render } from '@testing-library/react'; import { MockLocalizationProvider } from '~/test/utils'; vi.mock('react-time-ago', () => { @@ -11,6 +11,10 @@ vi.mock('react-time-ago', () => { }; }); +vi.mock('./AddComment', () => ({ + AddComment: () => null, +})); + describe('', () => { const DEFAULT_COMMENT = { author: '', @@ -72,4 +76,76 @@ describe('', () => { const link = getByRole('link', { name: /LKnope/i }); expect(link).toHaveAttribute('href', '/contributors/Leslie_Knope'); }); + + it('shows edit and delete buttons when canEditAndDelete is true', () => { + const { getByTitle } = render( + + + , + ); + + getByTitle('Edit comment'); + getByTitle('Delete comment'); + }); + + it('does not show edit and delete buttons when canEditAndDelete is false', () => { + const { queryByTitle } = render( + + + , + ); + + expect(queryByTitle('Edit comment')).toBeNull; + expect(queryByTitle('Delete comment')).toBeNull; + }); + + it('calls onDeleteComment when delete button is clicked', () => { + const onDeleteComment = vi.fn(); + + const { getByTitle } = render( + + + , + ); + + getByTitle('Delete comment').click(); + expect(onDeleteComment).toHaveBeenCalledWith(DEFAULT_COMMENT.id); + }); + + it('calls onEditComment when edit button is clicked', () => { + const onEditComment = vi.fn(); + + const { getByTitle, queryByText } = render( + + + , + ); + + getByTitle('Edit comment').click(); + expect( + queryByText( + "What I hear when I'm being yelled at is people caring loudly at me.", + ), + ).toBeNull(); + }); }); diff --git a/translate/src/modules/comments/components/Comment.tsx b/translate/src/modules/comments/components/Comment.tsx index a332a4bcea..dd7cf313e2 100644 --- a/translate/src/modules/comments/components/Comment.tsx +++ b/translate/src/modules/comments/components/Comment.tsx @@ -1,28 +1,45 @@ import { Localized } from '@fluent/react'; import parse from 'html-react-parser'; -import React from 'react'; +import React, { useState } from 'react'; // @ts-expect-error Working types are unavailable for react-linkify 0.2.2 import Linkify from 'react-linkify'; import ReactTimeAgo from 'react-time-ago'; import type { TranslationComment } from '~/api/comment'; -import { UserAvatar } from '~/modules/user'; +import { UserAvatar, UserState } from '~/modules/user'; import './Comment.css'; +import { AddComment } from './AddComment'; type Props = { comment: TranslationComment; canPin?: boolean; togglePinnedStatus?: (status: boolean, id: number) => void; + onEditComment?: (id: number, content: string) => void; + onDeleteComment?: (id: number) => void; + canEdit?: boolean; + canDelete?: boolean; + user?: UserState; }; export function Comment(props: Props): null | React.ReactElement<'li'> { - const { comment, canPin, togglePinnedStatus } = props; + const { + comment, + canPin, + togglePinnedStatus, + onEditComment, + onDeleteComment, + canEdit, + canDelete, + user, + } = props; if (!comment) { return null; } + const [isEditing, setIsEditing] = useState(false); + const handlePinAndUnpin = () => { if (!togglePinnedStatus) { return; @@ -30,48 +47,80 @@ export function Comment(props: Props): null | React.ReactElement<'li'> { togglePinnedStatus(!comment.pinned, comment.id); }; + const handleEdit = () => { + setIsEditing(true); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + }; + + const handleDelete = () => { + if (!onDeleteComment) { + return; + } + + onDeleteComment(comment.id); + }; + return ( -
  • - +
  • + {!isEditing && ( + + )}
    -
    -
    - e.stopPropagation()} - > - {comment.author} - - - {/* We can safely use parse with comment.content as it is - * sanitized when coming from the DB. See: - * - pontoon.base.forms.AddCommentForm(} - * - pontoon.base.forms.HtmlField() - */} - {parse(comment.content)} - - {!comment.pinned ? null : ( -
    -
    - - PINNED - -
    - )} + {isEditing && user ? ( + { + if (onEditComment) { + onEditComment(comment.id, newContent); + setIsEditing(false); + } + }} + user={user} + /> + ) : ( +
    +
    + e.stopPropagation()} + > + {comment.author} + + + {/* We can safely use parse with comment.content as it is + * sanitized when coming from the DB. See: + * - pontoon.base.forms.AddCommentForm(} + * - pontoon.base.forms.HtmlField() + */} + {parse(comment.content)} + + {!comment.pinned ? null : ( +
    +
    + + PINNED + +
    + )} +
    -
    + )}
    { ) ) : null} + {canEdit && ( + <> + {isEditing ? ( + + ) : ( + + + + )} + + )} + {canDelete && ( + + + + )}
  • diff --git a/translate/src/modules/comments/components/CommentsList.css b/translate/src/modules/comments/components/CommentsList.css index 8e66dabb0f..9c0d81b838 100644 --- a/translate/src/modules/comments/components/CommentsList.css +++ b/translate/src/modules/comments/components/CommentsList.css @@ -3,6 +3,11 @@ padding: 8px 0; } +.comments-list .comment.is-editing .info { + margin-left: 44px; + margin-top: -16px; /* Offset editor height(-2px normal mode gap + -14px edit mode gap) */ +} + .comments-list .pinned-comments > * { padding: 0 8px; } @@ -25,7 +30,7 @@ .comments-list .comment { display: flex; - margin-left: 58px; + margin-left: 0px; } .comments-list .comment .user-avatar { diff --git a/translate/src/modules/comments/components/CommentsList.tsx b/translate/src/modules/comments/components/CommentsList.tsx index 366e5b5740..c300294994 100644 --- a/translate/src/modules/comments/components/CommentsList.tsx +++ b/translate/src/modules/comments/components/CommentsList.tsx @@ -2,7 +2,11 @@ import React from 'react'; import type { HistoryTranslation } from '~/api/translation'; import type { UserState } from '~/modules/user'; -import { useAddCommentAndRefresh } from '../hooks'; +import { + useAddCommentAndRefresh, + useEditCommentAndRefresh, + useDeleteCommentAndRefresh, +} from '../hooks'; import { AddComment } from './AddComment'; import { Comment } from './Comment'; @@ -18,12 +22,23 @@ export function CommentsList({ user, }: Props): React.ReactElement<'div'> { const onAddComment = useAddCommentAndRefresh(translation); + const onDeleteComment = useDeleteCommentAndRefresh(translation); + const onEditComment = useEditCommentAndRefresh(translation); + return (
      {translation.comments.map((comment) => ( - + ))}
    {user.isAuthenticated ? ( diff --git a/translate/src/modules/comments/hooks.ts b/translate/src/modules/comments/hooks.ts index 9e45c7c395..10c2bc3c0c 100644 --- a/translate/src/modules/comments/hooks.ts +++ b/translate/src/modules/comments/hooks.ts @@ -1,12 +1,16 @@ import NProgress from 'nprogress'; import { useCallback, useContext } from 'react'; -import { addComment } from '~/api/comment'; +import { addComment, deleteComment, editComment } from '~/api/comment'; import type { HistoryTranslation } from '~/api/translation'; import { HistoryData } from '~/context/HistoryData'; import { Location } from '~/context/Location'; import { ShowNotification } from '~/context/Notification'; -import { COMMENT_ADDED } from '~/modules/notification/messages'; +import { + COMMENT_ADDED, + COMMENT_DELETED, + COMMENT_EDITED, +} from '~/modules/notification/messages'; import { useAppDispatch } from '~/hooks'; import { get as getTeamComments } from '~/modules/teamcomments/actions'; @@ -36,3 +40,59 @@ export function useAddCommentAndRefresh( [entity, locale, translation, updateHistory], ); } + +export function useDeleteCommentAndRefresh( + translation: HistoryTranslation | null, +) { + const dispatch = useAppDispatch(); + const { entity, locale } = useContext(Location); + const showNotification = useContext(ShowNotification); + const { updateHistory } = useContext(HistoryData); + + return useCallback( + async (commentId: number) => { + NProgress.start(); + + await deleteComment(commentId); + + showNotification(COMMENT_DELETED); + + if (translation) { + updateHistory(); + } else { + dispatch(getTeamComments(entity, locale)); + } + + NProgress.done(); + }, + [entity, locale, translation, updateHistory, showNotification], + ); +} + +export function useEditCommentAndRefresh( + translation: HistoryTranslation | null, +) { + const dispatch = useAppDispatch(); + const { entity, locale } = useContext(Location); + const showNotification = useContext(ShowNotification); + const { updateHistory } = useContext(HistoryData); + + return useCallback( + async (commentId: number, content: string) => { + NProgress.start(); + + await editComment(commentId, content); + + showNotification(COMMENT_EDITED); + + if (translation) { + updateHistory(); + } else { + dispatch(getTeamComments(entity, locale)); + } + + NProgress.done(); + }, + [entity, locale, translation, updateHistory, showNotification], + ); +} diff --git a/translate/src/modules/notification/messages.tsx b/translate/src/modules/notification/messages.tsx index cbdcf6e24a..097815a98b 100644 --- a/translate/src/modules/notification/messages.tsx +++ b/translate/src/modules/notification/messages.tsx @@ -180,3 +180,17 @@ export const COMMENT_ADDED: NotificationMessage = { ), type: 'info', }; + +export const COMMENT_EDITED: NotificationMessage = { + content: ( + Comment edited + ), + type: 'info', +}; + +export const COMMENT_DELETED: NotificationMessage = { + content: ( + Comment deleted + ), + type: 'info', +}; diff --git a/translate/src/modules/teamcomments/components/TeamComments.tsx b/translate/src/modules/teamcomments/components/TeamComments.tsx index 95926c3516..6cf1737481 100644 --- a/translate/src/modules/teamcomments/components/TeamComments.tsx +++ b/translate/src/modules/teamcomments/components/TeamComments.tsx @@ -4,7 +4,11 @@ import { TranslationComment } from '~/api/comment'; import { AddComment } from '~/modules/comments/components/AddComment'; import { Comment } from '~/modules/comments/components/Comment'; -import { useAddCommentAndRefresh } from '~/modules/comments/hooks'; +import { + useAddCommentAndRefresh, + useDeleteCommentAndRefresh, + useEditCommentAndRefresh, +} from '~/modules/comments/hooks'; import type { UserState } from '~/modules/user'; import type { TeamCommentState } from '~/modules/teamcomments'; @@ -28,6 +32,8 @@ export function TeamComments({ resetContactPerson, }: Props): null | React.ReactElement<'section'> { const onAddComment = useAddCommentAndRefresh(null); + const onDeleteComment = useDeleteCommentAndRefresh(null); + const onEditComment = useEditCommentAndRefresh(null); if (fetching || !comments) { return null; @@ -50,6 +56,11 @@ export function TeamComments({ canPin={user.isPM} key={comment.id} togglePinnedStatus={togglePinnedStatus} + canEdit={user.username === comment.username} + canDelete={user.username === comment.username || user.isPM} + onEditComment={onEditComment} + onDeleteComment={onDeleteComment} + user={user} /> );