diff --git a/admins/pageflow/accounts.rb b/admins/pageflow/accounts.rb index 01261c6599..50c0ea4d64 100644 --- a/admins/pageflow/accounts.rb +++ b/admins/pageflow/accounts.rb @@ -67,6 +67,7 @@ module Pageflow end controller do + helper Pageflow::Admin::CutoffModesHelper helper Pageflow::Admin::FeaturesHelper helper Pageflow::Admin::FormHelper helper Pageflow::Admin::LocalesHelper diff --git a/admins/pageflow/sites.rb b/admins/pageflow/sites.rb index 84c355f1f1..6589974b93 100644 --- a/admins/pageflow/sites.rb +++ b/admins/pageflow/sites.rb @@ -29,11 +29,13 @@ module Pageflow :copyright_link_url, :copyright_link_label, :privacy_link_url, - :home_url + :home_url, + :cutoff_mode_name ] + permitted_admin_form_input_params end controller do + helper Pageflow::Admin::CutoffModesHelper helper Pageflow::Admin::FormHelper before_create do |site| diff --git a/app/helpers/pageflow/admin/cutoff_modes_helper.rb b/app/helpers/pageflow/admin/cutoff_modes_helper.rb new file mode 100644 index 0000000000..e24ede2d82 --- /dev/null +++ b/app/helpers/pageflow/admin/cutoff_modes_helper.rb @@ -0,0 +1,12 @@ +module Pageflow + module Admin + # @api private + module CutoffModesHelper + def cutoff_modes_collection(config) + config.cutoff_modes.names.map do |name| + [t(name, scope: 'pageflow.cutoff_modes'), name] + end + end + end + end +end diff --git a/app/models/pageflow/draft_entry.rb b/app/models/pageflow/draft_entry.rb index 0f9748cbff..59ed688641 100644 --- a/app/models/pageflow/draft_entry.rb +++ b/app/models/pageflow/draft_entry.rb @@ -22,6 +22,10 @@ def translations(scope = -> { self }, **) ) end + def cutoff_mode_enabled_for?(_request) + false + end + def create_file!(file_type, attributes) check_foreign_key_custom_attributes(file_type.custom_attributes, attributes) diff --git a/app/models/pageflow/published_entry.rb b/app/models/pageflow/published_entry.rb index b105af2b41..077f094540 100644 --- a/app/models/pageflow/published_entry.rb +++ b/app/models/pageflow/published_entry.rb @@ -34,6 +34,10 @@ def translations(scope = -> { self }, include_noindex: false) end end + def cutoff_mode_enabled_for?(request) + Pageflow.config.cutoff_modes.enabled_for?(self, request) + end + def stylesheet_model custom_revision? ? revision : entry end diff --git a/app/models/pageflow/site.rb b/app/models/pageflow/site.rb index bb8b3d0465..a07a407c26 100644 --- a/app/models/pageflow/site.rb +++ b/app/models/pageflow/site.rb @@ -9,7 +9,8 @@ class Site < ApplicationRecord scope :with_home_url, -> { where.not(home_url: '') } scope :for_request, ->(request) { Pageflow.config.site_request_scope.call(all, request) } - validates :account, :presence => true + validates :account, presence: true + validates_inclusion_of :cutoff_mode_name, in: :available_cutoff_mode_names, allow_blank: true delegate :enabled_feature_names, to: :account @@ -67,5 +68,11 @@ def self.ransackable_attributes(_auth_object = nil) def self.ransackable_associations(_auth_object = nil) %w[account] end + + private + + def available_cutoff_mode_names + Pageflow.config_for(account).cutoff_modes.names + end end end diff --git a/app/views/admin/sites/_fields.html.erb b/app/views/admin/sites/_fields.html.erb index 4883ec8fc8..a84d18cb4c 100644 --- a/app/views/admin/sites/_fields.html.erb +++ b/app/views/admin/sites/_fields.html.erb @@ -12,6 +12,12 @@ <%= f.input :copyright_link_url %> <%= f.input :privacy_link_url %> + <% if cutoff_modes_collection(account_config).present? %> + <%= f.input(:cutoff_mode_name, + collection: cutoff_modes_collection(account_config), + include_blank: t('pageflow.cutoff_modes.none')) %> + <% end %> + <%= f.input :feeds_enabled, hint: t('pageflow.admin.sites.feeds_hint', site_host: @site&.persisted? ? @site.host : '') %> <%= f.input :sitemap_enabled, hint: t('pageflow.admin.sites.sitemap_hint', 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 new file mode 100644 index 0000000000..2bb508d439 --- /dev/null +++ b/config/locales/new/cutoff_modes.de.yml @@ -0,0 +1,14 @@ +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: + cutoff_mode_name: "Cutoff-Modus" diff --git a/config/locales/new/cutoff_modes.en.yml b/config/locales/new/cutoff_modes.en.yml new file mode 100644 index 0000000000..f97dc428da --- /dev/null +++ b/config/locales/new/cutoff_modes.en.yml @@ -0,0 +1,14 @@ +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: + cutoff_mode_name: "Cutoff mode" diff --git a/db/migrate/20240612110434_add_cutoff_mode_name_to_sites.rb b/db/migrate/20240612110434_add_cutoff_mode_name_to_sites.rb new file mode 100644 index 0000000000..f2dde24bdf --- /dev/null +++ b/db/migrate/20240612110434_add_cutoff_mode_name_to_sites.rb @@ -0,0 +1,5 @@ +class AddCutoffModeNameToSites < ActiveRecord::Migration[5.2] + def change + add_column :pageflow_sites, :cutoff_mode_name, :string + end +end diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb index 3322f19f88..46dcfe9060 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb @@ -23,17 +23,27 @@ def scrolled_entry_json_seed_script_tag(scrolled_entry, options = {}) end def scrolled_entry_json_seed(json, scrolled_entry, options = {}) - main_storyline = Storyline.all_for_revision(scrolled_entry.revision).first - main_storyline ||= Storyline.new + main_storyline = Storyline.all_for_revision(scrolled_entry.revision).first || Storyline.new + sections = scrolled_entry_json_seed_sections(scrolled_entry, main_storyline) json.partial!('pageflow_scrolled/entry_json_seed/entry', - chapters: main_storyline.chapters, entry: scrolled_entry, entry_config: Pageflow.config_for(scrolled_entry), - sections: main_storyline.sections, - content_elements: main_storyline.content_elements, + chapters: main_storyline.chapters, + sections:, + content_elements: main_storyline.content_elements.where(section: sections), widgets: scrolled_entry.resolve_widgets(insert_point: :react), options:) end + + private + + def scrolled_entry_json_seed_sections(scrolled_entry, main_storyline) + if scrolled_entry.cutoff_mode_enabled_for?(request) + main_storyline.sections_before_cutoff_section + else + main_storyline.sections + end + end end end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb index 7ad5f621b3..59172b8b3e 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb @@ -27,5 +27,19 @@ class Storyline < Pageflow::ApplicationRecord through: :sections nested_revision_components :chapters + + def sections_before_cutoff_section + sections_before(cutoff_section) + end + + private + + def sections_before(section) + section ? sections[0...sections.index(section)] : sections + end + + def cutoff_section + sections.find_by_perma_id(revision.configuration['cutoff_section_perma_id']) + end end end diff --git a/entry_types/scrolled/package/spec/editor/models/Cutoff-spec.js b/entry_types/scrolled/package/spec/editor/models/Cutoff-spec.js new file mode 100644 index 0000000000..a05170129e --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/Cutoff-spec.js @@ -0,0 +1,22 @@ +import {useEditorGlobals, useFakeXhr} from 'support'; +import '@testing-library/jest-dom/extend-expect'; + +describe('Cutoff', () => { + useFakeXhr(); + + const {createEntry} = useEditorGlobals(); + + it('resets metadata configuration when deleting the cutoff section', () => { + const entry = createEntry({ + metadata: {configuration: {cutoff_section_perma_id: 100}}, + sections: [ + {id: 1, permaId: 100}, + {id: 2, permaId: 101}, + ] + }); + + entry.sections.get(1).destroy(); + + expect(entry.metadata.configuration.get('cutoff_section_perma_id')).toBeUndefined(); + }); +}); 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..7a1f758480 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/Cutoff.js @@ -0,0 +1,33 @@ +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')); + + this.listenTo(this.entry.sections, 'destroy', (section) => { + if (this.isAtSection(section)) { + this.reset(); + } + }); + }, + + 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..8d34704670 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'; @@ -23,6 +24,8 @@ export const ScrolledEntry = Entry.extend({ this.sections.sort(); + this.cutoff = new Cutoff({entry: this}); + editor.failures.watch(this.contentElements); editor.failures.watch(this.sections); editor.failures.watch(this.chapters); 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/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb b/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb index 8dd4919dfa..836decbc02 100644 --- a/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb +++ b/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb @@ -81,6 +81,34 @@ def render(helper, entry, options = {}) ] }) end + + it 'renders all chapter even when cutoff mode is enabled' do + pageflow_configure do |config| + config.cutoff_modes.register( + :test, + proc { true } + ) + end + + site = create(:site, cutoff_mode_name: 'test') + entry = create(:published_entry, + site:, + type_name: 'scrolled', + revision_attributes: { + configuration: { + cutoff_section_perma_id: 100 + } + }) + chapter1 = create(:scrolled_chapter, revision: entry.revision, position: 1) + create(:section, chapter: chapter1, perma_id: 100) + chapter2 = create(:scrolled_chapter, revision: entry.revision, position: 2) + + result = render(helper, entry) + + expect( + JSON.parse(result).dig('collections', 'chapters').map { |chapter| chapter['id'] } + ).to eq([chapter1.id, chapter2.id]) + end end context 'sections' do @@ -131,6 +159,152 @@ def render(helper, entry, options = {}) ] }) end + + it 'supports filtering sections based on cutoff section when cutoff mode is enabled' do + pageflow_configure do |config| + config.cutoff_modes.register( + :test, + proc { true } + ) + end + + site = create(:site, cutoff_mode_name: 'test') + entry = create(:published_entry, + site:, + type_name: 'scrolled', + revision_attributes: { + configuration: { + cutoff_section_perma_id: 100 + } + }) + + chapter1 = create(:scrolled_chapter, position: 1, revision: entry.revision) + section11 = create(:section, chapter: chapter1, position: 1) + section12 = create(:section, chapter: chapter1, position: 2) + chapter2 = create(:scrolled_chapter, position: 2, revision: entry.revision) + section21 = create(:section, chapter: chapter2, position: 1) + create(:section, chapter: chapter2, position: 2, perma_id: 100) + create(:section, chapter: chapter2, position: 3) + + result = render(helper, entry) + + expect( + JSON.parse(result).dig('collections', 'sections').map { |s| s['id'] } + ).to eq([section11.id, section12.id, section21.id]) + end + + it 'does not filter sections when no cutoff section is configured' do + pageflow_configure do |config| + config.cutoff_modes.register( + :test, + proc { true } + ) + end + + site = create(:site, cutoff_mode_name: 'test') + entry = create(:published_entry, + site:, + type_name: 'scrolled') + chapter1 = create(:scrolled_chapter, position: 1, revision: entry.revision) + section11 = create(:section, chapter: chapter1, position: 1) + section12 = create(:section, chapter: chapter1, position: 2) + chapter2 = create(:scrolled_chapter, position: 2, revision: entry.revision) + section21 = create(:section, chapter: chapter2, position: 1) + section22 = create(:section, chapter: chapter2, position: 2) + + result = render(helper, entry) + + expect( + JSON.parse(result).dig('collections', 'sections').map { |s| s['id'] } + ).to eq([section11.id, section12.id, section21.id, section22.id]) + end + + it 'does not filter sections when configured cutoff mode is disabled' do + pageflow_configure do |config| + config.cutoff_modes.register( + :test, + proc { false } + ) + end + + site = create(:site, cutoff_mode_name: 'test') + entry = create(:published_entry, + site:, + type_name: 'scrolled', + revision_attributes: { + configuration: { + cutoff_section_perma_id: 100 + } + }) + chapter1 = create(:scrolled_chapter, position: 1, revision: entry.revision) + section11 = create(:section, chapter: chapter1, position: 1) + section12 = create(:section, chapter: chapter1, position: 2) + chapter2 = create(:scrolled_chapter, position: 2, revision: entry.revision) + section21 = create(:section, chapter: chapter2, position: 1, perma_id: 100) + section22 = create(:section, chapter: chapter2, position: 2) + + result = render(helper, entry) + + expect( + JSON.parse(result).dig('collections', 'sections').map { |s| s['id'] } + ).to eq([section11.id, section12.id, section21.id, section22.id]) + end + + it 'does not filter sections when no cutoff mode is configured' do + site = create(:site) + entry = create(:published_entry, + site:, + type_name: 'scrolled', + revision_attributes: { + configuration: { + cutoff_section_perma_id: 100 + } + }) + chapter1 = create(:scrolled_chapter, position: 1, revision: entry.revision) + section11 = create(:section, chapter: chapter1, position: 1) + section12 = create(:section, chapter: chapter1, position: 2) + chapter2 = create(:scrolled_chapter, position: 2, revision: entry.revision) + section21 = create(:section, chapter: chapter2, position: 1, perma_id: 100) + section22 = create(:section, chapter: chapter2, position: 2) + + result = render(helper, entry) + + expect( + JSON.parse(result).dig('collections', 'sections').map { |s| s['id'] } + ).to eq([section11.id, section12.id, section21.id, section22.id]) + end + + it 'does not filter sections based on cutoff for draft entry' do + pageflow_configure do |config| + config.cutoff_modes.register( + :test, + proc { true } + ) + end + + site = create(:site, cutoff_mode_name: 'test') + entry = create(:draft_entry, + site:, + type_name: 'scrolled', + revision_attributes: { + configuration: { + cutoff_section_perma_id: 100 + } + }) + + chapter1 = create(:scrolled_chapter, position: 1, revision: entry.revision) + section11 = create(:section, chapter: chapter1, position: 1) + section12 = create(:section, chapter: chapter1, position: 2) + chapter2 = create(:scrolled_chapter, position: 2, revision: entry.revision) + section21 = create(:section, chapter: chapter2, position: 1) + section22 = create(:section, chapter: chapter2, position: 2, perma_id: 100) + + result = render(helper, entry) + + expect( + JSON.parse(result).dig('collections', 'sections').map { |s| s['id'] } + ).to eq([section11.id, section12.id, section21.id, section22.id]) + end end context 'content_elements' do @@ -199,6 +373,42 @@ def render(helper, entry, options = {}) ] }) end + + it 'supports filtering content elements based on cutoff section when cutoff mode is enabled' do + pageflow_configure do |config| + config.cutoff_modes.register( + :test, + proc { true } + ) + end + + site = create(:site, cutoff_mode_name: 'test') + entry = create(:published_entry, + site:, + type_name: 'scrolled', + revision_attributes: { + configuration: { + cutoff_section_perma_id: 100 + } + }) + + chapter1 = create(:scrolled_chapter, position: 1, revision: entry.revision) + section11 = create(:section, chapter: chapter1, position: 1) + content_element11 = create(:content_element, section: section11) + section12 = create(:section, chapter: chapter1, position: 2) + content_element12 = create(:content_element, section: section12) + chapter2 = create(:scrolled_chapter, position: 2, revision: entry.revision) + section21 = create(:section, chapter: chapter2, position: 1, perma_id: 100) + create(:content_element, section: section21) + section22 = create(:section, chapter: chapter2, position: 2) + create(:content_element, section: section22) + + result = render(helper, entry) + + expect( + JSON.parse(result).dig('collections', 'contentElements').map { |c| c['id'] } + ).to eq([content_element11.id, content_element12.id]) + end end describe 'widgets' do diff --git a/lib/pageflow/configuration.rb b/lib/pageflow/configuration.rb index de834d1bf2..851730bfb6 100644 --- a/lib/pageflow/configuration.rb +++ b/lib/pageflow/configuration.rb @@ -245,6 +245,24 @@ class Configuration # construct the embed url of a published entry. attr_accessor :entry_embed_url_options + + # Define strategies to determine whether entries should be cut off + # (e.g., to preview paywalled premium content). + # + # @example + # + # config.cutoff_modes.register( + # 'subscription_header', + # lambda do |_entry, request| + # # Set by some proxy handling user authentication + # request.headers['X-Subscription'] != 'Premium' + # end + # ) + # + # @return [CutoffModes] + # @since edge + attr_reader :cutoff_modes + # Either a lambda or an object with a `call` method taking a hash # of theme option overrides and an {EntryAtRevision} and returning # a transformed hash of overrides. Can be used to filter overrides @@ -456,6 +474,7 @@ def initialize(target_type_name = nil) @additional_public_entry_headers = AdditionalHeaders.new @public_entry_url_options = Pageflow::SitesHelper::DEFAULT_PUBLIC_ENTRY_OPTIONS @entry_embed_url_options = {protocol: 'https'} + @cutoff_modes = CutoffModes.new @transform_theme_customization_overrides = ->(overrides, _entry) { overrides } @transform_theme_customization_files = ->(files, _entry) { files } @@ -598,6 +617,7 @@ def enable_all_features delegate :widget_types, to: :config delegate :public_entry_cache_control_header=, to: :config delegate :additional_public_entry_headers, to: :config + delegate :cutoff_modes, to: :config delegate :for_entry_type, to: :config end diff --git a/lib/pageflow/cutoff_modes.rb b/lib/pageflow/cutoff_modes.rb new file mode 100644 index 0000000000..be5c7e326d --- /dev/null +++ b/lib/pageflow/cutoff_modes.rb @@ -0,0 +1,39 @@ +module Pageflow + # Register strategies to determine whether only the part of an entry + # before some cutoff point should be rendered (e.g., to preview + # paywalled premium content). + # @since edge + class CutoffModes + # @api private + def initialize + @modes = {} + end + + # Register callable to determine whether only the part of an entry + # before some cutoff point should be rendered (e.g., to preview + # paywalled premium content). Cutoff modes can then be enabled per + # site. + # + # @param name [String] + # Referenced in sites. + # @param enabled [#call] + # Take {EntryAtRevision} and {ActionDispatch::Request} and + # return true if the entry shall be cut off. + def register(name, enabled) + @modes[name.to_s] = Mode.new(name, enabled) + end + + # @api private + def enabled_for?(entry, request) + !!@modes[entry.site.cutoff_mode_name]&.enabled&.call(entry, request) + end + + # @api private + def names + @modes.keys + end + + # @api private + Mode = Struct.new(:name, :enabled) + end +end 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/admin/sites_controller_spec.rb b/spec/controllers/admin/sites_controller_spec.rb index ae2481052c..8edfea671f 100644 --- a/spec/controllers/admin/sites_controller_spec.rb +++ b/spec/controllers/admin/sites_controller_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'pageflow/shared_contexts/fake_translations' module Admin describe SitesController do @@ -161,6 +162,8 @@ def self.name end describe '#new' do + include_context 'fake translations' + it 'displays name input' do account = create(:account) @@ -182,6 +185,37 @@ def self.name expect(response.body).to have_selector('[name="site[custom_field]"]') end + + it 'does not display cutoff mode select by default' do + pageflow_configure do |config| + config.features.register('test_cutoff_mode') do |entry_type_config| + entry_type_config.cutoff_modes.register(:some_cutoff, proc { true }) + end + end + account = create(:account) + + sign_in(create(:user, :admin), scope: :user) + get(:new, params: {account_id: account}) + + expect(response.body).not_to have_select('Cutoff mode') + end + + it 'displays cutoff modes enabled for account' do + translation(I18n.locale, + 'pageflow.cutoff_modes.some_cutoff', + 'Some Cutoff Mode') + pageflow_configure do |config| + config.features.register('test_cutoff_mode') do |entry_type_config| + entry_type_config.cutoff_modes.register(:some_cutoff, proc { true }) + end + end + account = create(:account, with_feature: 'test_cutoff_mode') + + sign_in(create(:user, :admin), scope: :user) + get(:new, params: {account_id: account}) + + expect(response.body).to have_select('Cutoff mode', options: ['(None)', 'Some Cutoff Mode']) + end end describe '#edit' do @@ -210,6 +244,9 @@ def self.name describe '#create' do it 'sets attributes' do + pageflow_configure do |config| + config.cutoff_modes.register(:some_cutoff, proc { true }) + end account = create(:account) sign_in(create(:user, :admin), scope: :user) @@ -221,7 +258,8 @@ def self.name title: 'Second Site', sitemap_enabled: true, feeds_enabled: true, - imprint_link_url: 'http://example.com/new' + imprint_link_url: 'http://example.com/new', + cutoff_mode_name: 'some_cutoff' } }) @@ -231,6 +269,7 @@ def self.name expect(site.title).to eq('Second Site') expect(site.sitemap_enabled?).to eq(true) expect(site.feeds_enabled?).to eq(true) + expect(site.cutoff_mode_name).to eq('some_cutoff') end it 'creates root permalink directory' do 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 diff --git a/spec/models/pageflow/site_spec.rb b/spec/models/pageflow/site_spec.rb index c952d6f38a..ef0b91fb46 100644 --- a/spec/models/pageflow/site_spec.rb +++ b/spec/models/pageflow/site_spec.rb @@ -76,6 +76,53 @@ module Pageflow end end + describe '#cutoff_mode_name' do + it 'is invalid if cutoff mode not registered' do + site = build(:site, cutoff_mode_name: 'unknown') + + site.valid? + + expect(site.errors).to include(:cutoff_mode_name) + end + + it 'is invalid if cutoff mode disabled for account' do + pageflow_configure do |config| + config.features.register('some_cutoff_mode') do |feature_config| + feature_config.cutoff_modes.register(:some, proc { true }) + end + end + + account = create(:account) + site = build(:site, account:, cutoff_mode_name: 'some') + + site.valid? + expect(site.errors).to include(:cutoff_mode_name) + end + + it 'is valid if cutoff mode registered' do + pageflow_configure do |config| + config.cutoff_modes.register(:some, proc { true }) + end + + site = build(:site, cutoff_mode_name: 'some') + + expect(site).to be_valid + end + + it 'is valid if cutoff mode enabled for account' do + pageflow_configure do |config| + config.features.register('some_cutoff_mode') do |feature_config| + feature_config.cutoff_modes.register(:some, proc { true }) + end + end + + account = create(:account, with_feature: 'some_cutoff_mode') + site = build(:site, account:, cutoff_mode_name: 'some') + + expect(site).to be_valid + end + end + describe '.with_home_url' do it 'includes site with home_url' do site = create(:site, home_url: 'http://home.example.com') diff --git a/spec/pageflow/cutoff_modes_spec.rb b/spec/pageflow/cutoff_modes_spec.rb new file mode 100644 index 0000000000..3247c3aebd --- /dev/null +++ b/spec/pageflow/cutoff_modes_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +module Pageflow + describe CutoffModes do + let :request do + ActionDispatch::Request.new(Rack::MockRequest.env_for('https://example.com')) + end + + it 'returns false by default' do + site = create(:site) + entry = create(:published_entry, site:) + + result = Pageflow.config.cutoff_modes.enabled_for?(entry, request) + + expect(result).to eq(false) + end + + it 'returns result from cutoff mode configured in site' do + pageflow_configure do |config| + config.cutoff_modes.register(:test, proc { true }) + end + site = create(:site, cutoff_mode_name: 'test') + entry = create(:published_entry, site:) + + result = Pageflow.config.cutoff_modes.enabled_for?(entry, request) + + expect(result).to eq(true) + end + + it 'passes entry and request to registered proc' do + pageflow_configure do |config| + config.cutoff_modes.register(:test, proc { true }) + end + site = create(:site, cutoff_mode_name: 'test') + entry = create(:published_entry, site:) + + expect { |probe| + cutoff_modes = Pageflow.config.cutoff_modes + cutoff_modes.register(:test, probe.to_proc) + cutoff_modes.enabled_for?(entry, request) + }.to yield_with_args(entry, request) + end + end +end