diff --git a/backend/src/baserow/contrib/builder/elements/element_types.py b/backend/src/baserow/contrib/builder/elements/element_types.py index dbb20c47b..52dd42a6a 100644 --- a/backend/src/baserow/contrib/builder/elements/element_types.py +++ b/backend/src/baserow/contrib/builder/elements/element_types.py @@ -42,6 +42,7 @@ from baserow.contrib.builder.elements.models import ( TableElement, TextElement, VerticalAlignments, + get_default_table_orientation, ) from baserow.contrib.builder.elements.registries import ( ElementType, @@ -219,14 +220,15 @@ class TableElementType(CollectionElementWithFieldsTypeMixin, ElementType): class SerializedDict(CollectionElementWithFieldsTypeMixin.SerializedDict): button_color: str + orientation: dict @property def allowed_fields(self): - return super().allowed_fields + ["button_color"] + return super().allowed_fields + ["button_color", "orientation"] @property def serializer_field_names(self): - return super().serializer_field_names + ["button_color"] + return super().serializer_field_names + ["button_color", "orientation"] @property def serializer_field_overrides(self): @@ -238,10 +240,18 @@ class TableElementType(CollectionElementWithFieldsTypeMixin, ElementType): default="primary", help_text="Button color.", ), + "orientation": serializers.JSONField( + allow_null=False, + default=get_default_table_orientation, + help_text=TableElement._meta.get_field("orientation").help_text, + ), } def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]: - return {"data_source_id": None} + return { + "data_source_id": None, + "orientation": get_default_table_orientation(), + } class RepeatElementType( diff --git a/backend/src/baserow/contrib/builder/elements/models.py b/backend/src/baserow/contrib/builder/elements/models.py index c3d53bdc7..85a72a871 100644 --- a/backend/src/baserow/contrib/builder/elements/models.py +++ b/backend/src/baserow/contrib/builder/elements/models.py @@ -59,6 +59,14 @@ def get_default_element_content_type(): return ContentType.objects.get_for_model(Element) +def get_default_table_orientation(): + return { + "smartphone": "horizontal", + "tablet": "horizontal", + "desktop": "horizontal", + } + + class Element( HierarchicalModelMixin, TrashableModelMixin, @@ -765,7 +773,12 @@ class TableElement(CollectionElement): blank=True, help_text="The color of the button", ) - + orientation = models.JSONField( + blank=True, + null=True, + default=get_default_table_orientation, + help_text="The table orientation (horizontal or vertical) for each device type", + ) fields = models.ManyToManyField(CollectionField) diff --git a/backend/src/baserow/contrib/builder/migrations/0023_tableelement_orientation.py b/backend/src/baserow/contrib/builder/migrations/0023_tableelement_orientation.py new file mode 100644 index 000000000..187de3d95 --- /dev/null +++ b/backend/src/baserow/contrib/builder/migrations/0023_tableelement_orientation.py @@ -0,0 +1,39 @@ +# Generated by Django 4.1.13 on 2024-06-06 11:12 + +from django.db import migrations, models + +from baserow.contrib.builder.elements.models import get_default_table_orientation + + +def populate_orientation_field(apps, schema_editor): + """Add default orientation settings to all table elements.""" + + TableElement = apps.get_model("builder", "tableelement") + TableElement.objects.update(orientation={ + "smartphone": "horizontal", + "tablet": "horizontal", + "desktop": "horizontal", + }) + + +class Migration(migrations.Migration): + dependencies = [ + ("builder", "0022_choiceelement_choiceelementoption_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="tableelement", + name="orientation", + field=models.JSONField( + blank=True, + default=get_default_table_orientation, + help_text="The table orientation (horizontal or vertical) for each device type", + null=True, + ), + ), + migrations.RunPython( + populate_orientation_field, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/backend/tests/baserow/contrib/builder/test_builder_application_type.py b/backend/tests/baserow/contrib/builder/test_builder_application_type.py index 9e2628a3d..89e9559fa 100644 --- a/backend/tests/baserow/contrib/builder/test_builder_application_type.py +++ b/backend/tests/baserow/contrib/builder/test_builder_application_type.py @@ -372,6 +372,11 @@ def test_builder_application_export(data_fixture): "type": "table", "order": str(element4.order), "button_color": "primary", + "orientation": { + "smartphone": "horizontal", + "tablet": "horizontal", + "desktop": "horizontal", + }, "parent_element_id": None, "place_in_container": None, "visibility": "all", diff --git a/changelog/entries/unreleased/feature/2523_add_responsive_behavior_configuration_for_table_element.json b/changelog/entries/unreleased/feature/2523_add_responsive_behavior_configuration_for_table_element.json new file mode 100644 index 000000000..a5c522271 --- /dev/null +++ b/changelog/entries/unreleased/feature/2523_add_responsive_behavior_configuration_for_table_element.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "[Builder] Add responsive behavior configuration for table element", + "issue_number": 2523, + "bullet_points": [], + "created_at": "2024-06-05" +} diff --git a/web-frontend/modules/builder/components/elements/components/BaserowTable.vue b/web-frontend/modules/builder/components/elements/components/BaserowTable.vue index 398614157..217eb0594 100644 --- a/web-frontend/modules/builder/components/elements/components/BaserowTable.vue +++ b/web-frontend/modules/builder/components/elements/components/BaserowTable.vue @@ -1,47 +1,93 @@ <template> - <table class="baserow-table"> - <thead> - <tr class="baserow-table__header-row"> - <th - v-for="field in fields" - :key="field.__id__" - class="baserow-table__header-cell" - > - <slot name="field-name" :field="field">{{ field.name }}</slot> - </th> - </tr> - </thead> - <tbody v-if="rows.length"> - <tr - v-for="(row, index) in rows" - :key="row.__id__" - class="baserow-table__row" - > - <td v-for="field in fields" :key="field.id" class="baserow-table__cell"> - <slot - name="cell-content" - :value="row[field.name]" - :field="field" - :row-index="index" + <div class="baserow-table-wrapper"> + <table class="baserow-table" :class="`baserow-table--${orientation}`"> + <template v-if="orientation === TABLE_ORIENTATION.HORIZONTAL"> + <thead> + <tr class="baserow-table__row"> + <th + v-for="field in fields" + :key="field.__id__" + class="baserow-table__header-cell" + > + <slot name="field-name" :field="field">{{ field.name }}</slot> + </th> + </tr> + </thead> + <tbody v-if="rows.length"> + <tr + v-for="(row, index) in rows" + :key="row.__id__" + class="baserow-table__row" > - {{ value }} - </slot> - </td> - </tr> - </tbody> - <tbody v-else> - <tr> - <td class="baserow-table__empty-message" :colspan="fields.length"> - <slot name="empty-state"></slot> - </td> - </tr> - </tbody> - </table> + <td + v-for="field in fields" + :key="field.id" + class="baserow-table__cell" + > + <slot + name="cell-content" + :value="row[field.name]" + :field="field" + :row-index="index" + > + {{ value }} + </slot> + </td> + </tr> + </tbody> + </template> + <template v-else> + <tbody v-if="rows.length"> + <template v-for="(row, rowIndex) in rows"> + <tr + v-for="(field, fieldIndex) in fields" + :key="`${row.__id__}_${field.id}`" + class="baserow-table__row" + > + <th + class="baserow-table__header-cell" + :class="{ + 'baserow-table__separator': fieldIndex === fields.length - 1, + }" + > + {{ field.name }} + </th> + <td + class="baserow-table__cell" + :class="{ + 'baserow-table__separator': fieldIndex === fields.length - 1, + }" + > + <slot + name="cell-content" + :value="row[field.name]" + :field="field" + :row-index="rowIndex" + > + {{ value }} + </slot> + </td> + </tr> + </template> + </tbody> + </template> + <tbody v-if="!rows.length"> + <tr> + <td class="baserow-table__empty-message" :colspan="fields.length"> + <slot name="empty-state"></slot> + </td> + </tr> + </tbody> + </table> + </div> </template> <script> +import { TABLE_ORIENTATION } from '@baserow/modules/builder/enums' + export default { name: 'BaserowTable', + inject: ['mode'], props: { fields: { type: Array, @@ -51,12 +97,18 @@ export default { type: Array, required: true, }, + orientation: { + type: String, + default: TABLE_ORIENTATION.HORIZONTAL, + }, }, data() { return {} }, - computed: {}, - watch: {}, - methods: {}, + computed: { + TABLE_ORIENTATION() { + return TABLE_ORIENTATION + }, + }, } </script> diff --git a/web-frontend/modules/builder/components/elements/components/TableElement.vue b/web-frontend/modules/builder/components/elements/components/TableElement.vue index cd4d16345..852674b19 100644 --- a/web-frontend/modules/builder/components/elements/components/TableElement.vue +++ b/web-frontend/modules/builder/components/elements/components/TableElement.vue @@ -5,7 +5,11 @@ }" class="table-element" > - <BaserowTable :fields="element.fields" :rows="rows"> + <BaserowTable + :fields="element.fields" + :rows="rows" + :orientation="orientation" + > <template #cell-content="{ rowIndex, field, value }"> <component :is="collectionFieldTypes[field.type].component" @@ -55,6 +59,8 @@ export default { * display. * @property {Object} fields - The fields of the data source. * @property {int} items_per_page - The number of items per page. + * @property {string} button_color - The color of the button. + * @property {string} orientation - The orientation for eaceh device. */ element: { type: Object, @@ -94,6 +100,10 @@ export default { collectionFieldTypes() { return this.$registry.getAll('collectionField') }, + orientation() { + const device = this.$store.getters['page/getDeviceTypeSelected'] + return this.element.orientation[device] + }, }, methods: { 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 8891fccba..c50b4c8e6 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 @@ -30,10 +30,18 @@ @blur="$v.values.items_per_page.$touch()" ></FormInput> <FormGroup :label="$t('repeatElementForm.orientationLabel')"> - <RadioButton v-model="values.orientation" value="vertical"> + <RadioButton + v-model="values.orientation" + value="vertical" + icon="iconoir-table-rows" + > {{ $t('repeatElementForm.orientationVertical') }} </RadioButton> - <RadioButton v-model="values.orientation" value="horizontal"> + <RadioButton + v-model="values.orientation" + value="horizontal" + icon="iconoir-view-columns-3" + > {{ $t('repeatElementForm.orientationHorizontal') }} </RadioButton> </FormGroup> diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue index 844b9ace7..6bae04134 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue @@ -145,6 +145,30 @@ </template> <p v-else>{{ $t('tableElementForm.selectSourceFirst') }}</p> </FormGroup> + <FormGroup :label="$t('tableElementForm.orientation')"> + <DeviceSelector + :device-type-selected="deviceTypeSelected" + direction="row" + @selected="actionSetDeviceTypeSelected" + > + <template #deviceTypeControl="{ deviceType }"> + <RadioButton + v-model="values.orientation[deviceType.getType()]" + icon="iconoir-view-columns-3" + :value="TABLE_ORIENTATION.HORIZONTAL" + > + {{ $t('tableElementForm.orientationHorizontal') }} + </RadioButton> + <RadioButton + v-model="values.orientation[deviceType.getType()]" + icon="iconoir-table-rows" + :value="TABLE_ORIENTATION.VERTICAL" + > + {{ $t('tableElementForm.orientationVertical') }} + </RadioButton> + </template> + </DeviceSelector> + </FormGroup> <ColorInputGroup v-model="values.button_color" :label="$t('tableElementForm.buttonColor')" @@ -168,23 +192,38 @@ import { } from 'vuelidate/lib/validators' import elementForm from '@baserow/modules/builder/mixins/elementForm' import collectionElementForm from '@baserow/modules/builder/mixins/collectionElementForm' +import { TABLE_ORIENTATION } from '@baserow/modules/builder/enums' +import DeviceSelector from '@baserow/modules/builder/components/page/header/DeviceSelector.vue' +import { mapActions, mapGetters } from 'vuex' export default { name: 'TableElementForm', - components: { ApplicationBuilderFormulaInputGroup }, + components: { DeviceSelector, ApplicationBuilderFormulaInputGroup }, mixins: [elementForm, collectionElementForm], data() { return { - allowedValues: ['data_source_id', 'fields', 'items_per_page'], + allowedValues: [ + 'data_source_id', + 'fields', + 'items_per_page', + 'button_color', + 'orientation', + ], values: { fields: [], data_source_id: null, items_per_page: 1, + button_color: '', + orientation: {}, }, userHasChangedDataSource: false, } }, computed: { + ...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected' }), + TABLE_ORIENTATION() { + return TABLE_ORIENTATION + }, orderedCollectionTypes() { return this.$registry.getOrderedList('collectionField') }, @@ -203,6 +242,9 @@ export default { }, }, methods: { + ...mapActions({ + actionSetDeviceTypeSelected: 'page/setDeviceTypeSelected', + }), addField() { this.values.fields.push({ name: getNextAvailableNameInSequence( diff --git a/web-frontend/modules/builder/components/page/header/DeviceSelector.vue b/web-frontend/modules/builder/components/page/header/DeviceSelector.vue index ad38b9c62..7ee8b2ac8 100644 --- a/web-frontend/modules/builder/components/page/header/DeviceSelector.vue +++ b/web-frontend/modules/builder/components/page/header/DeviceSelector.vue @@ -1,23 +1,22 @@ <template> - <ul class="header__filter"> - <li + <div class="device-selector"> + <div v-for="(deviceType, index) in deviceTypes" - :key="deviceType.getType()" - class="header__filter-item" - :class="{ 'header__filter-item--no-margin-left': index === 0 }" + :key="index" + class="device-selector__item" + :class="`device-selector__item--${direction}`" > - <a - class="header__filter-link" - :class="{ - 'active active--primary': deviceTypeSelected === deviceType.getType(), - }" - @click="$emit('selected', deviceType.getType())" - > - <i :class="`header__filter-icon ${deviceType.iconClass}`"></i> - </a> + <RadioButton + :key="deviceType.getType()" + :value="deviceType.getType()" + :icon="deviceType.iconClass" + :model-value="deviceTypeSelected" + class="device-selector__button" + @click.native="$emit('selected', deviceType.getType())" + ></RadioButton> <slot name="deviceTypeControl" :device-type="deviceType"></slot> - </li> - </ul> + </div> + </div> </template> <script> @@ -28,6 +27,11 @@ export default { type: String, required: true, }, + direction: { + type: String, + required: false, + default: 'column', + }, }, computed: { deviceTypes() { diff --git a/web-frontend/modules/builder/enums.js b/web-frontend/modules/builder/enums.js index c8d6fef2f..9baa391ff 100644 --- a/web-frontend/modules/builder/enums.js +++ b/web-frontend/modules/builder/enums.js @@ -147,3 +147,8 @@ export const ELEMENT_EVENTS = { DATA_SOURCE_REMOVED: 'DATA_SOURCE_REMOVED', DATA_SOURCE_AFTER_UPDATE: 'DATA_SOURCE_AFTER_UPDATE', } + +export const TABLE_ORIENTATION = { + HORIZONTAL: 'horizontal', + VERTICAL: 'vertical', +} diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json index 22fa8f868..964f57d5a 100644 --- a/web-frontend/modules/builder/locales/en.json +++ b/web-frontend/modules/builder/locales/en.json @@ -427,7 +427,10 @@ "itemsPerPagePlaceholder": "Enter value...", "selectSourceFirst": "Choose a list data source to begin configuring your fields.", "buttonColor": "Button color", - "refreshFieldsFromDataSource": "refresh fields from data source" + "refreshFieldsFromDataSource": "refresh fields from data source", + "orientation": "Orientation", + "orientationHorizontal": "Horizontal", + "orientationVertical": "Vertical" }, "tableElement": { "empty": "No items have been found.", @@ -443,7 +446,7 @@ "itemsPerPage": "Items per page", "itemsPerPagePlaceholder": "Enter value...", "itemsPerRowLabel": "Items per row", - "itemsPerRowDescription": "Choose, per device type, what the number of repetitions per row should be.", + "itemsPerRowDescription": "Number of columns per row and device type.", "orientationLabel": "Orientation", "orientationVertical": "Vertical", "orientationHorizontal": "Horizontal" diff --git a/web-frontend/modules/core/assets/scss/components/builder/all.scss b/web-frontend/modules/core/assets/scss/components/builder/all.scss index 7c4f6398c..9e48afdd1 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/all.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/all.scss @@ -29,3 +29,4 @@ @import 'loading_spinner'; @import 'update_user_source_form'; @import 'user_source_users_context'; +@import 'device_selector'; diff --git a/web-frontend/modules/core/assets/scss/components/builder/device_selector.scss b/web-frontend/modules/core/assets/scss/components/builder/device_selector.scss new file mode 100644 index 000000000..680cf954e --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/builder/device_selector.scss @@ -0,0 +1,31 @@ +.device-selector { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 5px; +} + +.device-selector__item { + display: flex; + gap: 5px; +} + +.device-selector__item--column { + flex: 1; + flex-flow: column nowrap; +} + +.device-selector__item--row { + flex-direction: row; + flex-grow: 1; + align-items: center; +} + +.device-selector__button { + border: none; + box-shadow: none; + + .button__icon { + margin: 0 auto; + } +} diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements/baserow_table.scss b/web-frontend/modules/core/assets/scss/components/builder/elements/baserow_table.scss index 9cb055cd4..5a8afb9a5 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/elements/baserow_table.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/elements/baserow_table.scss @@ -1,3 +1,7 @@ +.baserow-table-wrapper { + overflow-x: auto; +} + .baserow-table { width: 100%; border: 1px solid $black; @@ -6,10 +10,6 @@ font-size: 12px; } -.baserow-table__header-row { - background-color: $palette-neutral-200; -} - .baserow-table__header-cell, .baserow-table__cell { padding: 12px 20px 10px; @@ -17,13 +17,28 @@ .baserow-table__header-cell { font-weight: 600; - border-bottom: 1px solid $black; + background-color: $palette-neutral-200; } -.baserow-table__cell { - border-bottom: 1px solid $black; +.baserow-table__separator { + .baserow-table__row:not(:last-child) & { + border-bottom: 1px solid $black; + } +} - .baserow-table__row:last-child & { +.baserow-table--horizontal { + .baserow-table__header-cell, + .baserow-table__cell { + border-bottom: 1px solid $black; + } + + .baserow-table__row:last-child .baserow-table__cell { border-bottom: none; } } + +.baserow-table--vertical { + .baserow-table__header-cell { + border-right: 1px solid $black; + } +} 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 index fd45fd5d4..b2920e6d3 100644 --- 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 @@ -1,14 +1,7 @@ .repeat-element__device-selector { - gap: 10px; - input[type='number'] { - margin-top: 5px; text-align: center; } - - .header__filter-icon { - margin: 0 auto; - } } .repeat-element__preview {