diff --git a/.rubocop.yml b/.rubocop.yml index acacb21dda..071067c935 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 2.3 + TargetRubyVersion: 2.5 # Use double quotes only for interpolation. Style/StringLiterals: diff --git a/app/assets/stylesheets/pageflow/editor/info_box.scss b/app/assets/stylesheets/pageflow/editor/info_box.scss index f5ca556968..22ba07da95 100644 --- a/app/assets/stylesheets/pageflow/editor/info_box.scss +++ b/app/assets/stylesheets/pageflow/editor/info_box.scss @@ -3,6 +3,13 @@ margin-bottom: 1em; } + &.error { + color: var(--ui-on-error-surface-color); + background-color: var(--ui-error-surface-color); + border-radius: rounded(); + padding: space(3); + } + .shortcuts { dt { display: block; diff --git a/app/assets/stylesheets/pageflow/ui/forms.scss b/app/assets/stylesheets/pageflow/ui/forms.scss index ebb0fd7100..305aaa105e 100644 --- a/app/assets/stylesheets/pageflow/ui/forms.scss +++ b/app/assets/stylesheets/pageflow/ui/forms.scss @@ -219,7 +219,7 @@ textarea.short { text-decoration: underline; } -.input-hidden_via_binding { +.hidden_via_binding { display: none; } diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/entry_json_seed_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/entry_json_seed_helper.rb index 01fe9bc764..cb7d45ee73 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/entry_json_seed_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/entry_json_seed_helper.rb @@ -8,7 +8,10 @@ module EntryJsonSeedHelper def scrolled_entry_editor_json_seed(json, scrolled_entry) json.key_format!(camelize: :lower) - scrolled_entry_editor_legacy_typography_variants_seed(json, scrolled_entry) + entry_config = Pageflow.config_for(scrolled_entry) + + scrolled_entry_editor_legacy_typography_variants_seed(json, entry_config) + scrolled_entry_editor_consent_vendor_host_matchers_seed(json, entry_config) scrolled_entry_json_seed(json, scrolled_entry, @@ -19,14 +22,21 @@ def scrolled_entry_editor_json_seed(json, scrolled_entry) private - def scrolled_entry_editor_legacy_typography_variants_seed(json, scrolled_entry) + def scrolled_entry_editor_legacy_typography_variants_seed(json, entry_config) json.legacy_typography_variants( - Pageflow - .config_for(scrolled_entry) + entry_config .legacy_typography_variants .deep_transform_keys { |key| key.to_s.camelize(:lower) } ) end + + def scrolled_entry_editor_consent_vendor_host_matchers_seed(json, entry_config) + json.consent_vendor_host_matchers( + entry_config + .consent_vendor_host_matchers + .transform_keys { |regexp| regexp.inspect[1..-2] } + ) + end end end end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_consent_vendors.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_consent_vendors.json.jbuilder new file mode 100644 index 0000000000..0cffece34c --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_consent_vendors.json.jbuilder @@ -0,0 +1,16 @@ +content_element_vendors = + entry_config.content_element_consent_vendors.by_content_element_id(entry) + +json.content_element_consent_vendors(content_element_vendors) + +I18n.with_locale(entry.locale) do + json.consent_vendors do + json.array!(content_element_vendors.values.uniq) do |name| + json.name name + json.display_name t("pageflow_scrolled.consent_vendors.#{name}.name") + json.description t("pageflow_scrolled.consent_vendors.#{name}.description") + json.opt_in_prompt t("pageflow_scrolled.consent_vendors.#{name}.opt_in_prompt") + json.paradigm 'lazy opt-in' + end + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder index 23d3e17f37..f11263922d 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder @@ -39,6 +39,9 @@ json.config do self, include_unused: options[:include_unused_additional_seed_data]) ) + + json.partial! 'pageflow_scrolled/entry_json_seed/consent_vendors', + entry: entry, entry_config: entry_config end unless options[:skip_i18n] diff --git a/entry_types/scrolled/config/locales/new/iframe_consent.de.yml b/entry_types/scrolled/config/locales/new/iframe_consent.de.yml new file mode 100644 index 0000000000..8529354036 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/iframe_consent.de.yml @@ -0,0 +1,16 @@ +de: + pageflow_scrolled: + editor: + content_elements: + iframeEmbed: + attributes: + requireConsent: + label: "Datenschutz-Einwilligung aktivieren" + inline_help: |- + iframe erst laden, nachdem Besucher der + Datenverarbeitung durch die eingebettete Seite + zugestimmt hat. + help_texts: + missingConsentVendor: |- + Für den Anbieter der angegeben URL stehen keine + Datenschutzangaben zur Verfügung. diff --git a/entry_types/scrolled/config/locales/new/iframe_consent.en.yml b/entry_types/scrolled/config/locales/new/iframe_consent.en.yml new file mode 100644 index 0000000000..1fa8082a1b --- /dev/null +++ b/entry_types/scrolled/config/locales/new/iframe_consent.en.yml @@ -0,0 +1,15 @@ +en: + pageflow_scrolled: + editor: + content_elements: + iframeEmbed: + attributes: + requireConsent: + label: "Display privacy opt-in" + inline_help: | + Only load iframe after the visitor has given + consent to the data processing by the embedded page. + help_texts: + missingConsentVendor: |- + No privacy policy information available for the provider + of the given URL. diff --git a/entry_types/scrolled/lib/pageflow_scrolled/configuration.rb b/entry_types/scrolled/lib/pageflow_scrolled/configuration.rb index abd537a7c6..bfe220f605 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/configuration.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/configuration.rb @@ -44,6 +44,44 @@ class Configuration # @since 15.7 attr_reader :additional_frontend_seed_data + # Determine which vendors a content element will require consent + # for. Based on the vendor name returned here, the following + # translations will be used in consent UI components. + # + # pageflow_scrolled.consent_vendors.#{name}.name + # pageflow_scrolled.consent_vendors.#{name}.description + # pageflow_scrolled.consent_vendors.#{name}.opt_in_prompt + # + # @example + # + # config.content_element_consent_vendors.register( + # lambda |configuration:| do + # if configuration['provider'] == 'youtube' + # 'youtube' + # else + # 'vimeo' + # end + # end, + # content_element_type_name: 'videoEmbed' + # ) + # + # @return [ContentElementConsentVendors] + # @since edge + attr_reader :content_element_consent_vendors + + # Mapping from URL hosts to consent vendor names. Used for iframe + # embed opt-in. + # + # @exmaple + # + # entry_type_config.consent_vendor_host_matchers = { + # /\.some-vendor\.com$/ => 'someVendor' + # } + # + # @return [Hash] + # @since edge + attr_accessor :consent_vendor_host_matchers + # Migrate typography variants to palette colors. Before palette # colors for text blocks and headings were introduced, it was # already possible to color text by defining typography variants @@ -104,6 +142,8 @@ def initialize(*) @additional_editor_packs = AdditionalPacks.new @additional_frontend_seed_data = AdditionalSeedData.new + @content_element_consent_vendors = ContentElementConsentVendors.new + @consent_vendor_host_matchers = {} @legacy_typography_variants = {} end diff --git a/entry_types/scrolled/lib/pageflow_scrolled/content_element_consent_vendors.rb b/entry_types/scrolled/lib/pageflow_scrolled/content_element_consent_vendors.rb new file mode 100644 index 0000000000..dfa94a7e83 --- /dev/null +++ b/entry_types/scrolled/lib/pageflow_scrolled/content_element_consent_vendors.rb @@ -0,0 +1,38 @@ +module PageflowScrolled + # Register consent vendors based on content element configuration + # data. + class ContentElementConsentVendors + # @api private + def initialize + @callables = {} + end + + # Register callable to determine consent vendor from configuration + # attributes for a content element type. + # + # @param callable [#call] + # Receives configuration keyword argument and returns + # @param content_element_type_name [String] + def register(callable, content_element_type_name:) + @callables[content_element_type_name] = callable + end + + # @api private + def by_content_element_id(entry) + content_elements_with_consent_vendor(entry).each_with_object({}) { |content_element, result| + next unless @callables[content_element.type_name] + + result[content_element.id] = @callables[content_element.type_name].call( + entry: entry, + configuration: content_element.configuration + ) + }.compact + end + + private + + def content_elements_with_consent_vendor(entry) + ContentElement.all_for_revision(entry.revision).where(type_name: @callables.keys) + end + end +end diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 08014b405b..f5327bb64e 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -32,9 +32,27 @@ def configure(config) 'frontendVersion', FRONTEND_VERSION_SEED_DATA ) + + c.content_element_consent_vendors.register( + IFRAME_EMBED_CONSENT_VENDOR, + content_element_type_name: 'iframeEmbed' + ) end end + IFRAME_EMBED_CONSENT_VENDOR = lambda do |configuration:, entry:, **| + return unless configuration['requireConsent'] + + uri = URI.parse(configuration['source']) + host_matchers = Pageflow.config_for(entry).consent_vendor_host_matchers + + host_matchers.detect do |matcher, _| + uri.host =~ matcher + end&.last + rescue URI::InvalidURIError + nil + end + FRONTEND_VERSION_SEED_DATA = lambda do |request:, entry:, **| if request.params[:frontend] == 'v2' || entry.feature_state('frontend_v2') 2 diff --git a/entry_types/scrolled/package/spec/editor/models/ConsentVendors-spec.js b/entry_types/scrolled/package/spec/editor/models/ConsentVendors-spec.js new file mode 100644 index 0000000000..3d9bdc6dec --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ConsentVendors-spec.js @@ -0,0 +1,18 @@ +import {ConsentVendors} from 'editor/models/ConsentVendors'; + +describe('ConsentVendors', () => { + describe('fromUrl', () => { + it('detects vendor from seed data host matcher', () => { + const consentVendors = new ConsentVendors({ + hostMatchers: { + '\\.some-vendor.com$': 'someVendor' + } + }); + + expect(consentVendors.fromUrl('https://foo.some-vendor.com/abc')) + .toEqual('someVendor'); + expect(consentVendors.fromUrl('https://other.com/abc')) + .toBeUndefined(); + }); + }) +}) diff --git a/entry_types/scrolled/package/spec/entryState/consentVendors-spec.js b/entry_types/scrolled/package/spec/entryState/consentVendors-spec.js new file mode 100644 index 0000000000..9c89f9eb3f --- /dev/null +++ b/entry_types/scrolled/package/spec/entryState/consentVendors-spec.js @@ -0,0 +1,33 @@ +import {useContentElementConsentVendor} from 'entryState'; + +import {renderHookInEntry} from 'support'; + +describe('useContentElementConsentVendor', () => { + it('reads data from seed', () => { + const {result} = renderHookInEntry( + () => useContentElementConsentVendor({contentElementId: 10}), { + seed: { + consentVendors: [{name: 'someVendor', displayName: 'Some Vendor'}], + contentElementConsentVendors: {10: 'someVendor'}, + contentElements: [{id: 10}] + } + } + ); + + const data = result.current; + expect(data).toMatchObject({name: 'someVendor', displayName: 'Some Vendor'}); + }); + + it('returns undefined if content element does not have consent vendor', () => { + const {result} = renderHookInEntry( + () => useContentElementConsentVendor({contentElementId: 1}), { + seed: { + contentElements: [{id: 1}] + } + } + ); + + const data = result.current; + expect(data).toBeUndefined(); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/features/thirdPartyConsent-spec.js b/entry_types/scrolled/package/spec/frontend/features/thirdPartyConsent-spec.js index e9028fed72..485be1e1b5 100644 --- a/entry_types/scrolled/package/spec/frontend/features/thirdPartyConsent-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/thirdPartyConsent-spec.js @@ -58,6 +58,33 @@ describe('Third party consent', () => { }); describe('vendor registration', () => { + it('registers content element vendors from config seed', async () => { + const consent = Consent.create(); + jest.spyOn(consent, 'registerVendor'); + + await renderEntry({ + consent, + seed: { + themeOptions: {thirdPartyConsent: {cookieName: 'optIn'}}, + consentVendors: [ + { + name: 'someVendor', + displayName: 'Some Vendor', + paradigm: 'lazy opt-in' + } + ] + } + }); + + expect(consent.registerVendor).toHaveBeenCalledWith( + 'someVendor', + expect.objectContaining({ + displayName: 'Some Vendor', + paradigm: 'lazy opt-in' + }) + ) + }); + it('relies on required vendors reported by content elements', async () => { const consent = Consent.create(); @@ -313,6 +340,76 @@ describe('Third party consent', () => { }); }); + describe('opt in with implicit provider name', () => { + beforeEach(() => { + frontend.contentElementTypes.register('test', { + component: function Component() { + return ( +
+ +
Data from SomeService
+
+
+ ); + } + }); + }); + + it('uses prompt from server rendered consent vendors', async () => { + const {getByTestId} = await renderEntry({ + seed: { + themeOptions: {thirdPartyConsent: {cookieName: 'optIn'}}, + contentElements: [{id: 10, typeName: 'test'}], + consentVendors: [{ + name: 'someVendor', + optInPrompt: 'Enable Some Vendor?', + paradigm: 'lazy opt-in' + }], + contentElementConsentVendors: {10: 'someVendor'} + } + }); + + expect(getByTestId('test-content-element')).toHaveTextContent('Enable Some Vendor?'); + }); + + it('is skipped if flag for provider is true in privacy cookie', async () => { + cookies.setItem('optIn', '{"someVendor": true}'); + + const {getByTestId} = await renderEntry({ + seed: { + themeOptions: {thirdPartyConsent: {cookieName: 'optIn'}}, + contentElements: [{id: 10, typeName: 'test'}], + consentVendors: [{ + name: 'someVendor', + paradigm: 'lazy opt-in' + }], + contentElementConsentVendors: {10: 'someVendor'} + } + }); + + expect(getByTestId('test-content-element')).toHaveTextContent('Data from SomeService'); + }); + + it('sets flag for provider in privacy cookie when consent is given', async () => { + const {getByTestId} = await renderEntry({ + seed: { + themeOptions: {thirdPartyConsent: {cookieName: 'optIn'}}, + contentElements: [{id: 10, typeName: 'test'}], + consentVendors: [{ + name: 'someVendor', + paradigm: 'lazy opt-in' + }], + contentElementConsentVendors: {10: 'someVendor'} + } + }); + + const {getByText} = within(getByTestId('test-content-element')); + await click(getByText('Confirm')); + + expect(JSON.parse(cookies.getItem('optIn'))).toMatchObject({'someVendor': true}); + }); + }); + describe('opt in with wrapper', () => { beforeEach(() => { frontend.contentElementTypes.register('test', { @@ -507,6 +604,69 @@ describe('Third party consent', () => { }); }); + describe('opt out info with implicit provider name', () => { + beforeEach(() => { + frontend.contentElementTypes.register('test', { + component: function Component() { + return ( +
+ + +
Data from SomeService
+
+
+ ); + } + }); + }); + + it('is displayed when consent has been given and privacy link is set in theme options', async () => { + const {getByTestId} = await renderEntry({ + seed: { + themeOptions: { + thirdPartyConsent: { + cookieName: 'optIn', + optOutUrl: 'https://example.com/privacy', + } + }, + consentVendors: [{ + name: 'someVendor', + paradigm: 'lazy opt-in' + }], + contentElementConsentVendors: {10: 'someVendor'}, + contentElements: [{id: 10, typeName: 'test'}] + } + }); + + const {getByText} = within(getByTestId('test-content-element')); + await click(getByText('Confirm')); + + expect(getByTestId('test-content-element')).toHaveTextContent('Click here to opt out'); + expect(getByText('Click here')).toHaveAttribute('href', 'https://example.com/privacy'); + }); + + it('is not displayed if consent has not been given', async () => { + const {getByTestId} = await renderEntry({ + seed: { + themeOptions: { + thirdPartyConsent: { + cookieName: 'optIn', + optOutUrl: 'https://example.com/privacy', + } + }, + consentVendors: [{ + name: 'someVendor', + paradigm: 'lazy opt-in' + }], + contentElementConsentVendors: {10: 'someVendor'}, + contentElements: [{id: 10, typeName: 'test'}] + } + }); + + expect(getByTestId('test-content-element')).not.toHaveTextContent('Click here to opt out'); + }); + }); + describe('opt out info with custom hiding logic', () => { beforeEach(() => { frontend.contentElementTypes.register('test', { diff --git a/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js b/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js index d8d58f2b03..71d5477e7d 100644 --- a/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js +++ b/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js @@ -129,7 +129,20 @@ describe('exampleStories', () => { expect(stories).toContainEqual(expect.objectContaining({ title: 'Consent - Opt-In', - requireConsentOptIn: true + requireConsentOptIn: true, + seed: expect.objectContaining({ + config: expect.objectContaining({ + consentVendors: [ + expect.objectContaining({name: 'test'}) + ], + contentElementConsentVendors: {1000: 'test'} + }), + collections: expect.objectContaining({ + contentElements: expect.arrayContaining([ + expect.objectContaining({id: 1000}) + ]) + }) + }) })); }); }); diff --git a/entry_types/scrolled/package/spec/support/stories.js b/entry_types/scrolled/package/spec/support/stories.js index e6c8488574..63047a6106 100644 --- a/entry_types/scrolled/package/spec/support/stories.js +++ b/entry_types/scrolled/package/spec/support/stories.js @@ -245,17 +245,25 @@ function consentOptInStories({typeName, consent, baseConfiguration}) { return exampleStoryGroup({ typeName, name: 'Consent', - requireConsentOptIn: true, + consentVendors: [ + { + name: 'test', + optInPrompt: 'I agree with being shown this content.' + } + ], examples: [ { name: 'Opt-In', - contentElementConfiguration: baseConfiguration + contentElementConfiguration: { + ...baseConfiguration, + ...consent.configuration + } } ] }); } -function exampleStoryGroup({name, typeName, examples, parameters, requireConsentOptIn}) { +function exampleStoryGroup({name, typeName, examples, parameters, consentVendors}) { const defaultSectionConfiguration = {transition: 'scroll', backdrop: {image: '#000'}, fullHeight: false}; const sections = examples.map((example, index) => ({ @@ -266,7 +274,12 @@ function exampleStoryGroup({name, typeName, examples, parameters, requireConsent const contentElements = examples.reduce((memo, example, index) => [ ...memo, exampleHeading({sectionId: index, text: `${name} - ${example.name}`}), - {sectionId: index, typeName, configuration: example.contentElementConfiguration} + { + id: 1000 + index, + sectionId: index, + typeName, + configuration: example.contentElementConfiguration + } ], []); return sections.map((section, index) => ({ @@ -274,9 +287,17 @@ function exampleStoryGroup({name, typeName, examples, parameters, requireConsent seed: normalizeAndMergeFixture({ sections: [section], contentElements: contentElements, - themeOptions: examples[index].themeOptions + themeOptions: examples[index].themeOptions, + consentVendors, + contentElementConsentVendors: consentVendors && + contentElements + .filter(({id}) => id) + .reduce( + (memo, {id}) => ({...memo, [id]: consentVendors[0].name}), + {} + ) }), - requireConsentOptIn, + requireConsentOptIn: !!consentVendors, parameters, cssRules: Object.entries(examples[index].themeOptions?.properties || {}).reduce( (result, [scope, properties]) => { @@ -308,7 +329,13 @@ export function normalizeAndMergeFixture(options = {}) { ...seedFixture, config: mergeDeep( seedFixture.config, - {theme: {options: options.themeOptions || {}}} + { + theme: { + options: options.themeOptions || {} + }, + consentVendors: options.consentVendors || [], + contentElementConsentVendors: options.contentElementConsentVendors || {} + } ), collections: { ...seedFixture.collections, diff --git a/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js b/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js index 3f69b30da3..296bde6e4a 100644 --- a/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js +++ b/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js @@ -5,6 +5,8 @@ import { ContentElementBox, ContentElementFigure, FitViewport, + ThirdPartyOptIn, + ThirdPartyOptOutInfo, useContentElementEditorState, useContentElementLifecycle, usePortraitOrientation, @@ -37,15 +39,28 @@ export function IframeEmbed({configuration}) { - {shouldLoad && -