1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 09:08:32 +00:00

Resolve "Add an image collection field"

This commit is contained in:
Afonso Silva 2024-09-03 14:34:20 +00:00
parent e62a0c91ec
commit 4d25f17cc2
14 changed files with 278 additions and 14 deletions
backend
src/baserow/contrib/builder
tests/baserow/contrib/builder/elements
changelog/entries/unreleased/feature
web-frontend/modules
builder
collectionFieldTypes.js
components/elements
baseComponents
components
locales
plugin.js
core/assets/scss/components/builder/elements
integrations

View file

@ -283,6 +283,7 @@ class BuilderConfig(AppConfig):
from .elements.collection_field_types import (
BooleanCollectionFieldType,
ButtonCollectionFieldType,
ImageCollectionFieldType,
LinkCollectionFieldType,
TagsCollectionFieldType,
TextCollectionFieldType,
@ -294,6 +295,7 @@ class BuilderConfig(AppConfig):
collection_field_type_registry.register(LinkCollectionFieldType())
collection_field_type_registry.register(TagsCollectionFieldType())
collection_field_type_registry.register(ButtonCollectionFieldType())
collection_field_type_registry.register(ImageCollectionFieldType())
from .domains.receivers import connect_to_domain_pre_delete_signal

View file

@ -243,3 +243,31 @@ class ButtonCollectionFieldType(CollectionFieldType):
def before_delete(self, instance: CollectionField):
# We delete the related workflow actions
BuilderWorkflowAction.objects.filter(event__startswith=instance.uid).delete()
class ImageCollectionFieldType(CollectionFieldType):
type = "image"
allowed_fields = ["src", "alt"]
serializer_field_names = ["src", "alt"]
simple_formula_fields = ["src", "alt"]
class SerializedDict(TypedDict):
src: BaserowFormula
alt: BaserowFormula
@property
def serializer_field_overrides(self):
return {
"src": FormulaSerializerField(
help_text="A link to the image file",
required=False,
allow_blank=True,
default="",
),
"alt": FormulaSerializerField(
help_text="A brief text description of the image",
required=False,
allow_blank=True,
default="",
),
}

View file

@ -0,0 +1,75 @@
from io import BytesIO
from tempfile import tempdir
from django.core.files.storage import FileSystemStorage
import pytest
from baserow.contrib.builder.pages.service import PageService
from baserow.core.user_files.handler import UserFileHandler
@pytest.mark.django_db
@pytest.mark.parametrize(
"storage",
[None, FileSystemStorage(location=str(tempdir), base_url="http://localhost")],
)
def test_import_export_image_collection_field_type(data_fixture, fake, storage):
"""
Ensure that the ImageCollectionField's formulas are exported correctly
with the updated data sources.
"""
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
# Create a database table with a "files" column
image_file = UserFileHandler().upload_user_file(
user,
"test.jpg",
BytesIO(fake.image()),
storage=storage,
)
table, fields, rows = data_fixture.build_table(
user=user,
columns=[("Images", "file")],
rows=[[[image_file.serialize()]]],
)
# Create a new builder table element and connect it to the previous
# created database table
data_source_1 = data_fixture.create_builder_local_baserow_list_rows_data_source(
table=table, page=page
)
table_element = data_fixture.create_builder_table_element(
page=page,
data_source=data_source_1,
fields=[
{
"name": "Images",
"type": "image",
"config": {
"src": f"get('data_source.{data_source_1.id}.*.{fields[0].db_column}.url')",
"alt": f"get('data_source.{data_source_1.id}.*.{fields[0].db_column}.name')",
},
},
],
)
# Duplicate the page and create a second data source
duplicated_page = PageService().duplicate_page(user, page)
data_source_2 = duplicated_page.datasource_set.first()
id_mapping = {"builder_data_sources": {data_source_1.id: data_source_2.id}}
# Export the table element and import it, applying the id mapping of the
# second data source created
exported = table_element.get_type().export_serialized(table_element)
imported_table_element = table_element.get_type().import_serialized(
page, exported, id_mapping
)
images = imported_table_element.fields.get(name="Images")
assert images.config == {
"src": f"get('data_source.{data_source_2.id}.*.{fields[0].db_column}.url')",
"alt": f"get('data_source.{data_source_2.id}.*.{fields[0].db_column}.name')",
}

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "[Builder] Add image collection field to table elements",
"issue_number": 2759,
"bullet_points": [],
"created_at": "2024-08-22"
}

View file

@ -9,6 +9,8 @@ import TagsField from '@baserow/modules/builder/components/elements/components/c
import TextFieldForm from '@baserow/modules/builder/components/elements/components/collectionField/form/TextFieldForm'
import TagsFieldForm from '@baserow/modules/builder/components/elements/components/collectionField/form/TagsFieldForm.vue'
import LinkFieldForm from '@baserow/modules/builder/components/elements/components/collectionField/form/LinkFieldForm'
import ImageField from '@baserow/modules/builder/components/elements/components/collectionField/ImageField.vue'
import ImageFieldForm from '@baserow/modules/builder/components/elements/components/collectionField/form/ImageFieldForm.vue'
import {
ensureArray,
ensureBoolean,
@ -207,7 +209,7 @@ export class ButtonCollectionFieldType extends CollectionFieldType {
}
get name() {
return 'Button'
return this.app.i18n.t('collectionFieldType.button')
}
get component() {
@ -226,3 +228,31 @@ export class ButtonCollectionFieldType extends CollectionFieldType {
return { label: ensureString(resolveFormula(field.label)) }
}
}
export class ImageCollectionFieldType extends CollectionFieldType {
static getType() {
return 'image'
}
get name() {
return this.app.i18n.t('collectionFieldType.image')
}
get component() {
return ImageField
}
get formComponent() {
return ImageFieldForm
}
getProps(field, { resolveFormula, applicationContext }) {
const srcs = ensureArray(resolveFormula(field.src))
const alts = ensureArray(resolveFormula(field.alt))
const images = srcs.map((src, index) => ({
src,
alt: alts[index % alts.length],
}))
return { images }
}
}

View file

@ -1,6 +1,11 @@
<template>
<div class="ab-image">
<img class="ab-image__img" :alt="alt" :src="src" />
<img
class="ab-image__img"
:alt="alt"
:src="src"
:loading="lazy ? 'lazy' : null"
/>
</div>
</template>
@ -28,6 +33,14 @@ export default {
required: false,
default: '',
},
/**
* @type {Boolean} - Whether the image should be loaded lazily.
*/
lazy: {
type: Boolean,
required: false,
default: true,
},
},
}
</script>

View file

@ -0,0 +1,26 @@
<template>
<div class="image-field">
<ABImage
v-for="(image, index) in images"
:key="`${index}-${image.src}`"
:src="image.src"
:alt="image.alt"
:lazy="true"
></ABImage>
</div>
</template>
<script>
import collectionField from '@baserow/modules/builder/mixins/collectionField'
export default {
name: 'ImageField',
mixins: [collectionField],
props: {
images: {
type: Array,
required: true,
},
},
}
</script>

View file

@ -0,0 +1,50 @@
<template>
<form @submit.prevent @keydown.enter.prevent>
<FormGroup
small-label
:label="$t('imageFieldForm.fieldSrcLabel')"
class="margin-bottom-2"
horizontal
required
>
<InjectedFormulaInput
v-model="values.src"
:placeholder="$t('imageFieldForm.fieldSrcPlaceholder')"
/>
</FormGroup>
<FormGroup
small-label
:label="$t('imageFieldForm.fieldAltLabel')"
:helper-text="$t('imageFieldForm.fieldAltHelp')"
class="margin-bottom-2"
horizontal
required
>
<InjectedFormulaInput
v-model="values.alt"
:placeholder="$t('imageFieldForm.fieldAltPlaceholder')"
/>
</FormGroup>
</form>
</template>
<script>
import collectionFieldForm from '@baserow/modules/builder/mixins/collectionFieldForm'
import InjectedFormulaInput from '@baserow/modules/core/components/formula/InjectedFormulaInput'
export default {
name: 'ImageFieldForm',
components: { InjectedFormulaInput },
mixins: [collectionFieldForm],
data() {
return {
allowedValues: ['src', 'alt', 'styles'],
values: {
src: '',
alt: '',
styles: {},
},
}
},
}
</script>

View file

@ -15,7 +15,7 @@
class="margin-bottom-2"
>
<DataSourceDropdown
v-model="values.data_source_id"
v-model="computedDataSourceId"
small
:data-sources="dataSources"
>
@ -283,7 +283,6 @@ export default {
orientation: {},
button_load_more_label: '',
},
userHasChangedDataSource: false,
}
},
computed: {
@ -312,15 +311,17 @@ export default {
? this.$t('error.maxValueField', { max: this.maxItemPerPage })
: ''
},
},
watch: {
async 'values.data_source_id'(newValue, oldValue) {
if (newValue && !oldValue) {
await this.$nextTick()
if (this.userHasChangedDataSource) {
computedDataSourceId: {
get() {
return this.element.data_source_id
},
set(newValue) {
const oldValue = this.values.data_source_id
this.values.data_source_id = newValue
if (newValue !== oldValue && newValue) {
this.refreshFieldsFromDataSource()
}
}
},
},
},
methods: {
@ -380,7 +381,11 @@ export default {
refreshFieldsFromDataSource() {
// If the data source returns multiple records, generate
// the collection field values.
if (this.selectedDataSourceReturnsList) {
if (
this.selectedDataSourceReturnsList &&
this.selectedDataSourceType.isValid(this.selectedDataSource)
) {
this.values.fields =
this.selectedDataSourceType.getDefaultCollectionFields(
this.selectedDataSource

View file

@ -587,9 +587,11 @@
},
"collectionFieldType": {
"boolean": "Boolean",
"button": "Button",
"text": "Text",
"link": "Link",
"tags": "Tags"
"tags": "Tags",
"image": "Image"
},
"textFieldForm": {
"fieldValueLabel": "Value",
@ -611,6 +613,13 @@
"linkField": {
"details": "Details"
},
"imageFieldForm": {
"fieldSrcLabel": "Image source",
"fieldSrcPlaceholder": "Enter value",
"fieldAltLabel": "Alt text",
"fieldAltPlaceholder": "Enter value...",
"fieldAltHelp": "Is used by screen readers and displayed if the image can't load"
},
"createUserSourceForm": {
"userSourceType": "Type",
"userSourceIntegration": "Integration",

View file

@ -111,6 +111,7 @@ import {
LinkCollectionFieldType,
ButtonCollectionFieldType,
TagsCollectionFieldType,
ImageCollectionFieldType,
} from '@baserow/modules/builder/collectionFieldTypes'
import {
@ -352,6 +353,10 @@ export default (context) => {
'collectionField',
new ButtonCollectionFieldType(context)
)
app.$registry.register(
'collectionField',
new ImageCollectionFieldType(context)
)
app.$registry.register('fontFamily', new InterFontFamilyType(context))
app.$registry.register('fontFamily', new ArialFontFamilyType(context))

View file

@ -11,3 +11,4 @@
@import 'iframe_element';
@import 'repeat_element';
@import 'tag_field';
@import 'image_field';

View file

@ -0,0 +1,6 @@
.image-field {
overflow: hidden;
display: flex;
flex-flow: row nowrap;
gap: 4px;
}

View file

@ -106,7 +106,6 @@ export class LocalBaserowListRowsServiceType extends ServiceType {
.filter(
(field) =>
field !== 'id' &&
service.schema.items.properties[field].original_type !== 'file' && // we have no way to display files in a table &&
service.schema.items.properties[field].original_type !== 'formula' // every formula has different properties
)
.map((field) => {
@ -129,6 +128,14 @@ export class LocalBaserowListRowsServiceType extends ServiceType {
target: 'blank',
type: 'link',
}
} else if (originalType === 'file') {
return {
id: uuid(),
name: service.schema.items.properties[field].title,
type: 'image',
src: `get('current_record.${field}.*.url')`,
alt: `get('current_record.${field}.*.visible_name')`,
}
} else if (
originalType === 'last_modified_by' ||
originalType === 'created_by'