Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions pontoon/base/tests/views/test_comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
10 changes: 10 additions & 0 deletions pontoon/base/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions pontoon/base/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 5 additions & 1 deletion translate/public/locale/en-US/translate.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions translate/src/api/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
const payload = new URLSearchParams({
comment_id: String(commentId),
});

const url = '/delete-comment/';
const headers = new Headers({ 'X-CSRFToken': getCSRFToken() });
return POST(url, payload, { headers });
}
32 changes: 31 additions & 1 deletion translate/src/modules/comments/components/AddComment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { MentionList } from './MentionList';
type Props = {
contactPerson?: string;
initFocus: boolean;
initialContent?: string;
onAddComment(comment: string): void;
resetContactPerson?: () => void;
user: UserState;
Expand Down Expand Up @@ -81,6 +82,7 @@ function isEditable(elem: Element | null): boolean {
export function AddComment({
contactPerson,
initFocus,
initialContent,
onAddComment,
resetContactPerson,
user: { gravatarURLSmall, username },
Expand All @@ -94,7 +96,10 @@ export function AddComment({
const { initMentions, mentionUsers } = useContext(MentionUsers);
const [slateKey, resetValue] = useReducer((key) => key + 1, 0);
const initialValue = useMemo<Paragraph[]>(
() => [{ type: 'paragraph', children: [{ text: '' }] }],
() =>
initialContent
? deserialize(initialContent)
: [{ type: 'paragraph', children: [{ text: '' }] }],
[slateKey],
);

Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions translate/src/modules/comments/components/Comment.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down
78 changes: 77 additions & 1 deletion translate/src/modules/comments/components/Comment.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -11,6 +11,10 @@ vi.mock('react-time-ago', () => {
};
});

vi.mock('./AddComment', () => ({
AddComment: () => null,
}));

describe('<Comment>', () => {
const DEFAULT_COMMENT = {
author: '',
Expand Down Expand Up @@ -72,4 +76,76 @@ describe('<Comment>', () => {
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(
<MockLocalizationProvider>
<Comment
comment={DEFAULT_COMMENT}
canEdit={true}
canDelete={true}
user={DEFAULT_USER}
/>
</MockLocalizationProvider>,
);

getByTitle('Edit comment');
getByTitle('Delete comment');
});

it('does not show edit and delete buttons when canEditAndDelete is false', () => {
const { queryByTitle } = render(
<MockLocalizationProvider>
<Comment
comment={DEFAULT_COMMENT}
canEdit={false}
canDelete={false}
user={DEFAULT_USER}
/>
</MockLocalizationProvider>,
);

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(
<MockLocalizationProvider>
<Comment
comment={DEFAULT_COMMENT}
canDelete={true}
onDeleteComment={onDeleteComment}
user={DEFAULT_USER}
/>
</MockLocalizationProvider>,
);

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(
<MockLocalizationProvider>
<Comment
comment={DEFAULT_COMMENT}
canEdit={true}
onEditComment={onEditComment}
user={DEFAULT_USER}
/>
</MockLocalizationProvider>,
);

getByTitle('Edit comment').click();
expect(
queryByText(
"What I hear when I'm being yelled at is people caring loudly at me.",
),
).toBeNull();
});
});
Loading
Loading