From e07bc8347a15196dec1ac429393592b0dddc03aa Mon Sep 17 00:00:00 2001
From: Peter Evans <peter@baserow.io>
Date: Fri, 24 May 2024 11:09:00 +0000
Subject: [PATCH] Resolve "Repeat element: add styling options"

---
 .../contrib/builder/elements/element_types.py |  16 ++-
 .../contrib/builder/elements/models.py        |  15 ++-
 .../migrations/0019_repeat_element_styling.py |  29 +++++
 ...t_type_called_repeat_given_a_list_dat.json |   7 ++
 .../components/elements/AddElementZone.vue    |  20 +++-
 .../elements/components/RepeatElement.vue     | 113 ++++++++++++------
 .../forms/general/RepeatElementForm.vue       |  97 ++++++++++++++-
 .../builder/components/page/PageContent.vue   |  40 +++++++
 .../components/page/header/DeviceSelector.vue |   1 +
 web-frontend/modules/builder/elementTypes.js  |  10 ++
 web-frontend/modules/builder/locales/en.json  |  10 +-
 web-frontend/modules/builder/plugin.js        |   5 +-
 web-frontend/modules/builder/store/page.js    |   6 +-
 .../components/builder/add_element_zone.scss  |   4 +
 .../scss/components/builder/elements/all.scss |   1 +
 .../builder/elements/repeat_element.scss      |  17 +++
 16 files changed, 343 insertions(+), 48 deletions(-)
 create mode 100644 backend/src/baserow/contrib/builder/migrations/0019_repeat_element_styling.py
 create mode 100644 changelog/entries/unreleased/feature/2485_introduced_a_new_element_type_called_repeat_given_a_list_dat.json
 create mode 100644 web-frontend/modules/core/assets/scss/components/builder/elements/repeat_element.scss

diff --git a/backend/src/baserow/contrib/builder/elements/element_types.py b/backend/src/baserow/contrib/builder/elements/element_types.py
index 6380e1ae5..845b9f377 100644
--- a/backend/src/baserow/contrib/builder/elements/element_types.py
+++ b/backend/src/baserow/contrib/builder/elements/element_types.py
@@ -251,14 +251,26 @@ class RepeatElementType(
     type = "repeat"
     model_class = RepeatElement
 
+    @property
+    def allowed_fields(self):
+        return super().allowed_fields + ["orientation", "items_per_row"]
+
+    @property
+    def serializer_field_names(self):
+        return super().serializer_field_names + ["orientation", "items_per_row"]
+
     class SerializedDict(
         CollectionElementTypeMixin.SerializedDict,
         ContainerElementTypeMixin.SerializedDict,
     ):
-        pass
+        orientation: str
+        items_per_row: dict
 
     def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]:
-        return {"data_source_id": None}
+        return {
+            "data_source_id": None,
+            "orientation": RepeatElement.ORIENTATIONS.VERTICAL,
+        }
 
 
 class HeadingElementType(ElementType):
diff --git a/backend/src/baserow/contrib/builder/elements/models.py b/backend/src/baserow/contrib/builder/elements/models.py
index da9c10775..f4de1077c 100644
--- a/backend/src/baserow/contrib/builder/elements/models.py
+++ b/backend/src/baserow/contrib/builder/elements/models.py
@@ -781,4 +781,17 @@ class RepeatElement(CollectionElement, ContainerElement):
     item in the data source that it is bound to.
     """
 
-    ...
+    class ORIENTATIONS(models.TextChoices):
+        VERTICAL = "vertical"
+        HORIZONTAL = "horizontal"
+
+    orientation = models.CharField(
+        choices=ORIENTATIONS.choices,
+        max_length=10,
+        default=ORIENTATIONS.VERTICAL,
+    )
+    items_per_row = models.JSONField(
+        default=dict,
+        help_text="The amount repetitions per row, per device type. "
+        "Only applicable when the orientation is horizontal.",
+    )
diff --git a/backend/src/baserow/contrib/builder/migrations/0019_repeat_element_styling.py b/backend/src/baserow/contrib/builder/migrations/0019_repeat_element_styling.py
new file mode 100644
index 000000000..9f2925de2
--- /dev/null
+++ b/backend/src/baserow/contrib/builder/migrations/0019_repeat_element_styling.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.1.13 on 2024-05-21 20:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("builder", "0018_resolve_collection_field_configs"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="repeatelement",
+            name="items_per_row",
+            field=models.JSONField(
+                default=dict,
+                help_text="The amount repetitions per row, per device type. Only applicable when the orientation is horizontal.",
+            ),
+        ),
+        migrations.AddField(
+            model_name="repeatelement",
+            name="orientation",
+            field=models.CharField(
+                choices=[("vertical", "Vertical"), ("horizontal", "Horizontal")],
+                default="vertical",
+                max_length=10,
+            ),
+        ),
+    ]
diff --git a/changelog/entries/unreleased/feature/2485_introduced_a_new_element_type_called_repeat_given_a_list_dat.json b/changelog/entries/unreleased/feature/2485_introduced_a_new_element_type_called_repeat_given_a_list_dat.json
new file mode 100644
index 000000000..e1a6a1032
--- /dev/null
+++ b/changelog/entries/unreleased/feature/2485_introduced_a_new_element_type_called_repeat_given_a_list_dat.json
@@ -0,0 +1,7 @@
+{
+    "type": "feature",
+    "message": "Introduced a new element type called 'Repeat'. Given a list datasource, will repeat its child elements n number of times for each result in the datasource.",
+    "issue_number": 2485,
+    "bullet_points": [],
+    "created_at": "2024-05-19"
+}
diff --git a/web-frontend/modules/builder/components/elements/AddElementZone.vue b/web-frontend/modules/builder/components/elements/AddElementZone.vue
index 1336616d9..7f29253a1 100644
--- a/web-frontend/modules/builder/components/elements/AddElementZone.vue
+++ b/web-frontend/modules/builder/components/elements/AddElementZone.vue
@@ -1,6 +1,10 @@
 <template>
-  <div class="add-element-zone" @click="$emit('add-element')">
-    <div class="add-element-zone__content">
+  <div class="add-element-zone" @click="!disabled && $emit('add-element')">
+    <div
+      v-tooltip="disabled ? tooltip : null"
+      class="add-element-zone__content"
+      :class="{ 'add-element-zone__button--disabled': disabled }"
+    >
       <i class="iconoir-plus add-element-zone__icon"></i>
     </div>
   </div>
@@ -9,5 +13,17 @@
 <script>
 export default {
   name: 'AddElementZone',
+  props: {
+    disabled: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    tooltip: {
+      type: String,
+      required: false,
+      default: null,
+    },
+  },
 }
 </script>
diff --git a/web-frontend/modules/builder/components/elements/components/RepeatElement.vue b/web-frontend/modules/builder/components/elements/components/RepeatElement.vue
index 5e1e7cbce..6b3cb5d7e 100644
--- a/web-frontend/modules/builder/components/elements/components/RepeatElement.vue
+++ b/web-frontend/modules/builder/components/elements/components/RepeatElement.vue
@@ -1,44 +1,57 @@
 <template>
-  <div>
+  <div
+    :class="{
+      [`repeat-element--orientation-${element.orientation}`]: true,
+    }"
+  >
     <!-- If we have any contents to repeat... -->
     <template v-if="elementContent.length > 0">
-      <!-- Iterate over each content -->
-      <div v-for="(content, index) in elementContent" :key="content.id">
-        <!-- If the container has an children -->
-        <template v-if="children.length > 0">
-          <!-- Iterate over each child -->
-          <template v-for="child in children">
-            <!-- The first iteration is editable if we're in editing mode -->
-            <ElementPreview
-              v-if="index === 0 && isEditMode"
-              :key="child.id"
-              :element="child"
-              :application-context-additions="{
-                recordIndex: index,
-              }"
-              @move="moveElement(child, $event)"
-            />
-            <!-- Other iterations are not editable -->
-            <!-- Override the mode so that any children are in preview mode -->
-            <PageElement
-              v-else
-              :key="child.id"
-              :element="child"
-              :force-mode="'preview'"
-              :application-context-additions="{
-                recordIndex: index,
-              }"
-              :class="{
-                'repeat-element-preview': index > 0 && isEditMode,
-              }"
-            />
+      <div
+        class="repeat-element__repeated-elements"
+        :style="repeatedElementsStyles"
+      >
+        <!-- Iterate over each content -->
+        <div v-for="(content, index) in elementContent" :key="content.id">
+          <!-- If the container has an children -->
+          <template v-if="children.length > 0">
+            <!-- Iterate over each child -->
+            <template v-for="child in children">
+              <!-- The first iteration is editable if we're in editing mode -->
+              <ElementPreview
+                v-if="index === 0 && isEditMode"
+                :key="child.id"
+                :element="child"
+                :application-context-additions="{
+                  recordIndex: index,
+                }"
+                @move="moveElement(child, $event)"
+              />
+              <!-- Other iterations are not editable -->
+              <!-- Override the mode so that any children are in preview mode -->
+              <PageElement
+                v-else
+                :key="child.id"
+                :element="child"
+                :force-mode="'preview'"
+                :application-context-additions="{
+                  recordIndex: index,
+                }"
+                :class="{
+                  'repeat-element-preview': index > 0 && isEditMode,
+                }"
+              />
+            </template>
           </template>
-        </template>
+        </div>
       </div>
       <!-- We have contents, but the container has no children... -->
       <template v-if="children.length === 0 && isEditMode">
         <!-- Give the designer the chance to add child elements -->
-        <AddElementZone @add-element="showAddElementModal"></AddElementZone>
+        <AddElementZone
+          :disabled="element.data_source_id === null"
+          :tooltip="$t('repeatElement.missingDataSourceTooltip')"
+          @add-element="showAddElementModal"
+        ></AddElementZone>
         <AddElementModal
           ref="addElementModal"
           :page="page"
@@ -50,7 +63,11 @@
     <template v-else>
       <!-- If we also have no children, allow the designer to add elements -->
       <template v-if="children.length === 0 && isEditMode">
-        <AddElementZone @add-element="showAddElementModal"></AddElementZone>
+        <AddElementZone
+          :disabled="element.data_source_id === null"
+          :tooltip="$t('repeatElement.missingDataSourceTooltip')"
+          @add-element="showAddElementModal"
+        ></AddElementZone>
         <AddElementModal
           ref="addElementModal"
           :page="page"
@@ -81,7 +98,7 @@
 </template>
 
 <script>
-import { mapActions } from 'vuex'
+import { mapActions, mapGetters } from 'vuex'
 
 import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone'
 import containerElement from '@baserow/modules/builder/mixins/containerElement'
@@ -105,12 +122,38 @@ export default {
      * @type {Object}
      * @property {int} data_source_id - The collection data source Id we want to display.
      * @property {int} items_per_page - The number of items per page.
+     * @property {str} orientation - The orientation to repeat in (vertical, horizontal).
+     * @property {Object} items_per_row - The number of items, per device, which should
+     *  be repeated in a row. Only applicable to when the orientation is 'horizontal'.
      */
     element: {
       type: Object,
       required: true,
     },
   },
+  computed: {
+    ...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected' }),
+    repeatedElementsStyles() {
+      // These styles are applied inline as we are unable to provide
+      // the CSS rules with the correct `items_per_row` per device. If
+      // we add CSS vars to the element, and pass them into the
+      // `grid-template-columns` rule's `repeat`, it will cause a repaint
+      // following page load when the orientation is horizontal. Initially the
+      // page visitor will see repetitions vertically, then suddenly horizontally.
+      const itemsPerRow = this.element.items_per_row[this.deviceTypeSelected]
+      if (this.element.orientation === 'vertical') {
+        return {
+          display: 'flex',
+          'flex-direction': 'column',
+        }
+      } else {
+        return {
+          display: 'grid',
+          'grid-template-columns': `repeat(${itemsPerRow}, 1fr)`,
+        }
+      }
+    },
+  },
   methods: {
     ...mapActions({
       actionMoveElement: 'element/moveElement',
diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue
index 11c02a212..8891fccba 100644
--- a/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue
+++ b/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue
@@ -29,26 +29,113 @@
       type="number"
       @blur="$v.values.items_per_page.$touch()"
     ></FormInput>
+    <FormGroup :label="$t('repeatElementForm.orientationLabel')">
+      <RadioButton v-model="values.orientation" value="vertical">
+        {{ $t('repeatElementForm.orientationVertical') }}
+      </RadioButton>
+      <RadioButton v-model="values.orientation" value="horizontal">
+        {{ $t('repeatElementForm.orientationHorizontal') }}
+      </RadioButton>
+    </FormGroup>
+    <FormGroup
+      v-if="values.orientation === 'horizontal'"
+      :error="getItemsPerRowError"
+      :label="$t('repeatElementForm.itemsPerRowLabel')"
+      :description="$t('repeatElementForm.itemsPerRowDescription')"
+    >
+      <DeviceSelector
+        :device-type-selected="deviceTypeSelected"
+        class="repeat-element__device-selector"
+        @selected="actionSetDeviceTypeSelected"
+      >
+        <template #deviceTypeControl="{ deviceType }">
+          <input
+            :ref="`itemsPerRow-${deviceType.getType()}`"
+            v-model="values.items_per_row[deviceType.getType()]"
+            :class="{
+              'input--error':
+                $v.values.items_per_row[deviceType.getType()].$error,
+              'remove-number-input-controls': true,
+            }"
+            type="number"
+            class="input"
+            @input="handlePerRowInput($event, deviceType.getType())"
+          />
+        </template>
+      </DeviceSelector>
+    </FormGroup>
   </form>
 </template>
 
 <script>
+import _ from 'lodash'
 import { required, integer, minValue, maxValue } from 'vuelidate/lib/validators'
 import elementForm from '@baserow/modules/builder/mixins/elementForm'
 import collectionElementForm from '@baserow/modules/builder/mixins/collectionElementForm'
+import DeviceSelector from '@baserow/modules/builder/components/page/header/DeviceSelector.vue'
+import { mapActions, mapGetters } from 'vuex'
 
 export default {
   name: 'RepeatElementForm',
+  components: { DeviceSelector },
   mixins: [elementForm, collectionElementForm],
   data() {
     return {
-      allowedValues: ['data_source_id', 'items_per_page'],
+      allowedValues: [
+        'data_source_id',
+        'items_per_page',
+        'items_per_row',
+        'orientation',
+      ],
       values: {
         data_source_id: null,
         items_per_page: 1,
+        items_per_row: {},
+        orientation: 'vertical',
       },
     }
   },
+  computed: {
+    ...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected' }),
+    deviceTypes() {
+      return Object.values(this.$registry.getOrderedList('device'))
+    },
+    getItemsPerRowError() {
+      for (const device of this.deviceTypes) {
+        const validation = this.$v.values.items_per_row[device.getType()]
+        if (validation.$dirty) {
+          if (!validation.integer) {
+            return this.$t('error.integerField')
+          }
+          if (!validation.minValue) {
+            return this.$t('error.minValueField', { min: 1 })
+          }
+          if (!validation.maxValue) {
+            return this.$t('error.maxValueField', { max: 10 })
+          }
+        }
+      }
+      return ''
+    },
+  },
+  mounted() {
+    if (_.isEmpty(this.values.items_per_row)) {
+      this.values.items_per_row = this.deviceTypes.reduce((acc, deviceType) => {
+        acc[deviceType.getType()] = 2
+        return acc
+      }, {})
+    }
+  },
+  methods: {
+    ...mapActions({
+      actionSetDeviceTypeSelected: 'page/setDeviceTypeSelected',
+    }),
+    handlePerRowInput(event, deviceTypeType) {
+      this.$v.values.items_per_row[deviceTypeType].$touch()
+      this.values.items_per_row[deviceTypeType] = parseInt(event.target.value)
+      this.$emit('input', this.values)
+    },
+  },
   validations() {
     return {
       values: {
@@ -58,6 +145,14 @@ export default {
           minValue: minValue(1),
           maxValue: maxValue(this.maxItemPerPage),
         },
+        items_per_row: this.deviceTypes.reduce((acc, deviceType) => {
+          acc[deviceType.getType()] = {
+            integer,
+            minValue: minValue(1),
+            maxValue: maxValue(10),
+          }
+          return acc
+        }, {}),
       },
     }
   },
diff --git a/web-frontend/modules/builder/components/page/PageContent.vue b/web-frontend/modules/builder/components/page/PageContent.vue
index 11edbb0f8..bb1a031f8 100644
--- a/web-frontend/modules/builder/components/page/PageContent.vue
+++ b/web-frontend/modules/builder/components/page/PageContent.vue
@@ -12,9 +12,12 @@
 <script>
 import PageElement from '@baserow/modules/builder/components/page/PageElement'
 import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider'
+import { dimensionMixin } from '@baserow/modules/core/mixins/dimensions'
+import _ from 'lodash'
 
 export default {
   components: { ThemeProvider, PageElement },
+  mixins: [dimensionMixin],
   inject: ['builder', 'mode'],
   props: {
     page: {
@@ -34,5 +37,42 @@ export default {
       required: true,
     },
   },
+  watch: {
+    'dimensions.width': {
+      handler(newValue) {
+        this.debounceGuessDevice(newValue)
+      },
+    },
+  },
+  mounted() {
+    this.dimensions.targetElement = document.documentElement
+  },
+  methods: {
+    /**
+     * Returns the device type that is the closest to the given observer width.
+     * It does this by sorting the device types by order ASC (as we want to start
+     * with the smallest screen) and then checking if the observer width is smaller
+     * (or in the case of desktop, unlimited with `null`) than the max width of
+     * the device. If it is, the device is returned.
+     *
+     * @param {number} observerWidth The width of the observer.
+     * @returns {DeviceType|null}
+     */
+    closestDeviceType(observerWidth) {
+      const deviceTypes = Object.values(this.$registry.getAll('device'))
+        .sort((deviceA, deviceB) => deviceA.getOrder() - deviceB.getOrder())
+        .reverse()
+      for (const device of deviceTypes) {
+        if (device.maxWidth === null || observerWidth <= device.maxWidth) {
+          return device
+        }
+      }
+      return null
+    },
+    debounceGuessDevice: _.debounce(function (newWidth) {
+      const device = this.closestDeviceType(newWidth)
+      this.$store.dispatch('page/setDeviceTypeSelected', device.getType())
+    }, 300),
+  },
 }
 </script>
diff --git a/web-frontend/modules/builder/components/page/header/DeviceSelector.vue b/web-frontend/modules/builder/components/page/header/DeviceSelector.vue
index 371951048..ad38b9c62 100644
--- a/web-frontend/modules/builder/components/page/header/DeviceSelector.vue
+++ b/web-frontend/modules/builder/components/page/header/DeviceSelector.vue
@@ -15,6 +15,7 @@
       >
         <i :class="`header__filter-icon ${deviceType.iconClass}`"></i>
       </a>
+      <slot name="deviceTypeControl" :device-type="deviceType"></slot>
     </li>
   </ul>
 </template>
diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js
index 72aa1a8dd..e575917f2 100644
--- a/web-frontend/modules/builder/elementTypes.js
+++ b/web-frontend/modules/builder/elementTypes.js
@@ -800,6 +800,16 @@ export class RepeatElementType extends ContainerElementTypeMixin(
       ...this.getVerticalPlacementsDisabled(page, element),
     ]
   }
+
+  /**
+   * A repeat element is in error whilst it has no data source.
+   * @param {Object} element - The repeat element
+   * @param {Object} builder - The builder application.
+   * @returns {Boolean} - Whether the element is in error.
+   */
+  isInError({ element, builder }) {
+    return element.data_source_id === null
+  }
 }
 /**
  * This class serves as a parent class for all form element types. Form element types
diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json
index 583055b11..bfbcf2e03 100644
--- a/web-frontend/modules/builder/locales/en.json
+++ b/web-frontend/modules/builder/locales/en.json
@@ -427,12 +427,18 @@
   },
   "repeatElement": {
     "empty": "No items have been found.",
-    "showMore": "Show more"
+    "showMore": "Show more",
+    "missingDataSourceTooltip": "Choose a data source to begin adding elements."
   },
   "repeatElementForm": {
     "dataSource": "Data source",
     "itemsPerPage": "Items per page",
-    "itemsPerPagePlaceholder": "Enter value..."
+    "itemsPerPagePlaceholder": "Enter value...",
+    "itemsPerRowLabel": "Items per row",
+    "itemsPerRowDescription": "Choose, per device type, what the number of repetitions per row should be.",
+    "orientationLabel": "Orientation",
+    "orientationVertical": "Vertical",
+    "orientationHorizontal": "Horizontal"
   },
   "currentRecordDataProviderType": {
     "index": "Index",
diff --git a/web-frontend/modules/builder/plugin.js b/web-frontend/modules/builder/plugin.js
index 11dcfcbf9..900e17ad3 100644
--- a/web-frontend/modules/builder/plugin.js
+++ b/web-frontend/modules/builder/plugin.js
@@ -179,10 +179,7 @@ export default (context) => {
   app.$registry.register('element', new InputTextElementType(context))
   app.$registry.register('element', new DropdownElementType(context))
   app.$registry.register('element', new CheckboxElementType(context))
-
-  if (app.$featureFlagIsEnabled('builder-repeat-element')) {
-    app.$registry.register('element', new RepeatElementType(context))
-  }
+  app.$registry.register('element', new RepeatElementType(context))
 
   app.$registry.register('device', new DesktopDeviceType(context))
   app.$registry.register('device', new TabletDeviceType(context))
diff --git a/web-frontend/modules/builder/store/page.js b/web-frontend/modules/builder/store/page.js
index 785bac1da..6a84924c6 100644
--- a/web-frontend/modules/builder/store/page.js
+++ b/web-frontend/modules/builder/store/page.js
@@ -22,7 +22,11 @@ export function populatePage(page) {
 const state = {
   // Holds the value of which page is currently selected
   selected: {},
-  deviceTypeSelected: null,
+  // By default, the device type will be desktop. This will be overridden
+  // in the editor, and in the public page. We set a default as otherwise
+  // in the public page, we can trigger a repaint, causing some layouts to
+  // redraw.
+  deviceTypeSelected: 'desktop',
   // A job object that tracks the progress of a page duplication currently running
   duplicateJob: null,
 }
diff --git a/web-frontend/modules/core/assets/scss/components/builder/add_element_zone.scss b/web-frontend/modules/core/assets/scss/components/builder/add_element_zone.scss
index 85100664d..3f24c8fad 100644
--- a/web-frontend/modules/core/assets/scss/components/builder/add_element_zone.scss
+++ b/web-frontend/modules/core/assets/scss/components/builder/add_element_zone.scss
@@ -22,5 +22,9 @@
     .add-element-zone__icon {
       border-color: $color-primary-500;
     }
+
+    .add-element-zone__button--disabled {
+      cursor: not-allowed;
+    }
   }
 }
diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements/all.scss b/web-frontend/modules/core/assets/scss/components/builder/elements/all.scss
index c37cfe759..041d4c6c6 100644
--- a/web-frontend/modules/core/assets/scss/components/builder/elements/all.scss
+++ b/web-frontend/modules/core/assets/scss/components/builder/elements/all.scss
@@ -10,3 +10,4 @@
 @import 'checkbox_element';
 @import 'dropdown_element';
 @import 'iframe_element';
+@import 'repeat_element';
diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements/repeat_element.scss b/web-frontend/modules/core/assets/scss/components/builder/elements/repeat_element.scss
new file mode 100644
index 000000000..f3e35db45
--- /dev/null
+++ b/web-frontend/modules/core/assets/scss/components/builder/elements/repeat_element.scss
@@ -0,0 +1,17 @@
+.repeat-element--orientation-vertical {
+  display: flex;
+  flex-direction: column;
+}
+
+.repeat-element__device-selector {
+  gap: 10px;
+
+  input[type='number'] {
+    margin-top: 5px;
+    text-align: center;
+  }
+
+  .header__filter-icon {
+    margin: 0 auto;
+  }
+}