Skip to content

Commit 6c9df37

Browse files
authored
Merge pull request #2399 from tf/resolve-comments
Resolving comments
2 parents ca0b255 + ca7c130 commit 6c9df37

22 files changed

Lines changed: 560 additions & 13 deletions

File tree

.rubocop.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ Lint/UselessConstantScoping:
173173
Gemspec/DevelopmentDependencies:
174174
Enabled: false
175175

176+
# Allow I18n-style %{var} tokens
177+
Style/FormatStringToken:
178+
Mode: conservative
179+
176180
# Ignore empty specs and factory traits
177181
Lint/EmptyBlock:
178182
Exclude:

app/controllers/pageflow/review/comment_threads_controller.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ def create
2828
render :create, status: :created
2929
end
3030

31+
def update
32+
entry = DraftEntry.find(params[:entry_id])
33+
authorize!(:read, entry.to_model)
34+
35+
@comment_thread = entry.comment_threads.find(params[:id])
36+
37+
if ActiveModel::Type::Boolean.new.cast(params[:comment_thread][:resolved])
38+
@comment_thread.resolve(current_user)
39+
else
40+
@comment_thread.unresolve
41+
end
42+
43+
render :create
44+
end
45+
3146
private
3247

3348
def thread_params

app/models/pageflow/comment_thread.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,19 @@ class CommentThread < ApplicationRecord
44
include RevisionComponent
55

66
belongs_to :creator, class_name: 'User'
7+
belongs_to :resolver, class_name: 'User', foreign_key: :resolved_by_id, optional: true
78
has_many :comments, dependent: :destroy
89

910
nested_revision_components :comments
1011

1112
validates :subject_type, :subject_id, presence: true
13+
14+
def resolve(user)
15+
update!(resolved_at: Time.current, resolver: user) unless resolved_at
16+
end
17+
18+
def unresolve
19+
update!(resolved_at: nil, resolver: nil)
20+
end
1221
end
1322
end

config/routes.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979

8080
namespace :review do
8181
resources :entries, only: [] do
82-
resources :comment_threads, only: [:index, :create] do
82+
resources :comment_threads, only: [:index, :create, :update] do
8383
resources :comments, only: [:create]
8484
end
8585
end

entry_types/scrolled/config/locales/de.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,11 @@ de:
19221922
send: Senden
19231923
enter_for_new_line: Enter für neue Zeile
19241924
toggle_replies: Antworten umschalten
1925+
resolve: Als gelöst markieren
1926+
unresolve: Als ungelöst markieren
1927+
resolved_count:
1928+
one: 1 erledigt
1929+
other: '%{count} erledigt'
19251930
reply_count:
19261931
one: 1 Antwort
19271932
other: '%{count} Antworten'

entry_types/scrolled/config/locales/en.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1751,6 +1751,11 @@ en:
17511751
send: Send
17521752
enter_for_new_line: Enter for new line
17531753
toggle_replies: Toggle replies
1754+
resolve: Mark as resolved
1755+
unresolve: Mark as unresolved
1756+
resolved_count:
1757+
one: 1 resolved
1758+
other: '%{count} resolved'
17541759
reply_count:
17551760
one: 1 reply
17561761
other: '%{count} replies'

entry_types/scrolled/package/spec/review/CommentBadge-spec.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,34 @@ describe('CommentBadge', () => {
3333
expect(getByRole('status')).toHaveTextContent('2');
3434
});
3535

36+
it('only counts unresolved threads', () => {
37+
const {getByRole} = renderWithReviewState(
38+
<CommentBadge subjectType="ContentElement" subjectId={10} />,
39+
{
40+
commentThreads: [
41+
{id: 1, subjectType: 'ContentElement', subjectId: 10, resolvedAt: null, comments: []},
42+
{id: 2, subjectType: 'ContentElement', subjectId: 10, resolvedAt: null, comments: []},
43+
{id: 3, subjectType: 'ContentElement', subjectId: 10, resolvedAt: '2026-04-09T10:00:00Z', comments: []}
44+
]
45+
}
46+
);
47+
48+
expect(getByRole('status')).toHaveTextContent('2');
49+
});
50+
51+
it('renders nothing when all threads are resolved', () => {
52+
const {container} = renderWithReviewState(
53+
<CommentBadge subjectType="ContentElement" subjectId={10} />,
54+
{
55+
commentThreads: [
56+
{id: 1, subjectType: 'ContentElement', subjectId: 10, resolvedAt: '2026-04-09T10:00:00Z', comments: []}
57+
]
58+
}
59+
);
60+
61+
expect(container).toBeEmptyDOMElement();
62+
});
63+
3664
it('renders nothing when no threads exist for subject', () => {
3765
const {container} = renderWithReviewState(
3866
<CommentBadge subjectType="ContentElement" subjectId={10} />

entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {ReviewMessageHandler} from 'review/ReviewMessageHandler';
55
function fakeReviewSession() {
66
const session = {
77
createThread: jest.fn().mockResolvedValue(),
8-
createComment: jest.fn().mockResolvedValue()
8+
createComment: jest.fn().mockResolvedValue(),
9+
updateThread: jest.fn().mockResolvedValue()
910
};
1011

1112
Object.assign(session, BackboneEvents);
@@ -91,6 +92,27 @@ describe('ReviewMessageHandler', () => {
9192
window.postMessage.mockRestore();
9293
});
9394

95+
it('calls session.updateThread on UPDATE_THREAD message from targetWindow', async () => {
96+
const session = fakeReviewSession();
97+
98+
ReviewMessageHandler.create({session, targetWindow: window});
99+
100+
window.dispatchEvent(new MessageEvent('message', {
101+
data: {
102+
type: 'UPDATE_THREAD',
103+
payload: {threadId: 1, resolved: true}
104+
},
105+
origin: window.location.origin,
106+
source: window
107+
}));
108+
109+
await new Promise(resolve => setTimeout(resolve, 0));
110+
111+
expect(session.updateThread).toHaveBeenCalledWith({
112+
threadId: 1, resolved: true
113+
});
114+
});
115+
94116
it('ignores messages not from targetWindow', async () => {
95117
const session = fakeReviewSession();
96118
const iframeWindow = {};

entry_types/scrolled/package/spec/review/ThreadList-spec.js

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ describe('ThreadList', () => {
1616
'pageflow_scrolled.review.reply_placeholder': 'Reply...',
1717
'pageflow_scrolled.review.send': 'Send',
1818
'pageflow_scrolled.review.enter_for_new_line': 'Enter for new line',
19-
'pageflow_scrolled.review.toggle_replies': 'Toggle replies'
19+
'pageflow_scrolled.review.toggle_replies': 'Toggle replies',
20+
'pageflow_scrolled.review.resolve': 'Mark as resolved',
21+
'pageflow_scrolled.review.unresolve': 'Mark as unresolved',
22+
'pageflow_scrolled.review.resolved_count.one': '1 resolved',
23+
'pageflow_scrolled.review.resolved_count.other': '%{count} resolved'
2024
});
2125
it('displays comments of threads for subject', () => {
2226
const {getByText} = renderWithReviewState(
@@ -389,6 +393,127 @@ describe('ThreadList', () => {
389393
expect(getByRole('button', {name: 'Send'})).toBeInTheDocument();
390394
});
391395

396+
it('hides resolved threads and shows resolved count pill', () => {
397+
const {queryByText, getByText} = renderWithReviewState(
398+
<ThreadList subjectType="ContentElement" subjectId={10} />,
399+
{
400+
commentThreads: [
401+
{id: 1, subjectType: 'ContentElement', subjectId: 10,
402+
resolvedAt: '2026-04-09T10:00:00Z',
403+
comments: [{id: 10, body: 'Resolved thread', creatorName: 'Bob', creatorId: 2}]},
404+
{id: 2, subjectType: 'ContentElement', subjectId: 10,
405+
resolvedAt: null,
406+
comments: [{id: 20, body: 'Active thread', creatorName: 'Alice', creatorId: 1}]}
407+
]
408+
}
409+
);
410+
411+
expect(getByText('Active thread')).toBeInTheDocument();
412+
expect(queryByText('Resolved thread')).not.toBeInTheDocument();
413+
expect(getByText('1 resolved')).toBeInTheDocument();
414+
});
415+
416+
it('toggles resolved threads when pill is clicked', async () => {
417+
const user = userEvent.setup();
418+
419+
const {getByText, queryByText} = renderWithReviewState(
420+
<ThreadList subjectType="ContentElement" subjectId={10} />,
421+
{
422+
commentThreads: [
423+
{id: 1, subjectType: 'ContentElement', subjectId: 10,
424+
resolvedAt: '2026-04-09T10:00:00Z',
425+
comments: [{id: 10, body: 'Resolved thread', creatorName: 'Bob', creatorId: 2}]},
426+
{id: 2, subjectType: 'ContentElement', subjectId: 10,
427+
resolvedAt: null,
428+
comments: [{id: 20, body: 'Active thread', creatorName: 'Alice', creatorId: 1}]}
429+
]
430+
}
431+
);
432+
433+
await user.click(getByText('1 resolved'));
434+
expect(getByText('Resolved thread')).toBeInTheDocument();
435+
expect(getByText('1 resolved')).toBeInTheDocument();
436+
437+
await user.click(getByText('1 resolved'));
438+
expect(queryByText('Resolved thread')).not.toBeInTheDocument();
439+
});
440+
441+
it('posts resolve message when resolve button is clicked', async () => {
442+
const user = userEvent.setup();
443+
const postMessage = jest.spyOn(window.top, 'postMessage').mockImplementation(() => {});
444+
445+
const {getByText} = renderWithReviewState(
446+
<ThreadList subjectType="ContentElement" subjectId={10} />,
447+
{
448+
commentThreads: [
449+
{id: 1, subjectType: 'ContentElement', subjectId: 10,
450+
resolvedAt: null,
451+
comments: [{id: 10, body: 'Open thread', creatorName: 'Bob', creatorId: 2}]}
452+
]
453+
}
454+
);
455+
456+
await user.click(getByText('Mark as resolved'));
457+
458+
expect(postMessage).toHaveBeenCalledWith(
459+
{type: 'UPDATE_THREAD', payload: {threadId: 1, resolved: true}},
460+
window.location.origin
461+
);
462+
463+
postMessage.mockRestore();
464+
});
465+
466+
it('does not show resolve button on collapsed threads with replies', () => {
467+
const {queryByText} = renderWithReviewState(
468+
<ThreadList subjectType="ContentElement" subjectId={10} />,
469+
{
470+
commentThreads: [
471+
{id: 1, subjectType: 'ContentElement', subjectId: 10,
472+
resolvedAt: null,
473+
comments: [
474+
{id: 10, body: 'First thread', creatorName: 'Bob', creatorId: 2},
475+
{id: 11, body: 'Reply', creatorName: 'Alice', creatorId: 1}
476+
]},
477+
{id: 2, subjectType: 'ContentElement', subjectId: 10,
478+
resolvedAt: null,
479+
comments: [
480+
{id: 20, body: 'Second thread', creatorName: 'Alice', creatorId: 1},
481+
{id: 21, body: 'Reply', creatorName: 'Bob', creatorId: 2}
482+
]}
483+
]
484+
}
485+
);
486+
487+
expect(queryByText('Mark as resolved')).not.toBeInTheDocument();
488+
});
489+
490+
it('does not show reply form on resolved threads', async () => {
491+
const user = userEvent.setup();
492+
493+
const {queryByPlaceholderText, getByText} = renderWithReviewState(
494+
<ThreadList subjectType="ContentElement" subjectId={10} />,
495+
{
496+
commentThreads: [
497+
{id: 1, subjectType: 'ContentElement', subjectId: 10,
498+
resolvedAt: null,
499+
comments: [{id: 10, body: 'Active thread', creatorName: 'Bob', creatorId: 2}]},
500+
{id: 2, subjectType: 'ContentElement', subjectId: 10,
501+
resolvedAt: '2026-04-09T10:00:00Z',
502+
comments: [{id: 20, body: 'Resolved thread', creatorName: 'Alice', creatorId: 1}]}
503+
]
504+
}
505+
);
506+
507+
await user.click(getByText('1 resolved'));
508+
509+
const replyFields = queryByPlaceholderText('Reply...');
510+
expect(replyFields).toBeInTheDocument();
511+
512+
expect(getByText('Resolved thread')).toBeInTheDocument();
513+
const resolvedThread = getByText('Resolved thread').closest('[class*="thread"]');
514+
expect(resolvedThread.querySelector('textarea[placeholder="Reply..."]')).toBeNull();
515+
});
516+
392517
it('shows reply form in collapsed thread without replies', () => {
393518
const {getAllByPlaceholderText} = renderWithReviewState(
394519
<ThreadList subjectType="ContentElement" subjectId={10} />,

entry_types/scrolled/package/src/review/CommentBadge.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import styles from './CommentBadge.module.css';
88

99
export function CommentBadge({subjectType, subjectId, onClick, mode}) {
1010
const threads = useCommentThreads(subjectType, subjectId);
11-
const hasThreads = threads.length > 0;
11+
const unresolvedCount = threads.filter(t => !t.resolvedAt).length;
12+
const hasThreads = unresolvedCount > 0;
1213

1314
const variant = resolveVariant(mode, hasThreads);
1415

@@ -21,7 +22,7 @@ export function CommentBadge({subjectType, subjectId, onClick, mode}) {
2122
className={classNames(styles.badge, styles[variant])}
2223
onClick={onClick}>
2324
{variant !== 'dot' && <CommentIcon className={styles.icon} />}
24-
{(variant === 'active' || variant === 'expanded') && threads.length > 1 ? threads.length : null}
25+
{(variant === 'active' || variant === 'expanded') && unresolvedCount > 1 ? unresolvedCount : null}
2526
</button>
2627
);
2728
}

0 commit comments

Comments
 (0)