diff --git a/.rubocop.yml b/.rubocop.yml index ffd1a224d4..dc5dc41f59 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -173,6 +173,10 @@ Lint/UselessConstantScoping: Gemspec/DevelopmentDependencies: Enabled: false +# Allow I18n-style %{var} tokens +Style/FormatStringToken: + Mode: conservative + # Ignore empty specs and factory traits Lint/EmptyBlock: Exclude: diff --git a/app/controllers/pageflow/review/comment_threads_controller.rb b/app/controllers/pageflow/review/comment_threads_controller.rb index 968d421e26..69b82517b5 100644 --- a/app/controllers/pageflow/review/comment_threads_controller.rb +++ b/app/controllers/pageflow/review/comment_threads_controller.rb @@ -28,6 +28,21 @@ def create render :create, status: :created end + def update + entry = DraftEntry.find(params[:entry_id]) + authorize!(:read, entry.to_model) + + @comment_thread = entry.comment_threads.find(params[:id]) + + if ActiveModel::Type::Boolean.new.cast(params[:comment_thread][:resolved]) + @comment_thread.resolve(current_user) + else + @comment_thread.unresolve + end + + render :create + end + private def thread_params diff --git a/app/models/pageflow/comment_thread.rb b/app/models/pageflow/comment_thread.rb index c29f529ef0..e62faa8731 100644 --- a/app/models/pageflow/comment_thread.rb +++ b/app/models/pageflow/comment_thread.rb @@ -4,10 +4,19 @@ class CommentThread < ApplicationRecord include RevisionComponent belongs_to :creator, class_name: 'User' + belongs_to :resolver, class_name: 'User', foreign_key: :resolved_by_id, optional: true has_many :comments, dependent: :destroy nested_revision_components :comments validates :subject_type, :subject_id, presence: true + + def resolve(user) + update!(resolved_at: Time.current, resolver: user) unless resolved_at + end + + def unresolve + update!(resolved_at: nil, resolver: nil) + end end end diff --git a/config/routes.rb b/config/routes.rb index 0f7aed0b8f..f788acef9c 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, :create] do + resources :comment_threads, only: [:index, :create, :update] do resources :comments, only: [:create] end end diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 5347704b06..2ff13ccec4 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1922,6 +1922,11 @@ de: send: Senden enter_for_new_line: Enter für neue Zeile toggle_replies: Antworten umschalten + resolve: Als gelöst markieren + unresolve: Als ungelöst markieren + resolved_count: + one: 1 erledigt + other: '%{count} erledigt' 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 68d3b07134..40a0ee16d7 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1751,6 +1751,11 @@ en: send: Send enter_for_new_line: Enter for new line toggle_replies: Toggle replies + resolve: Mark as resolved + unresolve: Mark as unresolved + resolved_count: + one: 1 resolved + other: '%{count} resolved' reply_count: one: 1 reply other: '%{count} replies' diff --git a/entry_types/scrolled/package/spec/review/CommentBadge-spec.js b/entry_types/scrolled/package/spec/review/CommentBadge-spec.js index 187c17d058..3704948935 100644 --- a/entry_types/scrolled/package/spec/review/CommentBadge-spec.js +++ b/entry_types/scrolled/package/spec/review/CommentBadge-spec.js @@ -33,6 +33,34 @@ describe('CommentBadge', () => { expect(getByRole('status')).toHaveTextContent('2'); }); + it('only counts unresolved threads', () => { + const {getByRole} = renderWithReviewState( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, resolvedAt: null, comments: []}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, resolvedAt: null, comments: []}, + {id: 3, subjectType: 'ContentElement', subjectId: 10, resolvedAt: '2026-04-09T10:00:00Z', comments: []} + ] + } + ); + + expect(getByRole('status')).toHaveTextContent('2'); + }); + + it('renders nothing when all threads are resolved', () => { + const {container} = renderWithReviewState( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, resolvedAt: '2026-04-09T10:00:00Z', comments: []} + ] + } + ); + + expect(container).toBeEmptyDOMElement(); + }); + it('renders nothing when no threads exist for subject', () => { const {container} = renderWithReviewState( diff --git a/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js index 98591fd1a2..6c2862adaa 100644 --- a/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js +++ b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js @@ -5,7 +5,8 @@ import {ReviewMessageHandler} from 'review/ReviewMessageHandler'; function fakeReviewSession() { const session = { createThread: jest.fn().mockResolvedValue(), - createComment: jest.fn().mockResolvedValue() + createComment: jest.fn().mockResolvedValue(), + updateThread: jest.fn().mockResolvedValue() }; Object.assign(session, BackboneEvents); @@ -91,6 +92,27 @@ describe('ReviewMessageHandler', () => { window.postMessage.mockRestore(); }); + it('calls session.updateThread on UPDATE_THREAD message from targetWindow', async () => { + const session = fakeReviewSession(); + + ReviewMessageHandler.create({session, targetWindow: window}); + + window.dispatchEvent(new MessageEvent('message', { + data: { + type: 'UPDATE_THREAD', + payload: {threadId: 1, resolved: true} + }, + origin: window.location.origin, + source: window + })); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(session.updateThread).toHaveBeenCalledWith({ + threadId: 1, resolved: true + }); + }); + it('ignores messages not from targetWindow', async () => { const session = fakeReviewSession(); const iframeWindow = {}; diff --git a/entry_types/scrolled/package/spec/review/ThreadList-spec.js b/entry_types/scrolled/package/spec/review/ThreadList-spec.js index f6f2b91f49..53c1f3d008 100644 --- a/entry_types/scrolled/package/spec/review/ThreadList-spec.js +++ b/entry_types/scrolled/package/spec/review/ThreadList-spec.js @@ -16,7 +16,11 @@ describe('ThreadList', () => { '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' + 'pageflow_scrolled.review.toggle_replies': 'Toggle replies', + 'pageflow_scrolled.review.resolve': 'Mark as resolved', + 'pageflow_scrolled.review.unresolve': 'Mark as unresolved', + 'pageflow_scrolled.review.resolved_count.one': '1 resolved', + 'pageflow_scrolled.review.resolved_count.other': '%{count} resolved' }); it('displays comments of threads for subject', () => { const {getByText} = renderWithReviewState( @@ -389,6 +393,127 @@ describe('ThreadList', () => { expect(getByRole('button', {name: 'Send'})).toBeInTheDocument(); }); + it('hides resolved threads and shows resolved count pill', () => { + const {queryByText, getByText} = renderWithReviewState( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: '2026-04-09T10:00:00Z', + comments: [{id: 10, body: 'Resolved thread', creatorName: 'Bob', creatorId: 2}]}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: null, + comments: [{id: 20, body: 'Active thread', creatorName: 'Alice', creatorId: 1}]} + ] + } + ); + + expect(getByText('Active thread')).toBeInTheDocument(); + expect(queryByText('Resolved thread')).not.toBeInTheDocument(); + expect(getByText('1 resolved')).toBeInTheDocument(); + }); + + it('toggles resolved threads when pill is clicked', async () => { + const user = userEvent.setup(); + + const {getByText, queryByText} = renderWithReviewState( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: '2026-04-09T10:00:00Z', + comments: [{id: 10, body: 'Resolved thread', creatorName: 'Bob', creatorId: 2}]}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: null, + comments: [{id: 20, body: 'Active thread', creatorName: 'Alice', creatorId: 1}]} + ] + } + ); + + await user.click(getByText('1 resolved')); + expect(getByText('Resolved thread')).toBeInTheDocument(); + expect(getByText('1 resolved')).toBeInTheDocument(); + + await user.click(getByText('1 resolved')); + expect(queryByText('Resolved thread')).not.toBeInTheDocument(); + }); + + it('posts resolve message when resolve button is clicked', async () => { + const user = userEvent.setup(); + const postMessage = jest.spyOn(window.top, 'postMessage').mockImplementation(() => {}); + + const {getByText} = renderWithReviewState( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: null, + comments: [{id: 10, body: 'Open thread', creatorName: 'Bob', creatorId: 2}]} + ] + } + ); + + await user.click(getByText('Mark as resolved')); + + expect(postMessage).toHaveBeenCalledWith( + {type: 'UPDATE_THREAD', payload: {threadId: 1, resolved: true}}, + window.location.origin + ); + + postMessage.mockRestore(); + }); + + it('does not show resolve button on collapsed threads with replies', () => { + const {queryByText} = renderWithReviewState( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: null, + comments: [ + {id: 10, body: 'First thread', creatorName: 'Bob', creatorId: 2}, + {id: 11, body: 'Reply', creatorName: 'Alice', creatorId: 1} + ]}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: null, + comments: [ + {id: 20, body: 'Second thread', creatorName: 'Alice', creatorId: 1}, + {id: 21, body: 'Reply', creatorName: 'Bob', creatorId: 2} + ]} + ] + } + ); + + expect(queryByText('Mark as resolved')).not.toBeInTheDocument(); + }); + + it('does not show reply form on resolved threads', async () => { + const user = userEvent.setup(); + + const {queryByPlaceholderText, getByText} = renderWithReviewState( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: null, + comments: [{id: 10, body: 'Active thread', creatorName: 'Bob', creatorId: 2}]}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: '2026-04-09T10:00:00Z', + comments: [{id: 20, body: 'Resolved thread', creatorName: 'Alice', creatorId: 1}]} + ] + } + ); + + await user.click(getByText('1 resolved')); + + const replyFields = queryByPlaceholderText('Reply...'); + expect(replyFields).toBeInTheDocument(); + + expect(getByText('Resolved thread')).toBeInTheDocument(); + const resolvedThread = getByText('Resolved thread').closest('[class*="thread"]'); + expect(resolvedThread.querySelector('textarea[placeholder="Reply..."]')).toBeNull(); + }); + it('shows reply form in collapsed thread without replies', () => { const {getAllByPlaceholderText} = renderWithReviewState( , diff --git a/entry_types/scrolled/package/src/review/CommentBadge.js b/entry_types/scrolled/package/src/review/CommentBadge.js index dbdc49c2fb..57b793f373 100644 --- a/entry_types/scrolled/package/src/review/CommentBadge.js +++ b/entry_types/scrolled/package/src/review/CommentBadge.js @@ -8,7 +8,8 @@ import styles from './CommentBadge.module.css'; export function CommentBadge({subjectType, subjectId, onClick, mode}) { const threads = useCommentThreads(subjectType, subjectId); - const hasThreads = threads.length > 0; + const unresolvedCount = threads.filter(t => !t.resolvedAt).length; + const hasThreads = unresolvedCount > 0; const variant = resolveVariant(mode, hasThreads); @@ -21,7 +22,7 @@ export function CommentBadge({subjectType, subjectId, onClick, mode}) { className={classNames(styles.badge, styles[variant])} onClick={onClick}> {variant !== 'dot' && } - {(variant === 'active' || variant === 'expanded') && threads.length > 1 ? threads.length : null} + {(variant === 'active' || variant === 'expanded') && unresolvedCount > 1 ? unresolvedCount : null} ); } diff --git a/entry_types/scrolled/package/src/review/ReviewMessageHandler.js b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js index cee34975b8..81cd63eead 100644 --- a/entry_types/scrolled/package/src/review/ReviewMessageHandler.js +++ b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js @@ -17,6 +17,9 @@ export const ReviewMessageHandler = { else if (type === 'CREATE_COMMENT') { session.createComment(payload); } + else if (type === 'UPDATE_THREAD') { + session.updateThread(payload); + } } function handleReset(state) { diff --git a/entry_types/scrolled/package/src/review/Thread.js b/entry_types/scrolled/package/src/review/Thread.js index d03d0df43e..9b50823bdc 100644 --- a/entry_types/scrolled/package/src/review/Thread.js +++ b/entry_types/scrolled/package/src/review/Thread.js @@ -6,12 +6,15 @@ import {Comment} from './Comment'; import {ReplyForm} from './ReplyForm'; import ChevronIcon from './images/chevron.svg'; +import ResolveIcon from './images/resolve.svg'; +import UnresolveIcon from './images/unresolve.svg'; import styles from './Thread.module.css'; -export function Thread({thread, collapsed, onToggle}) { +export function Thread({thread, collapsed, onToggle, onResolve}) { const {t} = useI18n({locale: 'ui'}); const firstComment = thread.comments[0]; const replies = thread.comments.slice(1); + const repliesCollapsed = collapsed && replies.length > 0; return (
@@ -24,7 +27,7 @@ export function Thread({thread, collapsed, onToggle}) { {firstComment && } - {collapsed && replies.length > 0 && + {repliesCollapsed && +
} ); } diff --git a/entry_types/scrolled/package/src/review/Thread.module.css b/entry_types/scrolled/package/src/review/Thread.module.css index c40d396e1d..35ce2cfe0d 100644 --- a/entry_types/scrolled/package/src/review/Thread.module.css +++ b/entry_types/scrolled/package/src/review/Thread.module.css @@ -52,3 +52,28 @@ .expandButton:hover { text-decoration: underline; } + +.resolveRow { + display: flex; + justify-content: center; + margin-top: space(2); + padding-top: space(2); + border-top: 1px solid var(--ui-on-surface-color-lightest); +} + +.resolveButton { + font: inherit; + font-weight: 500; + display: flex; + align-items: center; + gap: space(1); + color: var(--ui-on-surface-color-light); + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +.resolveButton:hover { + color: var(--ui-primary-color); +} diff --git a/entry_types/scrolled/package/src/review/ThreadList.js b/entry_types/scrolled/package/src/review/ThreadList.js index c66f954ca3..a13a3bfd99 100644 --- a/entry_types/scrolled/package/src/review/ThreadList.js +++ b/entry_types/scrolled/package/src/review/ThreadList.js @@ -5,16 +5,23 @@ import {useI18n} from '../frontend/i18n'; import {useCommentThreads} from './ReviewStateProvider'; import {Thread} from './Thread'; import {NewThreadForm} from './NewThreadForm'; +import {postUpdateThreadMessage} from './postMessage'; +import ChevronIcon from './images/chevron.svg'; import NewTopicIcon from './images/newTopic.svg'; import styles from './ThreadList.module.css'; export function ThreadList({subjectType, subjectId, showNewForm: showNewFormProp, reversed, onDismiss, newTopicButtonClassName}) { const {t} = useI18n({locale: 'ui'}); const threads = useCommentThreads(subjectType, subjectId); + + const activeThreads = threads.filter(thread => !thread.resolvedAt); + const resolvedThreads = threads.filter(thread => thread.resolvedAt); + const [expandedThreadId, setExpandedThreadId] = useState(null); const [formToggled, setFormToggled] = useState(null); - const showNewForm = formToggled !== null ? formToggled : (showNewFormProp || threads.length === 0); + const [showResolved, setShowResolved] = useState(false); + const showNewForm = formToggled !== null ? formToggled : (showNewFormProp || activeThreads.length === 0); function toggleThread(threadId) { setExpandedThreadId(expandedThreadId === threadId ? null : threadId); @@ -38,15 +45,34 @@ export function ThreadList({subjectType, subjectId, showNewForm: showNewFormProp onSubmit={() => setFormToggled(false)} onCancel={() => { setFormToggled(false); - if (threads.length === 0 && onDismiss) onDismiss(); + if (activeThreads.length === 0 && onDismiss) onDismiss(); }} />} - {threads.map(thread => ( + {activeThreads.map(thread => ( 1 && expandedThreadId !== thread.id} - onToggle={() => toggleThread(thread.id)} /> + collapsed={activeThreads.length > 1 && expandedThreadId !== thread.id} + onToggle={() => toggleThread(thread.id)} + onResolve={() => postUpdateThreadMessage({threadId: thread.id, resolved: true})} /> ))} + + {resolvedThreads.length > 0 && +
+ + + {showResolved && resolvedThreads.map(thread => ( + 1 && expandedThreadId !== thread.id} + onToggle={() => toggleThread(thread.id)} + onResolve={() => postUpdateThreadMessage({threadId: thread.id, resolved: false})} /> + ))} +
} ); } diff --git a/entry_types/scrolled/package/src/review/ThreadList.module.css b/entry_types/scrolled/package/src/review/ThreadList.module.css index a367a470fd..3ab6d15f3c 100644 --- a/entry_types/scrolled/package/src/review/ThreadList.module.css +++ b/entry_types/scrolled/package/src/review/ThreadList.module.css @@ -24,3 +24,35 @@ .reversed { align-self: flex-end; } + +.resolvedSection { + display: flex; + flex-direction: column; + gap: space(2); + margin-top: space(3); +} + +.resolvedPill { + align-self: center; + display: flex; + align-items: center; + gap: space(1); + font: inherit; + font-size: space(3); + font-weight: 500; + white-space: nowrap; + color: var(--ui-on-primary-color); + background: var(--ui-primary-color); + border: 0; + border-radius: rounded(full); + padding: space(1) space(1.5) space(1) space(2.5); + cursor: pointer; +} + +.chevron { + transition: transform 0.2s; +} + +.chevronExpanded { + transform: rotate(180deg); +} diff --git a/entry_types/scrolled/package/src/review/images/resolve.svg b/entry_types/scrolled/package/src/review/images/resolve.svg new file mode 100644 index 0000000000..ff0f1b455d --- /dev/null +++ b/entry_types/scrolled/package/src/review/images/resolve.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/review/images/unresolve.svg b/entry_types/scrolled/package/src/review/images/unresolve.svg new file mode 100644 index 0000000000..1aeab095e6 --- /dev/null +++ b/entry_types/scrolled/package/src/review/images/unresolve.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/review/postMessage.js b/entry_types/scrolled/package/src/review/postMessage.js index 168f8675d3..effc13b235 100644 --- a/entry_types/scrolled/package/src/review/postMessage.js +++ b/entry_types/scrolled/package/src/review/postMessage.js @@ -12,6 +12,13 @@ export function postCreateCommentMessage({threadId, body}) { ); } +export function postUpdateThreadMessage({threadId, resolved}) { + window.top.postMessage( + {type: 'UPDATE_THREAD', payload: {threadId, resolved}}, + window.location.origin + ); +} + export function postReviewStateResetMessage(targetWindow, state) { targetWindow.postMessage( {type: 'REVIEW_STATE_RESET', payload: state}, 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 e2293f8f30..b6a0cfedad 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 @@ -10,6 +10,10 @@ before do translation('en', 'pageflow_scrolled.review.add_comment', 'Add comment') translation('en', 'pageflow_scrolled.review.select_content_element', 'Select to comment') + translation('en', 'pageflow_scrolled.review.resolve', 'Mark as resolved') + translation('en', 'pageflow_scrolled.review.unresolve', 'Mark as unresolved') + translation('en', 'pageflow_scrolled.review.resolved_count.one', '1 resolved') + translation('en', 'pageflow_scrolled.review.resolved_count.other', '%{count} resolved') end scenario 'sees existing comment badge in entry preview' do @@ -118,4 +122,44 @@ expect(page).to have_css('[role="status"]', wait: 10) end + + scenario 'resolves and unresolves a 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: 'Needs work') + end + + visit(pageflow.revision_path(entry.draft)) + + find('[role="status"]', wait: 10).click + expect(page).to have_text('Needs work', wait: 10) + + click_button('Mark as resolved') + + expect(page).to have_text('1 resolved', wait: 10) + expect(page).not_to have_text('Needs work') + + click_button('1 resolved') + expect(page).to have_text('Needs work', wait: 10) + + click_button('Mark as unresolved') + + expect(page).not_to have_text('1 resolved', wait: 10) + expect(page).to have_text('Needs work') + end end diff --git a/package/spec/review/ReviewSession-spec.js b/package/spec/review/ReviewSession-spec.js index d803818443..e9ff528671 100644 --- a/package/spec/review/ReviewSession-spec.js +++ b/package/spec/review/ReviewSession-spec.js @@ -119,6 +119,92 @@ describe('ReviewSession', () => { ]); }); + it('emits change:thread with resolvedAt after updateThread', async () => { + const request = jest.fn() + .mockResolvedValueOnce({ + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [ + {id: 1, subjectType: 'CE', subjectId: 10, resolvedAt: null, comments: []} + ] + }) + .mockResolvedValueOnce({ + id: 1, + subjectType: 'CE', + subjectId: 10, + resolvedAt: '2026-04-09T10:00:00Z', + comments: [] + }); + + const session = new ReviewSession({entryId: 5, request}); + await session.fetch(); + + const listener = jest.fn(); + session.on('change:thread', listener); + + await session.updateThread({threadId: 1, resolved: true}); + + expect(request).toHaveBeenLastCalledWith({ + url: '/review/entries/5/comment_threads/1', + method: 'PATCH', + payload: {comment_thread: {resolved: true}} + }); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({id: 1, resolvedAt: '2026-04-09T10:00:00Z'}) + ); + }); + + it('updates state after updateThread', async () => { + const request = jest.fn() + .mockResolvedValueOnce({ + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [ + {id: 1, subjectType: 'CE', subjectId: 10, resolvedAt: null, comments: []} + ] + }) + .mockResolvedValueOnce({ + id: 1, subjectType: 'CE', subjectId: 10, + resolvedAt: '2026-04-09T10:00:00Z', comments: [] + }); + + const session = new ReviewSession({entryId: 5, request}); + await session.fetch(); + await session.updateThread({threadId: 1, resolved: true}); + + expect(session.state.commentThreads).toEqual([ + expect.objectContaining({id: 1, resolvedAt: '2026-04-09T10:00:00Z'}) + ]); + }); + + it('emits change:thread with null resolvedAt after unresolving', async () => { + const request = jest.fn() + .mockResolvedValueOnce({ + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [ + {id: 1, subjectType: 'CE', subjectId: 10, resolvedAt: '2026-04-09T10:00:00Z', comments: []} + ] + }) + .mockResolvedValueOnce({ + id: 1, + subjectType: 'CE', + subjectId: 10, + resolvedAt: null, + comments: [] + }); + + const session = new ReviewSession({entryId: 5, request}); + await session.fetch(); + + const listener = jest.fn(); + session.on('change:thread', listener); + + await session.updateThread({threadId: 1, resolved: false}); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({id: 1, resolvedAt: null}) + ); + }); + it('updates state after createComment', async () => { const request = jest.fn() .mockResolvedValueOnce({ diff --git a/package/src/review/ReviewSession.js b/package/src/review/ReviewSession.js index 093848e709..620bb841d0 100644 --- a/package/src/review/ReviewSession.js +++ b/package/src/review/ReviewSession.js @@ -28,6 +28,17 @@ export class ReviewSession { this.trigger('change:thread', thread); } + async updateThread({threadId, resolved}) { + const thread = await this._request({ + url: `/review/entries/${this._entryId}/comment_threads/${threadId}`, + method: 'PATCH', + payload: {comment_thread: {resolved}} + }); + + this._upsertThread(thread); + this.trigger('change:thread', thread); + } + async createComment({threadId, body}) { const comment = await this._request({ url: `/review/entries/${this._entryId}/comment_threads/${threadId}/comments`, diff --git a/spec/controllers/pageflow/review/comment_threads_controller_spec.rb b/spec/controllers/pageflow/review/comment_threads_controller_spec.rb index 094f91bde5..9063d65dab 100644 --- a/spec/controllers/pageflow/review/comment_threads_controller_spec.rb +++ b/spec/controllers/pageflow/review/comment_threads_controller_spec.rb @@ -125,5 +125,88 @@ module Pageflow expect(response.status).to eq(403) end end + + describe '#update' do + it 'resolves a 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) + patch(:update, params: { + entry_id: entry.id, + id: thread.id, + comment_thread: {resolved: true} + }, format: 'json') + + expect(response.status).to eq(200) + expect(thread.reload.resolved_at).to be_present + expect(thread.resolver).to eq(user) + expect(response.body).to include_json( + id: thread.id, + resolvedAt: be_present + ) + end + + it 'unresolves a thread' do + user = create(:user) + entry = create(:entry, with_previewer: user) + resolver = create(:user) + thread = create(:comment_thread, + revision: entry.draft, + creator: user, + resolved_at: Time.current, + resolved_by_id: resolver.id) + + sign_in(user, scope: :user) + patch(:update, params: { + entry_id: entry.id, + id: thread.id, + comment_thread: {resolved: false} + }, format: 'json') + + expect(response.status).to eq(200) + expect(thread.reload.resolved_at).to be_nil + expect(thread.resolver).to be_nil + expect(response.body).to include_json( + id: thread.id, + resolvedAt: nil + ) + end + + it 'requires user to be signed in' do + entry = create(:entry) + thread = create(:comment_thread, + revision: entry.draft, + creator: create(:user)) + + patch(:update, params: { + entry_id: entry.id, + id: thread.id, + comment_thread: {resolved: true} + }, 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, + creator: create(:user)) + + sign_in(user, scope: :user) + patch(:update, params: { + entry_id: entry.id, + id: thread.id, + comment_thread: {resolved: true} + }, format: 'json') + + expect(response.status).to eq(403) + end + end end end