diff --git a/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py b/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py index 610186679..92cb63c2d 100644 --- a/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py +++ b/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py @@ -12,8 +12,8 @@ from baserow.contrib.builder.data_sources.exceptions import ( DataSourceImproperlyConfigured, ) 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.mixins import FormElementTypeMixin from baserow.contrib.builder.elements.models import FormElement from baserow.contrib.builder.workflow_actions.handler import ( BuilderWorkflowActionHandler, @@ -62,7 +62,7 @@ class FormDataProviderType(DataProviderType): """ 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): raise FormDataProviderChunkInvalidException( f"Form data {data_chunk} is invalid for its element." diff --git a/backend/src/baserow/contrib/builder/elements/element_types.py b/backend/src/baserow/contrib/builder/elements/element_types.py index 9b288f3da..965a6f82e 100644 --- a/backend/src/baserow/contrib/builder/elements/element_types.py +++ b/backend/src/baserow/contrib/builder/elements/element_types.py @@ -1,33 +1,30 @@ import abc -from abc import ABC -from typing import Any, Dict, List, Optional, Type, TypedDict +from typing import Any, Dict, List, Optional, TypedDict from django.core.exceptions import ValidationError from django.core.validators import validate_email, validate_integer from django.db.models import IntegerField, QuerySet from django.db.models.functions import Cast -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.api.elements.serializers import DropdownOptionSerializer -from baserow.contrib.builder.data_sources.handler import DataSourceHandler -from baserow.contrib.builder.elements.handler import ElementHandler +from baserow.contrib.builder.elements.mixins import ( + CollectionElementTypeMixin, + ContainerElementTypeMixin, + FormElementTypeMixin, +) from baserow.contrib.builder.elements.models import ( INPUT_TEXT_TYPES, WIDTHS, ButtonElement, CheckboxElement, - CollectionField, ColumnElement, - ContainerElement, DropdownElement, DropdownElementOption, Element, FormContainerElement, - FormElement, HeadingElement, HorizontalAlignments, IFrameElement, @@ -43,7 +40,6 @@ from baserow.contrib.builder.elements.registries import ( ElementType, 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.pages.handler import PageHandler 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.registry import T -from .registries import collection_field_type_registry - -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): +class ColumnElementType(ContainerElementTypeMixin, ElementType): """ A column element is a container element that can be used to display other elements 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): """ 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) -class InputElementType(FormElementType, abc.ABC): +class InputElementType(FormElementTypeMixin, ElementType, abc.ABC): pass @@ -1113,105 +915,6 @@ class ButtonElementType(ElementType): 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): type = "checkbox" model_class = CheckboxElement @@ -1270,7 +973,7 @@ class CheckboxElementType(InputElementType): } -class DropdownElementType(FormElementType): +class DropdownElementType(FormElementTypeMixin, ElementType): type = "dropdown" model_class = DropdownElement allowed_fields = ["label", "default_value", "required", "placeholder"] diff --git a/backend/src/baserow/contrib/builder/elements/mixins.py b/backend/src/baserow/contrib/builder/elements/mixins.py new file mode 100644 index 000000000..4bdcdf80b --- /dev/null +++ b/backend/src/baserow/contrib/builder/elements/mixins.py @@ -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) diff --git a/backend/tests/baserow/contrib/builder/elements/test_element_types.py b/backend/tests/baserow/contrib/builder/elements/test_element_types.py index 8fd504295..b1491e83f 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_element_types.py +++ b/backend/tests/baserow/contrib/builder/elements/test_element_types.py @@ -5,13 +5,15 @@ from rest_framework.exceptions import ValidationError from baserow.contrib.builder.elements.element_types import ( CheckboxElementType, - ContainerElementType, DropdownElementType, - FormElementType, IFrameElementType, InputTextElementType, ) from baserow.contrib.builder.elements.handler import ElementHandler +from baserow.contrib.builder.elements.mixins import ( + ContainerElementTypeMixin, + FormElementTypeMixin, +) from baserow.contrib.builder.elements.models import ( CheckboxElement, DropdownElementOption, @@ -232,18 +234,18 @@ def test_element_type_import_element_priority(): container_element_types = [ element_type for element_type in element_types - if isinstance(element_type, ContainerElementType) + if isinstance(element_type, ContainerElementTypeMixin) ] form_element_types = [ element_type for element_type in element_types - if isinstance(element_type, FormElementType) + if isinstance(element_type, FormElementTypeMixin) ] other_element_types = [ element_type for element_type in element_types - if not isinstance(element_type, ContainerElementType) - and not isinstance(element_type, FormElementType) + if not isinstance(element_type, ContainerElementTypeMixin) + and not isinstance(element_type, FormElementTypeMixin) ] manual_ordering = container_element_types + form_element_types + other_element_types expected_ordering = sorted( diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js index fcbfb2efe..6efb62474 100644 --- a/web-frontend/modules/builder/elementTypes.js +++ b/web-frontend/modules/builder/elementTypes.js @@ -368,61 +368,121 @@ export class ElementType extends Registerable { } } -export class ContainerElementType extends ElementType { - get elementTypesAll() { - return Object.values(this.app.$registry.getAll('element')) - } +const ContainerElementTypeMixin = (Base) => + class extends Base { + isContainerElementType = true - /** - * 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) + get elementTypesAll() { + return Object.values(this.app.$registry.getAll('element')) } + + /** + * 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. */ getPlacementsDisabledForChild(page, containerElement, element) { - super.getPlacementsDisabled(page, element) - } - - getNextHorizontalElementToSelect(page, element, placement) { - return null + return [ + PLACEMENTS.LEFT, + PLACEMENTS.RIGHT, + ...this.getVerticalPlacementsDisabled(page, element), + ] } } -export class ColumnElementType extends ContainerElementType { +export class ColumnElementType extends ContainerElementTypeMixin(ElementType) { getType() { return 'column' } @@ -470,7 +530,7 @@ export class ColumnElementType extends ContainerElementType { get childElementTypesForbidden() { 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 * 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 { static getType() { 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 { getType() { return 'checkbox' diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json index b7334c1d6..570917c4b 100644 --- a/web-frontend/modules/builder/locales/en.json +++ b/web-frontend/modules/builder/locales/en.json @@ -93,7 +93,9 @@ "iframe": "IFrame", "iframeDescription": "Inline frame", "authForm": "Login form", - "authFormDescription": "A user login form" + "authFormDescription": "A user login form", + "repeat": "Repeat", + "repeatDescription": "A repeatable set of elements" }, "addElementButton": { "label": "Element" diff --git a/web-frontend/modules/builder/plugin.js b/web-frontend/modules/builder/plugin.js index 6e072c798..5f55c2436 100644 --- a/web-frontend/modules/builder/plugin.js +++ b/web-frontend/modules/builder/plugin.js @@ -38,6 +38,7 @@ import { DropdownElementType, CheckboxElementType, IFrameElementType, + RepeatElementType, } from '@baserow/modules/builder/elementTypes' import { DesktopDeviceType, @@ -171,6 +172,10 @@ export default (context) => { app.$registry.register('element', new DropdownElementType(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 TabletDeviceType(context)) app.$registry.register('device', new SmartphoneDeviceType(context))