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:
parent
67c059c3a0
commit
5fc28f50c8
73 changed files with 2954 additions and 408 deletions
backend
src/baserow
contrib/builder
test_utils/fixtures
tests/baserow/contrib/builder
web-frontend
locales
modules
builder
components
elements
page
locales
mixins
pages
plugin.jsrealtime.jsservices
store
core
test/unit/core/utils
|
@ -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},
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -2,6 +2,4 @@ from typing import NewType
|
|||
|
||||
from .models import DataSource
|
||||
|
||||
Expression = str
|
||||
|
||||
DataSourceForUpdate = NewType("DataSourceForUpdate", DataSource)
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -4,4 +4,5 @@ element_created = Signal()
|
|||
element_deleted = Signal()
|
||||
element_updated = Signal()
|
||||
element_moved = Signal()
|
||||
elements_moved = Signal()
|
||||
element_orders_recalculated = Signal()
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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):
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
|
@ -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
|
||||
|
|
|
@ -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")
|
|
@ -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
|
||||
]
|
|
@ -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 == []
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
},
|
|
@ -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,
|
|
@ -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: {
|
|
@ -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>
|
|
@ -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>
|
|
@ -4,8 +4,6 @@
|
|||
v-for="element in elements"
|
||||
:key="element.id"
|
||||
:element="element"
|
||||
:builder="builder"
|
||||
:page="page"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
|
|
19
web-frontend/modules/builder/components/page/PageElement.vue
Normal file
19
web-frontend/modules/builder/components/page/PageElement.vue
Normal 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>
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<component
|
||||
:is="elementType.formComponent"
|
||||
:is="elementType.generalFormComponent"
|
||||
:key="element.id"
|
||||
ref="panelForm"
|
||||
class="element-form"
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
12
web-frontend/modules/builder/mixins/containerElement.js
Normal file
12
web-frontend/modules/builder/mixins/containerElement.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import element from '@baserow/modules/builder/mixins/element'
|
||||
|
||||
export default {
|
||||
mixins: [element],
|
||||
props: {
|
||||
children: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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'
|
||||
},
|
||||
|
|
|
@ -19,6 +19,12 @@ export default {
|
|||
return null
|
||||
},
|
||||
|
||||
parentElement() {
|
||||
return this.$store.getters['element/getElementById'](
|
||||
this.element?.parent_element_id
|
||||
)
|
||||
},
|
||||
|
||||
defaultValues() {
|
||||
return this.element
|
||||
},
|
||||
|
|
62
web-frontend/modules/builder/mixins/pageElement.js
Normal file
62
web-frontend/modules/builder/mixins/pageElement.js
Normal 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
|
||||
}, {})
|
||||
},
|
||||
},
|
||||
}
|
71
web-frontend/modules/builder/mixins/styleForm.js
Normal file
71
web-frontend/modules/builder/mixins/styleForm.js
Normal 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
|
||||
}, {})
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,3 +21,4 @@
|
|||
@import 'data_source_context';
|
||||
@import 'data_source_form';
|
||||
@import 'preview_navigation_bar';
|
||||
@import 'add_element_zone';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -4,3 +4,4 @@
|
|||
@import 'link_element';
|
||||
@import 'image_element';
|
||||
@import 'input_element';
|
||||
@import 'column_element';
|
||||
|
|
|
@ -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%;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,6 +95,14 @@
|
|||
background-color: $color-neutral-100;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: $color-primary-100;
|
||||
}
|
||||
|
||||
&--indented {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
&.select__item--loading::before {
|
||||
content: ' ';
|
||||
|
||||
|
|
36
web-frontend/modules/core/mixins/dimensions.js
Normal file
36
web-frontend/modules/core/mixins/dimensions.js
Normal 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
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
29
web-frontend/modules/core/utils/order.js
Normal file
29
web-frontend/modules/core/utils/order.js
Normal 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()
|
||||
}
|
45
web-frontend/test/unit/core/utils/order.spec.js
Normal file
45
web-frontend/test/unit/core/utils/order.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Add table
Reference in a new issue