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; + } +}