diff --git a/app/controllers/pageflow/review/comment_threads_controller.rb b/app/controllers/pageflow/review/comment_threads_controller.rb
index 7ef9f3c951..968d421e26 100644
--- a/app/controllers/pageflow/review/comment_threads_controller.rb
+++ b/app/controllers/pageflow/review/comment_threads_controller.rb
@@ -11,6 +11,28 @@ def index
@comment_threads = entry.comment_threads.includes(comments: :creator)
end
+
+ def create
+ entry = DraftEntry.find(params[:entry_id])
+ authorize!(:read, entry.to_model)
+
+ @comment_thread = entry.comment_threads.build(thread_params)
+ @comment_thread.creator = current_user
+
+ @comment_thread.comments.build(
+ body: params[:comment_thread][:comment][:body],
+ creator: current_user
+ )
+
+ @comment_thread.save!
+ render :create, status: :created
+ end
+
+ private
+
+ def thread_params
+ params.require(:comment_thread).permit(:subject_type, :subject_id)
+ end
end
end
end
diff --git a/app/controllers/pageflow/review/comments_controller.rb b/app/controllers/pageflow/review/comments_controller.rb
new file mode 100644
index 0000000000..621b7372dc
--- /dev/null
+++ b/app/controllers/pageflow/review/comments_controller.rb
@@ -0,0 +1,27 @@
+module Pageflow
+ module Review
+ # @api private
+ class CommentsController < Pageflow::ApplicationController
+ respond_to :json
+ before_action :authenticate_user!
+
+ def create
+ entry = DraftEntry.find(params[:entry_id])
+ authorize!(:read, entry.to_model)
+
+ thread = entry.comment_threads.find(params[:comment_thread_id])
+ @comment = thread.comments.build(comment_params)
+ @comment.creator = current_user
+ @comment.save!
+
+ render :create, status: :created
+ end
+
+ private
+
+ def comment_params
+ params.require(:comment).permit(:body)
+ end
+ end
+ end
+end
diff --git a/app/views/pageflow/review/comment_threads/create.json.jbuilder b/app/views/pageflow/review/comment_threads/create.json.jbuilder
new file mode 100644
index 0000000000..671fd3e2f5
--- /dev/null
+++ b/app/views/pageflow/review/comment_threads/create.json.jbuilder
@@ -0,0 +1,2 @@
+json.partial!('pageflow/review/comment_threads/comment_thread',
+ comment_thread: @comment_thread)
diff --git a/app/views/pageflow/review/comments/create.json.jbuilder b/app/views/pageflow/review/comments/create.json.jbuilder
new file mode 100644
index 0000000000..65e0686e91
--- /dev/null
+++ b/app/views/pageflow/review/comments/create.json.jbuilder
@@ -0,0 +1 @@
+json.partial!('pageflow/review/comments/comment', comment: @comment)
diff --git a/config/routes.rb b/config/routes.rb
index 3490e675d0..0f7aed0b8f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -79,7 +79,9 @@
namespace :review do
resources :entries, only: [] do
- resources :comment_threads, only: [:index]
+ resources :comment_threads, only: [:index, :create] do
+ resources :comments, only: [:create]
+ end
end
end
diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb b/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb
index 3c8077a152..65f6ed9a2c 100644
--- a/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb
+++ b/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb
@@ -7,6 +7,9 @@
+ <% if seed_options[:load_commenting] %>
+ <%= csrf_meta_tags %>
+ <% end %>
<%= social_share_meta_tags_for(entry) %>
<%= meta_tags_for_entry(entry) %>
diff --git a/entry_types/scrolled/bin/rspec-with-retry-on-timeout b/entry_types/scrolled/bin/rspec-with-retry-on-timeout
index f9b3bfc753..6b88ea7c78 100755
--- a/entry_types/scrolled/bin/rspec-with-retry-on-timeout
+++ b/entry_types/scrolled/bin/rspec-with-retry-on-timeout
@@ -1,12 +1,12 @@
#!/bin/bash
# See REDMINE-17430
-# Running only the js specs should not take more than 20 seconds.
+# Running only the js specs should not take more than 30 seconds.
# If Chrome Driver hangs, the timeout command will exit with 137.
# Retry or else exit with original exit status of rspec command.
for i in {1..10}; do
- timeout --signal=KILL 30 bin/rspec $@
+ timeout --signal=KILL 45 bin/rspec $@
e=$?
[[ $e -gt 100 ]] && echo Timeout || exit $e;
done;
diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml
index a26803be37..5347704b06 100644
--- a/entry_types/scrolled/config/locales/de.yml
+++ b/entry_types/scrolled/config/locales/de.yml
@@ -1912,4 +1912,16 @@ de:
content_element_margin_bottom: Unterer Außenabstand
expose_motif_area: Motiv freilegen
review:
- toolbar_label: Kommentare
+ add_comment: Kommentar hinzufügen
+ cancel_add_comment: Abbrechen
+ select_content_element: Zum Kommentieren auswählen
+ new_topic: Neues Thema
+ add_comment_placeholder: Kommentar hinzufügen...
+ reply_placeholder: Antworten...
+ cancel: Abbrechen
+ send: Senden
+ enter_for_new_line: Enter für neue Zeile
+ toggle_replies: Antworten umschalten
+ reply_count:
+ one: 1 Antwort
+ other: '%{count} Antworten'
diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml
index af89606a43..68d3b07134 100644
--- a/entry_types/scrolled/config/locales/en.yml
+++ b/entry_types/scrolled/config/locales/en.yml
@@ -1741,4 +1741,16 @@ en:
content_element_margin_bottom: Bottom margin
expose_motif_area: Expose motif
review:
- toolbar_label: Comments
+ add_comment: Add comment
+ cancel_add_comment: Cancel
+ select_content_element: Select to comment
+ new_topic: New topic
+ add_comment_placeholder: Add a comment...
+ reply_placeholder: Reply...
+ cancel: Cancel
+ send: Send
+ enter_for_new_line: Enter for new line
+ toggle_replies: Toggle replies
+ reply_count:
+ one: 1 reply
+ other: '%{count} replies'
diff --git a/entry_types/scrolled/package/spec/frontend/features/addCommentMode-spec.js b/entry_types/scrolled/package/spec/frontend/features/addCommentMode-spec.js
new file mode 100644
index 0000000000..f194ce6927
--- /dev/null
+++ b/entry_types/scrolled/package/spec/frontend/features/addCommentMode-spec.js
@@ -0,0 +1,191 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import {act} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {useFakeTranslations} from 'pageflow/testHelpers';
+
+import {Entry} from 'frontend/Entry';
+import {usePageObjects} from 'support/pageObjects';
+import {renderInEntry} from 'support';
+import {clearExtensions} from 'frontend/extensions';
+import {loadCommentingComponents} from 'frontend/commenting';
+
+describe('add comment mode', () => {
+ usePageObjects();
+
+ useFakeTranslations({
+ 'pageflow_scrolled.review.add_comment': 'Add comment',
+ 'pageflow_scrolled.review.select_content_element': 'Select to comment',
+ 'pageflow_scrolled.review.add_comment_placeholder': 'Add a comment...',
+ 'pageflow_scrolled.review.new_topic': 'New topic',
+ 'pageflow_scrolled.review.send': 'Send',
+ 'pageflow_scrolled.review.cancel': 'Cancel',
+ 'pageflow_scrolled.review.cancel_add_comment': 'Cancel add comment'
+ });
+
+ beforeEach(() => {
+ jest.spyOn(window, 'fetch').mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({currentUser: null, commentThreads: []})
+ });
+
+ loadCommentingComponents();
+ });
+
+ afterEach(() => {
+ act(() => clearExtensions());
+ window.fetch.mockRestore();
+ });
+
+ it('renders add comment button', () => {
+ const {getByRole} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ expect(getByRole('button', {name: 'Add comment'})).toBeInTheDocument();
+ });
+
+ it('shows highlight overlays on content elements when activated', async () => {
+ const user = userEvent.setup();
+ const {getByRole} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ await user.click(getByRole('button', {name: 'Add comment'}));
+
+ expect(getByRole('button', {name: 'Select to comment'})).toBeInTheDocument();
+ });
+
+ it('opens new thread form when selecting element', async () => {
+ const user = userEvent.setup();
+ const {getByRole, getByPlaceholderText} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ await user.click(getByRole('button', {name: 'Add comment'}));
+ await user.click(getByRole('button', {name: 'Select to comment'}));
+
+ expect(getByPlaceholderText('Add a comment...')).toBeInTheDocument();
+ });
+
+ it('auto expands new thread form when selecting element with existing threads', async () => {
+ window.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({
+ currentUser: null,
+ commentThreads: [{
+ id: 1,
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ comments: [{id: 1, body: 'Existing comment', createdAt: '2026-01-01T00:00:00Z'}]
+ }]
+ })
+ });
+
+ const user = userEvent.setup();
+ const {getByRole, getByPlaceholderText} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ permaId: 10,
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ await user.click(getByRole('button', {name: 'Add comment'}));
+ await user.click(getByRole('button', {name: 'Select to comment'}));
+
+ expect(getByPlaceholderText('Add a comment...')).toBeInTheDocument();
+ });
+
+ it('closes popover when cancelling new thread form on element without threads', async () => {
+ const user = userEvent.setup();
+ const {getByRole, queryByRole} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ await user.click(getByRole('button', {name: 'Add comment'}));
+ await user.click(getByRole('button', {name: 'Select to comment'}));
+ await user.click(getByRole('button', {name: 'Cancel'}));
+
+ expect(queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('exits add comment mode when overlay is clicked', async () => {
+ const user = userEvent.setup();
+ const {getByRole, queryByRole} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ await user.click(getByRole('button', {name: 'Add comment'}));
+ expect(getByRole('button', {name: 'Select to comment'})).toBeInTheDocument();
+
+ await user.click(getByRole('button', {name: 'Select to comment'}));
+
+ expect(queryByRole('button', {name: 'Select to comment'})).not.toBeInTheDocument();
+ });
+
+ it('deactivates when clicking toggle button again', async () => {
+ const user = userEvent.setup();
+ const {getByRole, queryByRole} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ await user.click(getByRole('button', {name: 'Add comment'}));
+ expect(getByRole('button', {name: 'Select to comment'})).toBeInTheDocument();
+
+ await user.click(getByRole('button', {name: 'Cancel add comment'}));
+
+ expect(queryByRole('button', {name: 'Select to comment'})).not.toBeInTheDocument();
+ });
+
+ it('exits add comment mode when clicking outside', async () => {
+ const user = userEvent.setup();
+ const {getByRole, queryByRole} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ await user.click(getByRole('button', {name: 'Add comment'}));
+ expect(getByRole('button', {name: 'Select to comment'})).toBeInTheDocument();
+
+ await user.click(document.body);
+
+ expect(queryByRole('button', {name: 'Select to comment'})).not.toBeInTheDocument();
+ });
+});
diff --git a/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js b/entry_types/scrolled/package/spec/frontend/features/commentingBadges-spec.js
similarity index 56%
rename from entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js
rename to entry_types/scrolled/package/spec/frontend/features/commentingBadges-spec.js
index 5e9697c9cc..862a8d3ea3 100644
--- a/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js
+++ b/entry_types/scrolled/package/spec/frontend/features/commentingBadges-spec.js
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
-import {act, waitFor} from '@testing-library/react';
+import {act, fireEvent, waitFor} from '@testing-library/react';
import {Entry} from 'frontend/Entry';
import {usePageObjects} from 'support/pageObjects';
@@ -8,7 +8,7 @@ import {renderInEntry} from 'support';
import {clearExtensions} from 'frontend/extensions';
import {loadCommentingComponents} from 'frontend/commenting';
-describe('commenting mode', () => {
+describe('commenting badges', () => {
usePageObjects();
beforeEach(() => {
@@ -49,4 +49,36 @@ describe('commenting mode', () => {
expect(getByRole('status')).toBeInTheDocument();
});
});
+
+ it('shows thread list when clicking badge', async () => {
+ window.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, comments: [
+ {id: 10, body: 'Nice work', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ })
+ });
+
+ const {getByRole, getByText} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ await waitFor(() => {
+ expect(getByRole('status')).toBeInTheDocument();
+ });
+
+ fireEvent.click(getByRole('status'));
+
+ expect(getByText('Nice work')).toBeInTheDocument();
+ expect(getByText('Bob')).toBeInTheDocument();
+ });
});
diff --git a/entry_types/scrolled/package/spec/review/CommentBadge-spec.js b/entry_types/scrolled/package/spec/review/CommentBadge-spec.js
index e4f5662faa..cfad9b4c09 100644
--- a/entry_types/scrolled/package/spec/review/CommentBadge-spec.js
+++ b/entry_types/scrolled/package/spec/review/CommentBadge-spec.js
@@ -1,43 +1,28 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
-import {render, act} from '@testing-library/react';
-import {ReviewStateProvider} from 'review/ReviewStateProvider';
import {CommentBadge} from 'review/CommentBadge';
+import {renderWithReviewState} from 'testHelpers/renderWithReviewState';
describe('CommentBadge', () => {
it('displays thread count for subject', () => {
- const {getByRole} = render(
-
-
-
+ const {getByRole} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: []},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: []},
+ {id: 3, subjectType: 'ContentElement', subjectId: 20, comments: []}
+ ]
+ }
);
- act(() => {
- window.dispatchEvent(new MessageEvent('message', {
- data: {
- type: 'REVIEW_STATE_RESET',
- payload: {
- currentUser: {id: 1},
- commentThreads: [
- {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: []},
- {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: []},
- {id: 3, subjectType: 'ContentElement', subjectId: 20, comments: []}
- ]
- }
- },
- origin: window.location.origin
- }));
- });
-
expect(getByRole('status')).toHaveTextContent('2');
});
it('renders nothing when no threads exist for subject', () => {
- const {container} = render(
-
-
-
+ const {container} = renderWithReviewState(
+
);
expect(container).toBeEmptyDOMElement();
diff --git a/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js
index cb9cdb49a8..c7fbcf241f 100644
--- a/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js
+++ b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js
@@ -3,12 +3,58 @@ import BackboneEvents from 'backbone-events-standalone';
import {ReviewMessageHandler} from 'review/ReviewMessageHandler';
function fakeReviewSession() {
- const session = {};
+ const session = {
+ createThread: jest.fn().mockResolvedValue(),
+ createComment: jest.fn().mockResolvedValue()
+ };
+
Object.assign(session, BackboneEvents);
return session;
}
describe('ReviewMessageHandler', () => {
+ it('calls session.createThread on CREATE_COMMENT_THREAD message', async () => {
+ const session = fakeReviewSession();
+ const targetWindow = {postMessage: jest.fn()};
+
+ ReviewMessageHandler.create({session, targetWindow});
+
+ window.dispatchEvent(new MessageEvent('message', {
+ data: {
+ type: 'CREATE_COMMENT_THREAD',
+ payload: {subjectType: 'CE', subjectId: 10, body: 'Test'}
+ },
+ origin: window.location.origin
+ }));
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(session.createThread).toHaveBeenCalledWith({
+ subjectType: 'CE', subjectId: 10, body: 'Test'
+ });
+ });
+
+ it('calls session.createComment on CREATE_COMMENT message', async () => {
+ const session = fakeReviewSession();
+ const targetWindow = {postMessage: jest.fn()};
+
+ ReviewMessageHandler.create({session, targetWindow});
+
+ window.dispatchEvent(new MessageEvent('message', {
+ data: {
+ type: 'CREATE_COMMENT',
+ payload: {threadId: 1, body: 'Reply'}
+ },
+ origin: window.location.origin
+ }));
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(session.createComment).toHaveBeenCalledWith({
+ threadId: 1, body: 'Reply'
+ });
+ });
+
it('posts REVIEW_STATE_RESET to target window on session reset', () => {
const session = fakeReviewSession();
const targetWindow = {postMessage: jest.fn()};
diff --git a/entry_types/scrolled/package/spec/review/ThreadList-spec.js b/entry_types/scrolled/package/spec/review/ThreadList-spec.js
new file mode 100644
index 0000000000..f6f2b91f49
--- /dev/null
+++ b/entry_types/scrolled/package/spec/review/ThreadList-spec.js
@@ -0,0 +1,409 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import userEvent from '@testing-library/user-event';
+import {useFakeTranslations} from 'pageflow/testHelpers';
+
+import {ThreadList} from 'review/ThreadList';
+import {renderWithReviewState} from 'testHelpers/renderWithReviewState';
+
+describe('ThreadList', () => {
+ useFakeTranslations({
+ 'pageflow_scrolled.review.reply_count.one': '1 reply',
+ 'pageflow_scrolled.review.reply_count.other': '%{count} replies',
+ 'pageflow_scrolled.review.add_comment_placeholder': 'Add a comment...',
+ 'pageflow_scrolled.review.new_topic': 'New topic',
+ 'pageflow_scrolled.review.cancel': 'Cancel',
+ 'pageflow_scrolled.review.reply_placeholder': 'Reply...',
+ 'pageflow_scrolled.review.send': 'Send',
+ 'pageflow_scrolled.review.enter_for_new_line': 'Enter for new line',
+ 'pageflow_scrolled.review.toggle_replies': 'Toggle replies'
+ });
+ it('displays comments of threads for subject', () => {
+ const {getByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Looks good', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ expect(getByText('Bob')).toBeInTheDocument();
+ expect(getByText('Looks good')).toBeInTheDocument();
+ });
+
+ it('only displays threads for given subject', () => {
+ const {getByText, queryByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Matching', creatorName: 'Bob', creatorId: 2}
+ ]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 20, comments: [
+ {id: 20, body: 'Other element', creatorName: 'Alice', creatorId: 1}
+ ]},
+ {id: 3, subjectType: 'Section', subjectId: 10, comments: [
+ {id: 30, body: 'Other type', creatorName: 'Eve', creatorId: 3}
+ ]}
+ ]
+ }
+ );
+
+ expect(getByText('Matching')).toBeInTheDocument();
+ expect(queryByText('Other element')).not.toBeInTheDocument();
+ expect(queryByText('Other type')).not.toBeInTheDocument();
+ });
+
+ it('displays formatted timestamp', () => {
+ const {getByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Hello', creatorName: 'Bob', creatorId: 2,
+ createdAt: '2026-03-15T14:30:00Z'}
+ ]}
+ ]
+ }
+ );
+
+ expect(getByText('Mar 15')).toBeInTheDocument();
+ });
+
+ it('displays avatar with initial', () => {
+ const {getByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Hello', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ expect(getByText('B')).toBeInTheDocument();
+ });
+
+ it('collapses threads when more than one exists', () => {
+ const {queryByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2},
+ {id: 11, body: 'First reply', creatorName: 'Alice', creatorId: 1}
+ ]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 20, body: 'Second comment', creatorName: 'Eve', creatorId: 3},
+ {id: 21, body: 'Second reply', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ expect(queryByText('First comment')).toBeInTheDocument();
+ expect(queryByText('First reply')).not.toBeInTheDocument();
+ expect(queryByText('Second comment')).toBeInTheDocument();
+ expect(queryByText('Second reply')).not.toBeInTheDocument();
+ });
+
+ it('does not collapse single thread', () => {
+ const {getByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2},
+ {id: 11, body: 'A reply', creatorName: 'Alice', creatorId: 1}
+ ]}
+ ]
+ }
+ );
+
+ expect(getByText('First comment')).toBeInTheDocument();
+ expect(getByText('A reply')).toBeInTheDocument();
+ });
+
+ it('shows avatar stack of reply authors when collapsed', () => {
+ const {getAllByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Start', creatorName: 'Bob', creatorId: 2},
+ {id: 11, body: 'Reply 1', creatorName: 'Alice', creatorId: 1},
+ {id: 12, body: 'Reply 2', creatorName: 'Eve', creatorId: 3}
+ ]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 20, body: 'Other', creatorName: 'Dan', creatorId: 4}
+ ]}
+ ]
+ }
+ );
+
+ expect(getAllByText('A')).toHaveLength(1);
+ expect(getAllByText('E')).toHaveLength(1);
+ });
+
+ it('shows chevron toggle on threads with replies', () => {
+ const {getAllByRole} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Topic', creatorName: 'Bob', creatorId: 2},
+ {id: 11, body: 'Reply', creatorName: 'Alice', creatorId: 1}
+ ]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 20, body: 'No replies', creatorName: 'Eve', creatorId: 3}
+ ]}
+ ]
+ }
+ );
+
+ expect(getAllByRole('button', {name: 'Toggle replies'})).toHaveLength(1);
+ });
+
+ it('expands collapsed thread via chevron', async () => {
+ const user = userEvent.setup();
+ const {getByRole, getByText, queryByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2},
+ {id: 11, body: 'Hidden reply', creatorName: 'Alice', creatorId: 1}
+ ]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 20, body: 'Other thread', creatorName: 'Eve', creatorId: 3}
+ ]}
+ ]
+ }
+ );
+
+ expect(queryByText('Hidden reply')).not.toBeInTheDocument();
+
+ await user.click(getByRole('button', {name: 'Toggle replies'}));
+
+ expect(getByText('Hidden reply')).toBeInTheDocument();
+ });
+
+ it('expands collapsed thread via reply count button', async () => {
+ const user = userEvent.setup();
+ const {getByText, queryByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2},
+ {id: 11, body: 'Hidden reply', creatorName: 'Alice', creatorId: 1}
+ ]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 20, body: 'Other thread', creatorName: 'Eve', creatorId: 3}
+ ]}
+ ]
+ }
+ );
+
+ expect(queryByText('Hidden reply')).not.toBeInTheDocument();
+
+ await user.click(getByText('1 reply'));
+
+ expect(getByText('Hidden reply')).toBeInTheDocument();
+ });
+
+ it('displays multiple threads', () => {
+ const {getByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'First thread', creatorName: 'Bob', creatorId: 2}
+ ]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 20, body: 'Second thread', creatorName: 'Alice', creatorId: 1}
+ ]}
+ ]
+ }
+ );
+
+ expect(getByText('First thread')).toBeInTheDocument();
+ expect(getByText('Second thread')).toBeInTheDocument();
+ });
+
+ it('posts create thread message on form submit', async () => {
+ const user = userEvent.setup();
+ const postMessage = jest.spyOn(window.top, 'postMessage').mockImplementation(() => {});
+
+ const {getByPlaceholderText, getByRole} = renderWithReviewState(
+
+ );
+
+ await user.type(getByPlaceholderText('Add a comment...'), 'New thread');
+ await user.click(getByRole('button', {name: 'Send'}));
+
+ expect(postMessage).toHaveBeenCalledWith(
+ {
+ type: 'CREATE_COMMENT_THREAD',
+ payload: {
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ body: 'New thread'
+ }
+ },
+ window.location.origin
+ );
+
+ postMessage.mockRestore();
+ });
+
+ it('shows new topic form automatically when no threads exist', () => {
+ const {getByPlaceholderText} = renderWithReviewState(
+
+ );
+
+ expect(getByPlaceholderText('Add a comment...')).toBeInTheDocument();
+ });
+
+ it('hides new topic form behind button when threads exist', () => {
+ const {queryByPlaceholderText, getByRole} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Existing', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ expect(queryByPlaceholderText('Add a comment...')).not.toBeInTheDocument();
+ expect(getByRole('button', {name: 'New topic'})).toBeInTheDocument();
+ });
+
+ it('shows form when New topic button is clicked', async () => {
+ const user = userEvent.setup();
+
+ const {getByPlaceholderText, getByRole} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Existing', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ await user.click(getByRole('button', {name: 'New topic'}));
+
+ expect(getByPlaceholderText('Add a comment...')).toBeInTheDocument();
+ });
+
+ it('hides form when cancel is clicked', async () => {
+ const user = userEvent.setup();
+
+ const {getByRole, queryByPlaceholderText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Existing', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ await user.click(getByRole('button', {name: 'New topic'}));
+ await user.click(getByRole('button', {name: 'Cancel'}));
+
+ expect(queryByPlaceholderText('Add a comment...')).not.toBeInTheDocument();
+ expect(getByRole('button', {name: 'New topic'})).toBeInTheDocument();
+ });
+
+ it('posts create comment message when replying to thread', async () => {
+ const user = userEvent.setup();
+ const postMessage = jest.spyOn(window.top, 'postMessage').mockImplementation(() => {});
+
+ const {getByPlaceholderText, getByRole} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Start', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ await user.type(getByPlaceholderText('Reply...'), 'My reply');
+ await user.click(getByRole('button', {name: 'Send'}));
+
+ expect(postMessage).toHaveBeenCalledWith(
+ {
+ type: 'CREATE_COMMENT',
+ payload: {threadId: 1, body: 'My reply'}
+ },
+ window.location.origin
+ );
+
+ postMessage.mockRestore();
+ });
+
+ it('hides reply submit button when textarea is empty', () => {
+ const {queryByRole, getByPlaceholderText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Start', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ expect(getByPlaceholderText('Reply...')).toBeInTheDocument();
+ expect(queryByRole('button', {name: 'Send'})).not.toBeInTheDocument();
+ });
+
+ it('shows reply submit button when text is entered', async () => {
+ const user = userEvent.setup();
+
+ const {getByRole, getByPlaceholderText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'Start', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ await user.type(getByPlaceholderText('Reply...'), 'Some text');
+
+ expect(getByRole('button', {name: 'Send'})).toBeInTheDocument();
+ });
+
+ it('shows reply form in collapsed thread without replies', () => {
+ const {getAllByPlaceholderText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'First topic', creatorName: 'Bob', creatorId: 2}
+ ]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 20, body: 'Second topic', creatorName: 'Alice', creatorId: 1}
+ ]}
+ ]
+ }
+ );
+
+ expect(getAllByPlaceholderText('Reply...')).toHaveLength(2);
+ });
+});
diff --git a/entry_types/scrolled/package/src/frontend/commenting/AddCommentModeProvider.js b/entry_types/scrolled/package/src/frontend/commenting/AddCommentModeProvider.js
new file mode 100644
index 0000000000..09b1b6aad7
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/AddCommentModeProvider.js
@@ -0,0 +1,52 @@
+import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react';
+
+const AddCommentModeContext = createContext({
+ active: false,
+ toggle: () => {},
+ deactivate: () => {}
+});
+
+export function AddCommentModeProvider({children}) {
+ const [active, setActive] = useState(false);
+
+ const toggle = useCallback(() => {
+ setActive(prev => !prev);
+ }, []);
+
+ const deactivate = useCallback(() => {
+ setActive(false);
+ }, []);
+
+ useEffect(() => {
+ if (!active) return;
+
+ function handlePointerDown(event) {
+ if (!event.target.closest('[data-add-comment-overlay]') &&
+ !event.target.closest('[data-add-comment-toggle]')) {
+ setActive(false);
+ }
+ }
+
+ document.addEventListener('pointerdown', handlePointerDown);
+
+ return () => {
+ document.removeEventListener('pointerdown', handlePointerDown);
+ };
+ }, [active]);
+
+ const value = useMemo(() => ({
+ active,
+ toggle,
+ deactivate
+ }), [active, toggle, deactivate]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAddCommentMode() {
+ return useContext(AddCommentModeContext);
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/AddCommentOverlay.js b/entry_types/scrolled/package/src/frontend/commenting/AddCommentOverlay.js
new file mode 100644
index 0000000000..1812bc2f89
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/AddCommentOverlay.js
@@ -0,0 +1,33 @@
+import React from 'react';
+
+import {useI18n} from '../i18n';
+import {useAddCommentMode} from './AddCommentModeProvider';
+import {useSelectedSubject} from './SelectedSubjectProvider';
+
+import AddCommentIcon from './images/addComment.svg';
+import styles from './AddCommentOverlay.module.css';
+
+export function AddCommentOverlay({permaId}) {
+ const {t} = useI18n({locale: 'ui'});
+ const {active, deactivate} = useAddCommentMode();
+ const {select} = useSelectedSubject('ContentElement', permaId);
+
+ if (!active) return null;
+
+ function handleClick() {
+ select({showNewForm: true});
+ deactivate();
+ }
+
+ return (
+
+ );
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/AddCommentOverlay.module.css b/entry_types/scrolled/package/src/frontend/commenting/AddCommentOverlay.module.css
new file mode 100644
index 0000000000..d9f5bb2081
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/AddCommentOverlay.module.css
@@ -0,0 +1,35 @@
+.highlight {
+ position: absolute;
+ inset: 0;
+ cursor: pointer;
+ background-color: var(--ui-selection-color-lightest);
+ border: none;
+ outline: none;
+ box-shadow: 0 0 0 2px var(--ui-selection-color),
+ 0 0 0 calc(2px + space(1)) var(--ui-selection-color-light);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.pill {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: space(1);
+ width: space(9);
+ border-radius: rounded(full);
+ background: var(--ui-accent-color);
+ color: var(--ui-on-accent-color);
+ font-family: var(--ui-font-family);
+ font-size: space(3.5);
+ font-weight: 600;
+ padding: space(1.5) space(3);
+ box-shadow: 0 0 0 space(1) var(--ui-accent-color-glow);
+ opacity: 0.9;
+}
+
+.highlight:hover .pill,
+.highlight:focus .pill {
+ opacity: 1;
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js
index 61ade27f92..16fcfb24bd 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js
@@ -1,12 +1,29 @@
import React from 'react';
+import classNames from 'classnames';
-import {CommentBadge} from 'pageflow-scrolled/review';
+import {useAddCommentMode} from './AddCommentModeProvider';
+import {AddCommentOverlay} from './AddCommentOverlay';
+import {FloatingAnchor} from './FloatingAnchor';
+import {Popover} from './Popover';
+import {useSelectedSubject} from './SelectedSubjectProvider';
+
+import styles from './ContentElementDecorator.module.css';
export function ContentElementDecorator({permaId, children}) {
+ const {active} = useAddCommentMode();
+ const {isSelected} = useSelectedSubject('ContentElement', permaId);
+
return (
- <>
- {children}
-
- >
+
+
{children}
+
+
+ {({placement}) => (
+
+ )}
+
+
);
}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.module.css b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.module.css
new file mode 100644
index 0000000000..0e7fc8c244
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.module.css
@@ -0,0 +1,8 @@
+.wrapper {
+ position: relative;
+}
+
+.selected {
+ box-shadow: 0 0 0 2px var(--ui-accent-color),
+ 0 0 0 calc(2px + space(1)) var(--ui-accent-color-glow);
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js
index f8a794a595..7ac530b431 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js
@@ -3,14 +3,20 @@ import React, {useEffect} from 'react';
import {useEntryMetadata} from '../../entryState';
import {createReviewSession} from 'pageflow/review';
import {ReviewStateProvider, ReviewMessageHandler} from 'pageflow-scrolled/review';
+import {AddCommentModeProvider} from './AddCommentModeProvider';
+import {SelectedSubjectProvider} from './SelectedSubjectProvider';
import {FloatingToolbar} from './FloatingToolbar';
export function EntryDecorator(props) {
return (
- {props.children}
-
+
+
+ {props.children}
+
+
+
);
}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/FloatingAnchor.js b/entry_types/scrolled/package/src/frontend/commenting/FloatingAnchor.js
new file mode 100644
index 0000000000..22a7825d05
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/FloatingAnchor.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import {
+ useFloating, FloatingPortal,
+ offset, flip, autoUpdate
+} from '@floating-ui/react';
+
+import {useFloatingPortalRoot} from '../FloatingPortalRootProvider';
+import useMediaQuery from '../useMediaQuery';
+
+import styles from './FloatingAnchor.module.css';
+
+const NARROW_BREAKPOINT = '(max-width: 960px)';
+
+export function FloatingAnchor({children}) {
+ const portalRoot = useFloatingPortalRoot();
+ const isNarrow = useMediaQuery(NARROW_BREAKPOINT);
+
+ const {refs, floatingStyles, placement} = useFloating({
+ placement: isNarrow ? 'bottom-start' : 'right-start',
+ middleware: [
+ offset(isNarrow ? {mainAxis: -10} : {mainAxis: -16, crossAxis: -10}),
+ ...(!isNarrow ? [flip({crossAxis: false, padding: 8})] : [])
+ ],
+ whileElementsMounted: autoUpdate
+ });
+
+ return (
+ <>
+
+
+
+
+ {children({placement})}
+
+
+ >
+ );
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/FloatingAnchor.module.css b/entry_types/scrolled/package/src/frontend/commenting/FloatingAnchor.module.css
new file mode 100644
index 0000000000..9c82463d81
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/FloatingAnchor.module.css
@@ -0,0 +1,9 @@
+.anchor {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.floating {
+ z-index: 200;
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js
index 759e7cce40..87b76743e6 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js
@@ -1,14 +1,30 @@
import React from 'react';
import {useI18n} from '../i18n';
+import {useAddCommentMode} from './AddCommentModeProvider';
+
+import AddCommentIcon from './images/addComment.svg';
+import CancelCommentIcon from './images/cancelComment.svg';
import styles from './FloatingToolbar.module.css';
export function FloatingToolbar() {
const {t} = useI18n({locale: 'ui'});
+ const {active, toggle} = useAddCommentMode();
+
+ const Icon = active ? CancelCommentIcon : AddCommentIcon;
+ const label = t(active
+ ? 'pageflow_scrolled.review.cancel_add_comment'
+ : 'pageflow_scrolled.review.add_comment');
return (
- {t('pageflow_scrolled.review.toolbar_label')}
+
);
}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css
index acb82270e5..1dab7a1d3b 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css
+++ b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css
@@ -1,14 +1,20 @@
.toolbar {
position: fixed;
- bottom: 20px;
- left: 50%;
- transform: translateX(-50%);
+ bottom: 0;
+ right: 0;
z-index: 100;
- padding: 8px 16px;
- background: #ffd24d;
- color: #795f0f;
- border-radius: 20px;
- font-family: sans-serif;
- font-size: 14px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ padding: space(2);
+}
+
+.button {
+ font-family: var(--ui-font-family);
+ color: var(--ui-on-primary-color);
+ background: var(--ui-primary-color);
+ border: none;
+ border-radius: rounded(md);
+ padding: space(2);
+ cursor: pointer;
+ box-shadow: var(--ui-box-shadow-md);
+ display: flex;
+ align-items: center;
}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/Popover.js b/entry_types/scrolled/package/src/frontend/commenting/Popover.js
new file mode 100644
index 0000000000..09fdff57a9
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/Popover.js
@@ -0,0 +1,69 @@
+import React, {useEffect, useRef} from 'react';
+import classNames from 'classnames';
+
+import {CommentBadge, ThreadList} from 'pageflow-scrolled/review';
+import {useSelectedSubject} from './SelectedSubjectProvider';
+
+import styles from './Popover.module.css';
+
+export function Popover({subjectType, subjectId, placement}) {
+ const {isSelected, hasSelection, showNewForm, select, clearSelection} = useSelectedSubject(subjectType, subjectId);
+ const ref = useRef(null);
+
+ function handleBadgeClick() {
+ if (isSelected) {
+ clearSelection();
+ }
+ else {
+ select();
+ }
+ }
+
+ useEffect(() => {
+ if (!isSelected) return;
+
+ function handleClick(event) {
+ if (ref.current && !ref.current.contains(event.target)) {
+ clearSelection();
+ }
+ }
+
+ function handleKeyDown(event) {
+ if (event.key === 'Escape') {
+ clearSelection();
+ }
+ }
+
+ document.addEventListener('pointerdown', handleClick);
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('pointerdown', handleClick);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isSelected, clearSelection]);
+
+ const onLeft = placement && placement.startsWith('left');
+ const onBottom = placement && placement.startsWith('bottom');
+
+ return (
+
+
+
+ {isSelected &&
+ }
+
+
+ );
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/Popover.module.css b/entry_types/scrolled/package/src/frontend/commenting/Popover.module.css
new file mode 100644
index 0000000000..adbac372ec
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/Popover.module.css
@@ -0,0 +1,21 @@
+.popover {
+ display: flex;
+ align-items: flex-start;
+ gap: space(2);
+ font-family: var(--ui-font-family);
+ font-size: var(--ui-font-size);
+ color: var(--ui-on-surface-color);
+}
+
+.reversed {
+ flex-direction: row-reverse;
+}
+
+.bottom {
+ flex-direction: column;
+}
+
+.threadListContainer {
+ min-width: space(72);
+ max-width: space(96);
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js b/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js
new file mode 100644
index 0000000000..4fcccade84
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js
@@ -0,0 +1,42 @@
+import React, {createContext, useCallback, useContext, useMemo, useState} from 'react';
+
+const SelectedSubjectContext = createContext({
+ selectedSubject: null,
+ setSelectedSubject: () => {},
+ clearSelection: () => {}
+});
+
+export function SelectedSubjectProvider({children}) {
+ const [selectedSubject, setSelectedSubject] = useState(null);
+
+ const clearSelection = useCallback(() => {
+ setSelectedSubject(null);
+ }, []);
+
+ const value = useMemo(() => ({
+ selectedSubject,
+ setSelectedSubject,
+ clearSelection
+ }), [selectedSubject, clearSelection]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSelectedSubject(subjectType, subjectId) {
+ const {selectedSubject, setSelectedSubject, clearSelection} = useContext(SelectedSubjectContext);
+
+ const isSelected = selectedSubject &&
+ selectedSubject.subjectType === subjectType &&
+ selectedSubject.subjectId === subjectId;
+
+ const select = useCallback((options) => {
+ setSelectedSubject({subjectType, subjectId, ...options});
+ }, [setSelectedSubject, subjectType, subjectId]);
+
+ return {isSelected, hasSelection: !!selectedSubject, select, clearSelection,
+ showNewForm: isSelected && selectedSubject.showNewForm};
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/images/addComment.svg b/entry_types/scrolled/package/src/frontend/commenting/images/addComment.svg
new file mode 100644
index 0000000000..d9439b2ca8
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/images/addComment.svg
@@ -0,0 +1 @@
+
diff --git a/entry_types/scrolled/package/src/frontend/commenting/images/cancelComment.svg b/entry_types/scrolled/package/src/frontend/commenting/images/cancelComment.svg
new file mode 100644
index 0000000000..072b18eb38
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/images/cancelComment.svg
@@ -0,0 +1 @@
+
diff --git a/entry_types/scrolled/package/src/review/Avatar.js b/entry_types/scrolled/package/src/review/Avatar.js
new file mode 100644
index 0000000000..3ed68f82e7
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/Avatar.js
@@ -0,0 +1,37 @@
+import React from 'react';
+
+import styles from './Avatar.module.css';
+
+export function Avatar({name, className}) {
+ const initial = (name || '?')[0].toUpperCase();
+ const hue = nameToHue(name);
+
+ return (
+
+ {initial}
+
+ );
+}
+
+export function AvatarStack({names}) {
+ const unique = [...new Set(names.filter(Boolean))];
+
+ return (
+
+ {unique.map((name, i) => (
+
+ ))}
+
+ );
+}
+
+function nameToHue(name) {
+ let hash = 0;
+
+ for (let i = 0; i < (name || '').length; i++) {
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
+ }
+
+ return Math.abs(hash) % 360;
+}
diff --git a/entry_types/scrolled/package/src/review/Avatar.module.css b/entry_types/scrolled/package/src/review/Avatar.module.css
new file mode 100644
index 0000000000..ab2777abba
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/Avatar.module.css
@@ -0,0 +1,31 @@
+.avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: space(6);
+ height: space(6);
+ border-radius: rounded(full);
+ background: var(--ui-primary-color);
+ color: var(--ui-on-primary-color);
+ font-size: space(3);
+ font-weight: 600;
+ line-height: 0;
+ flex-shrink: 0;
+}
+
+.avatarStack {
+ display: flex;
+}
+
+.stackedAvatar {
+ width: space(5);
+ height: space(5);
+ font-size: space(2.5);
+ line-height: 0;
+ margin-right: space(-1.5);
+ border: 2px solid var(--ui-surface-color);
+}
+
+.stackedAvatar:last-child {
+ margin-right: 0;
+}
diff --git a/entry_types/scrolled/package/src/review/Comment.js b/entry_types/scrolled/package/src/review/Comment.js
new file mode 100644
index 0000000000..02b7a1aa10
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/Comment.js
@@ -0,0 +1,24 @@
+import React from 'react';
+
+import {Avatar} from './Avatar';
+
+import styles from './Comment.module.css';
+
+export function Comment({comment}) {
+ return (
+
+
+
+
{comment.creatorName}
+ {comment.createdAt &&
+
}
+
+
{comment.body}
+
+ );
+}
+
+function formatDate(isoString) {
+ const date = new Date(isoString);
+ return date.toLocaleDateString('en-US', {month: 'short', day: 'numeric'});
+}
diff --git a/entry_types/scrolled/package/src/review/Comment.module.css b/entry_types/scrolled/package/src/review/Comment.module.css
new file mode 100644
index 0000000000..1d9cb10937
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/Comment.module.css
@@ -0,0 +1,23 @@
+.header {
+ display: flex;
+ align-items: center;
+ gap: space(2);
+ margin-bottom: space(1);
+}
+
+.author {
+ font-weight: 600;
+ color: var(--ui-on-surface-color);
+}
+
+.timestamp {
+ font-size: space(3);
+ color: var(--ui-on-surface-color-light);
+}
+
+.body {
+ margin: 0;
+ padding-left: space(8);
+ line-height: 1.4;
+ color: var(--ui-on-surface-color);
+}
diff --git a/entry_types/scrolled/package/src/review/CommentBadge.js b/entry_types/scrolled/package/src/review/CommentBadge.js
index 3356a38e0f..3bf624b826 100644
--- a/entry_types/scrolled/package/src/review/CommentBadge.js
+++ b/entry_types/scrolled/package/src/review/CommentBadge.js
@@ -1,21 +1,24 @@
import React from 'react';
+import classNames from 'classnames';
import {useCommentThreads} from './ReviewStateProvider';
import CommentIcon from './images/comment.svg';
import styles from './CommentBadge.module.css';
-export function CommentBadge({subjectType, subjectId}) {
+export function CommentBadge({subjectType, subjectId, onClick, active, hidden}) {
const threads = useCommentThreads(subjectType, subjectId);
- if (threads.length === 0) {
+ if (hidden || (threads.length === 0 && !active)) {
return null;
}
return (
-
+
+ {threads.length > 0 && threads.length}
+
);
}
diff --git a/entry_types/scrolled/package/src/review/CommentBadge.module.css b/entry_types/scrolled/package/src/review/CommentBadge.module.css
index caf7fd3d42..14e756ec3a 100644
--- a/entry_types/scrolled/package/src/review/CommentBadge.module.css
+++ b/entry_types/scrolled/package/src/review/CommentBadge.module.css
@@ -7,7 +7,7 @@
border-radius: rounded(full);
background: var(--ui-accent-color);
color: var(--ui-on-accent-color);
- font-family: var(--ui-font-family);
+ font-family: inherit;
font-size: space(3.5);
font-weight: 600;
line-height: 0;
@@ -17,7 +17,8 @@
opacity: 0.9;
}
-.badge:hover {
+.badge:hover,
+.active {
opacity: 1;
}
diff --git a/entry_types/scrolled/package/src/review/NewThreadForm.js b/entry_types/scrolled/package/src/review/NewThreadForm.js
new file mode 100644
index 0000000000..3ab0911cad
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/NewThreadForm.js
@@ -0,0 +1,52 @@
+import React, {useState} from 'react';
+
+import {useI18n} from '../frontend/i18n';
+import {postCreateCommentThreadMessage} from './postMessage';
+import {autoGrow, autoResize} from './autoGrow';
+
+import SendIcon from './images/send.svg';
+import styles from './NewThreadForm.module.css';
+
+export function NewThreadForm({subjectType, subjectId, onSubmit, onCancel}) {
+ const {t} = useI18n({locale: 'ui'});
+ const [body, setBody] = useState('');
+
+ function handleChange(e) {
+ setBody(e.target.value);
+ autoGrow(e.target);
+ }
+
+ function handleSubmit(event) {
+ event.preventDefault();
+ if (!body.trim()) return;
+
+ postCreateCommentThreadMessage({subjectType, subjectId, body});
+ setBody('');
+
+ if (onSubmit) onSubmit();
+ }
+
+ return (
+
+ );
+}
diff --git a/entry_types/scrolled/package/src/review/NewThreadForm.module.css b/entry_types/scrolled/package/src/review/NewThreadForm.module.css
new file mode 100644
index 0000000000..4644f34a03
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/NewThreadForm.module.css
@@ -0,0 +1,59 @@
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: space(2);
+ padding: space(3);
+ background: var(--ui-surface-color);
+ border-radius: rounded(lg);
+ box-shadow: var(--ui-box-shadow);
+}
+
+.input {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ font: inherit;
+ color: var(--ui-on-surface-color);
+ background: var(--ui-surface-color);
+ border: 1px solid var(--ui-on-surface-color-lighter);
+ border-radius: rounded();
+ padding: space(2);
+ outline: none;
+ resize: none;
+ overflow: hidden;
+}
+
+.input:focus {
+ border-color: var(--ui-primary-color);
+ box-shadow: 0 0 0 2px var(--ui-primary-color-lightest);
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: space(2);
+}
+
+.cancelButton {
+ font: inherit;
+ font-weight: 500;
+ color: var(--ui-on-surface-color-light);
+ background: none;
+ border: none;
+ padding: space(1.5) space(3);
+ cursor: pointer;
+}
+
+.submitButton {
+ display: flex;
+ align-items: center;
+ gap: space(1);
+ font: inherit;
+ font-weight: 500;
+ color: var(--ui-on-primary-color);
+ background: var(--ui-primary-color);
+ border: none;
+ border-radius: rounded();
+ padding: space(1.5) space(3);
+ cursor: pointer;
+}
diff --git a/entry_types/scrolled/package/src/review/ReplyForm.js b/entry_types/scrolled/package/src/review/ReplyForm.js
new file mode 100644
index 0000000000..1e27b6ff9c
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/ReplyForm.js
@@ -0,0 +1,49 @@
+import React, {useState} from 'react';
+
+import {useI18n} from '../frontend/i18n';
+import {postCreateCommentMessage} from './postMessage';
+import {autoGrow, autoResize} from './autoGrow';
+
+import SendIcon from './images/send.svg';
+import styles from './ReplyForm.module.css';
+
+export function ReplyForm({threadId}) {
+ const {t} = useI18n({locale: 'ui'});
+ const [body, setBody] = useState('');
+ const hasText = body.trim().length > 0;
+
+ function handleChange(e) {
+ setBody(e.target.value);
+ autoGrow(e.target);
+ }
+
+ function handleSubmit(event) {
+ event.preventDefault();
+ if (!hasText) return;
+
+ postCreateCommentMessage({threadId, body});
+ setBody('');
+ }
+
+ return (
+
+ );
+}
diff --git a/entry_types/scrolled/package/src/review/ReplyForm.module.css b/entry_types/scrolled/package/src/review/ReplyForm.module.css
new file mode 100644
index 0000000000..9231bfaffd
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/ReplyForm.module.css
@@ -0,0 +1,49 @@
+.form {
+ padding-left: space(8);
+}
+
+.input {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ font: inherit;
+ color: var(--ui-on-surface-color);
+ background: var(--ui-surface-color);
+ border: 1px solid var(--ui-on-surface-color-lighter);
+ border-radius: rounded();
+ padding: space(2);
+ outline: none;
+ resize: none;
+ overflow: hidden;
+}
+
+.input:focus {
+ border-color: var(--ui-primary-color);
+ box-shadow: 0 0 0 2px var(--ui-primary-color-lightest);
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: space(1.5);
+}
+
+.hint {
+ color: var(--ui-on-surface-color-light);
+ font-size: space(3);
+}
+
+.submitButton {
+ display: flex;
+ align-items: center;
+ gap: space(1);
+ font: inherit;
+ font-weight: 500;
+ color: var(--ui-on-primary-color);
+ background: var(--ui-primary-color);
+ border: none;
+ border-radius: rounded();
+ padding: space(1.5) space(3);
+ cursor: pointer;
+}
diff --git a/entry_types/scrolled/package/src/review/ReviewMessageHandler.js b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js
index cef2b4d4ce..a916715ced 100644
--- a/entry_types/scrolled/package/src/review/ReviewMessageHandler.js
+++ b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js
@@ -5,6 +5,19 @@ import {
export const ReviewMessageHandler = {
create({session, targetWindow}) {
+ function handleMessage(event) {
+ if (window.location.href.indexOf(event.origin) !== 0) return;
+
+ const {type, payload} = event.data;
+
+ if (type === 'CREATE_COMMENT_THREAD') {
+ session.createThread(payload);
+ }
+ else if (type === 'CREATE_COMMENT') {
+ session.createComment(payload);
+ }
+ }
+
function handleReset(state) {
postReviewStateResetMessage(targetWindow, state);
}
@@ -13,11 +26,13 @@ export const ReviewMessageHandler = {
postReviewStateThreadChangeMessage(targetWindow, thread);
}
+ window.addEventListener('message', handleMessage);
session.on('reset', handleReset);
session.on('change:thread', handleThreadChange);
return {
dispose() {
+ window.removeEventListener('message', handleMessage);
session.off('reset', handleReset);
session.off('change:thread', handleThreadChange);
}
diff --git a/entry_types/scrolled/package/src/review/Thread.js b/entry_types/scrolled/package/src/review/Thread.js
new file mode 100644
index 0000000000..d03d0df43e
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/Thread.js
@@ -0,0 +1,40 @@
+import React from 'react';
+
+import {useI18n} from '../frontend/i18n';
+import {AvatarStack} from './Avatar';
+import {Comment} from './Comment';
+import {ReplyForm} from './ReplyForm';
+
+import ChevronIcon from './images/chevron.svg';
+import styles from './Thread.module.css';
+
+export function Thread({thread, collapsed, onToggle}) {
+ const {t} = useI18n({locale: 'ui'});
+ const firstComment = thread.comments[0];
+ const replies = thread.comments.slice(1);
+
+ return (
+
+ {replies.length > 0 &&
+
}
+
+ {firstComment &&
}
+
+ {collapsed && replies.length > 0 &&
+
}
+
+ {!collapsed && replies.map(comment => (
+
+ ))}
+
+ {(!collapsed || replies.length === 0) &&
}
+
+ );
+}
diff --git a/entry_types/scrolled/package/src/review/Thread.module.css b/entry_types/scrolled/package/src/review/Thread.module.css
new file mode 100644
index 0000000000..e43be41564
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/Thread.module.css
@@ -0,0 +1,53 @@
+.thread {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: space(2);
+ padding: space(3);
+ background: var(--ui-surface-color);
+ border-radius: rounded(lg);
+ box-shadow: var(--ui-box-shadow);
+}
+
+.chevronButton {
+ position: absolute;
+ top: space(3);
+ right: space(4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: space(6);
+ color: var(--ui-on-surface-color-light);
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+}
+
+.chevronButton:hover {
+ color: var(--ui-on-surface-color);
+}
+
+.chevronExpanded {
+ transform: rotate(180deg);
+ transition: transform 0.2s;
+}
+
+.expandButton {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ gap: space(2);
+ padding: 0 0 0 space(8);
+ font: inherit;
+ font-weight: 500;
+ color: var(--ui-primary-color);
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.expandButton:hover {
+ text-decoration: underline;
+}
diff --git a/entry_types/scrolled/package/src/review/ThreadList.js b/entry_types/scrolled/package/src/review/ThreadList.js
new file mode 100644
index 0000000000..abceb86ce5
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/ThreadList.js
@@ -0,0 +1,51 @@
+import React, {useState} from 'react';
+import classNames from 'classnames';
+
+import {useI18n} from '../frontend/i18n';
+import {useCommentThreads} from './ReviewStateProvider';
+import {Thread} from './Thread';
+import {NewThreadForm} from './NewThreadForm';
+
+import NewTopicIcon from './images/newTopic.svg';
+import styles from './ThreadList.module.css';
+
+export function ThreadList({subjectType, subjectId, showNewForm: showNewFormProp, reversed, onDismiss}) {
+ const {t} = useI18n({locale: 'ui'});
+ const threads = useCommentThreads(subjectType, subjectId);
+ const [expandedThreadId, setExpandedThreadId] = useState(null);
+ const [formToggled, setFormToggled] = useState(null);
+ const showNewForm = formToggled !== null ? formToggled : (showNewFormProp || threads.length === 0);
+
+ function toggleThread(threadId) {
+ setExpandedThreadId(expandedThreadId === threadId ? null : threadId);
+ }
+
+ return (
+
+ {!showNewForm &&
+ }
+
+ {showNewForm &&
+ setFormToggled(false)}
+ onCancel={() => {
+ setFormToggled(false);
+ if (threads.length === 0 && onDismiss) onDismiss();
+ }} />}
+
+ {threads.map(thread => (
+ 1 && expandedThreadId !== thread.id}
+ onToggle={() => toggleThread(thread.id)} />
+ ))}
+
+ );
+}
diff --git a/entry_types/scrolled/package/src/review/ThreadList.module.css b/entry_types/scrolled/package/src/review/ThreadList.module.css
new file mode 100644
index 0000000000..35ee2aba97
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/ThreadList.module.css
@@ -0,0 +1,26 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: space(2);
+}
+
+.newTopicButton {
+ display: flex;
+ align-items: center;
+ gap: space(1.5);
+ align-self: flex-start;
+ font: inherit;
+ font-weight: 500;
+ white-space: nowrap;
+ color: var(--ui-on-surface-color);
+ background: var(--ui-surface-color);
+ border: 0;
+ border-radius: rounded();
+ box-shadow: var(--ui-box-shadow);
+ padding: space(1.5) space(2);
+ cursor: pointer;
+}
+
+.reversed {
+ align-self: flex-end;
+}
diff --git a/entry_types/scrolled/package/src/review/autoGrow.js b/entry_types/scrolled/package/src/review/autoGrow.js
new file mode 100644
index 0000000000..72008b06d9
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/autoGrow.js
@@ -0,0 +1,14 @@
+export function autoGrow(el) {
+ el.style.height = 'auto';
+
+ if (el.scrollHeight > 0) {
+ el.style.height = el.scrollHeight + 'px';
+ }
+}
+
+export function autoResize(el) {
+ if (el) {
+ autoGrow(el);
+ }
+}
+
diff --git a/entry_types/scrolled/package/src/review/images/chevron.svg b/entry_types/scrolled/package/src/review/images/chevron.svg
new file mode 100644
index 0000000000..a96f8096bc
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/images/chevron.svg
@@ -0,0 +1 @@
+
diff --git a/entry_types/scrolled/package/src/review/images/newTopic.svg b/entry_types/scrolled/package/src/review/images/newTopic.svg
new file mode 100644
index 0000000000..47df267e27
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/images/newTopic.svg
@@ -0,0 +1 @@
+
diff --git a/entry_types/scrolled/package/src/review/images/send.svg b/entry_types/scrolled/package/src/review/images/send.svg
new file mode 100644
index 0000000000..312e5956cb
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/images/send.svg
@@ -0,0 +1 @@
+
diff --git a/entry_types/scrolled/package/src/review/index.js b/entry_types/scrolled/package/src/review/index.js
index 90a7b4acfe..b2a1b9826c 100644
--- a/entry_types/scrolled/package/src/review/index.js
+++ b/entry_types/scrolled/package/src/review/index.js
@@ -1,3 +1,5 @@
export {ReviewStateProvider, useCommentThreads} from './ReviewStateProvider';
export {ReviewMessageHandler} from './ReviewMessageHandler';
export {CommentBadge} from './CommentBadge';
+export {ThreadList} from './ThreadList';
+export {postCreateCommentThreadMessage} from './postMessage';
diff --git a/entry_types/scrolled/package/src/review/postMessage.js b/entry_types/scrolled/package/src/review/postMessage.js
index 37224c2f13..168f8675d3 100644
--- a/entry_types/scrolled/package/src/review/postMessage.js
+++ b/entry_types/scrolled/package/src/review/postMessage.js
@@ -1,3 +1,17 @@
+export function postCreateCommentThreadMessage({subjectType, subjectId, body}) {
+ window.top.postMessage(
+ {type: 'CREATE_COMMENT_THREAD', payload: {subjectType, subjectId, body}},
+ window.location.origin
+ );
+}
+
+export function postCreateCommentMessage({threadId, body}) {
+ window.top.postMessage(
+ {type: 'CREATE_COMMENT', payload: {threadId, body}},
+ window.location.origin
+ );
+}
+
export function postReviewStateResetMessage(targetWindow, state) {
targetWindow.postMessage(
{type: 'REVIEW_STATE_RESET', payload: state},
diff --git a/entry_types/scrolled/package/src/testHelpers/renderWithReviewState.js b/entry_types/scrolled/package/src/testHelpers/renderWithReviewState.js
new file mode 100644
index 0000000000..b39912a3a7
--- /dev/null
+++ b/entry_types/scrolled/package/src/testHelpers/renderWithReviewState.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import {render, act} from '@testing-library/react';
+
+import {ReviewStateProvider} from '../review/ReviewStateProvider';
+
+export function renderWithReviewState(ui, {commentThreads = [], currentUser = null} = {}) {
+ const result = render(
+
+ {ui}
+
+ );
+
+ if (commentThreads.length > 0 || currentUser) {
+ act(() => {
+ window.dispatchEvent(new MessageEvent('message', {
+ data: {
+ type: 'REVIEW_STATE_RESET',
+ payload: {currentUser, commentThreads}
+ },
+ origin: window.location.origin
+ }));
+ });
+ }
+
+ return result;
+}
diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/entries_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/entries_controller_spec.rb
index 24c401a925..1bba232897 100644
--- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/entries_controller_spec.rb
+++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/entries_controller_spec.rb
@@ -248,6 +248,35 @@ module PageflowScrolled
visible: false
)
end
+
+ it 'renders csrf meta tags when commenting is enabled' do
+ entry = create(:entry, :published, type_name: 'scrolled',
+ with_feature: 'commenting')
+
+ with_forgery_protection do
+ get_with_entry_env(:show, entry:, mode: :preview)
+ end
+
+ expect(response.body).to have_selector('meta[name="csrf-param"]', visible: false)
+ expect(response.body).to have_selector('meta[name="csrf-token"]', visible: false)
+ end
+
+ it 'does not render csrf meta tags when commenting is disabled' do
+ entry = create(:entry, :published, type_name: 'scrolled')
+
+ with_forgery_protection do
+ get_with_entry_env(:show, entry:, mode: :preview)
+ end
+
+ expect(response.body).not_to have_selector('meta[name="csrf-param"]', visible: false)
+ end
+ end
+
+ def with_forgery_protection
+ ActionController::Base.allow_forgery_protection = true
+ yield
+ ensure
+ ActionController::Base.allow_forgery_protection = false
end
end
end
diff --git a/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb b/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb
index 4f49809a9d..308c76d3b7 100644
--- a/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb
+++ b/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb
@@ -1,19 +1,24 @@
require 'spec_helper'
require 'pageflow/dom'
+require 'pageflow/shared_contexts/fake_translations'
require 'support/dominos/editor'
RSpec.feature 'as entry previewer, commenting on content elements', js: true do
+ include_context 'fake translations'
+
+ before do
+ translation('en', 'pageflow_scrolled.review.add_comment', 'Add comment')
+ translation('en', 'pageflow_scrolled.review.select_content_element', 'Select to comment')
+ end
+
scenario 'sees existing comment badge in entry preview' do
entry = create(:entry, :published, type_name: 'scrolled',
- with_feature: 'commenting')
+ with_feature: 'commenting',
+ draft_attributes: {locale: 'en'})
content_element = create(:content_element,
revision: entry.draft,
- type_name: 'textBlock',
- configuration: {
- value: [{type: 'paragraph',
- children: [{text: 'Some text'}]}]
- })
+ type_name: 'inlineImage')
user = Pageflow::Dom::Admin::Page.sign_in_as(:previewer, on: entry)
@@ -30,7 +35,87 @@
visit(pageflow.revision_path(entry.draft))
- expect(page).to have_text('Some text', wait: 10)
+ expect(page).to have_css('[role="status"]', text: '1', wait: 10)
+ end
+
+ scenario 'sees thread comments when clicking badge' do
+ entry = create(:entry, :published, type_name: 'scrolled',
+ with_feature: 'commenting',
+ draft_attributes: {locale: 'en'})
+ content_element = create(:content_element,
+ revision: entry.draft,
+ type_name: 'inlineImage')
+
+ user = Pageflow::Dom::Admin::Page.sign_in_as(:previewer, on: entry)
+
+ create(:comment_thread,
+ revision: entry.draft,
+ creator: user,
+ subject_type: 'ContentElement',
+ subject_id: content_element.perma_id) do |thread|
+ create(:comment,
+ comment_thread: thread,
+ creator: user,
+ body: 'Please review this')
+ end
+
+ visit(pageflow.revision_path(entry.draft))
+
+ find('[role="status"]', text: '1', wait: 10).click
+
+ expect(page).to have_text('Please review this', wait: 10)
+ end
+
+ scenario 'leaves a reply to an existing thread' do
+ entry = create(:entry, :published, type_name: 'scrolled',
+ with_feature: 'commenting',
+ draft_attributes: {locale: 'en'})
+ content_element = create(:content_element,
+ revision: entry.draft,
+ type_name: 'inlineImage')
+
+ user = Pageflow::Dom::Admin::Page.sign_in_as(:previewer, on: entry)
+
+ create(:comment_thread,
+ revision: entry.draft,
+ creator: user,
+ subject_type: 'ContentElement',
+ subject_id: content_element.perma_id) do |thread|
+ create(:comment,
+ comment_thread: thread,
+ creator: user,
+ body: 'Please review this')
+ end
+
+ visit(pageflow.revision_path(entry.draft))
+
+ find('[role="status"]', text: '1', wait: 10).click
+ expect(page).to have_text('Please review this', wait: 10)
+
+ page.find('textarea', match: :first, wait: 10).fill_in(with: 'Looks good!')
+ page.find('button[type="submit"]', match: :first).click
+
+ expect(page).to have_text('Looks good!', wait: 10)
+ end
+
+ scenario 'creates a new comment thread via add comment mode' do
+ entry = create(:entry, :published, type_name: 'scrolled',
+ with_feature: 'commenting',
+ draft_attributes: {locale: 'en'})
+ create(:content_element,
+ revision: entry.draft,
+ type_name: 'inlineImage')
+
+ Pageflow::Dom::Admin::Page.sign_in_as(:previewer, on: entry)
+
+ visit(pageflow.revision_path(entry.draft))
+
+ find('[aria-label="Add comment"]', wait: 10).click
+ find('[aria-label="Select to comment"]', wait: 10).click
+
+ page.find('textarea', match: :first, wait: 10).fill_in(with: 'First comment')
+ page.find('button[type="submit"]', match: :first).click
+
expect(page).to have_css('[role="status"]', text: '1', wait: 10)
end
end
diff --git a/package/spec/review/ReviewSession-spec.js b/package/spec/review/ReviewSession-spec.js
index c33d298576..dfc6e075a9 100644
--- a/package/spec/review/ReviewSession-spec.js
+++ b/package/spec/review/ReviewSession-spec.js
@@ -32,4 +32,85 @@ describe('ReviewSession', () => {
]
});
});
+
+ it('emits change:thread after createThread', async () => {
+ const request = jest.fn()
+ .mockResolvedValueOnce({
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: []
+ })
+ .mockResolvedValueOnce({
+ id: 1,
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ comments: [{id: 100, body: 'Looks good!', creatorId: 42}]
+ });
+
+ const session = new ReviewSession({entryId: 5, request});
+ await session.fetch();
+
+ const listener = jest.fn();
+ session.on('change:thread', listener);
+
+ await session.createThread({
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ body: 'Looks good!'
+ });
+
+ expect(request).toHaveBeenLastCalledWith({
+ url: '/review/entries/5/comment_threads',
+ method: 'POST',
+ payload: {
+ comment_thread: {
+ subject_type: 'ContentElement',
+ subject_id: 10,
+ comment: {body: 'Looks good!'}
+ }
+ }
+ });
+
+ expect(listener).toHaveBeenCalledWith(
+ expect.objectContaining({id: 1, subjectType: 'ContentElement'})
+ );
+ });
+
+ it('emits change:thread with appended comment after createComment', async () => {
+ const request = jest.fn()
+ .mockResolvedValueOnce({
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'CE', subjectId: 10, comments: [
+ {id: 100, body: 'First', creatorId: 42}
+ ]}
+ ]
+ })
+ .mockResolvedValueOnce({
+ id: 101, body: 'Reply', creatorId: 42
+ });
+
+ const session = new ReviewSession({entryId: 5, request});
+ await session.fetch();
+
+ const listener = jest.fn();
+ session.on('change:thread', listener);
+
+ await session.createComment({threadId: 1, body: 'Reply'});
+
+ expect(request).toHaveBeenLastCalledWith({
+ url: '/review/entries/5/comment_threads/1/comments',
+ method: 'POST',
+ payload: {comment: {body: 'Reply'}}
+ });
+
+ expect(listener).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 1,
+ comments: [
+ expect.objectContaining({body: 'First'}),
+ expect.objectContaining({body: 'Reply'})
+ ]
+ })
+ );
+ });
});
diff --git a/package/spec/review/request-spec.js b/package/spec/review/request-spec.js
index cc8b98b8bb..8cbda1277d 100644
--- a/package/spec/review/request-spec.js
+++ b/package/spec/review/request-spec.js
@@ -23,6 +23,33 @@ describe('request', () => {
expect(result).toEqual({some: 'data'});
});
+ it('sends JSON body with CSRF token for POST requests', async () => {
+ window.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({id: 1})
+ });
+
+ const meta = document.createElement('meta');
+ meta.name = 'csrf-token';
+ meta.content = 'test-token';
+ document.head.appendChild(meta);
+
+ await request({url: '/some/path', method: 'POST', payload: {body: 'Hello'}});
+
+ expect(window.fetch).toHaveBeenCalledWith('/some/path', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': 'test-token'
+ },
+ body: '{"body":"Hello"}'
+ });
+
+ document.head.removeChild(meta);
+ });
+
it('throws on non-ok response', async () => {
window.fetch = jest.fn().mockResolvedValue({
ok: false,
diff --git a/package/src/review/ReviewSession.js b/package/src/review/ReviewSession.js
index ab372bd48a..5bdda095b9 100644
--- a/package/src/review/ReviewSession.js
+++ b/package/src/review/ReviewSession.js
@@ -4,6 +4,43 @@ export class ReviewSession {
constructor({entryId, request}) {
this._entryId = entryId;
this._request = request;
+ this._threads = {};
+ }
+
+ async createThread({subjectType, subjectId, body}) {
+ const thread = await this._request({
+ url: `/review/entries/${this._entryId}/comment_threads`,
+ method: 'POST',
+ payload: {
+ comment_thread: {
+ subject_type: subjectType,
+ subject_id: subjectId,
+ comment: {body}
+ }
+ }
+ });
+
+ this._threads[thread.id] = thread;
+ this.trigger('change:thread', thread);
+ }
+
+ async createComment({threadId, body}) {
+ const comment = await this._request({
+ url: `/review/entries/${this._entryId}/comment_threads/${threadId}/comments`,
+ method: 'POST',
+ payload: {comment: {body}}
+ });
+
+ const thread = this._threads[threadId];
+
+ if (thread) {
+ this._threads[threadId] = {
+ ...thread,
+ comments: [...thread.comments, comment]
+ };
+
+ this.trigger('change:thread', this._threads[threadId]);
+ }
}
async fetch() {
@@ -12,6 +49,10 @@ export class ReviewSession {
method: 'GET'
});
+ data.commentThreads.forEach(thread => {
+ this._threads[thread.id] = thread;
+ });
+
this.trigger('reset', {
currentUser: data.currentUser,
commentThreads: data.commentThreads
diff --git a/package/src/review/request.js b/package/src/review/request.js
index 64d5bc56cd..502c229b26 100644
--- a/package/src/review/request.js
+++ b/package/src/review/request.js
@@ -1,11 +1,21 @@
-export async function request({url, method}) {
- const response = await fetch(url, {
+export async function request({url, method, payload}) {
+ const headers = {
+ 'Accept': 'application/json'
+ };
+
+ const options = {
method,
credentials: 'same-origin',
- headers: {
- 'Accept': 'application/json'
- }
- });
+ headers
+ };
+
+ if (payload) {
+ headers['Content-Type'] = 'application/json';
+ headers['X-CSRF-Token'] = getCSRFToken();
+ options.body = JSON.stringify(payload);
+ }
+
+ const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
@@ -13,3 +23,9 @@ export async function request({url, method}) {
return response.json();
}
+
+function getCSRFToken() {
+ const meta = typeof document !== 'undefined' &&
+ document.querySelector('meta[name="csrf-token"]');
+ return meta ? meta.content : '';
+}
diff --git a/spec/controllers/pageflow/review/comment_threads_controller_spec.rb b/spec/controllers/pageflow/review/comment_threads_controller_spec.rb
index 41dc5b1b6f..094f91bde5 100644
--- a/spec/controllers/pageflow/review/comment_threads_controller_spec.rb
+++ b/spec/controllers/pageflow/review/comment_threads_controller_spec.rb
@@ -68,5 +68,62 @@ module Pageflow
expect(response.status).to eq(403)
end
end
+
+ describe '#create' do
+ it 'creates thread with first comment' do
+ user = create(:user)
+ entry = create(:entry, with_previewer: user)
+
+ sign_in(user, scope: :user)
+ post(:create, params: {
+ entry_id: entry.id,
+ comment_thread: {
+ subject_type: 'ContentElement',
+ subject_id: 5,
+ comment: {body: 'Looks good!'}
+ }
+ }, format: 'json')
+
+ expect(response.status).to eq(201)
+ expect(response.body).to include_json(
+ subjectType: 'ContentElement',
+ subjectId: 5,
+ creatorId: user.id,
+ comments: [{body: 'Looks good!', creatorId: user.id}]
+ )
+ end
+
+ it 'requires user to be signed in' do
+ entry = create(:entry)
+
+ post(:create, params: {
+ entry_id: entry.id,
+ comment_thread: {
+ subject_type: 'ContentElement',
+ subject_id: 5,
+ comment: {body: 'Test'}
+ }
+ }, format: 'json')
+
+ expect(response.status).to eq(401)
+ end
+
+ it 'requires read permission on entry' do
+ user = create(:user)
+ entry = create(:entry)
+
+ sign_in(user, scope: :user)
+ post(:create, params: {
+ entry_id: entry.id,
+ comment_thread: {
+ subject_type: 'ContentElement',
+ subject_id: 5,
+ comment: {body: 'Test'}
+ }
+ }, format: 'json')
+
+ expect(response.status).to eq(403)
+ end
+ end
end
end
diff --git a/spec/controllers/pageflow/review/comments_controller_spec.rb b/spec/controllers/pageflow/review/comments_controller_spec.rb
new file mode 100644
index 0000000000..44ac20521c
--- /dev/null
+++ b/spec/controllers/pageflow/review/comments_controller_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+module Pageflow
+ describe Review::CommentsController do
+ routes { Engine.routes }
+ render_views
+
+ describe '#create' do
+ it 'creates comment on thread' do
+ user = create(:user)
+ entry = create(:entry, with_previewer: user)
+ thread = create(:comment_thread, revision: entry.draft, creator: user)
+
+ sign_in(user, scope: :user)
+ post(:create, params: {
+ entry_id: entry.id,
+ comment_thread_id: thread.id,
+ comment: {body: 'A reply'}
+ }, format: 'json')
+
+ expect(response.status).to eq(201)
+ expect(response.body).to include_json(
+ body: 'A reply',
+ creatorId: user.id
+ )
+ end
+
+ it 'requires user to be signed in' do
+ entry = create(:entry)
+ thread = create(:comment_thread, revision: entry.draft)
+
+ post(:create, params: {
+ entry_id: entry.id,
+ comment_thread_id: thread.id,
+ comment: {body: 'Test'}
+ }, format: 'json')
+
+ expect(response.status).to eq(401)
+ end
+
+ it 'requires read permission on entry' do
+ user = create(:user)
+ entry = create(:entry)
+ thread = create(:comment_thread, revision: entry.draft)
+
+ sign_in(user, scope: :user)
+ post(:create, params: {
+ entry_id: entry.id,
+ comment_thread_id: thread.id,
+ comment: {body: 'Test'}
+ }, format: 'json')
+
+ expect(response.status).to eq(403)
+ end
+ end
+ end
+end