1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-03-31 11:25:00 +00:00

Resolve "Repeat element: add styling options"

This commit is contained in:
Peter Evans 2024-05-24 11:09:00 +00:00
parent 4b38d3a49d
commit e07bc8347a
16 changed files with 343 additions and 48 deletions
backend/src/baserow/contrib/builder
changelog/entries/unreleased/feature
web-frontend/modules

View file

@ -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):

View file

@ -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.",
)

View file

@ -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,
),
),
]

View file

@ -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"
}

View file

@ -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>

View file

@ -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',

View file

@ -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
}, {}),
},
}
},

View file

@ -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>

View file

@ -15,6 +15,7 @@
>
<i :class="`header__filter-icon ${deviceType.iconClass}`"></i>
</a>
<slot name="deviceTypeControl" :device-type="deviceType"></slot>
</li>
</ul>
</template>

View file

@ -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

View file

@ -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",

View file

@ -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))

View file

@ -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,
}

View file

@ -22,5 +22,9 @@
.add-element-zone__icon {
border-color: $color-primary-500;
}
.add-element-zone__button--disabled {
cursor: not-allowed;
}
}
}

View file

@ -10,3 +10,4 @@
@import 'checkbox_element';
@import 'dropdown_element';
@import 'iframe_element';
@import 'repeat_element';

View file

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