1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 17:18:33 +00:00

Resolve "Container Elements - Column Element"

This commit is contained in:
Alexander Haller 2023-08-08 13:28:03 +00:00
parent 67c059c3a0
commit 5fc28f50c8
73 changed files with 2954 additions and 408 deletions
backend
web-frontend

View file

@ -52,7 +52,15 @@ class PublicElementSerializer(serializers.ModelSerializer):
class Meta:
model = Element
fields = ("id", "type", "style_padding_top", "style_padding_bottom")
fields = (
"id",
"type",
"order",
"parent_element_id",
"place_in_container",
"style_padding_top",
"style_padding_bottom",
)
extra_kwargs = {
"id": {"read_only": True},
"type": {"read_only": True},

View file

@ -27,6 +27,8 @@ class ElementSerializer(serializers.ModelSerializer):
"page_id",
"type",
"order",
"parent_element_id",
"place_in_container",
"style_padding_top",
"style_padding_bottom",
)
@ -54,16 +56,33 @@ class CreateElementSerializer(serializers.ModelSerializer):
help_text="If provided, creates the element before the element with the "
"given id.",
)
parent_element_id = serializers.IntegerField(
allow_null=True,
required=False,
help_text="If provided, creates the element as a child of the element with "
"the given id.",
)
class Meta:
model = Element
fields = ("before_id", "type", "style_padding_top", "style_padding_bottom")
fields = (
"order",
"before_id",
"type",
"parent_element_id",
"place_in_container",
"style_padding_top",
"style_padding_bottom",
)
class UpdateElementSerializer(serializers.ModelSerializer):
class Meta:
model = Element
fields = ("style_padding_top", "style_padding_bottom")
fields = (
"style_padding_top",
"style_padding_bottom",
)
class MoveElementSerializer(serializers.Serializer):
@ -75,6 +94,19 @@ class MoveElementSerializer(serializers.Serializer):
"Otherwise the element is placed at the end of the page."
),
)
parent_element_id = serializers.IntegerField(
allow_null=True,
required=False,
default=None,
help_text="If provided, the element is moved as a child of the element with "
"the given id.",
)
place_in_container = serializers.CharField(
required=False,
allow_null=True,
default=None,
help_text="The place in the container.",
)
class PageParameterValueSerializer(serializers.Serializer):

View file

@ -306,12 +306,20 @@ class MoveElementView(APIView):
element = ElementHandler().get_element_for_update(element_id)
before_id = data.get("before_id", None)
parent_element_id = data.get("parent_element_id", element.parent_element_id)
place_in_container = data.get("place_in_container", element.place_in_container)
before = None
if before_id:
if before_id is not None:
before = ElementHandler().get_element(before_id)
moved_element = ElementService().move_element(request.user, element, before)
parent_element = None
if parent_element_id is not None:
parent_element = ElementHandler().get_element(parent_element_id)
moved_element = ElementService().move_element(
request.user, element, parent_element, place_in_container, before
)
serializer = element_type_registry.get_serializer(
moved_element, ElementSerializer

View file

@ -13,7 +13,9 @@ from baserow.contrib.builder.api.serializers import BuilderSerializer
from baserow.contrib.builder.data_sources.handler import DataSourceHandler
from baserow.contrib.builder.data_sources.models import DataSource
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.elements.types import ElementDictSubClass
from baserow.contrib.builder.models import Builder
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.pages.service import PageService
@ -261,6 +263,9 @@ class BuilderApplicationType(ApplicationType):
if "builder_pages" not in id_mapping:
id_mapping["builder_pages"] = {}
if "builder_page_elements" not in id_mapping:
id_mapping["builder_page_elements"] = {}
if "workspace_id" not in id_mapping and builder.workspace is not None:
id_mapping["workspace_id"] = builder.workspace.id
@ -286,13 +291,12 @@ class BuilderApplicationType(ApplicationType):
# Then we create all the element instances.
for serialized_page in serialized_pages:
for serialized_element in serialized_page["elements"]:
element_type = element_type_registry.get(serialized_element["type"])
element_instance = element_type.import_serialized(
serialized_page["_object"], serialized_element, id_mapping
self.import_element(
serialized_element,
serialized_page,
id_mapping,
)
serialized_page["_element_objects"].append(element_instance)
progress.increment(state=IMPORT_SERIALIZED_IMPORTING)
# Then we create all the datasource instances.
@ -330,6 +334,67 @@ class BuilderApplicationType(ApplicationType):
return imported_pages
def import_element(
self,
serialized_element: ElementDictSubClass,
serialized_page: Dict,
id_mapping: Dict,
) -> Element:
"""
This is a recursive function that will create all the parent elements of
an element before it creates the element itself.
This is important since an element depends on its parent which in turn depends
on its parent, therefore we need to make sure to create elements in an order
where we go from "top to bottom" in the hierarchy.
:param serialized_element: The element we are trying to create
:param serialized_page: The page we are creating the elements in
:param id_mapping: The mapping of old ids to new ids
:return: The created element
"""
serialized_elements = serialized_page["elements"]
page = serialized_page["_object"]
instance_id = id_mapping["builder_page_elements"].get(
serialized_element["id"], None
)
# The element has already been created, and we just need to return it
if instance_id is not None:
for element in serialized_page["_element_objects"]:
if element.id == instance_id:
return element
# The element has not been created and it has a parent that potentially has
# to be created first
parent_element_id = serialized_element["parent_element_id"]
if (
parent_element_id is not None
and parent_element_id not in id_mapping["builder_page_elements"]
):
for element in serialized_elements:
if element["id"] == parent_element_id:
self.import_element(element, serialized_page, id_mapping)
# The element either has no parents or they were all created already
serialized_element["parent_element_id"] = id_mapping[
"builder_page_elements"
].get(serialized_element["parent_element_id"], None)
element_type = element_type_registry.get(serialized_element["type"])
element_imported = element_type.import_serialized(
page, serialized_element, id_mapping
)
id_mapping["builder_page_elements"][
serialized_element["id"]
] = element_imported.id
serialized_page["_element_objects"].append(element_imported)
return element_imported
def import_serialized(
self,
workspace: Workspace,

View file

@ -133,6 +133,7 @@ class BuilderConfig(AppConfig):
permission_manager_type_registry.register(AllowPublicBuilderManagerType())
from .elements.element_types import (
ColumnElementType,
HeadingElementType,
ImageElementType,
InputTextElementType,
@ -146,6 +147,7 @@ class BuilderConfig(AppConfig):
element_type_registry.register(LinkElementType())
element_type_registry.register(ImageElementType())
element_type_registry.register(InputTextElementType())
element_type_registry.register(ColumnElementType())
from .domains.trash_types import DomainTrashableItemType

View file

@ -2,6 +2,4 @@ from typing import NewType
from .models import DataSource
Expression = str
DataSourceForUpdate = NewType("DataSourceForUpdate", DataSource)

View file

@ -1,26 +1,182 @@
import abc
from typing import Dict, List, Optional
from abc import ABC
from typing import Any, Dict, List, Optional
from django.db.models import IntegerField, QuerySet
from django.db.models.functions import Cast
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from baserow.api.user_files.serializers import UserFileField, UserFileSerializer
from baserow.contrib.builder.api.validators import image_file_validation
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.models import (
ALIGNMENTS,
ColumnElement,
ContainerElement,
Element,
HeadingElement,
HorizontalAlignments,
ImageElement,
InputTextElement,
LinkElement,
ParagraphElement,
VerticalAlignments,
)
from baserow.contrib.builder.elements.registries import ElementType
from baserow.contrib.builder.elements.signals import elements_moved
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.types import ElementDict
from baserow.core.formula.types import BaserowFormula
class ContainerElementType(ElementType, ABC):
@abc.abstractmethod
def get_new_place_in_container(
self, container_element: ContainerElement, places_removed: List[str]
) -> 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
"""
pass
@abc.abstractmethod
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
"""
pass
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 ValidationError: If the place in container is invalid
"""
pass
class ColumnElementType(ContainerElementType):
"""
A column element is a container element that can be used to display other elements
in a column.
"""
type = "column"
model_class = ColumnElement
class SerializedDict(ElementDict):
column_amount: int
column_gap: int
alignment: str
@property
def serializer_field_names(self):
return super().serializer_field_names + [
"column_amount",
"column_gap",
"alignment",
]
@property
def allowed_fields(self):
return super().allowed_fields + [
"column_amount",
"column_gap",
"alignment",
]
def get_sample_params(self) -> Dict[str, Any]:
return {
"column_amount": 2,
"column_gap": 10,
"alignment": VerticalAlignments.TOP,
}
def get_new_place_in_container(
self, container_element_before_update: ColumnElement, places_removed: List[str]
) -> int:
places_removed_casted = [int(place) for place in places_removed]
if len(places_removed) == 0:
return container_element_before_update.column_amount - 1
return min(places_removed_casted) - 1
def get_places_in_container_removed(
self, values: Dict, instance: ColumnElement
) -> List[str]:
column_amount = values.get("column_amount", None)
if column_amount is None:
return []
places_removed = list(range(column_amount, instance.column_amount))
return [str(place) for place in places_removed]
def apply_order_by_children(self, queryset: QuerySet[Element]) -> QuerySet[Element]:
return queryset.annotate(
place_in_container_as_int=Cast(
"place_in_container", output_field=IntegerField()
)
).order_by("place_in_container_as_int", "order")
def validate_place_in_container(
self, place_in_container: str, instance: ColumnElement
):
max_place_in_container = instance.column_amount - 1
if int(place_in_container) > max_place_in_container:
raise ValidationError(
f"place_in_container can at most be {max_place_in_container}, ({place_in_container}, was given)"
)
class HeadingElementType(ElementType):
"""
A simple heading element that can be used to display a title.
@ -41,7 +197,7 @@ class HeadingElementType(ElementType):
overrides = {
"value": FormulaSerializerField(
help_text="The value of the element. Must be an expression.",
help_text="The value of the element. Must be an formula.",
required=False,
allow_blank=True,
default="",
@ -159,7 +315,7 @@ class LinkElementType(ElementType):
overrides = {
"value": FormulaSerializerField(
help_text="The value of the element. Must be an expression.",
help_text="The value of the element. Must be an formula.",
required=False,
allow_blank=True,
default="",
@ -202,7 +358,7 @@ class LinkElementType(ElementType):
required=False,
),
"alignment": serializers.ChoiceField(
choices=ALIGNMENTS.choices,
choices=HorizontalAlignments.choices,
help_text=LinkElement._meta.get_field("alignment").help_text,
required=False,
),
@ -239,7 +395,7 @@ class LinkElementType(ElementType):
self._raise_if_path_params_are_invalid(page_params, page)
return values
return super().prepare_value_for_db(values, instance)
def _raise_if_path_params_are_invalid(self, path_params: Dict, page: Page) -> None:
"""
@ -306,7 +462,7 @@ class ImageElementType(ElementType):
"image_file_id": None,
"image_url": "https://test.com/image.png",
"alt_text": "some alt text",
"alignment": ALIGNMENTS.LEFT,
"alignment": HorizontalAlignments.LEFT,
}
@property
@ -329,7 +485,7 @@ class ImageElementType(ElementType):
validators=[image_file_validation],
),
"alignment": serializers.ChoiceField(
choices=ALIGNMENTS.choices,
choices=HorizontalAlignments.choices,
help_text=ImageElement._meta.get_field("alignment").help_text,
required=False,
),

View file

@ -1,12 +1,19 @@
from typing import Iterable, Optional, Union, cast
from typing import Iterable, List, Optional, Union, cast
from django.db.models import QuerySet
from baserow.contrib.builder.elements.exceptions import ElementDoesNotExist
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.registries import ElementType
from baserow.contrib.builder.elements.exceptions import (
ElementDoesNotExist,
ElementNotInSamePage,
)
from baserow.contrib.builder.elements.models import ContainerElement, Element
from baserow.contrib.builder.elements.registries import (
ElementType,
element_type_registry,
)
from baserow.contrib.builder.pages.models import Page
from baserow.core.db import specific_iterator
from baserow.core.exceptions import IdDoesNotExist
from baserow.core.utils import extract_allowed
from .types import ElementForUpdate
@ -88,13 +95,21 @@ class ElementHandler:
return queryset
def create_element(
self, element_type: ElementType, page: Page, before=None, **kwargs
self,
element_type: ElementType,
page: Page,
before: Optional[Element] = None,
order: Optional[int] = None,
**kwargs
) -> Element:
"""
Creates a new element for a page.
:param element_type: The type of the element.
:param page: The page the element exists in.
:param order: If set, the new element is inserted at this order ignoring before.
:param before: If provided and no order is provided, will place the new element
before the given element.
:param kwargs: Additional attributes of the element.
:raises CannotCalculateIntermediateOrder: If it's not possible to find an
intermediate order. The full order of the element of the page must be
@ -102,12 +117,26 @@ class ElementHandler:
:return: The created element.
"""
if before:
order = Element.get_unique_order_before_element(before)
else:
order = Element.get_last_order(page)
parent_element_id = kwargs.get("parent_element_id", None)
place_in_container = kwargs.get("place_in_container", None)
shared_allowed_fields = ["type", "style_padding_top", "style_padding_bottom"]
if order is None:
if before:
order = Element.get_unique_order_before_element(
before, parent_element_id, place_in_container
)
else:
order = Element.get_last_order(
page, parent_element_id, place_in_container
)
shared_allowed_fields = [
"type",
"parent_element_id",
"place_in_container",
"style_padding_top",
"style_padding_bottom",
]
allowed_values = extract_allowed(
kwargs, shared_allowed_fields + element_type.allowed_fields
)
@ -140,7 +169,12 @@ class ElementHandler:
:return: The updated element.
"""
shared_allowed_fields = ["style_padding_top", "style_padding_bottom"]
shared_allowed_fields = [
"parent_element_id",
"place_in_container",
"style_padding_top",
"style_padding_bottom",
]
allowed_updates = extract_allowed(
kwargs, shared_allowed_fields + element.get_type().allowed_fields
)
@ -157,7 +191,11 @@ class ElementHandler:
return element
def move_element(
self, element: ElementForUpdate, before: Optional[Element] = None
self,
element: ElementForUpdate,
parent_element: Optional[Element],
place_in_container: str,
before: Optional[Element] = None,
) -> Element:
"""
Moves the given element before the specified `before` element in the same page.
@ -165,21 +203,113 @@ class ElementHandler:
:param element: The element to move.
:param before: The element before which to move the `element`. If before is not
specified, the element is moved at the end of the list.
:param parent_element: The new parent element of the element.
:param place_in_container: The new place in container of the element.
:raises CannotCalculateIntermediateOrder: If it's not possible to find an
intermediate order. The full order of the element of the page must be
recalculated in this case before calling this method again.
:return: The moved element.
"""
parent_element_id = getattr(parent_element, "id", None)
if parent_element is not None and place_in_container is not None:
parent_element = parent_element.specific
parent_element_type = element_type_registry.get_by_model(parent_element)
parent_element_type.validate_place_in_container(
place_in_container, parent_element
)
if before:
element.order = Element.get_unique_order_before_element(before)
element.order = Element.get_unique_order_before_element(
before, parent_element_id, place_in_container
)
else:
element.order = Element.get_last_order(element.page)
element.order = Element.get_last_order(
element.page, parent_element_id, place_in_container
)
element.parent_element = parent_element
element.place_in_container = place_in_container
element.save()
return element
def order_elements(self, page: Page, order: List[int], base_qs=None) -> List[int]:
"""
Assigns a new order to the elements on a page.
You can provide a base_qs for pre-filter the elements affected by this change
:param page: The page that the elements belong to
:param order: The new order of the elements
:param base_qs: A QS that can have filters already applied
:raises ElementNotInSamePage: If the element is not part of the provided page
:return: The new order of the elements
"""
if base_qs is None:
base_qs = Element.objects.filter(page=page)
try:
full_order = Element.order_objects(base_qs, order)
except IdDoesNotExist:
raise ElementNotInSamePage()
return full_order
def before_places_in_container_removed(
self, container_element: ContainerElement, places: List[str]
) -> List[Element]:
"""
This should be called before places in a container have been removed to make
sure that all the elements that used to be in the removed containers are moved
somewhere else.
:param container_element: The container element affected
:param places: The places that were removed
:return: The elements that received a new order
"""
element_type = element_type_registry.get_by_model(container_element)
elements_being_moved = Element.objects.filter(
parent_element=container_element,
place_in_container__in=places,
)
element_count = elements_being_moved.count()
if element_count == 0:
return []
new_place_in_container = element_type.get_new_place_in_container(
container_element, places
)
new_order_values = Element.get_last_orders(
container_element.page,
container_element.id,
new_place_in_container,
amount=element_count,
)
elements_being_moved = element_type.apply_order_by_children(
elements_being_moved
)
elements_being_moved = list(elements_being_moved)
to_update = []
for element in elements_being_moved:
# Add order values in the same order
element.order = new_order_values.pop(0)
element.place_in_container = new_place_in_container
to_update.append(element)
Element.objects.bulk_update(to_update, ["order", "place_in_container"])
return elements_being_moved
def recalculate_full_orders(
self,
page: Page,

View file

@ -1,5 +1,9 @@
from typing import Optional
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import QuerySet
from baserow.contrib.builder.pages.models import Page
from baserow.core.formula.field import FormulaField
@ -14,12 +18,18 @@ from baserow.core.mixins import (
from baserow.core.user_files.models import UserFile
class ALIGNMENTS(models.TextChoices):
class HorizontalAlignments(models.TextChoices):
LEFT = "left"
CENTER = "center"
RIGHT = "right"
class VerticalAlignments(models.TextChoices):
TOP = "top"
CENTER = "center"
BOTTOM = "bottom"
def get_default_element_content_type():
return ContentType.objects.get_for_model(Element)
@ -52,6 +62,25 @@ class Element(
related_name="page_elements",
on_delete=models.SET(get_default_element_content_type),
)
# This is used for container elements, if NULL then this is a root element
parent_element = models.ForeignKey(
"self",
on_delete=models.CASCADE,
null=True,
default=None,
help_text="The parent element, if inside a container.",
related_name="children",
)
# The following fields are used to store the position of the element in the
# container. If the element is a root element then this is null.
place_in_container = models.CharField(
null=True,
blank=True,
default=None,
max_length=255,
help_text="The place in the container.",
)
style_padding_top = models.PositiveIntegerField(default=10)
style_padding_bottom = models.PositiveIntegerField(default=10)
@ -68,25 +97,68 @@ class Element(
def get_parent(self):
return self.page
def get_sibling_elements(self):
return Element.objects.filter(
parent_element=self.parent_element, page=self.page
).exclude(id=self.id)
@property
def is_root_element(self):
return self.parent_element is None
@classmethod
def get_last_order(cls, page: Page):
def get_last_order(
cls,
page: Page,
parent_element_id: Optional[int] = None,
place_in_container: Optional[str] = None,
):
"""
Returns the last order for the given page.
:param page: The page we want the order for.
:param base_queryset: The base queryset to use.
:return: The last order.
"""
return cls.get_last_orders(page, parent_element_id, place_in_container)[0]
@classmethod
def get_last_orders(
cls,
page: Page,
parent_element_id: Optional[int] = None,
place_in_container: Optional[str] = None,
amount=1,
):
"""
Returns the last orders for the given page.
:param page: The page we want the order for.
:param parent_element_id: The id of the parent element.
:param place_in_container: The place in the container
:param amount: The number of orders you wish to have returned
:return: The last order.
"""
queryset = Element.objects.filter(page=page)
return cls.get_highest_order_of_queryset(queryset)[0]
queryset = cls._scope_queryset_to_container(
queryset, parent_element_id, place_in_container
)
return cls.get_highest_order_of_queryset(queryset, amount=amount)
@classmethod
def get_unique_order_before_element(cls, before: "Element"):
def get_unique_order_before_element(
cls, before: "Element", parent_element_id: int, place_in_container: str
):
"""
Returns a safe order value before the given element in the given page.
:param page: The page we want the order for.
:param before: The element before which we want the safe order
:param parent_element_id: The id of the parent element.
:param place_in_container: The place in the container
:raises CannotCalculateIntermediateOrder: If it's not possible to find an
intermediate order. The full order of the items must be recalculated in this
case before calling this method again.
@ -94,8 +166,74 @@ class Element(
"""
queryset = Element.objects.filter(page=before.page)
queryset = cls._scope_queryset_to_container(
queryset, parent_element_id, place_in_container
)
return cls.get_unique_orders_before_item(before, queryset)[0]
@classmethod
def _scope_queryset_to_container(
cls, queryset: QuerySet, parent_element_id: int, place_in_container: str
) -> QuerySet:
"""
Filters the queryset to only include elements that are in the same container
as the child element.
:param queryset: The queryset to filter.
:param parent_element_id: The ID of the parent element.
:param place_in_container: The place in container of the child element.
:return: The filtered queryset.
"""
if parent_element_id:
return queryset.filter(
parent_element_id=parent_element_id,
place_in_container=place_in_container,
)
else:
return queryset.filter(
parent_element_id=None,
)
class ContainerElement(Element):
"""
Base class for container elements.
"""
class Meta:
abstract = True
class ColumnElement(ContainerElement):
"""
A column element that can contain other elements.
"""
column_amount = models.IntegerField(
default=3,
help_text="The amount of columns inside this column element.",
validators=[
MinValueValidator(1, message="Value cannot be less than 0."),
MaxValueValidator(6, message="Value cannot be greater than 6."),
],
)
column_gap = models.IntegerField(
default=30,
help_text="The amount of space between the columns.",
validators=[
MinValueValidator(0, message="Value cannot be less than 0."),
MaxValueValidator(2000, message="Value cannot be greater than 2000."),
],
)
alignment = models.CharField(
choices=VerticalAlignments.choices,
max_length=10,
default=VerticalAlignments.TOP,
)
class HeadingElement(Element):
"""
@ -187,7 +325,9 @@ class LinkElement(Element):
default=WIDTHS.AUTO,
)
alignment = models.CharField(
choices=ALIGNMENTS.choices, max_length=10, default=ALIGNMENTS.LEFT
choices=HorizontalAlignments.choices,
max_length=10,
default=HorizontalAlignments.LEFT,
)
@ -222,7 +362,9 @@ class ImageElement(Element):
blank=True,
)
alignment = models.CharField(
choices=ALIGNMENTS.choices, max_length=10, default=ALIGNMENTS.LEFT
choices=HorizontalAlignments.choices,
max_length=10,
default=HorizontalAlignments.LEFT,
)

View file

@ -41,6 +41,20 @@ class ElementType(
:return:
"""
from baserow.contrib.builder.elements.handler import ElementHandler
parent_element_id = values.get(
"parent_element_id", getattr(instance, "parent_element_id", None)
)
place_in_container = values.get("place_in_container", None)
if parent_element_id is not None and place_in_container is not None:
parent_element = ElementHandler().get_element(parent_element_id)
parent_element_type = element_type_registry.get_by_model(parent_element)
parent_element_type.validate_place_in_container(
place_in_container, parent_element
)
return values
def get_property_for_serialization(self, element: Element, prop_name: str):

View file

@ -82,6 +82,7 @@ class ElementService:
element_type: ElementType,
page: Page,
before: Optional[Element] = None,
order: Optional[int] = None,
**kwargs,
) -> Element:
"""
@ -91,6 +92,7 @@ class ElementService:
:param element_type: The type of the element.
:param page: The page the element exists in.
:param before: If set, the new element is inserted before this element.
:param order: If set, the new element is inserted at this order ignoring before.
:param kwargs: Additional attributes of the element.
:return: The created element.
"""
@ -108,7 +110,7 @@ class ElementService:
try:
new_element = self.handler.create_element(
element_type, page, before=before, **kwargs
element_type, page, before=before, order=order, **kwargs
)
except CannotCalculateIntermediateOrder:
self.recalculate_full_orders(user, page)
@ -183,6 +185,8 @@ class ElementService:
self,
user: AbstractUser,
element: ElementForUpdate,
parent_element: Optional[Element],
place_in_container: str,
before: Optional[Element] = None,
) -> Element:
"""
@ -191,6 +195,8 @@ class ElementService:
:param user: The user who move the element.
:param element: The element we want to move.
:param parent_element: The new parent element of the element.
:param place_in_container: The new place in container of the element.
:param before: The element before which we want to move the given element.
:return: The element with an updated order.
"""
@ -207,15 +213,24 @@ class ElementService:
raise ElementNotInSamePage()
try:
element = self.handler.move_element(element, before=before)
element = self.handler.move_element(
element, parent_element, place_in_container, before=before
)
except CannotCalculateIntermediateOrder:
# If it's failing, we need to recalculate all orders then move again.
self.recalculate_full_orders(user, element.page)
# Refresh the before element as the order might have changed.
before.refresh_from_db()
element = self.handler.move_element(element, before=before)
element = self.handler.move_element(
element, parent_element, place_in_container, before=before
)
element_moved.send(self, element=element, before=before, user=user)
element_moved.send(
self,
element=element,
before=before,
user=user,
)
return element

View file

@ -4,4 +4,5 @@ element_created = Signal()
element_deleted = Signal()
element_updated = Signal()
element_moved = Signal()
elements_moved = Signal()
element_orders_recalculated = Signal()

View file

@ -0,0 +1,99 @@
# Generated by Django 3.2.18 on 2023-07-24 11:38
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("builder", "0015_inputtextelement"),
]
operations = [
migrations.CreateModel(
name="ColumnElement",
fields=[
(
"element_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="builder.element",
),
),
(
"column_amount",
models.IntegerField(
default=3,
help_text="The amount of columns inside this column element.",
validators=[
django.core.validators.MinValueValidator(
1, message="Value cannot be less than 0."
),
django.core.validators.MaxValueValidator(
6, message="Value cannot be greater than 6."
),
],
),
),
(
"column_gap",
models.IntegerField(
default=30,
help_text="The amount of space between the columns.",
validators=[
django.core.validators.MinValueValidator(
0, message="Value cannot be less than 0."
),
django.core.validators.MaxValueValidator(
2000, message="Value cannot be greater than 2000."
),
],
),
),
(
"alignment",
models.CharField(
choices=[
("top", "Top"),
("center", "Center"),
("bottom", "Bottom"),
],
default="top",
max_length=10,
),
),
],
options={
"abstract": False,
},
bases=("builder.element",),
),
migrations.AddField(
model_name="element",
name="parent_element",
field=models.ForeignKey(
default=None,
help_text="The parent element, if inside a container.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="builder.element",
),
),
migrations.AddField(
model_name="element",
name="place_in_container",
field=models.CharField(
blank=True,
default=None,
help_text="The place in the container.",
max_length=255,
null=True,
),
),
]

View file

@ -9,8 +9,10 @@ class ElementDict(TypedDict):
id: int
order: int
type: str
style_padding_bottom: int
parent_element_id: int
place_in_container: str
style_padding_top: int
style_padding_bottom: int
class DataSourceDict(TypedDict):

View file

@ -1,3 +1,5 @@
from typing import List
from django.contrib.auth.models import AbstractUser
from django.db import transaction
from django.dispatch import receiver
@ -72,6 +74,8 @@ def element_moved(
"type": "element_moved",
"element_id": element.id,
"before_id": before.id if before else None,
"parent_element_id": element.parent_element_id,
"place_in_container": element.place_in_container,
"page_id": element.page.id,
},
getattr(user, "web_socket_id", None),
@ -112,3 +116,28 @@ def element_orders_recalculated(
getattr(user, "web_socket_id", None),
)
)
@receiver(element_signals.elements_moved)
def elements_moved(
sender, page: Page, elements: List[Element], user: AbstractUser = None, **kwargs
):
transaction.on_commit(
lambda: broadcast_to_permitted_users.delay(
page.builder.workspace_id,
ListElementsPageOperationType.type,
BuilderPageObjectScopeType.type,
page.id,
{
"type": "elements_moved",
"page_id": page.id,
"elements": [
element_type_registry.get_serializer(
element, ElementSerializer
).data
for element in elements
],
},
getattr(user, "web_socket_id", None),
)
)

View file

@ -1,4 +1,5 @@
from baserow.contrib.builder.elements.models import (
ColumnElement,
HeadingElement,
ImageElement,
LinkElement,
@ -19,6 +20,10 @@ class ElementFixtures:
element = self.create_builder_element(ImageElement, user, page, **kwargs)
return element
def create_builder_column_element(self, user=None, page=None, **kwargs):
element = self.create_builder_element(ColumnElement, user, page, **kwargs)
return element
def create_builder_link_element(self, user=None, page=None, **kwargs):
element = self.create_builder_element(LinkElement, user, page, **kwargs)
return element

View file

@ -0,0 +1,274 @@
from django.urls import reverse
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_can_get_a_column_element(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
column_element = data_fixture.create_builder_column_element(user=user)
url = reverse(
"api:builder:element:list", kwargs={"page_id": column_element.page.id}
)
response = api_client.get(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
[column_element_returned] = response.json()
assert response.status_code == HTTP_200_OK
assert column_element_returned["id"] == column_element.id
assert column_element_returned["type"] == "column"
@pytest.mark.django_db
def test_can_create_a_column_element(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
url = reverse("api:builder:element:list", kwargs={"page_id": page.id})
response = api_client.post(
url,
{
"type": "column",
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["type"] == "column"
@pytest.mark.django_db
def test_column_element_column_amount_errors(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
url = reverse("api:builder:element:list", kwargs={"page_id": page.id})
response = api_client.post(
url,
{
"type": "column",
"column_amount": 0,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION"
response = api_client.post(
url,
{
"type": "column",
"column_amount": 7,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION"
@pytest.mark.django_db
def test_column_element_column_gap_errors(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
url = reverse("api:builder:element:list", kwargs={"page_id": page.id})
response = api_client.post(
url,
{
"type": "column",
"column_gap": -1,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION"
response = api_client.post(
url,
{
"type": "column",
"column_gap": 2001,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION"
@pytest.mark.django_db
def test_elements_moved_when_column_is_removed(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
column = data_fixture.create_builder_column_element(
user=user, page=page, column_amount=3
)
column_element_column_0 = data_fixture.create_builder_paragraph_element(
user=user,
page=page,
parent_element_id=column.id,
place_in_container="0",
order=22,
)
column_element_column_1 = data_fixture.create_builder_paragraph_element(
user=user,
page=page,
parent_element_id=column.id,
place_in_container="1",
order=4,
)
column_element_column_1_1 = data_fixture.create_builder_paragraph_element(
user=user,
page=page,
parent_element_id=column.id,
place_in_container="1",
order=5,
)
column_element_column_2 = data_fixture.create_builder_paragraph_element(
user=user,
page=page,
parent_element_id=column.id,
place_in_container="2",
order=1,
)
url = reverse("api:builder:element:item", kwargs={"element_id": column.id})
response = api_client.patch(
url,
{
"column_amount": 1,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
column_element_column_0.refresh_from_db()
column_element_column_1.refresh_from_db()
column_element_column_1_1.refresh_from_db()
column_element_column_2.refresh_from_db()
assert column_element_column_0.place_in_container == "0"
assert column_element_column_1.place_in_container == "0"
assert column_element_column_1_1.place_in_container == "0"
assert column_element_column_2.place_in_container == "0"
assert column_element_column_0.order < column_element_column_1.order
assert column_element_column_1.order < column_element_column_1_1.order
assert column_element_column_1_1.order < column_element_column_2.order
@pytest.mark.django_db
def test_moving_an_element_to_new_column_appends_element(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
column_element = data_fixture.create_builder_column_element(
user=user, page=page, column_amount=2
)
element_in_column_0 = data_fixture.create_builder_paragraph_element(
user=user,
page=page,
parent_element_id=column_element.id,
place_in_container="0",
order=1,
)
element_in_column_1 = data_fixture.create_builder_paragraph_element(
user=user,
page=page,
parent_element_id=column_element.id,
place_in_container="1",
order=4,
)
url = reverse(
"api:builder:element:move", kwargs={"element_id": element_in_column_0.id}
)
response = api_client.patch(
url,
{
"parent_element_id": column_element.id,
"place_in_container": "1",
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
element_in_column_0.refresh_from_db()
element_in_column_1.refresh_from_db()
assert element_in_column_0.place_in_container == "1"
assert element_in_column_1.place_in_container == "1"
assert element_in_column_0.order > element_in_column_1.order
@pytest.mark.django_db
def test_column_element_invalid_child_in_container_on_move(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
column_element = data_fixture.create_builder_column_element(
user=user, column_amount=2
)
child = data_fixture.create_builder_paragraph_element(page=column_element.page)
url = reverse("api:builder:element:move", kwargs={"element_id": child.id})
response = api_client.patch(
url,
{
"parent_element_id": column_element.id,
"place_in_container": "9999",
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert "place_in_container" in response.json()[0]
@pytest.mark.django_db
def test_column_element_invalid_child_in_container_on_create(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
column_element = data_fixture.create_builder_column_element(
user=user, column_amount=2
)
url = reverse(
"api:builder:element:list", kwargs={"page_id": column_element.page.id}
)
response = api_client.post(
url,
{
"type": "paragraph",
"parent_element_id": column_element.id,
"place_in_container": "9999",
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert "place_in_container" in response.json()[0]

View file

@ -379,3 +379,38 @@ def test_link_element_path_parameter_wrong_type(api_client, data_fixture):
response.json()["detail"]["page_parameters"][0]["value"][0]["error"]
== "The formula is invalid: Invalid syntax at line 1, col 3: missing '(' at ' '"
)
@pytest.mark.django_db
def test_can_move_element_inside_container(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
container_element = data_fixture.create_builder_column_element(page=page)
element_one = data_fixture.create_builder_heading_element(
page=page, parent_element=container_element, place_in_container="0"
)
element_two = data_fixture.create_builder_heading_element(
page=page, parent_element=container_element, place_in_container="0"
)
assert element_two.parent_element is container_element
assert element_two.place_in_container == "0"
url = reverse("api:builder:element:move", kwargs={"element_id": element_two.id})
response = api_client.patch(
url,
{
"before_id": element_one.id,
"parent_element_id": None,
"place_in_container": None,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == 200
element_two.refresh_from_db()
assert element_two.parent_element is None
assert element_two.place_in_container is None

View file

@ -0,0 +1,71 @@
import pytest
from rest_framework.exceptions import ValidationError
from baserow.contrib.builder.elements.element_types import ColumnElementType
from baserow.contrib.builder.elements.models import Element
@pytest.mark.django_db
def test_get_new_place_in_container(data_fixture):
column_element = data_fixture.create_builder_column_element(column_amount=3)
assert ColumnElementType().get_new_place_in_container(column_element, ["2"]) == 1
assert (
ColumnElementType().get_new_place_in_container(column_element, ["2", "1"]) == 0
)
assert ColumnElementType().get_new_place_in_container(column_element, []) == 2
@pytest.mark.django_db
def test_get_places_in_container_removed(data_fixture):
column_element = data_fixture.create_builder_column_element(column_amount=3)
assert ColumnElementType().get_places_in_container_removed(
{"column_amount": 2}, column_element
) == ["2"]
assert ColumnElementType().get_places_in_container_removed(
{"column_amount": 1}, column_element
) == ["1", "2"]
assert (
ColumnElementType().get_places_in_container_removed(
{"column_amount": 3}, column_element
)
== []
)
assert ColumnElementType().get_places_in_container_removed(
{"column_amount": 0}, column_element
) == ["0", "1", "2"]
@pytest.mark.django_db
def test_apply_order_by_children(data_fixture):
column_element = data_fixture.create_builder_column_element(column_amount=20)
first_element = data_fixture.create_builder_heading_element(
parent_element=column_element, place_in_container="0"
)
last_element = data_fixture.create_builder_paragraph_element(
parent_element=column_element, place_in_container="11"
)
middle_element = data_fixture.create_builder_paragraph_element(
parent_element=column_element, place_in_container="5"
)
queryset = Element.objects.filter(parent_element=column_element)
queryset_ordered = ColumnElementType().apply_order_by_children(queryset)
ids_ordered = [element.id for element in queryset_ordered]
assert ids_ordered == [first_element.id, middle_element.id, last_element.id]
@pytest.mark.django_db
def test_validate_place_in_container(data_fixture):
column_element = data_fixture.create_builder_column_element(column_amount=2)
with pytest.raises(ValidationError):
ColumnElementType().validate_place_in_container("5", column_element)
try:
ColumnElementType().validate_place_in_container("1", column_element)
except ValidationError:
pytest.fail("Should not have raised since 1 is between 0-1")

View file

@ -0,0 +1,33 @@
import pytest
from baserow.contrib.builder.elements.models import ColumnElement, HeadingElement
from baserow.core.db import specific_iterator
@pytest.mark.django_db
def test_column_element_can_have_children(data_fixture):
"""
We are using the column element here as an example of a container element. A
container element is an element that can have children elements. In this case the
column element can have children elements, but the heading element can not.
"""
page = data_fixture.create_builder_page()
container_element = ColumnElement.objects.create(page=page)
child_element_one = HeadingElement.objects.create(
page=page, parent_element=container_element
)
child_element_two = HeadingElement.objects.create(
page=page, parent_element=container_element
)
assert list(specific_iterator(container_element.children.all())) == [
child_element_one,
child_element_two,
]
assert container_element.is_root_element is True
assert child_element_one.is_root_element is False
assert child_element_two.is_root_element is False
assert list(specific_iterator(child_element_one.get_sibling_elements())) == [
child_element_two
]

View file

@ -2,7 +2,14 @@ from decimal import Decimal
import pytest
from baserow.contrib.builder.elements.exceptions import ElementDoesNotExist
from baserow.contrib.builder.elements.element_types import (
ColumnElementType,
ParagraphElementType,
)
from baserow.contrib.builder.elements.exceptions import (
ElementDoesNotExist,
ElementNotInSamePage,
)
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.models import (
Element,
@ -104,7 +111,9 @@ def test_move_element_end_of_page(data_fixture):
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(page=page)
element_moved = ElementHandler().move_element(element1)
element_moved = ElementHandler().move_element(
element1, element1.parent_element, element1.place_in_container
)
assert Element.objects.filter(page=page).last().id == element_moved.id
@ -116,7 +125,9 @@ def test_move_element_before(data_fixture):
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(page=page)
ElementHandler().move_element(element3, before=element2)
ElementHandler().move_element(
element3, element3.parent_element, element3.place_in_container, before=element2
)
assert [e.id for e in Element.objects.filter(page=page).all()] == [
element1.id,
@ -137,7 +148,67 @@ def test_move_element_before_fails(data_fixture):
element3 = data_fixture.create_builder_heading_element(page=page, order="3.0000")
with pytest.raises(CannotCalculateIntermediateOrder):
ElementHandler().move_element(element3, before=element2)
ElementHandler().move_element(
element3,
element3.parent_element,
element3.place_in_container,
before=element2,
)
@pytest.mark.django_db
def test_creating_element_in_container_starts_its_own_order_sequence(data_fixture):
page = data_fixture.create_builder_page()
container = ElementHandler().create_element(ColumnElementType(), page=page)
root_element = ElementHandler().create_element(ParagraphElementType(), page=page)
element_inside_container_one = ElementHandler().create_element(
ParagraphElementType(),
page=page,
parent_element_id=container.id,
place_in_container="1",
)
element_inside_container_two = ElementHandler().create_element(
ParagraphElementType(),
page=page,
parent_element_id=container.id,
place_in_container="1",
)
# Irrespective of the order the elements were created, we need to assert that a new
# order has started inside the container
assert container.order < root_element.order
assert element_inside_container_one.order < element_inside_container_two.order
assert element_inside_container_one.order < root_element.order
@pytest.mark.django_db
def test_moving_elements_inside_container(data_fixture):
page = data_fixture.create_builder_page()
container = ElementHandler().create_element(ColumnElementType(), page=page)
root_element = ElementHandler().create_element(ParagraphElementType(), page=page)
element_inside_container_one = ElementHandler().create_element(
ParagraphElementType(),
page=page,
parent_element_id=container.id,
place_in_container="1",
)
element_inside_container_two = ElementHandler().create_element(
ParagraphElementType(),
page=page,
parent_element_id=container.id,
place_in_container="1",
)
ElementHandler().move_element(
element_inside_container_two,
element_inside_container_two.parent_element,
element_inside_container_two.place_in_container,
before=element_inside_container_one,
)
assert element_inside_container_two.order < element_inside_container_one.order
assert element_inside_container_two.order < root_element.order
assert element_inside_container_one.order < root_element.order
@pytest.mark.django_db
@ -211,3 +282,83 @@ def test_recalculate_full_orders(data_fixture):
assert elements[1].id == elementB.id
assert elements[1].order == Decimal("2.00300000000000000000")
@pytest.mark.django_db
def test_order_elements(data_fixture):
page = data_fixture.create_builder_page()
element_one = data_fixture.create_builder_heading_element(
order="1.00000000000000000000", page=page
)
element_two = data_fixture.create_builder_heading_element(
order="2.00000000000000000000", page=page
)
ElementHandler().order_elements(page, [element_two.id, element_one.id])
element_one.refresh_from_db()
element_two.refresh_from_db()
assert element_one.order > element_two.order
@pytest.mark.django_db
def test_order_elements_not_in_page(data_fixture):
page = data_fixture.create_builder_page()
element_one = data_fixture.create_builder_heading_element(
order="1.00000000000000000000", page=page
)
element_two = data_fixture.create_builder_heading_element(
order="2.00000000000000000000"
)
with pytest.raises(ElementNotInSamePage):
ElementHandler().order_elements(page, [element_two.id, element_one.id])
@pytest.mark.django_db
def test_before_places_in_container_removed(data_fixture):
column_element = data_fixture.create_builder_column_element(column_amount=3)
element_one = data_fixture.create_builder_heading_element(
parent_element=column_element, place_in_container="2"
)
element_two = data_fixture.create_builder_heading_element(
parent_element=column_element, place_in_container="1"
)
result = ElementHandler().before_places_in_container_removed(
column_element, ["1", "2"]
)
result_specific = [element.specific for element in result]
element_one.refresh_from_db()
element_two.refresh_from_db()
assert element_one.place_in_container == "0"
assert element_two.place_in_container == "0"
assert element_one.order > element_two.order
assert result_specific == [element_two, element_one]
@pytest.mark.django_db
def test_before_places_in_container_removed_no_change(data_fixture):
column_element = data_fixture.create_builder_column_element(column_amount=3)
element_one = data_fixture.create_builder_heading_element(
parent_element=column_element, place_in_container="0"
)
element_two = data_fixture.create_builder_heading_element(
parent_element=column_element, place_in_container="0"
)
result = ElementHandler().before_places_in_container_removed(
column_element, ["1", "2"]
)
element_one.refresh_from_db()
element_two.refresh_from_db()
assert element_one.place_in_container == "0"
assert element_two.place_in_container == "0"
assert result == []

View file

@ -258,7 +258,13 @@ def test_move_element(element_updated_mock, data_fixture):
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_paragraph_element(page=page)
element_moved = ElementService().move_element(user, element3, before=element2)
element_moved = ElementService().move_element(
user,
element3,
element3.parent_element,
element3.place_in_container,
before=element2,
)
assert element_updated_mock.called_with(element=element_moved, user=user)
@ -273,7 +279,13 @@ def test_move_element_not_same_page(data_fixture, stub_check_permissions):
element3 = data_fixture.create_builder_paragraph_element(page=page2)
with pytest.raises(ElementNotInSamePage):
ElementService().move_element(user, element3, before=element2)
ElementService().move_element(
user,
element3,
element3.parent_element,
element3.place_in_container,
before=element2,
)
@pytest.mark.django_db
@ -287,7 +299,13 @@ def test_move_element_permission_denied(data_fixture, stub_check_permissions):
with stub_check_permissions(raise_permission_denied=True), pytest.raises(
PermissionException
):
ElementService().move_element(user, element3, before=element2)
ElementService().move_element(
user,
element3,
element3.parent_element,
element3.place_in_container,
before=element2,
)
@pytest.mark.django_db
@ -305,6 +323,12 @@ def test_move_element_trigger_order_recalculed(
)
element3 = data_fixture.create_builder_heading_element(page=page, order="3.0000")
ElementService().move_element(user, element3, before=element2)
ElementService().move_element(
user,
element3,
element3.parent_element,
element3.place_in_container,
before=element2,
)
assert element_orders_recalculated_mock.called_with(page=page, user=user)

View file

@ -1,7 +1,13 @@
import pytest
from baserow.contrib.builder.application_types import BuilderApplicationType
from baserow.contrib.builder.elements.models import HeadingElement, ParagraphElement
from baserow.contrib.builder.elements.models import (
ColumnElement,
Element,
HeadingElement,
ParagraphElement,
)
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.models import Builder
from baserow.contrib.builder.pages.models import Page
from baserow.core.db import specific_iterator
@ -34,6 +40,12 @@ def test_builder_application_export(data_fixture):
)
element2 = data_fixture.create_builder_paragraph_element(page=page1)
element3 = data_fixture.create_builder_heading_element(page=page2)
element_container = data_fixture.create_builder_column_element(
page=page1, column_amount=3, column_gap=50
)
element_inside_container = data_fixture.create_builder_paragraph_element(
page=page1, parent_element=element_container, place_in_container="0"
)
integration = data_fixture.create_local_baserow_integration(
application=builder, authorized_user=user, name="test"
@ -80,6 +92,8 @@ def test_builder_application_export(data_fixture):
"id": element1.id,
"type": "heading",
"order": str(element1.order),
"parent_element_id": None,
"place_in_container": None,
"style_padding_top": 10,
"style_padding_bottom": 10,
"value": element1.value,
@ -89,10 +103,34 @@ def test_builder_application_export(data_fixture):
"id": element2.id,
"type": "paragraph",
"order": str(element2.order),
"parent_element_id": None,
"place_in_container": None,
"style_padding_top": 10,
"style_padding_bottom": 10,
"value": element2.value,
},
{
"id": element_container.id,
"type": "column",
"parent_element_id": None,
"place_in_container": None,
"style_padding_top": 10,
"style_padding_bottom": 10,
"order": str(element_container.order),
"column_amount": 3,
"column_gap": 50,
"alignment": "top",
},
{
"id": element_inside_container.id,
"type": "paragraph",
"parent_element_id": element_container.id,
"place_in_container": "0",
"style_padding_top": 10,
"style_padding_bottom": 10,
"order": str(element_inside_container.order),
"value": element_inside_container.value,
},
],
},
{
@ -131,6 +169,8 @@ def test_builder_application_export(data_fixture):
"id": element3.id,
"type": "heading",
"order": str(element3.order),
"parent_element_id": None,
"place_in_container": None,
"style_padding_top": 10,
"style_padding_bottom": 10,
"value": element3.value,
@ -167,6 +207,8 @@ IMPORT_REFERENCE = {
{
"id": 998,
"type": "heading",
"parent_element_id": None,
"place_in_container": None,
"order": 1,
"value": "foo",
"level": 2,
@ -174,9 +216,33 @@ IMPORT_REFERENCE = {
{
"id": 999,
"type": "paragraph",
"parent_element_id": None,
"place_in_container": None,
"order": 2,
"value": "",
},
{
"id": 500,
"type": "column",
"parent_element_id": None,
"place_in_container": None,
"style_padding_top": 10,
"style_padding_bottom": 10,
"order": 3,
"column_amount": 3,
"column_gap": 50,
"alignment": "top",
},
{
"id": 501,
"type": "paragraph",
"parent_element_id": 500,
"place_in_container": "0",
"style_padding_top": 10,
"style_padding_bottom": 10,
"order": 1,
"value": "test",
},
],
"data_sources": [
{
@ -208,6 +274,8 @@ IMPORT_REFERENCE = {
{
"id": 997,
"type": "heading",
"parent_element_id": None,
"place_in_container": None,
"order": 1,
"value": "",
"level": 1,
@ -275,7 +343,7 @@ def test_builder_application_import(data_fixture):
[page1, page2] = builder.page_set.all()
assert page1.element_set.count() == 2
assert page1.element_set.count() == 4
assert page2.element_set.count() == 1
assert page1.datasource_set.count() == 2
@ -285,14 +353,22 @@ def test_builder_application_import(data_fixture):
assert first_data_source.name == "source 2"
assert first_data_source.service.integration.id == first_integration.id
[element1, element2] = specific_iterator(page1.element_set.all())
[
element1,
element_inside_container,
element2,
container_element,
] = specific_iterator(page1.element_set.all())
assert isinstance(element1, HeadingElement)
assert isinstance(element2, ParagraphElement)
assert isinstance(container_element, ColumnElement)
assert element1.order == 1
assert element1.level == 2
assert element_inside_container.parent_element.specific == container_element
@pytest.mark.django_db
def test_delete_builder_application_with_published_builder(data_fixture):
@ -305,3 +381,81 @@ def test_delete_builder_application_with_published_builder(data_fixture):
TrashHandler.permanently_delete(builder)
assert Builder.objects.count() == 0
@pytest.mark.django_db
def test_import_element(data_fixture):
element = data_fixture.create_builder_paragraph_element(value="test")
element_type = element_type_registry.get_by_model(element)
element_serialized = element_type.export_serialized(element)
serialized_page = {
"_object": element.page,
"_element_objects": [],
"elements": [element_serialized],
}
element_imported = BuilderApplicationType().import_element(
element_serialized,
serialized_page,
{"builder_page_elements": {}},
)
assert element_imported.id != element.id
assert element_imported.specific.value == element.value
@pytest.mark.django_db
def test_import_element_has_to_import_parent_first(data_fixture):
page = data_fixture.create_builder_page()
parent = data_fixture.create_builder_column_element(page=page, column_amount=15)
element = data_fixture.create_builder_paragraph_element(
page=page, parent_element=parent
)
parent_serialized = element_type_registry.get_by_model(parent).export_serialized(
parent
)
element_serialized = element_type_registry.get_by_model(element).export_serialized(
element
)
serialized_page = {
"_object": page,
"_element_objects": [],
"elements": [parent_serialized, element_serialized],
}
element_imported = BuilderApplicationType().import_element(
element_serialized,
serialized_page,
{"builder_page_elements": {}},
)
assert element_imported.id != element.id
assert element_imported.specific.value == element.value
parent_imported = Element.objects.get(id=element_imported.parent_element_id)
assert parent_imported.id != parent.id
assert parent_imported.specific.column_amount == parent.column_amount
@pytest.mark.django_db
def test_import_element_has_to_instance_already_created(data_fixture):
element = data_fixture.create_builder_paragraph_element()
element_imported = data_fixture.create_builder_paragraph_element()
element_serialized = element_type_registry.get_by_model(element).export_serialized(
element
)
serialized_page = {
"_object": element_imported.page,
"_element_objects": [element_imported],
"elements": [element_serialized],
}
element_returned = BuilderApplicationType().import_element(
element_serialized,
serialized_page,
{"builder_page_elements": {element.id: element_imported.id}},
)
assert element_returned is element_imported
assert Element.objects.count() == 2

View file

@ -67,7 +67,10 @@
"minLength": "A minimum of {min} characters is required here.",
"maxLength": "A maximum of {max} characters is allowed here.",
"minMaxLength": "A minimum of {min} and a maximum of {max} characters is allowed here.",
"requiredField": "This field is required."
"requiredField": "This field is required.",
"integerField": "The field must be an integer.",
"minValueField": "The field must be greater than or equal to {min}.",
"maxValueField": "The field must be less than or equal to {max}."
},
"permission": {
"admin": "Admin",

View file

@ -14,7 +14,7 @@
:element-type="elementType"
:loading="addingElementType === elementType.getType()"
:disabled="isCardDisabled(elementType)"
@click="$emit('add', elementType)"
@click="addElement(elementType)"
/>
</div>
</Modal>
@ -24,6 +24,8 @@
import modal from '@baserow/modules/core/mixins/modal'
import AddElementCard from '@baserow/modules/builder/components/elements/AddElementCard'
import { isSubstringOfStrings } from '@baserow/modules/core/utils/string'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { mapActions } from 'vuex'
export default {
name: 'AddElementModal',
@ -34,8 +36,8 @@ export default {
type: Object,
required: true,
},
addingElementType: {
type: String,
elementTypesAllowed: {
type: Array,
required: false,
default: null,
},
@ -43,12 +45,18 @@ export default {
data() {
return {
search: '',
placeInContainer: null,
beforeId: null,
parentElementId: null,
addingElementType: null,
}
},
computed: {
elementTypes() {
const allElementTypes = Object.values(this.$registry.getAll('element'))
return allElementTypes.filter((elementType) =>
const elementTypesAll =
this.elementTypesAllowed ||
Object.values(this.$registry.getAll('element'))
return elementTypesAll.filter((elementType) =>
isSubstringOfStrings(
[elementType.name, elementType.description],
this.search
@ -63,6 +71,40 @@ export default {
elementType.getType() !== this.addingElementType
)
},
...mapActions({
actionCreateElement: 'element/create',
}),
show({ placeInContainer, beforeId, parentElementId } = {}, ...args) {
this.placeInContainer = placeInContainer
this.beforeId = beforeId
this.parentElementId = parentElementId
modal.methods.show.bind(this)(...args)
},
async addElement(elementType) {
this.addingElementType = elementType.getType()
const configuration = this.parentElementId
? {
parent_element_id: this.parentElementId,
place_in_container: this.placeInContainer,
}
: null
try {
await this.actionCreateElement({
pageId: this.page.id,
elementType: elementType.getType(),
beforeId: this.beforeId,
configuration,
})
this.$emit('element-added')
this.hide()
} catch (error) {
notifyIf(error)
}
this.addingElementType = null
},
},
}
</script>

View file

@ -0,0 +1,13 @@
<template>
<div class="add-element-zone" @click="$emit('add-element')">
<div class="add-element-zone__content">
<i class="fas fa-plus add-element-zone__icon"></i>
</div>
</div>
</template>
<script>
export default {
name: 'AddElementZone',
}
</script>

View file

@ -11,26 +11,67 @@
</span>
</a>
<a
v-if="isPlacementVisible(PLACEMENTS.LEFT)"
class="element-preview__menu-item"
:class="{ disabled: moveUpDisabled }"
@click="!moveUpDisabled && $emit('move', PLACEMENTS.BEFORE)"
:class="{ disabled: isPlacementDisabled(PLACEMENTS.LEFT) }"
@click="
!isPlacementDisabled(PLACEMENTS.LEFT) && $emit('move', PLACEMENTS.LEFT)
"
>
<i class="fas fa-arrow-left"></i>
<span
v-if="!isPlacementDisabled(PLACEMENTS.LEFT)"
class="element-preview__menu-item-description"
>
{{ $t('elementMenu.moveLeft') }}
</span>
</a>
<a
v-if="isPlacementVisible(PLACEMENTS.RIGHT)"
class="element-preview__menu-item"
:class="{ disabled: isPlacementDisabled(PLACEMENTS.RIGHT) }"
@click="
!isPlacementDisabled(PLACEMENTS.RIGHT) &&
$emit('move', PLACEMENTS.RIGHT)
"
>
<i class="fas fa-arrow-right"></i>
<span
v-if="!isPlacementDisabled(PLACEMENTS.RIGHT)"
class="element-preview__menu-item-description"
>
{{ $t('elementMenu.moveRight') }}
</span>
</a>
<a
v-if="isPlacementVisible(PLACEMENTS.BEFORE)"
class="element-preview__menu-item"
:class="{ disabled: isPlacementDisabled(PLACEMENTS.BEFORE) }"
@click="
!isPlacementDisabled(PLACEMENTS.BEFORE) &&
$emit('move', PLACEMENTS.BEFORE)
"
>
<i class="fas fa-arrow-up"></i>
<span
v-if="!moveUpDisabled"
v-if="!isPlacementDisabled(PLACEMENTS.BEFORE)"
class="element-preview__menu-item-description"
>
{{ $t('elementMenu.moveUp') }}
</span>
</a>
<a
v-if="isPlacementVisible(PLACEMENTS.AFTER)"
class="element-preview__menu-item"
:class="{ disabled: moveDownDisabled }"
@click="!moveDownDisabled && $emit('move', PLACEMENTS.AFTER)"
:class="{ disabled: isPlacementDisabled(PLACEMENTS.AFTER) }"
@click="
!isPlacementDisabled(PLACEMENTS.AFTER) &&
$emit('move', PLACEMENTS.AFTER)
"
>
<i class="fas fa-arrow-down"></i>
<span
v-if="!moveDownDisabled"
v-if="!isPlacementDisabled(PLACEMENTS.AFTER)"
class="element-preview__menu-item-description"
>
{{ $t('elementMenu.moveDown') }}
@ -51,24 +92,32 @@ import { PLACEMENTS } from '@baserow/modules/builder/enums'
export default {
name: 'ElementMenu',
props: {
moveUpDisabled: {
type: Boolean,
required: false,
default: false,
},
moveDownDisabled: {
type: Boolean,
required: false,
default: false,
},
isDuplicating: {
type: Boolean,
required: false,
default: false,
},
placements: {
type: Array,
required: false,
default: () => [PLACEMENTS.BEFORE, PLACEMENTS.AFTER],
},
placementsDisabled: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
PLACEMENTS: () => PLACEMENTS,
},
methods: {
isPlacementVisible(placement) {
return this.placements.includes(placement)
},
isPlacementDisabled(placement) {
return this.placementsDisabled.includes(placement)
},
},
}
</script>

View file

@ -2,35 +2,42 @@
<div
class="element-preview"
:class="{
'element-preview--active': active,
'element-preview--active': isSelected,
'element-preview--in-error': inError,
}"
@click="$emit('selected')"
@click.stop="actionSelectElement({ element })"
>
<InsertElementButton
v-if="active"
v-if="isSelected"
class="element-preview__insert--top"
@click="$emit('insert', PLACEMENTS.BEFORE)"
@click="showAddElementModal(PLACEMENTS.BEFORE)"
/>
<ElementMenu
v-if="active"
:move-up-disabled="isFirstElement"
:move-down-disabled="isLastElement"
v-if="isSelected"
:placements="placements"
:placements-disabled="placementsDisabled"
:is-copying="isCopying"
@delete="$emit('delete')"
@delete="deleteElement"
@move="$emit('move', $event)"
@duplicate="$emit('duplicate')"
@duplicate="duplicateElement"
/>
<PageRootElement
v-if="isRootElement"
:element="element"
:builder="builder"
:page="page"
:mode="mode"
/>
></PageRootElement>
<PageElement v-else :element="element" :mode="mode" />
<InsertElementButton
v-if="active"
v-if="isSelected"
class="element-preview__insert--bottom"
@click="$emit('insert', PLACEMENTS.AFTER)"
@click="showAddElementModal(PLACEMENTS.AFTER)"
/>
<AddElementModal
ref="addElementModal"
:element-types-allowed="elementTypesAllowed"
:page="page"
/>
</div>
</template>
@ -38,27 +45,28 @@
<script>
import ElementMenu from '@baserow/modules/builder/components/elements/ElementMenu'
import InsertElementButton from '@baserow/modules/builder/components/elements/InsertElementButton'
import PageRootElement from '@baserow/modules/builder/components/page/PageRootElement'
import PageElement from '@baserow/modules/builder/components/page/PageElement'
import { PLACEMENTS } from '@baserow/modules/builder/enums'
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { mapActions, mapGetters } from 'vuex'
import PageRootElement from '@baserow/modules/builder/components/page/PageRootElement'
export default {
name: 'ElementPreview',
components: { ElementMenu, InsertElementButton, PageRootElement },
inject: ['builder', 'mode'],
components: {
AddElementModal,
ElementMenu,
InsertElementButton,
PageElement,
PageRootElement,
},
inject: ['builder', 'page', 'mode'],
props: {
element: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,
},
active: {
type: Boolean,
required: false,
default: false,
},
isLastElement: {
type: Boolean,
required: false,
@ -69,17 +77,62 @@ export default {
required: false,
default: false,
},
isCopying: {
placements: {
type: Array,
required: false,
default: () => [PLACEMENTS.BEFORE, PLACEMENTS.AFTER],
},
placementsDisabled: {
type: Array,
required: false,
default: () => [],
},
isRootElement: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isCopying: false,
}
},
computed: {
...mapGetters({ elementSelected: 'element/getSelected' }),
PLACEMENTS: () => PLACEMENTS,
elementTypesAllowed() {
return this.parentElementType?.childElementTypes || null
},
isSelected() {
return this.element.id === this.elementSelected?.id
},
elementType() {
return this.$registry.get('element', this.element.type)
},
parentElement() {
return this.$store.getters['element/getElementById'](
this.element.parent_element_id
)
},
parentElementType() {
return this.parentElement
? this.$registry.get('element', this.parentElement?.type)
: null
},
siblingElements() {
return this.$store.getters['element/getSiblings'](this.element)
},
samePlaceInContainerElements() {
return this.siblingElements.filter(
(e) => e.place_in_container === this.element.place_in_container
)
},
nextElement() {
return [...this.samePlaceInContainerElements].find((e) =>
e.order.gt(this.element.order)
)
},
inError() {
return this.elementType.isInError({
element: this.element,
@ -87,5 +140,47 @@ export default {
})
},
},
methods: {
...mapActions({
actionDuplicateElement: 'element/duplicate',
actionDeleteElement: 'element/delete',
actionSelectElement: 'element/select',
}),
showAddElementModal(placement) {
this.$refs.addElementModal.show({
placeInContainer: this.element.place_in_container,
parentElementId: this.element.parent_element_id,
beforeId: this.getBeforeId(placement),
})
},
getBeforeId(placement) {
return placement === PLACEMENTS.BEFORE
? this.element.id
: this.nextElement?.id || null
},
async duplicateElement() {
this.isCopying = true
try {
await this.actionDuplicateElement({
pageId: this.page.id,
elementId: this.element.id,
configuration: {
parent_element_id: this.element.parent_element_id,
place_in_container: this.element.place_in_container,
},
})
} catch (error) {
notifyIf(error)
}
this.isCopying = false
},
async deleteElement() {
try {
await this.actionDeleteElement({ elementId: this.element.id })
} catch (error) {
notifyIf(error)
}
},
},
}
</script>

View file

@ -1,6 +1,14 @@
<template>
<ul v-auto-overflow-scroll class="select__items">
<li v-for="element in elements" :key="element.id" class="select__item">
<li
v-for="{ element, indented } in elementsAndChildren"
:key="element.id"
:class="{
'select__item--selected': element.id === elementSelectedId,
'select__item--indented': indented,
}"
class="select__item"
>
<ElementsListItem :element="element" @click="$emit('select', element)" />
</li>
</ul>
@ -16,6 +24,35 @@ export default {
type: Array,
required: true,
},
elementSelected: {
type: Object,
required: false,
default: null,
},
},
computed: {
elementSelectedId() {
return this.elementSelected ? this.elementSelected.id : null
},
elementsAndChildren() {
return this.elements.reduce((acc, element) => {
acc.push({ element, indented: false })
const children = this.getChildren(element)
if (children.length) {
acc.push(
...children.map((child) => ({ element: child, indented: true }))
)
}
return acc
}, [])
},
},
methods: {
getChildren(element) {
return this.$store.getters['element/getChildren'](element)
},
},
}
</script>

View file

@ -0,0 +1,235 @@
<template>
<div
class="column-element"
:style="{
'--space-between-columns': `${element.column_gap}px`,
'--alignment': flexAlignment,
}"
>
<div
v-for="(childrenInColumn, columnIndex) in childrenElements"
:key="columnIndex"
class="column-element__column"
:style="{ '--column-width': `${columnWidth}%` }"
>
<template v-if="childrenInColumn.length > 0">
<div
v-for="(childCurrent, rowIndex) in childrenInColumn"
:key="rowIndex"
class="column-element__element"
>
<ElementPreview
v-if="mode === 'editing'"
class="element"
:element="childCurrent"
:active="childCurrent.id === elementSelectedId"
:index="rowIndex"
:placements="[
PLACEMENTS.BEFORE,
PLACEMENTS.AFTER,
PLACEMENTS.LEFT,
PLACEMENTS.RIGHT,
]"
:placements-disabled="getPlacementsDisabled(columnIndex, rowIndex)"
@move="move(childCurrent, columnIndex, rowIndex, $event)"
></ElementPreview>
<PageElement
v-else
:element="childCurrent"
:mode="mode"
></PageElement>
</div>
</template>
<AddElementZone
v-else-if="mode === 'editing'"
@add-element="showAddElementModal(columnIndex)"
/>
</div>
<AddElementModal
ref="addElementModal"
:page="page"
:element-types-allowed="elementType.childElementTypes"
/>
</div>
</template>
<script>
import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone'
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal'
import containerElement from '@baserow/modules/builder/mixins/containerElement'
import PageElement from '@baserow/modules/builder/components/page/PageElement'
import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview'
import { mapActions, mapGetters } from 'vuex'
import { PLACEMENTS, VERTICAL_ALIGNMENTS } from '@baserow/modules/builder/enums'
import { notifyIf } from '@baserow/modules/core/utils/error'
import _ from 'lodash'
import flushPromises from 'flush-promises'
import { dimensionMixin } from '@baserow/modules/core/mixins/dimensions'
export default {
name: 'ColumnElement',
components: {
AddElementZone,
ElementPreview,
PageElement,
AddElementModal,
},
mixins: [containerElement, dimensionMixin],
props: {
/**
* @type {Object}
* @property {number} column_amount - The amount of columns
* @property {number} column_gap - The space between the columns
* @property {string} alignment - The alignment of the columns
*/
element: {
type: Object,
required: true,
},
},
computed: {
PLACEMENTS: () => PLACEMENTS,
...mapGetters({
elementSelected: 'element/getSelected',
}),
flexAlignment() {
const alignmentMapping = {
[VERTICAL_ALIGNMENTS.TOP.value]: 'start',
[VERTICAL_ALIGNMENTS.CENTER.value]: 'center',
[VERTICAL_ALIGNMENTS.BOTTOM.value]: 'end',
}
return alignmentMapping[this.element.alignment]
},
breakingPoint() {
const minColumnWidth = 130
const totalColumnWidth = minColumnWidth * this.element.column_amount
const totalColumnGap =
this.element.column_gap * (this.element.column_amount - 1)
const extraPadding = 120
return totalColumnWidth + totalColumnGap + extraPadding
},
elementSelectedId() {
return this.elementSelected?.id
},
columnAmount() {
if (
this.dimensions.width !== null &&
this.dimensions.width < this.breakingPoint
) {
return 1
} else {
return this.element.column_amount
}
},
columnWidth() {
return 100 / this.columnAmount - 0.00000000000001
},
childrenByColumnOrdered() {
return _.groupBy(this.children, (child) => {
const childCol = parseInt(child.place_in_container, 10)
return childCol > this.columnAmount - 1
? this.columnAmount - 1
: childCol
})
},
childrenElements() {
return [...Array(this.columnAmount).keys()].map(
(columnIndex) => this.childrenByColumnOrdered[columnIndex] || []
)
},
},
mounted() {
this.dimensions.targetElement = this.$el.parentElement
},
methods: {
...mapActions({
actionMoveElement: 'element/move',
actionUpdateElement: 'element/update',
}),
showAddElementModal(columnIndex) {
this.$refs.addElementModal.show({
placeInContainer: `${columnIndex}`,
parentElementId: this.element.id,
})
},
getPlacementsDisabled(columnIndex, rowIndex) {
const placementsDisabled = []
if (columnIndex === 0) {
placementsDisabled.push(PLACEMENTS.LEFT)
}
if (columnIndex === this.columnAmount - 1) {
placementsDisabled.push(PLACEMENTS.RIGHT)
}
if (rowIndex === 0) {
placementsDisabled.push(PLACEMENTS.BEFORE)
}
if (rowIndex === this.childrenByColumnOrdered[columnIndex].length - 1) {
placementsDisabled.push(PLACEMENTS.AFTER)
}
return placementsDisabled
},
async move(element, columnIndex, rowIndex, placement) {
// Wait for the event propagation to be stopped by the child element otherwise
// the click event select the container because the element is removed from the
// DOM too quickly
await flushPromises()
if (placement === PLACEMENTS.AFTER || placement === PLACEMENTS.BEFORE) {
this.moveVertical(element, rowIndex, columnIndex, placement)
} else {
this.moveHorizontal(element, columnIndex, placement)
}
},
async moveVertical(element, rowIndex, columnIndex, placement) {
const elementsInColumn = this.childrenByColumnOrdered[columnIndex]
const elementToMoveId = element.id
// BeforeElementId remains null if we are moving the element at the end of the
// list
let beforeElementId = null
if (placement === PLACEMENTS.BEFORE) {
beforeElementId = elementsInColumn[rowIndex - 1].id
} else if (rowIndex + 2 < elementsInColumn.length) {
beforeElementId = elementsInColumn[rowIndex + 2].id
}
try {
await this.actionMoveElement({
elementId: elementToMoveId,
beforeElementId,
parentElementId: this.element.id,
placeInContainer: element.place_in_container,
})
} catch (error) {
notifyIf(error)
}
},
async moveHorizontal(element, columnIndex, placement) {
const placeInContainer = parseInt(element.place_in_container)
const newPlaceInContainer =
placement === PLACEMENTS.LEFT
? placeInContainer - 1
: placeInContainer + 1
if (newPlaceInContainer >= 0) {
try {
await this.actionMoveElement({
elementId: element.id,
beforeElementId: null,
parentElementId: this.element.id,
placeInContainer: `${newPlaceInContainer}`,
})
} catch (error) {
notifyIf(error)
}
}
},
},
}
</script>

View file

@ -37,7 +37,7 @@ export default {
},
classes() {
return {
[`element--alignment-${this.element.alignment}`]: true,
[`element--alignment-horizontal-${this.element.alignment}`]: true,
'element--no-value': !this.imageSource && !this.element.alt_text,
}
},

View file

@ -54,7 +54,7 @@ export default {
},
classes() {
return {
[`element--alignment-${this.element.alignment}`]: true,
[`element--alignment-horizontal-${this.element.alignment}`]: true,
'element--no-value': !this.resolvedValue,
}
},

View file

@ -0,0 +1,96 @@
<template>
<form @submit.prevent @keydown.enter.prevent>
<FormElement class="control">
<label class="control__label">
{{ $t('columnElementForm.columnAmountTitle') }}
</label>
<div class="control__elements">
<Dropdown v-model="values.column_amount" :show-search="false">
<DropdownItem
v-for="columnAmount in columnAmounts"
:key="columnAmount.value"
:name="columnAmount.name"
:value="columnAmount.value"
>
{{ columnAmount.name }}
</DropdownItem>
</Dropdown>
</div>
</FormElement>
<FormElement class="control">
<FormInput
v-model="values.column_gap"
:label="$t('columnElementForm.columnGapTitle')"
:placeholder="$t('columnElementForm.columnGapPlaceholder')"
:error="
$v.values.column_gap.$dirty && !$v.values.column_gap.required
? $t('error.requiredField')
: !$v.values.column_gap.integer
? $t('error.integerField')
: !$v.values.column_gap.minValue
? $t('error.minValueField', { min: 0 })
: !$v.values.column_gap.maxValue
? $t('error.maxValueField', { max: 2000 })
: ''
"
type="number"
@blur="$v.values.column_gap.$touch()"
></FormInput>
</FormElement>
<FormElement class="control">
<VerticalAlignmentSelector v-model="values.alignment" />
</FormElement>
</form>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
import { VERTICAL_ALIGNMENTS } from '@baserow/modules/builder/enums'
import { required, integer, minValue, maxValue } from 'vuelidate/lib/validators'
import VerticalAlignmentSelector from '@baserow/modules/builder/components/elements/components/forms/general/settings/VerticalAlignmentSelector'
export default {
name: 'ColumnElementForm',
components: {
VerticalAlignmentSelector,
},
mixins: [form],
data() {
return {
values: {
column_amount: 1,
column_gap: 30,
alignment: VERTICAL_ALIGNMENTS.TOP.value,
},
}
},
computed: {
columnAmounts() {
const maximumColumnAmount = 6
return [...Array(maximumColumnAmount).keys()].map((columnAmount) => ({
name: this.$tc('columnElementForm.columnAmountName', columnAmount + 1, {
columnAmount: columnAmount + 1,
}),
value: columnAmount + 1,
}))
},
},
methods: {
emitChange(newValues) {
if (this.isFormValid()) {
form.methods.emitChange.bind(this)(newValues)
}
},
},
validations: {
values: {
column_gap: {
required,
integer,
minValue: minValue(0),
maxValue: maxValue(2000),
},
},
},
}
</script>

View file

@ -94,7 +94,7 @@
</div>
</FormElement>
<FormElement class="control">
<AlignmentSelector v-model="values.alignment" />
<HorizontalAlignmentSelector v-model="values.alignment" />
</FormElement>
</form>
</template>
@ -102,16 +102,19 @@
<script>
import form from '@baserow/modules/core/mixins/form'
import { isValidAbsoluteURL } from '@baserow/modules/core/utils/string'
import { ALIGNMENTS, IMAGE_SOURCE_TYPES } from '@baserow/modules/builder/enums'
import {
HORIZONTAL_ALIGNMENTS,
IMAGE_SOURCE_TYPES,
} from '@baserow/modules/builder/enums'
import { IMAGE_FILE_TYPES } from '@baserow/modules/core/enums'
import UserFilesModal from '@baserow/modules/core/components/files/UserFilesModal'
import { UploadFileUserFileUploadType } from '@baserow/modules/core/userFileUploadTypes'
import AlignmentSelector from '@baserow/modules/builder/components/elements/components/forms/settings/AlignmentSelector'
import HorizontalAlignmentSelector from '@baserow/modules/builder/components/elements/components/forms/general/settings/HorizontalAlignmentsSelector'
import { maxLength } from 'vuelidate/lib/validators'
export default {
name: 'ImageElementForm',
components: { AlignmentSelector, UserFilesModal },
components: { HorizontalAlignmentSelector, UserFilesModal },
mixins: [form],
data() {
return {
@ -120,7 +123,7 @@ export default {
image_file: null,
image_url: '',
alt_text: '',
alignment: ALIGNMENTS.LEFT.value,
alignment: HORIZONTAL_ALIGNMENTS.LEFT.value,
},
}
},

View file

@ -109,7 +109,7 @@
</div>
</FormElement>
<FormElement class="control">
<AlignmentSelector v-model="values.alignment" />
<HorizontalAlignmentSelector v-model="values.alignment" />
</FormElement>
<FormElement class="control">
<label class="control__label">
@ -143,14 +143,14 @@
<script>
import form from '@baserow/modules/core/mixins/form'
import { LinkElementType } from '@baserow/modules/builder/elementTypes'
import AlignmentSelector from '@baserow/modules/builder/components/elements/components/forms/settings/AlignmentSelector'
import { ALIGNMENTS } from '@baserow/modules/builder/enums'
import HorizontalAlignmentSelector from '@baserow/modules/builder/components/elements/components/forms/general/settings/HorizontalAlignmentsSelector'
import { HORIZONTAL_ALIGNMENTS } from '@baserow/modules/builder/enums'
import FormulaInputGroup from '@baserow/modules/core/components/formula/FormulaInputGroup'
import { isValidFormula } from '@baserow/formula'
export default {
name: 'LinkElementForm',
components: { AlignmentSelector, FormulaInputGroup },
components: { HorizontalAlignmentSelector, FormulaInputGroup },
mixins: [form],
props: {
builder: { type: Object, required: true },
@ -168,7 +168,7 @@ export default {
return {
values: {
value: '',
alignment: ALIGNMENTS.LEFT.value,
alignment: HORIZONTAL_ALIGNMENTS.LEFT.value,
variant: 'link',
navigation_type: 'page',
navigate_to_page_id: null,

View file

@ -1,11 +1,11 @@
<template>
<div>
<label class="control__label">
{{ $t('alignmentSelector.alignment') }}
{{ $t('horizontalAlignmentSelector.alignment') }}
</label>
<div class="control__elements">
<RadioButton
v-for="alignment in alignments"
v-for="alignment in alignmentValues"
:key="alignment.value"
v-model="selected"
:value="alignment.value"
@ -18,15 +18,20 @@
</template>
<script>
import { ALIGNMENTS } from '@baserow/modules/builder/enums'
import { HORIZONTAL_ALIGNMENTS } from '@baserow/modules/builder/enums'
export default {
name: 'AlignmentSelector',
name: 'HorizontalAlignmentsSelector',
props: {
value: {
type: String,
required: false,
default: ALIGNMENTS.LEFT.value,
default: null,
},
alignments: {
type: Object,
required: false,
default: () => HORIZONTAL_ALIGNMENTS,
},
},
data() {
@ -35,8 +40,8 @@ export default {
}
},
computed: {
alignments() {
return Object.values(ALIGNMENTS)
alignmentValues() {
return Object.values(this.alignments)
},
},
watch: {

View file

@ -0,0 +1,47 @@
<template>
<div>
<label class="control__label">
{{ $t('verticalAlignmentSelector.alignment') }}
</label>
<Dropdown
class="control__elements"
:show-search="false"
:value="value"
@input="$emit('input', $event)"
>
<DropdownItem
v-for="alignment in alignmentValues"
:key="alignment.value"
:name="$t(alignment.name)"
:value="alignment.value"
>
{{ $t(alignment.name) }}
</DropdownItem>
</Dropdown>
</div>
</template>
<script>
import { VERTICAL_ALIGNMENTS } from '@baserow/modules/builder/enums'
export default {
name: 'VerticalAlignmentSelector',
props: {
value: {
type: String,
required: false,
default: null,
},
alignments: {
type: Object,
required: false,
default: () => VERTICAL_ALIGNMENTS,
},
},
computed: {
alignmentValues() {
return Object.values(this.alignments)
},
},
}
</script>

View file

@ -0,0 +1,24 @@
<template>
<form @submit.prevent>
<StyleBoxForm
v-if="isStyleAllowed('style_padding_top')"
v-model="boxStyles.top"
:label="$t('defaultStyleForm.boxTop')"
/>
<StyleBoxForm
v-if="isStyleAllowed('style_padding_top')"
v-model="boxStyles.bottom"
:label="$t('defaultStyleForm.boxBottom')"
/>
</form>
</template>
<script>
import StyleBoxForm from '@baserow/modules/builder/components/page/sidePanels/StyleBoxForm'
import styleForm from '@baserow/modules/builder/mixins/styleForm'
export default {
components: { StyleBoxForm },
mixins: [styleForm],
}
</script>

View file

@ -4,8 +4,6 @@
v-for="element in elements"
:key="element.id"
:element="element"
:builder="builder"
:page="page"
:mode="mode"
/>
</div>

View file

@ -0,0 +1,19 @@
<template>
<div :style="baseStyles">
<component
:is="component"
:element="element"
:children="children"
class="element"
/>
</div>
</template>
<script>
import PageElement from '@baserow/modules/builder/mixins/pageElement'
export default {
name: 'PageElement',
mixins: [PageElement],
}
</script>

View file

@ -1,30 +1,24 @@
<template>
<div class="page-preview__wrapper" @click.self="selectElement(null)">
<div
class="page-preview__wrapper"
@click.self="actionSelectElement({ element: null })"
>
<PreviewNavigationBar :page="page" :style="{ maxWidth }" />
<div ref="preview" class="page-preview" :style="{ maxWidth }">
<div ref="preview" class="page-preview" :style="{ 'max-width': maxWidth }">
<div ref="previewScaled" class="page-preview__scaled">
<ElementPreview
v-for="(element, index) in elements"
:key="element.id"
is-root-element
:element="element"
:page="page"
:active="element.id === elementSelectedId"
:is-first-element="index === 0"
:is-last-element="index === elements.length - 1"
:placements="[PLACEMENTS.BEFORE, PLACEMENTS.AFTER]"
:placements-disabled="getPlacementsDisabled(index)"
:is-copying="copyingElementIndex === index"
@selected="selectElement(element)"
@delete="deleteElement(element)"
@move="moveElement(element, index, $event)"
@insert="showAddElementModal(element, index, $event)"
@duplicate="duplicateElement(element, index)"
/>
</div>
<AddElementModal
ref="addElementModal"
:adding-element-type="addingElementType"
:page="page"
@add="addElement"
/>
</div>
</div>
</template>
@ -33,20 +27,14 @@
import { mapGetters, mapActions } from 'vuex'
import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview'
import { notifyIf } from '@baserow/modules/core/utils/error'
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal'
import PreviewNavigationBar from '@baserow/modules/builder/components/page/PreviewNavigationBar'
import { PLACEMENTS } from '@baserow/modules/builder/enums'
export default {
name: 'PagePreview',
components: { AddElementModal, ElementPreview, PreviewNavigationBar },
components: { ElementPreview, PreviewNavigationBar },
data() {
return {
// This value is set when the insertion of a new element is in progress to
// indicate where the element should be inserted
beforeId: null,
addingElementType: null,
// The element that is currently being copied
copyingElementIndex: null,
@ -55,15 +43,13 @@ export default {
}
},
computed: {
PLACEMENTS: () => PLACEMENTS,
...mapGetters({
page: 'page/getSelected',
deviceTypeSelected: 'page/getDeviceTypeSelected',
elements: 'element/getRootElements',
elementSelected: 'element/getSelected',
elements: 'element/getElements',
}),
elementSelectedId() {
return this.elementSelected?.id
},
deviceType() {
return this.deviceTypeSelected
? this.$registry.get('device', this.deviceTypeSelected)
@ -94,11 +80,9 @@ export default {
},
methods: {
...mapActions({
actionCreateElement: 'element/create',
actionDuplicateElement: 'element/duplicate',
actionMoveElement: 'element/move',
actionDeleteElement: 'element/delete',
actionSelectElement: 'element/select',
actionUpdateElement: 'element/update',
}),
onWindowResized() {
this.$nextTick(() => {
@ -127,16 +111,7 @@ export default {
previewScaled.style.width = `${currentWidth / scale}px`
previewScaled.style.height = `${currentHeight / scale}px`
},
async deleteElement(element) {
try {
await this.actionDeleteElement({
elementId: element.id,
})
} catch (error) {
notifyIf(error)
}
},
moveElement(element, index, placement) {
async moveElement(element, index, placement) {
const elementToMoveId = element.id
// BeforeElementId remains null if we are moving the element at the end of the
@ -150,7 +125,7 @@ export default {
}
try {
this.actionMoveElement({
await this.actionMoveElement({
pageId: this.page.id,
elementId: elementToMoveId,
beforeElementId,
@ -159,42 +134,18 @@ export default {
notifyIf(error)
}
},
showAddElementModal(element, index, placement) {
this.beforeId =
placement === PLACEMENTS.BEFORE
? element.id
: this.elements[index + 1]?.id
this.$refs.addElementModal.show()
},
async addElement(elementType) {
this.addingElementType = elementType.getType()
try {
await this.actionCreateElement({
pageId: this.page.id,
elementType: elementType.getType(),
beforeId: this.beforeId,
})
this.$refs.addElementModal.hide()
} catch (error) {
notifyIf(error)
getPlacementsDisabled(index) {
const placementsDisabled = []
if (index === 0) {
placementsDisabled.push(PLACEMENTS.BEFORE)
}
this.addingElementType = null
},
async duplicateElement(element, index) {
this.copyingElementIndex = index
try {
await this.actionDuplicateElement({
pageId: this.page.id,
elementId: element.id,
})
this.$refs.addElementModal.hide()
} catch (error) {
notifyIf(error)
if (index === this.elements.length - 1) {
placementsDisabled.push(PLACEMENTS.AFTER)
}
this.copyingElementIndex = null
},
selectElement(element) {
this.actionSelectElement({ element })
return placementsDisabled
},
},
}

View file

@ -1,24 +1,10 @@
<template functional>
<!--
This element is supposed to be wrapping the root elements on a page. They allow
setting a width, background, borders, and more, but this only makes sense if they're
added to the root of the page. Child elements in for example a containing element must
not be wrapped by this component.
-->
<div
class="page-root-element"
:style="{
'padding-top': `${props.element.style_padding_top || 0}px`,
'padding-bottom': `${props.element.style_padding_bottom || 0}px`,
}"
>
<template>
<div :style="baseStyles">
<div class="page-root-element__inner">
<component
:is="$options.methods.getComponent(parent, props.element, props.mode)"
:element="props.element"
:builder="props.builder"
:mode="props.mode"
:page="props.page"
:is="component"
:element="element"
:children="children"
class="element"
/>
</div>
@ -26,33 +12,10 @@
</template>
<script>
import PageElement from '@baserow/modules/builder/mixins/pageElement'
export default {
name: 'PageRootElement',
props: {
element: {
type: Object,
required: true,
},
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,
},
mode: {
type: String,
required: false,
default: '',
},
},
methods: {
getComponent(parent, element, mode) {
const elementType = parent.$registry.get('element', element.type)
const componentName = mode === 'editing' ? 'editComponent' : 'component'
return elementType[componentName]
},
},
mixins: [PageElement],
}
</script>

View file

@ -13,23 +13,25 @@
<ElementsList
v-if="elementsMatchingSearchTerm.length"
:elements="elementsMatchingSearchTerm"
:element-selected="elementSelected"
@select="selectElement($event)"
/>
<div class="select__footer">
<div class="select__footer-create">
<AddElementButton
:class="{ 'margin-top-1': elementsMatchingSearchTerm.length === 0 }"
:class="{
'margin-top-1': elementsMatchingSearchTerm.length === 0,
}"
@click="$refs.addElementModal.show()"
/>
</div>
</div>
<AddElementModal
ref="addElementModal"
:adding-element-type="addingElementType"
:page="page"
@add="addElement"
/>
</div>
<AddElementModal
ref="addElementModal"
:page="page"
@element-added="onElementAdded"
/>
</Context>
</template>
@ -39,7 +41,6 @@ import ElementsList from '@baserow/modules/builder/components/elements/ElementsL
import AddElementButton from '@baserow/modules/builder/components/elements/AddElementButton'
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal'
import { mapActions, mapGetters } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { isSubstringOfStrings } from '@baserow/modules/core/utils/string'
export default {
@ -55,7 +56,8 @@ export default {
computed: {
...mapGetters({
page: 'page/getSelected',
elements: 'element/getElements',
elements: 'element/getRootElements',
elementSelected: 'element/getSelected',
}),
elementsMatchingSearchTerm() {
if (
@ -74,22 +76,10 @@ export default {
},
methods: {
...mapActions({
actionCreateElement: 'element/create',
actionSelectElement: 'element/select',
}),
async addElement(elementType) {
this.addingElementType = elementType.getType()
try {
await this.actionCreateElement({
pageId: this.page.id,
elementType: elementType.getType(),
})
this.hide()
this.$refs.addElementModal.hide()
} catch (error) {
notifyIf(error)
}
this.addingElementType = null
onElementAdded() {
this.hide()
},
selectElement(element) {
this.actionSelectElement({ element })

View file

@ -1,6 +1,6 @@
<template>
<component
:is="elementType.formComponent"
:is="elementType.generalFormComponent"
:key="element.id"
ref="panelForm"
class="element-form"

View file

@ -1,52 +0,0 @@
<template>
<form @submit.prevent>
<StyleBoxForm v-model="boxStyles.top" :label="$t('styleForm.boxTop')" />
<StyleBoxForm
v-model="boxStyles.bottom"
:label="$t('styleForm.boxBottom')"
/>
</form>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
import StyleBoxForm from '@baserow/modules/builder/components/page/sidePanels/StyleBoxForm'
export default {
components: { StyleBoxForm },
mixins: [form],
props: {},
data() {
return {
allowedValues: ['style_padding_top', 'style_padding_bottom'],
values: {
style_padding_top: 0,
style_padding_bottom: 0,
},
boxStyles: Object.fromEntries(
['top', 'bottom'].map((pos) => [pos, this.getBoxStyleValue(pos)])
),
}
},
watch: {
boxStyles: {
deep: true,
handler(newValue) {
Object.entries(newValue).forEach(([prop, value]) => {
this.setBoxStyleValue(prop, value)
})
},
},
},
methods: {
getBoxStyleValue(pos) {
return { padding: this.defaultValues[`style_padding_${pos}`] }
},
setBoxStyleValue(pos, newValue) {
if (newValue.padding !== undefined) {
this.values[`style_padding_${pos}`] = newValue.padding
}
},
},
}
</script>

View file

@ -1,19 +1,19 @@
<template>
<StyleForm
:key="element.id"
<component
:is="elementType.styleFormComponent"
ref="panelForm"
:default-values="element"
:key="element.id"
:element="element"
:parent-element="parentElement"
:default-values="defaultValues"
@values-changed="onChange($event)"
/>
></component>
</template>
<script>
import elementSidePanel from '@baserow/modules/builder/mixins/elementSidePanel'
import StyleForm from '@baserow/modules/builder/components/page/sidePanels/StyleForm.vue'
export default {
name: 'StyleSidePanel',
components: { StyleForm },
mixins: [elementSidePanel],
}
</script>

View file

@ -2,17 +2,20 @@ import { Registerable } from '@baserow/modules/core/registry'
import ParagraphElement from '@baserow/modules/builder/components/elements/components/ParagraphElement'
import HeadingElement from '@baserow/modules/builder/components/elements/components/HeadingElement'
import LinkElement from '@baserow/modules/builder/components/elements/components/LinkElement'
import ParagraphElementForm from '@baserow/modules/builder/components/elements/components/forms/ParagraphElementForm'
import HeadingElementForm from '@baserow/modules/builder/components/elements/components/forms/HeadingElementForm'
import LinkElementForm from '@baserow/modules/builder/components/elements/components/forms/LinkElementForm'
import ImageElementForm from '@baserow/modules/builder/components/elements/components/forms/ImageElementForm'
import ParagraphElementForm from '@baserow/modules/builder/components/elements/components/forms/general/ParagraphElementForm'
import HeadingElementForm from '@baserow/modules/builder/components/elements/components/forms/general/HeadingElementForm'
import LinkElementForm from '@baserow/modules/builder/components/elements/components/forms/general/LinkElementForm'
import ImageElementForm from '@baserow/modules/builder/components/elements/components/forms/general/ImageElementForm'
import ImageElement from '@baserow/modules/builder/components/elements/components/ImageElement'
import InputTextElement from '@baserow/modules/builder/components/elements/components/InputTextElement.vue'
import InputTextElementForm from '@baserow/modules/builder/components/elements/components/forms/InputTextElementForm.vue'
import { PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS } from '@baserow/modules/builder/enums'
import { compile } from 'path-to-regexp'
import ColumnElement from '@baserow/modules/builder/components/elements/components/ColumnElement'
import ColumnElementForm from '@baserow/modules/builder/components/elements/components/forms/general/ColumnElementForm'
import _ from 'lodash'
import DefaultStyleForm from '@baserow/modules/builder/components/elements/components/forms/style/DefaultStyleForm'
import { compile } from 'path-to-regexp'
export class ElementType extends Registerable {
get name() {
@ -35,10 +38,22 @@ export class ElementType extends Registerable {
return this.component
}
get formComponent() {
get generalFormComponent() {
return null
}
get styleFormComponent() {
return DefaultStyleForm
}
get stylesAll() {
return ['style_padding_top', 'style_padding_bottom']
}
get styles() {
return this.stylesAll
}
/**
* Returns whether the element configuration is valid or not.
* @param {object} param An object containing the element and the builder
@ -60,6 +75,73 @@ export class ElementType extends Registerable {
}
}
export class ContainerElementType extends ElementType {
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')
}
}
export class ColumnElementType extends ContainerElementType {
getType() {
return 'column'
}
get name() {
return this.app.i18n.t('elementType.column')
}
get description() {
return this.app.i18n.t('elementType.columnDescription')
}
get iconClass() {
return 'columns'
}
get component() {
return ColumnElement
}
get generalFormComponent() {
return ColumnElementForm
}
get childElementTypesForbidden() {
return this.elementTypesAll.filter(
(elementType) => elementType instanceof ContainerElementType
)
}
get defaultPlaceInContainer() {
return '0'
}
}
export class HeadingElementType extends ElementType {
static getType() {
return 'heading'
@ -81,7 +163,7 @@ export class HeadingElementType extends ElementType {
return HeadingElement
}
get formComponent() {
get generalFormComponent() {
return HeadingElementForm
}
}
@ -107,7 +189,7 @@ export class ParagraphElementType extends ElementType {
return ParagraphElement
}
get formComponent() {
get generalFormComponent() {
return ParagraphElementForm
}
}
@ -133,7 +215,7 @@ export class LinkElementType extends ElementType {
return LinkElement
}
get formComponent() {
get generalFormComponent() {
return LinkElementForm
}
@ -223,7 +305,7 @@ export class ImageElementType extends ElementType {
return ImageElement
}
get formComponent() {
get generalFormComponent() {
return ImageElementForm
}
}

View file

@ -3,16 +3,11 @@ import {
ensureString,
} from '@baserow/modules/core/utils/validator'
export const DIRECTIONS = {
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
}
export const PLACEMENTS = {
BEFORE: 'before',
AFTER: 'after',
LEFT: 'left',
RIGHT: 'right',
}
export const PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS = {
numeric: ensureInteger,
@ -24,20 +19,35 @@ export const IMAGE_SOURCE_TYPES = {
URL: 'url',
}
export const ALIGNMENTS = {
export const HORIZONTAL_ALIGNMENTS = {
LEFT: {
name: 'alignmentSelector.alignmentLeft',
name: 'horizontalAlignmentSelector.alignmentLeft',
value: 'left',
icon: 'align-left',
},
CENTER: {
name: 'alignmentSelector.alignmentCenter',
name: 'horizontalAlignmentSelector.alignmentCenter',
value: 'center',
icon: 'align-center',
},
RIGHT: {
name: 'alignmentSelector.alignmentRight',
name: 'horizontalAlignmentSelector.alignmentRight',
value: 'right',
icon: 'align-right',
},
}
export const VERTICAL_ALIGNMENTS = {
TOP: {
name: 'verticalAlignmentSelector.alignmentTop',
value: 'top',
},
CENTER: {
name: 'verticalAlignmentSelector.alignmentCenter',
value: 'center',
},
BOTTOM: {
name: 'verticalAlignmentSelector.alignmentBottom',
value: 'bottom',
},
}

View file

@ -63,7 +63,9 @@
"image": "Image",
"imageDescription": "Display image",
"inputText": "Text input",
"inputTextDescription": "A text input field"
"inputTextDescription": "A text input field",
"column": "Columns",
"columnDescription": "Columns container"
},
"addElementButton": {
"label": "Element"
@ -74,7 +76,9 @@
},
"elementMenu": {
"moveUp": "Move up",
"moveDown": "Move down"
"moveDown": "Move down",
"moveLeft": "Move left",
"moveRight": "Move right"
},
"duplicatePageJobType": {
"duplicating": "Duplicating",
@ -132,6 +136,12 @@
"placeholderPlaceholder": "Enter a placeholder (optional)",
"requiredTitle": "Required"
},
"columnElementForm": {
"columnAmountTitle": "Layout",
"columnAmountName": "no columns | 1 column | {columnAmount} columns",
"columnGapTitle": "Space between columns",
"columnGapPlaceholder": "Enter space between columns..."
},
"domainSettings": {
"titleOverview": "Domains",
"titleAddDomain": "Add domain",
@ -181,12 +191,18 @@
"paramsInErrorButton": "Update parameters",
"pageParameterTypeError": "Invalid type"
},
"alignmentSelector": {
"alignment": "Alignment",
"horizontalAlignmentSelector": {
"alignment": "Horizontal alignment",
"alignmentLeft": "Link",
"alignmentCenter": "Center",
"alignmentRight": "Right"
},
"verticalAlignmentSelector": {
"alignment": "Vertical alignment",
"alignmentTop": "Top",
"alignmentCenter": "Middle",
"alignmentBottom": "Bottom"
},
"pageSettingsTypes": {
"pageName": "Page"
},
@ -232,7 +248,7 @@
"noDataSourceTitle": "You have not yet added a data",
"noDataSourceMessage": "Data can be used to fetch from internal or external sources and display it on the page."
},
"styleForm": {
"defaultStyleForm": {
"boxTop": "Padding top",
"boxBottom": "Padding bottom"
},

View file

@ -0,0 +1,12 @@
import element from '@baserow/modules/builder/mixins/element'
export default {
mixins: [element],
props: {
children: {
type: Array,
required: false,
default: () => [],
},
},
}

View file

@ -2,29 +2,17 @@ import RuntimeFormulaContext from '@baserow/modules/core/runtimeFormulaContext'
import { resolveFormula } from '@baserow/formula'
export default {
inject: ['builder', 'page', 'mode'],
props: {
element: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,
},
builder: {
type: Object,
required: true,
},
mode: {
// editing = being editing by the page editor
// preview = previewing the application
// public = publicly published application
type: String,
required: false,
default: '',
},
},
computed: {
elementType() {
return this.$registry.get('element', this.element.type)
},
isEditable() {
return this.mode === 'editing'
},

View file

@ -19,6 +19,12 @@ export default {
return null
},
parentElement() {
return this.$store.getters['element/getElementById'](
this.element?.parent_element_id
)
},
defaultValues() {
return this.element
},

View file

@ -0,0 +1,62 @@
import _ from 'lodash'
export default {
props: {
element: {
type: Object,
required: true,
},
mode: {
type: String,
required: false,
default: '',
},
},
computed: {
component() {
const elementType = this.$registry.get('element', this.element.type)
const componentName =
this.mode === 'editing' ? 'editComponent' : 'component'
return elementType[componentName]
},
children() {
return this.$store.getters['element/getChildren'](this.element)
},
allowedStyles() {
const parentElement = this.$store.getters['element/getElementById'](
this.element.parent_element_id
)
const elementType = this.$registry.get('element', this.element.type)
const parentElementType = this.parentElement
? this.$registry.get('element', parentElement?.type)
: null
return !parentElementType
? elementType.styles
: _.difference(
elementType.styles,
parentElementType.childStylesForbidden
)
},
baseStyles() {
const stylesAllowed = this.allowedStyles
const styles = {
style_padding_top: {
'padding-top': `${this.element.style_padding_top || 0}px`,
},
style_padding_bottom: {
'padding-bottom': `${this.element.style_padding_bottom || 0}px`,
},
}
return Object.keys(styles).reduce((acc, key) => {
if (stylesAllowed.includes(key)) {
acc = { ...acc, ...styles[key] }
}
return acc
}, {})
},
},
}

View file

@ -0,0 +1,71 @@
import _ from 'lodash'
import form from '@baserow/modules/core/mixins/form'
export default {
props: {
element: {
type: Object,
required: true,
},
parentElement: {
type: Object,
required: false,
default: null,
},
},
mixins: [form],
data() {
const allowedValues = this.getAllowedValues()
return {
allowedValues,
values: this.getValuesFromElement(allowedValues),
boxStyles: Object.fromEntries(
['top', 'bottom'].map((pos) => [pos, this.getBoxStyleValue(pos)])
),
}
},
watch: {
boxStyles: {
deep: true,
handler(newValue) {
Object.entries(newValue).forEach(([prop, value]) => {
this.setBoxStyleValue(prop, value)
})
},
},
},
methods: {
isStyleAllowed(style) {
return this.allowedValues.includes(style)
},
getBoxStyleValue(pos) {
return { padding: this.defaultValues[`style_padding_${pos}`] }
},
setBoxStyleValue(pos, newValue) {
if (newValue.padding !== undefined) {
this.values[`style_padding_${pos}`] = newValue.padding
}
},
getAllowedValues() {
const elementType = this.$registry.get('element', this.element.type)
const parentElementType = this.parentElement
? this.$registry.get('element', this.parentElement?.type)
: null
if (!parentElementType) {
return elementType.styles
}
return _.difference(
elementType.styles,
parentElementType.childStylesForbidden
)
},
getValuesFromElement(allowedValues) {
return allowedValues.reduce((obj, value) => {
obj[value] = this.element[value] || null
return obj
}, {})
},
},
}

View file

@ -23,10 +23,7 @@ export default {
name: 'PageEditor',
components: { PagePreview, PageHeader, PageSidePanels },
provide() {
return {
builder: this.builder,
mode: 'editing',
}
return { builder: this.builder, page: this.page, mode: 'editing' }
},
/**
* When the user leaves to another page we want to unselect the selected page. This

View file

@ -16,7 +16,7 @@ import { mapGetters } from 'vuex'
export default {
components: { PageContent },
provide() {
return { builder: this.builder, mode: this.mode }
return { builder: this.builder, page: this.page, mode: this.mode }
},
async asyncData(context) {
let builder = context.store.getters['publicBuilder/getBuilder']
@ -112,7 +112,7 @@ export default {
},
computed: {
...mapGetters({
elements: 'element/getElements',
elements: 'element/getRootElements',
}),
},
}

View file

@ -26,6 +26,7 @@ import {
ParagraphElementType,
LinkElementType,
InputTextElementType,
ColumnElementType,
} from '@baserow/modules/builder/elementTypes'
import {
DesktopDeviceType,
@ -121,6 +122,7 @@ export default (context) => {
app.$registry.register('element', new LinkElementType(context))
app.$registry.register('element', new ImageElementType(context))
app.$registry.register('element', new InputTextElementType(context))
app.$registry.register('element', new ColumnElementType(context))
app.$registry.register('device', new DesktopDeviceType(context))
app.$registry.register('device', new TabletDeviceType(context))

View file

@ -83,6 +83,8 @@ export const registerRealtimeEvents = (realtime) => {
store.dispatch('element/forceMove', {
elementId: data.element_id,
beforeElementId: data.before_id,
parentElementId: data.parent_element_id,
placeInContainer: data.place_in_container,
})
}
})
@ -98,4 +100,16 @@ export const registerRealtimeEvents = (realtime) => {
}
}
)
realtime.registerEvent('elements_moved', ({ store, app }, { elements }) => {
elements.forEach((element) => {
store.dispatch('element/forceUpdate', {
element,
values: {
order: element.order,
place_in_container: element.place_in_container,
},
})
})
})
}

View file

@ -21,9 +21,11 @@ export default (client) => {
delete(elementId) {
return client.delete(`builder/element/${elementId}/`)
},
move(elementId, beforeId) {
move(elementId, beforeId, parentElementId, placeInContainer) {
return client.patch(`builder/element/${elementId}/move/`, {
before_id: beforeId,
parent_element_id: parentElementId,
place_in_container: placeInContainer,
})
},
}

View file

@ -1,5 +1,7 @@
import ElementService from '@baserow/modules/builder/services/element'
import PublicBuilderService from '@baserow/modules/builder/services/publishedBuilder'
import { calculateTempOrder } from '@baserow/modules/core/utils/order'
import BigNumber from 'bignumber.js'
const state = {
// The elements of the currently selected page
@ -34,6 +36,9 @@ const mutations = {
Object.assign(element, values)
}
})
if (state.selected?.id === elementToUpdate.id) {
Object.assign(state.selected, values)
}
},
DELETE_ITEM(state, { elementId }) {
const index = state.elements.findIndex(
@ -70,20 +75,28 @@ const actions = {
}
commit('DELETE_ITEM', { elementId })
},
forceMove({ commit, getters }, { elementId, beforeElementId }) {
const currentOrder = getters.getElements.map((element) => element.id)
const oldIndex = currentOrder.findIndex((id) => id === elementId)
const index = beforeElementId
? currentOrder.findIndex((id) => id === beforeElementId)
: getters.getElements.length
forceMove(
{ commit, getters },
{ elementId, beforeElementId, parentElementId, placeInContainer }
) {
const beforeElement = getters.getElementById(beforeElementId)
const afterOrder = beforeElement?.order || null
const beforeOrder =
getters.getPreviousElement(
beforeElement,
parentElementId,
placeInContainer
)?.order || null
const tempOrder = calculateTempOrder(beforeOrder, afterOrder)
// If the element is before the beforeElement we must decrease the target index by
// one to compensate the removed element.
if (oldIndex < index) {
commit('MOVE_ITEM', { index: index - 1, oldIndex })
} else {
commit('MOVE_ITEM', { index, oldIndex })
}
commit('UPDATE_ITEM', {
element: getters.getElementById(elementId),
values: {
order: tempOrder,
parent_element_id: parentElementId,
place_in_container: placeInContainer,
},
})
},
select({ commit }, { element }) {
updateContext.lastUpdatedValues = null
@ -91,7 +104,13 @@ const actions = {
},
async create(
{ dispatch },
{ pageId, elementType, beforeId = null, configuration = null }
{
pageId,
elementType,
beforeId = null,
configuration = null,
forceCreate = true,
}
) {
const { data: element } = await ElementService(this.$client).create(
pageId,
@ -100,7 +119,12 @@ const actions = {
configuration
)
await dispatch('forceCreate', { element, beforeId })
if (forceCreate) {
await dispatch('forceCreate', { element, beforeId })
await dispatch('select', { element })
}
return element
},
async update({ dispatch }, { element, values }) {
const elementType = this.$registry.get('element', element.type)
@ -215,37 +239,159 @@ const actions = {
return elements
},
async move({ dispatch }, { elementId, beforeElementId }) {
async fetchPublic({ dispatch, commit }, { page }) {
commit('CLEAR_ITEMS')
const { data: elements } = await PublicBuilderService(
this.$client
).fetchPublicBuilderElements(page)
await Promise.all(
elements.map((element) => dispatch('forceCreate', { element }))
)
return elements
},
async move(
{ dispatch, getters },
{
elementId,
beforeElementId,
parentElementId = null,
placeInContainer = null,
}
) {
const element = getters.getElementById(elementId)
await dispatch('forceMove', {
elementId,
beforeElementId,
parentElementId,
placeInContainer,
})
try {
await ElementService(this.$client).move(elementId, beforeElementId)
const { data: elementUpdated } = await ElementService(this.$client).move(
elementId,
beforeElementId,
parentElementId,
placeInContainer
)
dispatch('forceUpdate', {
element: elementUpdated,
values: { ...elementUpdated },
})
} catch (error) {
await dispatch('forceMove', {
elementId: beforeElementId,
beforeElementId: elementId,
await dispatch('forceUpdate', {
element,
values: element,
})
throw error
}
},
async duplicate({ getters, dispatch }, { elementId, pageId }) {
async duplicate(
{ getters, dispatch },
{ elementId, pageId, configuration = {} }
) {
// TODO this duplication only works with one layer of children
const element = getters.getElements.find((e) => e.id === elementId)
await dispatch('create', {
const children = getters.getChildren(element)
const parentCreated = await dispatch('create', {
pageId,
beforeId: element.id,
elementType: element.type,
configuration: element,
configuration: { ...element, ...configuration },
forceCreate: false,
})
const childrenCreated = await Promise.all(
children.map((child) =>
dispatch('create', {
pageId,
elementType: child.type,
configuration: {
...child,
parent_element_id: parentCreated.id,
},
forceCreate: false,
})
)
)
await Promise.all(
childrenCreated.map((child) =>
dispatch('forceCreate', {
element: child,
})
)
)
// We insert the parent element at the end such that the children already exist
// in the frontend and won't just pop in one after the other
await dispatch('forceCreate', {
element: parentCreated,
beforeId: element.id,
})
return [parentCreated, ...childrenCreated]
},
}
const getters = {
getElements: (state) => {
return state.elements
getElementById: (state, getters) => (id) => {
return getters.getElements.find((e) => e.id === id)
},
getElements: (state) => {
return state.elements.map((element) => ({
...element,
order: new BigNumber(element.order),
}))
},
getElementsOrdered: (state, getters) => {
return [...getters.getElements].sort((a, b) => {
if (a.parent_element_id !== b.parent_element_id) {
return a.parent_element_id > b.parent_element_id ? 1 : -1
}
if (a.place_in_container !== b.place_in_container) {
return a.place_in_container > b.place_in_container ? 1 : -1
}
return a.order.gt(b.order) ? 1 : -1
})
},
getRootElements: (state, getters) => {
return getters.getElementsOrdered.filter(
(e) => e.parent_element_id === null
)
},
getChildren: (state, getters) => (element) => {
return getters.getElementsOrdered.filter(
(e) => e.parent_element_id === element.id
)
},
getSiblings: (state, getters) => (element) => {
return getters.getElementsOrdered.filter(
(e) => e.parent_element_id === element.parent_element_id
)
},
getElementsInPlace: (state, getters) => (parentId, placeInContainer) => {
return getters.getElementsOrdered.filter(
(e) =>
e.parent_element_id === parentId &&
e.place_in_container === placeInContainer
)
},
getPreviousElement:
(state, getters) => (before, parentId, placeInContainer) => {
const elementsInPlace = getters.getElementsInPlace(
parentId,
placeInContainer
)
return before
? elementsInPlace.reverse().find((e) => e.order.lt(before.order)) ||
null
: elementsInPlace.at(-1)
},
getSelected(state) {
return state.selected
},

View file

@ -0,0 +1,25 @@
.add-element-zone__icon {
border: solid 1px $color-neutral-300;
border-radius: 100%;
padding: 8px;
}
.add-element-zone {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 70px;
border: dashed 2px $color-neutral-300;
border-radius: 6px;
cursor: pointer;
&:hover {
color: $color-primary-500;
border-color: $color-primary-500;
.add-element-zone__icon {
border-color: $color-primary-500;
}
}
}

View file

@ -21,3 +21,4 @@
@import 'data_source_context';
@import 'data_source_form';
@import 'preview_navigation_bar';
@import 'add_element_zone';

View file

@ -6,14 +6,26 @@
opacity: 0.3;
}
.element--alignment-left {
.element--alignment-vertical-top {
align-items: start;
}
.element--alignment-vertical-center {
align-items: center;
}
.element--alignment-vertical-bottom {
align-items: end;
}
.element--alignment-horizontal-left {
justify-content: start;
}
.element--alignment-center {
.element--alignment-horizontal-center {
justify-content: center;
}
.element--alignment-right {
.element--alignment-horizontal-right {
justify-content: end;
}

View file

@ -4,3 +4,4 @@
@import 'link_element';
@import 'image_element';
@import 'input_element';
@import 'column_element';

View file

@ -0,0 +1,15 @@
.column-element {
display: flex;
width: 100%;
justify-content: space-between;
gap: var(--space-between-columns);
align-items: var(--alignment);
}
.column-element__column {
width: var(--column-width);
}
.column-element__element {
width: 100%;
}

View file

@ -35,11 +35,11 @@
width: 100%;
}
.element--alignment-center & {
.element--alignment-horizontal-center & {
text-align: center;
}
.element--alignment-right & {
.element--alignment-horizontal-right & {
text-align: right;
}
}

View file

@ -95,6 +95,14 @@
background-color: $color-neutral-100;
}
&--selected {
background-color: $color-primary-100;
}
&--indented {
margin-left: 20px;
}
&.select__item--loading::before {
content: ' ';

View file

@ -0,0 +1,36 @@
export const dimensionMixin = {
data() {
return {
dimensions: {
targetElement: null,
width: 1920,
height: 1080,
},
}
},
mounted() {
// Gives you time to set the targetElement in the mounted function of the component
// using this mixin
this.$nextTick(() => {
this.dimensions.targetElement = this.dimensions.targetElement || this.$el
this.resizeObserver = new ResizeObserver(this.updateElementSize)
this.resizeObserver.observe(this.dimensions.targetElement)
})
},
beforeDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
methods: {
updateElementSize(entries) {
for (const entry of entries) {
if (entry.target === this.dimensions.targetElement) {
const { width, height } = entry.contentRect
this.dimensions.width = width
this.dimensions.height = height
}
}
},
},
}

View file

@ -0,0 +1,29 @@
/**
* This function lets you create a new order for an object that is between 2 other
* objects. It won't match exactly what the backend will calculate but it approximates
* it.
*
* @param beforeOrder
* @param afterOrder
* @returns {string|*}
*/
import BigNumber from 'bignumber.js'
export function calculateTempOrder(beforeOrder, afterOrder) {
const beforeOrderCasted = new BigNumber(beforeOrder)
const afterOrderCasted = new BigNumber(afterOrder)
if (beforeOrder === null && afterOrder === null) {
return '1'
}
if (afterOrder === null) {
return beforeOrderCasted.plus(1).toString()
}
if (beforeOrder === null) {
return afterOrderCasted.dividedBy(2).toString()
}
return beforeOrderCasted.plus(afterOrderCasted).dividedBy(2).toString()
}

View file

@ -0,0 +1,45 @@
import { calculateTempOrder } from '@baserow/modules/core/utils/order'
describe('Order', () => {
describe('calculateTempOrder', () => {
it('should return 1 if beforeOrder and afterOrder are null', () => {
const beforeOrder = null
const afterOrder = null
const expected = '1'
const result = calculateTempOrder(beforeOrder, afterOrder)
expect(result).toEqual(expected)
})
it('should return beforeOrder + 1 if afterOrder is null', () => {
const beforeOrder = '1'
const afterOrder = null
const expected = '2'
const result = calculateTempOrder(beforeOrder, afterOrder)
expect(result).toEqual(expected)
})
it('should return afterOrder / 2 if beforeOrder is null', () => {
const beforeOrder = null
const afterOrder = '2'
const expected = '1'
const result = calculateTempOrder(beforeOrder, afterOrder)
expect(result).toEqual(expected)
})
it('should return (beforeOrder + afterOrder) / 2 if beforeOrder and afterOrder are not null', () => {
const beforeOrder = '1'
const afterOrder = '3'
const expected = '2'
const result = calculateTempOrder(beforeOrder, afterOrder)
expect(result).toEqual(expected)
})
it('should be able to handle numbers with a lot of decimals', () => {
const beforeOrder = '1.55555555555'
const afterOrder = '1.577777777777'
const expected = '1.5666666666635'
const result = calculateTempOrder(beforeOrder, afterOrder)
expect(result).toEqual(expected)
})
})
})