diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 49e6c63b0b..2181f72fd0 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1146,6 +1146,7 @@ de: reset: Zurücksetzen save: Speichern chapter_menu_items: + copy_permalink: Permalink kopieren move_to_main: In Kapitel umwandeln move_to_excursions: In Exkurs umwandeln destroy_chapter_menu_item: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index e7bc604fe2..8684142885 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1130,6 +1130,7 @@ en: reset: Reset save: Save chapter_menu_items: + copy_permalink: Copy permalink move_to_main: Turn into chapter move_to_excursions: Turn into excursion destroy_chapter_menu_item: diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getChapterPermalink-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getChapterPermalink-spec.js new file mode 100644 index 0000000000..096d67eb08 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getChapterPermalink-spec.js @@ -0,0 +1,65 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {factories} from 'pageflow/testHelpers'; +import {normalizeSeed} from 'support'; + +describe('ScrolledEntry', () => { + describe('#getChapterPermalink', () => { + it('returns permalink with slugified title', () => { + const entry = factories.entry( + ScrolledEntry, + {pretty_url: 'https://example.com/entry'}, + { + entryTypeSeed: normalizeSeed({ + chapters: [ + {id: 1, permaId: 100, configuration: {title: 'My Chapter'}} + ] + }) + } + ); + const chapter = entry.chapters.get(1); + + const result = entry.getChapterPermalink(chapter); + + expect(result).toEqual('https://example.com/entry#my-chapter'); + }); + + it('uses chapter-permaId for chapters without title', () => { + const entry = factories.entry( + ScrolledEntry, + {pretty_url: 'https://example.com/entry'}, + { + entryTypeSeed: normalizeSeed({ + chapters: [ + {id: 1, permaId: 100} + ] + }) + } + ); + const chapter = entry.chapters.get(1); + + const result = entry.getChapterPermalink(chapter); + + expect(result).toEqual('https://example.com/entry#chapter-100'); + }); + + it('appends permaId if slug would not be unique', () => { + const entry = factories.entry( + ScrolledEntry, + {pretty_url: 'https://example.com/entry'}, + { + entryTypeSeed: normalizeSeed({ + chapters: [ + {id: 1, permaId: 100, configuration: {title: 'Same Title'}}, + {id: 2, permaId: 200, configuration: {title: 'Same Title'}} + ] + }) + } + ); + const chapter1 = entry.chapters.get(1); + const chapter2 = entry.chapters.get(2); + + expect(entry.getChapterPermalink(chapter1)).toEqual('https://example.com/entry#same-title'); + expect(entry.getChapterPermalink(chapter2)).toEqual('https://example.com/entry#same-title-200'); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js new file mode 100644 index 0000000000..eb62d38a69 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js @@ -0,0 +1,106 @@ +import { + CopyPermalinkMenuItem, + ToggleExcursionMenuItem, + DestroyChapterMenuItem +} from 'editor/models/chapterMenuItems'; + +import {useFakeTranslations} from 'pageflow/testHelpers'; +import {useEditorGlobals} from 'support'; + +describe('ChapterMenuItems', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.chapter_menu_items.copy_permalink': 'Copy permalink', + 'pageflow_scrolled.editor.chapter_menu_items.move_to_main': 'Move to main', + 'pageflow_scrolled.editor.chapter_menu_items.move_to_excursions': 'Move to excursions', + 'pageflow_scrolled.editor.destroy_chapter_menu_item.destroy': 'Delete chapter', + 'pageflow_scrolled.editor.destroy_chapter_menu_item.confirm_destroy': 'Really delete this chapter?' + }); + + const {createEntry} = useEditorGlobals(); + + describe('CopyPermalinkMenuItem', () => { + it('has Copy permalink label', () => { + const entry = createEntry({chapters: [{id: 1}]}); + const chapter = entry.chapters.get(1); + const menuItem = new CopyPermalinkMenuItem({}, {entry, chapter}); + + expect(menuItem.get('label')).toBe('Copy permalink'); + }); + + it('supports separated attribute', () => { + const entry = createEntry({chapters: [{id: 1}]}); + const chapter = entry.chapters.get(1); + const menuItem = new CopyPermalinkMenuItem({separated: true}, {entry, chapter}); + + expect(menuItem.get('separated')).toBe(true); + }); + + it('copies permalink to clipboard when selected', () => { + const entry = createEntry({chapters: [{id: 1}]}); + entry.getChapterPermalink = jest.fn().mockReturnValue('http://example.com/chapter'); + const chapter = entry.chapters.get(1); + const menuItem = new CopyPermalinkMenuItem({}, {entry, chapter}); + navigator.clipboard = {writeText: jest.fn()}; + + menuItem.selected(); + + expect(entry.getChapterPermalink).toHaveBeenCalledWith(chapter); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('http://example.com/chapter'); + }); + }); + + describe('ToggleExcursionMenuItem', () => { + it('has Move to excursions label when chapter is in main storyline', () => { + const entry = createEntry({chapters: [{id: 1}]}); + const chapter = entry.chapters.get(1); + const menuItem = new ToggleExcursionMenuItem({}, {chapter}); + + expect(menuItem.get('label')).toBe('Move to excursions'); + }); + + it('has Move to main label when chapter is an excursion', () => { + const entry = createEntry({ + storylines: [{id: 1, configuration: {main: true}}, {id: 2}], + chapters: [{id: 1, storylineId: 2}] + }); + const chapter = entry.chapters.get(1); + const menuItem = new ToggleExcursionMenuItem({}, {chapter}); + + expect(menuItem.get('label')).toBe('Move to main'); + }); + + it('calls toggleExcursion on chapter when selected', () => { + const entry = createEntry({chapters: [{id: 1}]}); + const chapter = entry.chapters.get(1); + chapter.toggleExcursion = jest.fn(); + const menuItem = new ToggleExcursionMenuItem({}, {chapter}); + + menuItem.selected(); + + expect(chapter.toggleExcursion).toHaveBeenCalled(); + }); + }); + + describe('DestroyChapterMenuItem', () => { + it('has Delete chapter label', () => { + const entry = createEntry({chapters: [{id: 1}]}); + const chapter = entry.chapters.get(1); + const menuItem = new DestroyChapterMenuItem({}, {chapter}); + + expect(menuItem.get('label')).toBe('Delete chapter'); + }); + + it('calls destroyWithDelay on chapter when confirmed', () => { + const entry = createEntry({chapters: [{id: 1}]}); + const chapter = entry.chapters.get(1); + chapter.destroyWithDelay = jest.fn(); + const menuItem = new DestroyChapterMenuItem({}, {chapter}); + window.confirm = jest.fn().mockReturnValue(true); + + menuItem.selected(); + + expect(window.confirm).toHaveBeenCalledWith('Really delete this chapter?'); + expect(chapter.destroyWithDelay).toHaveBeenCalled(); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js b/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js new file mode 100644 index 0000000000..41810154a2 --- /dev/null +++ b/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js @@ -0,0 +1,47 @@ +import {getChapterSlugs} from 'shared/chapterSlug'; + +describe('getChapterSlugs', () => { + it('returns slugified title', () => { + const chapters = [ + {configuration: {title: 'My Chapter'}, permaId: 100} + ]; + + expect(getChapterSlugs(chapters)[100]).toBe('my-chapter'); + }); + + it('returns chapter-permaId for empty title', () => { + const chapters = [ + {configuration: {title: ''}, permaId: 100} + ]; + + expect(getChapterSlugs(chapters)[100]).toBe('chapter-100'); + }); + + it('returns chapter-permaId for missing title', () => { + const chapters = [ + {configuration: {}, permaId: 100} + ]; + + expect(getChapterSlugs(chapters)[100]).toBe('chapter-100'); + }); + + it('appends permaId if slug would not be unique', () => { + const chapters = [ + {configuration: {title: 'Same Title'}, permaId: 100}, + {configuration: {title: 'Same Title'}, permaId: 200} + ]; + + const slugs = getChapterSlugs(chapters); + + expect(slugs[100]).toBe('same-title'); + expect(slugs[200]).toBe('same-title-200'); + }); + + it('handles special characters', () => { + const chapters = [ + {configuration: {title: 'Über uns & mehr!'}, permaId: 100} + ]; + + expect(getChapterSlugs(chapters)[100]).toBe('ueber-uns-and-mehr'); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js index 7ce82be2d4..f650b3ca91 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -20,6 +20,7 @@ import {duplicateContentElement} from './duplicateContentElement'; import {sortColors} from './sortColors'; import {Scale} from '../../../shared/Scale'; +import {getChapterSlugs} from '../../../shared/chapterSlug'; const typographySizeSuffixes = ['xl', 'lg', 'md', 'sm', 'xs']; @@ -287,6 +288,16 @@ export const ScrolledEntry = Entry.extend({ return `${this.get('pretty_url')}#section-${section.get('permaId')}`; }, + getChapterPermalink(chapter) { + const allChapters = this.chapters.map(c => ({ + permaId: c.get('permaId'), + configuration: c.configuration.attributes + })); + const chapterSlugs = getChapterSlugs(allChapters); + + return `${this.get('pretty_url')}#${chapterSlugs[chapter.get('permaId')]}`; + }, + getPaletteColors({name} = {}) { const themeOptions = this.scrolledSeed.config.theme.options diff --git a/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js index 42dcc65067..5d41892786 100644 --- a/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js @@ -2,6 +2,20 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; import {DestroyMenuItem} from 'pageflow/editor'; +export const CopyPermalinkMenuItem = Backbone.Model.extend({ + initialize(attributes, {entry, chapter}) { + this.entry = entry; + this.chapter = chapter; + this.set('label', I18n.t('pageflow_scrolled.editor.chapter_menu_items.copy_permalink')); + }, + + selected() { + navigator.clipboard.writeText( + this.entry.getChapterPermalink(this.chapter) + ); + } +}); + export const ToggleExcursionMenuItem = Backbone.Model.extend({ initialize(attributes, {chapter}) { this.chapter = chapter; diff --git a/entry_types/scrolled/package/src/editor/views/EditChapterView.js b/entry_types/scrolled/package/src/editor/views/EditChapterView.js index 6b87e9f0a5..4a2bc7c801 100644 --- a/entry_types/scrolled/package/src/editor/views/EditChapterView.js +++ b/entry_types/scrolled/package/src/editor/views/EditChapterView.js @@ -1,7 +1,11 @@ import {EditConfigurationView} from 'pageflow/editor'; import {CheckBoxInputView, TextInputView, TextAreaInputView} from 'pageflow/ui'; -import {DestroyChapterMenuItem, ToggleExcursionMenuItem} from '../models/chapterMenuItems'; +import { + CopyPermalinkMenuItem, + DestroyChapterMenuItem, + ToggleExcursionMenuItem +} from '../models/chapterMenuItems'; export const EditChapterView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_chapter', @@ -9,6 +13,7 @@ export const EditChapterView = EditConfigurationView.extend({ getActionsMenuItems() { return [ new ToggleExcursionMenuItem({}, {chapter: this.model}), + new CopyPermalinkMenuItem({}, {entry: this.options.entry, chapter: this.model}), new DestroyChapterMenuItem({separated: true}, {chapter: this.model}) ]; }, diff --git a/entry_types/scrolled/package/src/entryState/structure.js b/entry_types/scrolled/package/src/entryState/structure.js index d6d85543da..f5c78bcc98 100644 --- a/entry_types/scrolled/package/src/entryState/structure.js +++ b/entry_types/scrolled/package/src/entryState/structure.js @@ -1,6 +1,6 @@ import {useMemo, useCallback} from 'react'; import {useEntryStateCollectionItems, useEntryStateCollectionItem} from './EntryStateProvider'; -import slugify from 'slugify'; +import {getChapterSlugs} from '../shared/chapterSlug'; /** * Returns a nested data structure representing the chapters and sections @@ -273,37 +273,16 @@ export function useChapters() { const chapters = useEntryStateCollectionItems('chapters'); return useMemo(() => { - const chapterSlugs = {}; - - return chapters.map((chapter, index) => { - let chapterSlug = chapter.configuration.title; - - if (chapterSlug) { - chapterSlug = slugify(chapterSlug, { - lower: true, - locale: 'de', - strict: true - }); - - if (chapterSlugs[chapterSlug]) { - chapterSlug = chapterSlug+'-'+chapter.permaId; //append permaId if chapter reference is not unique - } - - chapterSlugs[chapterSlug] = chapter; - } - else{ - chapterSlug = 'chapter-'+chapter.permaId; - } - - return ({ - id: chapter.id, - permaId: chapter.permaId, - storylineId: chapter.storylineId, - chapterSlug, - index, - ...chapter.configuration - }); - }); + const chapterSlugs = getChapterSlugs(chapters); + + return chapters.map((chapter, index) => ({ + id: chapter.id, + permaId: chapter.permaId, + storylineId: chapter.storylineId, + chapterSlug: chapterSlugs[chapter.permaId], + index, + ...chapter.configuration + })); }, [chapters]); } diff --git a/entry_types/scrolled/package/src/shared/chapterSlug.js b/entry_types/scrolled/package/src/shared/chapterSlug.js new file mode 100644 index 0000000000..41238e6b96 --- /dev/null +++ b/entry_types/scrolled/package/src/shared/chapterSlug.js @@ -0,0 +1,31 @@ +import slugify from 'slugify'; + +export function getChapterSlugs(chapters) { + const result = {}; + const usedSlugs = {}; + + chapters.forEach(chapter => { + let chapterSlug = chapter.configuration.title; + + if (chapterSlug) { + chapterSlug = slugify(chapterSlug, { + lower: true, + locale: 'de', + strict: true + }); + + if (usedSlugs[chapterSlug]) { + chapterSlug = chapterSlug + '-' + chapter.permaId; + } + + usedSlugs[chapterSlug] = true; + } + else { + chapterSlug = 'chapter-' + chapter.permaId; + } + + result[chapter.permaId] = chapterSlug; + }); + + return result; +}