diff --git a/app/views/pageflow/editor/sites/_site.json.jbuilder b/app/views/pageflow/editor/sites/_site.json.jbuilder index d995099a68..0769da9858 100644 --- a/app/views/pageflow/editor/sites/_site.json.jbuilder +++ b/app/views/pageflow/editor/sites/_site.json.jbuilder @@ -1 +1,2 @@ +json.(site, :cutoff_mode_name) json.pretty_url pretty_site_url(site) diff --git a/config/locales/new/cutoff_modes.de.yml b/config/locales/new/cutoff_modes.de.yml index 9f7fbbd768..2bb508d439 100644 --- a/config/locales/new/cutoff_modes.de.yml +++ b/config/locales/new/cutoff_modes.de.yml @@ -2,6 +2,12 @@ de: pageflow: cutoff_modes: none: "(Kein)" + pageflow_scrolled: + editor: + section_item: + set_cutoff: "Paywall Grenze oberhalb setzen" + reset_cutoff: "Paywall Grenze entfernen" + cutoff: "Paywall Grenze" activerecord: attributes: pageflow/site: diff --git a/config/locales/new/cutoff_modes.en.yml b/config/locales/new/cutoff_modes.en.yml index a850f2bdc8..f97dc428da 100644 --- a/config/locales/new/cutoff_modes.en.yml +++ b/config/locales/new/cutoff_modes.en.yml @@ -2,6 +2,12 @@ en: pageflow: cutoff_modes: none: "(None)" + pageflow_scrolled: + editor: + section_item: + set_cutoff: "Set paywall cutoff above" + reset_cutoff: "Remove paywall cutoff" + cutoff: "Paywall cutoff" activerecord: attributes: pageflow/site: diff --git a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js new file mode 100644 index 0000000000..2927d2ad8b --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js @@ -0,0 +1,159 @@ +import {SectionItemView} from 'editor/views/SectionItemView'; + +import {useEditorGlobals, useFakeXhr, useReactBasedBackboneViews} from 'support'; +import userEvent from '@testing-library/user-event'; +import {useFakeTranslations} from 'pageflow/testHelpers'; +import '@testing-library/jest-dom/extend-expect'; + +describe('SectionItemView', () => { + useFakeXhr(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.section_item.set_cutoff': 'Set cutoff point', + 'pageflow_scrolled.editor.section_item.reset_cutoff': 'Remove cutoff point', + 'pageflow_scrolled.editor.section_item.cutoff': 'Cutoff point', + }); + + const {createEntry} = useEditorGlobals(); + const {render} = useReactBasedBackboneViews(); + + it('does not offer menu item to set cutoff section by default', () => { + const entry = createEntry({ + sections: [ + {id: 1, permaId: 100} + ] + }); + const view = new SectionItemView({ + entry, + model: entry.sections.get(1) + }); + + const {queryByRole} = render(view); + + expect(queryByRole('link', {name: 'Set cutoff point'})).toBeNull(); + }); + + it('offers menu item to set cutoff section if site has cutoff mode', async () => { + const entry = createEntry({ + site: { + cutoff_mode_name: 'subscription_headers' + }, + sections: [ + {id: 1, permaId: 100} + ] + }); + const view = new SectionItemView({ + entry, + model: entry.sections.get(1) + }); + + const user = userEvent.setup(); + const {getByRole} = render(view); + await user.click(getByRole('link', {name: 'Set cutoff point'})); + + expect(entry.metadata.configuration.get('cutoff_section_perma_id')).toEqual(100); + }); + + it('offers menu item to reset cutoff section if site has cutoff mode', async () => { + const entry = createEntry({ + site: { + cutoff_mode_name: 'subscription_headers' + }, + metadata: {configuration: {cutoff_section_perma_id: 101}}, + sections: [ + {id: 1, permaId: 100}, + {id: 2, permaId: 101} + ] + }); + const view = new SectionItemView({ + entry, + model: entry.sections.get(2) + }); + + const user = userEvent.setup(); + const {getByRole} = render(view); + await user.click(getByRole('link', {name: 'Remove cutoff point'})); + + expect(entry.metadata.configuration.get('cutoff_section_perma_id')).toBeUndefined(); + }); + + it('updates menu item when cutoff section changes', () => { + const entry = createEntry({ + site: { + cutoff_mode_name: 'subscription_headers' + }, + sections: [ + {id: 1, permaId: 100}, + {id: 2, permaId: 101} + ] + }); + const view = new SectionItemView({ + entry, + model: entry.sections.get(2) + }); + + const {queryByRole} = render(view); + entry.metadata.configuration.set('cutoff_section_perma_id', 101) + + expect(queryByRole('link', {name: 'Remove cutoff point'})).not.toBeNull(); + }); + + it('renders cutoff indicator', () => { + const entry = createEntry({ + site: { + cutoff_mode_name: 'subscription_headers' + }, + sections: [ + {id: 1, permaId: 100}, + {id: 2, permaId: 101} + ] + }); + const view = new SectionItemView({ + entry, + model: entry.sections.get(2) + }); + + const {queryByText} = render(view); + entry.metadata.configuration.set('cutoff_section_perma_id', 101) + + expect(queryByText('Cutoff point')).toBeVisible(); + }); + + it('does not render cutoff indicator if cutoff section not set', () => { + const entry = createEntry({ + site: { + cutoff_mode_name: 'subscription_headers' + }, + sections: [ + {id: 1, permaId: 100}, + {id: 2, permaId: 101} + ] + }); + const view = new SectionItemView({ + entry, + model: entry.sections.get(2) + }); + + const {queryByText} = render(view); + + expect(queryByText('Cutoff point')).not.toBeVisible(); + }); + + it('does not render cutoff indicator if site does not have cutoff mode', () => { + const entry = createEntry({ + metadata: {configuration: {cutoff_section_perma_id: 101}}, + sections: [ + {id: 1, permaId: 100}, + {id: 2, permaId: 101} + ] + }); + const view = new SectionItemView({ + entry, + model: entry.sections.get(2) + }); + + const {queryByText} = render(view); + + expect(queryByText('Cutoff point')).not.toBeVisible(); + }); +}); diff --git a/entry_types/scrolled/package/spec/support/useEditorGlobals.js b/entry_types/scrolled/package/spec/support/useEditorGlobals.js index 8290d0dfdd..499c779d44 100644 --- a/entry_types/scrolled/package/spec/support/useEditorGlobals.js +++ b/entry_types/scrolled/package/spec/support/useEditorGlobals.js @@ -1,4 +1,4 @@ -import {editor, FilesCollection} from 'pageflow/editor'; +import {editor, FilesCollection, Site} from 'pageflow/editor'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {setupGlobals} from 'pageflow/testHelpers'; @@ -23,12 +23,15 @@ export function useEditorGlobals() { return { createEntry(options) { const { + metadata, imageFiles, videoFiles, audioFiles, textTrackFiles, + site, ...seedOptions } = options; const {entry} = setGlobals({ - entry: factories.entry(ScrolledEntry, {}, { + entry: factories.entry(ScrolledEntry, {metadata}, { + site: new Site(site), files: FilesCollection.createForFileTypes( [ editor.fileTypes.findByCollectionName('image_files'), diff --git a/entry_types/scrolled/package/src/editor/models/Cutoff.js b/entry_types/scrolled/package/src/editor/models/Cutoff.js new file mode 100644 index 0000000000..baff2d8591 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/Cutoff.js @@ -0,0 +1,27 @@ +import {Object} from 'pageflow/ui'; + +export const Cutoff = Object.extend({ + initialize({entry}) { + this.entry = entry; + + this.listenTo(this.entry.metadata.configuration, + 'change:cutoff_section_perma_id', + () => this.trigger('change')); + }, + + isEnabled() { + return !!this.entry.site.get('cutoff_mode_name'); + }, + + isAtSection(section) { + return this.entry.metadata.configuration.get('cutoff_section_perma_id') === section.get('permaId'); + }, + + reset() { + this.entry.metadata.configuration.unset('cutoff_section_perma_id'); + }, + + setSection(section) { + this.entry.metadata.configuration.set('cutoff_section_perma_id', section.get('permaId')); + } +}); 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 9d7ec14f1c..37b69c6ade 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -4,6 +4,7 @@ import I18n from 'i18n-js'; import {ConsentVendors} from '../ConsentVendors'; import {ChaptersCollection, SectionsCollection, ContentElementsCollection} from '../../collections'; import {ContentElement} from '../ContentElement'; +import {Cutoff} from '../Cutoff'; import {insertContentElement} from './insertContentElement'; import {moveContentElement} from './moveContentElement'; @@ -12,6 +13,7 @@ import {deleteContentElement} from './deleteContentElement'; export const ScrolledEntry = Entry.extend({ setupFromEntryTypeSeed(seed) { this.consentVendors = new ConsentVendors({hostMatchers: seed.consentVendorHostMatchers}); + this.cutoff = new Cutoff({entry: this}); this.contentElements = new ContentElementsCollection(seed.collections.contentElements); this.sections = new SectionsCollection(seed.collections.sections, diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js index 0a8b08b325..524f8ecd51 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js @@ -10,7 +10,7 @@ import styles from './ChapterItemView.module.css'; export const ChapterItemView = Marionette.Layout.extend({ tagName: 'li', - className: `${styles.root} ${styles.withTransitions}`, + className: `${styles.root}`, mixins: [modelLifecycleTrackingView({classNames: styles})], diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css b/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css index f1058399b3..7cc075a10e 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css @@ -58,23 +58,6 @@ min-height: 20px; } -.withTransitions .sections { - margin-top: 20px; -} - -.root:first-child .sections { - margin-top: 10px; -} - -.sections > :global(.sortable-placeholder) { - margin-top: 25px; - margin-bottom: 22px; -} - -.root:first-child .sections > li:first-child + :global(.sortable-placeholder) { - margin-top: 16px; -} - .creating .creatingIndicator { display: block; } .destroying .destroyingIndicator { display: block; } .failed .failedIndicator { display: block; } diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index 61861e865e..f071ad8e47 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.js @@ -5,7 +5,6 @@ import {modelLifecycleTrackingView, DropDownButtonView} from 'pageflow/editor'; import {cssModulesUtils} from 'pageflow/ui'; import {SectionThumbnailView} from './SectionThumbnailView' -import {getAvailableTransitionNames} from 'pageflow-scrolled/frontend'; import arrowsIcon from './images/arrows.svg'; @@ -13,32 +12,39 @@ import styles from './SectionItemView.module.css'; export const SectionItemView = Marionette.ItemView.extend({ tagName: 'li', - className: `${styles.root} ${styles.withTransition}`, + className: `${styles.root}`, mixins: [modelLifecycleTrackingView({classNames: styles})], template: (data) => ` +
+ ${I18n.t('pageflow_scrolled.editor.section_item.cutoff')} +
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ + +
- - -
`, - ui: cssModulesUtils.ui(styles, 'thumbnail', 'dropDownButton', 'editTransition', 'transition'), + ui: cssModulesUtils.ui(styles, + 'thumbnail', 'dropDownButton', 'editTransition', + 'transition', 'cutoffIndicator'), events: cssModulesUtils.events(styles, { 'click clickMask': function() { @@ -73,10 +79,15 @@ export const SectionItemView = Marionette.ItemView.extend({ this.updateActive(); this.updateTransition(); }); + + this.listenTo(this.options.entry.cutoff, 'change', () => { + this.updateCutoffIndicator(); + }); }, onRender() { this.updateTransition(); + this.updateCutoffIndicator(); if (this.updateActive()) { setTimeout(() => this.$el[0].scrollIntoView({block: 'nearest'}), 10) @@ -113,6 +124,13 @@ export const SectionItemView = Marionette.ItemView.extend({ this.model.chapter.insertSection({after: this.model}) })); + if (this.options.entry.cutoff.isEnabled()) { + dropDownMenuItems.add(new CutoffMenuItem({}, { + cutoff: this.options.entry.cutoff, + section: this.model + })); + } + this.appendSubview(new DropDownButtonView({ items: dropDownMenuItems, alignMenu: 'right', @@ -128,6 +146,19 @@ export const SectionItemView = Marionette.ItemView.extend({ ); }, + updateCutoffIndicator() { + this.ui.cutoffIndicator.css( + 'display', + this.options.entry.cutoff.isEnabled() && + this.options.entry.cutoff.isAtSection(this.model) + ? '' : 'none' + ); + }, + + cutoffModeEnabled() { + return !!this.options.entry.site.get('cutoff_mode_name'); + }, + updateActive() { const active = this.options.entry.sections.indexOf(this.model) === this.options.entry.get('currentSectionIndex'); @@ -146,3 +177,30 @@ const MenuItem = Backbone.Model.extend({ this.options.selected(); } }); + +const CutoffMenuItem = Backbone.Model.extend({ + initialize: function(attributes, {cutoff, section}) { + this.cutoff = cutoff; + this.section = section; + + this.listenTo(cutoff, 'change', this.update); + this.update(); + }, + + selected() { + if (this.cutoff.isAtSection(this.section)) { + this.cutoff.reset(); + } + else { + this.cutoff.setSection(this.section); + } + }, + + update() { + this.set('label', I18n.t( + this.cutoff.isAtSection(this.section) ? + 'pageflow_scrolled.editor.section_item.reset_cutoff' : + 'pageflow_scrolled.editor.section_item.set_cutoff' + )); + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css b/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css index 576056040c..6b40be201f 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css @@ -3,21 +3,34 @@ .root { position: relative; +} + +.outline { border: solid selectionWidth transparent; border-radius: rounded(); padding: 1px; margin-left: -6px; margin-right: -6px; - text-align: right; } -.withTransition { - composes: sectionWithTransition from './outline.module.css'; +.selectable:hover .outline, +.active .outline { + border: solid selectionWidth selectionColor; } -.selectable:hover, -.active { - border: solid selectionWidth selectionColor; +.cutoffIndicator { + display: flex; + justify-content: space-between; + align-items: center; + gap: 5px; + padding: 5px 0; +} + +.cutoffIndicator::before, +.cutoffIndicator::after { + content: ""; + flex: 1; + border-top: solid 1px var(--ui-error-color); } .thumbnailContainer { @@ -85,10 +98,6 @@ .editTransition { composes: transition from './outline.module.css'; - position: absolute; - bottom: 100%; - left: 1px; - width: 100%; border: 0; background: transparent; padding: 5px 5px 5px 2px; diff --git a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js index 3d1870cd57..ab31668d1d 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js @@ -11,14 +11,16 @@ export const SelectableSectionItemView = Marionette.ItemView.extend({ className: `${styles.root} ${styles.selectable}`, template: (data) => ` -
-
- - -
- `, +
+
+
+ + +
+
+ `, ui: cssModulesUtils.ui(styles, 'thumbnail'), diff --git a/entry_types/scrolled/package/src/editor/views/outline.module.css b/entry_types/scrolled/package/src/editor/views/outline.module.css index 9d06e3d577..a3a26bae21 100644 --- a/entry_types/scrolled/package/src/editor/views/outline.module.css +++ b/entry_types/scrolled/package/src/editor/views/outline.module.css @@ -1,13 +1,5 @@ @value indicatorIconColor, errorIconColor from './colors.module.css'; -.sectionWithTransition { - margin-top: 20px; -} - -.chapter:first-child .sectionWithTransition:first-child { - margin-top: 0; -} - .chapter:first-child .sectionWithTransition:first-child .transition { display: none; } diff --git a/package/src/editor/initializers/setupCollections.js b/package/src/editor/initializers/setupCollections.js index 4e4390d09e..9e54bbfe3b 100644 --- a/package/src/editor/initializers/setupCollections.js +++ b/package/src/editor/initializers/setupCollections.js @@ -33,8 +33,8 @@ app.addInitializer(function(options) { state.pages = new PagesCollection(options.pages); state.chapters = new ChaptersCollection(options.chapters); state.storylines = new StorylinesCollection(options.storylines); - state.entry = editor.createEntryModel(options, {widgets: widgets}); state.site = new Site(options.site); + state.entry = editor.createEntryModel(options, {widgets: widgets}); state.account = new Backbone.Model(options.account); widgets.subject = state.entry; diff --git a/package/src/editor/models/Entry.js b/package/src/editor/models/Entry.js index 433cdba550..d41ec5d822 100644 --- a/package/src/editor/models/Entry.js +++ b/package/src/editor/models/Entry.js @@ -39,6 +39,7 @@ export const Entry = Backbone.Model.extend({ this.configuration = this.metadata; this.themes = options.themes || state.themes; + this.site = options.site || state.site; this.files = options.files || state.files; this.fileTypes = options.fileTypes || editor.fileTypes; this.storylines = options.storylines || state.storylines; diff --git a/spec/controllers/pageflow/editor/entries_controller_spec.rb b/spec/controllers/pageflow/editor/entries_controller_spec.rb index 160a5f9eeb..e15cd392e3 100644 --- a/spec/controllers/pageflow/editor/entries_controller_spec.rb +++ b/spec/controllers/pageflow/editor/entries_controller_spec.rb @@ -217,6 +217,22 @@ def main_app expect(response.body).to include_json(entry: {last_published_with_noindex: true}) end + + it 'renders site cutoff mode' do + pageflow_configure do |config| + config.cutoff_modes.register('subscription_header', proc { true }) + end + user = create(:user) + site = create(:site, cutoff_mode_name: 'subscription_header') + entry = create(:entry, + site:, + with_editor: user) + + sign_in(user, scope: :user) + get(:seed, params: {id: entry}, format: 'json') + + expect(response.body).to include_json(site: {cutoff_mode_name: 'subscription_header'}) + end end describe '#update' do