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:
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
core/assets/scss/components/builder/elements
integrations
|
@ -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
|
||||
|
||||
|
|
|
@ -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="",
|
||||
),
|
||||
}
|
||||
|
|
|
@ -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')",
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -11,3 +11,4 @@
|
|||
@import 'iframe_element';
|
||||
@import 'repeat_element';
|
||||
@import 'tag_field';
|
||||
@import 'image_field';
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
.image-field {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
gap: 4px;
|
||||
}
|
|
@ -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'
|
||||
|
|
Loading…
Add table
Reference in a new issue