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 &&
+
+ setShowResolved(!showResolved)}>
+ {t('pageflow_scrolled.review.resolved_count', {count: resolvedThreads.length})}
+
+
+
+ {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