From d3c056152cb241c2e8871da2db87d524eae8f3c3 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 7 Apr 2026 16:34:55 +0200 Subject: [PATCH 1/9] Show thread comments in bubble on badge click Clicking the comment badge on a content element opens a bubble displaying all threads with their comments. The ThreadList component renders author names and comment text, filtered by subject. REDMINE-21261 --- .../frontend/features/commentingMode-spec.js | 34 +++++++++- .../package/spec/review/CommentBadge-spec.js | 39 ++++------- .../package/spec/review/ThreadList-spec.js | 65 +++++++++++++++++++ .../commenting/ContentElementDecorator.js | 27 ++++++-- .../ContentElementDecorator.module.css | 19 ++++++ .../package/src/review/CommentBadge.js | 8 ++- .../scrolled/package/src/review/ThreadList.js | 36 ++++++++++ .../package/src/review/ThreadList.module.css | 31 +++++++++ .../scrolled/package/src/review/index.js | 1 + .../src/testHelpers/renderWithReviewState.js | 26 ++++++++ .../commenting_on_content_elements_spec.rb | 31 +++++++++ 11 files changed, 281 insertions(+), 36 deletions(-) create mode 100644 entry_types/scrolled/package/spec/review/ThreadList-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.module.css create mode 100644 entry_types/scrolled/package/src/review/ThreadList.js create mode 100644 entry_types/scrolled/package/src/review/ThreadList.module.css create mode 100644 entry_types/scrolled/package/src/testHelpers/renderWithReviewState.js diff --git a/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js b/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js index 5e9697c9cc..adbfecf11c 100644 --- a/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/commentingMode-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'; @@ -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/ThreadList-spec.js b/entry_types/scrolled/package/spec/review/ThreadList-spec.js new file mode 100644 index 0000000000..5c397ac573 --- /dev/null +++ b/entry_types/scrolled/package/spec/review/ThreadList-spec.js @@ -0,0 +1,65 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; + +import {ThreadList} from 'review/ThreadList'; +import {renderWithReviewState} from 'testHelpers/renderWithReviewState'; + +describe('ThreadList', () => { + 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 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(); + }); +}); diff --git a/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js index 61ade27f92..f18ee69664 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 React, {useCallback, useState} from 'react'; -import {CommentBadge} from 'pageflow-scrolled/review'; +import {CommentBadge, ThreadList} from 'pageflow-scrolled/review'; + +import styles from './ContentElementDecorator.module.css'; export function ContentElementDecorator({permaId, children}) { + const [open, setOpen] = useState(false); + + const handleBadgeClick = useCallback(() => { + setOpen(prev => !prev); + }, []); + return ( - <> +
{children} - - +
+ + {open && +
+ +
} +
+
); } 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..c6dad0d66f --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.module.css @@ -0,0 +1,19 @@ +.wrapper { + position: relative; +} + +.overlay { + position: absolute; + top: 0; + right: 0; + z-index: 10; + font-family: var(--ui-font-family); + font-size: var(--ui-font-size); + color: var(--ui-on-surface-color); +} + +.bubble { + min-width: space(72); + max-width: space(96); + margin-top: space(2); +} diff --git a/entry_types/scrolled/package/src/review/CommentBadge.js b/entry_types/scrolled/package/src/review/CommentBadge.js index 3356a38e0f..3a8984e823 100644 --- a/entry_types/scrolled/package/src/review/CommentBadge.js +++ b/entry_types/scrolled/package/src/review/CommentBadge.js @@ -5,7 +5,7 @@ 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}) { const threads = useCommentThreads(subjectType, subjectId); if (threads.length === 0) { @@ -13,9 +13,11 @@ export function CommentBadge({subjectType, subjectId}) { } return ( - + ); } 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..9253cfa088 --- /dev/null +++ b/entry_types/scrolled/package/src/review/ThreadList.js @@ -0,0 +1,36 @@ +import React from 'react'; + +import {useCommentThreads} from './ReviewStateProvider'; + +import styles from './ThreadList.module.css'; + +export function ThreadList({subjectType, subjectId}) { + const threads = useCommentThreads(subjectType, subjectId); + + return ( +
+ {threads.map(thread => ( + + ))} +
+ ); +} + +function Thread({thread}) { + return ( +
+ {thread.comments.map(comment => ( + + ))} +
+ ); +} + +function Comment({comment}) { + return ( +
+
{comment.creatorName}
+
{comment.body}
+
+ ); +} 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..3533a462b2 --- /dev/null +++ b/entry_types/scrolled/package/src/review/ThreadList.module.css @@ -0,0 +1,31 @@ +.container { + display: flex; + flex-direction: column; + gap: space(2); +} + +.thread { + 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); +} + +.comment { + display: flex; + flex-direction: column; + gap: space(1); +} + +.commentAuthor { + font-weight: 600; + font-size: var(--ui-font-size); +} + +.commentBody { + font-size: var(--ui-font-size); + line-height: 1.4; +} diff --git a/entry_types/scrolled/package/src/review/index.js b/entry_types/scrolled/package/src/review/index.js index 90a7b4acfe..a50edc70e5 100644 --- a/entry_types/scrolled/package/src/review/index.js +++ b/entry_types/scrolled/package/src/review/index.js @@ -1,3 +1,4 @@ export {ReviewStateProvider, useCommentThreads} from './ReviewStateProvider'; export {ReviewMessageHandler} from './ReviewMessageHandler'; export {CommentBadge} from './CommentBadge'; +export {ThreadList} from './ThreadList'; 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/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..e616513f11 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 @@ -33,4 +33,35 @@ 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') + content_element = create(:content_element, + revision: entry.draft, + type_name: 'textBlock', + configuration: { + value: [{type: 'paragraph', + children: [{text: 'Some text'}]}] + }) + + 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 end From a58588d091271fcbb1c68e1b332d1b039cdf0735 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 7 Apr 2026 17:02:46 +0200 Subject: [PATCH 2/9] Add avatars, timestamps and thread collapsing Threads with replies collapse when multiple threads exist, showing reply count with an avatar stack of unique authors. Clicking expands to reveal all replies. Comments display author avatar with hue derived from name, formatted date, and body text indented past the avatar. REDMINE-21261 --- entry_types/scrolled/config/locales/de.yml | 3 + entry_types/scrolled/config/locales/en.yml | 3 + .../package/spec/review/ThreadList-spec.js | 120 ++++++++++++++++++ .../scrolled/package/src/review/Avatar.js | 37 ++++++ .../package/src/review/Avatar.module.css | 31 +++++ .../scrolled/package/src/review/Comment.js | 24 ++++ .../package/src/review/Comment.module.css | 23 ++++ .../src/review/CommentBadge.module.css | 2 +- .../scrolled/package/src/review/Thread.js | 27 ++++ .../package/src/review/Thread.module.css | 28 ++++ .../scrolled/package/src/review/ThreadList.js | 32 ++--- .../package/src/review/ThreadList.module.css | 26 ---- 12 files changed, 308 insertions(+), 48 deletions(-) create mode 100644 entry_types/scrolled/package/src/review/Avatar.js create mode 100644 entry_types/scrolled/package/src/review/Avatar.module.css create mode 100644 entry_types/scrolled/package/src/review/Comment.js create mode 100644 entry_types/scrolled/package/src/review/Comment.module.css create mode 100644 entry_types/scrolled/package/src/review/Thread.js create mode 100644 entry_types/scrolled/package/src/review/Thread.module.css diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index a26803be37..7b1fa9d1a0 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1913,3 +1913,6 @@ de: expose_motif_area: Motiv freilegen review: toolbar_label: Kommentare + 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..46f77f445f 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1742,3 +1742,6 @@ en: expose_motif_area: Expose motif review: toolbar_label: Comments + reply_count: + one: 1 reply + other: '%{count} replies' diff --git a/entry_types/scrolled/package/spec/review/ThreadList-spec.js b/entry_types/scrolled/package/spec/review/ThreadList-spec.js index 5c397ac573..59c6f56049 100644 --- a/entry_types/scrolled/package/spec/review/ThreadList-spec.js +++ b/entry_types/scrolled/package/spec/review/ThreadList-spec.js @@ -1,10 +1,15 @@ import React from 'react'; import '@testing-library/jest-dom/extend-expect'; +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' + }); it('displays comments of threads for subject', () => { const {getByText} = renderWithReviewState( , @@ -44,6 +49,121 @@ describe('ThreadList', () => { 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('expands collapsed thread on click', () => { + 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(); + + getByText('1 reply').click(); + + expect(getByText('Hidden reply')).toBeInTheDocument(); + }); + it('displays multiple threads', () => { const {getByText} = renderWithReviewState( , 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.module.css b/entry_types/scrolled/package/src/review/CommentBadge.module.css index caf7fd3d42..fe7d1f30f4 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; 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..d024db9cd7 --- /dev/null +++ b/entry_types/scrolled/package/src/review/Thread.js @@ -0,0 +1,27 @@ +import React from 'react'; + +import {useI18n} from '../frontend/i18n'; +import {AvatarStack} from './Avatar'; +import {Comment} from './Comment'; + +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 ( +
+ {firstComment && } + {collapsed && replies.length > 0 && + } + {!collapsed && replies.map(comment => ( + + ))} +
+ ); +} 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..2e444c5d27 --- /dev/null +++ b/entry_types/scrolled/package/src/review/Thread.module.css @@ -0,0 +1,28 @@ +.thread { + 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); +} + +.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 index 9253cfa088..d397a38d61 100644 --- a/entry_types/scrolled/package/src/review/ThreadList.js +++ b/entry_types/scrolled/package/src/review/ThreadList.js @@ -1,36 +1,26 @@ -import React from 'react'; +import React, {useState} from 'react'; import {useCommentThreads} from './ReviewStateProvider'; +import {Thread} from './Thread'; import styles from './ThreadList.module.css'; export function ThreadList({subjectType, subjectId}) { const threads = useCommentThreads(subjectType, subjectId); + const [expandedThreadId, setExpandedThreadId] = useState(null); + + function toggleThread(threadId) { + setExpandedThreadId(expandedThreadId === threadId ? null : threadId); + } return (
{threads.map(thread => ( - - ))} -
- ); -} - -function Thread({thread}) { - return ( -
- {thread.comments.map(comment => ( - + 1 && expandedThreadId !== thread.id} + onToggle={() => toggleThread(thread.id)} /> ))}
); } - -function Comment({comment}) { - return ( -
-
{comment.creatorName}
-
{comment.body}
-
- ); -} diff --git a/entry_types/scrolled/package/src/review/ThreadList.module.css b/entry_types/scrolled/package/src/review/ThreadList.module.css index 3533a462b2..18ce560d51 100644 --- a/entry_types/scrolled/package/src/review/ThreadList.module.css +++ b/entry_types/scrolled/package/src/review/ThreadList.module.css @@ -3,29 +3,3 @@ flex-direction: column; gap: space(2); } - -.thread { - 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); -} - -.comment { - display: flex; - flex-direction: column; - gap: space(1); -} - -.commentAuthor { - font-weight: 600; - font-size: var(--ui-font-size); -} - -.commentBody { - font-size: var(--ui-font-size); - line-height: 1.4; -} From d3258594bff788a6a94d138a826d3f852c85b841 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 7 Apr 2026 17:34:00 +0200 Subject: [PATCH 3/9] Allow creating comment threads via API Previewers can create a new comment thread with an initial comment on a content element. The ReviewSession posts to the API, the ReviewMessageHandler routes the postMessage, and the NewThreadForm collects user input. REDMINE-21261 --- .../review/comment_threads_controller.rb | 22 +++++++ .../comment_threads/create.json.jbuilder | 2 + config/routes.rb | 2 +- entry_types/scrolled/config/locales/de.yml | 2 + entry_types/scrolled/config/locales/en.yml | 2 + .../spec/review/ReviewMessageHandler-spec.js | 25 +++++++- .../package/spec/review/ThreadList-spec.js | 36 +++++++++++- .../package/src/review/NewThreadForm.js | 36 ++++++++++++ .../src/review/NewThreadForm.module.css | 35 ++++++++++++ .../src/review/ReviewMessageHandler.js | 12 ++++ .../scrolled/package/src/review/ThreadList.js | 3 + .../scrolled/package/src/review/index.js | 1 + .../package/src/review/postMessage.js | 7 +++ .../commenting_on_content_elements_spec.rb | 31 ++++++++++ package/spec/review/ReviewSession-spec.js | 42 ++++++++++++++ package/src/review/ReviewSession.js | 16 ++++++ package/src/review/request.js | 21 +++++-- .../review/comment_threads_controller_spec.rb | 57 +++++++++++++++++++ 18 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 app/views/pageflow/review/comment_threads/create.json.jbuilder create mode 100644 entry_types/scrolled/package/src/review/NewThreadForm.js create mode 100644 entry_types/scrolled/package/src/review/NewThreadForm.module.css 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/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/config/routes.rb b/config/routes.rb index 3490e675d0..6d529b4ab7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -79,7 +79,7 @@ namespace :review do resources :entries, only: [] do - resources :comment_threads, only: [:index] + resources :comment_threads, only: [:index, :create] end end diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 7b1fa9d1a0..b9794ee23b 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1913,6 +1913,8 @@ de: expose_motif_area: Motiv freilegen review: toolbar_label: Kommentare + add_comment_placeholder: Kommentar hinzufügen... + submit: Absenden 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 46f77f445f..3ada376447 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1742,6 +1742,8 @@ en: expose_motif_area: Expose motif review: toolbar_label: Comments + add_comment_placeholder: Add a comment... + submit: Submit reply_count: one: 1 reply other: '%{count} replies' diff --git a/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js index cb9cdb49a8..81adc1baa1 100644 --- a/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js +++ b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js @@ -3,12 +3,35 @@ import BackboneEvents from 'backbone-events-standalone'; import {ReviewMessageHandler} from 'review/ReviewMessageHandler'; function fakeReviewSession() { - const session = {}; + const session = { + createThread: 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('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 index 59c6f56049..fc5fbceb50 100644 --- a/entry_types/scrolled/package/spec/review/ThreadList-spec.js +++ b/entry_types/scrolled/package/spec/review/ThreadList-spec.js @@ -1,5 +1,6 @@ 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'; @@ -8,7 +9,9 @@ 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.reply_count.other': '%{count} replies', + 'pageflow_scrolled.review.add_comment_placeholder': 'Add a comment...', + 'pageflow_scrolled.review.submit': 'Submit' }); it('displays comments of threads for subject', () => { const {getByText} = renderWithReviewState( @@ -141,7 +144,8 @@ describe('ThreadList', () => { expect(getAllByText('E')).toHaveLength(1); }); - it('expands collapsed thread on click', () => { + it('expands collapsed thread on click', async () => { + const user = userEvent.setup(); const {getByText, queryByText} = renderWithReviewState( , { @@ -159,7 +163,7 @@ describe('ThreadList', () => { expect(queryByText('Hidden reply')).not.toBeInTheDocument(); - getByText('1 reply').click(); + await user.click(getByText('1 reply')); expect(getByText('Hidden reply')).toBeInTheDocument(); }); @@ -182,4 +186,30 @@ describe('ThreadList', () => { 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: 'Submit'})); + + expect(postMessage).toHaveBeenCalledWith( + { + type: 'CREATE_COMMENT_THREAD', + payload: { + subjectType: 'ContentElement', + subjectId: 10, + body: 'New thread' + } + }, + window.location.origin + ); + + postMessage.mockRestore(); + }); }); 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..cbae18e9b5 --- /dev/null +++ b/entry_types/scrolled/package/src/review/NewThreadForm.js @@ -0,0 +1,36 @@ +import React, {useState} from 'react'; + +import {useI18n} from '../frontend/i18n'; +import {postCreateCommentThreadMessage} from './postMessage'; + +import styles from './NewThreadForm.module.css'; + +export function NewThreadForm({subjectType, subjectId, onSubmit}) { + const {t} = useI18n({locale: 'ui'}); + const [body, setBody] = useState(''); + + function handleSubmit(event) { + event.preventDefault(); + if (!body.trim()) return; + + postCreateCommentThreadMessage({subjectType, subjectId, body}); + setBody(''); + + if (onSubmit) onSubmit(); + } + + return ( +
+