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 ( +
+
+ ); +} 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 ( - + ); } 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 ( +
+