Skip to content

Commit 09c1916

Browse files
authored
Merge pull request #2456 from tf/comment-quote
Preserve the wording each comment refers to
2 parents 64fa339 + 53337e4 commit 09c1916

29 files changed

Lines changed: 906 additions & 26 deletions

File tree

app/controllers/pageflow/review/comment_threads_controller.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,8 @@ def create
1919
@comment_thread = entry.comment_threads.build(thread_params)
2020
@comment_thread.creator = current_user
2121

22-
@comment_thread.comments.build(
23-
body: params[:comment_thread][:comment][:body],
24-
creator: current_user
25-
)
22+
first_comment = @comment_thread.comments.build(first_comment_params)
23+
first_comment.creator = current_user
2624

2725
@comment_thread.save!
2826
render :create, status: :created
@@ -51,6 +49,10 @@ def thread_params
5149
permitted[:subject_range] = params[:comment_thread][:subject_range]&.permit!
5250
permitted
5351
end
52+
53+
def first_comment_params
54+
params.require(:comment_thread).require(:comment).permit(:body, :quote)
55+
end
5456
end
5557
end
5658
end

app/controllers/pageflow/review/comments_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def create
2020
private
2121

2222
def comment_params
23-
params.require(:comment).permit(:body)
23+
params.require(:comment).permit(:body, :quote)
2424
end
2525
end
2626
end

app/models/pageflow/comment.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,28 @@ module Pageflow
33
class Comment < ApplicationRecord
44
include NestedRevisionComponent
55

6+
# Quotes are recorded by the client, so cut rather than reject: an
7+
# unexpectedly long selection must not keep a comment from being saved.
8+
# Kept in sync with the limit quote extraction applies in
9+
# entry_types/scrolled/package/src/review/subjectQuote.js, so that a
10+
# recorded quote stays comparable to the text as it reads later on.
11+
QUOTE_LIMIT = 4_000
12+
613
belongs_to :comment_thread
714
belongs_to :creator, class_name: 'User'
815

916
validates :body, presence: true
1017

18+
before_validation :truncate_quote
19+
1120
def entry_for_auto_generated_perma_id
1221
comment_thread.revision.entry
1322
end
23+
24+
private
25+
26+
def truncate_quote
27+
self.quote = quote[0, QUOTE_LIMIT] if quote
28+
end
1429
end
1530
end

app/views/pageflow/review/comments/_comment.json.jbuilder

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ json.call(comment,
55
:perma_id,
66
:creator_id,
77
:body,
8+
:quote,
89
:created_at,
910
:updated_at)
1011

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddQuoteToComments < ActiveRecord::Migration[7.1]
2+
def change
3+
add_column :pageflow_comments, :quote, :text
4+
end
5+
end
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import 'contentElements/heading/review';
2+
import {review} from 'review';
3+
4+
describe('contentElements/heading/review', () => {
5+
const extractQuote = review.contentElementTypes.findExtractQuote('heading');
6+
7+
function inlineValue(...texts) {
8+
return [{children: texts.map(text => ({text}))}];
9+
}
10+
11+
it('returns the heading text', () => {
12+
expect(extractQuote({value: inlineValue('A headline')})).toEqual('A headline');
13+
});
14+
15+
it('joins the text of adjacent marks', () => {
16+
expect(extractQuote({value: inlineValue('A ', 'bold', ' headline')}))
17+
.toEqual('A bold headline');
18+
});
19+
20+
it('includes tagline and subtitle in reading order', () => {
21+
const configuration = {
22+
tagline: inlineValue('Tagline'),
23+
value: inlineValue('A headline'),
24+
subtitle: inlineValue('Subtitle')
25+
};
26+
27+
expect(extractQuote(configuration)).toEqual('Tagline\nA headline\nSubtitle');
28+
});
29+
30+
it('skips blank taglines and subtitles', () => {
31+
const configuration = {
32+
tagline: inlineValue(''),
33+
value: inlineValue('A headline'),
34+
subtitle: undefined
35+
};
36+
37+
expect(extractQuote(configuration)).toEqual('A headline');
38+
});
39+
40+
it('falls back to the legacy string value', () => {
41+
expect(extractQuote({children: 'A legacy headline'})).toEqual('A legacy headline');
42+
});
43+
44+
it('prefers the inline text value over the legacy string value', () => {
45+
const configuration = {
46+
value: inlineValue('A headline'),
47+
children: 'A legacy headline'
48+
};
49+
50+
expect(extractQuote(configuration)).toEqual('A headline');
51+
});
52+
53+
it('does not fall back to the legacy value once the heading was emptied', () => {
54+
const configuration = {
55+
value: inlineValue(''),
56+
children: 'A legacy headline'
57+
};
58+
59+
expect(extractQuote(configuration)).toBeNull();
60+
});
61+
62+
it('returns null for headings without any text', () => {
63+
expect(extractQuote({})).toBeNull();
64+
});
65+
66+
it('ignores a range since heading comments cover the whole element', () => {
67+
const range = {anchor: {path: [0, 0], offset: 0}, focus: {path: [0, 0], offset: 1}};
68+
69+
expect(extractQuote({value: inlineValue('A headline')}, range)).toEqual('A headline');
70+
});
71+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import 'contentElements/review';
2+
import {review} from 'review';
3+
4+
describe('contentElements/review', () => {
5+
it('registers quote extraction of content element types in review api', () => {
6+
expect(review.contentElementTypes.findExtractQuote('heading')).toBeDefined();
7+
expect(review.contentElementTypes.findExtractQuote('textBlock')).toBeDefined();
8+
});
9+
});

entry_types/scrolled/package/spec/contentElements/textBlock/review-spec.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,45 @@ describe('contentElements/textBlock/review', () => {
4646
expect(compareRanges(r, undefined)).toBeLessThan(0);
4747
expect(compareRanges(undefined, undefined)).toBe(0);
4848
});
49+
50+
describe('extractQuote', () => {
51+
const extractQuote = review.contentElementTypes.findExtractQuote('textBlock');
52+
53+
const value = [
54+
{type: 'paragraph', children: [{text: 'The quick brown fox'}]},
55+
{type: 'paragraph', children: [{text: 'jumps over the lazy dog'}]}
56+
];
57+
58+
it('returns the text covered by the range', () => {
59+
expect(extractQuote({value}, range([0, 0, 4], [0, 0, 15]))).toEqual('quick brown');
60+
});
61+
62+
it('joins the text of blocks the range spans', () => {
63+
expect(extractQuote({value}, range([0, 0, 16], [1, 0, 5]))).toEqual('fox\njumps');
64+
});
65+
66+
it('handles ranges whose focus precedes their anchor', () => {
67+
expect(extractQuote({value}, range([0, 0, 15], [0, 0, 4]))).toEqual('quick brown');
68+
});
69+
70+
it('trims surrounding whitespace', () => {
71+
expect(extractQuote({value}, range([0, 0, 3], [0, 0, 16]))).toEqual('quick brown');
72+
});
73+
74+
it('returns null for ranges pointing past the end of the value', () => {
75+
expect(extractQuote({value}, range([5, 0, 0], [5, 0, 3]))).toBeNull();
76+
});
77+
78+
it('returns null for collapsed ranges', () => {
79+
expect(extractQuote({value}, range([0, 0, 4], [0, 0, 4]))).toBeNull();
80+
});
81+
82+
it('returns null for content elements without a value', () => {
83+
expect(extractQuote({}, range([0, 0, 0], [0, 0, 3]))).toBeNull();
84+
});
85+
86+
it('returns null for element wide comments without a range', () => {
87+
expect(extractQuote({value})).toBeNull();
88+
});
89+
});
4990
});

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

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import '@testing-library/jest-dom/extend-expect';
33
import {useFakeTranslations} from 'pageflow/testHelpers';
44

55
import {Thread} from 'review/Thread';
6+
import {review} from 'review/api';
67
import {renderWithReviewState} from 'support/renderWithReviewState';
78

89
describe('Thread', () => {
@@ -42,4 +43,131 @@ describe('Thread', () => {
4243
expect(hint.compareDocumentPosition(comment) & Node.DOCUMENT_POSITION_FOLLOWING)
4344
.toBeTruthy();
4445
});
46+
47+
describe('comment quotes', () => {
48+
const seed = {
49+
sections: [{id: 1, permaId: 1}],
50+
contentElements: [
51+
{
52+
id: 1, permaId: 10, sectionId: 1, typeName: 'textBlock',
53+
configuration: {value: 'Current wording'}
54+
}
55+
]
56+
};
57+
58+
const quotingThread = {
59+
...thread,
60+
subjectType: 'ContentElement',
61+
subjectId: 10,
62+
subjectRange: {anchor: {path: [0, 0], offset: 0}, focus: {path: [0, 0], offset: 7}}
63+
};
64+
65+
function threadWithQuotes(...quotes) {
66+
return {
67+
...quotingThread,
68+
comments: quotes.map((quote, index) => ({
69+
id: 10 + index,
70+
body: `Comment ${index + 1}`,
71+
creatorName: 'Bob',
72+
creatorId: 2,
73+
quote
74+
}))
75+
};
76+
}
77+
78+
beforeEach(() => {
79+
review.contentElementTypes.register('textBlock', {
80+
extractQuote: configuration => configuration.value
81+
});
82+
});
83+
84+
afterEach(() => {
85+
review.contentElementTypes.types = {};
86+
});
87+
88+
it('renders a quote for a comment whose text has since changed', () => {
89+
const {getByText} = renderWithReviewState(
90+
<Thread thread={threadWithQuotes('Original wording')} interactive={false} />,
91+
{seed}
92+
);
93+
94+
expect(getByText('Original wording')).toBeInTheDocument();
95+
});
96+
97+
it('renders no quote while the text still reads the same', () => {
98+
const {container} = renderWithReviewState(
99+
<Thread thread={threadWithQuotes('Current wording')} interactive={false} />,
100+
{seed}
101+
);
102+
103+
expect(container.querySelector('blockquote')).toBeNull();
104+
});
105+
106+
it('renders the quote inside the comment, above its body', () => {
107+
const {getByText} = renderWithReviewState(
108+
<Thread thread={threadWithQuotes('Original wording')} interactive={false} />,
109+
{seed}
110+
);
111+
112+
const quote = getByText('Original wording');
113+
const body = getByText('Comment 1');
114+
115+
expect(quote.parentNode).toBe(body.parentNode);
116+
expect(quote.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING)
117+
.toBeTruthy();
118+
});
119+
120+
it('renders a quote per version once the text changes mid thread', () => {
121+
const {getByText} = renderWithReviewState(
122+
<Thread thread={threadWithQuotes('First wording', 'Second wording')}
123+
interactive={false} />,
124+
{seed}
125+
);
126+
127+
expect(getByText('First wording')).toBeInTheDocument();
128+
expect(getByText('Second wording')).toBeInTheDocument();
129+
});
130+
131+
it('renders the quote once for a run of replies about the same wording', () => {
132+
const {queryAllByText} = renderWithReviewState(
133+
<Thread thread={threadWithQuotes('Same wording', 'Same wording')}
134+
interactive={false} />,
135+
{seed}
136+
);
137+
138+
expect(queryAllByText('Same wording')).toHaveLength(1);
139+
});
140+
141+
it('renders no quote for the last comment when it matches the current text', () => {
142+
const {getByText, queryByText} = renderWithReviewState(
143+
<Thread thread={threadWithQuotes('Original wording', 'Current wording')}
144+
interactive={false} />,
145+
{seed}
146+
);
147+
148+
expect(getByText('Original wording')).toBeInTheDocument();
149+
expect(queryByText('Current wording')).not.toBeInTheDocument();
150+
});
151+
152+
it('renders quotes for threads whose content element was deleted', () => {
153+
const {getByText} = renderWithReviewState(
154+
<Thread thread={{...threadWithQuotes('Original wording'),
155+
subjectId: 999,
156+
orphaned: true}}
157+
interactive={false} />,
158+
{seed}
159+
);
160+
161+
expect(getByText('Original wording')).toBeInTheDocument();
162+
});
163+
164+
it('renders no quote for comments recorded without one', () => {
165+
const {container} = renderWithReviewState(
166+
<Thread thread={quotingThread} interactive={false} />,
167+
{seed}
168+
);
169+
170+
expect(container.querySelector('blockquote')).toBeNull();
171+
});
172+
});
45173
});

0 commit comments

Comments
 (0)