Skip to content

Commit e478f5b

Browse files
committed
Display comment badge on content elements
Wire up the full data flow from REST API to UI: ReviewSession fetches threads, ReviewMessageHandler bridges via postMessage, ReviewStateProvider holds React state, CommentBadge renders thread count. The commenting EntryDecorator sets up the session, ContentElementDecorator renders the badge. REDMINE-21261
1 parent 1015876 commit e478f5b

27 files changed

+619
-5
lines changed

app/assets/stylesheets/pageflow/ui/properties.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@
5757
--ui-box-shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
5858
--ui-box-shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
5959

60+
--ui-accent-color: #ffd24d;
61+
--ui-accent-color-glow: #ffd24d3d;
62+
--ui-on-accent-color: #795f0f;
63+
6064
--ui-on-button-color: var(--ui-primary-color);
6165
--ui-button-border-color: var(--ui-primary-color-light);
6266
--ui-button-hover-border-color: var(--ui-primary-color);

entry_types/scrolled/package/.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
/contentElements-editor.js
99
/contentElements-frontend.js
1010
/contentElements-frontend.css
11+
/review.js
1112
/testHelpers.js
1213
/widgets
1314
/.storybook/out/

entry_types/scrolled/package/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
/contentElements-editor.js
77
/contentElements-frontend.js
88
/contentElements-frontend.css
9+
/review.css
10+
/review.js
911
/testHelpers.js
1012
/widgets
1113
/.storybook/out/

entry_types/scrolled/package/.storybook/main.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ module.exports = {
3535
alias: {
3636
...config.resolve.alias,
3737
'pageflow/frontend': path.resolve(__dirname, '../../../../package/src/frontend'),
38+
'pageflow/review': path.resolve(__dirname, '../../../../package/src/review'),
3839
'pageflow-scrolled/frontend': path.resolve(__dirname, '../src/frontend'),
40+
'pageflow-scrolled/review': path.resolve(__dirname, '../src/review'),
3941
'pageflow-scrolled/testHelpers': path.resolve(__dirname, '../src/testHelpers')
4042
}
4143
}

entry_types/scrolled/package/config/webpack.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ module.exports = {
8383
import: ['pageflow-scrolled/frontend/inlineEditing.css']
8484
},
8585
'pageflow-scrolled-frontend-commenting': {
86-
import: ['pageflow-scrolled/frontend/commenting.css']
86+
import: [
87+
'pageflow-scrolled/frontend/commenting.css',
88+
'pageflow-scrolled/review.css'
89+
]
8790
}
8891
}
8992
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
import '@testing-library/jest-dom/extend-expect';
3+
import {act, waitFor} from '@testing-library/react';
4+
5+
import {Entry} from 'frontend/Entry';
6+
import {usePageObjects} from 'support/pageObjects';
7+
import {renderInEntry} from 'support';
8+
import {clearExtensions} from 'frontend/extensions';
9+
import {loadCommentingComponents} from 'frontend/commenting';
10+
11+
describe('commenting mode', () => {
12+
usePageObjects();
13+
14+
beforeEach(() => {
15+
jest.spyOn(window, 'fetch').mockResolvedValue({
16+
ok: true,
17+
json: () => Promise.resolve({currentUser: null, commentThreads: []})
18+
});
19+
20+
loadCommentingComponents();
21+
});
22+
23+
afterEach(() => {
24+
act(() => clearExtensions());
25+
window.fetch.mockRestore();
26+
});
27+
28+
it('fetches threads from API and displays badge', async () => {
29+
window.fetch.mockResolvedValue({
30+
ok: true,
31+
json: () => Promise.resolve({
32+
currentUser: {id: 42, name: 'Alice'},
33+
commentThreads: [
34+
{id: 1, subjectType: 'ContentElement', subjectId: 1, comments: []}
35+
]
36+
})
37+
});
38+
39+
const {getByRole} = renderInEntry(<Entry />, {
40+
seed: {
41+
contentElements: [{
42+
typeName: 'withTestId',
43+
configuration: {testId: 5}
44+
}]
45+
}
46+
});
47+
48+
await waitFor(() => {
49+
expect(getByRole('status')).toBeInTheDocument();
50+
});
51+
});
52+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React from 'react';
2+
import '@testing-library/jest-dom/extend-expect';
3+
import {render, act} from '@testing-library/react';
4+
5+
import {ReviewStateProvider} from 'review/ReviewStateProvider';
6+
import {CommentBadge} from 'review/CommentBadge';
7+
8+
describe('CommentBadge', () => {
9+
it('displays thread count for subject', () => {
10+
const {getByRole} = render(
11+
<ReviewStateProvider>
12+
<CommentBadge subjectType="ContentElement" subjectId={10} />
13+
</ReviewStateProvider>
14+
);
15+
16+
act(() => {
17+
window.dispatchEvent(new MessageEvent('message', {
18+
data: {
19+
type: 'REVIEW_STATE_RESET',
20+
payload: {
21+
currentUser: {id: 1},
22+
commentThreads: [
23+
{id: 1, subjectType: 'ContentElement', subjectId: 10, comments: []},
24+
{id: 2, subjectType: 'ContentElement', subjectId: 10, comments: []},
25+
{id: 3, subjectType: 'ContentElement', subjectId: 20, comments: []}
26+
]
27+
}
28+
},
29+
origin: window.location.origin
30+
}));
31+
});
32+
33+
expect(getByRole('status')).toHaveTextContent('2');
34+
});
35+
36+
it('renders nothing when no threads exist for subject', () => {
37+
const {container} = render(
38+
<ReviewStateProvider>
39+
<CommentBadge subjectType="ContentElement" subjectId={10} />
40+
</ReviewStateProvider>
41+
);
42+
43+
expect(container).toBeEmptyDOMElement();
44+
});
45+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import BackboneEvents from 'backbone-events-standalone';
2+
3+
import {ReviewMessageHandler} from 'review/ReviewMessageHandler';
4+
5+
function fakeReviewSession() {
6+
const session = {};
7+
Object.assign(session, BackboneEvents);
8+
return session;
9+
}
10+
11+
describe('ReviewMessageHandler', () => {
12+
it('posts REVIEW_STATE_RESET to target window on session reset', () => {
13+
const session = fakeReviewSession();
14+
const targetWindow = {postMessage: jest.fn()};
15+
16+
ReviewMessageHandler.create({session, targetWindow});
17+
18+
const state = {currentUser: {id: 42}, commentThreads: [{id: 1}]};
19+
session.trigger('reset', state);
20+
21+
expect(targetWindow.postMessage).toHaveBeenCalledWith(
22+
{type: 'REVIEW_STATE_RESET', payload: state},
23+
window.location.origin
24+
);
25+
});
26+
27+
it('posts REVIEW_STATE_THREAD_CHANGE to target window on session change:thread', () => {
28+
const session = fakeReviewSession();
29+
const targetWindow = {postMessage: jest.fn()};
30+
31+
ReviewMessageHandler.create({session, targetWindow});
32+
33+
const thread = {id: 1, comments: [{body: 'Hello'}]};
34+
session.trigger('change:thread', thread);
35+
36+
expect(targetWindow.postMessage).toHaveBeenCalledWith(
37+
{type: 'REVIEW_STATE_THREAD_CHANGE', payload: thread},
38+
window.location.origin
39+
);
40+
});
41+
42+
it('can be disposed', () => {
43+
const session = fakeReviewSession();
44+
const targetWindow = {postMessage: jest.fn()};
45+
46+
const handler = ReviewMessageHandler.create({session, targetWindow});
47+
handler.dispose();
48+
49+
session.trigger('reset', {});
50+
51+
expect(targetWindow.postMessage).not.toHaveBeenCalled();
52+
});
53+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React from 'react';
2+
import '@testing-library/jest-dom/extend-expect';
3+
import {act} from '@testing-library/react';
4+
import {renderHook} from '@testing-library/react-hooks';
5+
6+
import {
7+
ReviewStateProvider,
8+
useCommentThreads
9+
} from 'review/ReviewStateProvider';
10+
import {
11+
postReviewStateResetMessage,
12+
postReviewStateThreadChangeMessage
13+
} from 'review/postMessage';
14+
15+
function wrapper({children}) {
16+
return <ReviewStateProvider>{children}</ReviewStateProvider>;
17+
}
18+
19+
function postReset(payload) {
20+
act(() => {
21+
postReviewStateResetMessage(window, payload);
22+
});
23+
}
24+
25+
function postThreadChange(payload) {
26+
act(() => {
27+
postReviewStateThreadChangeMessage(window, payload);
28+
});
29+
}
30+
31+
describe('ReviewStateProvider', () => {
32+
it('provides empty initial state', () => {
33+
const {result} = renderHook(
34+
() => useCommentThreads('CE', 10),
35+
{wrapper}
36+
);
37+
38+
expect(result.current).toEqual([]);
39+
});
40+
41+
it('updates state on reset message', async () => {
42+
const {result, waitForNextUpdate} = renderHook(
43+
() => useCommentThreads('CE', 10),
44+
{wrapper}
45+
);
46+
47+
postReset({
48+
currentUser: {id: 42, name: 'Alice'},
49+
commentThreads: [
50+
{id: 1, subjectType: 'CE', subjectId: 10, comments: []},
51+
{id: 2, subjectType: 'CE', subjectId: 20, comments: []}
52+
]
53+
});
54+
55+
await waitForNextUpdate();
56+
57+
expect(result.current).toHaveLength(1);
58+
expect(result.current[0].id).toBe(1);
59+
});
60+
61+
it('updates single thread on thread change message', async () => {
62+
const {result, waitForNextUpdate} = renderHook(
63+
() => useCommentThreads('CE', 10),
64+
{wrapper}
65+
);
66+
67+
postReset({
68+
currentUser: {id: 42},
69+
commentThreads: [
70+
{id: 1, subjectType: 'CE', subjectId: 10, comments: []}
71+
]
72+
});
73+
74+
await waitForNextUpdate();
75+
expect(result.current[0].comments).toHaveLength(0);
76+
77+
postThreadChange({
78+
id: 1,
79+
subjectType: 'CE',
80+
subjectId: 10,
81+
comments: [{id: 100, body: 'Hello'}]
82+
});
83+
84+
await waitForNextUpdate();
85+
expect(result.current[0].comments).toHaveLength(1);
86+
});
87+
88+
it('ignores messages from different origins', () => {
89+
const {result} = renderHook(
90+
() => useCommentThreads('CE', 10),
91+
{wrapper}
92+
);
93+
94+
act(() => {
95+
window.dispatchEvent(new MessageEvent('message', {
96+
data: {
97+
type: 'REVIEW_STATE_RESET',
98+
payload: {
99+
currentUser: {id: 99},
100+
commentThreads: [{id: 1, subjectType: 'CE', subjectId: 10, comments: []}]
101+
}
102+
},
103+
origin: 'https://evil.example.com'
104+
}));
105+
});
106+
107+
expect(result.current).toEqual([]);
108+
});
109+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React from 'react';
2+
3+
import {CommentBadge} from 'pageflow-scrolled/review';
4+
5+
export function ContentElementDecorator({permaId, children}) {
6+
return (
7+
<>
8+
{children}
9+
<CommentBadge subjectType="ContentElement" subjectId={permaId} />
10+
</>
11+
);
12+
}

0 commit comments

Comments
 (0)