Skip to content

Commit 4154dfd

Browse files
committed
Add "Copy Permalink" menu item to chapter's action menu
REDMINE-21205
1 parent 52c38fb commit 4154dfd

File tree

10 files changed

+298
-33
lines changed

10 files changed

+298
-33
lines changed

entry_types/scrolled/config/locales/de.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,7 @@ de:
11461146
reset: Zurücksetzen
11471147
save: Speichern
11481148
chapter_menu_items:
1149+
copy_permalink: Permalink kopieren
11491150
move_to_main: In Kapitel umwandeln
11501151
move_to_excursions: In Exkurs umwandeln
11511152
destroy_chapter_menu_item:

entry_types/scrolled/config/locales/en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,7 @@ en:
11301130
reset: Reset
11311131
save: Save
11321132
chapter_menu_items:
1133+
copy_permalink: Copy permalink
11331134
move_to_main: Turn into chapter
11341135
move_to_excursions: Turn into excursion
11351136
destroy_chapter_menu_item:
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {ScrolledEntry} from 'editor/models/ScrolledEntry';
2+
import {factories} from 'pageflow/testHelpers';
3+
import {normalizeSeed} from 'support';
4+
5+
describe('ScrolledEntry', () => {
6+
describe('#getChapterPermalink', () => {
7+
it('returns permalink with slugified title', () => {
8+
const entry = factories.entry(
9+
ScrolledEntry,
10+
{pretty_url: 'https://example.com/entry'},
11+
{
12+
entryTypeSeed: normalizeSeed({
13+
chapters: [
14+
{id: 1, permaId: 100, configuration: {title: 'My Chapter'}}
15+
]
16+
})
17+
}
18+
);
19+
const chapter = entry.chapters.get(1);
20+
21+
const result = entry.getChapterPermalink(chapter);
22+
23+
expect(result).toEqual('https://example.com/entry#my-chapter');
24+
});
25+
26+
it('uses chapter-permaId for chapters without title', () => {
27+
const entry = factories.entry(
28+
ScrolledEntry,
29+
{pretty_url: 'https://example.com/entry'},
30+
{
31+
entryTypeSeed: normalizeSeed({
32+
chapters: [
33+
{id: 1, permaId: 100}
34+
]
35+
})
36+
}
37+
);
38+
const chapter = entry.chapters.get(1);
39+
40+
const result = entry.getChapterPermalink(chapter);
41+
42+
expect(result).toEqual('https://example.com/entry#chapter-100');
43+
});
44+
45+
it('appends permaId if slug would not be unique', () => {
46+
const entry = factories.entry(
47+
ScrolledEntry,
48+
{pretty_url: 'https://example.com/entry'},
49+
{
50+
entryTypeSeed: normalizeSeed({
51+
chapters: [
52+
{id: 1, permaId: 100, configuration: {title: 'Same Title'}},
53+
{id: 2, permaId: 200, configuration: {title: 'Same Title'}}
54+
]
55+
})
56+
}
57+
);
58+
const chapter1 = entry.chapters.get(1);
59+
const chapter2 = entry.chapters.get(2);
60+
61+
expect(entry.getChapterPermalink(chapter1)).toEqual('https://example.com/entry#same-title');
62+
expect(entry.getChapterPermalink(chapter2)).toEqual('https://example.com/entry#same-title-200');
63+
});
64+
});
65+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {
2+
CopyPermalinkMenuItem,
3+
ToggleExcursionMenuItem,
4+
DestroyChapterMenuItem
5+
} from 'editor/models/chapterMenuItems';
6+
7+
import {useFakeTranslations} from 'pageflow/testHelpers';
8+
import {useEditorGlobals} from 'support';
9+
10+
describe('ChapterMenuItems', () => {
11+
useFakeTranslations({
12+
'pageflow_scrolled.editor.chapter_menu_items.copy_permalink': 'Copy permalink',
13+
'pageflow_scrolled.editor.chapter_menu_items.move_to_main': 'Move to main',
14+
'pageflow_scrolled.editor.chapter_menu_items.move_to_excursions': 'Move to excursions',
15+
'pageflow_scrolled.editor.destroy_chapter_menu_item.destroy': 'Delete chapter',
16+
'pageflow_scrolled.editor.destroy_chapter_menu_item.confirm_destroy': 'Really delete this chapter?'
17+
});
18+
19+
const {createEntry} = useEditorGlobals();
20+
21+
describe('CopyPermalinkMenuItem', () => {
22+
it('has Copy permalink label', () => {
23+
const entry = createEntry({chapters: [{id: 1}]});
24+
const chapter = entry.chapters.get(1);
25+
const menuItem = new CopyPermalinkMenuItem({}, {entry, chapter});
26+
27+
expect(menuItem.get('label')).toBe('Copy permalink');
28+
});
29+
30+
it('supports separated attribute', () => {
31+
const entry = createEntry({chapters: [{id: 1}]});
32+
const chapter = entry.chapters.get(1);
33+
const menuItem = new CopyPermalinkMenuItem({separated: true}, {entry, chapter});
34+
35+
expect(menuItem.get('separated')).toBe(true);
36+
});
37+
38+
it('copies permalink to clipboard when selected', () => {
39+
const entry = createEntry({chapters: [{id: 1}]});
40+
entry.getChapterPermalink = jest.fn().mockReturnValue('http://example.com/chapter');
41+
const chapter = entry.chapters.get(1);
42+
const menuItem = new CopyPermalinkMenuItem({}, {entry, chapter});
43+
navigator.clipboard = {writeText: jest.fn()};
44+
45+
menuItem.selected();
46+
47+
expect(entry.getChapterPermalink).toHaveBeenCalledWith(chapter);
48+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('http://example.com/chapter');
49+
});
50+
});
51+
52+
describe('ToggleExcursionMenuItem', () => {
53+
it('has Move to excursions label when chapter is in main storyline', () => {
54+
const entry = createEntry({chapters: [{id: 1}]});
55+
const chapter = entry.chapters.get(1);
56+
const menuItem = new ToggleExcursionMenuItem({}, {chapter});
57+
58+
expect(menuItem.get('label')).toBe('Move to excursions');
59+
});
60+
61+
it('has Move to main label when chapter is an excursion', () => {
62+
const entry = createEntry({
63+
storylines: [{id: 1, configuration: {main: true}}, {id: 2}],
64+
chapters: [{id: 1, storylineId: 2}]
65+
});
66+
const chapter = entry.chapters.get(1);
67+
const menuItem = new ToggleExcursionMenuItem({}, {chapter});
68+
69+
expect(menuItem.get('label')).toBe('Move to main');
70+
});
71+
72+
it('calls toggleExcursion on chapter when selected', () => {
73+
const entry = createEntry({chapters: [{id: 1}]});
74+
const chapter = entry.chapters.get(1);
75+
chapter.toggleExcursion = jest.fn();
76+
const menuItem = new ToggleExcursionMenuItem({}, {chapter});
77+
78+
menuItem.selected();
79+
80+
expect(chapter.toggleExcursion).toHaveBeenCalled();
81+
});
82+
});
83+
84+
describe('DestroyChapterMenuItem', () => {
85+
it('has Delete chapter label', () => {
86+
const entry = createEntry({chapters: [{id: 1}]});
87+
const chapter = entry.chapters.get(1);
88+
const menuItem = new DestroyChapterMenuItem({}, {chapter});
89+
90+
expect(menuItem.get('label')).toBe('Delete chapter');
91+
});
92+
93+
it('calls destroyWithDelay on chapter when confirmed', () => {
94+
const entry = createEntry({chapters: [{id: 1}]});
95+
const chapter = entry.chapters.get(1);
96+
chapter.destroyWithDelay = jest.fn();
97+
const menuItem = new DestroyChapterMenuItem({}, {chapter});
98+
window.confirm = jest.fn().mockReturnValue(true);
99+
100+
menuItem.selected();
101+
102+
expect(window.confirm).toHaveBeenCalledWith('Really delete this chapter?');
103+
expect(chapter.destroyWithDelay).toHaveBeenCalled();
104+
});
105+
});
106+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {getChapterSlugs} from 'shared/chapterSlug';
2+
3+
describe('getChapterSlugs', () => {
4+
it('returns slugified title', () => {
5+
const chapters = [
6+
{configuration: {title: 'My Chapter'}, permaId: 100}
7+
];
8+
9+
expect(getChapterSlugs(chapters)[100]).toBe('my-chapter');
10+
});
11+
12+
it('returns chapter-permaId for empty title', () => {
13+
const chapters = [
14+
{configuration: {title: ''}, permaId: 100}
15+
];
16+
17+
expect(getChapterSlugs(chapters)[100]).toBe('chapter-100');
18+
});
19+
20+
it('returns chapter-permaId for missing title', () => {
21+
const chapters = [
22+
{configuration: {}, permaId: 100}
23+
];
24+
25+
expect(getChapterSlugs(chapters)[100]).toBe('chapter-100');
26+
});
27+
28+
it('appends permaId if slug would not be unique', () => {
29+
const chapters = [
30+
{configuration: {title: 'Same Title'}, permaId: 100},
31+
{configuration: {title: 'Same Title'}, permaId: 200}
32+
];
33+
34+
const slugs = getChapterSlugs(chapters);
35+
36+
expect(slugs[100]).toBe('same-title');
37+
expect(slugs[200]).toBe('same-title-200');
38+
});
39+
40+
it('handles special characters', () => {
41+
const chapters = [
42+
{configuration: {title: 'Über uns & mehr!'}, permaId: 100}
43+
];
44+
45+
expect(getChapterSlugs(chapters)[100]).toBe('ueber-uns-and-mehr');
46+
});
47+
});

entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {duplicateContentElement} from './duplicateContentElement';
2020

2121
import {sortColors} from './sortColors';
2222
import {Scale} from '../../../shared/Scale';
23+
import {getChapterSlugs} from '../../../shared/chapterSlug';
2324

2425
const typographySizeSuffixes = ['xl', 'lg', 'md', 'sm', 'xs'];
2526

@@ -287,6 +288,17 @@ export const ScrolledEntry = Entry.extend({
287288
return `${this.get('pretty_url')}#section-${section.get('permaId')}`;
288289
},
289290

291+
getChapterPermalink(chapter) {
292+
const allChapters = this.chapters.map(c => ({
293+
permaId: c.get('permaId'),
294+
configuration: c.configuration.attributes
295+
}));
296+
297+
const chapterSlugs = getChapterSlugs(allChapters);
298+
299+
return `${this.get('pretty_url')}#${chapterSlugs[chapter.get('permaId')]}`;
300+
},
301+
290302
getPaletteColors({name} = {}) {
291303
const themeOptions = this.scrolledSeed.config.theme.options
292304

entry_types/scrolled/package/src/editor/models/chapterMenuItems.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@ import Backbone from 'backbone';
22
import I18n from 'i18n-js';
33
import {DestroyMenuItem} from 'pageflow/editor';
44

5+
export const CopyPermalinkMenuItem = Backbone.Model.extend({
6+
initialize(attributes, {entry, chapter}) {
7+
this.entry = entry;
8+
this.chapter = chapter;
9+
this.set('label', I18n.t('pageflow_scrolled.editor.chapter_menu_items.copy_permalink'));
10+
},
11+
12+
selected() {
13+
navigator.clipboard.writeText(
14+
this.entry.getChapterPermalink(this.chapter)
15+
);
16+
}
17+
});
18+
519
export const ToggleExcursionMenuItem = Backbone.Model.extend({
620
initialize(attributes, {chapter}) {
721
this.chapter = chapter;

entry_types/scrolled/package/src/editor/views/EditChapterView.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import {EditConfigurationView} from 'pageflow/editor';
22
import {CheckBoxInputView, TextInputView, TextAreaInputView} from 'pageflow/ui';
33

4-
import {DestroyChapterMenuItem, ToggleExcursionMenuItem} from '../models/chapterMenuItems';
4+
import {
5+
CopyPermalinkMenuItem,
6+
DestroyChapterMenuItem,
7+
ToggleExcursionMenuItem
8+
} from '../models/chapterMenuItems';
59

610
export const EditChapterView = EditConfigurationView.extend({
711
translationKeyPrefix: 'pageflow_scrolled.editor.edit_chapter',
812

913
getActionsMenuItems() {
1014
return [
1115
new ToggleExcursionMenuItem({}, {chapter: this.model}),
16+
new CopyPermalinkMenuItem({}, {entry: this.options.entry, chapter: this.model}),
1217
new DestroyChapterMenuItem({separated: true}, {chapter: this.model})
1318
];
1419
},

entry_types/scrolled/package/src/entryState/structure.js

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {useMemo, useCallback} from 'react';
22
import {useEntryStateCollectionItems, useEntryStateCollectionItem} from './EntryStateProvider';
3-
import slugify from 'slugify';
3+
import {getChapterSlugs} from '../shared/chapterSlug';
44

55
/**
66
* Returns a nested data structure representing the chapters and sections
@@ -273,37 +273,16 @@ export function useChapters() {
273273
const chapters = useEntryStateCollectionItems('chapters');
274274

275275
return useMemo(() => {
276-
const chapterSlugs = {};
277-
278-
return chapters.map((chapter, index) => {
279-
let chapterSlug = chapter.configuration.title;
280-
281-
if (chapterSlug) {
282-
chapterSlug = slugify(chapterSlug, {
283-
lower: true,
284-
locale: 'de',
285-
strict: true
286-
});
287-
288-
if (chapterSlugs[chapterSlug]) {
289-
chapterSlug = chapterSlug+'-'+chapter.permaId; //append permaId if chapter reference is not unique
290-
}
291-
292-
chapterSlugs[chapterSlug] = chapter;
293-
}
294-
else{
295-
chapterSlug = 'chapter-'+chapter.permaId;
296-
}
297-
298-
return ({
299-
id: chapter.id,
300-
permaId: chapter.permaId,
301-
storylineId: chapter.storylineId,
302-
chapterSlug,
303-
index,
304-
...chapter.configuration
305-
});
306-
});
276+
const chapterSlugs = getChapterSlugs(chapters);
277+
278+
return chapters.map((chapter, index) => ({
279+
id: chapter.id,
280+
permaId: chapter.permaId,
281+
storylineId: chapter.storylineId,
282+
chapterSlug: chapterSlugs[chapter.permaId],
283+
index,
284+
...chapter.configuration
285+
}));
307286
}, [chapters]);
308287
}
309288

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import slugify from 'slugify';
2+
3+
export function getChapterSlug(chapter, allChapters) {
4+
return getChapterSlugs(allChapters)[chapter.permaId];
5+
}
6+
7+
export function getChapterSlugs(chapters) {
8+
const result = {};
9+
const usedSlugs = {};
10+
11+
chapters.forEach(chapter => {
12+
let chapterSlug = chapter.configuration.title;
13+
14+
if (chapterSlug) {
15+
chapterSlug = slugify(chapterSlug, {
16+
lower: true,
17+
locale: 'de',
18+
strict: true
19+
});
20+
21+
if (usedSlugs[chapterSlug]) {
22+
chapterSlug = chapterSlug + '-' + chapter.permaId;
23+
}
24+
25+
usedSlugs[chapterSlug] = true;
26+
}
27+
else {
28+
chapterSlug = 'chapter-' + chapter.permaId;
29+
}
30+
31+
result[chapter.permaId] = chapterSlug;
32+
});
33+
34+
return result;
35+
}

0 commit comments

Comments
 (0)