Skip to content

Commit 4cf4d6d

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 4cf4d6d

File tree

13 files changed

+313
-10
lines changed

13 files changed

+313
-10
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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ 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: '2026-04-09T10:00:00Z', comments: []}
43+
]
44+
}
45+
);
46+
47+
expect(getByRole('status')).toHaveTextContent('1');
48+
});
49+
50+
it('renders nothing when all threads are resolved', () => {
51+
const {container} = renderWithReviewState(
52+
<CommentBadge subjectType="ContentElement" subjectId={10} />,
53+
{
54+
commentThreads: [
55+
{id: 1, subjectType: 'ContentElement', subjectId: 10, resolvedAt: '2026-04-09T10:00:00Z', comments: []}
56+
]
57+
}
58+
);
59+
60+
expect(container).toBeEmptyDOMElement();
61+
});
62+
3663
it('renders nothing when no threads exist for subject', () => {
3764
const {container} = renderWithReviewState(
3865
<CommentBadge subjectType="ContentElement" subjectId={10} />

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

Lines changed: 120 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,121 @@ 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', () => {
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: [{id: 10, body: 'First thread', creatorName: 'Bob', creatorId: 2}]},
474+
{id: 2, subjectType: 'ContentElement', subjectId: 10,
475+
resolvedAt: null,
476+
comments: [{id: 20, body: 'Second thread', creatorName: 'Alice', creatorId: 1}]}
477+
]
478+
}
479+
);
480+
481+
expect(queryByText('Mark as resolved')).not.toBeInTheDocument();
482+
});
483+
484+
it('does not show reply form on resolved threads', async () => {
485+
const user = userEvent.setup();
486+
487+
const {queryByPlaceholderText, getByText} = renderWithReviewState(
488+
<ThreadList subjectType="ContentElement" subjectId={10} />,
489+
{
490+
commentThreads: [
491+
{id: 1, subjectType: 'ContentElement', subjectId: 10,
492+
resolvedAt: null,
493+
comments: [{id: 10, body: 'Active thread', creatorName: 'Bob', creatorId: 2}]},
494+
{id: 2, subjectType: 'ContentElement', subjectId: 10,
495+
resolvedAt: '2026-04-09T10:00:00Z',
496+
comments: [{id: 20, body: 'Resolved thread', creatorName: 'Alice', creatorId: 1}]}
497+
]
498+
}
499+
);
500+
501+
await user.click(getByText('1 resolved'));
502+
503+
const replyFields = queryByPlaceholderText('Reply...');
504+
expect(replyFields).toBeInTheDocument();
505+
506+
expect(getByText('Resolved thread')).toBeInTheDocument();
507+
const resolvedThread = getByText('Resolved thread').closest('[class*="thread"]');
508+
expect(resolvedThread.querySelector('textarea[placeholder="Reply..."]')).toBeNull();
509+
});
510+
392511
it('shows reply form in collapsed thread without replies', () => {
393512
const {getAllByPlaceholderText} = renderWithReviewState(
394513
<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: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ 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);
@@ -34,7 +36,18 @@ export function Thread({thread, collapsed, onToggle}) {
3436
<Comment key={comment.id} comment={comment} />
3537
))}
3638

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

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
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,35 @@
2424
.reversed {
2525
align-self: flex-end;
2626
}
27+
28+
.resolvedSection {
29+
display: flex;
30+
flex-direction: column;
31+
gap: space(2);
32+
margin-top: space(3);
33+
}
34+
35+
.resolvedPill {
36+
align-self: center;
37+
display: flex;
38+
align-items: center;
39+
gap: space(1);
40+
font: inherit;
41+
font-size: space(3);
42+
font-weight: 500;
43+
white-space: nowrap;
44+
color: var(--ui-on-primary-color);
45+
background: var(--ui-primary-color);
46+
border: 0;
47+
border-radius: rounded(full);
48+
padding: space(1) space(1.5) space(1) space(2.5);
49+
cursor: pointer;
50+
}
51+
52+
.chevron {
53+
transition: transform 0.2s;
54+
}
55+
56+
.chevronExpanded {
57+
transform: rotate(180deg);
58+
}

0 commit comments

Comments
 (0)