Skip to content

Commit ca7c130

Browse files
committed
Show resolved threads UI in ThreadList
Separate active from resolved threads. Show a "N resolved" pill to expand the resolved section. Add resolve/unresolve button on each thread that posts a RESOLVE_THREAD message. REDMINE-21261
1 parent a1af422 commit ca7c130

File tree

13 files changed

+322
-11
lines changed

13 files changed

+322
-11
lines changed

.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:

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/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
}

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import {Comment} from './Comment';
66
import {ReplyForm} from './ReplyForm';
77

88
import ChevronIcon from './images/chevron.svg';
9+
import ResolveIcon from './images/resolve.svg';
10+
import UnresolveIcon from './images/unresolve.svg';
911
import styles from './Thread.module.css';
1012

11-
export function Thread({thread, collapsed, onToggle}) {
13+
export function Thread({thread, collapsed, onToggle, onResolve}) {
1214
const {t} = useI18n({locale: 'ui'});
1315
const firstComment = thread.comments[0];
1416
const replies = thread.comments.slice(1);
17+
const repliesCollapsed = collapsed && replies.length > 0;
1518

1619
return (
1720
<div className={styles.thread}>
@@ -24,7 +27,7 @@ export function Thread({thread, collapsed, onToggle}) {
2427

2528
{firstComment && <Comment comment={firstComment} />}
2629

27-
{collapsed && replies.length > 0 &&
30+
{repliesCollapsed &&
2831
<button className={styles.expandButton} onClick={onToggle}>
2932
{t('pageflow_scrolled.review.reply_count', {count: replies.length})}
3033
<AvatarStack names={replies.map(c => c.creatorName)} />
@@ -34,7 +37,18 @@ export function Thread({thread, collapsed, onToggle}) {
3437
<Comment key={comment.id} comment={comment} />
3538
))}
3639

37-
{(!collapsed || replies.length === 0) && <ReplyForm threadId={thread.id} />}
40+
{!thread.resolvedAt && !repliesCollapsed &&
41+
<ReplyForm threadId={thread.id} />}
42+
43+
{onResolve && !repliesCollapsed &&
44+
<div className={styles.resolveRow}>
45+
<button className={styles.resolveButton} onClick={onResolve}>
46+
{thread.resolvedAt ? <UnresolveIcon /> : <ResolveIcon />}
47+
{t(thread.resolvedAt
48+
? 'pageflow_scrolled.review.unresolve'
49+
: 'pageflow_scrolled.review.resolve')}
50+
</button>
51+
</div>}
3852
</div>
3953
);
4054
}

entry_types/scrolled/package/src/review/Thread.module.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,28 @@
5252
.expandButton:hover {
5353
text-decoration: underline;
5454
}
55+
56+
.resolveRow {
57+
display: flex;
58+
justify-content: center;
59+
margin-top: space(2);
60+
padding-top: space(2);
61+
border-top: 1px solid var(--ui-on-surface-color-lightest);
62+
}
63+
64+
.resolveButton {
65+
font: inherit;
66+
font-weight: 500;
67+
display: flex;
68+
align-items: center;
69+
gap: space(1);
70+
color: var(--ui-on-surface-color-light);
71+
background: none;
72+
border: none;
73+
cursor: pointer;
74+
padding: 0;
75+
}
76+
77+
.resolveButton:hover {
78+
color: var(--ui-primary-color);
79+
}

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

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@ import {useI18n} from '../frontend/i18n';
55
import {useCommentThreads} from './ReviewStateProvider';
66
import {Thread} from './Thread';
77
import {NewThreadForm} from './NewThreadForm';
8+
import {postUpdateThreadMessage} from './postMessage';
89

10+
import ChevronIcon from './images/chevron.svg';
911
import NewTopicIcon from './images/newTopic.svg';
1012
import styles from './ThreadList.module.css';
1113

1214
export function ThreadList({subjectType, subjectId, showNewForm: showNewFormProp, reversed, onDismiss, newTopicButtonClassName}) {
1315
const {t} = useI18n({locale: 'ui'});
1416
const threads = useCommentThreads(subjectType, subjectId);
17+
18+
const activeThreads = threads.filter(thread => !thread.resolvedAt);
19+
const resolvedThreads = threads.filter(thread => thread.resolvedAt);
20+
1521
const [expandedThreadId, setExpandedThreadId] = useState(null);
1622
const [formToggled, setFormToggled] = useState(null);
17-
const showNewForm = formToggled !== null ? formToggled : (showNewFormProp || threads.length === 0);
23+
const [showResolved, setShowResolved] = useState(false);
24+
const showNewForm = formToggled !== null ? formToggled : (showNewFormProp || activeThreads.length === 0);
1825

1926
function toggleThread(threadId) {
2027
setExpandedThreadId(expandedThreadId === threadId ? null : threadId);
@@ -38,15 +45,34 @@ export function ThreadList({subjectType, subjectId, showNewForm: showNewFormProp
3845
onSubmit={() => setFormToggled(false)}
3946
onCancel={() => {
4047
setFormToggled(false);
41-
if (threads.length === 0 && onDismiss) onDismiss();
48+
if (activeThreads.length === 0 && onDismiss) onDismiss();
4249
}} />}
4350

44-
{threads.map(thread => (
51+
{activeThreads.map(thread => (
4552
<Thread key={thread.id}
4653
thread={thread}
47-
collapsed={threads.length > 1 && expandedThreadId !== thread.id}
48-
onToggle={() => toggleThread(thread.id)} />
54+
collapsed={activeThreads.length > 1 && expandedThreadId !== thread.id}
55+
onToggle={() => toggleThread(thread.id)}
56+
onResolve={() => postUpdateThreadMessage({threadId: thread.id, resolved: true})} />
4957
))}
58+
59+
{resolvedThreads.length > 0 &&
60+
<div className={styles.resolvedSection}>
61+
<button className={styles.resolvedPill}
62+
onClick={() => setShowResolved(!showResolved)}>
63+
{t('pageflow_scrolled.review.resolved_count', {count: resolvedThreads.length})}
64+
<ChevronIcon className={classNames(styles.chevron,
65+
{[styles.chevronExpanded]: showResolved})} />
66+
</button>
67+
68+
{showResolved && resolvedThreads.map(thread => (
69+
<Thread key={thread.id}
70+
thread={thread}
71+
collapsed={resolvedThreads.length > 1 && expandedThreadId !== thread.id}
72+
onToggle={() => toggleThread(thread.id)}
73+
onResolve={() => postUpdateThreadMessage({threadId: thread.id, resolved: false})} />
74+
))}
75+
</div>}
5076
</div>
5177
);
5278
}

0 commit comments

Comments
 (0)