diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml
index 580a53480..49e6c63b0 100644
--- a/entry_types/scrolled/config/locales/de.yml
+++ b/entry_types/scrolled/config/locales/de.yml
@@ -1161,6 +1161,9 @@ de:
duplicate_content_element_menu_item:
label: Element duplizieren
selection_label: Auswahl duplizieren
+ content_element_menu_items:
+ move: Element verschieben...
+ move_selection: Auswahl verschieben...
destroy_section_menu_item:
confirm_destroy: Abschnitt inklusive aller Elemente wirklich löschen?
destroy: Abschnitt löschen
@@ -1298,6 +1301,7 @@ de:
hide: Außerhalb des Editors ausblenden
insert_section_above: Abschnitt oberhalb einfügen
insert_section_below: Abschnitt unterhalb einfügen
+ move: Abschnitt verschieben...
reset_cutoff: Paywall Grenze entfernen
set_cutoff: Paywall Grenze oberhalb setzen
show: Außerhalb des Editors einblenden
@@ -1320,10 +1324,22 @@ de:
select_file: Biete eine Datei zum Download an
select_file_description: Lasse Leser eine Datei herunterladen, wenn sie auf den Link klicken.
select_in_sidebar: Datei auswählen
+ select_move_destination:
+ cancel: Abbrechen
+ header_insertPosition: Abschnitt verschieben
+ header_sectionPart: Element verschieben
+ hint: Wähle die Position aus, an die verschoben werden soll.
+ selectable_storyline_item:
+ blank_slate: Keine Kapitel
+ blank_slate_excursions: Keine Exkurse
selectable_chapter_item:
title: Kapitel auswählen
+ insert_here: Hierhin verschieben
selectable_section_item:
title: Abschnitt auswählen
+ insert_here: Hierhin verschieben
+ insert_at_beginning: An den Anfang
+ insert_at_end: Ans Ende
edit_motif_area_menu_item: Motivbereich markieren...
edit_motif_area_input:
select: Motivbereich auswählen
diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml
index dc5dba958..e7bc604fe 100644
--- a/entry_types/scrolled/config/locales/en.yml
+++ b/entry_types/scrolled/config/locales/en.yml
@@ -1145,6 +1145,9 @@ en:
duplicate_content_element_menu_item:
label: Duplicate element
selection_label: Duplicate selection
+ content_element_menu_items:
+ move: Move element...
+ move_selection: Move selection...
destroy_section_menu_item:
confirm_destroy: Really delete this section including all its elements?
destroy: Delete section
@@ -1282,6 +1285,7 @@ en:
hide: Hide outside of the editor
insert_section_above: Insert section above
insert_section_below: Insert section below
+ move: Move section...
reset_cutoff: Remove paywall cutoff
set_cutoff: Set paywall cutoff above
show: Show outside of the editor
@@ -1304,10 +1308,22 @@ en:
select_file: Provide file download
select_file_description: Let readers download a file when they click the link.
select_in_sidebar: Select file
+ select_move_destination:
+ cancel: Cancel
+ header_insertPosition: Move section
+ header_sectionPart: Move element
+ hint: Select the position to move to.
+ selectable_storyline_item:
+ blank_slate: No chapters
+ blank_slate_excursions: No excursions
selectable_chapter_item:
title: Select chapter
+ insert_here: Move here
selectable_section_item:
title: Select section
+ insert_here: Move here
+ insert_at_beginning: Move to beginning
+ insert_at_end: Move to end
edit_motif_area_menu_item: Select motif area...
edit_motif_area_input:
select: Select motif area
diff --git a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js
index 332170cc4..fa24dad7b 100644
--- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js
+++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js
@@ -264,6 +264,202 @@ describe('Chapter', () => {
});
});
+ describe('#moveSection', () => {
+ describe('within same chapter', () => {
+ beforeEach(() => {
+ testContext.entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ chapters: [{id: 10}],
+ sections: [
+ {id: 100, chapterId: 10, position: 0},
+ {id: 101, chapterId: 10, position: 1},
+ {id: 102, chapterId: 10, position: 2}
+ ]
+ })
+ });
+ });
+
+ setupGlobals({
+ entry: () => testContext.entry
+ });
+
+ useFakeXhr(() => testContext);
+
+ it('re-indexes sections when moving after another section', () => {
+ const {entry} = testContext;
+ const chapter = entry.chapters.first();
+ const sectionToMove = chapter.sections.get(100);
+ const targetSection = chapter.sections.get(102);
+
+ chapter.moveSection(sectionToMove, {after: targetSection});
+
+ expect(chapter.sections.pluck('position')).toEqual([0, 1, 2]);
+ expect(chapter.sections.pluck('id')).toEqual([101, 102, 100]);
+ });
+
+ it('triggers selectSection and scrollToSection events', () => {
+ const {entry} = testContext;
+ const chapter = entry.chapters.first();
+ const sectionToMove = chapter.sections.get(100);
+ const targetSection = chapter.sections.get(102);
+ const selectListener = jest.fn();
+ const scrollListener = jest.fn();
+
+ entry.on('selectSection', selectListener);
+ entry.on('scrollToSection', scrollListener);
+
+ chapter.moveSection(sectionToMove, {after: targetSection});
+
+ expect(selectListener).toHaveBeenCalledWith(sectionToMove);
+ expect(scrollListener).toHaveBeenCalledWith(sectionToMove);
+ });
+
+ it('calls saveOrder', () => {
+ const {entry, requests} = testContext;
+ const chapter = entry.chapters.first();
+ const sectionToMove = chapter.sections.get(100);
+ const targetSection = chapter.sections.get(102);
+
+ chapter.moveSection(sectionToMove, {after: targetSection});
+
+ expect(requests.length).toBe(1);
+ expect(requests[0].url).toContain('/order');
+ });
+
+ it('re-indexes sections when moving before another section', () => {
+ const {entry} = testContext;
+ const chapter = entry.chapters.first();
+ const sectionToMove = chapter.sections.get(102);
+ const targetSection = chapter.sections.get(100);
+
+ chapter.moveSection(sectionToMove, {before: targetSection});
+
+ expect(chapter.sections.pluck('position')).toEqual([0, 1, 2]);
+ expect(chapter.sections.pluck('id')).toEqual([102, 100, 101]);
+ });
+ });
+
+ describe('between chapters', () => {
+ beforeEach(() => {
+ testContext.entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ chapters: [{id: 10}, {id: 20}],
+ sections: [
+ {id: 100, chapterId: 10, position: 0},
+ {id: 101, chapterId: 10, position: 1},
+ {id: 200, chapterId: 20, position: 0},
+ {id: 201, chapterId: 20, position: 1}
+ ]
+ })
+ });
+ });
+
+ setupGlobals({
+ entry: () => testContext.entry
+ });
+
+ useFakeXhr(() => testContext);
+
+ it('moves section to different chapter', () => {
+ const {entry} = testContext;
+ const sourceChapter = entry.chapters.get(10);
+ const targetChapter = entry.chapters.get(20);
+ const sectionToMove = sourceChapter.sections.get(100);
+ const targetSection = targetChapter.sections.get(200);
+
+ targetChapter.moveSection(sectionToMove, {after: targetSection});
+
+ expect(sectionToMove.get('chapterId')).toBe(20);
+ expect(sourceChapter.sections.pluck('id')).toEqual([101]);
+ expect(targetChapter.sections.pluck('id')).toEqual([200, 100, 201]);
+ });
+
+ it('updates positions in target chapter', () => {
+ const {entry} = testContext;
+ const sourceChapter = entry.chapters.get(10);
+ const targetChapter = entry.chapters.get(20);
+ const sectionToMove = sourceChapter.sections.get(100);
+ const targetSection = targetChapter.sections.get(200);
+
+ targetChapter.moveSection(sectionToMove, {after: targetSection});
+
+ expect(sourceChapter.sections.pluck('position')).toEqual([1]);
+ expect(targetChapter.sections.pluck('position')).toEqual([0, 1, 2]);
+ });
+
+ it('calls saveOrder on target chapter', () => {
+ const {entry, requests} = testContext;
+ const sourceChapter = entry.chapters.get(10);
+ const targetChapter = entry.chapters.get(20);
+ const sectionToMove = sourceChapter.sections.get(100);
+ const targetSection = targetChapter.sections.get(200);
+
+ targetChapter.moveSection(sectionToMove, {after: targetSection});
+
+ expect(requests.length).toBe(1);
+ expect(requests[0].url).toContain('/chapters/20/sections/order');
+ });
+
+ it('moves section before target section in different chapter', () => {
+ const {entry} = testContext;
+ const sourceChapter = entry.chapters.get(10);
+ const targetChapter = entry.chapters.get(20);
+ const sectionToMove = sourceChapter.sections.get(100);
+ const targetSection = targetChapter.sections.get(201);
+
+ targetChapter.moveSection(sectionToMove, {before: targetSection});
+
+ expect(sectionToMove.get('chapterId')).toBe(20);
+ expect(sourceChapter.sections.pluck('id')).toEqual([101]);
+ expect(targetChapter.sections.pluck('id')).toEqual([200, 100, 201]);
+ });
+ });
+
+ describe('into empty chapter', () => {
+ beforeEach(() => {
+ testContext.entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ chapters: [{id: 10}, {id: 20}],
+ sections: [
+ {id: 100, chapterId: 10, position: 0},
+ {id: 101, chapterId: 10, position: 1}
+ ]
+ })
+ });
+ });
+
+ setupGlobals({
+ entry: () => testContext.entry
+ });
+
+ useFakeXhr(() => testContext);
+
+ it('moves section into empty chapter', () => {
+ const {entry} = testContext;
+ const sourceChapter = entry.chapters.get(10);
+ const targetChapter = entry.chapters.get(20);
+ const sectionToMove = sourceChapter.sections.get(100);
+
+ targetChapter.moveSection(sectionToMove);
+
+ expect(sectionToMove.get('chapterId')).toBe(20);
+ expect(sourceChapter.sections.pluck('id')).toEqual([101]);
+ expect(targetChapter.sections.pluck('id')).toEqual([100]);
+ });
+
+ it('sets position to 0 when moving into empty chapter', () => {
+ const {entry} = testContext;
+ const sourceChapter = entry.chapters.get(10);
+ const targetChapter = entry.chapters.get(20);
+ const sectionToMove = sourceChapter.sections.get(100);
+
+ targetChapter.moveSection(sectionToMove);
+
+ expect(targetChapter.sections.pluck('position')).toEqual([0]);
+ });
+ });
+ });
+
describe('#isExcursion', () => {
it('returns false for chapters in main storyline', () => {
const entry = factories.entry(ScrolledEntry, {}, {
diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/moveContentElement-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/moveContentElement-spec.js
index cc051e125..8344ff7a1 100644
--- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/moveContentElement-spec.js
+++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/moveContentElement-spec.js
@@ -62,6 +62,24 @@ describe('ScrolledEntry', () => {
expect(entry.sections.first().contentElements.pluck('position')).toEqual([0, 1, 2]);
});
+ it('calls success callback after save', () => {
+ const {entry, server} = testContext;
+ const success = jest.fn();
+
+ entry.moveContentElement({id: 7}, {at: 'before', id: 5}, {success});
+
+ expect(success).not.toHaveBeenCalled();
+
+ server.respond(
+ 'PUT', '/editor/entries/100/scrolled/sections/10/content_elements/batch',
+ [200, {'Content-Type': 'application/json'}, JSON.stringify([
+ {id: 7, permaId: 70}, {id: 5, permaId: 50}, {id: 6, permaId: 60}
+ ])]
+ );
+
+ expect(success).toHaveBeenCalled();
+ });
+
it('supports moving after other content element', () => {
const {entry, requests} = testContext;
diff --git a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js
index 098a95b17..d014a6dea 100644
--- a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js
+++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js
@@ -1,9 +1,20 @@
-import {DestroyContentElementMenuItem, DuplicateContentElementMenuItem} from 'editor/models/contentElementMenuItems';
+import {
+ DestroyContentElementMenuItem,
+ DuplicateContentElementMenuItem,
+ MoveContentElementMenuItem
+} from 'editor/models/contentElementMenuItems';
import {ScrolledEntry} from 'editor/models/ScrolledEntry';
+import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView';
import {useFakeTranslations} from 'pageflow/testHelpers';
import {factories, normalizeSeed} from 'support';
+jest.mock('editor/views/SelectMoveDestinationDialogView', () => ({
+ SelectMoveDestinationDialogView: {
+ show: jest.fn()
+ }
+}));
+
describe('ContentElementMenuItems', () => {
describe('DuplicateContentElementMenuItem', () => {
useFakeTranslations({
@@ -240,4 +251,195 @@ describe('ContentElementMenuItems', () => {
expect(entry.deleteContentElement).not.toHaveBeenCalled();
});
});
+
+ describe('MoveContentElementMenuItem', () => {
+ useFakeTranslations({
+ 'pageflow_scrolled.editor.content_element_menu_items.move': 'Move...',
+ 'pageflow_scrolled.editor.content_element_menu_items.move_selection': 'Move selection...'
+ });
+
+ beforeEach(() => {
+ SelectMoveDestinationDialogView.show.mockClear();
+ });
+
+ it('has Move label', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ contentElements: [{id: 1, typeName: 'textBlock'}]
+ })
+ });
+ const contentElement = entry.contentElements.get(1);
+ editor.contentElementTypes.register('textBlock', {});
+
+ const menuItem = new MoveContentElementMenuItem({}, {
+ contentElement,
+ entry,
+ editor
+ });
+
+ expect(menuItem.get('label')).toBe('Move...');
+ });
+
+ it('has Move selection label when handleMove is defined', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ contentElements: [{id: 1, typeName: 'textBlock'}]
+ })
+ });
+ const contentElement = entry.contentElements.get(1);
+ editor.contentElementTypes.register('textBlock', {handleMove() {}});
+
+ const menuItem = new MoveContentElementMenuItem({}, {
+ contentElement,
+ entry,
+ editor
+ });
+
+ expect(menuItem.get('label')).toBe('Move selection...');
+ });
+
+ it('shows dialog when selected', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ contentElements: [{id: 1, typeName: 'textBlock'}]
+ })
+ });
+ const contentElement = entry.contentElements.get(1);
+ editor.contentElementTypes.register('textBlock', {});
+
+ const menuItem = new MoveContentElementMenuItem({}, {
+ contentElement,
+ entry,
+ editor
+ });
+
+ menuItem.selected();
+
+ expect(SelectMoveDestinationDialogView.show).toHaveBeenCalledWith({
+ entry,
+ mode: 'sectionPart',
+ onSelect: expect.any(Function)
+ });
+ });
+
+ it('moves content element to beginning and scrolls on success', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [{id: 10}, {id: 20}],
+ contentElements: [
+ {id: 1, sectionId: 10, typeName: 'textBlock'},
+ {id: 2, sectionId: 20, typeName: 'textBlock'},
+ {id: 3, sectionId: 20, typeName: 'textBlock'}
+ ]
+ })
+ });
+ const contentElement = entry.contentElements.get(1);
+ const targetSection = entry.sections.get(20);
+ editor.contentElementTypes.register('textBlock', {});
+ entry.moveContentElement = jest.fn();
+ const scrollHandler = jest.fn();
+ entry.on('scrollToSection', scrollHandler);
+
+ const menuItem = new MoveContentElementMenuItem({}, {
+ contentElement,
+ entry,
+ editor
+ });
+
+ menuItem.selected();
+
+ const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect;
+ onSelect({section: targetSection, part: 'beginning'});
+
+ expect(entry.moveContentElement).toHaveBeenCalledWith(
+ {id: 1},
+ {at: 'before', id: 2},
+ {success: expect.any(Function)}
+ );
+
+ entry.moveContentElement.mock.calls[0][2].success();
+
+ expect(scrollHandler).toHaveBeenCalledWith(targetSection, {align: 'nearStart'});
+ });
+
+ it('moves content element to end and scrolls on success', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [{id: 10}, {id: 20}],
+ contentElements: [
+ {id: 1, sectionId: 10, typeName: 'textBlock'},
+ {id: 2, sectionId: 20, typeName: 'textBlock'},
+ {id: 3, sectionId: 20, typeName: 'textBlock'}
+ ]
+ })
+ });
+ const contentElement = entry.contentElements.get(1);
+ const targetSection = entry.sections.get(20);
+ editor.contentElementTypes.register('textBlock', {});
+ entry.moveContentElement = jest.fn();
+ const scrollHandler = jest.fn();
+ entry.on('scrollToSection', scrollHandler);
+
+ const menuItem = new MoveContentElementMenuItem({}, {
+ contentElement,
+ entry,
+ editor
+ });
+
+ menuItem.selected();
+
+ const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect;
+ onSelect({section: targetSection, part: 'end'});
+
+ expect(entry.moveContentElement).toHaveBeenCalledWith(
+ {id: 1},
+ {at: 'after', id: 3},
+ {success: expect.any(Function)}
+ );
+
+ entry.moveContentElement.mock.calls[0][2].success();
+
+ expect(scrollHandler).toHaveBeenCalledWith(targetSection, {align: 'nearEnd'});
+ });
+
+ it('calls handleMove instead of moveContentElement if defined', () => {
+ const editor = factories.editorApi();
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [{id: 10}, {id: 20}],
+ contentElements: [
+ {id: 1, sectionId: 10, typeName: 'textBlock'},
+ {id: 2, sectionId: 20, typeName: 'textBlock'}
+ ]
+ })
+ });
+ const contentElement = entry.contentElements.get(1);
+ const targetSection = entry.sections.get(20);
+ const handleMove = jest.fn();
+ editor.contentElementTypes.register('textBlock', {handleMove});
+ entry.moveContentElement = jest.fn();
+
+ const menuItem = new MoveContentElementMenuItem({}, {
+ contentElement,
+ entry,
+ editor
+ });
+
+ menuItem.selected();
+
+ const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect;
+ onSelect({section: targetSection, part: 'beginning'});
+
+ expect(handleMove).toHaveBeenCalledWith(contentElement, {
+ at: 'before',
+ id: 2
+ });
+ expect(entry.moveContentElement).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js
index b6cc1285b..e46b8861f 100644
--- a/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js
+++ b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js
@@ -5,12 +5,20 @@ import {
InsertSectionBelowMenuItem,
CutoffSectionMenuItem,
CopyPermalinkMenuItem,
- DestroySectionMenuItem
+ DestroySectionMenuItem,
+ MoveSectionMenuItem
} from 'editor/models/sectionMenuItems';
+import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView';
import {useFakeTranslations} from 'pageflow/testHelpers';
import {useEditorGlobals} from 'support';
+jest.mock('editor/views/SelectMoveDestinationDialogView', () => ({
+ SelectMoveDestinationDialogView: {
+ show: jest.fn()
+ }
+}));
+
describe('SectionMenuItems', () => {
useFakeTranslations({
'pageflow_scrolled.editor.section_menu_items.hide': 'Hide',
@@ -21,6 +29,7 @@ describe('SectionMenuItems', () => {
'pageflow_scrolled.editor.section_menu_items.set_cutoff': 'Set cutoff',
'pageflow_scrolled.editor.section_menu_items.reset_cutoff': 'Reset cutoff',
'pageflow_scrolled.editor.section_menu_items.copy_permalink': 'Copy permalink',
+ 'pageflow_scrolled.editor.section_menu_items.move': 'Move...',
'pageflow_scrolled.editor.destroy_section_menu_item.destroy': 'Delete section',
'pageflow_scrolled.editor.destroy_section_menu_item.confirm_destroy': 'Really delete this section?'
});
@@ -259,4 +268,94 @@ describe('SectionMenuItems', () => {
expect(section.destroyWithDelay).toHaveBeenCalled();
});
});
+
+ describe('MoveSectionMenuItem', () => {
+ beforeEach(() => {
+ SelectMoveDestinationDialogView.show.mockClear();
+ });
+
+ it('has Move label', () => {
+ const entry = createEntry({sections: [{id: 1}]});
+ const section = entry.sections.get(1);
+ const menuItem = new MoveSectionMenuItem({}, {entry, section});
+
+ expect(menuItem.get('label')).toBe('Move...');
+ });
+
+ it('shows dialog when selected', () => {
+ const entry = createEntry({sections: [{id: 1}]});
+ const section = entry.sections.get(1);
+ const menuItem = new MoveSectionMenuItem({}, {entry, section});
+
+ menuItem.selected();
+
+ expect(SelectMoveDestinationDialogView.show).toHaveBeenCalledWith({
+ entry,
+ mode: 'insertPosition',
+ onSelect: expect.any(Function)
+ });
+ });
+
+ it('moves section after target when position is after', () => {
+ const entry = createEntry({
+ chapters: [{id: 10}, {id: 20}],
+ sections: [
+ {id: 1, chapterId: 10},
+ {id: 2, chapterId: 20}
+ ]
+ });
+ const section = entry.sections.get(1);
+ const targetSection = entry.sections.get(2);
+ const menuItem = new MoveSectionMenuItem({}, {entry, section});
+
+ menuItem.selected();
+
+ const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect;
+ targetSection.chapter.moveSection = jest.fn();
+ onSelect({section: targetSection, position: 'after'});
+
+ expect(targetSection.chapter.moveSection).toHaveBeenCalledWith(section, {after: targetSection});
+ });
+
+ it('moves section before target when position is before', () => {
+ const entry = createEntry({
+ chapters: [{id: 10}, {id: 20}],
+ sections: [
+ {id: 1, chapterId: 10},
+ {id: 2, chapterId: 20}
+ ]
+ });
+ const section = entry.sections.get(1);
+ const targetSection = entry.sections.get(2);
+ const menuItem = new MoveSectionMenuItem({}, {entry, section});
+
+ menuItem.selected();
+
+ const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect;
+ targetSection.chapter.moveSection = jest.fn();
+ onSelect({section: targetSection, position: 'before'});
+
+ expect(targetSection.chapter.moveSection).toHaveBeenCalledWith(section, {before: targetSection});
+ });
+
+ it('moves section into empty chapter when position is into', () => {
+ const entry = createEntry({
+ chapters: [{id: 10}, {id: 20}],
+ sections: [
+ {id: 1, chapterId: 10}
+ ]
+ });
+ const section = entry.sections.get(1);
+ const targetChapter = entry.chapters.get(20);
+ const menuItem = new MoveSectionMenuItem({}, {entry, section});
+
+ menuItem.selected();
+
+ const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect;
+ targetChapter.moveSection = jest.fn();
+ onSelect({chapter: targetChapter, position: 'into'});
+
+ expect(targetChapter.moveSection).toHaveBeenCalledWith(section);
+ });
+ });
});
diff --git a/entry_types/scrolled/package/spec/editor/views/SelectMoveDestinationDialogView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectMoveDestinationDialogView-spec.js
new file mode 100644
index 000000000..35495c9fa
--- /dev/null
+++ b/entry_types/scrolled/package/spec/editor/views/SelectMoveDestinationDialogView-spec.js
@@ -0,0 +1,146 @@
+import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView';
+import {ScrolledEntry} from 'editor/models/ScrolledEntry';
+
+import {factories, normalizeSeed} from 'support';
+import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers';
+
+import userEvent from '@testing-library/user-event';
+
+describe('SelectMoveDestinationDialogView', () => {
+ useFakeTranslations({
+ 'pageflow_scrolled.editor.select_move_destination.header_insertPosition': 'Move section',
+ 'pageflow_scrolled.editor.select_move_destination.header_sectionPart': 'Move element',
+ 'pageflow_scrolled.editor.select_move_destination.hint': 'Select the position to move to.',
+ 'pageflow_scrolled.editor.select_move_destination.cancel': 'Cancel',
+ 'pageflow_scrolled.editor.selectable_section_item.title': 'Select section',
+ 'pageflow_scrolled.editor.selectable_section_item.insert_here': 'Move here',
+ 'pageflow_scrolled.editor.selectable_section_item.insert_at_beginning': 'Move to beginning',
+ 'pageflow_scrolled.editor.selectable_section_item.insert_at_end': 'Move to end'
+ });
+
+ it('renders title for insertPosition mode', () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [{id: 1, permaId: 10}]
+ })
+ });
+ const view = new SelectMoveDestinationDialogView({
+ entry,
+ mode: 'insertPosition',
+ onSelect: jest.fn()
+ });
+
+ const {getByRole} = render(view);
+
+ expect(getByRole('heading', {name: 'Move section'})).toBeTruthy();
+ });
+
+ it('renders title for sectionPart mode', () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [{id: 1, permaId: 10}]
+ })
+ });
+ const view = new SelectMoveDestinationDialogView({
+ entry,
+ mode: 'sectionPart',
+ onSelect: jest.fn()
+ });
+
+ const {getByRole} = render(view);
+
+ expect(getByRole('heading', {name: 'Move element'})).toBeTruthy();
+ });
+
+ it('allows selecting section in insertPosition mode', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [
+ {id: 1, permaId: 10}
+ ]
+ })
+ });
+ const listener = jest.fn();
+ const view = new SelectMoveDestinationDialogView({
+ entry,
+ mode: 'insertPosition',
+ onSelect: listener
+ });
+
+ const user = userEvent.setup();
+ const {getAllByText} = render(view);
+ await user.click(getAllByText('Move here')[0]);
+
+ expect(listener).toHaveBeenCalledWith({
+ section: entry.sections.get(1),
+ position: 'before'
+ });
+ });
+
+ it('allows selecting section part in sectionPart mode', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [
+ {id: 1, permaId: 10}
+ ]
+ })
+ });
+ const listener = jest.fn();
+ const view = new SelectMoveDestinationDialogView({
+ entry,
+ mode: 'sectionPart',
+ onSelect: listener
+ });
+
+ const user = userEvent.setup();
+ const {getByText} = render(view);
+ await user.click(getByText('Move to beginning'));
+
+ expect(listener).toHaveBeenCalledWith({
+ section: entry.sections.get(1),
+ part: 'beginning'
+ });
+ });
+
+ it('closes when section is selected', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [
+ {id: 1, permaId: 10}
+ ]
+ })
+ });
+ const view = new SelectMoveDestinationDialogView({
+ entry,
+ mode: 'insertPosition',
+ onSelect: jest.fn()
+ });
+ view.close = jest.fn();
+
+ const user = userEvent.setup();
+ const {getAllByText} = render(view);
+ await user.click(getAllByText('Move here')[0]);
+
+ expect(view.close).toHaveBeenCalled();
+ });
+
+ it('closes when cancel button is clicked', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [{id: 1}]
+ })
+ });
+ const view = new SelectMoveDestinationDialogView({
+ entry,
+ mode: 'insertPosition',
+ onSelect: jest.fn()
+ });
+ view.close = jest.fn();
+
+ const user = userEvent.setup();
+ const {getByRole} = render(view);
+ await user.click(getByRole('button', {name: 'Cancel'}));
+
+ expect(view.close).toHaveBeenCalled();
+ });
+});
diff --git a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js
new file mode 100644
index 000000000..b279b1cb1
--- /dev/null
+++ b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js
@@ -0,0 +1,259 @@
+import {SelectableEntryOutlineView} from 'editor/views/SelectableEntryOutlineView';
+import {ScrolledEntry} from 'editor/models/ScrolledEntry';
+
+import {factories, normalizeSeed} from 'support';
+import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers';
+
+import userEvent from '@testing-library/user-event';
+
+describe('SelectableEntryOutlineView', () => {
+ useFakeTranslations({
+ 'pageflow_scrolled.editor.selectable_storyline_item.blank_slate': 'No chapters',
+ 'pageflow_scrolled.editor.selectable_storyline_item.blank_slate_excursions': 'No excursions',
+ 'pageflow_scrolled.editor.selectable_chapter_item.title': 'Select chapter',
+ 'pageflow_scrolled.editor.selectable_chapter_item.insert_here': 'Move to chapter',
+ 'pageflow_scrolled.editor.selectable_section_item.title': 'Select section',
+ 'pageflow_scrolled.editor.selectable_section_item.insert_here': 'Move here',
+ 'pageflow_scrolled.editor.selectable_section_item.insert_at_beginning': 'Move to beginning',
+ 'pageflow_scrolled.editor.selectable_section_item.insert_at_end': 'Move to end'
+ });
+
+ describe('in default mode', () => {
+ it('allows selecting chapter', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ chapters: [{id: 1, permaId: 10}],
+ sections: [{id: 1, chapterId: 1}]
+ })
+ });
+ const listener = jest.fn();
+ const view = new SelectableEntryOutlineView({
+ entry,
+ onSelectChapter: listener,
+ onSelectSection: jest.fn()
+ });
+
+ const user = userEvent.setup();
+ const {getByTitle} = render(view);
+ await user.click(getByTitle('Select chapter'));
+
+ expect(listener).toHaveBeenCalledWith(entry.chapters.get(1));
+ });
+
+ it('allows selecting section', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ chapters: [{id: 1, permaId: 10}],
+ sections: [{id: 1, permaId: 100, chapterId: 1}]
+ })
+ });
+ const listener = jest.fn();
+ const view = new SelectableEntryOutlineView({
+ entry,
+ onSelectChapter: jest.fn(),
+ onSelectSection: listener
+ });
+
+ const user = userEvent.setup();
+ const {getByTitle} = render(view);
+ await user.click(getByTitle('Select section'));
+
+ expect(listener).toHaveBeenCalledWith(entry.sections.get(1));
+ });
+ });
+
+ describe('with mode insertPosition', () => {
+ it('renders indicator inside upper mask', () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [{id: 1, permaId: 10}]
+ })
+ });
+ const view = new SelectableEntryOutlineView({
+ entry,
+ mode: 'insertPosition',
+ onSelectInsertPosition: jest.fn()
+ });
+
+ const {getAllByText} = render(view);
+
+ expect(getAllByText('Move here').length).toBe(2);
+ });
+
+ it('clicking upper mask calls onSelectInsertPosition with position before', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [
+ {id: 1, permaId: 10}
+ ]
+ })
+ });
+ const listener = jest.fn();
+ const view = new SelectableEntryOutlineView({
+ entry,
+ mode: 'insertPosition',
+ onSelectInsertPosition: listener
+ });
+
+ const user = userEvent.setup();
+ const {getAllByText} = render(view);
+ await user.click(getAllByText('Move here')[0]);
+
+ expect(listener).toHaveBeenCalledWith({
+ section: entry.sections.get(1),
+ position: 'before'
+ });
+ });
+
+ it('clicking lower mask calls onSelectInsertPosition with position after', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [
+ {id: 1, permaId: 10}
+ ]
+ })
+ });
+ const listener = jest.fn();
+ const view = new SelectableEntryOutlineView({
+ entry,
+ mode: 'insertPosition',
+ onSelectInsertPosition: listener
+ });
+
+ const user = userEvent.setup();
+ const {getAllByText} = render(view);
+ await user.click(getAllByText('Move here')[1]);
+
+ expect(listener).toHaveBeenCalledWith({
+ section: entry.sections.get(1),
+ position: 'after'
+ });
+ });
+
+ it('clicking empty chapter insert mask calls onSelectInsertPosition with position into', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ chapters: [{id: 1}]
+ })
+ });
+ const listener = jest.fn();
+ const view = new SelectableEntryOutlineView({
+ entry,
+ mode: 'insertPosition',
+ onSelectInsertPosition: listener
+ });
+
+ const user = userEvent.setup();
+ const {getByText} = render(view);
+ await user.click(getByText('Move to chapter'));
+
+ expect(listener).toHaveBeenCalledWith({
+ chapter: entry.chapters.get(1),
+ position: 'into'
+ });
+ });
+ });
+
+ describe('with mode sectionPart', () => {
+ it('renders indicator inside upper mask', () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [{id: 1, permaId: 10}]
+ })
+ });
+ const view = new SelectableEntryOutlineView({
+ entry,
+ mode: 'sectionPart',
+ onSelectSectionPart: jest.fn()
+ });
+
+ const {getByText} = render(view);
+
+ expect(getByText('Move to beginning')).toBeTruthy();
+ });
+
+ it('renders indicator inside lower mask', () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [{id: 1, permaId: 10}]
+ })
+ });
+ const view = new SelectableEntryOutlineView({
+ entry,
+ mode: 'sectionPart',
+ onSelectSectionPart: jest.fn()
+ });
+
+ const {getByText} = render(view);
+
+ expect(getByText('Move to end')).toBeTruthy();
+ });
+
+ it('clicking upper mask calls onSelectSectionPart with part beginning', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [
+ {id: 1, permaId: 10}
+ ]
+ })
+ });
+ const listener = jest.fn();
+ const view = new SelectableEntryOutlineView({
+ entry,
+ mode: 'sectionPart',
+ onSelectSectionPart: listener
+ });
+
+ const user = userEvent.setup();
+ const {getByText} = render(view);
+ await user.click(getByText('Move to beginning'));
+
+ expect(listener).toHaveBeenCalledWith({
+ section: entry.sections.get(1),
+ part: 'beginning'
+ });
+ });
+
+ it('clicking lower mask calls onSelectSectionPart with part end', async () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed({
+ sections: [
+ {id: 1, permaId: 10}
+ ]
+ })
+ });
+ const listener = jest.fn();
+ const view = new SelectableEntryOutlineView({
+ entry,
+ mode: 'sectionPart',
+ onSelectSectionPart: listener
+ });
+
+ const user = userEvent.setup();
+ const {getByText} = render(view);
+ await user.click(getByText('Move to end'));
+
+ expect(listener).toHaveBeenCalledWith({
+ section: entry.sections.get(1),
+ part: 'end'
+ });
+ });
+ });
+
+ describe('blank slates', () => {
+ it('renders blank slate when main storyline has no chapters', () => {
+ const entry = factories.entry(ScrolledEntry, {}, {
+ entryTypeSeed: normalizeSeed()
+ });
+ const view = new SelectableEntryOutlineView({
+ entry,
+ mode: 'sectionPart',
+ onSelectSectionPart: jest.fn()
+ });
+
+ const {getByText} = render(view);
+
+ expect(getByText('No chapters')).toBeTruthy();
+ });
+ });
+});
diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/computeBounds-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/computeBounds-spec.js
new file mode 100644
index 000000000..068bda8a4
--- /dev/null
+++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/computeBounds-spec.js
@@ -0,0 +1,109 @@
+/** @jsx jsx */
+import {computeBounds} from 'frontend/inlineEditing/EditableText/computeBounds';
+
+import {createHyperscript} from 'slate-hyperscript';
+
+const h = createHyperscript({
+ elements: {
+ paragraph: {type: 'paragraph'},
+ heading: {type: 'heading'}
+ },
+});
+
+// Strip meta tags to make deep equality checks work
+const jsx = (tagName, attributes, ...children) => {
+ delete attributes.__self;
+ delete attributes.__source;
+ return h(tagName, attributes, ...children);
+}
+
+describe('computeBounds', () => {
+ it('returns bounds of single selected paragraph', () => {
+ const editor = (
+
${I18n.t('pageflow_scrolled.editor.select_move_destination.hint')}
+ + + + +