Skip to content

Commit 935ffec

Browse files
authored
Merge pull request #2410 from tf/split-merge-comment-ranges
Split merge comment ranges
2 parents 8af6a29 + 889de81 commit 935ffec

20 files changed

Lines changed: 1215 additions & 84 deletions

File tree

app/models/pageflow/comment_thread.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ def unresolve
2121
update!(resolved_at: nil, resolver: nil)
2222
end
2323

24+
def self.migrate_to_subject(revision:, subject_type:, subject_id:, thread_ids:)
25+
return if thread_ids.blank?
26+
27+
where(revision_id: revision.id,
28+
subject_type:,
29+
id: thread_ids).update_all(subject_id:, updated_at: Time.current)
30+
end
31+
2432
def self.update_subject_ranges_for(revision:, subject_type:, subject_id:, ranges:)
2533
return if ranges.blank?
2634

entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@ def batch
1717
section = Section.all_for_revision(@entry.draft).find(params[:section_id])
1818

1919
items = params.require(:content_elements).map do |item|
20-
item.transform_keys(&:underscore).permit(:id, :type_name, :_delete, configuration: {})
20+
item.transform_keys(&:underscore).permit(:id, :type_name, :_delete,
21+
configuration: {},
22+
migrate_comment_threads: [])
2123
end
2224

23-
@content_elements = ContentElement::Batch.new(section, items).save!
25+
@content_elements = ContentElement::Batch.new(
26+
section, items,
27+
comment_thread_subject_ranges: permitted_subject_ranges
28+
).save!
2429
rescue ActiveRecord::RecordNotFound
2530
head :not_found
2631
end

entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,26 @@ def self.select_used_type_names(revision, type_names)
3333

3434
# @api private
3535
class Batch
36-
def initialize(section, items)
36+
def initialize(section, items, comment_thread_subject_ranges: nil)
3737
@section = section
3838
@storyline = section.chapter.storyline
3939
@items = items
40+
@comment_thread_subject_ranges = comment_thread_subject_ranges
4041
end
4142

4243
def save!
4344
ContentElement.transaction do
44-
@items.map.with_index { |item, index|
45+
result = @items.map.with_index { |item, index|
4546
if item[:_delete]
4647
@section.content_elements.delete(item[:id])
4748
nil
4849
else
4950
create_or_update(item, index)
5051
end
5152
}.compact
53+
54+
update_subject_ranges
55+
result
5256
end
5357
end
5458

@@ -60,11 +64,34 @@ def create_or_update(item, index)
6064
position: index
6165
}.merge(item.slice(:type_name, :configuration))
6266

63-
if item[:id]
64-
@storyline.content_elements.update(item[:id], attributes)
65-
else
66-
@section.content_elements.create(attributes)
67-
end
67+
content_element = if item[:id]
68+
@storyline.content_elements.update(item[:id], attributes)
69+
else
70+
@section.content_elements.create(attributes)
71+
end
72+
73+
migrate_comment_threads(content_element, item[:migrate_comment_threads])
74+
content_element
75+
end
76+
77+
def migrate_comment_threads(content_element, thread_ids)
78+
Pageflow::CommentThread.migrate_to_subject(
79+
revision: @storyline.revision,
80+
subject_type: 'ContentElement',
81+
subject_id: content_element.perma_id,
82+
thread_ids:
83+
)
84+
end
85+
86+
def update_subject_ranges
87+
return if @comment_thread_subject_ranges.blank?
88+
89+
Pageflow::CommentThread.update_subject_ranges_for(
90+
revision: @storyline.revision,
91+
subject_type: 'ContentElement',
92+
subject_id: @section.content_elements.pluck(:perma_id),
93+
ranges: @comment_thread_subject_ranges
94+
)
6895
end
6996
end
7097
end
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import 'contentElements/textBlock/editor';
2+
import {editor} from 'editor';
3+
4+
describe('contentElements/textBlock/editor', () => {
5+
const type = editor.contentElementTypes.findByTypeName('textBlock');
6+
7+
function paragraph(text) {
8+
return {type: 'paragraph', children: [{text}]};
9+
}
10+
11+
describe('#split', () => {
12+
it('splits configuration at the given block index', () => {
13+
const configuration = {value: [paragraph('first'), paragraph('second')]};
14+
15+
const {before, after} = type.split(configuration, 1, {ranges: {}});
16+
17+
expect(before.configuration.value).toEqual([paragraph('first')]);
18+
expect(after.configuration.value).toEqual([paragraph('second')]);
19+
});
20+
21+
it('leaves thread on the before side when range is fully before split', () => {
22+
const configuration = {
23+
value: [paragraph('first'), paragraph('second'), paragraph('third')]
24+
};
25+
const ranges = {
26+
7: {anchor: {path: [0, 0], offset: 0},
27+
focus: {path: [0, 0], offset: 3}}
28+
};
29+
30+
const {before, after} = type.split(configuration, 2, {ranges});
31+
32+
expect(before.ranges).toEqual(ranges);
33+
expect(after.ranges).toEqual({});
34+
});
35+
36+
it('rebases thread on the after side when range is fully after split', () => {
37+
const configuration = {
38+
value: [paragraph('first'), paragraph('second'), paragraph('third')]
39+
};
40+
const ranges = {
41+
7: {anchor: {path: [2, 0], offset: 0},
42+
focus: {path: [2, 0], offset: 3}}
43+
};
44+
45+
const {before, after} = type.split(configuration, 2, {ranges});
46+
47+
expect(before.ranges).toEqual({});
48+
expect(after.ranges).toEqual({
49+
7: {anchor: {path: [0, 0], offset: 0},
50+
focus: {path: [0, 0], offset: 3}}
51+
});
52+
});
53+
54+
it('clamps thread straddling the split point to the end of last before-block', () => {
55+
const configuration = {
56+
value: [paragraph('first'), paragraph('second'), paragraph('third')]
57+
};
58+
const ranges = {
59+
7: {anchor: {path: [0, 0], offset: 1},
60+
focus: {path: [2, 0], offset: 2}}
61+
};
62+
63+
const {before, after} = type.split(configuration, 2, {ranges});
64+
65+
expect(before.ranges).toEqual({
66+
7: {anchor: {path: [0, 0], offset: 1},
67+
focus: {path: [1, 0], offset: 'second'.length}}
68+
});
69+
expect(after.ranges).toEqual({});
70+
});
71+
});
72+
73+
describe('#merge', () => {
74+
it('concatenates values of both configurations', () => {
75+
const configA = {value: [paragraph('a')]};
76+
const configB = {value: [paragraph('b')]};
77+
78+
const {configuration} = type.merge(configA, configB,
79+
{rangesA: {}, rangesB: {}});
80+
81+
expect(configuration.value).toEqual([paragraph('a'), paragraph('b')]);
82+
});
83+
84+
it('keeps A-side ranges unchanged', () => {
85+
const configA = {value: [paragraph('one'), paragraph('two')]};
86+
const configB = {value: [paragraph('three')]};
87+
const rangesA = {
88+
7: {anchor: {path: [0, 0], offset: 0},
89+
focus: {path: [0, 0], offset: 3}}
90+
};
91+
92+
const {ranges} = type.merge(configA, configB, {rangesA, rangesB: {}});
93+
94+
expect(ranges).toEqual(rangesA);
95+
});
96+
97+
it('shifts B-side ranges by the length of A', () => {
98+
const configA = {value: [paragraph('one'), paragraph('two')]};
99+
const configB = {value: [paragraph('three'), paragraph('four')]};
100+
const rangesB = {
101+
8: {anchor: {path: [0, 0], offset: 0},
102+
focus: {path: [1, 0], offset: 4}}
103+
};
104+
105+
const {ranges} = type.merge(configA, configB, {rangesA: {}, rangesB});
106+
107+
expect(ranges).toEqual({
108+
8: {anchor: {path: [2, 0], offset: 0},
109+
focus: {path: [3, 0], offset: 4}}
110+
});
111+
});
112+
113+
it('combines both sides when both have ranges', () => {
114+
const configA = {value: [paragraph('one')]};
115+
const configB = {value: [paragraph('two')]};
116+
const rangesA = {
117+
7: {anchor: {path: [0, 0], offset: 0}, focus: {path: [0, 0], offset: 3}}
118+
};
119+
const rangesB = {
120+
8: {anchor: {path: [0, 0], offset: 0}, focus: {path: [0, 0], offset: 3}}
121+
};
122+
123+
const {ranges} = type.merge(configA, configB, {rangesA, rangesB});
124+
125+
expect(ranges).toEqual({
126+
7: {anchor: {path: [0, 0], offset: 0}, focus: {path: [0, 0], offset: 3}},
127+
8: {anchor: {path: [1, 0], offset: 0}, focus: {path: [1, 0], offset: 3}}
128+
});
129+
});
130+
});
131+
});

entry_types/scrolled/package/spec/editor/models/ScrolledEntry/deleteContentElement-spec.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,5 +256,64 @@ describe('ScrolledEntry', () => {
256256
});
257257
});
258258
});
259+
260+
describe('for range-aware content elements with comment threads', () => {
261+
beforeEach(() => {
262+
editor.contentElementTypes.register('rangeAware', {
263+
getLength(configuration) {
264+
return configuration.items.length;
265+
},
266+
267+
merge(configurationA, configurationB, {rangesA, rangesB}) {
268+
const offset = configurationA.items.length;
269+
const ranges = {...rangesA};
270+
Object.entries(rangesB).forEach(([id, {start, end}]) => {
271+
ranges[id] = {start: start + offset, end: end + offset};
272+
});
273+
return {
274+
configuration: {items: configurationA.items.concat(configurationB.items)},
275+
ranges
276+
};
277+
}
278+
});
279+
280+
testContext.entry = factories.entry(ScrolledEntry, {id: 100}, {
281+
entryTypeSeed: normalizeSeed({
282+
contentElements: [
283+
{id: 4, permaId: 40, position: 0, typeName: 'rangeAware',
284+
configuration: {items: ['a', 'b']}},
285+
{id: 5, permaId: 50, position: 1},
286+
{id: 6, permaId: 60, position: 2, typeName: 'rangeAware',
287+
configuration: {items: ['c', 'd']}}
288+
]
289+
})
290+
});
291+
292+
testContext.entry.reviewSession = factories.reviewSession({
293+
commentThreads: [
294+
{id: 7, subjectType: 'ContentElement', subjectId: 40,
295+
subjectRange: {start: 0, end: 1}, comments: []},
296+
{id: 8, subjectType: 'ContentElement', subjectId: 60,
297+
subjectRange: {start: 0, end: 1}, comments: []}
298+
]
299+
});
300+
});
301+
302+
setupGlobals({entry: () => testContext.entry});
303+
304+
it('migrates threads from the removed block onto the surviving merged block', () => {
305+
const {entry, requests} = testContext;
306+
307+
entry.deleteContentElement(entry.contentElements.get(5));
308+
309+
const body = JSON.parse(requests[0].requestBody);
310+
const survivor = body.content_elements.find(item => item.id === 4);
311+
312+
expect(survivor.migrate_comment_threads).toEqual([8]);
313+
expect(body.comment_thread_subject_ranges).toEqual({
314+
8: {start: 2, end: 3}
315+
});
316+
});
317+
});
259318
});
260319
});

entry_types/scrolled/package/spec/editor/models/ScrolledEntry/insertContentElement-spec.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,5 +886,79 @@ describe('ScrolledEntry', () => {
886886
);
887887
});
888888
});
889+
890+
describe('for range-aware content elements with comment threads', () => {
891+
beforeEach(() => {
892+
editor.contentElementTypes.register('rangeAware', {
893+
getLength(configuration) {
894+
return configuration.items.length;
895+
},
896+
897+
split(configuration, at, {ranges}) {
898+
const beforeRanges = {};
899+
const afterRanges = {};
900+
Object.entries(ranges).forEach(([id, {start, end}]) => {
901+
if (end <= at) {
902+
beforeRanges[id] = {start, end};
903+
}
904+
else if (start >= at) {
905+
afterRanges[id] = {start: start - at, end: end - at};
906+
}
907+
else {
908+
beforeRanges[id] = {start, end: at};
909+
}
910+
});
911+
return {
912+
before: {
913+
configuration: {items: configuration.items.slice(0, at)},
914+
ranges: beforeRanges
915+
},
916+
after: {
917+
configuration: {items: configuration.items.slice(at)},
918+
ranges: afterRanges
919+
}
920+
};
921+
}
922+
});
923+
924+
testContext.entry = factories.entry(ScrolledEntry, {id: 100}, {
925+
entryTypeSeed: normalizeSeed({
926+
contentElements: [
927+
{id: 5, permaId: 50, position: 0, typeName: 'rangeAware',
928+
configuration: {items: ['a', 'b', 'c']}}
929+
]
930+
})
931+
});
932+
933+
testContext.entry.reviewSession = factories.reviewSession({
934+
commentThreads: [
935+
{id: 7, subjectType: 'ContentElement', subjectId: 50,
936+
subjectRange: {start: 0, end: 1}, comments: []},
937+
{id: 8, subjectType: 'ContentElement', subjectId: 50,
938+
subjectRange: {start: 2, end: 3}, comments: []}
939+
]
940+
});
941+
});
942+
943+
setupGlobals({entry: () => testContext.entry});
944+
945+
it('migrates after-split threads onto the new split-off element', () => {
946+
const {entry, requests} = testContext;
947+
948+
entry.insertContentElement({typeName: 'inlineImage'},
949+
{at: 'split', id: 5, splitPoint: 2});
950+
951+
const body = JSON.parse(requests[0].requestBody);
952+
const splitOff = body.content_elements.find(
953+
item => item.typeName === 'rangeAware' && !item.id
954+
);
955+
956+
expect(splitOff).toBeDefined();
957+
expect(splitOff.migrate_comment_threads).toEqual([8]);
958+
expect(body.comment_thread_subject_ranges).toEqual({
959+
8: {start: 0, end: 1}
960+
});
961+
});
962+
});
889963
});
890964
});

0 commit comments

Comments
 (0)