diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml
index 5b425aead9..6b6c0c47d5 100644
--- a/entry_types/scrolled/config/locales/de.yml
+++ b/entry_types/scrolled/config/locales/de.yml
@@ -1584,6 +1584,12 @@ de:
linkButtonVariant:
label: Button-Variante
blank: "(Standard)"
+ main_menu:
+ comments: Kommentare
+ comments_view:
+ tabs:
+ comments: Alle Kommentare
+ selection: Für Auswahl
help_entries:
content_elements:
menu_item: Inhaltselemente
diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml
index 5265eff0a1..92439f3d7c 100644
--- a/entry_types/scrolled/config/locales/en.yml
+++ b/entry_types/scrolled/config/locales/en.yml
@@ -1567,6 +1567,12 @@ en:
linkButtonVariant:
label: Button Variante
blank: "(Default)"
+ main_menu:
+ comments: Comments
+ comments_view:
+ tabs:
+ comments: All comments
+ selection: For selection
help_entries:
content_elements:
menu_item: Content Elements
diff --git a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js
index 15d55ab30a..eb9ec16c5d 100644
--- a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js
+++ b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js
@@ -1,4 +1,5 @@
import 'editor/config';
+import Backbone from 'backbone';
import {editor} from 'pageflow-scrolled/editor';
import {features} from 'pageflow/frontend';
import {ScrolledEntry} from 'editor/models/ScrolledEntry';
@@ -158,6 +159,24 @@ describe('PreviewMessageController', () => {
})).resolves.toMatchObject({type: 'SELECT', payload: {id: 1, type: 'contentElement'}});
});
+ it('posts SELECT_COMMENT_THREAD message on selectCommentThread event', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()});
+ const iframeWindow = createIframeWindow();
+ controller = new PreviewMessageController({entry, iframeWindow});
+
+ await postReadyMessageAndWaitForAcknowledgement(iframeWindow);
+
+ return expect(new Promise(resolve => {
+ iframeWindow.addEventListener('message', event => {
+ if (event.data.type === 'SELECT_COMMENT_THREAD') resolve(event.data);
+ });
+ entry.trigger('selectCommentThread', 7);
+ })).resolves.toMatchObject({
+ type: 'SELECT_COMMENT_THREAD',
+ payload: {threadId: 7}
+ });
+ });
+
it('passes on range from selectContentElement event', async () => {
const entry = factories.entry(ScrolledEntry, {}, {
entryTypeSeed: normalizeSeed({
@@ -495,7 +514,7 @@ describe('PreviewMessageController', () => {
})).resolves.toBe('/');
});
- it('navigates to comments route on SELECTED message for contentElementComments', () => {
+ it('navigates to comments route with tab=selection on SELECTED contentElementComments', () => {
const editor = factories.editorApi();
const entry = factories.entry(ScrolledEntry, {}, {
entryTypeSeed: normalizeSeed({
@@ -508,10 +527,12 @@ describe('PreviewMessageController', () => {
return expect(new Promise(resolve => {
editor.on('navigate', resolve);
window.postMessage({type: 'SELECTED', payload: {id: 1, type: 'contentElementComments'}}, '*');
- })).resolves.toBe('/scrolled/content_elements/1/comments');
+ })).resolves.toBe('/scrolled/comments?tab=selection');
});
- it('navigates to comments route with threadIds payload on SELECTED contentElementComments', () => {
+ it('does not navigate on SELECTED contentElementComments while on the comments route', async () => {
+ Backbone.history.fragment = 'scrolled/comments';
+
const editor = factories.editorApi();
const entry = factories.entry(ScrolledEntry, {}, {
entryTypeSeed: normalizeSeed({contentElements: [{id: 1}]})
@@ -519,17 +540,150 @@ describe('PreviewMessageController', () => {
const iframeWindow = createIframeWindow();
controller = new PreviewMessageController({entry, iframeWindow, editor});
- const expectedPayload = encodeURIComponent(JSON.stringify({threadIds: [3, 7]}));
+ const navigate = jest.fn();
+ editor.on('navigate', navigate);
+
+ await new Promise(resolve => {
+ entry.once('change:highlightedThreadId', resolve);
+ window.postMessage({
+ type: 'SELECTED',
+ payload: {id: 10, type: 'contentElementComments', highlightedThreadId: 7}
+ }, '*');
+ });
+
+ expect(navigate).not.toHaveBeenCalled();
+ expect(entry.get('highlightedThreadId')).toBe(7);
+
+ Backbone.history.fragment = undefined;
+ });
+
+ it('does not navigate on SELECTED contentElementComments while on the comments selection tab', async () => {
+ Backbone.history.fragment = 'scrolled/comments?tab=selection';
+
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({contentElements: [{id: 1}]})
+ });
+ const iframeWindow = createIframeWindow();
+ controller = new PreviewMessageController({entry, iframeWindow, editor});
+
+ const navigate = jest.fn();
+ editor.on('navigate', navigate);
+
+ await new Promise(resolve => {
+ entry.once('change:highlightedThreadId', resolve);
+ window.postMessage({
+ type: 'SELECTED',
+ payload: {id: 10, type: 'contentElementComments', highlightedThreadId: 7}
+ }, '*');
+ });
+
+ expect(navigate).not.toHaveBeenCalled();
+
+ Backbone.history.fragment = undefined;
+ });
+
+ it('sets highlightedThreadId on entry on SELECTED contentElementComments', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()});
+ const iframeWindow = createIframeWindow();
+ controller = new PreviewMessageController({entry, iframeWindow, editor});
return expect(new Promise(resolve => {
- editor.on('navigate', resolve);
+ entry.once('change:highlightedThreadId', (model, value) => resolve(value));
window.postMessage({
type: 'SELECTED',
- payload: {id: 10, type: 'contentElementComments', threadIds: [3, 7]}
+ payload: {id: 10, type: 'contentElementComments', highlightedThreadId: 5}
}, '*');
- })).resolves.toBe(
- `/scrolled/content_elements/10/comments?payload=${expectedPayload}`
- );
+ })).resolves.toBe(5);
+ });
+
+ it('clears highlightedThreadId on entry on SELECTED with non-comments type', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()});
+ entry.set('highlightedThreadId', 5);
+
+ const iframeWindow = createIframeWindow();
+ controller = new PreviewMessageController({entry, iframeWindow, editor});
+
+ return expect(new Promise(resolve => {
+ entry.once('change:highlightedThreadId', (model, value) => resolve(value));
+ window.postMessage({
+ type: 'SELECTED',
+ payload: {id: 10, type: 'sectionSettings'}
+ }, '*');
+ })).resolves.toBeUndefined();
+ });
+
+ it('sets selectedContentElementCommentsId on SELECTED contentElementComments', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()});
+ const iframeWindow = createIframeWindow();
+ controller = new PreviewMessageController({entry, iframeWindow, editor});
+
+ return expect(new Promise(resolve => {
+ entry.once('change:selectedContentElementCommentsId', (model, value) => resolve(value));
+ window.postMessage({
+ type: 'SELECTED',
+ payload: {id: 7, type: 'contentElementComments'}
+ }, '*');
+ })).resolves.toBe(7);
+ });
+
+ it('sets selectedContentElementCommentsId on SELECTED contentElement', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({contentElements: [{id: 4}]})
+ });
+ const iframeWindow = createIframeWindow();
+ controller = new PreviewMessageController({entry, iframeWindow, editor});
+
+ return expect(new Promise(resolve => {
+ entry.once('change:selectedContentElementCommentsId', (model, value) => resolve(value));
+ window.postMessage({
+ type: 'SELECTED',
+ payload: {id: 4, type: 'contentElement'}
+ }, '*');
+ })).resolves.toBe(4);
+ });
+
+ it('sets selectedContentElementCommentsId from permaId on SELECTED newThread', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({contentElements: [{id: 4, permaId: 100}]})
+ });
+ const iframeWindow = createIframeWindow();
+ controller = new PreviewMessageController({entry, iframeWindow, editor});
+
+ return expect(new Promise(resolve => {
+ entry.once('change:selectedContentElementCommentsId', (model, value) => resolve(value));
+ window.postMessage({
+ type: 'SELECTED',
+ payload: {
+ id: 100,
+ type: 'newThread',
+ subjectType: 'ContentElement',
+ range: {anchor: {path: [0, 0], offset: 0}, focus: {path: [0, 0], offset: 1}}
+ }
+ }, '*');
+ })).resolves.toBe(4);
+ });
+
+ it('clears selectedContentElementCommentsId on SELECTED with non-content-element type', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()});
+ entry.set('selectedContentElementCommentsId', 9);
+
+ const iframeWindow = createIframeWindow();
+ controller = new PreviewMessageController({entry, iframeWindow, editor});
+
+ return expect(new Promise(resolve => {
+ entry.once('change:selectedContentElementCommentsId', (model, value) => resolve(value));
+ window.postMessage({
+ type: 'SELECTED',
+ payload: {id: 10, type: 'sectionSettings'}
+ }, '*');
+ })).resolves.toBeUndefined();
});
it('navigates to new thread route with encoded payload on SELECTED for newThread', () => {
diff --git a/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js
index 1a11f02604..b2a437ef85 100644
--- a/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js
+++ b/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js
@@ -1,7 +1,7 @@
import 'editor/config';
import {SideBarController} from 'editor/controllers/SideBarController';
-import {ContentElementCommentsView} from 'editor/views/ContentElementCommentsView';
+import {CommentsView} from 'editor/views/CommentsView';
import {factories} from 'pageflow/testHelpers';
import {useEditorGlobals} from 'support';
@@ -9,39 +9,31 @@ import {useEditorGlobals} from 'support';
describe('SideBarController', () => {
const {createEntry} = useEditorGlobals();
- describe('#contentElementComments', () => {
- it('shows a ContentElementCommentsView without threadIds when no payload given', () => {
- const entry = createEntry({
- contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
- });
+ describe('#comments', () => {
+ it('shows a CommentsView in the region', () => {
+ const entry = createEntry({});
entry.reviewSession = factories.reviewSession();
const region = {show: jest.fn()};
const controller = new SideBarController({region, entry});
- controller.contentElementComments(1);
+ controller.comments();
const shown = region.show.mock.calls[0][0];
- expect(shown).toBeInstanceOf(ContentElementCommentsView);
- expect(shown.options.threadIds).toBeUndefined();
+ expect(shown).toBeInstanceOf(CommentsView);
});
- it('decodes threadIds from the payload and passes it to the view', () => {
- const entry = createEntry({
- contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
- });
+ it('passes the tab arg as defaultTab to the CommentsView', () => {
+ const entry = createEntry({});
entry.reviewSession = factories.reviewSession();
const region = {show: jest.fn()};
const controller = new SideBarController({region, entry});
- const payload = encodeURIComponent(JSON.stringify({threadIds: [3, 7]}));
-
- controller.contentElementComments(1, payload);
+ controller.comments('selection');
const shown = region.show.mock.calls[0][0];
- expect(shown).toBeInstanceOf(ContentElementCommentsView);
- expect(shown.options.threadIds).toEqual([3, 7]);
+ expect(shown.options.defaultTab).toBe('selection');
});
});
});
diff --git a/entry_types/scrolled/package/spec/editor/views/CommentsView-spec.js b/entry_types/scrolled/package/spec/editor/views/CommentsView-spec.js
new file mode 100644
index 0000000000..f86a2fc49a
--- /dev/null
+++ b/entry_types/scrolled/package/spec/editor/views/CommentsView-spec.js
@@ -0,0 +1,111 @@
+import '@testing-library/jest-dom/extend-expect';
+
+import {editor} from 'pageflow-scrolled/editor';
+
+import {CommentsView} from 'editor/views/CommentsView';
+
+import {factories, useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers';
+import {useEditorGlobals} from 'support';
+import {fireEvent} from '@testing-library/dom';
+import {act} from '@testing-library/react';
+
+describe('CommentsView', () => {
+ const {createEntry} = useEditorGlobals();
+
+ useFakeTranslations({
+ 'pageflow_scrolled.review.new_topic': 'New topic',
+ 'pageflow_scrolled.review.add_comment_placeholder': 'Add a comment...',
+ 'pageflow_scrolled.review.send': 'Send',
+ 'pageflow_scrolled.editor.content_elements.textBlock.name': 'Text',
+ 'pageflow_scrolled.editor.comments_view.tabs.comments': 'All comments',
+ 'pageflow_scrolled.editor.comments_view.tabs.selection': 'For selection',
+ 'pageflow.editor.templates.back_button_decorator.outline': 'Outline'
+ });
+
+ function setupEntry() {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.set('selectedContentElementCommentsId', 1);
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 1,
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ comments: [{id: 100, body: 'A thread', creatorName: 'Alice'}]
+ }]
+ });
+ return entry;
+ }
+
+ it('renders both tab labels', () => {
+ const entry = setupEntry();
+
+ const view = new CommentsView({entry, editor});
+ const {getAllByRole} = renderBackboneView(view);
+
+ const labels = getAllByRole('tab').map(t => t.textContent);
+ expect(labels).toEqual(['All comments', 'For selection']);
+ });
+
+ it('shows the all-comments tab by default', () => {
+ const entry = setupEntry();
+
+ const view = new CommentsView({entry, editor});
+ const {getByText} = renderBackboneView(view);
+
+ expect(getByText('Text')).toBeInTheDocument();
+ expect(getByText('A thread')).toBeInTheDocument();
+ });
+
+ it('uses defaultTab option to pick the initial tab', () => {
+ const entry = setupEntry();
+
+ const view = new CommentsView({entry, editor, defaultTab: 'selection'});
+ const {queryByText, getByText} = renderBackboneView(view);
+
+ expect(getByText('A thread')).toBeInTheDocument();
+ expect(queryByText('Text')).not.toBeInTheDocument();
+ });
+
+ it('switches to the selection tab on click', () => {
+ const entry = setupEntry();
+
+ const view = new CommentsView({entry, editor});
+ const {getByRole, queryByText, getByText} = renderBackboneView(view);
+
+ expect(getByText('Text')).toBeInTheDocument();
+
+ act(() => {
+ fireEvent.click(getByRole('tab', {name: 'For selection'}));
+ });
+
+ expect(queryByText('Text')).not.toBeInTheDocument();
+ expect(getByText('A thread')).toBeInTheDocument();
+ });
+
+ it('renders a back link', () => {
+ const entry = setupEntry();
+
+ const view = new CommentsView({entry, editor});
+ const {getByText} = renderBackboneView(view);
+
+ expect(getByText('Outline')).toBeInTheDocument();
+ });
+
+ it('navigates to root when the back link is clicked', () => {
+ const entry = setupEntry();
+
+ const view = new CommentsView({entry, editor});
+ const {getByText} = renderBackboneView(view);
+
+ const navigate = jest.spyOn(editor, 'navigate').mockImplementation(() => {});
+
+ fireEvent.click(getByText('Outline'));
+
+ expect(navigate).toHaveBeenCalledWith('/', {trigger: true});
+
+ navigate.mockRestore();
+ });
+
+});
diff --git a/entry_types/scrolled/package/spec/editor/views/ContentElementCommentsView-spec.js b/entry_types/scrolled/package/spec/editor/views/ContentElementCommentsView-spec.js
index d9f7008f98..ec9efccdcb 100644
--- a/entry_types/scrolled/package/spec/editor/views/ContentElementCommentsView-spec.js
+++ b/entry_types/scrolled/package/spec/editor/views/ContentElementCommentsView-spec.js
@@ -1,4 +1,5 @@
import '@testing-library/jest-dom/extend-expect';
+import userEvent from '@testing-library/user-event';
import {ContentElementCommentsView} from 'editor/views/ContentElementCommentsView';
@@ -15,10 +16,11 @@ describe('ContentElementCommentsView', () => {
'pageflow_scrolled.review.send': 'Send'
});
- it('displays threads from session state', () => {
+ it('displays threads of selected content element from session state', () => {
const entry = createEntry({
contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
});
+ entry.set('selectedContentElementCommentsId', 1);
entry.reviewSession = factories.reviewSession({
commentThreads: [{
id: 1,
@@ -28,21 +30,20 @@ describe('ContentElementCommentsView', () => {
}]
});
- const view = new ContentElementCommentsView({
- entry,
- model: entry.contentElements.get(1),
- editor: {}
- });
+ const view = new ContentElementCommentsView({entry, editor: {}});
const {getByText} = renderBackboneView(view);
expect(getByText('Looks good')).toBeInTheDocument();
});
- it('filters threads by threadIds when given', () => {
+ it('filters threads by transient state commentThreadIdsAtSelection', () => {
const entry = createEntry({
contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
});
+ entry.set('selectedContentElementCommentsId', 1);
+ entry.contentElements.get(1).transientState
+ .set('commentThreadIdsAtSelection', [1]);
entry.reviewSession = factories.reviewSession({
commentThreads: [
{id: 1, subjectType: 'ContentElement', subjectId: 10,
@@ -52,12 +53,7 @@ describe('ContentElementCommentsView', () => {
]
});
- const view = new ContentElementCommentsView({
- entry,
- model: entry.contentElements.get(1),
- editor: {},
- threadIds: [1]
- });
+ const view = new ContentElementCommentsView({entry, editor: {}});
const {getByText, queryByText} = renderBackboneView(view);
@@ -65,10 +61,11 @@ describe('ContentElementCommentsView', () => {
expect(queryByText('Out of scope')).not.toBeInTheDocument();
});
- it('shows all threads when threadIds is not given', () => {
+ it('shows all threads when transient state has no commentThreadIdsAtSelection', () => {
const entry = createEntry({
contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
});
+ entry.set('selectedContentElementCommentsId', 1);
entry.reviewSession = factories.reviewSession({
commentThreads: [
{id: 1, subjectType: 'ContentElement', subjectId: 10,
@@ -78,11 +75,7 @@ describe('ContentElementCommentsView', () => {
]
});
- const view = new ContentElementCommentsView({
- entry,
- model: entry.contentElements.get(1),
- editor: {}
- });
+ const view = new ContentElementCommentsView({entry, editor: {}});
const {getByText} = renderBackboneView(view);
@@ -90,17 +83,201 @@ describe('ContentElementCommentsView', () => {
expect(getByText('Second')).toBeInTheDocument();
});
+ it('updates filter when transient state changes', async () => {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.set('selectedContentElementCommentsId', 1);
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 100, body: 'In scope', creatorName: 'Alice'}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 200, body: 'Out of scope', creatorName: 'Bob'}]}
+ ]
+ });
+
+ const view = new ContentElementCommentsView({entry, editor: {}});
+ const {getByText, queryByText} = renderBackboneView(view);
+
+ expect(getByText('Out of scope')).toBeInTheDocument();
+
+ act(() => {
+ entry.contentElements.get(1).transientState
+ .set('commentThreadIdsAtSelection', [1]);
+ });
+
+ await waitFor(() => {
+ expect(queryByText('Out of scope')).not.toBeInTheDocument();
+ });
+ expect(getByText('In scope')).toBeInTheDocument();
+ });
+
+ it('updates when selectedContentElementCommentsId changes', async () => {
+ const entry = createEntry({
+ contentElements: [
+ {id: 1, permaId: 10, typeName: 'textBlock'},
+ {id: 2, permaId: 11, typeName: 'textBlock'}
+ ]
+ });
+ entry.set('selectedContentElementCommentsId', 1);
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 100, body: 'On first', creatorName: 'Alice'}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 11,
+ comments: [{id: 200, body: 'On second', creatorName: 'Bob'}]}
+ ]
+ });
+
+ const view = new ContentElementCommentsView({entry, editor: {}});
+ const {getByText, queryByText} = renderBackboneView(view);
+
+ expect(getByText('On first')).toBeInTheDocument();
+
+ act(() => {
+ entry.set('selectedContentElementCommentsId', 2);
+ });
+
+ await waitFor(() => {
+ expect(getByText('On second')).toBeInTheDocument();
+ });
+ expect(queryByText('On first')).not.toBeInTheDocument();
+ });
+
+ it('marks thread matching entry.highlightedThreadId with aria-current when scoped', () => {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.set('selectedContentElementCommentsId', 1);
+ entry.contentElements.get(1).transientState
+ .set('commentThreadIdsAtSelection', [1, 2]);
+ entry.set('highlightedThreadId', 2);
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 10, body: 'first', creatorName: 'Alice'}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 20, body: 'second', creatorName: 'Bob'}]}
+ ]
+ });
+
+ const view = new ContentElementCommentsView({entry, editor: {}});
+ const {getByText} = renderBackboneView(view);
+
+ expect(getByText('second').closest('[aria-current="true"]')).not.toBeNull();
+ expect(getByText('first').closest('[aria-current="true"]')).toBeNull();
+ });
+
+ it('updates highlight when entry.highlightedThreadId changes', () => {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.set('selectedContentElementCommentsId', 1);
+ entry.contentElements.get(1).transientState
+ .set('commentThreadIdsAtSelection', [1, 2]);
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 10, body: 'first', creatorName: 'Alice'}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 20, body: 'second', creatorName: 'Bob'}]}
+ ]
+ });
+
+ const view = new ContentElementCommentsView({entry, editor: {}});
+ const {getByText} = renderBackboneView(view);
+
+ expect(getByText('first').closest('[aria-current="true"]')).toBeNull();
+
+ act(() => { entry.set('highlightedThreadId', 1); });
+
+ expect(getByText('first').closest('[aria-current="true"]')).not.toBeNull();
+ });
+
+ it('triggers selectCommentThread on entry when a thread is clicked while scoped', async () => {
+ const user = userEvent.setup();
+
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.set('selectedContentElementCommentsId', 1);
+ entry.contentElements.get(1).transientState
+ .set('commentThreadIdsAtSelection', [7]);
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 7, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 100, body: 'click me', creatorName: 'Alice'}]
+ }]
+ });
+
+ const listener = jest.fn();
+ entry.on('selectCommentThread', listener);
+
+ const view = new ContentElementCommentsView({entry, editor: {}});
+ const {getByText} = renderBackboneView(view);
+
+ await user.click(getByText('click me'));
+
+ expect(listener).toHaveBeenCalledWith(7);
+ });
+
+ it('does not highlight or trigger selectCommentThread when not scoped', async () => {
+ const user = userEvent.setup();
+
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.set('selectedContentElementCommentsId', 1);
+ entry.set('highlightedThreadId', 7);
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 7, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 100, body: 'click me', creatorName: 'Alice'}]
+ }]
+ });
+
+ const listener = jest.fn();
+ entry.on('selectCommentThread', listener);
+
+ const view = new ContentElementCommentsView({entry, editor: {}});
+ const {getByText} = renderBackboneView(view);
+
+ expect(getByText('click me').closest('[aria-current="true"]')).toBeNull();
+
+ await user.click(getByText('click me'));
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('does not render a new-topic button', () => {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.set('selectedContentElementCommentsId', 1);
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 1,
+ subjectType: 'ContentElement',
+ subjectId: 10,
+ comments: [{id: 100, body: 'A thread', creatorName: 'Alice'}]
+ }]
+ });
+
+ const view = new ContentElementCommentsView({entry, editor: {}});
+ const {queryByRole} = renderBackboneView(view);
+
+ expect(queryByRole('button', {name: 'New topic'})).not.toBeInTheDocument();
+ });
+
it('updates when session emits change:thread', async () => {
const entry = createEntry({
contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
});
+ entry.set('selectedContentElementCommentsId', 1);
entry.reviewSession = factories.reviewSession();
- const view = new ContentElementCommentsView({
- entry,
- model: entry.contentElements.get(1),
- editor: {}
- });
+ const view = new ContentElementCommentsView({entry, editor: {}});
const {getByText} = renderBackboneView(view);
diff --git a/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js
new file mode 100644
index 0000000000..bfe480967d
--- /dev/null
+++ b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js
@@ -0,0 +1,231 @@
+import '@testing-library/jest-dom/extend-expect';
+import userEvent from '@testing-library/user-event';
+import {act} from '@testing-library/react';
+
+import {editor} from 'pageflow-scrolled/editor';
+
+import {EntryCommentsView} from 'editor/views/EntryCommentsView';
+import styles from 'editor/views/EntryCommentsView.module.css';
+
+import {factories, useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers';
+import {useEditorGlobals} from 'support';
+
+describe('EntryCommentsView', () => {
+ const {createEntry} = useEditorGlobals();
+
+ useFakeTranslations({
+ 'pageflow_scrolled.review.new_topic': 'New topic',
+ 'pageflow_scrolled.review.add_comment_placeholder': 'Add a comment...',
+ 'pageflow_scrolled.review.send': 'Send',
+ 'pageflow_scrolled.editor.content_elements.textBlock.name': 'Text',
+ 'pageflow_scrolled.editor.content_elements.image.name': 'Image'
+ });
+
+ it('renders a thread group only for content elements that have threads', () => {
+ const entry = createEntry({
+ contentElements: [
+ {id: 1, permaId: 10, typeName: 'textBlock', position: 0},
+ {id: 2, permaId: 20, typeName: 'image', position: 1}
+ ]
+ });
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 1,
+ subjectType: 'ContentElement',
+ subjectId: 20,
+ comments: [{id: 100, body: 'Looks good', creatorName: 'Alice'}]
+ }]
+ });
+
+ const view = new EntryCommentsView({entry, editor});
+
+ const {getByText, queryByText} = renderBackboneView(view);
+
+ expect(getByText('Looks good')).toBeInTheDocument();
+ expect(getByText('Image')).toBeInTheDocument();
+ expect(queryByText('Text')).not.toBeInTheDocument();
+ });
+
+ it('orders groups by chapter, section and content element position', () => {
+ const entry = createEntry({
+ chapters: [
+ {id: 100, permaId: 10, position: 1, storylineId: 1000},
+ {id: 200, permaId: 20, position: 0, storylineId: 1000}
+ ],
+ sections: [
+ {id: 11, permaId: 11, chapterId: 100, position: 0},
+ {id: 12, permaId: 12, chapterId: 100, position: 1},
+ {id: 21, permaId: 21, chapterId: 200, position: 0}
+ ],
+ contentElements: [
+ {id: 1, permaId: 1, sectionId: 11, position: 1, typeName: 'textBlock'},
+ {id: 2, permaId: 2, sectionId: 11, position: 0, typeName: 'image'},
+ {id: 3, permaId: 3, sectionId: 12, position: 0, typeName: 'textBlock'},
+ {id: 4, permaId: 4, sectionId: 21, position: 0, typeName: 'image'}
+ ]
+ });
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1,
+ comments: [{id: 1, body: 'fourth', creatorName: 'A'}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 2,
+ comments: [{id: 2, body: 'third', creatorName: 'B'}]},
+ {id: 3, subjectType: 'ContentElement', subjectId: 3,
+ comments: [{id: 3, body: 'second', creatorName: 'C'}]},
+ {id: 4, subjectType: 'ContentElement', subjectId: 4,
+ comments: [{id: 4, body: 'first', creatorName: 'D'}]}
+ ]
+ });
+
+ const view = new EntryCommentsView({entry, editor});
+
+ const {getByText} = renderBackboneView(view);
+
+ const order = ['first', 'second', 'third', 'fourth']
+ .map(text => getByText(text).getBoundingClientRect().top);
+
+ expect(order).toEqual([...order].sort((a, b) => a - b));
+ });
+
+ it('does not render the new-topic button', () => {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 100, body: 'A comment', creatorName: 'Alice'}]
+ }]
+ });
+
+ const view = new EntryCommentsView({entry, editor});
+ const {queryByRole} = renderBackboneView(view);
+
+ expect(queryByRole('button', {name: 'New topic'})).not.toBeInTheDocument();
+ });
+
+ it('triggers selectCommentThread on entry when a thread is clicked', async () => {
+ const user = userEvent.setup();
+
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 7, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 100, body: 'A comment', creatorName: 'Alice'}]
+ }]
+ });
+
+ const listener = jest.fn();
+ entry.on('selectCommentThread', listener);
+
+ const view = new EntryCommentsView({entry, editor});
+ const {getByText} = renderBackboneView(view);
+
+ await user.click(getByText('A comment'));
+
+ expect(listener).toHaveBeenCalledWith(7);
+ });
+
+ it('marks the thread matching entry.highlightedThreadId with aria-current', () => {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 10, body: 'first', creatorName: 'Alice'}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 20, body: 'second', creatorName: 'Bob'}]}
+ ]
+ });
+ entry.set('highlightedThreadId', 2);
+
+ const view = new EntryCommentsView({entry, editor});
+ const {getByText} = renderBackboneView(view);
+
+ expect(getByText('second').closest('[aria-current="true"]')).not.toBeNull();
+ expect(getByText('first').closest('[aria-current="true"]')).toBeNull();
+ });
+
+ it('updates highlight when entry.highlightedThreadId changes', () => {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}]
+ });
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 10, body: 'first', creatorName: 'Alice'}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 20, body: 'second', creatorName: 'Bob'}]}
+ ]
+ });
+
+ const view = new EntryCommentsView({entry, editor});
+ const {getByText} = renderBackboneView(view);
+
+ expect(getByText('first').closest('[aria-current="true"]')).toBeNull();
+
+ act(() => { entry.set('highlightedThreadId', 1); });
+
+ expect(getByText('first').closest('[aria-current="true"]')).not.toBeNull();
+ });
+
+ it('shows the content element type name as group label', () => {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'image'}]
+ });
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 100, body: 'A comment', creatorName: 'Alice'}]
+ }]
+ });
+
+ const view = new EntryCommentsView({entry, editor});
+ const {getByText} = renderBackboneView(view);
+
+ expect(getByText('Image')).toBeInTheDocument();
+ });
+
+ it('renders the registered pictogram for the content element type', () => {
+ editor.contentElementTypes.register('image', {pictogram: '/some/image-pictogram.svg'});
+
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'image'}]
+ });
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 100, body: 'A comment', creatorName: 'Alice'}]
+ }]
+ });
+
+ const view = new EntryCommentsView({entry, editor});
+ renderBackboneView(view);
+
+ const pictogram = view.el.querySelector(`.${styles.pictogram}`);
+ expect(pictogram).toBeInTheDocument();
+ expect(pictogram.style.maskImage).toContain('/some/image-pictogram.svg');
+ });
+
+ it('falls back to the default pictogram for unknown content element types', () => {
+ const entry = createEntry({
+ contentElements: [{id: 1, permaId: 10, typeName: 'unregistered'}]
+ });
+ entry.reviewSession = factories.reviewSession({
+ commentThreads: [{
+ id: 1, subjectType: 'ContentElement', subjectId: 10,
+ comments: [{id: 100, body: 'A comment', creatorName: 'Alice'}]
+ }]
+ });
+
+ const view = new EntryCommentsView({entry, editor});
+ renderBackboneView(view);
+
+ const pictogram = view.el.querySelector(`.${styles.pictogram}`);
+ expect(pictogram).toBeInTheDocument();
+ expect(pictogram.style.maskImage).not.toBe('');
+ });
+});
diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText-spec.js
index 20d8951492..31012f5a41 100644
--- a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText-spec.js
+++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText-spec.js
@@ -410,83 +410,87 @@ describe('EditableText', () => {
expect(badges[2]).toHaveClass(badgeStyles.dot);
});
- it('posts SELECTED contentElementComments with overlapping threadIds on badge click', () => {
+ it('posts SELECTED contentElementComments with highlightedThreadId on badge click', () => {
fakeParentWindow();
window.parent.postMessage = jest.fn();
const value = [
- {type: 'paragraph', children: [{text: 'First paragraph'}]},
- {type: 'paragraph', children: [{text: 'Second paragraph'}]}
+ {type: 'paragraph', children: [{text: 'First paragraph'}]}
];
- const {getAllByRole} = renderWithCommenting(
+ const {getByRole} = renderWithCommenting(