From 7493a556a309b4c6a08b89a63d0f2e719aed4b2d Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 15 Dec 2025 12:40:40 +0100 Subject: [PATCH 01/18] Add RadioButtonGroupInputView New input view for selecting one value from a small set of options using radio buttons. Useful for binary choices or small option sets where all choices should be visible at once. REDMINE-21191 --- app/assets/stylesheets/pageflow/ui/forms.scss | 5 + package/documentation.yml | 1 + .../inputs/RadioButtonGroupInputView-spec.js | 183 ++++++++++++++++++ package/src/ui/index.js | 1 + .../views/inputs/RadioButtonGroupInputView.js | 100 ++++++++++ 5 files changed, 290 insertions(+) create mode 100644 package/spec/ui/views/inputs/RadioButtonGroupInputView-spec.js create mode 100644 package/src/ui/views/inputs/RadioButtonGroupInputView.js diff --git a/app/assets/stylesheets/pageflow/ui/forms.scss b/app/assets/stylesheets/pageflow/ui/forms.scss index b06764b7c5..092a019f55 100644 --- a/app/assets/stylesheets/pageflow/ui/forms.scss +++ b/app/assets/stylesheets/pageflow/ui/forms.scss @@ -85,6 +85,7 @@ textarea.short { } .radio_input, +.radio_button, .check_box_input { padding: space(1) 0; position: relative; @@ -108,6 +109,10 @@ textarea.short { } } +.radio_button { + padding: space(2); +} + .radio_input { padding-top: 10px; } diff --git a/package/documentation.yml b/package/documentation.yml index fe65896963..921e695e2a 100644 --- a/package/documentation.yml +++ b/package/documentation.yml @@ -86,6 +86,7 @@ toc: - inputWithPlaceholderText - CheckBoxGroupInputView - CheckBoxInputView + - RadioButtonGroupInputView - ColorInputView - LabelOnlyView - NumberInputView diff --git a/package/spec/ui/views/inputs/RadioButtonGroupInputView-spec.js b/package/spec/ui/views/inputs/RadioButtonGroupInputView-spec.js new file mode 100644 index 0000000000..6f9c3e6865 --- /dev/null +++ b/package/spec/ui/views/inputs/RadioButtonGroupInputView-spec.js @@ -0,0 +1,183 @@ +import Backbone from 'backbone'; +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; + +import {RadioButtonGroupInputView} from 'pageflow/ui'; + +import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; + +describe('pageflow.RadioButtonGroupInputView', () => { + var Model = Backbone.Model.extend({ + i18nKey: 'item' + }); + + it('loads value and checks corresponding radio button', () => { + var model = new Model({position: 'left'}); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'position', + values: ['left', 'center', 'right'], + texts: ['Left', 'Center', 'Right'] + }); + + const {getByRole} = render(view); + + expect(getByRole('radio', {name: 'Left'})).toBeChecked(); + expect(getByRole('radio', {name: 'Center'})).not.toBeChecked(); + expect(getByRole('radio', {name: 'Right'})).not.toBeChecked(); + }); + + it('saves value on change', async () => { + var model = new Model(); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'position', + values: ['left', 'center', 'right'], + texts: ['Left', 'Center', 'Right'] + }); + + const {getByRole} = render(view); + await userEvent.click(getByRole('radio', {name: 'Center'})); + + expect(model.get('position')).toEqual('center'); + }); + + it('updates checked state when model changes', () => { + var model = new Model({position: 'left'}); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'position', + values: ['left', 'center', 'right'], + texts: ['Left', 'Center', 'Right'] + }); + + const {getByRole} = render(view); + model.set('position', 'right'); + + expect(getByRole('radio', {name: 'Left'})).not.toBeChecked(); + expect(getByRole('radio', {name: 'Right'})).toBeChecked(); + }); + + it('renders labels from texts option', () => { + var model = new Model(); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'position', + values: ['left', 'right'], + texts: ['Align Left', 'Align Right'] + }); + + const {getByRole} = render(view); + + expect(getByRole('radio', {name: 'Align Left'})).toBeInTheDocument(); + expect(getByRole('radio', {name: 'Align Right'})).toBeInTheDocument(); + }); + + describe('with boolean values', () => { + it('loads true value', () => { + var model = new Model({enabled: true}); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'enabled', + values: [true, false], + texts: ['Yes', 'No'] + }); + + const {getByRole} = render(view); + + expect(getByRole('radio', {name: 'Yes'})).toBeChecked(); + expect(getByRole('radio', {name: 'No'})).not.toBeChecked(); + }); + + it('loads false value', () => { + var model = new Model({enabled: false}); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'enabled', + values: [true, false], + texts: ['Yes', 'No'] + }); + + const {getByRole} = render(view); + + expect(getByRole('radio', {name: 'Yes'})).not.toBeChecked(); + expect(getByRole('radio', {name: 'No'})).toBeChecked(); + }); + + it('saves boolean value on change', async () => { + var model = new Model({enabled: true}); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'enabled', + values: [true, false], + texts: ['Yes', 'No'] + }); + + const {getByRole} = render(view); + await userEvent.click(getByRole('radio', {name: 'No'})); + + expect(model.get('enabled')).toEqual(false); + }); + }); + + describe('with disabled option', () => { + it('disables all radio buttons when disabled is true', () => { + var model = new Model({position: 'left'}); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'position', + values: ['left', 'center', 'right'], + texts: ['Left', 'Center', 'Right'], + disabled: true + }); + + const {getByRole} = render(view); + + expect(getByRole('radio', {name: 'Left'})).toBeDisabled(); + expect(getByRole('radio', {name: 'Center'})).toBeDisabled(); + expect(getByRole('radio', {name: 'Right'})).toBeDisabled(); + }); + + it('disables radio buttons when disabledBinding attribute becomes true', () => { + var model = new Model({position: 'left', locked: false}); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'position', + values: ['left', 'center', 'right'], + texts: ['Left', 'Center', 'Right'], + disabledBinding: 'locked' + }); + + const {getByRole} = render(view); + + expect(getByRole('radio', {name: 'Left'})).not.toBeDisabled(); + + model.set('locked', true); + + expect(getByRole('radio', {name: 'Left'})).toBeDisabled(); + expect(getByRole('radio', {name: 'Center'})).toBeDisabled(); + expect(getByRole('radio', {name: 'Right'})).toBeDisabled(); + }); + }); + + describe('without texts option', () => { + useFakeTranslations({ + 'activerecord.values.item.position.left': 'Left', + 'activerecord.values.item.position.right': 'Right' + }); + + it('uses translations based on model i18nKey', () => { + var model = new Model(); + var view = new RadioButtonGroupInputView({ + model: model, + propertyName: 'position', + values: ['left', 'right'] + }); + + const {getByRole} = render(view); + + expect(getByRole('radio', {name: 'Left'})).toBeInTheDocument(); + expect(getByRole('radio', {name: 'Right'})).toBeInTheDocument(); + }); + }); +}); diff --git a/package/src/ui/index.js b/package/src/ui/index.js index 0fb841a560..cb902cce8d 100644 --- a/package/src/ui/index.js +++ b/package/src/ui/index.js @@ -39,6 +39,7 @@ export * from './views/inputs/ProxyUrlInputView'; export * from './views/inputs/SliderInputView'; export * from './views/inputs/JsonInputView'; export * from './views/inputs/CheckBoxInputView'; +export * from './views/inputs/RadioButtonGroupInputView'; export * from './views/inputs/UrlInputView'; export * from './views/inputs/SeparatorView'; export * from './views/inputs/LabelOnlyView'; diff --git a/package/src/ui/views/inputs/RadioButtonGroupInputView.js b/package/src/ui/views/inputs/RadioButtonGroupInputView.js new file mode 100644 index 0000000000..f0590fb580 --- /dev/null +++ b/package/src/ui/views/inputs/RadioButtonGroupInputView.js @@ -0,0 +1,100 @@ +import I18n from 'i18n-js'; +import Marionette from 'backbone.marionette'; +import _ from 'underscore'; + +import {findKeyWithTranslation} from '../../utils/i18nUtils'; +import {inputView} from '../mixins/inputView'; + +/** + * Input view for selecting one value from a small set of options + * using radio buttons. + * See {@link inputView} for further options. + * + * @param {Object} [options] + * + * @param {Array} options.values + * Array of possible values the property can be set to. + * + * @param {Array} [options.texts] + * Array of display texts for the values. If not provided, + * translations are looked up based on the model's i18nKey. + * + * @class + */ +export const RadioButtonGroupInputView = Marionette.ItemView.extend({ + mixins: [inputView], + + template: () => ` + + `, + className: 'radio_button_input', + + events: { + 'change': 'save' + }, + + initialize: function() { + if (!this.options.texts) { + var translationKeyPrefix = findKeyWithTranslation( + this.attributeTranslationKeys('values', {fallbackPrefix: 'activerecord.values'}) + ); + + this.options.texts = _.map(this.options.values, function(value) { + return I18n.t(translationKeyPrefix + '.' + value); + }); + } + }, + + onRender: function() { + this.appendOptions(); + this.load(); + this.listenTo(this.model, 'change:' + this.options.propertyName, this.load); + }, + + updateDisabled: function() { + this.$el.find('input').prop('disabled', this.isDisabled()); + }, + + save: function() { + var index = this.$el.find('input').index(this.$el.find('input:checked')); + this.model.set(this.options.propertyName, this.options.values[index]); + }, + + appendOptions: function() { + _.each(this.options.values, function(value, index) { + var id = this.cid + '_' + value; + + var wrapper = document.createElement('div'); + wrapper.className = 'radio_button'; + + var input = document.createElement('input'); + input.type = 'radio'; + input.name = this.options.propertyName; + input.value = value; + input.id = id; + + var label = document.createElement('label'); + label.htmlFor = id; + + var nameSpan = document.createElement('span'); + nameSpan.className = 'name'; + nameSpan.textContent = this.options.texts[index]; + label.appendChild(nameSpan); + + wrapper.appendChild(input); + wrapper.appendChild(document.createTextNode(' ')); + wrapper.appendChild(label); + this.$el.append(wrapper); + }, this); + }, + + load: function() { + if (!this.isClosed) { + var value = this.model.get(this.options.propertyName); + this.$el.find('input[value="' + value + '"]').prop('checked', true); + } + } +}); From 268cbb7b6b2ef2b55ea35d4a679c4e0cb097102b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 15 Dec 2025 12:42:11 +0100 Subject: [PATCH 02/18] Support discrete values in SliderInputView Allow passing a values array to snap the slider to specific values instead of a continuous range. Optionally provide texts array to display custom labels for each value. REDMINE-21191 --- .../ui/views/inputs/SliderInputView-spec.js | 128 ++++++++++++++++++ .../src/ui/views/inputs/SliderInputView.js | 39 ++++-- 2 files changed, 158 insertions(+), 9 deletions(-) diff --git a/package/spec/ui/views/inputs/SliderInputView-spec.js b/package/spec/ui/views/inputs/SliderInputView-spec.js index 0d98790511..b2275dbf59 100644 --- a/package/spec/ui/views/inputs/SliderInputView-spec.js +++ b/package/spec/ui/views/inputs/SliderInputView-spec.js @@ -263,4 +263,132 @@ describe('pageflow.SliderInputView', () => { expect(view.ui.widget.slider('option', 'value')).toEqual(20); }); + + describe('with values option', () => { + it('sets slider min to 0 and max to last index', () => { + var model = new Model(); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + values: [0, 25, 50, 75, 100] + }); + + view.render(); + + expect(view.ui.widget.slider('option', 'min')).toEqual(0); + expect(view.ui.widget.slider('option', 'max')).toEqual(4); + }); + + it('loads value by finding index in values array', () => { + var model = new Model({value: 50}); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + values: [0, 25, 50, 75, 100] + }); + + view.render(); + + expect(view.ui.widget.slider('value')).toEqual(2); + }); + + it('displays value from values array', () => { + var model = new Model({value: 50}); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + values: [0, 25, 50, 75, 100] + }); + + view.render(); + + expect(view.ui.value.text()).toEqual('50%'); + }); + + it('saves value from values array on slidechange', () => { + var model = new Model(); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + values: [0, 25, 50, 75, 100] + }); + + view.render(); + view.$el.trigger('slidechange', {value: 3}); + + expect(model.get('value')).toEqual(75); + }); + + it('updates displayed value on slide', () => { + var model = new Model(); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + values: [0, 25, 50, 75, 100] + }); + + view.render(); + view.$el.trigger('slide', {value: 2}); + + expect(view.ui.value.text()).toEqual('50%'); + }); + + it('displays text from texts option', () => { + var model = new Model({value: 50}); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + values: [0, 25, 50, 75, 100], + texts: ['None', 'Low', 'Medium', 'High', 'Full'] + }); + + view.render(); + + expect(view.ui.value.text()).toEqual('Medium'); + }); + + it('updates displayed text on slide', () => { + var model = new Model(); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + values: [0, 25, 50, 75, 100], + texts: ['None', 'Low', 'Medium', 'High', 'Full'] + }); + + view.render(); + view.$el.trigger('slide', {value: 3}); + + expect(view.ui.value.text()).toEqual('High'); + }); + + it('loads defaultValue by finding index in values array', () => { + var model = new Model(); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + values: ['none', 'sm', 'md', 'lg'], + defaultValue: 'md' + }); + + view.render(); + + expect(view.ui.widget.slider('value')).toEqual(2); + }); + + it('displays text for defaultValue from texts option', () => { + var model = new Model(); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + values: ['none', 'sm', 'md', 'lg'], + texts: ['None', 'Small', 'Medium', 'Large'], + defaultValue: 'md' + }); + + view.render(); + + expect(view.ui.value.text()).toEqual('Medium'); + }); + }); }); diff --git a/package/src/ui/views/inputs/SliderInputView.js b/package/src/ui/views/inputs/SliderInputView.js index 8ae9bd3f41..5e0ed245bc 100644 --- a/package/src/ui/views/inputs/SliderInputView.js +++ b/package/src/ui/views/inputs/SliderInputView.js @@ -53,8 +53,14 @@ export const SliderInputView = Marionette.ItemView.extend({ animate: 'fast' }); - this.setupAttributeBinding('minValue', value => this.updateSliderOption('min', value || 0)); - this.setupAttributeBinding('maxValue', value => this.updateSliderOption('max', value !== undefined ? value : 100)); + if (this.options.values) { + this.ui.widget.slider('option', 'min', 0); + this.ui.widget.slider('option', 'max', this.options.values.length - 1); + } + else { + this.setupAttributeBinding('minValue', value => this.updateSliderOption('min', value || 0)); + this.setupAttributeBinding('maxValue', value => this.updateSliderOption('max', value !== undefined ? value : 100)); + } this.load(); this.listenTo(this.model, 'change:' + this.options.propertyName, this.load); @@ -77,7 +83,8 @@ export const SliderInputView = Marionette.ItemView.extend({ }, handleSlide(event, ui) { - this.updateText(ui.value); + var value = this.options.values ? this.options.values[ui.value] : ui.value; + this.updateText(value); if (this.options.saveOnSlide) { this.save(event, ui); @@ -85,7 +92,8 @@ export const SliderInputView = Marionette.ItemView.extend({ }, save: function(event, ui) { - this.model.set(this.options.propertyName, ui.value); + var value = this.options.values ? this.options.values[ui.value] : ui.value; + this.model.set(this.options.propertyName, value); }, load: function() { @@ -98,7 +106,11 @@ export const SliderInputView = Marionette.ItemView.extend({ value = 'defaultValue' in this.options ? this.options.defaultValue : 0 } - this.ui.widget.slider('option', 'value', this.clampValue(value)); + var sliderValue = this.options.values ? + this.options.values.indexOf(value) : + value; + + this.ui.widget.slider('option', 'value', this.clampValue(sliderValue)); this.updateText(value); }, @@ -110,10 +122,19 @@ export const SliderInputView = Marionette.ItemView.extend({ }, updateText: function(value) { - var unit = 'unit' in this.options ? this.options.unit : '%'; - var text = 'displayText' in this.options ? - this.options.displayText(value) : - value + unit; + var text; + + if (this.options.texts) { + var index = this.options.values.indexOf(value); + text = this.options.texts[index]; + } + else if ('displayText' in this.options) { + text = this.options.displayText(value); + } + else { + var unit = 'unit' in this.options ? this.options.unit : '%'; + text = value + unit; + } this.ui.value.text(text); } From 1701bd074b2f8b544fe50086e1242274397ea7c3 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 11 Dec 2025 16:24:38 +0100 Subject: [PATCH 03/18] Allow hiding input labels visually REDMINE-21191 --- app/assets/stylesheets/pageflow/ui/forms.scss | 12 ++++++++++ .../spec/ui/views/mixins/inputView-spec.js | 23 +++++++++++++++++++ package/src/ui/views/mixins/inputView.js | 4 ++++ 3 files changed, 39 insertions(+) diff --git a/app/assets/stylesheets/pageflow/ui/forms.scss b/app/assets/stylesheets/pageflow/ui/forms.scss index 092a019f55..1500b1fb72 100644 --- a/app/assets/stylesheets/pageflow/ui/forms.scss +++ b/app/assets/stylesheets/pageflow/ui/forms.scss @@ -232,6 +232,18 @@ textarea.short { display: none; } +.visually_hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .input-disabled { button, select, diff --git a/package/spec/ui/views/mixins/inputView-spec.js b/package/spec/ui/views/mixins/inputView-spec.js index b4bec5cc3b..158f981d33 100644 --- a/package/spec/ui/views/mixins/inputView-spec.js +++ b/package/spec/ui/views/mixins/inputView-spec.js @@ -712,6 +712,29 @@ describe('pageflow.inputView', () => { }); }); + describe('hideLabel', () => { + it('adds visually_hidden class to label when true', () => { + var view = createInputView({ + model: new Backbone.Model(), + hideLabel: true + }); + + view.render(); + + expect(view.ui.label).toHaveClass('visually_hidden'); + }); + + it('does not add visually_hidden class to label by default', () => { + var view = createInputView({ + model: new Backbone.Model() + }); + + view.render(); + + expect(view.ui.label).not.toHaveClass('visually_hidden'); + }); + }); + describe('for view with input and label', () => { it('generates for and id attributes', () => { const Model = Backbone.Model.extend({ diff --git a/package/src/ui/views/mixins/inputView.js b/package/src/ui/views/mixins/inputView.js index 65f8120907..e66e6b5da3 100644 --- a/package/src/ui/views/mixins/inputView.js +++ b/package/src/ui/views/mixins/inputView.js @@ -189,6 +189,10 @@ export const inputView = { this.ui.labelText.text(this.labelText()); + if (this.options.hideLabel) { + this.ui.label.addClass('visually_hidden'); + } + this.updateInlineHelp(); this.setLabelFor(); From c6d7eb91f7e0957556bad5e1ff4f0c275d82f180 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 11 Dec 2025 16:25:27 +0100 Subject: [PATCH 04/18] Fix slider value alignment when label is hidden REDMINE-21191 --- app/assets/stylesheets/pageflow/ui/forms.scss | 16 +++++++++++++--- package/src/ui/templates/inputs/sliderInput.jst | 6 ++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/pageflow/ui/forms.scss b/app/assets/stylesheets/pageflow/ui/forms.scss index 1500b1fb72..86429f0293 100644 --- a/app/assets/stylesheets/pageflow/ui/forms.scss +++ b/app/assets/stylesheets/pageflow/ui/forms.scss @@ -145,9 +145,20 @@ textarea.short { .slider_input { padding: space(1) 0 0; + label { + margin-bottom: space(2.5); + } + + .slider_wrapper { + display: flex; + align-items: center; + gap: space(2); + } + .slider { - margin: 10px 10px 0 40px; + flex: 1; border: 1px solid var(--ui-on-surface-color-lighter); + margin-right: 10px; } .ui-slider-handle { @@ -157,8 +168,7 @@ textarea.short { .value { font-size: 11px; - margin: 7px 0; - float: left; + min-width: space(8); } &.disabled .slider, diff --git a/package/src/ui/templates/inputs/sliderInput.jst b/package/src/ui/templates/inputs/sliderInput.jst index 74a0e9c8be..7b2f421495 100644 --- a/package/src/ui/templates/inputs/sliderInput.jst +++ b/package/src/ui/templates/inputs/sliderInput.jst @@ -2,5 +2,7 @@ -
-
+
+
+
+
From 7b7ed97d9df0333b428834df9785c7b814c09640 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 15 Dec 2025 16:58:00 +0100 Subject: [PATCH 05/18] Prevent SliderInputView from saving on render Rendering a slider with defaultValue but no model attribute would incorrectly save the default value to the model. The jQuery UI slider triggers slidechange when value is set programmatically during load, which called save(). Use a loading flag to skip saves during load. REDMINE-21191 --- .../spec/ui/views/inputs/SliderInputView-spec.js | 13 +++++++++++++ package/src/ui/views/inputs/SliderInputView.js | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/package/spec/ui/views/inputs/SliderInputView-spec.js b/package/spec/ui/views/inputs/SliderInputView-spec.js index b2275dbf59..cd33398d11 100644 --- a/package/spec/ui/views/inputs/SliderInputView-spec.js +++ b/package/spec/ui/views/inputs/SliderInputView-spec.js @@ -81,6 +81,19 @@ describe('pageflow.SliderInputView', () => { expect(view.ui.value.text()).toEqual('€ 75'); }); + it('does not save default value on render', () => { + var model = new Model(); + var view = new SliderInputView({ + model: model, + propertyName: 'value', + defaultValue: 50 + }); + + view.render(); + + expect(model.has('value')).toEqual(false); + }); + it('saves value on slidechange', () => { var model = new Model(); var view = new SliderInputView({ diff --git a/package/src/ui/views/inputs/SliderInputView.js b/package/src/ui/views/inputs/SliderInputView.js index 5e0ed245bc..5547df1de5 100644 --- a/package/src/ui/views/inputs/SliderInputView.js +++ b/package/src/ui/views/inputs/SliderInputView.js @@ -92,6 +92,10 @@ export const SliderInputView = Marionette.ItemView.extend({ }, save: function(event, ui) { + if (this.loading) { + return; + } + var value = this.options.values ? this.options.values[ui.value] : ui.value; this.model.set(this.options.propertyName, value); }, @@ -110,7 +114,9 @@ export const SliderInputView = Marionette.ItemView.extend({ this.options.values.indexOf(value) : value; + this.loading = true; this.ui.widget.slider('option', 'value', this.clampValue(sliderValue)); + this.loading = false; this.updateText(value); }, From 59bc23dbebf408e8331a0009cdecb5f936899c58 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 12 Dec 2025 09:53:33 +0100 Subject: [PATCH 06/18] Add button input view to open motif area dialog REDMINE-21191 --- entry_types/scrolled/config/locales/de.yml | 3 + entry_types/scrolled/config/locales/en.yml | 3 + .../inputs/EditMotifAreaInputView-spec.js | 217 ++++++++++++++++++ .../views/inputs/EditMotifAreaInputView.js | 80 +++++++ .../inputs/EditMotifAreaInputView.module.css | 24 ++ 5 files changed, 327 insertions(+) create mode 100644 entry_types/scrolled/package/spec/editor/views/inputs/EditMotifAreaInputView-spec.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.module.css diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index c85d15de39..0636da85a4 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1296,6 +1296,9 @@ de: selectable_section_item: title: Abschnitt auswählen edit_motif_area_menu_item: Motivbereich markieren... + edit_motif_area_input: + select: Motivbereich auswählen + edit: Motivbereich bearbeiten backdrop_content_element_input: add: Neues Element unset: Nicht mehr als Hintergrund verwenden diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 0068fd2261..8a90108dd0 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1278,6 +1278,9 @@ en: selectable_section_item: title: Select section edit_motif_area_menu_item: Select motif area... + edit_motif_area_input: + select: Select motif area + edit: Edit motif area backdrop_content_element_input: add: New element unset: No longer use as backdrop diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/EditMotifAreaInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/EditMotifAreaInputView-spec.js new file mode 100644 index 0000000000..f0a3dda67f --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/inputs/EditMotifAreaInputView-spec.js @@ -0,0 +1,217 @@ +import '@testing-library/jest-dom/extend-expect'; + +import {EditMotifAreaInputView} from 'editor/views/inputs/EditMotifAreaInputView'; +import {EditMotifAreaDialogView} from 'editor/views/EditMotifAreaDialogView'; + +import styles from 'editor/views/inputs/EditMotifAreaInputView.module.css'; + +import {useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; +import {useEditorGlobals} from 'support'; +import userEvent from '@testing-library/user-event'; + +jest.mock('editor/views/EditMotifAreaDialogView'); + +describe('EditMotifAreaInputView', () => { + const {createEntry} = useEditorGlobals(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.edit_motif_area_input.select': 'Select motif area', + 'pageflow_scrolled.editor.edit_motif_area_input.edit': 'Edit motif area' + }); + + it('renders select button when no motif area defined', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).toBeInTheDocument(); + }); + + it('renders edit button when motif area is present', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImage: 10, backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50}}}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Edit motif area'})).toBeInTheDocument(); + }); + + it('renders select button when motif area is present but file is missing', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50}}}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).toBeInTheDocument(); + }); + + it('opens dialog with file looked up via getReference', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImage: 10}}] + }); + const file = entry.getFileCollection('image_files').get(100); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button')); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({ + propertyName: 'backdropImage', + file + }) + ); + }); + + it('looks up video file when backdropType is video', async () => { + const entry = createEntry({ + videoFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropType: 'video', backdropVideo: 10}}] + }); + const file = entry.getFileCollection('video_files').get(100); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button')); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({ + propertyName: 'backdropVideo', + file + }) + ); + }); + + it('looks up mobile image file on portrait tab', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImageMobile: 10}}] + }); + const file = entry.getFileCollection('image_files').get(100); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration, + portrait: true + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button')); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({ + propertyName: 'backdropImageMobile', + file + }) + ); + }); + + it('updates button text when motif area property changes', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImage: 10}}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).toBeInTheDocument(); + + entry.sections.get(1).configuration.set( + 'backdropImageMotifArea', + {left: 0, top: 0, width: 50, height: 50} + ); + + expect(getByRole('button', {name: 'Edit motif area'})).toBeInTheDocument(); + }); + + it('shows check icon when motif area is present', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImage: 10, backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50}}}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + renderBackboneView(view); + + expect(view.el.querySelector(`.${styles.checkIcon}`)).toBeVisible(); + }); + + it('hides check icon when no motif area defined', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + renderBackboneView(view); + + expect(view.el.querySelector(`.${styles.checkIcon}`)).not.toBeVisible(); + }); + + it('disables button when disabled option is true', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration, + disabled: true + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button')).toBeDisabled(); + }); + + it('disables button when file is not present', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button')).toBeDisabled(); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.js new file mode 100644 index 0000000000..5ce9d308de --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.js @@ -0,0 +1,80 @@ +import Marionette from 'backbone.marionette'; +import I18n from 'i18n-js'; + +import {cssModulesUtils, inputView} from 'pageflow/ui'; +import {buttonStyles} from 'pageflow-scrolled/editor'; + +import {EditMotifAreaDialogView} from '../EditMotifAreaDialogView'; + +import styles from './EditMotifAreaInputView.module.css'; + +export const EditMotifAreaInputView = Marionette.ItemView.extend({ + template: () => ` + + + `, + + mixins: [inputView], + + ui: cssModulesUtils.ui(styles, 'button', 'buttonText', 'checkIcon'), + + events: { + 'click button': function() { + EditMotifAreaDialogView.show({ + model: this.model, + propertyName: this.getPropertyName(), + file: this.getFile() + }); + } + }, + + onRender() { + this.update(); + this.listenTo( + this.model, + 'change:' + this.getPropertyName() + 'MotifArea', + this.update + ); + }, + + update() { + const file = this.getFile(); + const hasMotifArea = !!(file && this.model.get(this.getPropertyName() + 'MotifArea')); + const key = hasMotifArea ? 'edit' : 'select'; + + this.ui.buttonText.text(I18n.t(`pageflow_scrolled.editor.edit_motif_area_input.${key}`)); + this.ui.checkIcon.toggle(hasMotifArea); + }, + + updateDisabled(disabled) { + this.ui.button.prop('disabled', disabled || !this.getFile()); + }, + + getPropertyName() { + const backdropType = this.model.get('backdropType'); + + if (this.options.portrait) { + return backdropType === 'video' ? 'backdropVideoMobile' : 'backdropImageMobile'; + } + else { + return backdropType === 'video' ? 'backdropVideo' : 'backdropImage'; + } + }, + + getFile() { + const collection = this.model.get('backdropType') === 'video' ? 'video_files' : 'image_files'; + return this.model.getReference(this.getPropertyName(), collection); + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.module.css new file mode 100644 index 0000000000..3cea99d900 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.module.css @@ -0,0 +1,24 @@ +.button { + width: 100%; + display: flex !important; + align-items: center; + gap: space(2); +} + +.icon { + width: 16px; + height: 16px; + stroke: currentColor; + stroke-width: 2; + fill: none; + flex-shrink: 0; +} + +.buttonText { +} + +.checkIcon { + stroke: currentColor; + stroke-width: 2; + fill: none; +} From ef2c27c7e80267b588274a145a050e8f43d3abd4 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 12 Dec 2025 13:26:16 +0100 Subject: [PATCH 07/18] Add visualization views to illustrate section paddings REDMINE-21191 --- entry_types/scrolled/config/locales/de.yml | 6 + entry_types/scrolled/config/locales/en.yml | 6 + .../inputs/SectionPaddingVisualizationView.js | 810 ++++++++++++++++++ ...SectionPaddingVisualizationView.module.css | 53 ++ 4 files changed, 875 insertions(+) create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.js create mode 100644 entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.module.css diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 0636da85a4..39c81f902c 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1280,6 +1280,12 @@ de: hide: Außerhalb des Editors ausblenden show: Außerhalb des Editors einblenden hidden: Nur im Editor sichtbar + section_padding_visualization: + intersecting_auto: "Darstellung des dynamischen Abstands, der sich an die Größe des Motivbereichs anpasst, um Überlappungen von Text und Motiv zu vermeiden" + intersecting_manual: "Darstellung des manuell definierten Abstands, der bei Änderung der Fenstergröße konstant bleibt" + side_by_side: "Darstellung des oberen Abstands, wenn der Inhalt neben dem Motivbereich Platz findet" + top_padding: "Darstellung des oberen Abstands" + bottom_padding: "Darstellung des unteren Abstands" select_link_destination: cancel: Abbrechen create: Erstellen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 8a90108dd0..d6b60a4d32 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1262,6 +1262,12 @@ en: hide: Hide outside of the editor show: Show outside of the editor hidden: Only visible in editor + section_padding_visualization: + intersecting_auto: "Visualization of dynamic padding that adjusts to the motif area size to prevent text from overlapping the motif" + intersecting_manual: "Visualization of manually defined padding that stays constant as viewport size changes" + side_by_side: "Visualization of top padding when content fits next to the motif area" + top_padding: "Visualization of top padding" + bottom_padding: "Visualization of bottom padding" select_link_destination: cancel: Cancel create: Create diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.js b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.js new file mode 100644 index 0000000000..5fe446db21 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.js @@ -0,0 +1,810 @@ +import Marionette from 'backbone.marionette'; +import {inputView} from 'pageflow/ui'; +import I18n from 'i18n-js'; + +import styles from './SectionPaddingVisualizationView.module.css'; + +const prefix = 'pageflow_scrolled.editor.section_padding_visualization'; + +export const SectionPaddingVisualizationView = Marionette.ItemView.extend({ + mixins: [inputView], + + template: (data) => ` + + ${data.infoText ? `
${data.infoText}
` : ''} +
+ `, + + serializeData() { + return { + infoText: this.options.infoText + }; + }, + + ui: { + preview: `.${styles.preview}` + }, + + onRender() { + this.listenTo(this.model, 'change:layout', this.update); + this.update(); + }, + + update() { + const svg = this.getSvg(); + this.ui.preview.html(svg); + }, + + getSvg() { + const portrait = this.options.portrait; + const layout = this.model.get('layout') || 'left'; + + switch (this.options.variant) { + case 'intersectingAuto': return intersectingAutoSvg(portrait, layout); + case 'intersectingManual': return intersectingManualSvg(portrait, layout); + case 'sideBySide': return sideBySideSvg(portrait, layout); + case 'topPadding': return topPaddingSvg(portrait, layout); + case 'bottomPadding': return bottomPaddingSvg(portrait, layout); + default: return ''; + } + } +}); + +function intersectingAutoSvg(portrait, layout) { + if (portrait) { + return intersectingAutoSvgPortrait(layout); + } + + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + const arrowX = right ? '150;125;125;150' : '30;55;55;30'; + const textXY = right + ? '100 62;63 41;63 41;100 62' + : center + ? '55 62;55 41;55 41;55 62' + : '10 62;47 41;47 41;10 62'; + + return ` + + ${I18n.t(`${prefix}.intersecting_auto`)} + + + + + + + + +${diagonalStripesPattern('autoDiagonalStripes')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function intersectingAutoSvgPortrait(layout) { + // Canvas: 142x142, animates viewport width from 80 (9:16) to 142 (square) + // Height stays fixed at 142. Only x and width animate. + // 9:16: x=31, width=80 | Square: x=0, width=142 + // Scale factor: 80/142 ≈ 0.563 (motif scales with viewport width) + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + const arrowX = right ? '104;129;129;104' : '38;13;13;38'; + const textX = right + ? '48 62;79 108;79 108;48 62' + : center + ? '44 62;44 108;44 108;44 62' + : '39 62;8 108;8 108;39 62'; + + return ` + + ${I18n.t(`${prefix}.intersecting_auto`)} + + + + + + + + +${diagonalStripesPattern('autoDiagonalStripesPortrait')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function intersectingManualSvg(portrait, layout) { + if (portrait) { + return intersectingManualSvgPortrait(layout); + } + + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + const arrowX = right ? '125;150;150;125' : '55;30;30;55'; + const textX = right + ? '63;100;100;63' + : center + ? '55;55;55;55' + : '47;10;10;47'; + + return ` + + ${I18n.t(`${prefix}.intersecting_manual`)} + + + + + + + + +${diagonalStripesPattern('manualDiagonalStripes')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function intersectingManualSvgPortrait(layout) { + // Canvas: 142x142, animates viewport width from 142 (square) to 80 (9:16) + // Height stays fixed at 142. Only x and width animate. + // Square: x=0, width=142 | 9:16: x=31, width=80 + // Scale factor: 142/80 ≈ 1.775 → 0.563 (motif scales with viewport width) + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + const arrowX = right ? '129;104;104;129' : '13;38;38;13'; + const textX = right + ? '79 50;48 50;48 50;79 50' + : center + ? '44 50;44 50;44 50;44 50' + : '8 50;39 50;39 50;8 50'; + + return ` + + ${I18n.t(`${prefix}.intersecting_manual`)} + + + + + + + + +${diagonalStripesPattern('manualDiagonalStripesPortrait')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function sideBySideSvg(portrait, layout) { + // Center layout not supported for side-by-side, fall back to left + const right = layout === 'right'; + + if (portrait) { + const motifX = right ? 4 : 58; + const textX = right ? 47 : 8; + const arrowX = right ? 75 : 25; + + return ` + + ${I18n.t(`${prefix}.side_by_side`)} + + ${diagonalStripesPattern('diagonalStripesPortrait')} + + + + + + + + + ${staticArrow(arrowX, 5)} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + } + + const motifX = right ? 10 : 115; + const textX = right ? 90 : 10; + const arrowX = right ? 135 : 45; + + return ` + + ${I18n.t(`${prefix}.side_by_side`)} + + ${diagonalStripesPattern('diagonalStripes')} + + + + + + + + + ${staticArrow(arrowX, 5)} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function topPaddingSvg(portrait, layout) { + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + + if (portrait) { + const textX = center ? 10 : right ? 10 : 10; + const arrowX = center ? 50 : 50; + + return ` + + ${I18n.t(`${prefix}.top_padding`)} + + ${diagonalStripesPattern('topPaddingStripesPortrait')} + + + + + + + + + ${staticArrow(arrowX, 5)} + + + + + + + + + + + + + + + + + + + `; + } + + const textX = right ? 90 : center ? 50 : 10; + const arrowX = right ? 135 : center ? 90 : 45; + + return ` + + ${I18n.t(`${prefix}.top_padding`)} + + ${diagonalStripesPattern('topPaddingStripes')} + + + + + + + + + ${staticArrow(arrowX, 5)} + + + + + + + + + + + + + + + `; +} + +function bottomPaddingSvg(portrait, layout) { + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + + if (portrait) { + const textX = center ? 10 : right ? 10 : 10; + const arrowX = center ? 50 : 50; + + return ` + + ${I18n.t(`${prefix}.bottom_padding`)} + + ${diagonalStripesPattern('bottomPaddingStripesPortrait')} + + + + + + + + + + + + + + + + + + + + + + + + + + ${staticArrow(arrowX, 85)} + + `; + } + + const textX = right ? 90 : center ? 50 : 10; + const arrowX = right ? 135 : center ? 90 : 45; + + return ` + + ${I18n.t(`${prefix}.bottom_padding`)} + + ${diagonalStripesPattern('bottomPaddingStripes')} + + + + + + + + + + + + + + + + + + + + + + ${staticArrow(arrowX, 55)} + + `; +} + +const easing = '0.4 0 0.2 1;0 0 1 1;0.4 0 0.2 1'; + +function diagonalStripesPattern(id) { + return ` + + + + `; +} + +function staticArrow(x, y) { + return ` + + + + + + `; +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.module.css new file mode 100644 index 0000000000..4583d77738 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.module.css @@ -0,0 +1,53 @@ +.infoText { + margin: space(4) 0 space(2); +} + +.preview { + border: solid 1px var(--ui-on-surface-color-lightest); + border-radius: rounded(sm); + margin-bottom: space(1); + background-color: var(--ui-primary-color-lighter); + color: var(--ui-on-primary-color); + box-sizing: border-box; + overflow: hidden; +} + +:global(.input-disabled) .preview, +:global(.input-disabled) .infoText { + opacity: 0.5; +} + +.svg { + display: block; + aspect-ratio: 9 / 4; + max-height: 150px; + margin-left: auto; + margin-right: auto; +} + +.silhouette { + fill: currentColor; + opacity: 0.5; +} + +.cornerMarker { + stroke: currentColor; + stroke-width: 1.5; + fill: none; + opacity: 0.7; +} + +.spacingZone { + fill: url(#diagonalStripes); +} + +.arrow { + stroke: var(--ui-selection-color); + stroke-width: 1.5; + fill: none; +} + +.textBlock rect { + fill: currentColor; + opacity: 0.6; +} From f4632c45d8d33b8dcff65ca70c84e1b562d84155 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 15 Dec 2025 12:17:15 +0100 Subject: [PATCH 08/18] Represent motif area concepts in EditSectionPaddingsView Allow controlling `exposeMotifArea` and introduce additional `portraitExposeMotifArea` and `customPortraitPaddings` which still need to be wired up in frontend layout. REDMINE-21191 --- entry_types/scrolled/config/locales/de.yml | 29 +- entry_types/scrolled/config/locales/en.yml | 33 ++ .../views/EditSectionPaddingsView-spec.js | 531 ++++++++++++++++++ .../spec/support/toBeVisibleViaBinding.js | 12 + .../editor/views/EditSectionPaddingsView.js | 158 +++++- 5 files changed, 734 insertions(+), 29 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js create mode 100644 entry_types/scrolled/package/spec/support/toBeVisibleViaBinding.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 39c81f902c..660406c96f 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1357,19 +1357,32 @@ de: tabs: sectionPaddings: Innenabstände portrait: Hochkant + + side_by_side_info: 'Wenn Motiv und Inhalt nebeneinander passen:' attributes: + topPaddingVisualization: + label: Abstand oben + exposeMotifArea: + label: Abstand oben + values: + 'true': Automatisch anpassen, um das Motiv im Hintergrund freizustellen + 'false': Manuell festlegen + sideBySideVisualization: + label: Nebeneinander paddingTop: - blank: (Standard) - label: Oben + label: Abstand oben + bottomPaddingVisualization: + label: Abstand unten paddingBottom: - blank: (Standard) - label: Unten + label: Abstand unten + samePortraitPaddings: + label: Gleiche Innenabstände wie bei Querformat verwenden + portraitExposeMotifArea: + label: Abstand oben portraitPaddingTop: - blank: (Standard) - label: Oben (Hochkant) + label: Abstand oben (Hochkant) portraitPaddingBottom: - blank: (Standard) - label: Unten (Hochkant) + label: Abstand unten (Hochkant) typography_sizes: xl: Sehr groß lg: Groß diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index d6b60a4d32..ec8c35a5ca 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1206,6 +1206,8 @@ en: cardSurfaceColor: label: Cards background color auto: "(Auto)" + sectionPaddings: + label: "Top/bottom padding" tabs: section: Section edit_section_transition: @@ -1333,6 +1335,37 @@ en: remove: "Remove style" crop_types: circle: Circle + section_paddings_input: + auto: (Auto) + edit_section_paddings: + tabs: + sectionPaddings: Paddings + portrait: Portrait + side_by_side_info: 'When motif and content fit side by side:' + attributes: + topPaddingVisualization: + label: Top padding + exposeMotifArea: + label: Top Padding + values: + 'true': Adjust automatically to expose a motif in the background + 'false': Set manually + sideBySideVisualization: + label: Side by side + paddingTop: + label: Top padding + bottomPaddingVisualization: + label: Bottom padding + paddingBottom: + label: Bottom padding + samePortraitPaddings: + label: Use same paddings as in landscape orientation + portraitExposeMotifArea: + label: Top Padding + portraitPaddingTop: + label: Top padding (Portrait) + portraitPaddingBottom: + label: Bottom padding (Portrait) typography_sizes: xl: Very large lg: Large diff --git a/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js new file mode 100644 index 0000000000..0f14aea521 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js @@ -0,0 +1,531 @@ +import {EditSectionPaddingsView} from 'editor/views/EditSectionPaddingsView'; +import {EditMotifAreaDialogView} from 'editor/views/EditMotifAreaDialogView'; + +import {useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; +import {useEditorGlobals, useFakeXhr} from 'support'; +import '@testing-library/jest-dom/extend-expect'; +import 'support/toBeVisibleViaBinding'; +import userEvent from '@testing-library/user-event'; + +jest.mock('editor/views/EditMotifAreaDialogView'); + +describe('EditSectionPaddingsView', () => { + useFakeXhr(); + const {createEntry} = useEditorGlobals(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.section_padding_visualization.intersecting_auto': 'Auto', + 'pageflow_scrolled.editor.section_padding_visualization.intersecting_manual': 'Manual', + 'pageflow_scrolled.editor.section_padding_visualization.side_by_side': 'SideBySide', + 'pageflow_scrolled.editor.section_padding_visualization.top_padding': 'TopPadding', + 'pageflow_scrolled.editor.section_padding_visualization.bottom_padding': 'Bottom', + 'pageflow_scrolled.editor.edit_section_paddings.tabs.portrait': 'Portrait', + 'pageflow_scrolled.editor.edit_section_paddings.attributes.samePortraitPaddings.label': 'Same as landscape', + 'pageflow_scrolled.editor.edit_motif_area_input.select': 'Select motif area', + 'pageflow_scrolled.editor.edit_motif_area_input.edit': 'Edit motif area', + 'pageflow_scrolled.editor.edit_section_paddings.attributes.exposeMotifArea.values.true': 'Auto mode', + 'pageflow_scrolled.editor.edit_section_paddings.attributes.exposeMotifArea.values.false': 'Manual mode' + }); + + it('shows auto visualization when exposeMotifArea is true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Auto'})).toBeVisibleViaBinding(); + }); + + it('hides auto visualization when exposeMotifArea is false', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Auto'})).not.toBeVisibleViaBinding(); + }); + + it('shows manual visualization when exposeMotifArea is false', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Manual'})).toBeVisibleViaBinding(); + }); + + it('hides manual visualization when exposeMotifArea is true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Manual'})).not.toBeVisibleViaBinding(); + }); + + it('shows sideBySide visualization when exposeMotifArea is true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'})).toBeVisibleViaBinding(); + }); + + it('hides sideBySide visualization when exposeMotifArea is false', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'})).not.toBeVisibleViaBinding(); + }); + + it.each(['center', 'centerRagged']) + ('hides sideBySide visualization when layout is %s', (layout) => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true, layout}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'})).not.toBeVisibleViaBinding(); + }); + + it.each(['center', 'centerRagged']) + ('hides portrait sideBySide visualization when layout is %s', async (layout) => { + const entry = createEntry({ + sections: [{id: 1, configuration: {portraitExposeMotifArea: true, layout}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + expect(getByRole('img', {name: 'SideBySide'})).not.toBeVisibleViaBinding(); + }); + + it('shows motif area button when exposeMotifArea is true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).toBeVisibleViaBinding(); + }); + + it('hides motif area button when exposeMotifArea is false', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).not.toBeVisibleViaBinding(); + }); + + it('opens dialog with backdropImage propertyName for image backdrop', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {exposeMotifArea: true, backdropType: 'image', backdropImage: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button', {name: 'Select motif area'})); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({propertyName: 'backdropImage'}) + ); + }); + + it('opens dialog with backdropVideo propertyName for video backdrop', async () => { + const entry = createEntry({ + videoFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {exposeMotifArea: true, backdropType: 'video', backdropVideo: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button', {name: 'Select motif area'})); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({propertyName: 'backdropVideo'}) + ); + }); + + it('opens dialog with backdropImageMobile propertyName on portrait tab', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {portraitExposeMotifArea: true, backdropType: 'image', backdropImageMobile: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + await user.click(getByRole('button', {name: 'Select motif area'})); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({propertyName: 'backdropImageMobile'}) + ); + }); + + it('does not disable edit motif area button on portrait tab when same portrait paddings is enabled', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: { + portraitExposeMotifArea: true, + backdropImageMobile: 10, + customPortraitPaddings: false + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + expect(getByRole('button', {name: 'Select motif area'})).not.toBeDisabled(); + }); + + describe('with auto mode but no motif area defined', () => { + it('disables sideBySide visualization', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'}).closest('.input').classList).toContain('input-disabled'); + }); + + it('disables paddingTop slider', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).toContain('disabled'); + }); + }); + + describe('with manual mode and no motif area defined', () => { + it('does not disable paddingTop slider', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).not.toContain('disabled'); + }); + }); + + describe('with auto mode and motif area defined', () => { + it('does not disable sideBySide visualization', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: { + exposeMotifArea: true, + backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50} + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'}).closest('.input').classList).not.toContain('input-disabled'); + }); + + it('does not disable paddingTop slider', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: { + exposeMotifArea: true, + backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50} + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).not.toContain('disabled'); + }); + + it('does not disable paddingTop slider for video backdrop with motif area', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: { + exposeMotifArea: true, + backdropType: 'video', + backdropVideoMotifArea: {left: 0, top: 0, width: 50, height: 50} + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).not.toContain('disabled'); + }); + }); + + describe('portrait tab with same paddings checkbox checked', () => { + it('shows non-portrait exposeMotifArea value in radio button', async () => { + const entry = createEntry({ + sections: [{id: 1, configuration: { + exposeMotifArea: true, + portraitExposeMotifArea: false, + customPortraitPaddings: false + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + expect(getByRole('radio', {name: 'Auto mode'})).toBeChecked(); + }); + + it('disables portrait paddingTop slider', async () => { + const entry = createEntry({ + sections: [{id: 1, configuration: { + customPortraitPaddings: false + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + const sliders = view.el.querySelectorAll('.slider_input'); + expect(sliders[0].classList).toContain('disabled'); + }); + + it('switches to portrait auto mode and reveals elements when unchecking checkbox', async () => { + const entry = createEntry({ + sections: [{id: 1, configuration: { + exposeMotifArea: false, + portraitExposeMotifArea: true, + customPortraitPaddings: false + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + // Initially shows manual mode (from non-portrait exposeMotifArea) + expect(getByRole('radio', {name: 'Manual mode'})).toBeChecked(); + expect(getByRole('img', {name: 'SideBySide'})).not.toBeVisibleViaBinding(); + expect(getByRole('button', {name: 'Select motif area'})).not.toBeVisibleViaBinding(); + + await user.click(getByRole('checkbox', {name: 'Same as landscape'})); + + // Now shows auto mode (from portrait exposeMotifArea) + expect(getByRole('radio', {name: 'Auto mode'})).toBeChecked(); + expect(getByRole('img', {name: 'SideBySide'})).toBeVisibleViaBinding(); + expect(getByRole('button', {name: 'Select motif area'})).toBeVisibleViaBinding(); + }); + }); + + describe('with backdrop type color', () => { + it('does not disable paddingTop slider even with exposeMotifArea true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color', exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).not.toContain('disabled'); + }); + + it('hides intersecting auto visualization', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Auto'})).not.toBeVisibleViaBinding(); + }); + + it('hides intersecting manual visualization', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Manual'})).not.toBeVisibleViaBinding(); + }); + + it('shows topPadding visualization instead', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'TopPadding'})).toBeVisibleViaBinding(); + }); + + it('hides motif area button', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color', exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).not.toBeVisibleViaBinding(); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/support/toBeVisibleViaBinding.js b/entry_types/scrolled/package/spec/support/toBeVisibleViaBinding.js new file mode 100644 index 0000000000..8faf7f736a --- /dev/null +++ b/entry_types/scrolled/package/spec/support/toBeVisibleViaBinding.js @@ -0,0 +1,12 @@ +expect.extend({ + toBeVisibleViaBinding(element) { + const pass = !element.closest('.hidden_via_binding'); + + return { + pass, + message: () => pass + ? `expected element to have 'hidden_via_binding' class on itself or ancestors` + : `expected element not to have 'hidden_via_binding' class on itself or ancestors` + }; + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js index 062ae6b4f9..ffef121e39 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js @@ -1,8 +1,14 @@ -import {EditConfigurationView} from 'pageflow/editor'; -import {SelectInputView} from 'pageflow/ui'; +import I18n from 'i18n-js'; +import {EditConfigurationView, SeparatorView} from 'pageflow/editor'; +import {SliderInputView, RadioButtonGroupInputView, CheckBoxInputView} from 'pageflow/ui'; + +import {SectionPaddingVisualizationView} from './inputs/SectionPaddingVisualizationView'; +import {EditMotifAreaInputView} from './inputs/EditMotifAreaInputView'; import styles from './EditSectionPaddingsView.module.css'; +const i18nPrefix = 'pageflow_scrolled.editor.edit_section_paddings'; + export const EditSectionPaddingsView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_section_paddings', hideDestroyButton: true, @@ -20,30 +26,140 @@ export const EditSectionPaddingsView = EditConfigurationView.extend({ const [paddingBottomValues, paddingBottomTexts] = entry.getScale('sectionPaddingBottom'); configurationEditor.tab('sectionPaddings', function() { - this.input('paddingTop', SelectInputView, { - includeBlank: true, - values: paddingTopValues, - texts: paddingTopTexts - }); - - this.input('paddingBottom', SelectInputView, { - includeBlank: true, - values: paddingBottomValues, - texts: paddingBottomTexts - }); + paddingInputs(this, {paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts}); }); + configurationEditor.tab('portrait', function() { - this.input('portraitPaddingTop', SelectInputView, { - includeBlank: true, - values: paddingTopValues, - texts: paddingTopTexts + this.listenTo(this.model, 'change:customPortraitPaddings', () => { + configurationEditor.refresh(); }); - this.input('portraitPaddingBottom', SelectInputView, { - includeBlank: true, - values: paddingBottomValues, - texts: paddingBottomTexts + this.input('samePortraitPaddings', CheckBoxInputView, { + storeInverted: 'customPortraitPaddings' + }); + + const usePortraitProperties = this.model.get('customPortraitPaddings'); + + paddingInputs(this, { + prefix: usePortraitProperties ? 'portrait' : '', + portrait: true, + paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts, + disabledOptions: usePortraitProperties ? {} : {disabled: true} }); }); } }); + +function paddingInputs(tab, options) { + const { + prefix = '', + portrait = false, + paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts, + disabledOptions + } = options; + + const exposeMotifArea = prefix ? `${prefix}ExposeMotifArea` : 'exposeMotifArea'; + + tab.input('topPaddingVisualization', SectionPaddingVisualizationView, { + variant: 'intersectingAuto', + portrait, + visibleBinding: [exposeMotifArea, ...motifAreaBinding], + visible: ([exposeMotifAreaValue, ...motifAreaValues]) => + exposeMotifAreaValue && !motifAreaUnavailable(motifAreaValues), + ...disabledOptions + }); + tab.input('topPaddingVisualization', SectionPaddingVisualizationView, { + variant: 'intersectingManual', + portrait, + visibleBinding: [exposeMotifArea, ...motifAreaBinding], + visible: ([exposeMotifAreaValue, ...motifAreaValues]) => + !exposeMotifAreaValue && !motifAreaUnavailable(motifAreaValues), + ...disabledOptions + }); + tab.input('topPaddingVisualization', SectionPaddingVisualizationView, { + variant: 'topPadding', + portrait, + visibleBinding: motifAreaBinding, + visible: motifAreaUnavailable, + ...disabledOptions + }); + tab.input(exposeMotifArea, RadioButtonGroupInputView, { + hideLabel: true, + values: [true, false], + texts: [ + I18n.t(`${i18nPrefix}.attributes.exposeMotifArea.values.true`), + I18n.t(`${i18nPrefix}.attributes.exposeMotifArea.values.false`) + ], + visibleBinding: motifAreaBinding, + visible: values => !motifAreaUnavailable(values), + ...disabledOptions + }); + tab.input('editMotifArea', EditMotifAreaInputView, { + hideLabel: true, + portrait, + visibleBinding: [exposeMotifArea, ...motifAreaBinding], + visible: ([exposeMotifAreaValue, ...motifAreaValues]) => + exposeMotifAreaValue && !motifAreaUnavailable(motifAreaValues) + }); + + const imageMotifAreaPropertyName = portrait ? 'backdropImageMobileMotifArea' : 'backdropImageMotifArea'; + const videoMotifAreaPropertyName = portrait ? 'backdropVideoMobileMotifArea' : 'backdropVideoMotifArea'; + const motifAreaNotDefinedBinding = [ + exposeMotifArea, 'backdropType', imageMotifAreaPropertyName, videoMotifAreaPropertyName + ]; + + tab.input('sideBySideVisualization', SectionPaddingVisualizationView, { + hideLabel: true, + variant: 'sideBySide', + portrait, + infoText: I18n.t(`${i18nPrefix}.side_by_side_info`), + visibleBinding: [exposeMotifArea, 'layout', ...motifAreaBinding], + visible: ([exposeMotifAreaValue, layout, ...motifAreaValues]) => + exposeMotifAreaValue && + layout !== 'center' && + layout !== 'centerRagged' && + !motifAreaUnavailable(motifAreaValues), + disabledBinding: motifAreaNotDefinedBinding, + disabled: motifAreaNotDefined + }); + + tab.input(prefix ? `${prefix}PaddingTop` : 'paddingTop', SliderInputView, { + hideLabel: true, + texts: paddingTopTexts, + values: paddingTopValues, + saveOnSlide: true, + disabledBinding: motifAreaNotDefinedBinding, + disabled: motifAreaNotDefined, + ...disabledOptions + }); + + tab.view(SeparatorView); + + tab.input('bottomPaddingVisualization', SectionPaddingVisualizationView, { + variant: 'bottomPadding', + portrait, + ...disabledOptions + }); + tab.input(prefix ? `${prefix}PaddingBottom` : 'paddingBottom', SliderInputView, { + hideLabel: true, + texts: paddingBottomTexts, + values: paddingBottomValues, + saveOnSlide: true, + ...disabledOptions + }); +} + +const motifAreaBinding = ['backdropType']; + +function motifAreaUnavailable([backdropType]) { + return backdropType === 'color'; +} + +function motifAreaNotDefined([exposeMotifAreaValue, backdropType, imageMotifArea, videoMotifArea]) { + if (backdropType === 'color') { + return false; + } + + const motifArea = backdropType === 'video' ? videoMotifArea : imageMotifArea; + return exposeMotifAreaValue && !motifArea; +} From 3e0a77ee54defc44568668aa5bf61ee41cfb2712 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 12 Dec 2025 13:40:58 +0100 Subject: [PATCH 09/18] Only show portrait tab when portrait backdrop is set The portrait padding settings are only relevant when a separate portrait backdrop image or video has been configured. Hide the tab for color backdrops entirely, as there's nothing portrait-specific to configure. REDMINE-21191 --- .../views/EditSectionPaddingsView-spec.js | 94 ++++++++++++++++++- .../editor/views/EditSectionPaddingsView.js | 18 ++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js index 0f14aea521..fd5b3dc9cd 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js @@ -136,7 +136,8 @@ describe('EditSectionPaddingsView', () => { it.each(['center', 'centerRagged']) ('hides portrait sideBySide visualization when layout is %s', async (layout) => { const entry = createEntry({ - sections: [{id: 1, configuration: {portraitExposeMotifArea: true, layout}}] + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {portraitExposeMotifArea: true, layout, backdropImageMobile: 10}}] }); const view = new EditSectionPaddingsView({ @@ -378,10 +379,12 @@ describe('EditSectionPaddingsView', () => { describe('portrait tab with same paddings checkbox checked', () => { it('shows non-portrait exposeMotifArea value in radio button', async () => { const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], sections: [{id: 1, configuration: { exposeMotifArea: true, portraitExposeMotifArea: false, - customPortraitPaddings: false + customPortraitPaddings: false, + backdropImageMobile: 10 }}] }); @@ -400,8 +403,10 @@ describe('EditSectionPaddingsView', () => { it('disables portrait paddingTop slider', async () => { const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], sections: [{id: 1, configuration: { - customPortraitPaddings: false + customPortraitPaddings: false, + backdropImageMobile: 10 }}] }); @@ -421,10 +426,12 @@ describe('EditSectionPaddingsView', () => { it('switches to portrait auto mode and reveals elements when unchecking checkbox', async () => { const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], sections: [{id: 1, configuration: { exposeMotifArea: false, portraitExposeMotifArea: true, - customPortraitPaddings: false + customPortraitPaddings: false, + backdropImageMobile: 10 }}] }); @@ -528,4 +535,83 @@ describe('EditSectionPaddingsView', () => { expect(getByRole('button', {name: 'Select motif area'})).not.toBeVisibleViaBinding(); }); }); + + describe('portrait tab visibility', () => { + it('hides portrait tab when backdropType is color', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {queryByRole} = renderBackboneView(view); + + expect(queryByRole('tab', {name: 'Portrait'})).not.toBeInTheDocument(); + }); + + it('hides portrait tab when no portrait image is present', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'image'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {queryByRole} = renderBackboneView(view); + + expect(queryByRole('tab', {name: 'Portrait'})).not.toBeInTheDocument(); + }); + + it('hides portrait tab when no portrait video is present', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'video'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {queryByRole} = renderBackboneView(view); + + expect(queryByRole('tab', {name: 'Portrait'})).not.toBeInTheDocument(); + }); + + it('shows portrait tab when portrait image is present', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImageMobile: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('tab', {name: 'Portrait'})).toBeInTheDocument(); + }); + + it('shows portrait tab when portrait video is present', () => { + const entry = createEntry({ + videoFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropType: 'video', backdropVideoMobile: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('tab', {name: 'Portrait'})).toBeInTheDocument(); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js index ffef121e39..624cc7e666 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js @@ -21,6 +21,7 @@ export const EditSectionPaddingsView = EditConfigurationView.extend({ configure: function(configurationEditor) { const entry = this.options.entry; + const configuration = this.model.configuration; const [paddingTopValues, paddingTopTexts] = entry.getScale('sectionPaddingTop'); const [paddingBottomValues, paddingBottomTexts] = entry.getScale('sectionPaddingBottom'); @@ -29,6 +30,10 @@ export const EditSectionPaddingsView = EditConfigurationView.extend({ paddingInputs(this, {paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts}); }); + if (!hasPortraitBackdrop(configuration)) { + return; + } + configurationEditor.tab('portrait', function() { this.listenTo(this.model, 'change:customPortraitPaddings', () => { configurationEditor.refresh(); @@ -163,3 +168,16 @@ function motifAreaNotDefined([exposeMotifAreaValue, backdropType, imageMotifArea const motifArea = backdropType === 'video' ? videoMotifArea : imageMotifArea; return exposeMotifAreaValue && !motifArea; } + +function hasPortraitBackdrop(configuration) { + const backdropType = configuration.get('backdropType'); + + if (backdropType === 'color') { + return false; + } + + const propertyName = backdropType === 'video' ? 'backdropVideoMobile' : 'backdropImageMobile'; + const collection = backdropType === 'video' ? 'video_files' : 'image_files'; + + return !!configuration.getReference(propertyName, collection); +} From 270d9fe5eeb61d5cbdd051e69bf7146ff686d35c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 19 Dec 2025 10:27:14 +0100 Subject: [PATCH 10/18] Add icon option to SliderInputView Display padding direction icons in section padding sliders to help users understand which slider controls top vs bottom padding. Extract inline SVGs from SectionPaddingsInputView into reusable SVG files. REDMINE-21191 --- .../editor/views/EditSectionPaddingsView.js | 4 +++ .../src/editor/views/images/paddingBottom.svg | 1 + .../src/editor/views/images/paddingTop.svg | 1 + .../views/inputs/SectionPaddingsInputView.js | 8 ++--- .../SectionPaddingsInputView.module.css | 2 +- .../ui/views/inputs/SliderInputView-spec.js | 29 +++++++++++++++++++ .../src/ui/templates/inputs/sliderInput.jst | 1 + .../src/ui/views/inputs/SliderInputView.js | 9 ++++++ 8 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 entry_types/scrolled/package/src/editor/views/images/paddingBottom.svg create mode 100644 entry_types/scrolled/package/src/editor/views/images/paddingTop.svg diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js index 624cc7e666..0e779e15d8 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js @@ -5,6 +5,8 @@ import {SliderInputView, RadioButtonGroupInputView, CheckBoxInputView} from 'pag import {SectionPaddingVisualizationView} from './inputs/SectionPaddingVisualizationView'; import {EditMotifAreaInputView} from './inputs/EditMotifAreaInputView'; +import paddingTopIcon from './images/paddingTop.svg'; +import paddingBottomIcon from './images/paddingBottom.svg'; import styles from './EditSectionPaddingsView.module.css'; const i18nPrefix = 'pageflow_scrolled.editor.edit_section_paddings'; @@ -130,6 +132,7 @@ function paddingInputs(tab, options) { tab.input(prefix ? `${prefix}PaddingTop` : 'paddingTop', SliderInputView, { hideLabel: true, + icon: paddingTopIcon, texts: paddingTopTexts, values: paddingTopValues, saveOnSlide: true, @@ -147,6 +150,7 @@ function paddingInputs(tab, options) { }); tab.input(prefix ? `${prefix}PaddingBottom` : 'paddingBottom', SliderInputView, { hideLabel: true, + icon: paddingBottomIcon, texts: paddingBottomTexts, values: paddingBottomValues, saveOnSlide: true, diff --git a/entry_types/scrolled/package/src/editor/views/images/paddingBottom.svg b/entry_types/scrolled/package/src/editor/views/images/paddingBottom.svg new file mode 100644 index 0000000000..8df4cf334f --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/images/paddingBottom.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/editor/views/images/paddingTop.svg b/entry_types/scrolled/package/src/editor/views/images/paddingTop.svg new file mode 100644 index 0000000000..b62baa13b8 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/images/paddingTop.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingsInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingsInputView.js index 47fccd22f7..cfe7f57347 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingsInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingsInputView.js @@ -5,6 +5,8 @@ import {editor} from 'pageflow/editor'; import {buttonStyles} from 'pageflow-scrolled/editor'; import {cssModulesUtils, inputView} from 'pageflow/ui'; +import paddingTopIcon from '../images/paddingTop.svg'; +import paddingBottomIcon from '../images/paddingBottom.svg'; import styles from './SectionPaddingsInputView.module.css'; export const SectionPaddingsInputView = Marionette.Layout.extend({ @@ -17,12 +19,10 @@ export const SectionPaddingsInputView = Marionette.Layout.extend({