mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-25 21:33:41 +00:00
Merge branch '2513-repeat-element-refactor-the-collection-and-container-element-types-into-mixins' into 'develop'
Resolve "Repeat element: refactor the collection and container element types into mixins." Closes #2513 See merge request baserow/baserow!2266
This commit is contained in:
commit
7e04a43b51
7 changed files with 669 additions and 611 deletions
backend
src/baserow/contrib/builder
tests/baserow/contrib/builder/elements
web-frontend/modules/builder
|
@ -12,8 +12,8 @@ from baserow.contrib.builder.data_sources.exceptions import (
|
||||||
DataSourceImproperlyConfigured,
|
DataSourceImproperlyConfigured,
|
||||||
)
|
)
|
||||||
from baserow.contrib.builder.data_sources.handler import DataSourceHandler
|
from baserow.contrib.builder.data_sources.handler import DataSourceHandler
|
||||||
from baserow.contrib.builder.elements.element_types import FormElementType
|
|
||||||
from baserow.contrib.builder.elements.handler import ElementHandler
|
from baserow.contrib.builder.elements.handler import ElementHandler
|
||||||
|
from baserow.contrib.builder.elements.mixins import FormElementTypeMixin
|
||||||
from baserow.contrib.builder.elements.models import FormElement
|
from baserow.contrib.builder.elements.models import FormElement
|
||||||
from baserow.contrib.builder.workflow_actions.handler import (
|
from baserow.contrib.builder.workflow_actions.handler import (
|
||||||
BuilderWorkflowActionHandler,
|
BuilderWorkflowActionHandler,
|
||||||
|
@ -62,7 +62,7 @@ class FormDataProviderType(DataProviderType):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
element: Type[FormElement] = ElementHandler().get_element(element_id) # type: ignore
|
element: Type[FormElement] = ElementHandler().get_element(element_id) # type: ignore
|
||||||
element_type: FormElementType = element.get_type() # type: ignore
|
element_type: FormElementTypeMixin = element.get_type() # type: ignore
|
||||||
if not element_type.is_valid(element, data_chunk):
|
if not element_type.is_valid(element, data_chunk):
|
||||||
raise FormDataProviderChunkInvalidException(
|
raise FormDataProviderChunkInvalidException(
|
||||||
f"Form data {data_chunk} is invalid for its element."
|
f"Form data {data_chunk} is invalid for its element."
|
||||||
|
|
|
@ -1,33 +1,30 @@
|
||||||
import abc
|
import abc
|
||||||
from abc import ABC
|
from typing import Any, Dict, List, Optional, TypedDict
|
||||||
from typing import Any, Dict, List, Optional, Type, TypedDict
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import validate_email, validate_integer
|
from django.core.validators import validate_email, validate_integer
|
||||||
from django.db.models import IntegerField, QuerySet
|
from django.db.models import IntegerField, QuerySet
|
||||||
from django.db.models.functions import Cast
|
from django.db.models.functions import Cast
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||||
|
|
||||||
from baserow.api.exceptions import RequestBodyValidationException
|
|
||||||
from baserow.contrib.builder.api.elements.serializers import DropdownOptionSerializer
|
from baserow.contrib.builder.api.elements.serializers import DropdownOptionSerializer
|
||||||
from baserow.contrib.builder.data_sources.handler import DataSourceHandler
|
from baserow.contrib.builder.elements.mixins import (
|
||||||
from baserow.contrib.builder.elements.handler import ElementHandler
|
CollectionElementTypeMixin,
|
||||||
|
ContainerElementTypeMixin,
|
||||||
|
FormElementTypeMixin,
|
||||||
|
)
|
||||||
from baserow.contrib.builder.elements.models import (
|
from baserow.contrib.builder.elements.models import (
|
||||||
INPUT_TEXT_TYPES,
|
INPUT_TEXT_TYPES,
|
||||||
WIDTHS,
|
WIDTHS,
|
||||||
ButtonElement,
|
ButtonElement,
|
||||||
CheckboxElement,
|
CheckboxElement,
|
||||||
CollectionField,
|
|
||||||
ColumnElement,
|
ColumnElement,
|
||||||
ContainerElement,
|
|
||||||
DropdownElement,
|
DropdownElement,
|
||||||
DropdownElementOption,
|
DropdownElementOption,
|
||||||
Element,
|
Element,
|
||||||
FormContainerElement,
|
FormContainerElement,
|
||||||
FormElement,
|
|
||||||
HeadingElement,
|
HeadingElement,
|
||||||
HorizontalAlignments,
|
HorizontalAlignments,
|
||||||
IFrameElement,
|
IFrameElement,
|
||||||
|
@ -43,7 +40,6 @@ from baserow.contrib.builder.elements.registries import (
|
||||||
ElementType,
|
ElementType,
|
||||||
element_type_registry,
|
element_type_registry,
|
||||||
)
|
)
|
||||||
from baserow.contrib.builder.elements.signals import elements_moved
|
|
||||||
from baserow.contrib.builder.formula_importer import import_formula
|
from baserow.contrib.builder.formula_importer import import_formula
|
||||||
from baserow.contrib.builder.pages.handler import PageHandler
|
from baserow.contrib.builder.pages.handler import PageHandler
|
||||||
from baserow.contrib.builder.pages.models import Page
|
from baserow.contrib.builder.pages.models import Page
|
||||||
|
@ -51,301 +47,8 @@ from baserow.contrib.builder.types import ElementDict
|
||||||
from baserow.core.formula.types import BaserowFormula
|
from baserow.core.formula.types import BaserowFormula
|
||||||
from baserow.core.registry import T
|
from baserow.core.registry import T
|
||||||
|
|
||||||
from .registries import collection_field_type_registry
|
|
||||||
|
|
||||||
|
class ColumnElementType(ContainerElementTypeMixin, ElementType):
|
||||||
class ContainerElementType(ElementType, ABC):
|
|
||||||
# Container element types are imported first.
|
|
||||||
import_element_priority = 2
|
|
||||||
|
|
||||||
@property
|
|
||||||
def child_types_allowed(self) -> List[str]:
|
|
||||||
"""
|
|
||||||
Lets you define which children types can be placed inside the container.
|
|
||||||
|
|
||||||
:return: All the allowed children types
|
|
||||||
"""
|
|
||||||
|
|
||||||
return [element_type.type for element_type in element_type_registry.get_all()]
|
|
||||||
|
|
||||||
def get_new_place_in_container(
|
|
||||||
self, container_element: ContainerElement, places_removed: List[str]
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Provides an alternative place that elements can move to when places in the
|
|
||||||
container are removed.
|
|
||||||
|
|
||||||
:param container_element: The container element that has places removed
|
|
||||||
:param places_removed: The places that are being removed
|
|
||||||
:return: The new place in the container the elements can be moved to
|
|
||||||
"""
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_places_in_container_removed(
|
|
||||||
self, values: Dict, instance: ContainerElement
|
|
||||||
) -> List[str]:
|
|
||||||
"""
|
|
||||||
This method defines what elements in the container have been removed preceding
|
|
||||||
an update of hte container element.
|
|
||||||
|
|
||||||
:param values: The new values that are being set
|
|
||||||
:param instance: The current state of the element
|
|
||||||
:return: The places in the container that have been removed
|
|
||||||
"""
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
def apply_order_by_children(self, queryset: QuerySet[Element]) -> QuerySet[Element]:
|
|
||||||
"""
|
|
||||||
Defines the order of the children inside the container.
|
|
||||||
|
|
||||||
:param queryset: The queryset that the order is applied to.
|
|
||||||
:return: A queryset with the order applied to
|
|
||||||
"""
|
|
||||||
|
|
||||||
return queryset.order_by("place_in_container", "order")
|
|
||||||
|
|
||||||
def prepare_value_for_db(
|
|
||||||
self, values: Dict, instance: Optional[ContainerElement] = None
|
|
||||||
):
|
|
||||||
if instance is not None: # This is an update operation
|
|
||||||
places_removed = self.get_places_in_container_removed(values, instance)
|
|
||||||
|
|
||||||
if len(places_removed) > 0:
|
|
||||||
instances_moved = ElementHandler().before_places_in_container_removed(
|
|
||||||
instance, places_removed
|
|
||||||
)
|
|
||||||
|
|
||||||
elements_moved.send(self, page=instance.page, elements=instances_moved)
|
|
||||||
|
|
||||||
return super().prepare_value_for_db(values, instance)
|
|
||||||
|
|
||||||
def validate_place_in_container(
|
|
||||||
self, place_in_container: str, instance: ContainerElement
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Validate that the place in container being set on a child is valid.
|
|
||||||
|
|
||||||
:param place_in_container: The place in container being set
|
|
||||||
:param instance: The instance of the container element
|
|
||||||
:raises DRFValidationError: If the place in container is invalid
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionElementType(ElementType, ABC):
|
|
||||||
allowed_fields = ["data_source", "data_source_id", "items_per_page"]
|
|
||||||
serializer_field_names = ["data_source_id", "fields", "items_per_page"]
|
|
||||||
|
|
||||||
class SerializedDict(ElementDict):
|
|
||||||
data_source_id: int
|
|
||||||
items_per_page: int
|
|
||||||
fields: List[Dict]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer_field_overrides(self):
|
|
||||||
from baserow.contrib.builder.api.elements.serializers import (
|
|
||||||
CollectionFieldSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"data_source_id": serializers.IntegerField(
|
|
||||||
allow_null=True,
|
|
||||||
default=None,
|
|
||||||
help_text=TableElement._meta.get_field("data_source").help_text,
|
|
||||||
required=False,
|
|
||||||
),
|
|
||||||
"items_per_page": serializers.IntegerField(
|
|
||||||
default=20,
|
|
||||||
help_text=TableElement._meta.get_field("items_per_page").help_text,
|
|
||||||
required=False,
|
|
||||||
),
|
|
||||||
"fields": CollectionFieldSerializer(many=True, required=False),
|
|
||||||
}
|
|
||||||
|
|
||||||
def prepare_value_for_db(
|
|
||||||
self, values: Dict, instance: Optional[LinkElement] = None
|
|
||||||
):
|
|
||||||
if "data_source_id" in values:
|
|
||||||
data_source_id = values.pop("data_source_id")
|
|
||||||
if data_source_id is not None:
|
|
||||||
data_source = DataSourceHandler().get_data_source(data_source_id)
|
|
||||||
if (
|
|
||||||
not data_source.service
|
|
||||||
or not data_source.service.specific.get_type().returns_list
|
|
||||||
):
|
|
||||||
raise DRFValidationError(
|
|
||||||
f"The data source with ID {data_source_id} doesn't return a "
|
|
||||||
"list."
|
|
||||||
)
|
|
||||||
|
|
||||||
if instance:
|
|
||||||
current_page = PageHandler().get_page(instance.page_id)
|
|
||||||
else:
|
|
||||||
current_page = values["page"]
|
|
||||||
|
|
||||||
if current_page.id != data_source.page_id:
|
|
||||||
raise RequestBodyValidationException(
|
|
||||||
{
|
|
||||||
"data_source_id": [
|
|
||||||
{
|
|
||||||
"detail": "The provided data source doesn't belong "
|
|
||||||
"to the same application.",
|
|
||||||
"code": "invalid_data_source",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
values["data_source"] = data_source
|
|
||||||
else:
|
|
||||||
values["data_source"] = None
|
|
||||||
|
|
||||||
return super().prepare_value_for_db(values, instance)
|
|
||||||
|
|
||||||
def after_create(self, instance, values):
|
|
||||||
default_fields = [
|
|
||||||
{
|
|
||||||
"name": _("Column %(count)s") % {"count": 1},
|
|
||||||
"type": "text",
|
|
||||||
"config": {"value": ""},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": _("Column %(count)s") % {"count": 2},
|
|
||||||
"type": "text",
|
|
||||||
"config": {"value": ""},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": _("Column %(count)s") % {"count": 3},
|
|
||||||
"type": "text",
|
|
||||||
"config": {"value": ""},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
fields = values.get("fields", default_fields)
|
|
||||||
|
|
||||||
created_fields = CollectionField.objects.bulk_create(
|
|
||||||
[
|
|
||||||
CollectionField(**field, order=index)
|
|
||||||
for index, field in enumerate(fields)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
instance.fields.add(*created_fields)
|
|
||||||
|
|
||||||
def after_update(self, instance, values):
|
|
||||||
if "fields" in values:
|
|
||||||
# Remove previous fields
|
|
||||||
instance.fields.all().delete()
|
|
||||||
|
|
||||||
created_fields = CollectionField.objects.bulk_create(
|
|
||||||
[
|
|
||||||
CollectionField(**field, order=index)
|
|
||||||
for index, field in enumerate(values["fields"])
|
|
||||||
]
|
|
||||||
)
|
|
||||||
instance.fields.add(*created_fields)
|
|
||||||
|
|
||||||
def before_delete(self, instance):
|
|
||||||
instance.fields.all().delete()
|
|
||||||
|
|
||||||
def serialize_property(self, element: Element, prop_name: str):
|
|
||||||
"""
|
|
||||||
You can customize the behavior of the serialization of a property with this
|
|
||||||
hook.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if prop_name == "fields":
|
|
||||||
return [
|
|
||||||
collection_field_type_registry.get(f.type).export_serialized(f)
|
|
||||||
for f in element.fields.all()
|
|
||||||
]
|
|
||||||
|
|
||||||
return super().serialize_property(element, prop_name)
|
|
||||||
|
|
||||||
def deserialize_property(
|
|
||||||
self,
|
|
||||||
prop_name: str,
|
|
||||||
value: Any,
|
|
||||||
id_mapping: Dict[str, Any],
|
|
||||||
**kwargs,
|
|
||||||
) -> Any:
|
|
||||||
if prop_name == "data_source_id" and value:
|
|
||||||
return id_mapping["builder_data_sources"][value]
|
|
||||||
|
|
||||||
if prop_name == "fields":
|
|
||||||
return [
|
|
||||||
# We need to add the data_source_id for the current row
|
|
||||||
# provider.
|
|
||||||
collection_field_type_registry.get(f["type"]).import_serialized(
|
|
||||||
f, id_mapping, data_source_id=kwargs["data_source_id"]
|
|
||||||
)
|
|
||||||
for f in value
|
|
||||||
]
|
|
||||||
|
|
||||||
return super().deserialize_property(prop_name, value, id_mapping)
|
|
||||||
|
|
||||||
def create_instance_from_serialized(self, serialized_values: Dict[str, Any]):
|
|
||||||
"""Deals with the fields"""
|
|
||||||
|
|
||||||
fields = serialized_values.pop("fields", [])
|
|
||||||
|
|
||||||
instance = super().create_instance_from_serialized(serialized_values)
|
|
||||||
|
|
||||||
# Add the field order
|
|
||||||
for i, f in enumerate(fields):
|
|
||||||
f.order = i
|
|
||||||
|
|
||||||
# Create fields
|
|
||||||
created_fields = CollectionField.objects.bulk_create(fields)
|
|
||||||
|
|
||||||
instance.fields.add(*created_fields)
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
||||||
def import_serialized(
|
|
||||||
self,
|
|
||||||
parent: Any,
|
|
||||||
serialized_values: Dict[str, Any],
|
|
||||||
id_mapping: Dict[str, Any],
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Here we add the data_source_id to the import process to be able to resolve
|
|
||||||
current_record formulas migration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
actual_data_source_id = None
|
|
||||||
if serialized_values.get("data_source_id", None):
|
|
||||||
actual_data_source_id = id_mapping["builder_data_sources"][
|
|
||||||
serialized_values["data_source_id"]
|
|
||||||
]
|
|
||||||
|
|
||||||
return super().import_serialized(
|
|
||||||
parent,
|
|
||||||
serialized_values,
|
|
||||||
id_mapping,
|
|
||||||
data_source_id=actual_data_source_id,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FormElementType(ElementType):
|
|
||||||
# Form element types are imported second, after containers.
|
|
||||||
import_element_priority = 1
|
|
||||||
|
|
||||||
def is_valid(self, element: Type[FormElement], value: Any) -> bool:
|
|
||||||
"""
|
|
||||||
Given an element and form data value, returns whether it's valid.
|
|
||||||
Used by `FormDataProviderType` to determine if form data is valid.
|
|
||||||
|
|
||||||
:param element: The element we're trying to use form data in.
|
|
||||||
:param value: The form data value, which may be invalid.
|
|
||||||
:return: Whether the value is valid or not for this element.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return not (element.required and not value)
|
|
||||||
|
|
||||||
|
|
||||||
class ColumnElementType(ContainerElementType):
|
|
||||||
"""
|
"""
|
||||||
A column element is a container element that can be used to display other elements
|
A column element is a container element that can be used to display other elements
|
||||||
in a column.
|
in a column.
|
||||||
|
@ -421,6 +124,105 @@ class ColumnElementType(ContainerElementType):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormContainerElementType(ContainerElementTypeMixin, ElementType):
|
||||||
|
type = "form_container"
|
||||||
|
model_class = FormContainerElement
|
||||||
|
allowed_fields = [
|
||||||
|
"submit_button_label",
|
||||||
|
"button_color",
|
||||||
|
"reset_initial_values_post_submission",
|
||||||
|
]
|
||||||
|
serializer_field_names = [
|
||||||
|
"submit_button_label",
|
||||||
|
"button_color",
|
||||||
|
"reset_initial_values_post_submission",
|
||||||
|
]
|
||||||
|
|
||||||
|
class SerializedDict(ElementDict):
|
||||||
|
submit_button_label: BaserowFormula
|
||||||
|
button_color: str
|
||||||
|
|
||||||
|
def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]:
|
||||||
|
return {"submit_button_label": "'Submit'"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serializer_field_overrides(self):
|
||||||
|
from baserow.core.formula.serializers import FormulaSerializerField
|
||||||
|
|
||||||
|
return {
|
||||||
|
"submit_button_label": FormulaSerializerField(
|
||||||
|
help_text=FormContainerElement._meta.get_field(
|
||||||
|
"submit_button_label"
|
||||||
|
).help_text,
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
default="",
|
||||||
|
),
|
||||||
|
"button_color": serializers.CharField(
|
||||||
|
max_length=20,
|
||||||
|
required=False,
|
||||||
|
default="primary",
|
||||||
|
help_text="Button color.",
|
||||||
|
),
|
||||||
|
"reset_initial_values_post_submission": serializers.BooleanField(
|
||||||
|
help_text=FormContainerElement._meta.get_field(
|
||||||
|
"reset_initial_values_post_submission"
|
||||||
|
).help_text,
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def child_types_allowed(self) -> List[str]:
|
||||||
|
child_types_allowed = []
|
||||||
|
|
||||||
|
for element_type in element_type_registry.get_all():
|
||||||
|
if isinstance(element_type, FormElementTypeMixin):
|
||||||
|
child_types_allowed.append(element_type.type)
|
||||||
|
|
||||||
|
return child_types_allowed
|
||||||
|
|
||||||
|
def import_serialized(self, page, serialized_values, id_mapping):
|
||||||
|
serialized_copy = serialized_values.copy()
|
||||||
|
if serialized_copy["submit_button_label"]:
|
||||||
|
serialized_copy["submit_button_label"] = import_formula(
|
||||||
|
serialized_copy["submit_button_label"], id_mapping
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().import_serialized(page, serialized_copy, id_mapping)
|
||||||
|
|
||||||
|
|
||||||
|
class TableElementType(CollectionElementTypeMixin, ElementType):
|
||||||
|
type = "table"
|
||||||
|
model_class = TableElement
|
||||||
|
|
||||||
|
class SerializedDict(CollectionElementTypeMixin.SerializedDict):
|
||||||
|
button_color: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed_fields(self):
|
||||||
|
return super().allowed_fields + ["button_color"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serializer_field_names(self):
|
||||||
|
return super().serializer_field_names + ["button_color"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serializer_field_overrides(self):
|
||||||
|
return {
|
||||||
|
**super().serializer_field_overrides,
|
||||||
|
"button_color": serializers.CharField(
|
||||||
|
max_length=20,
|
||||||
|
required=False,
|
||||||
|
default="primary",
|
||||||
|
help_text="Button color.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]:
|
||||||
|
return {"data_source_id": None}
|
||||||
|
|
||||||
|
|
||||||
class HeadingElementType(ElementType):
|
class HeadingElementType(ElementType):
|
||||||
"""
|
"""
|
||||||
A simple heading element that can be used to display a title.
|
A simple heading element that can be used to display a title.
|
||||||
|
@ -918,7 +720,7 @@ class ImageElementType(ElementType):
|
||||||
return super().import_serialized(page, serialized_copy, id_mapping)
|
return super().import_serialized(page, serialized_copy, id_mapping)
|
||||||
|
|
||||||
|
|
||||||
class InputElementType(FormElementType, abc.ABC):
|
class InputElementType(FormElementTypeMixin, ElementType, abc.ABC):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@ -1113,105 +915,6 @@ class ButtonElementType(ElementType):
|
||||||
return super().import_serialized(page, serialized_copy, id_mapping)
|
return super().import_serialized(page, serialized_copy, id_mapping)
|
||||||
|
|
||||||
|
|
||||||
class TableElementType(CollectionElementType):
|
|
||||||
type = "table"
|
|
||||||
model_class = TableElement
|
|
||||||
|
|
||||||
class SerializedDict(CollectionElementType.SerializedDict):
|
|
||||||
button_color: str
|
|
||||||
|
|
||||||
@property
|
|
||||||
def allowed_fields(self):
|
|
||||||
return super().allowed_fields + ["button_color"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer_field_names(self):
|
|
||||||
return super().serializer_field_names + ["button_color"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer_field_overrides(self):
|
|
||||||
return {
|
|
||||||
**super().serializer_field_overrides,
|
|
||||||
"button_color": serializers.CharField(
|
|
||||||
max_length=20,
|
|
||||||
required=False,
|
|
||||||
default="primary",
|
|
||||||
help_text="Button color.",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]:
|
|
||||||
return {"data_source_id": None}
|
|
||||||
|
|
||||||
|
|
||||||
class FormContainerElementType(ContainerElementType):
|
|
||||||
type = "form_container"
|
|
||||||
model_class = FormContainerElement
|
|
||||||
allowed_fields = [
|
|
||||||
"submit_button_label",
|
|
||||||
"button_color",
|
|
||||||
"reset_initial_values_post_submission",
|
|
||||||
]
|
|
||||||
serializer_field_names = [
|
|
||||||
"submit_button_label",
|
|
||||||
"button_color",
|
|
||||||
"reset_initial_values_post_submission",
|
|
||||||
]
|
|
||||||
|
|
||||||
class SerializedDict(ElementDict):
|
|
||||||
submit_button_label: BaserowFormula
|
|
||||||
button_color: str
|
|
||||||
|
|
||||||
def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]:
|
|
||||||
return {"submit_button_label": "'Submit'"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer_field_overrides(self):
|
|
||||||
from baserow.core.formula.serializers import FormulaSerializerField
|
|
||||||
|
|
||||||
return {
|
|
||||||
"submit_button_label": FormulaSerializerField(
|
|
||||||
help_text=FormContainerElement._meta.get_field(
|
|
||||||
"submit_button_label"
|
|
||||||
).help_text,
|
|
||||||
required=False,
|
|
||||||
allow_blank=True,
|
|
||||||
default="",
|
|
||||||
),
|
|
||||||
"button_color": serializers.CharField(
|
|
||||||
max_length=20,
|
|
||||||
required=False,
|
|
||||||
default="primary",
|
|
||||||
help_text="Button color.",
|
|
||||||
),
|
|
||||||
"reset_initial_values_post_submission": serializers.BooleanField(
|
|
||||||
help_text=FormContainerElement._meta.get_field(
|
|
||||||
"reset_initial_values_post_submission"
|
|
||||||
).help_text,
|
|
||||||
required=False,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def child_types_allowed(self) -> List[str]:
|
|
||||||
child_types_allowed = []
|
|
||||||
|
|
||||||
for element_type in element_type_registry.get_all():
|
|
||||||
if isinstance(element_type, FormElementType):
|
|
||||||
child_types_allowed.append(element_type.type)
|
|
||||||
|
|
||||||
return child_types_allowed
|
|
||||||
|
|
||||||
def import_serialized(self, page, serialized_values, id_mapping):
|
|
||||||
serialized_copy = serialized_values.copy()
|
|
||||||
if serialized_copy["submit_button_label"]:
|
|
||||||
serialized_copy["submit_button_label"] = import_formula(
|
|
||||||
serialized_copy["submit_button_label"], id_mapping
|
|
||||||
)
|
|
||||||
|
|
||||||
return super().import_serialized(page, serialized_copy, id_mapping)
|
|
||||||
|
|
||||||
|
|
||||||
class CheckboxElementType(InputElementType):
|
class CheckboxElementType(InputElementType):
|
||||||
type = "checkbox"
|
type = "checkbox"
|
||||||
model_class = CheckboxElement
|
model_class = CheckboxElement
|
||||||
|
@ -1270,7 +973,7 @@ class CheckboxElementType(InputElementType):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class DropdownElementType(FormElementType):
|
class DropdownElementType(FormElementTypeMixin, ElementType):
|
||||||
type = "dropdown"
|
type = "dropdown"
|
||||||
model_class = DropdownElement
|
model_class = DropdownElement
|
||||||
allowed_fields = ["label", "default_value", "required", "placeholder"]
|
allowed_fields = ["label", "default_value", "required", "placeholder"]
|
||||||
|
|
320
backend/src/baserow/contrib/builder/elements/mixins.py
Normal file
320
backend/src/baserow/contrib/builder/elements/mixins.py
Normal file
|
@ -0,0 +1,320 @@
|
||||||
|
from typing import Any, Dict, List, Optional, Type
|
||||||
|
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||||
|
|
||||||
|
from baserow.api.exceptions import RequestBodyValidationException
|
||||||
|
from baserow.contrib.builder.data_sources.handler import DataSourceHandler
|
||||||
|
from baserow.contrib.builder.elements.handler import ElementHandler
|
||||||
|
from baserow.contrib.builder.elements.models import (
|
||||||
|
CollectionField,
|
||||||
|
ContainerElement,
|
||||||
|
Element,
|
||||||
|
FormElement,
|
||||||
|
LinkElement,
|
||||||
|
TableElement,
|
||||||
|
)
|
||||||
|
from baserow.contrib.builder.elements.registries import (
|
||||||
|
collection_field_type_registry,
|
||||||
|
element_type_registry,
|
||||||
|
)
|
||||||
|
from baserow.contrib.builder.elements.signals import elements_moved
|
||||||
|
from baserow.contrib.builder.pages.handler import PageHandler
|
||||||
|
from baserow.contrib.builder.types import ElementDict
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerElementTypeMixin:
|
||||||
|
# Container element types are imported first.
|
||||||
|
import_element_priority = 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def child_types_allowed(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Lets you define which children types can be placed inside the container.
|
||||||
|
|
||||||
|
:return: All the allowed children types
|
||||||
|
"""
|
||||||
|
|
||||||
|
return [element_type.type for element_type in element_type_registry.get_all()]
|
||||||
|
|
||||||
|
def get_new_place_in_container(
|
||||||
|
self, container_element: ContainerElement, places_removed: List[str]
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Provides an alternative place that elements can move to when places in the
|
||||||
|
container are removed.
|
||||||
|
|
||||||
|
:param container_element: The container element that has places removed
|
||||||
|
:param places_removed: The places that are being removed
|
||||||
|
:return: The new place in the container the elements can be moved to
|
||||||
|
"""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_places_in_container_removed(
|
||||||
|
self, values: Dict, instance: ContainerElement
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
This method defines what elements in the container have been removed preceding
|
||||||
|
an update of hte container element.
|
||||||
|
|
||||||
|
:param values: The new values that are being set
|
||||||
|
:param instance: The current state of the element
|
||||||
|
:return: The places in the container that have been removed
|
||||||
|
"""
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def apply_order_by_children(self, queryset: QuerySet[Element]) -> QuerySet[Element]:
|
||||||
|
"""
|
||||||
|
Defines the order of the children inside the container.
|
||||||
|
|
||||||
|
:param queryset: The queryset that the order is applied to.
|
||||||
|
:return: A queryset with the order applied to
|
||||||
|
"""
|
||||||
|
|
||||||
|
return queryset.order_by("place_in_container", "order")
|
||||||
|
|
||||||
|
def prepare_value_for_db(
|
||||||
|
self, values: Dict, instance: Optional[ContainerElement] = None
|
||||||
|
):
|
||||||
|
if instance is not None: # This is an update operation
|
||||||
|
places_removed = self.get_places_in_container_removed(values, instance)
|
||||||
|
|
||||||
|
if len(places_removed) > 0:
|
||||||
|
instances_moved = ElementHandler().before_places_in_container_removed(
|
||||||
|
instance, places_removed
|
||||||
|
)
|
||||||
|
|
||||||
|
elements_moved.send(self, page=instance.page, elements=instances_moved)
|
||||||
|
|
||||||
|
return super().prepare_value_for_db(values, instance)
|
||||||
|
|
||||||
|
def validate_place_in_container(
|
||||||
|
self, place_in_container: str, instance: ContainerElement
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Validate that the place in container being set on a child is valid.
|
||||||
|
|
||||||
|
:param place_in_container: The place in container being set
|
||||||
|
:param instance: The instance of the container element
|
||||||
|
:raises DRFValidationError: If the place in container is invalid
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionElementTypeMixin:
|
||||||
|
allowed_fields = ["data_source", "data_source_id", "items_per_page"]
|
||||||
|
serializer_field_names = ["data_source_id", "fields", "items_per_page"]
|
||||||
|
|
||||||
|
class SerializedDict(ElementDict):
|
||||||
|
data_source_id: int
|
||||||
|
items_per_page: int
|
||||||
|
fields: List[Dict]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serializer_field_overrides(self):
|
||||||
|
from baserow.contrib.builder.api.elements.serializers import (
|
||||||
|
CollectionFieldSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data_source_id": serializers.IntegerField(
|
||||||
|
allow_null=True,
|
||||||
|
default=None,
|
||||||
|
help_text=TableElement._meta.get_field("data_source").help_text,
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
"items_per_page": serializers.IntegerField(
|
||||||
|
default=20,
|
||||||
|
help_text=TableElement._meta.get_field("items_per_page").help_text,
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
"fields": CollectionFieldSerializer(many=True, required=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def prepare_value_for_db(
|
||||||
|
self, values: Dict, instance: Optional[LinkElement] = None
|
||||||
|
):
|
||||||
|
if "data_source_id" in values:
|
||||||
|
data_source_id = values.pop("data_source_id")
|
||||||
|
if data_source_id is not None:
|
||||||
|
data_source = DataSourceHandler().get_data_source(data_source_id)
|
||||||
|
if (
|
||||||
|
not data_source.service
|
||||||
|
or not data_source.service.specific.get_type().returns_list
|
||||||
|
):
|
||||||
|
raise DRFValidationError(
|
||||||
|
f"The data source with ID {data_source_id} doesn't return a "
|
||||||
|
"list."
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance:
|
||||||
|
current_page = PageHandler().get_page(instance.page_id)
|
||||||
|
else:
|
||||||
|
current_page = values["page"]
|
||||||
|
|
||||||
|
if current_page.id != data_source.page_id:
|
||||||
|
raise RequestBodyValidationException(
|
||||||
|
{
|
||||||
|
"data_source_id": [
|
||||||
|
{
|
||||||
|
"detail": "The provided data source doesn't belong "
|
||||||
|
"to the same application.",
|
||||||
|
"code": "invalid_data_source",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
values["data_source"] = data_source
|
||||||
|
else:
|
||||||
|
values["data_source"] = None
|
||||||
|
|
||||||
|
return super().prepare_value_for_db(values, instance)
|
||||||
|
|
||||||
|
def after_create(self, instance, values):
|
||||||
|
default_fields = [
|
||||||
|
{
|
||||||
|
"name": _("Column %(count)s") % {"count": 1},
|
||||||
|
"type": "text",
|
||||||
|
"config": {"value": ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": _("Column %(count)s") % {"count": 2},
|
||||||
|
"type": "text",
|
||||||
|
"config": {"value": ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": _("Column %(count)s") % {"count": 3},
|
||||||
|
"type": "text",
|
||||||
|
"config": {"value": ""},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
fields = values.get("fields", default_fields)
|
||||||
|
|
||||||
|
created_fields = CollectionField.objects.bulk_create(
|
||||||
|
[
|
||||||
|
CollectionField(**field, order=index)
|
||||||
|
for index, field in enumerate(fields)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
instance.fields.add(*created_fields)
|
||||||
|
|
||||||
|
def after_update(self, instance, values):
|
||||||
|
if "fields" in values:
|
||||||
|
# Remove previous fields
|
||||||
|
instance.fields.all().delete()
|
||||||
|
|
||||||
|
created_fields = CollectionField.objects.bulk_create(
|
||||||
|
[
|
||||||
|
CollectionField(**field, order=index)
|
||||||
|
for index, field in enumerate(values["fields"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
instance.fields.add(*created_fields)
|
||||||
|
|
||||||
|
def before_delete(self, instance):
|
||||||
|
instance.fields.all().delete()
|
||||||
|
|
||||||
|
def serialize_property(self, element: Element, prop_name: str):
|
||||||
|
"""
|
||||||
|
You can customize the behavior of the serialization of a property with this
|
||||||
|
hook.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if prop_name == "fields":
|
||||||
|
return [
|
||||||
|
collection_field_type_registry.get(f.type).export_serialized(f)
|
||||||
|
for f in element.fields.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
return super().serialize_property(element, prop_name)
|
||||||
|
|
||||||
|
def deserialize_property(
|
||||||
|
self,
|
||||||
|
prop_name: str,
|
||||||
|
value: Any,
|
||||||
|
id_mapping: Dict[str, Any],
|
||||||
|
**kwargs,
|
||||||
|
) -> Any:
|
||||||
|
if prop_name == "data_source_id" and value:
|
||||||
|
return id_mapping["builder_data_sources"][value]
|
||||||
|
|
||||||
|
if prop_name == "fields":
|
||||||
|
return [
|
||||||
|
# We need to add the data_source_id for the current row
|
||||||
|
# provider.
|
||||||
|
collection_field_type_registry.get(f["type"]).import_serialized(
|
||||||
|
f, id_mapping, data_source_id=kwargs["data_source_id"]
|
||||||
|
)
|
||||||
|
for f in value
|
||||||
|
]
|
||||||
|
|
||||||
|
return super().deserialize_property(prop_name, value, id_mapping)
|
||||||
|
|
||||||
|
def create_instance_from_serialized(self, serialized_values: Dict[str, Any]):
|
||||||
|
"""Deals with the fields"""
|
||||||
|
|
||||||
|
fields = serialized_values.pop("fields", [])
|
||||||
|
|
||||||
|
instance = super().create_instance_from_serialized(serialized_values)
|
||||||
|
|
||||||
|
# Add the field order
|
||||||
|
for i, f in enumerate(fields):
|
||||||
|
f.order = i
|
||||||
|
|
||||||
|
# Create fields
|
||||||
|
created_fields = CollectionField.objects.bulk_create(fields)
|
||||||
|
|
||||||
|
instance.fields.add(*created_fields)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def import_serialized(
|
||||||
|
self,
|
||||||
|
parent: Any,
|
||||||
|
serialized_values: Dict[str, Any],
|
||||||
|
id_mapping: Dict[str, Any],
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Here we add the data_source_id to the import process to be able to resolve
|
||||||
|
current_record formulas migration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
actual_data_source_id = None
|
||||||
|
if (
|
||||||
|
serialized_values.get("data_source_id", None)
|
||||||
|
and "builder_data_sources" in id_mapping
|
||||||
|
):
|
||||||
|
actual_data_source_id = id_mapping["builder_data_sources"][
|
||||||
|
serialized_values["data_source_id"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return super().import_serialized(
|
||||||
|
parent,
|
||||||
|
serialized_values,
|
||||||
|
id_mapping,
|
||||||
|
data_source_id=actual_data_source_id,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormElementTypeMixin:
|
||||||
|
# Form element types are imported second, after containers.
|
||||||
|
import_element_priority = 1
|
||||||
|
|
||||||
|
def is_valid(self, element: Type[FormElement], value: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Given an element and form data value, returns whether it's valid.
|
||||||
|
Used by `FormDataProviderType` to determine if form data is valid.
|
||||||
|
|
||||||
|
:param element: The element we're trying to use form data in.
|
||||||
|
:param value: The form data value, which may be invalid.
|
||||||
|
:return: Whether the value is valid or not for this element.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return not (element.required and not value)
|
|
@ -5,13 +5,15 @@ from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from baserow.contrib.builder.elements.element_types import (
|
from baserow.contrib.builder.elements.element_types import (
|
||||||
CheckboxElementType,
|
CheckboxElementType,
|
||||||
ContainerElementType,
|
|
||||||
DropdownElementType,
|
DropdownElementType,
|
||||||
FormElementType,
|
|
||||||
IFrameElementType,
|
IFrameElementType,
|
||||||
InputTextElementType,
|
InputTextElementType,
|
||||||
)
|
)
|
||||||
from baserow.contrib.builder.elements.handler import ElementHandler
|
from baserow.contrib.builder.elements.handler import ElementHandler
|
||||||
|
from baserow.contrib.builder.elements.mixins import (
|
||||||
|
ContainerElementTypeMixin,
|
||||||
|
FormElementTypeMixin,
|
||||||
|
)
|
||||||
from baserow.contrib.builder.elements.models import (
|
from baserow.contrib.builder.elements.models import (
|
||||||
CheckboxElement,
|
CheckboxElement,
|
||||||
DropdownElementOption,
|
DropdownElementOption,
|
||||||
|
@ -232,18 +234,18 @@ def test_element_type_import_element_priority():
|
||||||
container_element_types = [
|
container_element_types = [
|
||||||
element_type
|
element_type
|
||||||
for element_type in element_types
|
for element_type in element_types
|
||||||
if isinstance(element_type, ContainerElementType)
|
if isinstance(element_type, ContainerElementTypeMixin)
|
||||||
]
|
]
|
||||||
form_element_types = [
|
form_element_types = [
|
||||||
element_type
|
element_type
|
||||||
for element_type in element_types
|
for element_type in element_types
|
||||||
if isinstance(element_type, FormElementType)
|
if isinstance(element_type, FormElementTypeMixin)
|
||||||
]
|
]
|
||||||
other_element_types = [
|
other_element_types = [
|
||||||
element_type
|
element_type
|
||||||
for element_type in element_types
|
for element_type in element_types
|
||||||
if not isinstance(element_type, ContainerElementType)
|
if not isinstance(element_type, ContainerElementTypeMixin)
|
||||||
and not isinstance(element_type, FormElementType)
|
and not isinstance(element_type, FormElementTypeMixin)
|
||||||
]
|
]
|
||||||
manual_ordering = container_element_types + form_element_types + other_element_types
|
manual_ordering = container_element_types + form_element_types + other_element_types
|
||||||
expected_ordering = sorted(
|
expected_ordering = sorted(
|
||||||
|
|
|
@ -368,61 +368,121 @@ export class ElementType extends Registerable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ContainerElementType extends ElementType {
|
const ContainerElementTypeMixin = (Base) =>
|
||||||
get elementTypesAll() {
|
class extends Base {
|
||||||
return Object.values(this.app.$registry.getAll('element'))
|
isContainerElementType = true
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
get elementTypesAll() {
|
||||||
* Returns an array of element types that are not allowed as children of this element.
|
return Object.values(this.app.$registry.getAll('element'))
|
||||||
*
|
|
||||||
* @returns {Array}
|
|
||||||
*/
|
|
||||||
get childElementTypesForbidden() {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
get childElementTypes() {
|
|
||||||
return _.difference(this.elementTypesAll, this.childElementTypesForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an array of style types that are not allowed as children of this element.
|
|
||||||
* @returns {Array}
|
|
||||||
*/
|
|
||||||
get childStylesForbidden() {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
get defaultPlaceInContainer() {
|
|
||||||
throw new Error('Not implemented')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the default value when creating a child element to this container.
|
|
||||||
* @param {Object} page The current page object
|
|
||||||
* @param {Object} values The values of the to be created element
|
|
||||||
* @returns the default values for this element.
|
|
||||||
*/
|
|
||||||
getDefaultChildValues(page, values) {
|
|
||||||
// By default an element inside a container should have no left and right padding
|
|
||||||
return { style_padding_left: 0, style_padding_right: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a `page` and an `element`, move the child element of a container
|
|
||||||
* in the direction specified by the `placement`.
|
|
||||||
*
|
|
||||||
* The default implementation only supports moving the element vertically.
|
|
||||||
*
|
|
||||||
* @param {Object} page The page that is the parent component.
|
|
||||||
* @param {Number} element The child element to be moved.
|
|
||||||
* @param {String} placement The direction in which the element should move.
|
|
||||||
*/
|
|
||||||
async moveChildElement(page, parentElement, element, placement) {
|
|
||||||
if (placement === PLACEMENTS.AFTER || placement === PLACEMENTS.BEFORE) {
|
|
||||||
await this.moveElementInSamePlace(page, element, placement)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of element types that are not allowed as children of this element.
|
||||||
|
*
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
get childElementTypesForbidden() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
get childElementTypes() {
|
||||||
|
return _.difference(this.elementTypesAll, this.childElementTypesForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of style types that are not allowed as children of this element.
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
get childStylesForbidden() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
get defaultPlaceInContainer() {
|
||||||
|
throw new Error('Not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the default value when creating a child element to this container.
|
||||||
|
* @param {Object} page The current page object
|
||||||
|
* @param {Object} values The values of the to be created element
|
||||||
|
* @returns the default values for this element.
|
||||||
|
*/
|
||||||
|
getDefaultChildValues(page, values) {
|
||||||
|
// By default, an element inside a container should have no left and right padding
|
||||||
|
return { style_padding_left: 0, style_padding_right: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a `page` and an `element`, move the child element of a container
|
||||||
|
* in the direction specified by the `placement`.
|
||||||
|
*
|
||||||
|
* The default implementation only supports moving the element vertically.
|
||||||
|
*
|
||||||
|
* @param {Object} page The page that is the parent component.
|
||||||
|
* @param {Number} element The child element to be moved.
|
||||||
|
* @param {String} placement The direction in which the element should move.
|
||||||
|
*/
|
||||||
|
async moveChildElement(page, parentElement, element, placement) {
|
||||||
|
if (placement === PLACEMENTS.AFTER || placement === PLACEMENTS.BEFORE) {
|
||||||
|
await this.moveElementInSamePlace(page, element, placement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return an array of placements that are disallowed for the elements to move
|
||||||
|
* in their container.
|
||||||
|
*
|
||||||
|
* @param {Object} page The page that is the parent component.
|
||||||
|
* @param {Number} element The child element for which the placements should
|
||||||
|
* be calculated.
|
||||||
|
* @returns {Array} An array of placements that are disallowed for the element.
|
||||||
|
*/
|
||||||
|
getPlacementsDisabledForChild(page, containerElement, element) {
|
||||||
|
this.getPlacementsDisabled(page, element)
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextHorizontalElementToSelect(page, element, placement) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FormContainerElementType extends ContainerElementTypeMixin(
|
||||||
|
ElementType
|
||||||
|
) {
|
||||||
|
static getType() {
|
||||||
|
return 'form_container'
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.app.i18n.t('elementType.formContainer')
|
||||||
|
}
|
||||||
|
|
||||||
|
get description() {
|
||||||
|
return this.app.i18n.t('elementType.formContainerDescription')
|
||||||
|
}
|
||||||
|
|
||||||
|
get iconClass() {
|
||||||
|
return 'iconoir-frame'
|
||||||
|
}
|
||||||
|
|
||||||
|
get component() {
|
||||||
|
return FormContainerElement
|
||||||
|
}
|
||||||
|
|
||||||
|
get generalFormComponent() {
|
||||||
|
return FormContainerElementForm
|
||||||
|
}
|
||||||
|
|
||||||
|
get childElementTypesForbidden() {
|
||||||
|
return this.elementTypesAll.filter((type) => !type.isFormElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
get events() {
|
||||||
|
return [SubmitEvent]
|
||||||
|
}
|
||||||
|
|
||||||
|
get childStylesForbidden() {
|
||||||
|
return ['style_width']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -435,15 +495,15 @@ export class ContainerElementType extends ElementType {
|
||||||
* @returns {Array} An array of placements that are disallowed for the element.
|
* @returns {Array} An array of placements that are disallowed for the element.
|
||||||
*/
|
*/
|
||||||
getPlacementsDisabledForChild(page, containerElement, element) {
|
getPlacementsDisabledForChild(page, containerElement, element) {
|
||||||
super.getPlacementsDisabled(page, element)
|
return [
|
||||||
}
|
PLACEMENTS.LEFT,
|
||||||
|
PLACEMENTS.RIGHT,
|
||||||
getNextHorizontalElementToSelect(page, element, placement) {
|
...this.getVerticalPlacementsDisabled(page, element),
|
||||||
return null
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ColumnElementType extends ContainerElementType {
|
export class ColumnElementType extends ContainerElementTypeMixin(ElementType) {
|
||||||
getType() {
|
getType() {
|
||||||
return 'column'
|
return 'column'
|
||||||
}
|
}
|
||||||
|
@ -470,7 +530,7 @@ export class ColumnElementType extends ContainerElementType {
|
||||||
|
|
||||||
get childElementTypesForbidden() {
|
get childElementTypesForbidden() {
|
||||||
return this.elementTypesAll.filter(
|
return this.elementTypesAll.filter(
|
||||||
(elementType) => elementType instanceof ContainerElementType
|
(elementType) => elementType.isContainerElementType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -566,8 +626,110 @@ export class ColumnElementType extends ContainerElementType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CollectionElementTypeMixin = (Base) => class extends Base {}
|
||||||
|
|
||||||
|
export class TableElementType extends CollectionElementTypeMixin(ElementType) {
|
||||||
|
getType() {
|
||||||
|
return 'table'
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.app.i18n.t('elementType.table')
|
||||||
|
}
|
||||||
|
|
||||||
|
get description() {
|
||||||
|
return this.app.i18n.t('elementType.tableDescription')
|
||||||
|
}
|
||||||
|
|
||||||
|
get iconClass() {
|
||||||
|
return 'iconoir-table'
|
||||||
|
}
|
||||||
|
|
||||||
|
get component() {
|
||||||
|
return TableElement
|
||||||
|
}
|
||||||
|
|
||||||
|
get generalFormComponent() {
|
||||||
|
return TableElementForm
|
||||||
|
}
|
||||||
|
|
||||||
|
async onElementEvent(event, { page, element, dataSourceId }) {
|
||||||
|
if (event === ELEMENT_EVENTS.DATA_SOURCE_REMOVED) {
|
||||||
|
if (element.data_source_id === dataSourceId) {
|
||||||
|
// Remove the data_source_id
|
||||||
|
await this.app.store.dispatch('element/forceUpdate', {
|
||||||
|
page,
|
||||||
|
element,
|
||||||
|
values: { data_source_id: null },
|
||||||
|
})
|
||||||
|
// Empty the element content
|
||||||
|
await this.app.store.dispatch('elementContent/clearElementContent', {
|
||||||
|
element,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event === ELEMENT_EVENTS.DATA_SOURCE_AFTER_UPDATE) {
|
||||||
|
if (element.data_source_id === dataSourceId) {
|
||||||
|
await this.app.store.dispatch(
|
||||||
|
'elementContent/triggerElementContentReset',
|
||||||
|
{
|
||||||
|
element,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isInError({ element, builder }) {
|
||||||
|
const collectionFieldsInError = element.fields.map((collectionField) => {
|
||||||
|
const collectionFieldType = this.app.$registry.get(
|
||||||
|
'collectionField',
|
||||||
|
collectionField.type
|
||||||
|
)
|
||||||
|
return collectionFieldType.isInError({
|
||||||
|
field: collectionField,
|
||||||
|
builder,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return collectionFieldsInError.includes(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayName(element, { page }) {
|
||||||
|
let suffix = ''
|
||||||
|
|
||||||
|
if (element.data_source_id) {
|
||||||
|
const dataSource = this.app.store.getters[
|
||||||
|
'dataSource/getPageDataSourceById'
|
||||||
|
](page, element.data_source_id)
|
||||||
|
|
||||||
|
suffix = dataSource ? ` - ${dataSource.name}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${this.name}${suffix}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RepeatElementType extends ContainerElementTypeMixin(
|
||||||
|
CollectionElementTypeMixin(ElementType)
|
||||||
|
) {
|
||||||
|
getType() {
|
||||||
|
return 'repeat'
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.app.i18n.t('elementType.repeat')
|
||||||
|
}
|
||||||
|
|
||||||
|
get description() {
|
||||||
|
return this.app.i18n.t('elementType.repeatDescription')
|
||||||
|
}
|
||||||
|
|
||||||
|
get iconClass() {
|
||||||
|
return 'iconoir-repeat'
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This class servers as a parent class for all form element types. Form element types
|
* This class serves as a parent class for all form element types. Form element types
|
||||||
* are all elements that can be used as part of a form. So in simple terms, any element
|
* are all elements that can be used as part of a form. So in simple terms, any element
|
||||||
* that can represents data in a way that is directly modifiable by an application user.
|
* that can represents data in a way that is directly modifiable by an application user.
|
||||||
*/
|
*/
|
||||||
|
@ -918,87 +1080,6 @@ export class ButtonElementType extends ElementType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TableElementType extends ElementType {
|
|
||||||
getType() {
|
|
||||||
return 'table'
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return this.app.i18n.t('elementType.table')
|
|
||||||
}
|
|
||||||
|
|
||||||
get description() {
|
|
||||||
return this.app.i18n.t('elementType.tableDescription')
|
|
||||||
}
|
|
||||||
|
|
||||||
get iconClass() {
|
|
||||||
return 'iconoir-table'
|
|
||||||
}
|
|
||||||
|
|
||||||
get component() {
|
|
||||||
return TableElement
|
|
||||||
}
|
|
||||||
|
|
||||||
get generalFormComponent() {
|
|
||||||
return TableElementForm
|
|
||||||
}
|
|
||||||
|
|
||||||
async onElementEvent(event, { page, element, dataSourceId }) {
|
|
||||||
if (event === ELEMENT_EVENTS.DATA_SOURCE_REMOVED) {
|
|
||||||
if (element.data_source_id === dataSourceId) {
|
|
||||||
// Remove the data_source_id
|
|
||||||
await this.app.store.dispatch('element/forceUpdate', {
|
|
||||||
page,
|
|
||||||
element,
|
|
||||||
values: { data_source_id: null },
|
|
||||||
})
|
|
||||||
// Empty the element content
|
|
||||||
await this.app.store.dispatch('elementContent/clearElementContent', {
|
|
||||||
element,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event === ELEMENT_EVENTS.DATA_SOURCE_AFTER_UPDATE) {
|
|
||||||
if (element.data_source_id === dataSourceId) {
|
|
||||||
await this.app.store.dispatch(
|
|
||||||
'elementContent/triggerElementContentReset',
|
|
||||||
{
|
|
||||||
element,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isInError({ element, builder }) {
|
|
||||||
const collectionFieldsInError = element.fields.map((collectionField) => {
|
|
||||||
const collectionFieldType = this.app.$registry.get(
|
|
||||||
'collectionField',
|
|
||||||
collectionField.type
|
|
||||||
)
|
|
||||||
return collectionFieldType.isInError({
|
|
||||||
field: collectionField,
|
|
||||||
builder,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return collectionFieldsInError.includes(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
getDisplayName(element, { page }) {
|
|
||||||
let suffix = ''
|
|
||||||
|
|
||||||
if (element.data_source_id) {
|
|
||||||
const dataSource = this.app.store.getters[
|
|
||||||
'dataSource/getPageDataSourceById'
|
|
||||||
](page, element.data_source_id)
|
|
||||||
|
|
||||||
suffix = dataSource ? ` - ${dataSource.name}` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${this.name}${suffix}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DropdownElementType extends FormElementType {
|
export class DropdownElementType extends FormElementType {
|
||||||
static getType() {
|
static getType() {
|
||||||
return 'dropdown'
|
return 'dropdown'
|
||||||
|
@ -1064,61 +1145,6 @@ export class DropdownElementType extends FormElementType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FormContainerElementType extends ContainerElementType {
|
|
||||||
static getType() {
|
|
||||||
return 'form_container'
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return this.app.i18n.t('elementType.formContainer')
|
|
||||||
}
|
|
||||||
|
|
||||||
get description() {
|
|
||||||
return this.app.i18n.t('elementType.formContainerDescription')
|
|
||||||
}
|
|
||||||
|
|
||||||
get iconClass() {
|
|
||||||
return 'iconoir-frame'
|
|
||||||
}
|
|
||||||
|
|
||||||
get component() {
|
|
||||||
return FormContainerElement
|
|
||||||
}
|
|
||||||
|
|
||||||
get generalFormComponent() {
|
|
||||||
return FormContainerElementForm
|
|
||||||
}
|
|
||||||
|
|
||||||
get childElementTypesForbidden() {
|
|
||||||
return this.elementTypesAll.filter((type) => !type.isFormElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
get events() {
|
|
||||||
return [SubmitEvent]
|
|
||||||
}
|
|
||||||
|
|
||||||
get childStylesForbidden() {
|
|
||||||
return ['style_width']
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return an array of placements that are disallowed for the elements to move
|
|
||||||
* in their container.
|
|
||||||
*
|
|
||||||
* @param {Object} page The page that is the parent component.
|
|
||||||
* @param {Number} element The child element for which the placements should
|
|
||||||
* be calculated.
|
|
||||||
* @returns {Array} An array of placements that are disallowed for the element.
|
|
||||||
*/
|
|
||||||
getPlacementsDisabledForChild(page, containerElement, element) {
|
|
||||||
return [
|
|
||||||
PLACEMENTS.LEFT,
|
|
||||||
PLACEMENTS.RIGHT,
|
|
||||||
...this.getVerticalPlacementsDisabled(page, element),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CheckboxElementType extends FormElementType {
|
export class CheckboxElementType extends FormElementType {
|
||||||
getType() {
|
getType() {
|
||||||
return 'checkbox'
|
return 'checkbox'
|
||||||
|
|
|
@ -93,7 +93,9 @@
|
||||||
"iframe": "IFrame",
|
"iframe": "IFrame",
|
||||||
"iframeDescription": "Inline frame",
|
"iframeDescription": "Inline frame",
|
||||||
"authForm": "Login form",
|
"authForm": "Login form",
|
||||||
"authFormDescription": "A user login form"
|
"authFormDescription": "A user login form",
|
||||||
|
"repeat": "Repeat",
|
||||||
|
"repeatDescription": "A repeatable set of elements"
|
||||||
},
|
},
|
||||||
"addElementButton": {
|
"addElementButton": {
|
||||||
"label": "Element"
|
"label": "Element"
|
||||||
|
|
|
@ -38,6 +38,7 @@ import {
|
||||||
DropdownElementType,
|
DropdownElementType,
|
||||||
CheckboxElementType,
|
CheckboxElementType,
|
||||||
IFrameElementType,
|
IFrameElementType,
|
||||||
|
RepeatElementType,
|
||||||
} from '@baserow/modules/builder/elementTypes'
|
} from '@baserow/modules/builder/elementTypes'
|
||||||
import {
|
import {
|
||||||
DesktopDeviceType,
|
DesktopDeviceType,
|
||||||
|
@ -171,6 +172,10 @@ export default (context) => {
|
||||||
app.$registry.register('element', new DropdownElementType(context))
|
app.$registry.register('element', new DropdownElementType(context))
|
||||||
app.$registry.register('element', new CheckboxElementType(context))
|
app.$registry.register('element', new CheckboxElementType(context))
|
||||||
|
|
||||||
|
if (app.$featureFlagIsEnabled('builder-repeat-element')) {
|
||||||
|
app.$registry.register('element', new RepeatElementType(context))
|
||||||
|
}
|
||||||
|
|
||||||
app.$registry.register('device', new DesktopDeviceType(context))
|
app.$registry.register('device', new DesktopDeviceType(context))
|
||||||
app.$registry.register('device', new TabletDeviceType(context))
|
app.$registry.register('device', new TabletDeviceType(context))
|
||||||
app.$registry.register('device', new SmartphoneDeviceType(context))
|
app.$registry.register('device', new SmartphoneDeviceType(context))
|
||||||
|
|
Loading…
Add table
Reference in a new issue