mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-24 13:04:06 +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 (
|
from .elements.collection_field_types import (
|
||||||
BooleanCollectionFieldType,
|
BooleanCollectionFieldType,
|
||||||
ButtonCollectionFieldType,
|
ButtonCollectionFieldType,
|
||||||
|
ImageCollectionFieldType,
|
||||||
LinkCollectionFieldType,
|
LinkCollectionFieldType,
|
||||||
TagsCollectionFieldType,
|
TagsCollectionFieldType,
|
||||||
TextCollectionFieldType,
|
TextCollectionFieldType,
|
||||||
|
@ -294,6 +295,7 @@ class BuilderConfig(AppConfig):
|
||||||
collection_field_type_registry.register(LinkCollectionFieldType())
|
collection_field_type_registry.register(LinkCollectionFieldType())
|
||||||
collection_field_type_registry.register(TagsCollectionFieldType())
|
collection_field_type_registry.register(TagsCollectionFieldType())
|
||||||
collection_field_type_registry.register(ButtonCollectionFieldType())
|
collection_field_type_registry.register(ButtonCollectionFieldType())
|
||||||
|
collection_field_type_registry.register(ImageCollectionFieldType())
|
||||||
|
|
||||||
from .domains.receivers import connect_to_domain_pre_delete_signal
|
from .domains.receivers import connect_to_domain_pre_delete_signal
|
||||||
|
|
||||||
|
|
|
@ -243,3 +243,31 @@ class ButtonCollectionFieldType(CollectionFieldType):
|
||||||
def before_delete(self, instance: CollectionField):
|
def before_delete(self, instance: CollectionField):
|
||||||
# We delete the related workflow actions
|
# We delete the related workflow actions
|
||||||
BuilderWorkflowAction.objects.filter(event__startswith=instance.uid).delete()
|
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 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 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 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 {
|
import {
|
||||||
ensureArray,
|
ensureArray,
|
||||||
ensureBoolean,
|
ensureBoolean,
|
||||||
|
@ -207,7 +209,7 @@ export class ButtonCollectionFieldType extends CollectionFieldType {
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
return 'Button'
|
return this.app.i18n.t('collectionFieldType.button')
|
||||||
}
|
}
|
||||||
|
|
||||||
get component() {
|
get component() {
|
||||||
|
@ -226,3 +228,31 @@ export class ButtonCollectionFieldType extends CollectionFieldType {
|
||||||
return { label: ensureString(resolveFormula(field.label)) }
|
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>
|
<template>
|
||||||
<div class="ab-image">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -28,6 +33,14 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* @type {Boolean} - Whether the image should be loaded lazily.
|
||||||
|
*/
|
||||||
|
lazy: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</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"
|
class="margin-bottom-2"
|
||||||
>
|
>
|
||||||
<DataSourceDropdown
|
<DataSourceDropdown
|
||||||
v-model="values.data_source_id"
|
v-model="computedDataSourceId"
|
||||||
small
|
small
|
||||||
:data-sources="dataSources"
|
:data-sources="dataSources"
|
||||||
>
|
>
|
||||||
|
@ -283,7 +283,6 @@ export default {
|
||||||
orientation: {},
|
orientation: {},
|
||||||
button_load_more_label: '',
|
button_load_more_label: '',
|
||||||
},
|
},
|
||||||
userHasChangedDataSource: false,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -312,15 +311,17 @@ export default {
|
||||||
? this.$t('error.maxValueField', { max: this.maxItemPerPage })
|
? this.$t('error.maxValueField', { max: this.maxItemPerPage })
|
||||||
: ''
|
: ''
|
||||||
},
|
},
|
||||||
},
|
computedDataSourceId: {
|
||||||
watch: {
|
get() {
|
||||||
async 'values.data_source_id'(newValue, oldValue) {
|
return this.element.data_source_id
|
||||||
if (newValue && !oldValue) {
|
},
|
||||||
await this.$nextTick()
|
set(newValue) {
|
||||||
if (this.userHasChangedDataSource) {
|
const oldValue = this.values.data_source_id
|
||||||
|
this.values.data_source_id = newValue
|
||||||
|
if (newValue !== oldValue && newValue) {
|
||||||
this.refreshFieldsFromDataSource()
|
this.refreshFieldsFromDataSource()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -380,7 +381,11 @@ export default {
|
||||||
refreshFieldsFromDataSource() {
|
refreshFieldsFromDataSource() {
|
||||||
// If the data source returns multiple records, generate
|
// If the data source returns multiple records, generate
|
||||||
// the collection field values.
|
// the collection field values.
|
||||||
if (this.selectedDataSourceReturnsList) {
|
|
||||||
|
if (
|
||||||
|
this.selectedDataSourceReturnsList &&
|
||||||
|
this.selectedDataSourceType.isValid(this.selectedDataSource)
|
||||||
|
) {
|
||||||
this.values.fields =
|
this.values.fields =
|
||||||
this.selectedDataSourceType.getDefaultCollectionFields(
|
this.selectedDataSourceType.getDefaultCollectionFields(
|
||||||
this.selectedDataSource
|
this.selectedDataSource
|
||||||
|
|
|
@ -587,9 +587,11 @@
|
||||||
},
|
},
|
||||||
"collectionFieldType": {
|
"collectionFieldType": {
|
||||||
"boolean": "Boolean",
|
"boolean": "Boolean",
|
||||||
|
"button": "Button",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
"link": "Link",
|
"link": "Link",
|
||||||
"tags": "Tags"
|
"tags": "Tags",
|
||||||
|
"image": "Image"
|
||||||
},
|
},
|
||||||
"textFieldForm": {
|
"textFieldForm": {
|
||||||
"fieldValueLabel": "Value",
|
"fieldValueLabel": "Value",
|
||||||
|
@ -611,6 +613,13 @@
|
||||||
"linkField": {
|
"linkField": {
|
||||||
"details": "Details"
|
"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": {
|
"createUserSourceForm": {
|
||||||
"userSourceType": "Type",
|
"userSourceType": "Type",
|
||||||
"userSourceIntegration": "Integration",
|
"userSourceIntegration": "Integration",
|
||||||
|
|
|
@ -111,6 +111,7 @@ import {
|
||||||
LinkCollectionFieldType,
|
LinkCollectionFieldType,
|
||||||
ButtonCollectionFieldType,
|
ButtonCollectionFieldType,
|
||||||
TagsCollectionFieldType,
|
TagsCollectionFieldType,
|
||||||
|
ImageCollectionFieldType,
|
||||||
} from '@baserow/modules/builder/collectionFieldTypes'
|
} from '@baserow/modules/builder/collectionFieldTypes'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -352,6 +353,10 @@ export default (context) => {
|
||||||
'collectionField',
|
'collectionField',
|
||||||
new ButtonCollectionFieldType(context)
|
new ButtonCollectionFieldType(context)
|
||||||
)
|
)
|
||||||
|
app.$registry.register(
|
||||||
|
'collectionField',
|
||||||
|
new ImageCollectionFieldType(context)
|
||||||
|
)
|
||||||
|
|
||||||
app.$registry.register('fontFamily', new InterFontFamilyType(context))
|
app.$registry.register('fontFamily', new InterFontFamilyType(context))
|
||||||
app.$registry.register('fontFamily', new ArialFontFamilyType(context))
|
app.$registry.register('fontFamily', new ArialFontFamilyType(context))
|
||||||
|
|
|
@ -11,3 +11,4 @@
|
||||||
@import 'iframe_element';
|
@import 'iframe_element';
|
||||||
@import 'repeat_element';
|
@import 'repeat_element';
|
||||||
@import 'tag_field';
|
@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(
|
.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
field !== 'id' &&
|
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
|
service.schema.items.properties[field].original_type !== 'formula' // every formula has different properties
|
||||||
)
|
)
|
||||||
.map((field) => {
|
.map((field) => {
|
||||||
|
@ -129,6 +128,14 @@ export class LocalBaserowListRowsServiceType extends ServiceType {
|
||||||
target: 'blank',
|
target: 'blank',
|
||||||
type: 'link',
|
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 (
|
} else if (
|
||||||
originalType === 'last_modified_by' ||
|
originalType === 'last_modified_by' ||
|
||||||
originalType === 'created_by'
|
originalType === 'created_by'
|
||||||
|
|
Loading…
Add table
Reference in a new issue