diff --git a/app/assets/stylesheets/pageflow/ui/properties.scss b/app/assets/stylesheets/pageflow/ui/properties.scss index 98f1c56b43..c16e049483 100644 --- a/app/assets/stylesheets/pageflow/ui/properties.scss +++ b/app/assets/stylesheets/pageflow/ui/properties.scss @@ -57,6 +57,10 @@ --ui-box-shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); --ui-box-shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + --ui-accent-color: #ffd24d; + --ui-accent-color-glow: #ffd24d3d; + --ui-on-accent-color: #795f0f; + --ui-on-button-color: var(--ui-primary-color); --ui-button-border-color: var(--ui-primary-color-light); --ui-button-hover-border-color: var(--ui-primary-color); diff --git a/app/controllers/pageflow/review/comment_threads_controller.rb b/app/controllers/pageflow/review/comment_threads_controller.rb new file mode 100644 index 0000000000..7ef9f3c951 --- /dev/null +++ b/app/controllers/pageflow/review/comment_threads_controller.rb @@ -0,0 +1,16 @@ +module Pageflow + module Review + # @api private + class CommentThreadsController < Pageflow::ApplicationController + respond_to :json + before_action :authenticate_user! + + def index + entry = DraftEntry.find(params[:entry_id]) + authorize!(:read, entry.to_model) + + @comment_threads = entry.comment_threads.includes(comments: :creator) + end + end + end +end diff --git a/app/models/pageflow/comment.rb b/app/models/pageflow/comment.rb new file mode 100644 index 0000000000..6ba052bc05 --- /dev/null +++ b/app/models/pageflow/comment.rb @@ -0,0 +1,15 @@ +module Pageflow + # @api private + class Comment < ApplicationRecord + include NestedRevisionComponent + + belongs_to :comment_thread + belongs_to :creator, class_name: 'User' + + validates :body, presence: true + + def entry_for_auto_generated_perma_id + comment_thread.revision.entry + end + end +end diff --git a/app/models/pageflow/comment_thread.rb b/app/models/pageflow/comment_thread.rb new file mode 100644 index 0000000000..c29f529ef0 --- /dev/null +++ b/app/models/pageflow/comment_thread.rb @@ -0,0 +1,13 @@ +module Pageflow + # @api private + class CommentThread < ApplicationRecord + include RevisionComponent + + belongs_to :creator, class_name: 'User' + has_many :comments, dependent: :destroy + + nested_revision_components :comments + + validates :subject_type, :subject_id, presence: true + end +end diff --git a/app/models/pageflow/entry_at_revision.rb b/app/models/pageflow/entry_at_revision.rb index 6083fbfbdc..0d42c1fd08 100644 --- a/app/models/pageflow/entry_at_revision.rb +++ b/app/models/pageflow/entry_at_revision.rb @@ -38,6 +38,7 @@ def initialize(entry, revision, theme: nil) :author, :publisher, :keywords, :published_at, :noindex?, + :comment_threads, :configuration, :structured_data_type_name, to: :revision) diff --git a/app/models/pageflow/revision.rb b/app/models/pageflow/revision.rb index 52dc874b7d..f5122009cd 100644 --- a/app/models/pageflow/revision.rb +++ b/app/models/pageflow/revision.rb @@ -27,6 +27,8 @@ class Revision < ApplicationRecord # rubocop:todo Style/Documentation has_many :chapters, -> { order(CHAPTER_ORDER) }, through: :storylines has_many :pages, -> { reorder(PAGE_ORDER) }, through: :storylines + has_many :comment_threads, dependent: :destroy + has_many :file_usages, dependent: :destroy has_many :image_files, -> { extending WithFileUsageExtension }, diff --git a/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder b/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder new file mode 100644 index 0000000000..2c77cac296 --- /dev/null +++ b/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder @@ -0,0 +1,15 @@ +json.key_format!(camelize: :lower) + +json.call(comment_thread, + :id, + :perma_id, + :subject_type, + :subject_id, + :creator_id, + :resolved_at, + :created_at, + :updated_at) + +json.comments(comment_thread.comments) do |comment| + json.partial!('pageflow/review/comments/comment', comment:) +end diff --git a/app/views/pageflow/review/comment_threads/index.json.jbuilder b/app/views/pageflow/review/comment_threads/index.json.jbuilder new file mode 100644 index 0000000000..bdd65bcfe4 --- /dev/null +++ b/app/views/pageflow/review/comment_threads/index.json.jbuilder @@ -0,0 +1,10 @@ +json.key_format!(camelize: :lower) + +json.current_user do + json.id current_user.id + json.name current_user.full_name +end + +json.comment_threads(@comment_threads) do |comment_thread| + json.partial!('pageflow/review/comment_threads/comment_thread', comment_thread:) +end diff --git a/app/views/pageflow/review/comments/_comment.json.jbuilder b/app/views/pageflow/review/comments/_comment.json.jbuilder new file mode 100644 index 0000000000..612cada537 --- /dev/null +++ b/app/views/pageflow/review/comments/_comment.json.jbuilder @@ -0,0 +1,11 @@ +json.key_format!(camelize: :lower) + +json.call(comment, + :id, + :perma_id, + :creator_id, + :body, + :created_at, + :updated_at) + +json.creator_name comment.creator.full_name diff --git a/config/routes.rb b/config/routes.rb index d7d7b6a74d..3490e675d0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,6 +77,12 @@ resource :oembed, only: [:show] end + namespace :review do + resources :entries, only: [] do + resources :comment_threads, only: [:index] + end + end + root to: redirect('/admin') end diff --git a/db/migrate/20260323000000_create_comment_threads_and_comments.rb b/db/migrate/20260323000000_create_comment_threads_and_comments.rb new file mode 100644 index 0000000000..9e91ec8424 --- /dev/null +++ b/db/migrate/20260323000000_create_comment_threads_and_comments.rb @@ -0,0 +1,30 @@ +class CreateCommentThreadsAndComments < ActiveRecord::Migration[6.0] + def change + create_table :pageflow_comment_threads do |t| + t.integer :revision_id, null: false + t.integer :perma_id + t.string :subject_type, null: false + t.integer :subject_id, null: false + t.integer :creator_id, null: false + t.datetime :resolved_at + t.integer :resolved_by_id + t.timestamps + end + + add_index :pageflow_comment_threads, :revision_id + add_index :pageflow_comment_threads, :creator_id + add_index :pageflow_comment_threads, [:revision_id, :subject_type, :subject_id], + name: 'index_comment_threads_on_revision_and_subject' + + create_table :pageflow_comments do |t| + t.integer :comment_thread_id, null: false + t.integer :perma_id + t.integer :creator_id, null: false + t.text :body, null: false + t.timestamps + end + + add_index :pageflow_comments, :comment_thread_id + add_index :pageflow_comments, :creator_id + end +end diff --git a/entry_types/scrolled/package/.eslintignore b/entry_types/scrolled/package/.eslintignore index b74181e706..16b0dd0c46 100644 --- a/entry_types/scrolled/package/.eslintignore +++ b/entry_types/scrolled/package/.eslintignore @@ -8,6 +8,7 @@ /contentElements-editor.js /contentElements-frontend.js /contentElements-frontend.css +/review.js /testHelpers.js /widgets /.storybook/out/ diff --git a/entry_types/scrolled/package/.gitignore b/entry_types/scrolled/package/.gitignore index ff82081d81..1872c41511 100644 --- a/entry_types/scrolled/package/.gitignore +++ b/entry_types/scrolled/package/.gitignore @@ -6,6 +6,8 @@ /contentElements-editor.js /contentElements-frontend.js /contentElements-frontend.css +/review.css +/review.js /testHelpers.js /widgets /.storybook/out/ diff --git a/entry_types/scrolled/package/.storybook/main.js b/entry_types/scrolled/package/.storybook/main.js index 5371ba5ca6..f556f85294 100644 --- a/entry_types/scrolled/package/.storybook/main.js +++ b/entry_types/scrolled/package/.storybook/main.js @@ -35,7 +35,9 @@ module.exports = { alias: { ...config.resolve.alias, 'pageflow/frontend': path.resolve(__dirname, '../../../../package/src/frontend'), + 'pageflow/review': path.resolve(__dirname, '../../../../package/src/review'), 'pageflow-scrolled/frontend': path.resolve(__dirname, '../src/frontend'), + 'pageflow-scrolled/review': path.resolve(__dirname, '../src/review'), 'pageflow-scrolled/testHelpers': path.resolve(__dirname, '../src/testHelpers') } } diff --git a/entry_types/scrolled/package/config/webpack.js b/entry_types/scrolled/package/config/webpack.js index 48686995a9..a2b7e461c5 100644 --- a/entry_types/scrolled/package/config/webpack.js +++ b/entry_types/scrolled/package/config/webpack.js @@ -83,7 +83,10 @@ module.exports = { import: ['pageflow-scrolled/frontend/inlineEditing.css'] }, 'pageflow-scrolled-frontend-commenting': { - import: ['pageflow-scrolled/frontend/commenting.css'] + import: [ + 'pageflow-scrolled/frontend/commenting.css', + 'pageflow-scrolled/review.css' + ] } } }; diff --git a/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js b/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js new file mode 100644 index 0000000000..5e9697c9cc --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js @@ -0,0 +1,52 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import {act, waitFor} from '@testing-library/react'; + +import {Entry} from 'frontend/Entry'; +import {usePageObjects} from 'support/pageObjects'; +import {renderInEntry} from 'support'; +import {clearExtensions} from 'frontend/extensions'; +import {loadCommentingComponents} from 'frontend/commenting'; + +describe('commenting mode', () => { + usePageObjects(); + + beforeEach(() => { + jest.spyOn(window, 'fetch').mockResolvedValue({ + ok: true, + json: () => Promise.resolve({currentUser: null, commentThreads: []}) + }); + + loadCommentingComponents(); + }); + + afterEach(() => { + act(() => clearExtensions()); + window.fetch.mockRestore(); + }); + + it('fetches threads from API and displays badge', async () => { + window.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 1, comments: []} + ] + }) + }); + + const {getByRole} = renderInEntry(, { + seed: { + contentElements: [{ + typeName: 'withTestId', + configuration: {testId: 5} + }] + } + }); + + await waitFor(() => { + expect(getByRole('status')).toBeInTheDocument(); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/CommentBadge-spec.js b/entry_types/scrolled/package/spec/review/CommentBadge-spec.js new file mode 100644 index 0000000000..e4f5662faa --- /dev/null +++ b/entry_types/scrolled/package/spec/review/CommentBadge-spec.js @@ -0,0 +1,45 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import {render, act} from '@testing-library/react'; + +import {ReviewStateProvider} from 'review/ReviewStateProvider'; +import {CommentBadge} from 'review/CommentBadge'; + +describe('CommentBadge', () => { + it('displays thread count for subject', () => { + const {getByRole} = render( + + + + ); + + act(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { + type: 'REVIEW_STATE_RESET', + payload: { + currentUser: {id: 1}, + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: []}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: []}, + {id: 3, subjectType: 'ContentElement', subjectId: 20, comments: []} + ] + } + }, + origin: window.location.origin + })); + }); + + expect(getByRole('status')).toHaveTextContent('2'); + }); + + it('renders nothing when no threads exist for subject', () => { + const {container} = render( + + + + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js new file mode 100644 index 0000000000..cb9cdb49a8 --- /dev/null +++ b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js @@ -0,0 +1,53 @@ +import BackboneEvents from 'backbone-events-standalone'; + +import {ReviewMessageHandler} from 'review/ReviewMessageHandler'; + +function fakeReviewSession() { + const session = {}; + Object.assign(session, BackboneEvents); + return session; +} + +describe('ReviewMessageHandler', () => { + it('posts REVIEW_STATE_RESET to target window on session reset', () => { + const session = fakeReviewSession(); + const targetWindow = {postMessage: jest.fn()}; + + ReviewMessageHandler.create({session, targetWindow}); + + const state = {currentUser: {id: 42}, commentThreads: [{id: 1}]}; + session.trigger('reset', state); + + expect(targetWindow.postMessage).toHaveBeenCalledWith( + {type: 'REVIEW_STATE_RESET', payload: state}, + window.location.origin + ); + }); + + it('posts REVIEW_STATE_THREAD_CHANGE to target window on session change:thread', () => { + const session = fakeReviewSession(); + const targetWindow = {postMessage: jest.fn()}; + + ReviewMessageHandler.create({session, targetWindow}); + + const thread = {id: 1, comments: [{body: 'Hello'}]}; + session.trigger('change:thread', thread); + + expect(targetWindow.postMessage).toHaveBeenCalledWith( + {type: 'REVIEW_STATE_THREAD_CHANGE', payload: thread}, + window.location.origin + ); + }); + + it('can be disposed', () => { + const session = fakeReviewSession(); + const targetWindow = {postMessage: jest.fn()}; + + const handler = ReviewMessageHandler.create({session, targetWindow}); + handler.dispose(); + + session.trigger('reset', {}); + + expect(targetWindow.postMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/ReviewStateProvider-spec.js b/entry_types/scrolled/package/spec/review/ReviewStateProvider-spec.js new file mode 100644 index 0000000000..a8a5ac44f6 --- /dev/null +++ b/entry_types/scrolled/package/spec/review/ReviewStateProvider-spec.js @@ -0,0 +1,109 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import {act} from '@testing-library/react'; +import {renderHook} from '@testing-library/react-hooks'; + +import { + ReviewStateProvider, + useCommentThreads +} from 'review/ReviewStateProvider'; +import { + postReviewStateResetMessage, + postReviewStateThreadChangeMessage +} from 'review/postMessage'; + +function wrapper({children}) { + return {children}; +} + +function postReset(payload) { + act(() => { + postReviewStateResetMessage(window, payload); + }); +} + +function postThreadChange(payload) { + act(() => { + postReviewStateThreadChangeMessage(window, payload); + }); +} + +describe('ReviewStateProvider', () => { + it('provides empty initial state', () => { + const {result} = renderHook( + () => useCommentThreads('CE', 10), + {wrapper} + ); + + expect(result.current).toEqual([]); + }); + + it('updates state on reset message', async () => { + const {result, waitForNextUpdate} = renderHook( + () => useCommentThreads('CE', 10), + {wrapper} + ); + + postReset({ + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [ + {id: 1, subjectType: 'CE', subjectId: 10, comments: []}, + {id: 2, subjectType: 'CE', subjectId: 20, comments: []} + ] + }); + + await waitForNextUpdate(); + + expect(result.current).toHaveLength(1); + expect(result.current[0].id).toBe(1); + }); + + it('updates single thread on thread change message', async () => { + const {result, waitForNextUpdate} = renderHook( + () => useCommentThreads('CE', 10), + {wrapper} + ); + + postReset({ + currentUser: {id: 42}, + commentThreads: [ + {id: 1, subjectType: 'CE', subjectId: 10, comments: []} + ] + }); + + await waitForNextUpdate(); + expect(result.current[0].comments).toHaveLength(0); + + postThreadChange({ + id: 1, + subjectType: 'CE', + subjectId: 10, + comments: [{id: 100, body: 'Hello'}] + }); + + await waitForNextUpdate(); + expect(result.current[0].comments).toHaveLength(1); + }); + + it('ignores messages from different origins', () => { + const {result} = renderHook( + () => useCommentThreads('CE', 10), + {wrapper} + ); + + act(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { + type: 'REVIEW_STATE_RESET', + payload: { + currentUser: {id: 99}, + commentThreads: [{id: 1, subjectType: 'CE', subjectId: 10, comments: []}] + } + }, + origin: 'https://evil.example.com' + })); + }); + + expect(result.current).toEqual([]); + }); +}); diff --git a/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js new file mode 100644 index 0000000000..61ade27f92 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js @@ -0,0 +1,12 @@ +import React from 'react'; + +import {CommentBadge} from 'pageflow-scrolled/review'; + +export function ContentElementDecorator({permaId, children}) { + return ( + <> + {children} + + + ); +} diff --git a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js index 11151542d5..f8a794a595 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js +++ b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js @@ -1,12 +1,34 @@ -import React from 'react'; +import React, {useEffect} from 'react'; +import {useEntryMetadata} from '../../entryState'; +import {createReviewSession} from 'pageflow/review'; +import {ReviewStateProvider, ReviewMessageHandler} from 'pageflow-scrolled/review'; import {FloatingToolbar} from './FloatingToolbar'; export function EntryDecorator(props) { return ( - <> + + {props.children} - + ); } + +function ReviewSessionSetup() { + const entryMetadata = useEntryMetadata(); + const entryId = entryMetadata?.id; + + useEffect(() => { + if (!entryId) return; + + const session = createReviewSession({entryId}); + const handler = ReviewMessageHandler.create({session, targetWindow: window}); + + session.fetch(); + + return () => handler.dispose(); + }, [entryId]); + + return null; +} diff --git a/entry_types/scrolled/package/src/frontend/commenting/index.js b/entry_types/scrolled/package/src/frontend/commenting/index.js index c10d62008b..e62dbb8096 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/index.js +++ b/entry_types/scrolled/package/src/frontend/commenting/index.js @@ -1,10 +1,12 @@ import {provideExtensions} from '../extensions'; import {EntryDecorator} from './EntryDecorator'; +import {ContentElementDecorator} from './ContentElementDecorator'; export function loadCommentingComponents() { provideExtensions({ decorators: { - Entry: EntryDecorator + Entry: EntryDecorator, + ContentElement: ContentElementDecorator } }); } diff --git a/entry_types/scrolled/package/src/review/CommentBadge.js b/entry_types/scrolled/package/src/review/CommentBadge.js new file mode 100644 index 0000000000..3356a38e0f --- /dev/null +++ b/entry_types/scrolled/package/src/review/CommentBadge.js @@ -0,0 +1,21 @@ +import React from 'react'; + +import {useCommentThreads} from './ReviewStateProvider'; + +import CommentIcon from './images/comment.svg'; +import styles from './CommentBadge.module.css'; + +export function CommentBadge({subjectType, subjectId}) { + const threads = useCommentThreads(subjectType, subjectId); + + if (threads.length === 0) { + return null; + } + + return ( + + + {threads.length} + + ); +} diff --git a/entry_types/scrolled/package/src/review/CommentBadge.module.css b/entry_types/scrolled/package/src/review/CommentBadge.module.css new file mode 100644 index 0000000000..caf7fd3d42 --- /dev/null +++ b/entry_types/scrolled/package/src/review/CommentBadge.module.css @@ -0,0 +1,27 @@ +.badge { + display: inline-flex; + align-items: center; + gap: space(1); + height: space(7); + padding: space(1) space(3); + border-radius: rounded(full); + background: var(--ui-accent-color); + color: var(--ui-on-accent-color); + font-family: var(--ui-font-family); + font-size: space(3.5); + font-weight: 600; + line-height: 0; + cursor: pointer; + border: none; + box-shadow: 0 0 0 space(1) var(--ui-accent-color-glow); + opacity: 0.9; +} + +.badge:hover { + opacity: 1; +} + +.icon { + width: 16px; + height: 16px; +} diff --git a/entry_types/scrolled/package/src/review/ReviewMessageHandler.js b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js new file mode 100644 index 0000000000..cef2b4d4ce --- /dev/null +++ b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js @@ -0,0 +1,26 @@ +import { + postReviewStateResetMessage, + postReviewStateThreadChangeMessage +} from './postMessage'; + +export const ReviewMessageHandler = { + create({session, targetWindow}) { + function handleReset(state) { + postReviewStateResetMessage(targetWindow, state); + } + + function handleThreadChange(thread) { + postReviewStateThreadChangeMessage(targetWindow, thread); + } + + session.on('reset', handleReset); + session.on('change:thread', handleThreadChange); + + return { + dispose() { + session.off('reset', handleReset); + session.off('change:thread', handleThreadChange); + } + }; + } +}; diff --git a/entry_types/scrolled/package/src/review/ReviewStateProvider.js b/entry_types/scrolled/package/src/review/ReviewStateProvider.js new file mode 100644 index 0000000000..4517e7392f --- /dev/null +++ b/entry_types/scrolled/package/src/review/ReviewStateProvider.js @@ -0,0 +1,78 @@ +import React, {createContext, useContext, useEffect, useMemo, useReducer} from 'react'; + +const ReviewStateContext = createContext(null); + +export function ReviewStateProvider({children}) { + const [state, dispatch] = useReducer(reducer, { + currentUser: null, + threads: {} + }); + + useEffect(() => { + function handleMessage(event) { + if (window.location.href.indexOf(event.origin) !== 0) return; + + const {type, payload} = event.data; + + if (type === 'REVIEW_STATE_RESET') { + dispatch({type: 'RESET', payload}); + } + else if (type === 'REVIEW_STATE_THREAD_CHANGE') { + dispatch({type: 'UPSERT_THREAD', payload}); + } + } + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, []); + + const value = useMemo(() => ({ + currentUser: state.currentUser, + commentThreads: Object.values(state.threads) + }), [state]); + + return ( + + {children} + + ); +} + +export function useCommentThreads(subjectType, subjectId) { + const context = useContext(ReviewStateContext); + const commentThreads = context ? context.commentThreads : []; + + return useMemo( + () => commentThreads.filter( + thread => thread.subjectType === subjectType && + thread.subjectId === subjectId + ), + [commentThreads, subjectType, subjectId] + ); +} + +function reducer(state, action) { + switch (action.type) { + case 'RESET': { + const threads = {}; + action.payload.commentThreads.forEach(thread => { + threads[thread.id] = thread; + }); + + return { + currentUser: action.payload.currentUser, + threads + }; + } + case 'UPSERT_THREAD': + return { + ...state, + threads: { + ...state.threads, + [action.payload.id]: action.payload + } + }; + default: + return state; + } +} diff --git a/entry_types/scrolled/package/src/review/images/comment.svg b/entry_types/scrolled/package/src/review/images/comment.svg new file mode 100644 index 0000000000..d43b357c7d --- /dev/null +++ b/entry_types/scrolled/package/src/review/images/comment.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/review/index.js b/entry_types/scrolled/package/src/review/index.js new file mode 100644 index 0000000000..90a7b4acfe --- /dev/null +++ b/entry_types/scrolled/package/src/review/index.js @@ -0,0 +1,3 @@ +export {ReviewStateProvider, useCommentThreads} from './ReviewStateProvider'; +export {ReviewMessageHandler} from './ReviewMessageHandler'; +export {CommentBadge} from './CommentBadge'; diff --git a/entry_types/scrolled/package/src/review/postMessage.js b/entry_types/scrolled/package/src/review/postMessage.js new file mode 100644 index 0000000000..37224c2f13 --- /dev/null +++ b/entry_types/scrolled/package/src/review/postMessage.js @@ -0,0 +1,13 @@ +export function postReviewStateResetMessage(targetWindow, state) { + targetWindow.postMessage( + {type: 'REVIEW_STATE_RESET', payload: state}, + window.location.origin + ); +} + +export function postReviewStateThreadChangeMessage(targetWindow, thread) { + targetWindow.postMessage( + {type: 'REVIEW_STATE_THREAD_CHANGE', payload: thread}, + window.location.origin + ); +} diff --git a/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb b/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb new file mode 100644 index 0000000000..4f49809a9d --- /dev/null +++ b/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +require 'pageflow/dom' +require 'support/dominos/editor' + +RSpec.feature 'as entry previewer, commenting on content elements', js: true do + scenario 'sees existing comment badge in entry preview' do + entry = create(:entry, :published, type_name: 'scrolled', + with_feature: 'commenting') + content_element = create(:content_element, + revision: entry.draft, + type_name: 'textBlock', + configuration: { + value: [{type: 'paragraph', + children: [{text: 'Some text'}]}] + }) + + user = Pageflow::Dom::Admin::Page.sign_in_as(:previewer, on: entry) + + create(:comment_thread, + revision: entry.draft, + creator: user, + subject_type: 'ContentElement', + subject_id: content_element.perma_id) do |thread| + create(:comment, + comment_thread: thread, + creator: user, + body: 'Please review this paragraph') + end + + visit(pageflow.revision_path(entry.draft)) + + expect(page).to have_text('Some text', wait: 10) + expect(page).to have_css('[role="status"]', text: '1', wait: 10) + end +end diff --git a/package/.eslintignore b/package/.eslintignore index 909d318f96..1789551973 100644 --- a/package/.eslintignore +++ b/package/.eslintignore @@ -3,4 +3,5 @@ node_modules /editor.js /frontend.js /ui.js +/review.js /testHelpers.js diff --git a/package/.gitignore b/package/.gitignore index e5837c1feb..895b54e783 100644 --- a/package/.gitignore +++ b/package/.gitignore @@ -2,4 +2,5 @@ /editor.js /frontend.js /ui.js +/review.js /testHelpers.js \ No newline at end of file diff --git a/package/spec/review/ReviewSession-spec.js b/package/spec/review/ReviewSession-spec.js new file mode 100644 index 0000000000..c33d298576 --- /dev/null +++ b/package/spec/review/ReviewSession-spec.js @@ -0,0 +1,35 @@ +import {ReviewSession} from 'review/ReviewSession'; + +describe('ReviewSession', () => { + it('emits reset event with threads after fetch', async () => { + const request = jest.fn().mockResolvedValue({ + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [ + {id: 100, body: 'Hello', creatorId: 42, creatorName: 'Alice'} + ]} + ] + }); + + const session = new ReviewSession({entryId: 5, request}); + const listener = jest.fn(); + session.on('reset', listener); + + await session.fetch(); + + expect(request).toHaveBeenCalledWith({ + url: '/review/entries/5/comment_threads', + method: 'GET' + }); + + expect(listener).toHaveBeenCalledWith({ + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [ + expect.objectContaining({ + id: 1, + comments: [expect.objectContaining({body: 'Hello'})] + }) + ] + }); + }); +}); diff --git a/package/spec/review/request-spec.js b/package/spec/review/request-spec.js new file mode 100644 index 0000000000..cc8b98b8bb --- /dev/null +++ b/package/spec/review/request-spec.js @@ -0,0 +1,37 @@ +import {request} from 'review/request'; + +describe('request', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('performs request and returns parsed JSON', async () => { + window.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({some: 'data'}) + }); + + const result = await request({url: '/some/path', method: 'GET'}); + + expect(window.fetch).toHaveBeenCalledWith('/some/path', { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json' + } + }); + expect(result).toEqual({some: 'data'}); + }); + + it('throws on non-ok response', async () => { + window.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity' + }); + + await expect( + request({url: '/some/path', method: 'GET'}) + ).rejects.toThrow('422'); + }); +}); diff --git a/package/src/review/ReviewSession.js b/package/src/review/ReviewSession.js new file mode 100644 index 0000000000..ab372bd48a --- /dev/null +++ b/package/src/review/ReviewSession.js @@ -0,0 +1,22 @@ +import BackboneEvents from 'backbone-events-standalone'; + +export class ReviewSession { + constructor({entryId, request}) { + this._entryId = entryId; + this._request = request; + } + + async fetch() { + const data = await this._request({ + url: `/review/entries/${this._entryId}/comment_threads`, + method: 'GET' + }); + + this.trigger('reset', { + currentUser: data.currentUser, + commentThreads: data.commentThreads + }); + } +} + +Object.assign(ReviewSession.prototype, BackboneEvents); diff --git a/package/src/review/index.js b/package/src/review/index.js new file mode 100644 index 0000000000..dd4dbbe8e6 --- /dev/null +++ b/package/src/review/index.js @@ -0,0 +1,8 @@ +import {ReviewSession} from './ReviewSession'; +import {request} from './request'; + +export {ReviewSession}; + +export function createReviewSession({entryId}) { + return new ReviewSession({entryId, request}); +} diff --git a/package/src/review/request.js b/package/src/review/request.js new file mode 100644 index 0000000000..64d5bc56cd --- /dev/null +++ b/package/src/review/request.js @@ -0,0 +1,15 @@ +export async function request({url, method}) { + const response = await fetch(url, { + method, + credentials: 'same-origin', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + return response.json(); +} diff --git a/rollup.config.js b/rollup.config.js index 228a24b736..210d7fc194 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -148,6 +148,15 @@ const pageflow = [ external, plugins: plugins() }, + { + input: pageflowPackageRoot + '/src/review/index.js', + output: { + file: pageflowPackageRoot + '/review.js', + format: 'esm' + }, + external, + plugins: plugins() + }, { input: pageflowPackageRoot + '/src/ui/index.js', output: { @@ -282,6 +291,16 @@ const pageflowScrolled = [ }), ...ignoreJSXWarning }, + { + input: pageflowScrolledPackageRoot + '/src/review/index.js', + output: { + file: pageflowScrolledPackageRoot + '/review.js', + format: 'esm', + }, + external, + plugins: plugins({extractCss: true}), + ...ignoreJSXWarning + }, { input: pageflowScrolledPackageRoot + '/src/frontend/server.js', output: { diff --git a/spec/controllers/pageflow/review/comment_threads_controller_spec.rb b/spec/controllers/pageflow/review/comment_threads_controller_spec.rb new file mode 100644 index 0000000000..41dc5b1b6f --- /dev/null +++ b/spec/controllers/pageflow/review/comment_threads_controller_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +module Pageflow + describe Review::CommentThreadsController do + routes { Engine.routes } + render_views + + describe '#index' do + it 'returns threads with comments for draft revision' do + user = create(:user) + entry = create(:entry, with_previewer: user) + + thread = create(:comment_thread, + revision: entry.draft, + creator: user) + comment = create(:comment, comment_thread: thread, creator: user) + + sign_in(user, scope: :user) + get(:index, params: {entry_id: entry.id}, format: 'json') + + expect(response.status).to eq(200) + expect(response.body).to include_json( + currentUser: { + id: user.id, + name: user.full_name + }, + commentThreads: [ + { + id: thread.id, + comments: [{id: comment.id}] + } + ] + ) + end + + it 'does not have N+1 queries' do + user = create(:user) + entry = create(:entry, with_previewer: user) + + thread = create(:comment_thread, + revision: entry.draft, + creator: user) + create(:comment, comment_thread: thread, creator: user) + create(:comment, comment_thread: thread, creator: create(:user)) + + sign_in(user, scope: :user) + + detect_n_plus_one_queries do + get(:index, params: {entry_id: entry.id}, format: 'json') + end + end + + it 'requires user to be signed in' do + entry = create(:entry) + + get(:index, params: {entry_id: entry.id}, format: 'json') + + expect(response.status).to eq(401) + end + + it 'requires read permission on entry' do + user = create(:user) + entry = create(:entry) + + sign_in(user, scope: :user) + get(:index, params: {entry_id: entry.id}, format: 'json') + + expect(response.status).to eq(403) + end + end + end +end diff --git a/spec/factories/comment_threads.rb b/spec/factories/comment_threads.rb new file mode 100644 index 0000000000..6c4fff82ce --- /dev/null +++ b/spec/factories/comment_threads.rb @@ -0,0 +1,10 @@ +module Pageflow + FactoryBot.define do + factory :comment_thread, class: CommentThread do + revision + creator factory: :user + subject_type { 'ContentElement' } + subject_id { 1 } + end + end +end diff --git a/spec/factories/comments.rb b/spec/factories/comments.rb new file mode 100644 index 0000000000..7e856dd300 --- /dev/null +++ b/spec/factories/comments.rb @@ -0,0 +1,9 @@ +module Pageflow + FactoryBot.define do + factory :comment, class: Comment do + comment_thread + creator factory: :user + body { 'A comment' } + end + end +end