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:
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
builder
core/assets/scss/components/builder
|
@ -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):
|
||||
|
|
|
@ -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.",
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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"
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}, {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
>
|
||||
<i :class="`header__filter-icon ${deviceType.iconClass}`"></i>
|
||||
</a>
|
||||
<slot name="deviceTypeControl" :device-type="deviceType"></slot>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -22,5 +22,9 @@
|
|||
.add-element-zone__icon {
|
||||
border-color: $color-primary-500;
|
||||
}
|
||||
|
||||
.add-element-zone__button--disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,3 +10,4 @@
|
|||
@import 'checkbox_element';
|
||||
@import 'dropdown_element';
|
||||
@import 'iframe_element';
|
||||
@import 'repeat_element';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue