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

Resolve "I can add static Elements to a Builder application page"

This commit is contained in:
Jrmi 2023-03-17 10:59:23 +00:00
parent 3e43b4fb4f
commit 1eb25a29a8
95 changed files with 4876 additions and 353 deletions
backend
enterprise/backend
src/baserow_enterprise/role
tests/baserow_enterprise_tests
premium/backend/tests/baserow_premium_tests
web-frontend

View file

@ -359,6 +359,7 @@ SPECTACULAR_SETTINGS = {
{"name": "Database table webhooks"},
{"name": "Database tokens"},
{"name": "Builder pages"},
{"name": "Builder page elements"},
{"name": "Admin"},
],
"ENUM_NAME_OVERRIDES": {

View file

@ -0,0 +1,13 @@
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
ERROR_ELEMENT_DOES_NOT_EXIST = (
"ERROR_ELEMENT_DOES_NOT_EXIST",
HTTP_404_NOT_FOUND,
"The requested element does not exist.",
)
ERROR_ELEMENT_NOT_IN_PAGE = (
"ERROR_ELEMENT_NOT_IN_PAGE",
HTTP_400_BAD_REQUEST,
"The element id {e.element_id} does not belong to the page.",
)

View file

@ -0,0 +1,78 @@
from django.utils.functional import lazy
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.registries import element_type_registry
EXPRESSION_TYPES = [
("plain", "Plain"),
("formula", "Formula"),
("data", "Data"),
]
@extend_schema_field(OpenApiTypes.STR)
class ExpressionField(serializers.CharField):
"""
The expression field can be used to ensure the given data is an expression.
"""
class ElementSerializer(serializers.ModelSerializer):
"""
Basic element serializer mostly for returned values.
"""
type = serializers.SerializerMethodField(help_text="The type of the element.")
@extend_schema_field(OpenApiTypes.STR)
def get_type(self, instance):
return element_type_registry.get_by_model(instance.specific_class).type
class Meta:
model = Element
fields = ("id", "page_id", "type", "order")
extra_kwargs = {
"id": {"read_only": True},
"page_id": {"read_only": True},
"type": {"read_only": True},
"order": {"read_only": True, "help_text": "Lowest first."},
}
class CreateElementSerializer(serializers.ModelSerializer):
"""
This serializer allow to set the type of an element and the element id before which
we want to insert the new element.
"""
type = serializers.ChoiceField(
choices=lazy(element_type_registry.get_types, list)(),
required=True,
help_text="The type of the element.",
)
before_id = serializers.IntegerField(
required=False,
help_text="If provided, creates the element before the element with the "
"given id.",
)
class Meta:
model = Element
fields = ("before_id", "type")
class UpdateElementSerializer(serializers.ModelSerializer):
class Meta:
model = Element
fields = []
class OrderElementsSerializer(serializers.Serializer):
element_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text="The ids of the elements in the order they are supposed to be set in",
)

View file

@ -0,0 +1,23 @@
from django.urls import re_path
from baserow.contrib.builder.api.elements.views import (
ElementsView,
ElementView,
OrderElementsPageView,
)
app_name = "baserow.contrib.builder.api.elements"
urlpatterns = [
re_path(
r"page/(?P<page_id>[0-9]+)/elements/$",
ElementsView.as_view(),
name="list",
),
re_path(
r"page/(?P<page_id>[0-9]+)/elements/order/$",
OrderElementsPageView.as_view(),
name="order",
),
re_path(r"element/(?P<element_id>[0-9]+)/$", ElementView.as_view(), name="item"),
]

View file

@ -0,0 +1,306 @@
from typing import Dict
from django.db import transaction
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from baserow.api.decorators import (
map_exceptions,
validate_body,
validate_body_custom_fields,
)
from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema
from baserow.api.utils import (
CustomFieldRegistryMappingSerializer,
DiscriminatorCustomFieldsMappingSerializer,
type_from_data_or_registry,
validate_data_custom_fields,
)
from baserow.contrib.builder.api.elements.errors import (
ERROR_ELEMENT_DOES_NOT_EXIST,
ERROR_ELEMENT_NOT_IN_PAGE,
)
from baserow.contrib.builder.api.elements.serializers import (
CreateElementSerializer,
ElementSerializer,
OrderElementsSerializer,
UpdateElementSerializer,
)
from baserow.contrib.builder.api.pages.errors import ERROR_PAGE_DOES_NOT_EXIST
from baserow.contrib.builder.elements.exceptions import (
ElementDoesNotExist,
ElementNotInPage,
)
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.service import ElementService
from baserow.contrib.builder.pages.exceptions import PageDoesNotExist
from baserow.contrib.builder.pages.handler import PageHandler
class ElementsView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name="page_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="Returns only the elements of the page related to the "
"provided Id.",
)
],
tags=["Builder page elements"],
operation_id="list_builder_page_elements",
description=(
"Lists all the elements of the page related to the provided parameter if "
"the user has access to the related builder's workspace. "
"If the workspace is related to a template, then this endpoint will be "
"publicly accessible."
),
responses={
200: DiscriminatorCustomFieldsMappingSerializer(
element_type_registry, ElementSerializer, many=True
),
404: get_error_schema(["ERROR_PAGE_DOES_NOT_EXIST"]),
},
)
@map_exceptions(
{
PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST,
}
)
def get(self, request, page_id):
"""
Responds with a list of serialized elements that belong to the page if the user
has access to that page.
"""
page = PageHandler().get_page(page_id)
elements = ElementService().get_elements(request.user, page)
data = [
element_type_registry.get_serializer(element, ElementSerializer).data
for element in elements
]
return Response(data)
@extend_schema(
parameters=[
OpenApiParameter(
name="page_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="Creates an element for the builder page related to the "
"provided value.",
),
CLIENT_SESSION_ID_SCHEMA_PARAMETER,
],
tags=["Builder page elements"],
operation_id="create_builder_page_element",
description="Creates a new builder element",
request=DiscriminatorCustomFieldsMappingSerializer(
element_type_registry,
CreateElementSerializer,
),
responses={
200: DiscriminatorCustomFieldsMappingSerializer(
element_type_registry, ElementSerializer
),
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
]
),
404: get_error_schema(["ERROR_PAGE_DOES_NOT_EXIST"]),
},
)
@transaction.atomic
@map_exceptions(
{
PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST,
}
)
@validate_body_custom_fields(
element_type_registry, base_serializer_class=CreateElementSerializer
)
def post(self, request, data: Dict, page_id: int):
"""Creates a new element."""
type_name = data.pop("type")
page = PageHandler().get_page(page_id)
before_id = data.pop("before_id", None)
before = ElementHandler().get_element(before_id) if before_id else None
element_type = element_type_registry.get(type_name)
element = ElementService().create_element(
request.user, element_type, page, before=before, **data
)
serializer = element_type_registry.get_serializer(element, ElementSerializer)
return Response(serializer.data)
class OrderElementsPageView(APIView):
@extend_schema(
parameters=[
OpenApiParameter(
name="page_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="The page id we want to order the elements for.",
),
CLIENT_SESSION_ID_SCHEMA_PARAMETER,
],
tags=["Builder page elements"],
operation_id="order_builder_page_elements",
description="Apply a new order to the elements of the given page.",
request=OrderElementsSerializer,
responses={
204: None,
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_ELEMENT_NOT_IN_PAGE",
]
),
404: get_error_schema(["ERROR_PAGE_DOES_NOT_EXIST"]),
},
)
@transaction.atomic
@map_exceptions(
{
PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST,
ElementNotInPage: ERROR_ELEMENT_NOT_IN_PAGE,
}
)
@validate_body(OrderElementsSerializer)
def post(self, request, data: Dict, page_id: int):
"""
Change order of the pages to the given order.
"""
page = PageHandler().get_page(page_id)
ElementService().order_elements(request.user, page, data["element_ids"])
return Response(status=204)
class ElementView(APIView):
@extend_schema(
parameters=[
OpenApiParameter(
name="element_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="The id of the element",
),
CLIENT_SESSION_ID_SCHEMA_PARAMETER,
],
tags=["Builder page elements"],
operation_id="update_builder_page_element",
description="Updates an existing builder element.",
request=CustomFieldRegistryMappingSerializer(
element_type_registry,
UpdateElementSerializer,
),
responses={
200: DiscriminatorCustomFieldsMappingSerializer(
element_type_registry, ElementSerializer
),
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
]
),
404: get_error_schema(
[
"ERROR_ELEMENT_DOES_NOT_EXIST",
]
),
},
)
@transaction.atomic
@map_exceptions(
{
ElementDoesNotExist: ERROR_ELEMENT_DOES_NOT_EXIST,
}
)
def patch(self, request, element_id: int):
"""
Update an element.
"""
element = ElementHandler().get_element(
element_id,
base_queryset=Element.objects.select_for_update(of=("self",)),
)
type_name = type_from_data_or_registry(
request.data, element_type_registry, element
)
data = validate_data_custom_fields(
type_name,
element_type_registry,
request.data,
base_serializer_class=UpdateElementSerializer,
)
element_updated = ElementService().update_element(request.user, element, **data)
serializer = element_type_registry.get_serializer(
element_updated, ElementSerializer
)
return Response(serializer.data)
@extend_schema(
parameters=[
OpenApiParameter(
name="element_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="The id of the element",
),
CLIENT_SESSION_ID_SCHEMA_PARAMETER,
],
tags=["Builder page elements"],
operation_id="delete_builder_page_element",
description="Deletes the element related by the given id.",
responses={
204: None,
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
]
),
404: get_error_schema(["ERROR_ELEMENT_DOES_NOT_EXIST"]),
},
)
@transaction.atomic
@map_exceptions(
{
ElementDoesNotExist: ERROR_ELEMENT_DOES_NOT_EXIST,
}
)
@transaction.atomic
def delete(self, request, element_id: int):
"""
Deletes an element.
"""
element = ElementHandler().get_element(
element_id,
base_queryset=Element.objects.select_for_update(of=("self",)),
)
ElementService().delete_element(request.user, element)
return Response(status=204)

View file

@ -59,7 +59,6 @@ class PagesView(APIView):
200: PageSerializer,
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_REQUEST_BODY_VALIDATION",
]
),
@ -101,7 +100,6 @@ class PageView(APIView):
200: PageSerializer,
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_REQUEST_BODY_VALIDATION",
]
),
@ -140,7 +138,6 @@ class PageView(APIView):
204: None,
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_REQUEST_BODY_VALIDATION",
]
),
@ -181,7 +178,6 @@ class OrderPagesView(APIView):
204: None,
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_PAGE_NOT_IN_BUILDER",
]

View file

@ -1,5 +1,6 @@
from django.urls import include, path, re_path
from .elements import urls as element_urls
from .pages import urls as page_urls
app_name = "baserow.contrib.builder.api"
@ -22,6 +23,13 @@ paths_without_builder_id = [
namespace="pages",
),
),
path(
"",
include(
element_urls,
namespace="element",
),
),
]

View file

@ -1,6 +1,8 @@
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
from zipfile import ZipFile
from django.contrib.auth.models import AbstractUser
from django.core.files.storage import Storage
from django.db import transaction
from django.db.transaction import Atomic
from django.urls import include, path
@ -8,11 +10,14 @@ from django.utils import translation
from django.utils.translation import gettext as _
from baserow.contrib.builder.api.serializers import BuilderSerializer
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.models import Builder
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.pages.service import PageService
from baserow.core.models import Application
from baserow.contrib.builder.types import BuilderDict, PageDict
from baserow.contrib.database.constants import IMPORT_SERIALIZED_IMPORTING
from baserow.core.db import specific_iterator
from baserow.core.models import Application, Workspace
from baserow.core.registries import ApplicationType
from baserow.core.utils import ChildProgressBuilder
@ -38,44 +43,180 @@ class BuilderApplicationType(ApplicationType):
PageService().create_page(user, application.specific, first_page_name)
def export_pages_serialized(self, pages: List[Page]):
def export_pages_serialized(
self,
pages: List[Page],
files_zip: Optional[ZipFile] = None,
storage: Optional[Storage] = None,
) -> List[PageDict]:
"""
Exports all the pages given to a format that can be imported again to baserow
via `import_pages_serialized`
via `import_pages_serialized`.
:param pages: The pages that are supposed to be exported
:return:
:return: The list of serialized pages.
"""
from baserow.contrib.builder.api.pages.serializers import PageSerializer
serialized_pages: List[PageDict] = []
for page in pages:
return [PageSerializer(page).data for page in pages]
# Get serialized version of all elements of the current page
serialized_elements = []
for element in specific_iterator(page.element_set.all()):
element_type = element_type_registry.get_by_model(element)
serialized_elements.append(element_type.export_serialized(element))
serialized_pages.append(
PageDict(
id=page.id,
name=page.name,
order=page.order,
elements=serialized_elements,
)
)
return serialized_pages
def export_serialized(
self,
builder: Builder,
files_zip: Optional[ZipFile] = None,
storage: Optional[Storage] = None,
) -> BuilderDict:
"""
Exports the builder application type to a serialized format that can later
be imported via the `import_serialized`.
"""
pages = builder.page_set.all().prefetch_related(
"element_set",
)
serialized_pages = self.export_pages_serialized(pages, files_zip, storage)
serialized = super().export_serialized(builder, files_zip, storage)
return BuilderDict(pages=serialized_pages, **serialized)
def _ops_count_for_import_pages_serialized(
self,
serialized_pages: List[Dict[str, Any]],
) -> int:
return (
# Creating each page
len(serialized_pages)
+ sum(
[
# Inserting every field
len(page["elements"])
for page in serialized_pages
]
)
)
def import_pages_serialized(
self,
builder: Builder,
serialized_pages: List[Dict[str, any]],
serialized_pages: List[Dict[str, Any]],
id_mapping: Dict[str, Any],
files_zip: Optional[ZipFile] = None,
storage: Optional[Storage] = None,
progress_builder: Optional[ChildProgressBuilder] = None,
):
) -> List[Page]:
"""
Import pages to builder. This method has to be compatible with the output
of `export_pages_serialized`
of `export_pages_serialized`.
:param builder: The builder the pages where exported from
:param serialized_pages: The pages that are supposed to be imported
:param progress_builder: A progress builder that allows for publishing progress
:return: The created page instances
:param builder: The builder the pages where exported from.
:param serialized_pages: The pages that are supposed to be imported.
:param progress_builder: A progress builder that allows for publishing progress.
:param files_zip: An optional zip file for the related files.
:param storage: The storage instance.
:return: The created page instances.
"""
child_total = self._ops_count_for_import_pages_serialized(serialized_pages)
progress = ChildProgressBuilder.build(progress_builder, child_total=child_total)
if "import_workspace_id" not in id_mapping and builder.workspace is not None:
id_mapping["import_workspace_id"] = builder.workspace.id
if "builder_pages" not in id_mapping:
id_mapping["builder_pages"] = {}
if "workspace_id" not in id_mapping and builder.workspace is not None:
id_mapping["workspace_id"] = builder.workspace.id
imported_pages: List[Page] = []
# First, we want to create all the page instances because it could be that
# element depends on the existence of a page.
for serialized_page in serialized_pages:
page_instance = Page.objects.create(
builder=builder,
name=serialized_page["name"],
order=serialized_page["order"],
)
id_mapping["builder_pages"][serialized_page["id"]] = page_instance.id
serialized_page["_object"] = page_instance
serialized_page["_element_objects"] = []
imported_pages.append(page_instance)
progress.increment(state=IMPORT_SERIALIZED_IMPORTING)
# 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
)
serialized_page["_element_objects"].append(element_instance)
progress.increment(state=IMPORT_SERIALIZED_IMPORTING)
return imported_pages
def import_serialized(
self,
workspace: Workspace,
serialized_values: Dict[str, Any],
id_mapping: Dict[str, Any],
files_zip: Optional[ZipFile] = None,
storage: Optional[Storage] = None,
progress_builder: Optional[ChildProgressBuilder] = None,
) -> Application:
"""
Imports a builder application exported by the `export_serialized` method.
"""
serialized_pages = serialized_values.pop("pages")
builder_progress, page_progress = 5, 95
progress = ChildProgressBuilder.build(
progress_builder, child_total=len(serialized_pages)
progress_builder, child_total=builder_progress + page_progress
)
pages = []
application = super().import_serialized(
workspace,
serialized_values,
id_mapping,
files_zip,
storage,
progress.create_child_builder(represents_progress=builder_progress),
)
for page in serialized_pages:
page = PageHandler().create_page(builder, page["name"])
pages.append(page)
progress.increment(1)
builder = application.specific
return pages
if not serialized_pages:
progress.increment(state=IMPORT_SERIALIZED_IMPORTING, by=page_progress)
else:
self.import_pages_serialized(
builder,
serialized_pages,
id_mapping,
files_zip,
storage,
progress.create_child_builder(represents_progress=page_progress),
)
return builder

View file

@ -13,6 +13,9 @@ class BuilderConfig(AppConfig):
application_type_registry.register(BuilderApplicationType())
from baserow.contrib.builder.elements.object_scopes import (
BuilderElementObjectScopeType,
)
from baserow.contrib.builder.object_scopes import BuilderObjectScopeType
from baserow.contrib.builder.pages.object_scopes import (
BuilderPageObjectScopeType,
@ -20,6 +23,7 @@ class BuilderConfig(AppConfig):
object_scope_type_registry.register(BuilderObjectScopeType())
object_scope_type_registry.register(BuilderPageObjectScopeType())
object_scope_type_registry.register(BuilderElementObjectScopeType())
from baserow.contrib.builder.operations import (
ListPagesBuilderOperationType,
@ -48,6 +52,28 @@ class BuilderConfig(AppConfig):
job_type_registry.register(DuplicatePageJobType())
from baserow.contrib.builder.elements.operations import (
CreateElementOperationType,
DeleteElementOperationType,
ListElementsPageOperationType,
OrderElementsPageOperationType,
ReadElementOperationType,
UpdateElementOperationType,
)
operation_type_registry.register(ListElementsPageOperationType())
operation_type_registry.register(OrderElementsPageOperationType())
operation_type_registry.register(CreateElementOperationType())
operation_type_registry.register(ReadElementOperationType())
operation_type_registry.register(UpdateElementOperationType())
operation_type_registry.register(DeleteElementOperationType())
from .elements.element_types import HeadingElementType, ParagraphElementType
from .elements.registries import element_type_registry
element_type_registry.register(HeadingElementType())
element_type_registry.register(ParagraphElementType())
# The signals must always be imported last because they use the registries
# which need to be filled first.
import baserow.contrib.builder.ws.signals # noqa: F403, F401

View file

@ -0,0 +1,90 @@
from abc import ABC
from rest_framework import serializers
from baserow.contrib.builder.elements.models import HeadingElement, ParagraphElement
from baserow.contrib.builder.elements.registries import ElementType
from baserow.contrib.builder.elements.types import Expression
from baserow.contrib.builder.types import ElementDict
class BaseTextElementType(ElementType, ABC):
"""
Base class for text elements.
"""
serializer_field_names = ["value"]
allowed_fields = ["value"]
class SerializedDict(ElementDict):
value: Expression
@property
def serializer_field_overrides(self):
from baserow.contrib.builder.api.elements.serializers import ExpressionField
return {
"value": ExpressionField(
help_text="The value of the element. Must be an expression.",
required=False,
default="",
),
}
class HeadingElementType(BaseTextElementType):
"""
A simple heading element that can be used to display a title.
"""
type = "heading"
model_class = HeadingElement
class SerializedDict(ElementDict):
value: Expression
level: int
@property
def serializer_field_names(self):
return super().serializer_field_names + ["level"]
@property
def allowed_fields(self):
return super().allowed_fields + ["level"]
@property
def serializer_field_overrides(self):
overrides = {
"level": serializers.IntegerField(
help_text="The level of the heading from 1 to 6.",
min_value=1,
max_value=6,
default=1,
)
}
overrides.update(super().serializer_field_overrides)
return overrides
def get_sample_params(self):
return {
"value": "Corporis perspiciatis",
"level": 2,
}
class ParagraphElementType(BaseTextElementType):
"""
A simple paragraph element that can be used to display a paragraph of text.
"""
type = "paragraph"
model_class = ParagraphElement
def get_sample_params(self):
return {
"value": "Suscipit maxime eos ea vel commodi dolore. "
"Eum dicta sit rerum animi. Sint sapiente eum cupiditate nobis vel. "
"Maxime qui nam consequatur. "
"Asperiores corporis perspiciatis nam harum veritatis. "
"Impedit qui maxime aut illo quod ea molestias."
}

View file

@ -0,0 +1,14 @@
class ElementDoesNotExist(Exception):
"""Raised when trying to get an element that doesn't exist."""
class ElementNotInPage(Exception):
"""Raised when trying to get an element that does not belong to the correct page"""
def __init__(self, element_id=None, *args, **kwargs):
self.element_id = element_id
super().__init__(
f"The element {element_id} does not belong to the page.",
*args,
**kwargs,
)

View file

@ -0,0 +1,146 @@
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,
element_type_registry,
)
from baserow.contrib.builder.pages.models import Page
from baserow.core.db import specific_iterator
from baserow.core.utils import extract_allowed
class ElementHandler:
def get_element(self, element_id: int, base_queryset=None) -> Element:
"""
Returns an element instance from the database.
:param element_id: The ID of the element.
:raises ElementDoesNotExist: If the element can't be found.
:return: The element instance.
"""
queryset = base_queryset if base_queryset is not None else Element.objects.all()
try:
element = (
queryset.select_related(
"page", "page__builder", "page__builder__workspace"
)
.get(id=element_id)
.specific
)
except Element.DoesNotExist:
raise ElementDoesNotExist()
return element
def get_elements(
self,
page: Page,
base_queryset: Optional[QuerySet] = None,
specific: bool = True,
) -> Union[QuerySet[Page], Iterable[Page]]:
"""
Gets all the specific elements of a given page.
:param page: The page that holds the elements.
:param base_queryset: The base queryset to use to build the query.
:param specific: Whether to return the generic elements or the specific
instances.
:return: The elements of that page.
"""
queryset = base_queryset if base_queryset is not None else Element.objects.all()
queryset = queryset.filter(page=page)
if specific:
queryset = queryset.select_related("content_type")
return specific_iterator(queryset)
else:
return queryset
def create_element(
self, element_type: ElementType, page: Page, **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 kwargs: Additional attributes of the element.
:return: The created element.
"""
model_class = cast(Element, element_type.model_class)
shared_allowed_fields = ["type", "order"]
allowed_values = extract_allowed(
kwargs, shared_allowed_fields + element_type.allowed_fields
)
element = model_class(page=page, **allowed_values)
element.save()
return element
def delete_element(self, element: Element):
"""
Deletes an element.
:param element: The to-be-deleted element.
"""
element.delete()
def update_element(self, element: Element, **kwargs) -> Element:
"""
Updates and element with values. Will also check if the values are allowed
to be set on the element first.
:param element: The element that should be updated.
:param values: The values that should be set on the element.
:return: The updated element.
"""
element_type = element_type_registry.get_by_model(element)
shared_allowed_fields = []
allowed_updates = extract_allowed(
kwargs, shared_allowed_fields + element_type.allowed_fields
)
for key, value in allowed_updates.items():
setattr(element, key, value)
element.save()
return element
def order_elements(self, page: Page, new_order: List[int]) -> List[int]:
"""
Changes the order of the elements of a page.
:param page: The page the elements exist on.
:param new_order: The new order which they should have.
:return: The full order of all elements after they have been ordered.
"""
all_elements = Element.objects.filter(page=page)
full_order = Element.order_objects(all_elements, new_order)
return full_order
def recalculate_full_orders(
self,
page: Page,
):
"""
Recalculates the order to whole numbers of all elements of the given page.
"""
Element.recalculate_full_orders(queryset=Element.objects.filter(page=page))

View file

@ -0,0 +1,115 @@
from django.contrib.contenttypes.models import ContentType
from django.db import models
from baserow.contrib.builder.pages.models import Page
from baserow.core.mixins import (
CreatedAndUpdatedOnMixin,
FractionOrderableMixin,
HierarchicalModelMixin,
PolymorphicContentTypeMixin,
TrashableModelMixin,
)
class ExpressionField(models.TextField):
"""
An expression that can reference a data source, a formula or a plain value.
"""
def get_default_element_content_type():
return ContentType.objects.get_for_model(Element)
class Element(
HierarchicalModelMixin,
TrashableModelMixin,
CreatedAndUpdatedOnMixin,
FractionOrderableMixin,
PolymorphicContentTypeMixin,
models.Model,
):
"""
This model represents a page element. An element is a piece of the page that
display an information or something the user can interact with.
"""
page = models.ForeignKey(Page, on_delete=models.CASCADE)
order = models.DecimalField(
help_text="Lowest first.",
max_digits=40,
decimal_places=20,
editable=False,
default=1,
)
content_type = models.ForeignKey(
ContentType,
verbose_name="content type",
related_name="page_elements",
on_delete=models.SET(get_default_element_content_type),
)
class Meta:
ordering = ("order", "id")
def get_parent(self):
return self.page
@classmethod
def get_last_order(cls, page: Page):
"""
Returns the last order for the given page.
:param Page: The page we want the order for.
:return: The last order.
"""
queryset = Element.objects.filter(page=page)
return cls.get_highest_order_of_queryset(queryset)[0]
@classmethod
def get_unique_order_before_element(cls, page: Page, before: "Element"):
"""
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
:return: The order value.
"""
queryset = Element.objects.filter(page=page)
return cls.get_unique_orders_before_item(before, queryset)[0]
class BaseTextElement(Element):
"""
Base class for text elements.
"""
value = ExpressionField(default="")
class Meta:
abstract = True
class HeadingElement(BaseTextElement):
"""
A Heading element to display a title.
"""
class HeadingLevel(models.IntegerChoices):
H1 = 1
H2 = 2
H3 = 3
H4 = 4
H5 = 5
level = models.IntegerField(
choices=HeadingLevel.choices, default=1, help_text="The level of the heading"
)
class ParagraphElement(BaseTextElement):
"""
A simple paragraph.
"""

View file

@ -0,0 +1,44 @@
from typing import Optional
from django.db.models import Q
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.object_scopes import BuilderObjectScopeType
from baserow.contrib.builder.pages.object_scopes import BuilderPageObjectScopeType
from baserow.core.object_scopes import (
ApplicationObjectScopeType,
WorkspaceObjectScopeType,
)
from baserow.core.registries import ObjectScopeType, object_scope_type_registry
from baserow.core.types import ContextObject
class BuilderElementObjectScopeType(ObjectScopeType):
type = "builder_element"
model_class = Element
def get_parent_scope(self) -> Optional["ObjectScopeType"]:
return object_scope_type_registry.get("builder_page")
def get_parent(self, context: ContextObject) -> Optional[ContextObject]:
return context.page
def get_enhanced_queryset(self):
return self.get_base_queryset().prefetch_related(
"page", "page__builder", "page__builder__workspace"
)
def get_filter_for_scope_type(self, scope_type, scopes):
if scope_type.type == WorkspaceObjectScopeType.type:
return Q(page__builder__workspace__in=[s.id for s in scopes])
if (
scope_type.type == BuilderObjectScopeType.type
or scope_type.type == ApplicationObjectScopeType.type
):
return Q(page__builder__in=[s.id for s in scopes])
if scope_type.type == BuilderPageObjectScopeType.type:
return Q(page__in=[s.id for s in scopes])
raise TypeError("The given type is not handled.")

View file

@ -0,0 +1,34 @@
from abc import ABC
from baserow.contrib.builder.pages.operations import BuilderPageOperationType
from baserow.core.registries import OperationType
class ListElementsPageOperationType(BuilderPageOperationType):
type = "builder.page.list_elements"
object_scope_name = "builder_element"
class OrderElementsPageOperationType(BuilderPageOperationType):
type = "builder.page.order_elements"
object_scope_name = "builder_element"
class CreateElementOperationType(BuilderPageOperationType):
type = "builder.page.create_element"
class BuilderElementOperationType(OperationType, ABC):
context_scope_name = "builder_element"
class DeleteElementOperationType(BuilderElementOperationType):
type = "builder.page.element.delete"
class UpdateElementOperationType(BuilderElementOperationType):
type = "builder.page.element.update"
class ReadElementOperationType(BuilderElementOperationType):
type = "builder.page.element.read"

View file

@ -0,0 +1,107 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Dict, Type, TypeVar
from baserow.core.registry import (
CustomFieldsInstanceMixin,
CustomFieldsRegistryMixin,
ImportExportMixin,
Instance,
ModelInstanceMixin,
ModelRegistryMixin,
Registry,
)
if TYPE_CHECKING:
from baserow.contrib.builder.pages.models import Page
from .models import Element
from .types import ElementDictSubClass, ElementSubClass
class ElementType(
Instance,
ModelInstanceMixin[ElementSubClass],
ImportExportMixin[ElementSubClass],
CustomFieldsInstanceMixin,
ABC,
):
"""Element type"""
SerializedDict: Type[ElementDictSubClass]
def export_serialized(
self,
element: Element,
) -> ElementDictSubClass:
"""
Exports the element to a serialized dict that can be imported by the
`import_serialized` method. This dict is also JSON serializable.
:param element: The element instance that must be serialized.
:return: The exported element as serialized dict.
"""
other_properties = {key: getattr(element, key) for key in self.allowed_fields}
serialized = self.SerializedDict(
id=element.id, type=self.type, order=element.order, **other_properties
)
return serialized
def import_serialized(
self,
page: "Page",
serialized_values: Dict[str, Any],
id_mapping: Dict[str, Any],
) -> Element:
"""
Imports the previously exported dict generated by the `export_serialized`
method.
:param page: The page we want to import the element for.
:serialized_values: The dict containing the serialized version of the element.
:id_mapping: Used to mapped object ids from export to newly created instances.
:return: The created element.
"""
if "builder_elements" not in id_mapping:
id_mapping["builder_elements"] = {}
serialized_copy = serialized_values.copy()
# Remove extra keys
element_id = serialized_copy.pop("id")
serialized_copy.pop("type")
element = self.model_class(page=page, **serialized_copy)
element.save()
id_mapping["builder_elements"][element_id] = element.id
return element
@abstractmethod
def get_sample_params(self) -> Dict[str, Any]:
"""
Returns a sample of params for this type. This can be used to tests the element
for instance.
"""
ElementTypeSubClass = TypeVar("ElementTypeSubClass", bound=ElementType)
class ElementTypeRegistry(
Registry[ElementTypeSubClass],
ModelRegistryMixin[ElementSubClass, ElementTypeSubClass],
CustomFieldsRegistryMixin,
):
"""
Contains all registered element types.
"""
name = "element_type"
element_type_registry = ElementTypeRegistry()

View file

@ -0,0 +1,232 @@
from typing import List, Optional, cast
from django.contrib.auth.models import AbstractUser
from baserow.contrib.builder.elements.exceptions import ElementNotInPage
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.operations import (
CreateElementOperationType,
DeleteElementOperationType,
ListElementsPageOperationType,
OrderElementsPageOperationType,
ReadElementOperationType,
UpdateElementOperationType,
)
from baserow.contrib.builder.elements.registries import ElementType
from baserow.contrib.builder.elements.signals import (
element_created,
element_deleted,
element_orders_recalculated,
element_updated,
elements_reordered,
)
from baserow.contrib.builder.pages.models import Page
from baserow.core.exceptions import CannotCalculateIntermediateOrder
from baserow.core.handler import CoreHandler
class ElementService:
def __init__(self):
self.handler = ElementHandler()
def get_element(self, user: AbstractUser, element_id: int) -> Element:
"""
Returns an element instance from the database. Also checks the user permissions.
:param user: The user trying to get the element
:param element_id: The ID of the element
:return: The element instance
"""
element = self.handler.get_element(element_id)
CoreHandler().check_permissions(
user,
ReadElementOperationType.type,
workspace=element.page.builder.workspace,
context=element,
)
return element
def get_elements(self, user: AbstractUser, page: Page) -> List[Element]:
"""
Gets all the elements of a given page visible to the given user.
:param user: The user trying to get the elements.
:param page: The page that holds the elements.
:return: The elements of that page.
"""
CoreHandler().check_permissions(
user,
ListElementsPageOperationType.type,
workspace=page.builder.workspace,
context=page,
)
user_elements = CoreHandler().filter_queryset(
user,
ListElementsPageOperationType.type,
Element.objects.all(),
workspace=page.builder.workspace,
context=page,
)
return self.handler.get_elements(page, base_queryset=user_elements)
def create_element(
self,
user: AbstractUser,
element_type: ElementType,
page: Page,
before: Optional[Element] = None,
**kwargs,
) -> Element:
"""
Creates a new element for a page given the user permissions.
:param user: The user trying to create the element.
: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 kwargs: Additional attributes of the element.
:return: The created element.
"""
CoreHandler().check_permissions(
user,
CreateElementOperationType.type,
workspace=page.builder.workspace,
context=page,
)
model_class = cast(Element, element_type.model_class)
if before:
try:
element_order = model_class.get_unique_order_before_element(
page, before
)
except CannotCalculateIntermediateOrder:
# If the `find_intermediate_order` fails with a
# `CannotCalculateIntermediateOrder`, it means that it's not possible
# calculate an intermediate fraction. Therefore, must reset all the
# orders of the elements (while respecting their original order),
# so that we can then can find the fraction any many more after.
self.recalculate_full_orders(user, page)
# Refresh the before element as the order might have changed.
before.refresh_from_db()
element_order = model_class.get_unique_order_before_element(
page, before
)
else:
element_order = model_class.get_last_order(page)
new_element = self.handler.create_element(
element_type, page, order=element_order, **kwargs
)
element_created.send(self, element=new_element, user=user)
return new_element
def update_element(self, user: AbstractUser, element: Element, **kwargs) -> Element:
"""
Updates and element with values. Will also check if the values are allowed
to be set on the element first.
:param user: The user trying to update the element.
:param element: The element that should be updated.
:param values: The values that should be set on the element.
:param kwargs: Additional attributes of the element.
:return: The updated element.
"""
CoreHandler().check_permissions(
user,
UpdateElementOperationType.type,
workspace=element.page.builder.workspace,
context=element,
)
element = self.handler.update_element(element, **kwargs)
element_updated.send(self, element=element, user=user)
return element
def delete_element(self, user: AbstractUser, element: Element):
"""
Deletes an element.
:param user: The user trying to delete the element.
:param element: The to-be-deleted element.
"""
page = element.page
CoreHandler().check_permissions(
user,
DeleteElementOperationType.type,
workspace=element.page.builder.workspace,
context=element,
)
self.handler.delete_element(element)
element_deleted.send(self, element_id=element.id, page=page, user=user)
def order_elements(
self, user: AbstractUser, page: Page, new_order: List[int]
) -> List[int]:
"""
Orders the elements of a page in a new order. The user must have the permissions
over all elements matching the given ids.
:param user: The user trying to re-order the elements.
:param page: The page the elements exist on.
:param new_order: The new order which they should have.
:return: The full order of all elements after they have been ordered.
"""
CoreHandler().check_permissions(
user,
OrderElementsPageOperationType.type,
workspace=page.builder.workspace,
context=page,
)
all_elements = Element.objects.filter(page=page)
user_elements = CoreHandler().filter_queryset(
user,
OrderElementsPageOperationType.type,
all_elements,
workspace=page.builder.workspace,
context=page,
)
element_ids = set(user_elements.values_list("id", flat=True))
# Check if all ids belong to the page and if the user has access to it
for element_id in new_order:
if element_id not in element_ids:
raise ElementNotInPage(element_id)
full_order = self.handler.order_elements(page, new_order)
elements_reordered.send(self, page=page, order=full_order, user=user)
return full_order
def recalculate_full_orders(self, user: AbstractUser, page: Page):
"""
Recalculates the order to whole numbers of all elements of the given page and
send a signal.
"""
self.handler.recalculate_full_orders(page)
element_orders_recalculated.send(self, page=page, user=user)

View file

@ -0,0 +1,7 @@
from django.dispatch import Signal
element_created = Signal()
element_deleted = Signal()
element_updated = Signal()
elements_reordered = Signal()
element_orders_recalculated = Signal()

View file

@ -0,0 +1,10 @@
from typing import TypeVar
from baserow.contrib.builder.types import ElementDict
from .models import Element
Expression = str
ElementDictSubClass = TypeVar("ElementDictSubClass", bound=ElementDict)
ElementSubClass = TypeVar("ElementSubClass", bound=Element)

View file

@ -0,0 +1,126 @@
# Generated by Django 3.2.18 on 2023-03-15 10:06
import django.db.models.deletion
from django.db import migrations, models
import baserow.contrib.builder.elements.models
import baserow.core.mixins
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("builder", "0003_duplicatepagejob"),
]
operations = [
migrations.CreateModel(
name="Element",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_on", models.DateTimeField(auto_now_add=True)),
("updated_on", models.DateTimeField(auto_now=True)),
("trashed", models.BooleanField(db_index=True, default=False)),
(
"order",
models.DecimalField(
decimal_places=20,
default=1,
editable=False,
help_text="Lowest first.",
max_digits=40,
),
),
(
"content_type",
models.ForeignKey(
on_delete=models.SET(
baserow.contrib.builder.elements.models.get_default_element_content_type
),
related_name="page_elements",
to="contenttypes.contenttype",
verbose_name="content type",
),
),
(
"page",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="builder.page"
),
),
],
options={
"ordering": ("order", "id"),
},
bases=(
baserow.core.mixins.FractionOrderableMixin,
baserow.core.mixins.PolymorphicContentTypeMixin,
models.Model,
),
),
migrations.CreateModel(
name="HeadingElement",
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",
),
),
(
"value",
baserow.contrib.builder.elements.models.ExpressionField(default=""),
),
(
"level",
models.IntegerField(
choices=[(1, "H1"), (2, "H2"), (3, "H3"), (4, "H4"), (5, "H5")],
default=1,
help_text="The level of the heading",
),
),
],
options={
"abstract": False,
},
bases=("builder.element",),
),
migrations.CreateModel(
name="ParagraphElement",
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",
),
),
(
"value",
baserow.contrib.builder.elements.models.ExpressionField(default=""),
),
],
options={
"abstract": False,
},
bases=("builder.element",),
),
]

View file

@ -1,4 +1,4 @@
from typing import List, Optional
from typing import TYPE_CHECKING, List, Optional, cast
from django.db.models import QuerySet
@ -9,6 +9,9 @@ from baserow.core.exceptions import IdDoesNotExist
from baserow.core.registries import application_type_registry
from baserow.core.utils import ChildProgressBuilder, find_unused_name
if TYPE_CHECKING:
from baserow.contrib.builder.application_types import BuilderApplicationType
class PageHandler:
def get_page(self, page_id: int, base_queryset: QuerySet = None) -> Page:
@ -110,7 +113,9 @@ class PageHandler:
progress.increment(by=start_progress)
builder = page.builder
builder_application_type = application_type_registry.get_by_model(builder)
builder_application_type = cast(
"BuilderApplicationType", application_type_registry.get_by_model(builder)
)
[exported_page] = builder_application_type.export_pages_serialized([page])
@ -126,6 +131,7 @@ class PageHandler:
progress_builder=progress.create_child_builder(
represents_progress=import_progress
),
id_mapping={},
)
return new_page_clone

View file

@ -0,0 +1,22 @@
from typing import List, TypedDict
class ElementDict(TypedDict):
id: int
order: int
type: str
class PageDict(TypedDict):
id: int
name: str
order: int
elements: List[ElementDict]
class BuilderDict(TypedDict):
id: int
name: str
order: int
type: str
pages: List[PageDict]

View file

@ -0,0 +1,92 @@
from typing import List
from django.contrib.auth.models import AbstractUser
from django.db import transaction
from django.dispatch import receiver
from baserow.contrib.builder.api.elements.serializers import ElementSerializer
from baserow.contrib.builder.elements import signals as element_signals
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.object_scopes import BuilderElementObjectScopeType
from baserow.contrib.builder.elements.operations import ReadElementOperationType
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.pages.models import Page
from baserow.core.utils import generate_hash
from baserow.ws.tasks import broadcast_to_group, broadcast_to_permitted_users
@receiver(element_signals.element_created)
def element_created(sender, element: Element, user: AbstractUser, **kwargs):
transaction.on_commit(
lambda: broadcast_to_permitted_users.delay(
element.page.builder.workspace_id,
ReadElementOperationType.type,
BuilderElementObjectScopeType.type,
element.id,
{
"type": "element_created",
"element": element_type_registry.get_serializer(
element, ElementSerializer
).data,
},
getattr(user, "web_socket_id", None),
)
)
@receiver(element_signals.element_updated)
def element_updated(sender, element: Element, user: AbstractUser, **kwargs):
transaction.on_commit(
lambda: broadcast_to_permitted_users.delay(
element.page.builder.workspace_id,
ReadElementOperationType.type,
BuilderElementObjectScopeType.type,
element.id,
{
"type": "element_updated",
"element": element_type_registry.get_serializer(
element, ElementSerializer
).data,
},
getattr(user, "web_socket_id", None),
)
)
@receiver(element_signals.element_deleted)
def element_deleted(sender, page: Page, element_id: int, user: AbstractUser, **kwargs):
transaction.on_commit(
lambda: broadcast_to_permitted_users.delay(
page.builder.workspace_id,
ReadElementOperationType.type,
BuilderElementObjectScopeType.type,
element_id,
{
"type": "element_deleted",
"element_id": element_id,
"page_id": page.id,
},
getattr(user, "web_socket_id", None),
)
)
@receiver(element_signals.elements_reordered)
def element_reordered(
sender, page: Page, order: List[int], user: AbstractUser, **kwargs
):
# Hashing all values here to not expose real ids of elements a user might not have
# access to
order = [generate_hash(o) for o in order]
transaction.on_commit(
lambda: broadcast_to_group.delay(
page.builder.workspace_id,
{
"type": "elements_reordered",
# A user might also not have access to the page itself
"page_id": generate_hash(page.id),
"order": order,
},
getattr(user, "web_socket_id", None),
)
)

View file

@ -1,3 +1,18 @@
from .element.signals import (
element_created,
element_deleted,
element_reordered,
element_updated,
)
from .page.signals import page_created, page_deleted, page_reordered, page_updated
__all__ = ["page_created", "page_deleted", "page_updated", "page_reordered"]
__all__ = [
"page_created",
"page_deleted",
"page_updated",
"page_reordered",
"element_created",
"element_deleted",
"element_reordered",
"element_updated",
]

View file

@ -1816,7 +1816,7 @@ class PublicViewInfoView(APIView):
description="The slug of the view to get public information " "about.",
)
],
tags=["Database table view"],
tags=["Database table views"],
operation_id="get_public_view_info",
description=(
"Returns the required public information to display a single "

View file

@ -1,7 +1,10 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from faker import Faker
from baserow.contrib.database.fields.models import Field, SelectOption
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.models import Database
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.table.handler import TableHandler
@ -103,6 +106,7 @@ def load_test_data():
("Products", "link_row", {"link_row_table": products_table}),
("Production", "rating", {}),
("Certification", "multiple_select", {}),
("Image", "file", {}),
("Notes", "long_text", {"field_options": {"width": 400}}),
],
)
@ -132,12 +136,24 @@ def load_test_data():
products_by_name = {p.name: p.id for p in products.objects.all()}
certif_by_name = {p.value: p.id for p in select_field.select_options.all()}
image_field = Field.objects.get(table=suppliers_table, name="Image")
file_field_type = field_type_registry.get("file")
fake = Faker()
cache = {}
random_file_1 = file_field_type.random_value(image_field, fake, cache)
random_file_2 = file_field_type.random_value(image_field, fake, cache)
random_file_3 = file_field_type.random_value(image_field, fake, cache)
random_file_4 = file_field_type.random_value(image_field, fake, cache)
data = [
(
"The happy cow",
[products_by_name["Milk"], products_by_name["Butter"]],
3,
[certif_by_name["Animal protection"]],
random_file_1,
"Animals here are happy.",
),
(
@ -149,6 +165,7 @@ def load_test_data():
],
5,
[certif_by_name["Organic"], certif_by_name["Equitable"]],
random_file_2,
"Good guy.",
),
(
@ -156,6 +173,7 @@ def load_test_data():
[products_by_name["Beef"]],
2,
[certif_by_name["Fair trade"]],
random_file_3,
"",
),
(
@ -166,6 +184,7 @@ def load_test_data():
certif_by_name["Organic"],
certif_by_name["Natural"],
],
random_file_4,
"Excellent white & red wines.",
),
]

View file

@ -24,10 +24,3 @@ class ReportMaxErrorCountExceeded(Exception):
def __init__(self, report, *args, **kwargs):
self.report = report
super().__init__("Too many errors", *args, **kwargs)
class CannotCalculateIntermediateOrder(Exception):
"""
Raised when an intermediate order can't be calculated. This could be because the
fractions are equal.
"""

View file

@ -2,18 +2,16 @@ import re
from collections import defaultdict
from copy import copy
from decimal import Decimal
from math import ceil
from typing import Any, Dict, List, NewType, Optional, Set, Tuple, Type, cast
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import connection, transaction
from django.db.models import Max, Q, QuerySet
from django.db import transaction
from django.db.models import Q, QuerySet
from django.db.models.fields.related import ForeignKey, ManyToManyField
from django.utils.encoding import force_str
from opentelemetry import metrics, trace
from psycopg2 import sql
from baserow.contrib.database.fields.dependencies.handler import FieldDependencyHandler
from baserow.contrib.database.fields.dependencies.update_collector import (
@ -33,6 +31,12 @@ from baserow.contrib.database.table.operations import (
ImportRowsDatabaseTableOperationType,
)
from baserow.contrib.database.trash.models import TrashedRows
from baserow.core.db import (
get_highest_order_of_queryset,
get_unique_orders_before_item,
recalculate_full_orders,
)
from baserow.core.exceptions import CannotCalculateIntermediateOrder
from baserow.core.handler import CoreHandler
from baserow.core.telemetry.utils import baserow_trace_methods
from baserow.core.trash.handler import TrashHandler
@ -40,11 +44,7 @@ from baserow.core.utils import Progress, get_non_unique_values, grouper
from .constants import ROW_IMPORT_CREATION, ROW_IMPORT_VALIDATION
from .error_report import RowErrorReport
from .exceptions import (
CannotCalculateIntermediateOrder,
RowDoesNotExist,
RowIdsNotUnique,
)
from .exceptions import RowDoesNotExist, RowIdsNotUnique
from .operations import (
DeleteDatabaseRowOperationType,
MoveRowDatabaseRowOperationType,
@ -59,7 +59,6 @@ from .signals import (
rows_deleted,
rows_updated,
)
from .utils import find_intermediate_order
tracer = trace.get_tracer(__name__)
@ -286,42 +285,6 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
return values, manytomany_values
def _get_unique_orders_before_row(
self,
before_row: GeneratedTableModel,
model: Type[GeneratedTableModel],
amount: int = 1,
) -> List[Decimal]:
"""
Calculates a list of unique decimal orders that can safely be used before the
provided `before_row`.
:param before_row: The row instance where the before orders must be
calculated for.
:param model: The model of the related table
:param amount: The number of orders that must be requested. Can be higher if
multiple rows are inserted or moved.
:return: A list of decimals containing safe to use orders in order.
"""
# In order to find the intermediate order, we need to figure out what the
# order of the before adjacent row is. This queryset finds it in an
# efficient way.
adjacent_order = (
model.objects.filter(order__lt=before_row.order)
.aggregate(max=Max("order"))
.get("max")
) or Decimal("0")
new_orders = []
new_order = adjacent_order
for i in range(0, amount):
float_order = find_intermediate_order(new_order, before_row.order)
# Row orders "only" store 20 decimal places. We're already rounding it,
# so that the `order` will be set immediately.
new_order = round(Decimal(float_order), 20)
new_orders.append(new_order)
return new_orders
def get_unique_orders_before_row(
self,
before_row: Optional[GeneratedTableModel],
@ -345,9 +308,13 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
:return: A list of decimals containing safe to use orders in order.
"""
queryset = model.objects
if before_row:
try:
return self._get_unique_orders_before_row(before_row, model, amount)
return get_unique_orders_before_item(
before_row, queryset, amount=amount
)
except CannotCalculateIntermediateOrder:
# If the `find_intermediate_order` fails with a
# `CannotCalculateIntermediateOrder`, it means that it's not possible
@ -355,15 +322,15 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
# orders of the table (while respecting their original order),
# so that we can then can find the fraction any many more after.
self.recalculate_row_orders(model.baserow_table, model)
return self._get_unique_orders_before_row(before_row, model, amount)
# Refresh the row element as its order might have changed
before_row.refresh_from_db()
return get_unique_orders_before_item(
before_row, queryset, amount=amount
)
else:
# If no `before_row` is provided, we can just find the highest value and
# If no `before` is provided, we can just find the highest value and
# add one to it.
step = Decimal("1.00000000000000000000")
order_last_row = ceil(
model.objects.aggregate(max=Max("order")).get("max") or Decimal("0")
)
return [order_last_row + (step * i) for i in range(1, amount + 1)]
return get_highest_order_of_queryset(queryset, amount=amount)
def get_row(
self,
@ -1960,50 +1927,13 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
3 0.7500 1.0000
:param table: The table object for which the rows orders must be recalculated.
:param model: The already
:param model: The already generated model if any.
"""
if model is None:
model = table.get_model()
table_name = table.get_database_table_name()
ordering = model._meta.ordering
if len(ordering) != 2:
raise Exception(
"The ordering of the auto generated model has changed and must be "
"updated in the `recalculate_row_orders` method."
)
# Unfortunately, it's not possible to do this via the Django ORM. I've
# tried various ways, but ran into "Window expressions are not allowed" or
# 'NoneType' object has no attribute 'get_source_expressions' errors.
# More information can be found here:
# https://stackoverflow.com/questions/66022483/update-django-model-based-on-the-row-number-of-rows-produced-by-a-subquery-on-th
#
# model.objects_and_trash.annotate(
# row_number=Window(
# expression=RowNumber(),
# order_by=[F(order) for order in model._meta.ordering],
# )
# ).update(order=F('row_number')
# )
with connection.cursor() as cursor:
raw_query = """
update {table_name} c1
set "order" = c2.seqnum from (
select c2.*, row_number() over (
ORDER BY c2.{order_1}, c2.{order_2}
) as seqnum from {table_name} c2
) c2
where c2.id = c1.id
"""
sql_query = sql.SQL(raw_query).format(
table_name=sql.Identifier(table_name),
order_1=sql.Identifier(ordering[0]),
order_2=sql.Identifier(ordering[1]),
)
cursor.execute(sql_query)
recalculate_full_orders(model)
row_orders_recalculated.send(
self,

View file

@ -1,87 +0,0 @@
from decimal import Decimal
from fractions import Fraction
from typing import Tuple, Union
from .exceptions import CannotCalculateIntermediateOrder
def find_intermediate_fraction(p1: int, q1: int, p2: int, q2: int) -> Tuple[int, int]:
"""
Find an intermediate fraction between p1/q1 and p2/q2.
The fraction chosen is the highest fraction in the Stern-Brocot tree which falls
strictly between the specified values. This is intended to avoid going deeper in
the tree unnecessarily when the list is already sparse due to deletion or moving
of items, but in fact the case when the two items are already adjacent in the tree
is common so we shortcut it. As a bonus, this method always generates fractions
in lowest terms, so there is no need for GCD calculations anywhere.
Based on `find_intermediate` in
https://wiki.postgresql.org/wiki/User-specified_ordering_with_fractions
"""
pl = 0
ql = 1
ph = 1
qh = 0
if p1 * q2 + 1 != p2 * q1:
while True:
p = pl + ph
q = ql + qh
if p * q1 <= q * p1:
pl = p
ql = q
elif p2 * q <= q2 * p:
ph = p
qh = q
else:
return p, q
else:
p = p1 + p2
q = q1 + q2
return p, q
def find_intermediate_order(
order_1: Union[float, Decimal],
order_2: Union[float, Decimal],
max_denominator: int = 10000000,
) -> float:
"""
Calculates what the intermediate order of the two provided orders should be.
This can be used when a row must be moved before or after another row. It just
needs to order of the before and after row and it will return the best new order.
- order_1
- return_value
- order_2
:param order_1: The order of the before adjacent row. The new returned order will
be after this one
:param order_2: The order of the after adjacent row. The new returned order will
be before this one.
:param max_denominator: The max denominator of the fraction calculator from the
order.
:raises ValueError: If the fractions of the orders are equal.
:return: The new order that can safely be used and will be a unique value.
"""
p1, q1 = Fraction(order_1).limit_denominator(max_denominator).as_integer_ratio()
p2, q2 = Fraction(order_2).limit_denominator(max_denominator).as_integer_ratio()
if p1 == p2 and q1 == q2:
raise CannotCalculateIntermediateOrder("The fractions of the orders are equal.")
intermediate_fraction = float(Fraction(*find_intermediate_fraction(p1, q1, p2, q2)))
if intermediate_fraction == float(order_1) or intermediate_fraction == float(
order_2
):
raise CannotCalculateIntermediateOrder(
"Could not find an intermediate fraction because it's equal "
"to the provided order."
)
return float(Fraction(*find_intermediate_fraction(p1, q1, p2, q2)))

View file

@ -141,6 +141,11 @@ class AuthProviderType(
],
}
def import_serialized(
self, parent: Any, serialized_values: Dict[str, Any], id_mapping: Dict
) -> Any:
raise NotImplementedError()
class PasswordAuthProviderType(AuthProviderType):
"""

View file

@ -1,15 +1,19 @@
import contextlib
from collections import defaultdict
from typing import Any, Callable, Iterable, List, Optional, Tuple
from decimal import Decimal
from math import ceil
from typing import Any, Callable, Iterable, List, Optional, Tuple, TypeVar
from django.contrib.contenttypes.models import ContentType
from django.db import DEFAULT_DB_ALIAS, transaction
from django.db.models import Model, QuerySet
from django.db import DEFAULT_DB_ALIAS, connection, transaction
from django.db.models import Max, Model, QuerySet
from django.db.models.sql.query import LOOKUP_SEP
from django.db.transaction import Atomic, get_connection
from psycopg2 import sql
from .utils import find_intermediate_order
class LockedAtomicTransaction(Atomic):
"""
@ -45,10 +49,13 @@ class LockedAtomicTransaction(Atomic):
return super().__exit__(*args, **kwargs)
T = TypeVar("T", bound=Model)
def specific_iterator(
queryset: QuerySet,
queryset: QuerySet[T],
per_content_type_queryset_hook: Callable = None,
) -> Iterable[Model]:
) -> Iterable[T]:
"""
Iterates over the given queryset and finds the specific objects with the least
amount of queries. It respects the annotations, select related and prefetch
@ -187,3 +194,125 @@ def transaction_atomic(
first_sql, first_args = first_sql_to_run_in_transaction_with_args
cursor.execute(first_sql, first_args)
yield a
def get_unique_orders_before_item(
before: Model,
queryset: QuerySet,
amount: int = 1,
field: str = "order",
) -> List[Decimal]:
"""
Calculates a list of unique decimal orders that can safely be used before the
provided `before`.
:param before: The model instance where the before orders must be
calculated for.
:param queryset: The base queryset used to compute the value.
:param amount: The number of orders that must be requested. Can be higher if
multiple items are inserted or moved.
:return: A list of decimals containing safe to use orders in order.
"""
# In order to find the intermediate order, we need to figure out what the
# order of the before adjacent row is. This queryset finds it in an
# efficient way.
adjacent_order = (
queryset.filter(order__lt=before.order).aggregate(max=Max(field)).get("max")
) or Decimal("0")
new_orders = []
new_order = adjacent_order
for i in range(0, amount):
float_order = find_intermediate_order(new_order, before.order)
# Row orders "only" store 20 decimal places. We're already rounding it,
# so that the `order` will be set immediately.
new_order = round(Decimal(float_order), 20)
new_orders.append(new_order)
return new_orders
def get_highest_order_of_queryset(
queryset: QuerySet,
amount: int = 1,
field: str = "order",
) -> List[Decimal]:
"""
Returns the highest existing values of the provided order field.
:param queryset: The queryset containing the field to check.
:param amount: The amount of order to return.
:param field: The field name containing the value.
:return: A list of highest order value in the queryset.
"""
step = Decimal("1.00000000000000000000")
last_order = ceil(queryset.aggregate(max=Max(field)).get("max") or Decimal("0"))
return [last_order + (step * i) for i in range(1, amount + 1)]
def recalculate_full_orders(
model: Optional[Model] = None,
field="order",
queryset: Optional[QuerySet] = None,
):
"""
Recalculates the order to whole numbers of all instances based on the existing
position.
id old_order new_order
1 1.5000 2.0000
2 1.7500 3.0000
3 0.7500 1.0000
:param model: The model we want to reorder the instance for.
:param field: The order field name.
:param queryset: An optional queryset to filter the item orders that are
recalculated. This queryset must select all the item to recalculate.
"""
table_name = model.objects.model._meta.db_table
ordering = model._meta.ordering
if queryset:
item_ids = queryset.values_list("id", flat=True)
where_clause = sql.SQL("where c2.id in ({id_list})").format(
id_list=sql.SQL(", ").join([sql.Literal(item_id) for item_id in item_ids])
)
else:
where_clause = sql.SQL("")
# Unfortunately, it's not possible to do this via the Django ORM. I've
# tried various ways, but ran into "Window expressions are not allowed" or
# 'NoneType' object has no attribute 'get_source_expressions' errors.
# More information can be found here:
# https://stackoverflow.com/questions/66022483/update-django-model-based-on-the-row-number-of-rows-produced-by-a-subquery-on-th
#
# model.objects_and_trash.annotate(
# row_number=Window(
# expression=RowNumber(),
# order_by=[F(order) for order in model._meta.ordering],
# )
# ).update(order=F('row_number')
# )
raw_query = """
update {table_name} c1
set {order_field} = c2.seqnum from (
select c2.*, row_number() over (
ORDER BY {order_params}
) as seqnum from {table_name} c2 {where_clause}
) c2
where c2.id = c1.id"""
with connection.cursor() as cursor:
sql_query = sql.SQL(raw_query).format(
order_field=sql.Identifier(field),
table_name=sql.Identifier(table_name),
order_params=sql.SQL(", ").join(
[
sql.SQL(".").join([sql.Identifier("c2"), sql.Identifier(o)])
for o in ordering
]
),
where_clause=where_clause,
)
cursor.execute(sql_query)

View file

@ -281,3 +281,10 @@ class IdDoesNotExist(Exception):
*args,
**kwargs,
)
class CannotCalculateIntermediateOrder(Exception):
"""
Raised when an intermediate order can't be calculated. This could be because the
fractions are equal.
"""

View file

@ -1,6 +1,7 @@
import abc
import warnings
from typing import Dict, List, Type
from decimal import Decimal
from typing import Dict, List, Optional, Type
from django.contrib.contenttypes.models import ContentType
from django.db import models
@ -9,6 +10,11 @@ from django.db.models.fields import NOT_PROVIDED
from django.db.models.fields.mixins import FieldCacheMixin
from django.utils.functional import cached_property
from baserow.core.db import (
get_highest_order_of_queryset,
get_unique_orders_before_item,
recalculate_full_orders,
)
from baserow.core.exceptions import IdDoesNotExist
from baserow.core.managers import NoTrashManager, TrashOnlyManager, make_trash_manager
@ -19,16 +25,15 @@ class OrderableMixin:
"""
@classmethod
def get_highest_order_of_queryset(cls, queryset, field="order"):
def get_highest_order_of_queryset(
cls, queryset: QuerySet, field: str = "order"
) -> int:
"""
Returns the highest existing value of the provided field.
:param queryset: The queryset containing the field to check.
:type queryset: QuerySet
:param field: The field name containing the value.
:type field: str
:return: The highest value in the queryset.
:rtype: int
"""
return queryset.aggregate(models.Max(field)).get(f"{field}__max", 0) or 0
@ -98,6 +103,75 @@ class OrderableMixin:
return new_full_order
class FractionOrderableMixin(OrderableMixin):
"""
This mixin introduces a set of helpers of the model is orderable by a decimal field.
Needs a `models.DecimalField()` on the model.
"""
@classmethod
def get_highest_order_of_queryset(
cls, queryset: QuerySet, amount: int = 1, field: str = "order"
) -> List[Decimal]:
"""
Returns the highest existing values of the provided order field.
:param queryset: The queryset containing the field to check.
:param amount: The amount of order to return.
:param field: The field name containing the value.
:return: A list of highest order value in the queryset.
"""
return get_highest_order_of_queryset(queryset, amount=amount, field=field)
@classmethod
def get_unique_orders_before_item(
cls,
before: Optional[models.Model],
queryset: QuerySet,
amount: int = 1,
field: str = "order",
) -> List[Decimal]:
"""
Calculates a list of unique decimal orders that can safely be used before the
provided `before` item.
:param before: The model instance where the before orders must be
calculated for.
:param queryset: The base queryset used to compute the value.
:param amount: The number of orders that must be requested. Can be higher if
multiple items are inserted or moved.
:param field: The order field name.
:return: A list of decimals containing safe to use orders in order.
"""
return get_unique_orders_before_item(before, queryset, amount, field=field)
@classmethod
def recalculate_full_orders(
cls,
field="order",
queryset: Optional[QuerySet] = None,
):
"""
Recalculates the order to whole numbers of all instances based on the existing
position.
id old_order new_order
1 1.5000 2.0000
2 1.7500 3.0000
3 0.7500 1.0000
:param model: The model we want to reorder the instance for.
:param field: The order field name.
:param queryset: An optional queryset to filter the item orders that are
recalculated. This queryset must select all the item to recalculate.
"""
return recalculate_full_orders(cls, field=field, queryset=queryset)
class PolymorphicContentTypeMixin:
"""
This mixin introduces a set of helpers for a model that has a polymorphic

View file

@ -1,7 +1,7 @@
import abc
from collections import defaultdict
from functools import cached_property
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, TypeVar, Union
from xmlrpc.client import Boolean
from zipfile import ZipFile
@ -173,7 +173,10 @@ class PluginRegistry(APIUrlsRegistryMixin, Registry):
class ApplicationType(
APIUrlsInstanceMixin, ModelInstanceMixin, ImportExportMixin, Instance
APIUrlsInstanceMixin,
ModelInstanceMixin["Application"],
ImportExportMixin["Application"],
Instance,
):
"""
This abstract class represents a custom application that can be added to the
@ -342,7 +345,16 @@ class ApplicationType(
return application
class ApplicationTypeRegistry(APIUrlsRegistryMixin, ModelRegistryMixin, Registry):
ApplicationSubClassInstance = TypeVar(
"ApplicationSubClassInstance", bound="Application"
)
class ApplicationTypeRegistry(
APIUrlsRegistryMixin,
ModelRegistryMixin[ApplicationSubClassInstance, ApplicationType],
Registry[ApplicationType],
):
"""
With the application registry it is possible to register new applications. An
application is an abstraction made specifically for Baserow. If added to the

View file

@ -1,5 +1,6 @@
import contextlib
import typing
from abc import ABC, abstractmethod
from functools import lru_cache
from typing import (
Any,
@ -27,9 +28,6 @@ if typing.TYPE_CHECKING:
from django.contrib.contenttypes.models import ContentType
T = TypeVar("T")
class Instance(object):
"""
This abstract class represents a custom instance that can be added to the registry.
@ -48,13 +46,16 @@ class Instance(object):
raise ImproperlyConfigured("The type of an instance must be set.")
class ModelInstanceMixin(Generic[T]):
DjangoModel = TypeVar("DjangoModel", bound=models.Model)
class ModelInstanceMixin(Generic[DjangoModel]):
"""
This mixin introduces a model_class that will be related to the instance. It is to
be used in combination with a registry that extends the ModelRegistryMixin.
"""
model_class: Type[T]
model_class: Type[DjangoModel]
def __init__(self):
if not self.model_class:
@ -69,14 +70,14 @@ class ModelInstanceMixin(Generic[T]):
return ContentType.objects.get_for_model(self.model_class)
def get_object_for_this_type(self, **kwargs) -> T:
def get_object_for_this_type(self, **kwargs) -> DjangoModel:
"""
Returns the object given the filters in parameter.
"""
return self.get_content_type().get_object_for_this_type(**kwargs)
def get_all_objects_for_this_type(self, **kwargs) -> models.QuerySet:
def get_all_objects_for_this_type(self, **kwargs) -> models.QuerySet[DjangoModel]:
"""
Returns a queryset to get the objects given the filters in parameter.
"""
@ -264,8 +265,12 @@ class MapAPIExceptionsInstanceMixin:
yield
class ImportExportMixin:
def export_serialized(self, instance):
T = TypeVar("T")
class ImportExportMixin(Generic[T], ABC):
@abstractmethod
def export_serialized(self, instance: T) -> Dict[str, Any]:
"""
Should return with a serialized version of the provided instance. It must be
JSON serializable and it must be possible to the import via the
@ -274,38 +279,30 @@ class ImportExportMixin:
:param instance: The instance that must be serialized and exported. Could be
any object type because it depends on the type instance that uses this
mixin.
:type instance: Object
:return: Serialized version of the instance.
:rtype: dict
"""
raise NotImplementedError("The export_serialized method must be implemented.")
def import_serialized(self, parent, serialized_values, id_mapping):
@abstractmethod
def import_serialized(
self, parent: Any, serialized_values: Dict[str, Any], id_mapping: Dict
) -> T:
"""
Should import and create the correct instances in the database based on the
serialized values exported by the `export_serialized` method. It should create
a copy. An entry to the mapping could be made if a new instance is created.
:param parent: Optionally a parent instance can be provided here.
:type parent: Object
:param serialized_values: The values that must be inserted.
:type serialized_values: dict
:param id_mapping: The map of exported ids to newly created ids that must be
updated when a new instance has been created.
:type id_mapping: dict
:return: The newly created instance.
:rtype: Object
"""
raise NotImplementedError("The import_serialized method must be implemented.")
InstanceSubClass = TypeVar("InstanceSubClass", bound=Instance)
T = TypeVar("T", bound=Instance)
K = TypeVar("K")
class Registry(Generic[T]):
class Registry(Generic[InstanceSubClass]):
name: str
"""The unique name that is used when raising exceptions."""
@ -322,9 +319,9 @@ class Registry(Generic[T]):
"InstanceModelRegistry to raise proper errors."
)
self.registry: Dict[str, T] = {}
self.registry: Dict[str, InstanceSubClass] = {}
def get(self, type_name: str) -> T:
def get(self, type_name: str) -> InstanceSubClass:
"""
Returns a registered instance of the given type name.
@ -360,10 +357,10 @@ class Registry(Generic[T]):
if instance.compat_type == compat_name:
return instance.type
def get_by_type(self, instance_type: Type[K]) -> K:
def get_by_type(self, instance_type: Type[InstanceSubClass]) -> InstanceSubClass:
return self.get(instance_type.type)
def get_all(self) -> ValuesView[T]:
def get_all(self) -> ValuesView[InstanceSubClass]:
"""
Returns all registered instances
@ -393,7 +390,7 @@ class Registry(Generic[T]):
return [(k, k) for k in self.registry.keys()]
def register(self, instance: T):
def register(self, instance: InstanceSubClass):
"""
Registers a new instance in the registry.
@ -414,7 +411,7 @@ class Registry(Generic[T]):
self.registry[instance.type] = instance
def unregister(self, value: T):
def unregister(self, value: InstanceSubClass):
"""
Removes a registered instance from the registry. An instance or type name can be
provided as value.
@ -438,11 +435,10 @@ class Registry(Generic[T]):
)
P = TypeVar("P")
class ModelRegistryMixin(Generic[P, T]):
def get_by_model(self, model_instance: Union[P, type]) -> T:
class ModelRegistryMixin(Generic[DjangoModel, InstanceSubClass]):
def get_by_model(
self, model_instance: Union[DjangoModel, Type[DjangoModel]]
) -> InstanceSubClass:
"""
Returns a registered instance of the given model class.
@ -461,7 +457,7 @@ class ModelRegistryMixin(Generic[P, T]):
return self.get_for_class(clazz)
@lru_cache
def get_for_class(self, clazz: type) -> T:
def get_for_class(self, clazz: Type[DjangoModel]) -> InstanceSubClass:
"""
Returns a registered instance of the given model class.
@ -495,7 +491,9 @@ class ModelRegistryMixin(Generic[P, T]):
f"The {self.name} model {clazz} does not exist."
)
def get_all_by_model_isinstance(self, model_instance: P) -> List[T]:
def get_all_by_model_isinstance(
self, model_instance: DjangoModel
) -> List[InstanceSubClass]:
"""
Returns all registered types which are an instance of the provided
model_instance.
@ -512,8 +510,14 @@ class ModelRegistryMixin(Generic[P, T]):
return all_matching_non_abstract_types
class CustomFieldsRegistryMixin:
def get_serializer(self, model_instance, base_class=None, context=None, **kwargs):
class CustomFieldsRegistryMixin(Generic[DjangoModel]):
def get_serializer(
self,
model_instance: DjangoModel,
base_class: Optional[Type[serializers.ModelSerializer]] = None,
context: Optional[Dict[str, any]] = None,
**kwargs,
):
"""
Based on the provided model_instance and base_class a unique serializer
containing the correct field type is generated.

View file

@ -10,8 +10,9 @@ import re
import string
from collections import namedtuple
from decimal import Decimal
from fractions import Fraction
from itertools import islice
from typing import Dict, Iterable, List, Optional, Tuple
from typing import Dict, Iterable, List, Optional, Tuple, Union
from django.db import transaction
from django.db.models import ForeignKey
@ -19,6 +20,8 @@ from django.db.models.fields import NOT_PROVIDED
from baserow.contrib.database.db.schema import optional_atomic
from .exceptions import CannotCalculateIntermediateOrder
def extract_allowed(values, allowed_fields):
"""
@ -703,3 +706,85 @@ def generate_hash(value: str):
value_hashed = hashlib.sha256()
value_hashed.update(str(value).encode("UTF-8"))
return value_hashed.hexdigest()
def find_intermediate_fraction(p1: int, q1: int, p2: int, q2: int) -> Tuple[int, int]:
"""
Find an intermediate fraction between p1/q1 and p2/q2.
The fraction chosen is the highest fraction in the Stern-Brocot tree which falls
strictly between the specified values. This is intended to avoid going deeper in
the tree unnecessarily when the list is already sparse due to deletion or moving
of items, but in fact the case when the two items are already adjacent in the tree
is common so we shortcut it. As a bonus, this method always generates fractions
in lowest terms, so there is no need for GCD calculations anywhere.
Based on `find_intermediate` in
https://wiki.postgresql.org/wiki/User-specified_ordering_with_fractions
"""
pl = 0
ql = 1
ph = 1
qh = 0
if p1 * q2 + 1 != p2 * q1:
while True:
p = pl + ph
q = ql + qh
if p * q1 <= q * p1:
pl = p
ql = q
elif p2 * q <= q2 * p:
ph = p
qh = q
else:
return p, q
else:
p = p1 + p2
q = q1 + q2
return p, q
def find_intermediate_order(
order_1: Union[float, Decimal],
order_2: Union[float, Decimal],
max_denominator: int = 10000000,
) -> float:
"""
Calculates what the intermediate order of the two provided orders should be.
This can be used when a row must be moved before or after another row. It just
needs to order of the before and after row and it will return the best new order.
- order_1
- return_value
- order_2
:param order_1: The order of the before adjacent row. The new returned order will
be after this one
:param order_2: The order of the after adjacent row. The new returned order will
be before this one.
:param max_denominator: The max denominator of the fraction calculator from the
order.
:raises ValueError: If the fractions of the orders are equal.
:return: The new order that can safely be used and will be a unique value.
"""
p1, q1 = Fraction(order_1).limit_denominator(max_denominator).as_integer_ratio()
p2, q2 = Fraction(order_2).limit_denominator(max_denominator).as_integer_ratio()
if p1 == p2 and q1 == q2:
raise CannotCalculateIntermediateOrder("The fractions of the orders are equal.")
intermediate_fraction = float(Fraction(*find_intermediate_fraction(p1, q1, p2, q2)))
if intermediate_fraction == float(order_1) or intermediate_fraction == float(
order_2
):
raise CannotCalculateIntermediateOrder(
"Could not find an intermediate fraction because it's equal "
"to the provided order."
)
return float(Fraction(*find_intermediate_fraction(p1, q1, p2, q2)))

View file

@ -1,8 +1,7 @@
from faker import Faker
from .airtable import AirtableFixtures
from .application import ApplicationFixtures
from .auth_provider import AuthProviderFixtures
from .element import ElementFixtures
from .field import FieldFixtures
from .file_import import FileImportFixtures
from .job import JobFixtures
@ -39,5 +38,7 @@ class Fixtures(
SnapshotFixtures,
AuthProviderFixtures,
PageFixtures,
ElementFixtures,
):
fake = Faker()
def __init__(self, fake=None):
self.fake = fake

View file

@ -0,0 +1,29 @@
from baserow.contrib.builder.elements.models import HeadingElement, ParagraphElement
class ElementFixtures:
def create_builder_heading_element(self, user=None, page=None, **kwargs):
element = self.create_builder_element(HeadingElement, user, page, **kwargs)
return element
def create_builder_paragraph_element(self, user=None, page=None, **kwargs):
element = self.create_builder_element(ParagraphElement, user, page, **kwargs)
return element
def create_builder_element(self, model_class, user=None, page=None, **kwargs):
if user is None:
user = self.create_user()
if not page:
builder = kwargs.pop("builder", None)
page_args = kwargs.pop("page_args", {})
page = self.create_builder_page(user=user, builder=builder, **page_args)
if "order" not in kwargs:
kwargs["order"] = model_class.get_last_order(page)
page = model_class.objects.create(page=page, **kwargs)
return page

View file

@ -6,14 +6,14 @@ class PageFixtures:
if user is None:
user = self.create_user()
if "builder" not in kwargs:
if not kwargs.get("builder", None):
kwargs["builder"] = self.create_builder_application(user=user)
if "name" not in kwargs:
kwargs["name"] = self.fake.name()
kwargs["name"] = self.fake.unique.uri_page()
if "order" not in kwargs:
kwargs["order"] = 0
kwargs["order"] = Page.get_last_order(kwargs["builder"])
page = Page.objects.create(**kwargs)

View file

@ -11,10 +11,12 @@ from django.db.migrations.executor import MigrationExecutor
from django.utils.timezone import now
import pytest
from faker import Faker
from pyinstrument import Profiler
from baserow.compat.api.conf import GROUP_DEPRECATION
from baserow.contrib.database.application_types import DatabaseApplicationType
from baserow.core.exceptions import PermissionDenied
from baserow.core.permission_manager import CorePermissionManagerType
from baserow.core.trash.trash_types import WorkspaceTrashableItemType
@ -22,6 +24,12 @@ SKIP_FLAGS = ["disabled-in-ci", "once-per-day-in-ci"]
COMMAND_LINE_FLAG_PREFIX = "--run-"
# Provides a new fake instance for each class. Solve uniqueness problem sometimes.
@pytest.fixture(scope="class", autouse=True)
def fake():
yield Faker()
# We need to manually deal with the event loop since we are using asyncio in the
# tests in this directory and they have some issues when it comes to pytest.
# This solution is taken from: https://bit.ly/3UJ90co
@ -33,10 +41,10 @@ def async_event_loop():
@pytest.fixture
def data_fixture():
def data_fixture(fake):
from .fixtures import Fixtures
return Fixtures()
return Fixtures(fake)
@pytest.fixture()
@ -339,10 +347,23 @@ def trash_item_type_perm_delete_item_raising_operationalerror(
class StubbedCorePermissionManagerType(CorePermissionManagerType):
def check_permissions(
self, actor, operation, workspace=None, context=None, include_trash=False
):
return True
"""
Stub for the first permission manager.
"""
def __init__(self, raise_permission_denied: bool = False):
self.raise_permission_denied = raise_permission_denied
def check_multiple_permissions(self, checks, workspace=None, include_trash=False):
result = {}
for check in checks:
if self.raise_permission_denied:
result[check] = PermissionDenied()
else:
result[check] = True
return result
@pytest.fixture
@ -362,6 +383,34 @@ def bypass_check_permissions(
yield stub_core_permission_manager
@pytest.fixture
def stub_check_permissions() -> callable:
"""
Overrides the existing `CorePermissionManagerType` so that
we can return True or raise a PermissionDenied exception on a `check_permissions`
call.
"""
@contextlib.contextmanager
def _perform_stub(
raise_permission_denied: bool = False,
) -> CorePermissionManagerType:
from baserow.core.registries import permission_manager_type_registry
before = permission_manager_type_registry.registry.copy()
stub_core_permission_manager = StubbedCorePermissionManagerType(
raise_permission_denied
)
first_manager = settings.PERMISSION_MANAGERS[0]
permission_manager_type_registry.registry[
first_manager
] = stub_core_permission_manager
yield stub_core_permission_manager
permission_manager_type_registry.registry = before
return _perform_stub
@pytest.fixture
def teardown_table_metadata():
"""

View file

@ -0,0 +1,285 @@
from django.urls import reverse
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_204_NO_CONTENT,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_404_NOT_FOUND,
)
@pytest.mark.django_db
def test_get_elements(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_paragraph_element(page=page)
url = reverse("api:builder:element:list", kwargs={"page_id": page.id})
response = api_client.get(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json) == 3
assert response_json[0]["id"] == element1.id
assert response_json[0]["type"] == "heading"
assert "level" in response_json[0]
assert response_json[1]["id"] == element2.id
assert response_json[1]["type"] == "heading"
assert response_json[2]["id"] == element3.id
assert response_json[2]["type"] == "paragraph"
assert "level" not in response_json[2]
@pytest.mark.django_db
def test_create_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": "heading"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
print(response_json)
assert response.status_code == HTTP_200_OK
assert response_json["type"] == "heading"
assert response_json["value"] == ""
response = api_client.post(
url,
{
"type": "heading",
"value": "test",
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["value"] == "test"
@pytest.mark.django_db
def test_create_element_bad_request(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": "heading", "value": []},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
@pytest.mark.django_db
def test_create_element_permission_denied(
api_client, data_fixture, stub_check_permissions
):
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})
with stub_check_permissions(raise_permission_denied=True):
response = api_client.post(
url,
{"type": "heading"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json()["error"] == "PERMISSION_DENIED"
@pytest.mark.django_db
def test_create_element_page_does_not_exist(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
url = reverse("api:builder:element:list", kwargs={"page_id": 0})
response = api_client.post(
url,
{"type": "heading"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_PAGE_DOES_NOT_EXIST"
@pytest.mark.django_db
def test_update_element(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
url = reverse("api:builder:element:item", kwargs={"element_id": element1.id})
response = api_client.patch(
url,
{"value": "unusual suspect", "level": 3},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["value"] == "unusual suspect"
assert response.json()["level"] == 3
@pytest.mark.django_db
def test_update_element_bad_request(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
url = reverse("api:builder:element:item", kwargs={"element_id": element1.id})
response = api_client.patch(
url,
{"value": []},
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_update_element_does_not_exist(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
url = reverse("api:builder:element:item", kwargs={"element_id": 0})
response = api_client.patch(
url,
{"value": "test"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_ELEMENT_DOES_NOT_EXIST"
@pytest.mark.django_db
def test_order_elements(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(page=page)
url = reverse("api:builder:element:order", kwargs={"page_id": page.id})
response = api_client.post(
url,
{"element_ids": [element3.id, element1.id]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_204_NO_CONTENT
@pytest.mark.django_db
def test_order_elements_element_not_in_page(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(user=user)
url = reverse("api:builder:element:order", kwargs={"page_id": page.id})
response = api_client.post(
url,
{"element_ids": [element3.id, element1.id]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_ELEMENT_NOT_IN_PAGE"
@pytest.mark.django_db
def test_order_elements_page_does_not_exist(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(user=user)
url = reverse("api:builder:element:order", kwargs={"page_id": 0})
response = api_client.post(
url,
{"element_ids": [element3.id, element1.id]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_PAGE_DOES_NOT_EXIST"
@pytest.mark.django_db
def test_delete_element(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
url = reverse("api:builder:element:item", kwargs={"element_id": element1.id})
response = api_client.delete(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_204_NO_CONTENT
@pytest.mark.django_db
def test_delete_element_permission_denied(
api_client, data_fixture, stub_check_permissions
):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
url = reverse("api:builder:element:item", kwargs={"element_id": element1.id})
with stub_check_permissions(raise_permission_denied=True):
response = api_client.delete(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json()["error"] == "PERMISSION_DENIED"
@pytest.mark.django_db
def test_delete_element_element_not_exist(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
url = reverse("api:builder:element:item", kwargs={"element_id": 0})
response = api_client.delete(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_ELEMENT_DOES_NOT_EXIST"

View file

@ -0,0 +1,187 @@
from decimal import Decimal
import pytest
from baserow.contrib.builder.elements.exceptions import ElementDoesNotExist
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.models import (
Element,
HeadingElement,
ParagraphElement,
)
from baserow.contrib.builder.elements.registries import element_type_registry
def pytest_generate_tests(metafunc):
if "element_type" in metafunc.fixturenames:
metafunc.parametrize(
"element_type",
[pytest.param(e, id=e.type) for e in element_type_registry.get_all()],
)
@pytest.mark.django_db
def test_create_element(data_fixture, element_type):
page = data_fixture.create_builder_page()
sample_params = element_type.get_sample_params()
element = ElementHandler().create_element(element_type, page=page, **sample_params)
assert element.page.id == page.id
for key, value in sample_params.items():
assert getattr(element, key) == value
assert element.order == 1
assert Element.objects.count() == 1
@pytest.mark.django_db
def test_get_element(data_fixture):
element = data_fixture.create_builder_heading_element()
assert ElementHandler().get_element(element.id).id == element.id
@pytest.mark.django_db
def test_get_element_does_not_exist(data_fixture):
with pytest.raises(ElementDoesNotExist):
assert ElementHandler().get_element(0)
@pytest.mark.django_db
def test_get_elements(data_fixture):
page = data_fixture.create_builder_page()
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_paragraph_element(page=page)
elements = ElementHandler().get_elements(page)
assert [e.id for e in elements] == [
element1.id,
element2.id,
element3.id,
]
assert isinstance(elements[0], HeadingElement)
assert isinstance(elements[2], ParagraphElement)
@pytest.mark.django_db
def test_delete_element(data_fixture):
element = data_fixture.create_builder_heading_element()
ElementHandler().delete_element(element)
assert Element.objects.count() == 0
@pytest.mark.django_db
def test_update_element(data_fixture):
user = data_fixture.create_user()
element = data_fixture.create_builder_heading_element(user=user)
element_updated = ElementHandler().update_element(
element, value={"type": "plain", "expression": "newValue"}
)
assert element_updated.value == {"type": "plain", "expression": "newValue"}
@pytest.mark.django_db
def test_update_element_invalid_values(data_fixture):
element = data_fixture.create_builder_heading_element()
element_updated = ElementHandler().update_element(element, nonsense="hello")
assert not hasattr(element_updated, "nonsense")
@pytest.mark.django_db
def test_order_elements(data_fixture):
page = data_fixture.create_builder_page()
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(page=page)
ElementHandler().order_elements(page, [element3.id, element1.id])
first, second, third = Element.objects.all()
assert first.id == element2.id
assert second.id == element3.id
assert third.id == element1.id
@pytest.mark.django_db
def test_recalculate_full_orders(data_fixture):
page = data_fixture.create_builder_page()
element1 = data_fixture.create_builder_heading_element(
page=page, order="1.99999999999999999999"
)
element2 = data_fixture.create_builder_heading_element(
page=page, order="2.00000000000000000000"
)
element3 = data_fixture.create_builder_heading_element(
page=page, order="1.99999999999999999999"
)
element4 = data_fixture.create_builder_heading_element(
page=page, order="2.10000000000000000000"
)
element5 = data_fixture.create_builder_heading_element(
page=page, order="3.00000000000000000000"
)
element6 = data_fixture.create_builder_heading_element(
page=page, order="1.00000000000000000001"
)
element7 = data_fixture.create_builder_heading_element(
page=page, order="3.99999999999999999999"
)
element8 = data_fixture.create_builder_heading_element(
page=page, order="4.00000000000000000001"
)
page2 = data_fixture.create_builder_page()
elementA = data_fixture.create_builder_heading_element(
page=page2, order="1.99999999999999999999"
)
elementB = data_fixture.create_builder_heading_element(
page=page2, order="2.00300000000000000000"
)
ElementHandler().recalculate_full_orders(page)
elements = Element.objects.filter(page=page)
assert elements[0].id == element6.id
assert elements[0].order == Decimal("1.00000000000000000000")
assert elements[1].id == element1.id
assert elements[1].order == Decimal("2.00000000000000000000")
assert elements[2].id == element3.id
assert elements[2].order == Decimal("3.00000000000000000000")
assert elements[3].id == element2.id
assert elements[3].order == Decimal("4.00000000000000000000")
assert elements[4].id == element4.id
assert elements[4].order == Decimal("5.00000000000000000000")
assert elements[5].id == element5.id
assert elements[5].order == Decimal("6.00000000000000000000")
assert elements[6].id == element7.id
assert elements[6].order == Decimal("7.00000000000000000000")
assert elements[7].id == element8.id
assert elements[7].order == Decimal("8.00000000000000000000")
# Other page elements shouldn't be reordered
elements = Element.objects.filter(page=page2)
assert elements[0].id == elementA.id
assert elements[0].order == Decimal("1.99999999999999999999")
assert elements[1].id == elementB.id
assert elements[1].order == Decimal("2.00300000000000000000")

View file

@ -0,0 +1,289 @@
from decimal import Decimal
from unittest.mock import patch
import pytest
from baserow.contrib.builder.elements.exceptions import (
ElementDoesNotExist,
ElementNotInPage,
)
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.elements.service import ElementService
from baserow.core.exceptions import PermissionException
def pytest_generate_tests(metafunc):
if "element_type" in metafunc.fixturenames:
metafunc.parametrize(
"element_type",
[pytest.param(e, id=e.type) for e in element_type_registry.get_all()],
)
@pytest.mark.django_db
@patch("baserow.contrib.builder.elements.service.element_created")
def test_create_element(element_created_mock, data_fixture, element_type):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page, order="1.0000")
element3 = data_fixture.create_builder_heading_element(page=page, order="2.0000")
sample_params = element_type.get_sample_params()
element = ElementService().create_element(
user, element_type, page=page, **sample_params
)
last_element = Element.objects.last()
# Check it's the last element
assert last_element.id == element.id
assert element_created_mock.called_with(element=element, user=user)
@pytest.mark.django_db
def test_create_element_before(data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page, order="1.0000")
element3 = data_fixture.create_builder_heading_element(page=page, order="2.0000")
element_type = element_type_registry.get("heading")
sample_params = element_type.get_sample_params()
element2 = ElementService().create_element(
user, element_type, page=page, before=element3, **sample_params
)
elements = Element.objects.all()
assert elements[0].id == element1.id
assert elements[1].id == element2.id
assert elements[2].id == element3.id
@pytest.mark.django_db
def test_get_unique_orders_before_element_triggering_full_page_order_reset(
data_fixture,
):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element_1 = data_fixture.create_builder_heading_element(
page=page, order="1.00000000000000000000"
)
element_2 = data_fixture.create_builder_heading_element(
page=page, order="1.00000000000000001000"
)
element_3 = data_fixture.create_builder_heading_element(
page=page, order="2.99999999999999999999"
)
element_4 = data_fixture.create_builder_heading_element(
page=page, order="2.99999999999999999998"
)
element_type = element_type_registry.get("heading")
sample_params = element_type.get_sample_params()
element_created = ElementService().create_element(
user, element_type, page=page, before=element_3, **sample_params
)
element_1.refresh_from_db()
element_2.refresh_from_db()
element_3.refresh_from_db()
element_4.refresh_from_db()
assert element_1.order == Decimal("1.00000000000000000000")
assert element_2.order == Decimal("2.00000000000000000000")
assert element_4.order == Decimal("3.00000000000000000000")
assert element_3.order == Decimal("4.00000000000000000000")
assert element_created.order == Decimal("3.50000000000000000000")
@pytest.mark.django_db
@patch("baserow.contrib.builder.elements.service.element_orders_recalculated")
def test_recalculate_full_order(element_orders_recalculated_mock, data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
data_fixture.create_builder_heading_element(page=page, order="1.9000")
data_fixture.create_builder_heading_element(page=page, order="3.4000")
ElementService().recalculate_full_orders(user, page)
assert element_orders_recalculated_mock.called_with(page=page, user=user)
@pytest.mark.django_db
def test_create_element_permission_denied(data_fixture, stub_check_permissions):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element_type = element_type_registry.get("heading")
with stub_check_permissions(raise_permission_denied=True), pytest.raises(
PermissionException
):
ElementService().create_element(
user, element_type, page=page, **element_type.get_sample_params()
)
@pytest.mark.django_db
def test_get_element(data_fixture):
user = data_fixture.create_user()
element = data_fixture.create_builder_heading_element(user=user)
assert ElementService().get_element(user, element.id).id == element.id
@pytest.mark.django_db
def test_get_element_does_not_exist(data_fixture):
user = data_fixture.create_user()
with pytest.raises(ElementDoesNotExist):
assert ElementService().get_element(user, 0)
@pytest.mark.django_db
def test_get_element_permission_denied(data_fixture, stub_check_permissions):
user = data_fixture.create_user()
element = data_fixture.create_builder_heading_element(user=user)
with stub_check_permissions(raise_permission_denied=True), pytest.raises(
PermissionException
):
ElementService().get_element(user, element.id)
@pytest.mark.django_db
def test_get_elements(data_fixture, stub_check_permissions):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_paragraph_element(page=page)
assert [p.id for p in ElementService().get_elements(user, page)] == [
element1.id,
element2.id,
element3.id,
]
def exclude_element_1(
actor,
operation_name,
queryset,
workspace=None,
context=None,
allow_if_template=False,
):
return queryset.exclude(id=element1.id)
with stub_check_permissions() as stub:
stub.filter_queryset = exclude_element_1
assert [p.id for p in ElementService().get_elements(user, page)] == [
element2.id,
element3.id,
]
@pytest.mark.django_db
@patch("baserow.contrib.builder.elements.service.element_deleted")
def test_delete_element(element_deleted_mock, data_fixture):
user = data_fixture.create_user()
element = data_fixture.create_builder_heading_element(user=user)
ElementService().delete_element(user, element)
assert element_deleted_mock.called_with(element_id=element.id, user=user)
@pytest.mark.django_db(transaction=True)
def test_delete_element_permission_denied(data_fixture, stub_check_permissions):
user = data_fixture.create_user()
element = data_fixture.create_builder_heading_element(user=user)
with stub_check_permissions(raise_permission_denied=True), pytest.raises(
PermissionException
):
ElementService().delete_element(user, element)
@pytest.mark.django_db
@patch("baserow.contrib.builder.elements.service.element_updated")
def test_update_element(element_updated_mock, data_fixture):
user = data_fixture.create_user()
element = data_fixture.create_builder_heading_element(user=user)
element_updated = ElementService().update_element(user, element, value="newValue")
assert element_updated_mock.called_with(element=element_updated, user=user)
@pytest.mark.django_db(transaction=True)
def test_update_element_permission_denied(data_fixture, stub_check_permissions):
user = data_fixture.create_user()
element = data_fixture.create_builder_heading_element(user=user)
with stub_check_permissions(raise_permission_denied=True), pytest.raises(
PermissionException
):
ElementService().update_element(user, element, value="newValue")
@pytest.mark.django_db
@patch("baserow.contrib.builder.elements.service.elements_reordered")
def test_order_elements(element_updated_mock, data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(page=page)
ElementService().order_elements(user, page, [element3.id, element1.id])
assert element_updated_mock.called_with(
full_order=[element2.id, element3.id, element1.id], page=page, user=user
)
@pytest.mark.django_db
def test_order_elements_permission_denied(data_fixture, stub_check_permissions):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(page=page)
with stub_check_permissions(raise_permission_denied=True), pytest.raises(
PermissionException
):
ElementService().order_elements(user, page, [element3.id, element1.id])
@pytest.mark.django_db
def test_order_elements_not_in_page(data_fixture, stub_check_permissions):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(user=user)
with pytest.raises(ElementNotInPage):
ElementService().order_elements(user, page, [element3.id, element1.id])
def filter_queryset(
actor,
operation_name,
queryset,
workspace=None,
context=None,
allow_if_template=False,
):
return queryset.exclude(id=element1.id)
with stub_check_permissions() as stub, pytest.raises(ElementNotInPage):
stub.filter_queryset = filter_queryset
ElementService().order_elements(user, page, [element3.id, element1.id])

View file

@ -0,0 +1,51 @@
import pytest
from baserow.contrib.builder.elements.registries import (
ElementType,
element_type_registry,
)
def pytest_generate_tests(metafunc):
if "element_type" in metafunc.fixturenames:
metafunc.parametrize(
"element_type",
[pytest.param(e, id=e.type) for e in element_type_registry.get_all()],
)
@pytest.mark.django_db
def test_export_element(data_fixture, element_type: ElementType):
page = data_fixture.create_builder_page()
sample_params = element_type.get_sample_params()
element = data_fixture.create_builder_element(
element_type.model_class, page=page, order=17, **sample_params
)
exported = element_type.export_serialized(element)
assert exported["id"] == element.id
assert exported["type"] == element_type.type
assert exported["order"] == element.order
for key, value in sample_params.items():
assert exported[key] == value
@pytest.mark.django_db
def test_import_element(data_fixture, element_type: ElementType):
page = data_fixture.create_builder_page()
sample_params = element_type.get_sample_params()
serialized = {"id": 9999, "order": 42, "type": element_type.type}
serialized.update(element_type.get_sample_params())
id_mapping = {}
element = element_type.import_serialized(page, serialized, id_mapping)
assert element.id != 9999
assert element.order == element.order
assert isinstance(element, element_type.model_class)
for key, value in sample_params.items():
assert getattr(element, key) == value

View file

@ -1,7 +1,9 @@
import pytest
from baserow.contrib.builder.application_types import BuilderApplicationType
from baserow.contrib.builder.elements.models import HeadingElement, ParagraphElement
from baserow.contrib.builder.pages.models import Page
from baserow.core.db import specific_iterator
@pytest.mark.django_db
@ -14,3 +16,135 @@ def test_builder_application_type_init_application(data_fixture):
BuilderApplicationType().init_application(user, builder)
assert Page.objects.count() == 1
@pytest.mark.django_db
def test_builder_application_export(data_fixture):
user = data_fixture.create_user()
builder = data_fixture.create_builder_application(user=user)
page1 = data_fixture.create_builder_page(builder=builder)
page2 = data_fixture.create_builder_page(builder=builder)
element1 = data_fixture.create_builder_heading_element(
page=page1, level=2, value="foo"
)
element2 = data_fixture.create_builder_paragraph_element(page=page1)
element3 = data_fixture.create_builder_heading_element(page=page2)
serialized = BuilderApplicationType().export_serialized(builder)
assert serialized == {
"pages": [
{
"id": page1.id,
"name": page1.name,
"order": page1.order,
"elements": [
{
"id": element1.id,
"type": "heading",
"order": element1.order,
"value": element1.value,
"level": element1.level,
},
{
"id": element2.id,
"type": "paragraph",
"order": element2.order,
"value": element2.value,
},
],
},
{
"id": page2.id,
"name": page2.name,
"order": page2.order,
"elements": [
{
"id": element3.id,
"type": "heading",
"order": element3.order,
"value": element3.value,
"level": element3.level,
},
],
},
],
"id": builder.id,
"name": builder.name,
"order": builder.order,
"type": "builder",
}
IMPORT_REFERENCE = {
"pages": [
{
"id": 999,
"name": "Tammy Hall",
"order": 1,
"elements": [
{
"id": 998,
"type": "heading",
"order": 1,
"value": "foo",
"level": 2,
},
{
"id": 999,
"type": "paragraph",
"order": 2,
"value": "",
},
],
},
{
"id": 998,
"name": "Megan Clark",
"order": 2,
"elements": [
{
"id": 997,
"type": "heading",
"order": 1,
"value": "",
"level": 1,
}
],
},
],
"id": 999,
"name": "Holly Sherman",
"order": 0,
"type": "builder",
}
@pytest.mark.django_db
def test_builder_application_import(data_fixture):
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)
builder = BuilderApplicationType().import_serialized(
workspace, IMPORT_REFERENCE, {}
)
assert builder.id != IMPORT_REFERENCE["id"]
assert builder.page_set.count() == 2
assert builder.page_set.count() == 2
[page1, page2] = builder.page_set.all()
assert page1.element_set.count() == 2
assert page2.element_set.count() == 1
[element1, element2] = specific_iterator(page1.element_set.all())
assert isinstance(element1, HeadingElement)
assert isinstance(element2, ParagraphElement)
assert element1.order == 1
assert element1.level == 2

View file

@ -0,0 +1,81 @@
from unittest.mock import patch
import pytest
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.elements.service import ElementService
from baserow.core.utils import generate_hash
@pytest.mark.django_db(transaction=True)
@patch("baserow.contrib.builder.ws.element.signals.broadcast_to_permitted_users")
def test_element_created(mock_broadcast_to_permitted_users, data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element = ElementService().create_element(
user=user,
element_type=element_type_registry.get("heading"),
page=page,
)
mock_broadcast_to_permitted_users.delay.assert_called_once()
args = mock_broadcast_to_permitted_users.delay.call_args
assert args[0][4]["type"] == "element_created"
assert args[0][4]["element"]["id"] == element.id
assert args[0][4]["element"]["level"] == 1
@pytest.mark.django_db(transaction=True)
@patch("baserow.contrib.builder.ws.element.signals.broadcast_to_permitted_users")
def test_element_updated(mock_broadcast_to_permitted_users, data_fixture):
user = data_fixture.create_user()
element = data_fixture.create_builder_heading_element(user=user)
ElementService().update_element(user=user, element=element, level=3)
mock_broadcast_to_permitted_users.delay.assert_called_once()
args = mock_broadcast_to_permitted_users.delay.call_args
assert args[0][4]["type"] == "element_updated"
assert args[0][4]["element"]["id"] == element.id
assert args[0][4]["element"]["level"] == 3
@pytest.mark.django_db(transaction=True)
@patch("baserow.contrib.builder.ws.element.signals.broadcast_to_permitted_users")
def test_element_deleted(mock_broadcast_to_permitted_users, data_fixture):
user = data_fixture.create_user()
element = data_fixture.create_builder_heading_element(user=user)
ElementService().delete_element(user=user, element=element)
mock_broadcast_to_permitted_users.delay.assert_called_once()
args = mock_broadcast_to_permitted_users.delay.call_args
assert args[0][4]["type"] == "element_deleted"
assert args[0][4]["element_id"] == element.id
assert args[0][4]["page_id"] == element.page_id
@pytest.mark.django_db(transaction=True)
@patch("baserow.contrib.builder.ws.element.signals.broadcast_to_group")
def test_element_reorder(mock_broadcast_to_group, data_fixture):
user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user)
element1 = data_fixture.create_builder_heading_element(page=page)
element2 = data_fixture.create_builder_heading_element(page=page)
element3 = data_fixture.create_builder_heading_element(page=page)
ElementService().order_elements(
user=user, page=page, new_order=[element3.id, element1.id]
)
mock_broadcast_to_group.delay.assert_called_once()
args = mock_broadcast_to_group.delay.call_args
assert args[0][1]["type"] == "elements_reordered"
assert args[0][1]["order"] == [
generate_hash(e) for e in [element2.id, element3.id, element1.id]
]
assert args[0][1]["page_id"] == generate_hash(page.id)

View file

@ -977,8 +977,8 @@ def test_get_unique_orders_before_row_triggering_full_table_order_reset(data_fix
handler = RowHandler()
assert handler.get_unique_orders_before_row(row_3, model, 2) == [
Decimal("2.50000000000000000000"),
Decimal("2.66666666666666651864"),
Decimal("3.50000000000000000000"),
Decimal("3.66666666666666651864"),
]
row_1.refresh_from_db()

View file

@ -1,61 +0,0 @@
from decimal import Decimal
import pytest
from baserow.contrib.database.rows.exceptions import CannotCalculateIntermediateOrder
from baserow.contrib.database.rows.utils import find_intermediate_order
def test_find_intermediate_order_with_decimals():
assert find_intermediate_order(
Decimal("1.00000000000000000000"), Decimal("2.00000000000000000000")
) == Decimal("1.50000000000000000000")
def test_find_intermediate_order_with_floats():
assert find_intermediate_order(
1.00000000000000000000, 2.00000000000000000000
) == Decimal("1.50000000000000000000")
def test_find_intermediate_order_with_lower_than_one_values():
assert find_intermediate_order(
Decimal("0.00000000000000000000"), Decimal("1.00000000000000000000")
) == Decimal("0.50000000000000000000")
def test_find_intermediate_order_with_10k_iterations():
start = Decimal("1.00000000000000000000")
end = Decimal("2.00000000000000000000")
for i in range(0, 10000):
end = find_intermediate_order(start, end)
assert end == 1.000099990001
def test_find_intermediate_order_with_more_iterations_than_max_denominator():
start = Decimal("1.00000000000000000000")
end = Decimal("2.00000000000000000000")
for i in range(0, 100):
end = find_intermediate_order(start, end, 100)
with pytest.raises(CannotCalculateIntermediateOrder):
find_intermediate_order(start, end, 100)
def test_find_intermediate_with_equal_order():
with pytest.raises(CannotCalculateIntermediateOrder):
find_intermediate_order(
Decimal("1.00000000000000000001"), Decimal("1.00000000000000100000")
)
find_intermediate_order(
Decimal("1.0100000000000000000"), Decimal("1.02000000000000000000"), 100
)
with pytest.raises(CannotCalculateIntermediateOrder):
find_intermediate_order(
Decimal("1.0100000000000000000"), Decimal("1.02000000000000000000"), 10
)

View file

@ -1,3 +1,4 @@
from decimal import Decimal
from io import BytesIO
from unittest.mock import MagicMock, patch
@ -5,7 +6,10 @@ from django.db import OperationalError
import pytest
from baserow.core.exceptions import is_max_lock_exceeded_exception
from baserow.core.exceptions import (
CannotCalculateIntermediateOrder,
is_max_lock_exceeded_exception,
)
from baserow.core.utils import (
ChildProgressBuilder,
MirrorDict,
@ -13,6 +17,7 @@ from baserow.core.utils import (
atomic_if_not_already,
dict_to_object,
extract_allowed,
find_intermediate_order,
find_unused_name,
grouper,
random_string,
@ -401,3 +406,58 @@ def test_is_max_lock_exceeded_exception():
"HINT: You might need to increase max_locks_per_transaction."
)
assert is_max_lock_exceeded_exception(correct_exc)
def test_find_intermediate_order_with_decimals():
assert find_intermediate_order(
Decimal("1.00000000000000000000"), Decimal("2.00000000000000000000")
) == Decimal("1.50000000000000000000")
def test_find_intermediate_order_with_floats():
assert find_intermediate_order(
1.00000000000000000000, 2.00000000000000000000
) == Decimal("1.50000000000000000000")
def test_find_intermediate_order_with_lower_than_one_values():
assert find_intermediate_order(
Decimal("0.00000000000000000000"), Decimal("1.00000000000000000000")
) == Decimal("0.50000000000000000000")
def test_find_intermediate_order_with_10k_iterations():
start = Decimal("1.00000000000000000000")
end = Decimal("2.00000000000000000000")
for i in range(0, 10000):
end = find_intermediate_order(start, end)
assert end == 1.000099990001
def test_find_intermediate_order_with_more_iterations_than_max_denominator():
start = Decimal("1.00000000000000000000")
end = Decimal("2.00000000000000000000")
for i in range(0, 100):
end = find_intermediate_order(start, end, 100)
with pytest.raises(CannotCalculateIntermediateOrder):
find_intermediate_order(start, end, 100)
def test_find_intermediate_with_equal_order():
with pytest.raises(CannotCalculateIntermediateOrder):
find_intermediate_order(
Decimal("1.00000000000000000001"), Decimal("1.00000000000000100000")
)
find_intermediate_order(
Decimal("1.0100000000000000000"), Decimal("1.02000000000000000000"), 100
)
with pytest.raises(CannotCalculateIntermediateOrder):
find_intermediate_order(
Decimal("1.0100000000000000000"), Decimal("1.02000000000000000000"), 10
)

View file

@ -3,6 +3,14 @@ from baserow_premium.row_comments.operations import (
ReadRowCommentsOperationType,
)
from baserow.contrib.builder.elements.operations import (
CreateElementOperationType,
DeleteElementOperationType,
ListElementsPageOperationType,
OrderElementsPageOperationType,
ReadElementOperationType,
UpdateElementOperationType,
)
from baserow.contrib.builder.operations import (
ListPagesBuilderOperationType,
OrderPagesBuilderOperationType,
@ -270,6 +278,12 @@ BUILDER_OPS = EDITOR_OPS + [
UseTokenOperationType,
OrderTablesDatabaseTableOperationType,
OrderApplicationsOperationType,
CreateElementOperationType,
UpdateElementOperationType,
DeleteElementOperationType,
ReadElementOperationType,
ListElementsPageOperationType,
OrderElementsPageOperationType,
]
ADMIN_OPS = BUILDER_OPS + [
UpdateWorkspaceOperationType,

View file

@ -15,7 +15,7 @@ VALID_ONE_SEAT_ENTERPRISE_LICENSE = (
@pytest.fixture # noqa: F405
def enterprise_data_fixture(data_fixture):
def enterprise_data_fixture(fake, data_fixture):
from .enterprise_fixtures import EnterpriseFixtures as EnterpriseFixturesBase
from .fixtures.sso import OAuth2Fixture, SamlFixture
@ -27,7 +27,7 @@ def enterprise_data_fixture(data_fixture):
):
pass
return EnterpriseFixtures()
return EnterpriseFixtures(fake)
@pytest.fixture # noqa: F405

View file

@ -1267,6 +1267,7 @@ def test_all_operations_are_in_at_least_one_default_role(data_fixture):
for op in all_ops:
if op.type not in all_ops_in_roles and op.type not in exceptions:
missing_ops.append(op)
assert missing_ops == [], "Non Assigned Ops:\n" + str(
"\n".join([o.__class__.__name__ + "," for o in missing_ops])
)

View file

@ -14,13 +14,13 @@ from baserow.test_utils.pytest_conftest import * # noqa: F403, F401
@pytest.fixture
def premium_data_fixture(data_fixture):
def premium_data_fixture(fake, data_fixture):
from .fixtures import PremiumFixtures
class PremiumFixtures(PremiumFixtures, data_fixture.__class__):
pass
return PremiumFixtures()
return PremiumFixtures(fake)
@pytest.fixture

View file

@ -0,0 +1,14 @@
<template>
<a class="select__footer-create-link" v-on="$listeners">
<span>
<i class="select__footer-create-icon fas fa-stream"></i>
{{ $t('addElementButton.label') }}
</span>
</a>
</template>
<script>
export default {
name: 'AddElementButton',
}
</script>

View file

@ -0,0 +1,40 @@
<template>
<div
class="add-element-card"
:class="{ 'add-element-card--disabled': disabled }"
v-on="$listeners"
>
<div v-if="loading" class="loading"></div>
<template v-else>
<div>
<i :class="`fas fa-${elementType.iconClass}`"></i>
<span class="margin-left-1">{{ elementType.name }}</span>
</div>
<div class="margin-top-1 add-element-card__description">
{{ elementType.description }}
</div>
</template>
</div>
</template>
<script>
export default {
name: 'AddElementCard',
props: {
elementType: {
type: Object,
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
}
</script>

View file

@ -0,0 +1,68 @@
<template>
<Modal>
<h2 class="box__title">{{ $t('addElementModal.title') }}</h2>
<InputWithIcon
v-model="search"
class="margin-bottom-2"
:placeholder="$t('addElementModal.searchPlaceholder')"
icon="search"
/>
<div class="add-element-modal__element-cards">
<AddElementCard
v-for="elementType in elementTypes"
:key="elementType.getType()"
:element-type="elementType"
:loading="addingElementType === elementType.getType()"
:disabled="isCardDisabled(elementType)"
@click="$emit('add', elementType)"
/>
</div>
</Modal>
</template>
<script>
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'
export default {
name: 'AddElementModal',
components: { AddElementCard },
mixins: [modal],
props: {
page: {
type: Object,
required: true,
},
addingElementType: {
type: String,
required: false,
default: null,
},
},
data() {
return {
search: '',
}
},
computed: {
elementTypes() {
const allElementTypes = Object.values(this.$registry.getAll('element'))
return allElementTypes.filter((elementType) =>
isSubstringOfStrings(
[elementType.name, elementType.description],
this.search
)
)
},
},
methods: {
isCardDisabled(elementType) {
return (
this.addingElementType !== null &&
elementType.getType() !== this.addingElementType
)
},
},
}
</script>

View file

@ -0,0 +1,65 @@
<template>
<div class="element__menu">
<div v-if="isCopying" class="loading element__menu-copy-loading"></div>
<a v-else class="element__menu-item" @click="$emit('copy')">
<i class="fas fa-copy"></i>
<span class="element__menu-item-description">
{{ $t('action.copy') }}
</span>
</a>
<a
class="element__menu-item"
:class="{ disabled: moveUpDisabled }"
@click="!moveUpDisabled && $emit('move', PLACEMENTS.BEFORE)"
>
<i class="fas fa-arrow-up"></i>
<span v-if="!moveUpDisabled" class="element__menu-item-description">
{{ $t('elementMenu.moveUp') }}
</span>
</a>
<a
class="element__menu-item"
:class="{ disabled: moveDownDisabled }"
@click="!moveDownDisabled && $emit('move', PLACEMENTS.AFTER)"
>
<i class="fas fa-arrow-down"></i>
<span v-if="!moveDownDisabled" class="element__menu-item-description">
{{ $t('elementMenu.moveDown') }}
</span>
</a>
<a class="element__menu-item" @click="$emit('delete')">
<i class="fas fa-trash"></i>
<span class="element__menu-item-description">
{{ $t('action.delete') }}
</span>
</a>
</div>
</template>
<script>
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,
},
isCopying: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
PLACEMENTS: () => PLACEMENTS,
},
}
</script>

View file

@ -0,0 +1,74 @@
<template>
<div
class="element"
:class="{ 'element--active': active }"
@click="$emit('selected')"
>
<InsertElementButton
v-if="active"
class="element__insert--top"
@click="$emit('insert', PLACEMENTS.BEFORE)"
/>
<ElementMenu
v-if="active"
:move-up-disabled="isFirstElement"
:move-down-disabled="isLastElement"
:is-copying="isCopying"
@delete="$emit('delete')"
@move="$emit('move', $event)"
@copy="$emit('copy')"
/>
<component
:is="elementType.component"
v-bind="elementType.getComponentProps(element)"
class="element__component"
></component>
<InsertElementButton
v-if="active"
class="element__insert--bottom"
@click="$emit('insert', PLACEMENTS.AFTER)"
/>
</div>
</template>
<script>
import ElementMenu from '@baserow/modules/builder/components/elements/ElementMenu'
import InsertElementButton from '@baserow/modules/builder/components/elements/InsertElementButton'
import { PLACEMENTS } from '@baserow/modules/builder/enums'
export default {
name: 'ElementPreview',
components: { ElementMenu, InsertElementButton },
props: {
element: {
type: Object,
required: true,
},
active: {
type: Boolean,
required: false,
default: false,
},
isLastElement: {
type: Boolean,
required: false,
default: false,
},
isFirstElement: {
type: Boolean,
required: false,
default: false,
},
isCopying: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
PLACEMENTS: () => PLACEMENTS,
elementType() {
return this.$registry.get('element', this.element.type)
},
},
}
</script>

View file

@ -0,0 +1,102 @@
<template>
<Context>
<div class="select elements-context">
<div class="select__search">
<i class="select__search-icon fas fa-search"></i>
<input
v-model="search"
type="text"
class="select__search-input"
:placeholder="$t('elementsContext.searchPlaceholder')"
/>
</div>
<ElementsList
v-if="elementsMatchingSearchTerm.length"
:elements="elementsMatchingSearchTerm"
@select="selectElement($event)"
/>
<div class="select__footer">
<div class="select__footer-create">
<AddElementButton
: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>
</Context>
</template>
<script>
import context from '@baserow/modules/core/mixins/context'
import ElementsList from '@baserow/modules/builder/components/elements/ElementsList'
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 {
name: 'ElementsContext',
components: { AddElementModal, AddElementButton, ElementsList },
mixins: [context],
data() {
return {
search: null,
addingElementType: null,
}
},
computed: {
...mapGetters({
page: 'page/getSelected',
}),
elements() {
return this.$store.getters['element/getElements']
},
elementsMatchingSearchTerm() {
if (
this.search === '' ||
this.search === null ||
this.search === undefined
) {
return this.elements
}
return this.elements.filter((element) => {
const elementType = this.$registry.get('element', element.type)
return isSubstringOfStrings([elementType.name], this.search)
})
},
},
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
},
selectElement(element) {
this.actionSelectElement({ element })
this.hide()
},
},
}
</script>

View file

@ -0,0 +1,21 @@
<template>
<ul v-auto-overflow-scroll class="select__items">
<li v-for="element in elements" :key="element.id" class="select__item">
<ElementsListItem :element="element" @click="$emit('select', element)" />
</li>
</ul>
</template>
<script>
import ElementsListItem from '@baserow/modules/builder/components/elements/ElementsListItem'
export default {
name: 'ElementsList',
components: { ElementsListItem },
props: {
elements: {
type: Array,
required: true,
},
},
}
</script>

View file

@ -0,0 +1,25 @@
<template>
<a class="select__item-link" @click="$emit('click')">
<span class="select__item-name">
<i :class="`fas fa-${elementType.iconClass} select__item-icon`"></i>
{{ elementType.name }}
</span>
</a>
</template>
<script>
export default {
name: 'ElementsListItem',
props: {
element: {
type: Object,
required: true,
},
},
computed: {
elementType() {
return this.$registry.get('element', this.element.type)
},
},
}
</script>

View file

@ -0,0 +1,11 @@
<template>
<a class="element__insert" @click="$emit('click')">
<i class="fas fa-plus"></i>
</a>
</template>
<script>
export default {
name: 'InsertElementButton',
}
</script>

View file

@ -0,0 +1,23 @@
<template>
<div>
<a
ref="button"
class="header__filter-link"
@click="$refs.context.toggle($refs.button)"
>
<i class="header__filter-icon fas fa-stream"></i>
<span class="header__filter-name">{{
$t('pageHeaderElements.label')
}}</span>
</a>
<ElementsContext ref="context" />
</div>
</template>
<script>
import ElementsContext from '@baserow/modules/builder/components/elements/ElementsContext'
export default {
name: 'PageHeaderElements',
components: { ElementsContext },
}
</script>

View file

@ -0,0 +1,18 @@
<template>
<component :is="`h${level}`">{{ value }}</component>
</template>
<script>
import textElement from '@baserow/modules/builder/mixins/elements/textElement'
export default {
name: 'HeaderElement',
mixins: [textElement],
props: {
level: {
type: Number,
required: true,
},
},
}
</script>

View file

@ -0,0 +1,12 @@
<template>
<p>{{ value }}</p>
</template>
<script>
import textElement from '@baserow/modules/builder/mixins/elements/textElement'
export default {
name: 'ParagraphElement',
mixins: [textElement],
}
</script>

View file

@ -0,0 +1,37 @@
<template>
<ul class="header__filter">
<li
v-for="(deviceType, index) in deviceTypes"
:key="deviceType.getType()"
class="header__filter-item"
:class="{ 'header__filter-item--no-margin-left': index === 0 }"
>
<a
class="header__filter-link"
:class="{
'active active--primary': deviceTypeSelected === deviceType.getType(),
}"
@click="$emit('selected', deviceType.getType())"
>
<i :class="`header__filter-icon fas fa-${deviceType.iconClass}`"></i>
</a>
</li>
</ul>
</template>
<script>
export default {
name: 'DeviceSelector',
props: {
deviceTypeSelected: {
type: String,
required: true,
},
},
computed: {
deviceTypes() {
return Object.values(this.$registry.getOrderedList('device'))
},
},
}
</script>

View file

@ -0,0 +1,42 @@
<template>
<header class="layout__col-2-1 header header--space-between">
<ul class="header__filter">
<li class="header__filter-item">
<PageHeaderElements />
</li>
</ul>
<DeviceSelector
:device-type-selected="deviceTypeSelected"
@selected="actionSetDeviceTypeSelected"
/>
</header>
</template>
<script>
import PageHeaderElements from '@baserow/modules/builder/components/elements/PageHeaderElements'
import DeviceSelector from '@baserow/modules/builder/components/page/DeviceSelector'
import { mapActions, mapGetters } from 'vuex'
export default {
name: 'PageHeader',
components: { DeviceSelector, PageHeaderElements },
computed: {
...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected' }),
deviceTypes() {
return Object.values(this.$registry.getOrderedList('device'))
},
},
created() {
if (this.deviceTypeSelected === null) {
this.$store.dispatch(
'page/setDeviceTypeSelected',
this.deviceTypes[0].getType()
)
}
},
methods: {
...mapActions({
actionSetDeviceTypeSelected: 'page/setDeviceTypeSelected',
}),
},
}
</script>

View file

@ -0,0 +1,206 @@
<template>
<div class="page-preview__wrapper">
<div ref="preview" class="page-preview" :style="{ 'max-width': maxWidth }">
<div ref="previewScaled" class="page-preview__scaled">
<div ref="elementContainer">
<ElementPreview
v-for="(element, index) in elements"
:key="element.id"
:element="element"
:active="element.id === elementSelectedId"
:is-first-element="index === 0"
:is-last-element="index === elements.length - 1"
:is-copying="copyingElementIndex === index"
@selected="selectElement(element)"
@delete="deleteElement(element)"
@move="moveElement(element, index, $event)"
@insert="showAddElementModal(element, index, $event)"
@copy="duplicateElement(element, index)"
/>
</div>
</div>
</div>
<AddElementModal
ref="addElementModal"
:adding-element-type="addingElementType"
:page="page"
@add="addElement"
/>
</div>
</template>
<script>
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 { PLACEMENTS } from '@baserow/modules/builder/enums'
export default {
name: 'PagePreview',
components: { AddElementModal, ElementPreview },
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,
}
},
computed: {
...mapGetters({
page: 'page/getSelected',
deviceTypeSelected: 'page/getDeviceTypeSelected',
elementSelected: 'element/getSelected',
}),
elements() {
return this.$store.getters['element/getElements']
},
elementSelectedId() {
return this.elementSelected?.id
},
deviceType() {
return this.deviceTypeSelected
? this.$registry.get('device', this.deviceTypeSelected)
: null
},
maxWidth() {
return this.deviceType?.maxWidth
? `${this.deviceType.maxWidth}px`
: 'unset'
},
},
watch: {
deviceType(value) {
this.$nextTick(() => {
this.updatePreviewScale(value)
})
},
},
mounted() {
window.addEventListener('resize', this.onWindowResized)
},
destroyed() {
window.removeEventListener('resize', this.onWindowResized)
},
methods: {
...mapActions({
actionCreateElement: 'element/create',
actionCopyElement: 'element/copy',
actionMoveElement: 'element/move',
actionDeleteElement: 'element/delete',
actionSelectElement: 'element/select',
}),
onWindowResized() {
this.$nextTick(() => {
this.updatePreviewScale(this.deviceType)
})
},
updatePreviewScale(deviceType) {
// The widths are the minimum width the preview must have. If the preview dom
// element becomes smaller than the target, it will be scaled down so that the
// actual width remains the same, and it will preview the correct device.
const preview = this.$refs.preview
const previewScaled = this.$refs.previewScaled
const currentWidth = preview.clientWidth
const currentHeight = preview.clientHeight
const targetWidth = deviceType.minWidth
let scale = 1
let horizontal = 0
let vertical = 0
if (currentWidth < targetWidth) {
scale = Math.round((currentWidth / targetWidth) * 100) / 100
horizontal = (currentWidth - currentWidth * scale) / 2 / scale
vertical = (currentHeight - currentHeight * scale) / 2 / scale
}
previewScaled.style.transform = `scale(${scale})`
previewScaled.style.transformOrigin = `0 0`
previewScaled.style.width = `${horizontal * 2 + currentWidth}px`
previewScaled.style.height = `${vertical * 2 + currentHeight}px`
},
async deleteElement(element) {
try {
await this.actionDeleteElement({
elementId: element.id,
})
} catch (error) {
notifyIf(error)
}
},
moveElement(element, index, placement) {
let elementToMoveId = null
let beforeElementId = null
if (placement === PLACEMENTS.BEFORE && index !== 0) {
elementToMoveId = element.id
beforeElementId = this.elements[index - 1].id
} else if (
placement === PLACEMENTS.AFTER &&
index !== this.elements.length - 1
) {
elementToMoveId = this.elements[index + 1].id
beforeElementId = element.id
}
// If either is null then we are on the top or bottom end of the elements
// and therefore the element can't be moved anymore
if (elementToMoveId === null || beforeElementId === null) {
return
}
try {
this.actionMoveElement({
pageId: this.page.id,
elementId: elementToMoveId,
beforeElementId,
})
} catch (error) {
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)
}
this.addingElementType = null
},
async duplicateElement(element, index) {
this.copyingElementIndex = index
try {
await this.actionCopyElement({
pageId: this.page.id,
elementId: element.id,
})
this.$refs.addElementModal.hide()
} catch (error) {
notifyIf(error)
}
this.copyingElementIndex = null
},
selectElement(element) {
this.actionSelectElement({ element })
},
},
}
</script>

View file

@ -0,0 +1,85 @@
import { Registerable } from '@baserow/modules/core/registry'
export class DeviceType extends Registerable {
get iconClass() {
return null
}
getOrder() {
return null
}
get minWidth() {
return 0
}
get maxWidth() {
return 0
}
}
export class DesktopDeviceType extends DeviceType {
getType() {
return 'desktop'
}
get iconClass() {
return 'desktop'
}
getOrder() {
return 1
}
get minWidth() {
return 1100
}
get maxWidth() {
return null // Can be as wide as you want
}
}
export class TabletDeviceType extends DeviceType {
getType() {
return 'tablet'
}
get iconClass() {
return 'tablet'
}
getOrder() {
return 2
}
get minWidth() {
return 768
}
get maxWidth() {
return 768
}
}
export class SmartphoneDeviceType extends DeviceType {
getType() {
return 'smartphone'
}
get iconClass() {
return 'mobile'
}
getOrder() {
return 3
}
get minWidth() {
return 420
}
get maxWidth() {
return 420
}
}

View file

@ -0,0 +1,100 @@
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'
export class ElementType extends Registerable {
get name() {
return null
}
get description() {
return null
}
get iconClass() {
return null
}
get component() {
return null
}
get properties() {
return []
}
/**
* Extracts the attributes of the element instance into attributes that the component
* can use. The returned object needs to be a mapping from the name of the property
* at the component level to the value in the element object.
*
* Example:
* - Let's say you have a prop called `level`
* - The element looks like this: { 'id': 'someId', 'level': 1 }
*
* Then you will have to return { 'level': element.level }
*
* @param element
* @returns {{}}
*/
getComponentProps(element) {
return {}
}
}
export class HeadingElementType extends ElementType {
getType() {
return 'heading'
}
get name() {
return this.app.i18n.t('elementType.heading')
}
get description() {
return this.app.i18n.t('elementType.headingDescription')
}
get iconClass() {
return 'heading'
}
get component() {
return HeadingElement
}
getComponentProps(element) {
return {
value: 'some test value for now',
level: element.level,
}
}
}
export class ParagraphElementType extends ElementType {
getType() {
return 'paragraph'
}
get name() {
return this.app.i18n.t('elementType.paragraph')
}
get description() {
return this.app.i18n.t('elementType.paragraphDescription')
}
get iconClass() {
return 'paragraph'
}
get component() {
return ParagraphElement
}
getComponentProps(element) {
return {
value: 'some test value for now',
}
}
}

View file

@ -0,0 +1,11 @@
export const DIRECTIONS = {
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
}
export const PLACEMENTS = {
BEFORE: 'before',
AFTER: 'after',
}

View file

@ -22,6 +22,29 @@
"errorNameNotUnique": "A page with this name already exists",
"defaultName": "Page"
},
"pageHeaderElements": {
"label": "Elements"
},
"elementsContext": {
"searchPlaceholder": "Search elements"
},
"elementType": {
"heading": "Heading",
"headingDescription": "Page heading title",
"paragraph": "Paragraph",
"paragraphDescription": "Single line text"
},
"addElementButton": {
"label": "Element"
},
"addElementModal": {
"title": "Add new element",
"searchPlaceholder": "Search elements"
},
"elementMenu": {
"moveUp": "Move up",
"moveDown": "Move down"
},
"duplicatePageJobType": {
"duplicating": "Duplicating",
"duplicatedTitle": "Page duplicated"

View file

@ -0,0 +1,9 @@
export default {
props: {
value: {
type: String,
required: false,
default: 'Some temp default value',
},
},
}

View file

@ -1,14 +1,32 @@
<template>
<div>PAGE</div>
<div>
<PageHeader />
<div class="layout__col-2-2 content" @click.self="unselectElement">
<div class="page__wrapper">
<PagePreview />
</div>
</div>
</div>
</template>
<script>
import { StoreItemLookupError } from '@baserow/modules/core/errors'
import PageHeader from '@baserow/modules/builder/components/page/PageHeader'
import PagePreview from '@baserow/modules/builder/components/page/PagePreview'
export default {
name: 'Page',
layout: 'app',
components: { PagePreview, PageHeader },
/**
* When the user leaves to another page we want to unselect the selected page. This
* way it will not be highlighted the left sidebar.
*/
beforeRouteLeave(to, from, next) {
this.$store.dispatch('page/unselect')
next()
},
layout: 'app',
async asyncData({ store, params, error }) {
const builderId = parseInt(params.builderId)
const pageId = parseInt(params.pageId)
@ -23,6 +41,8 @@ export default {
await store.dispatch('group/selectById', builder.group.id)
data.builder = builder
data.page = page
await store.dispatch('element/fetch', { page })
} catch (e) {
// In case of a network error we want to fail hard.
if (e.response === undefined && !(e instanceof StoreItemLookupError)) {
@ -34,5 +54,10 @@ export default {
return data
},
methods: {
unselectElement() {
this.$store.dispatch('element/select', { element: null })
},
},
}
</script>

View file

@ -11,7 +11,17 @@ import {
} from '@baserow/modules/builder/builderSettingTypes'
import pageStore from '@baserow/modules/builder/store/page'
import elementStore from '@baserow/modules/builder/store/element'
import { registerRealtimeEvents } from '@baserow/modules/builder/realtime'
import {
HeadingElementType,
ParagraphElementType,
} from '@baserow/modules/builder/elementTypes'
import {
DesktopDeviceType,
SmartphoneDeviceType,
TabletDeviceType,
} from '@baserow/modules/builder/deviceTypes'
import { DuplicatePageJobType } from '@baserow/modules/builder/jobTypes'
import { BuilderApplicationType } from '@baserow/modules/builder/applicationTypes'
import { PublicSiteErrorPageType } from '@baserow/modules/builder/errorPageTypes'
@ -34,8 +44,11 @@ export default (context) => {
registerRealtimeEvents(app.$realtime)
store.registerModule('page', pageStore)
store.registerModule('element', elementStore)
app.$registry.registerNamespace('builderSettings')
app.$registry.registerNamespace('element')
app.$registry.registerNamespace('device')
app.$registry.register('application', new BuilderApplicationType(context))
app.$registry.register('job', new DuplicatePageJobType(context))
@ -50,4 +63,11 @@ export default (context) => {
)
app.$registry.register('errorPage', new PublicSiteErrorPageType(context))
app.$registry.register('element', new HeadingElementType(context))
app.$registry.register('element', new ParagraphElementType(context))
app.$registry.register('device', new DesktopDeviceType(context))
app.$registry.register('device', new TabletDeviceType(context))
app.$registry.register('device', new SmartphoneDeviceType(context))
}

View file

@ -0,0 +1,26 @@
export default (client) => {
return {
fetchAll(pageId) {
return client.get(`builder/page/${pageId}/elements/`)
},
create(pageId, elementType, beforeId = null) {
const payload = {
type: elementType,
}
if (beforeId !== null) {
payload.before_id = beforeId
}
return client.post(`builder/page/${pageId}/elements/`, payload)
},
delete(elementId) {
return client.delete(`builder/element/${elementId}/`)
},
order(pageId, newOrder) {
return client.post(`builder/page/${pageId}/elements/order/`, {
element_ids: newOrder,
})
},
}
}

View file

@ -0,0 +1,149 @@
import ElementService from '@baserow/modules/builder/services/element'
const state = {
// The elements of the currently selected page
elements: [],
// The currently selected element
selected: null,
}
const mutations = {
ADD_ITEM(state, { element, beforeId = null }) {
if (beforeId === null) {
state.elements.push(element)
} else {
const insertionIndex = state.elements.findIndex((e) => e.id === beforeId)
state.elements.splice(insertionIndex, 0, element)
}
},
DELETE_ITEM(state, { elementId }) {
const index = state.elements.findIndex(
(element) => element.id === elementId
)
state.elements.splice(index, 1)
},
MOVE_ITEM(state, { index, oldIndex }) {
state.elements.splice(index, 0, state.elements.splice(oldIndex, 1)[0])
},
SELECT_ITEM(state, { element }) {
state.selected = element
},
CLEAR_ITEMS(state) {
state.elements = []
},
}
const actions = {
forceCreate({ commit }, { element, beforeId = null }) {
commit('ADD_ITEM', { element, beforeId })
},
forceDelete({ commit }, { elementId }) {
commit('DELETE_ITEM', { elementId })
},
forceMove({ commit }, { index, oldIndex }) {
commit('MOVE_ITEM', { index, oldIndex })
},
select({ commit }, { element }) {
commit('SELECT_ITEM', { element })
},
async create({ dispatch }, { pageId, elementType, beforeId = null }) {
const { data: element } = await ElementService(this.$client).create(
pageId,
elementType,
beforeId
)
await dispatch('forceCreate', { element, beforeId })
},
async delete({ dispatch, getters }, { elementId }) {
const elementsOfPage = getters.getElements
const elementIndex = elementsOfPage.findIndex(
(element) => element.id === elementId
)
const elementToDelete = elementsOfPage[elementIndex]
const beforeId =
elementIndex !== elementsOfPage.length - 1
? elementsOfPage[elementIndex + 1].id
: null
await dispatch('forceDelete', { elementId })
try {
await ElementService(this.$client).delete(elementId)
} catch (error) {
await dispatch('forceCreate', {
element: elementToDelete,
beforeId,
})
throw error
}
},
async fetch({ dispatch, commit }, { page }) {
commit('CLEAR_ITEMS')
const { data: elements } = await ElementService(this.$client).fetchAll(
page.id
)
await Promise.all(
elements.map((element) => dispatch('forceCreate', { element }))
)
return elements
},
async move({ getters, dispatch }, { elementId, pageId, beforeElementId }) {
const originalOrder = getters.getElements.map((element) => element.id)
const newOrder = [...originalOrder]
const elementIndex = newOrder.findIndex((id) => id === elementId)
const indexToSwapWith = newOrder.findIndex((id) => id === beforeElementId)
// The element could be the last or the first one which we need to handle
if (indexToSwapWith === -1 || indexToSwapWith === newOrder.length) {
return
}
newOrder[elementIndex] = newOrder[indexToSwapWith]
newOrder[indexToSwapWith] = elementId
await dispatch('forceMove', {
index: indexToSwapWith,
oldIndex: elementIndex,
})
try {
await ElementService(this.$client).order(pageId, newOrder)
} catch (error) {
await dispatch('forceMove', {
index: elementIndex,
oldIndex: indexToSwapWith,
})
throw error
}
},
async copy({ getters, dispatch }, { elementId, pageId }) {
const element = getters.getElements.find((e) => e.id === elementId)
await dispatch('create', {
pageId,
elementType: element.type,
beforeId: element.id,
})
},
}
const getters = {
getElements: (state) => {
return state.elements
},
getSelected(state) {
return state.selected
},
}
export default {
namespaced: true,
state,
getters,
actions,
mutations,
}

View file

@ -13,7 +13,7 @@ export function populatePage(page) {
const state = {
// Holds the value of which page is currently selected
selected: {},
deviceTypeSelected: null,
// A job object that tracks the progress of a page duplication currently running
duplicateJob: null,
}
@ -37,6 +37,12 @@ const mutations = {
page._.selected = true
state.selected = page
},
UNSELECT(state) {
if (state.selected) {
state.selected._.selected = false
}
state.selected = {}
},
SET_DUPLICATE_JOB(state, job) {
state.duplicateJob = job
},
@ -47,6 +53,9 @@ const mutations = {
page.order = index === -1 ? 0 : index + 1
})
},
SET_DEVICE_TYPE_SELECTED(state, deviceType) {
state.deviceTypeSelected = deviceType
},
}
const actions = {
@ -82,6 +91,9 @@ const actions = {
return { builder, page }
},
unselect({ commit }) {
commit('UNSELECT')
},
forceDelete({ commit }, { builder, page }) {
if (page._.selected) {
// Redirect back to the dashboard because the page doesn't exist anymore.
@ -130,6 +142,9 @@ const actions = {
throw error
}
},
setDeviceTypeSelected({ commit }, deviceType) {
commit('SET_DEVICE_TYPE_SELECTED', deviceType)
},
async duplicate({ commit, dispatch }, { page }) {
const { data: job } = await PageService(this.$client).duplicate(page.id)
@ -140,6 +155,12 @@ const actions = {
}
const getters = {
getSelected(state) {
return state.selected
},
getDeviceTypeSelected(state) {
return state.deviceTypeSelected
},
getDuplicateJob(state) {
return state.duplicateJob
},

View file

@ -0,0 +1,23 @@
.add-element-card {
border: solid $color-neutral-300 1px;
border-radius: 5px;
padding: 10px 15px 10px 15px;
width: 133px;
height: 60px;
cursor: pointer;
&:hover {
background-color: $color-neutral-100;
text-decoration: none;
}
&--disabled {
cursor: inherit;
pointer-events: none;
}
}
.add-element-card__description {
font-size: 11px;
color: $color-neutral-400;
}

View file

@ -0,0 +1,5 @@
.add-element-modal__element-cards {
display: flex;
flex-flow: wrap;
gap: 15px;
}

View file

@ -1 +1,6 @@
@import 'page_builder';
@import 'elements_context';
@import 'add_element_card';
@import 'add_element_modal';
@import 'page_preview';
@import 'element.scss';

View file

@ -0,0 +1,138 @@
.element__menu {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
width: 26px;
border: solid 1px $color-neutral-400;
border-radius: 3px;
z-index: 2;
@include absolute(6px, 6px, auto, auto);
}
.element__insert {
@include center-text(26px, 10px);
display: block;
border-radius: 100%;
border: solid 1px $color-neutral-300;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.16);
color: $color-primary-900;
background-color: $white;
&:hover {
background-color: $color-neutral-50;
box-shadow: 0 3px 5px 0 rgba(0, 0, 0, 0.32);
}
&--top,
&--bottom {
@include absolute(-13px, auto, auto, 50%);
margin-left: -12px;
z-index: 2;
}
&--bottom {
top: auto;
bottom: -12px;
}
}
.element {
position: relative;
.element__insert {
display: none;
}
.element__menu {
display: none;
}
&:hover {
.element__insert {
display: block;
}
.element__menu {
display: flex;
}
}
&:not(.element--active) {
cursor: pointer;
}
&.element--active {
cursor: inherit;
&::before {
@include absolute(0, 0, 0, 0);
content: "";
border: solid 1px $color-primary-500;
pointer-events: none;
}
.element__insert {
display: block;
}
}
}
.element__menu-item-description {
@include absolute(2px, 30px, auto, auto);
display: none;
background-color: $color-neutral-900;
font-size: 11px;
color: $white;
line-height: 20px;
padding: 0 4px;
border-radius: 3px;
white-space: nowrap;
}
.element__menu-item {
@include center-text(24px, 9px);
position: relative;
background-color: $white;
color: $color-primary-900;
&:hover {
background-color: $color-neutral-100;
.element__menu-item-description {
display: block;
}
}
&:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
&:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
&.disabled {
cursor: inherit;
color: $color-neutral-300;
&:hover {
background-color: $white;
}
}
}
.element__component {
margin: 0;
}
.element__menu-copy-loading {
margin: 5px;
}

View file

@ -0,0 +1,3 @@
.elements-context {
width: 300px;
}

View file

@ -0,0 +1,3 @@
.page__wrapper {
@include absolute(0, 0, 0, 0);
}

View file

@ -0,0 +1,46 @@
.page-preview__wrapper {
@include absolute(30px, 400px, 0, 40px);
display: flex;
justify-content: center;
}
.page-preview {
position: relative;
z-index: 1;
background-color: $white;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
width: 100%;
height: 100%;
overflow: hidden;
}
.page-preview__add {
@include center-text(26px, 10px);
display: block;
border-radius: 100%;
border: solid 1px $color-neutral-300;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.16);
color: $color-primary-900;
background-color: $white;
&:hover {
background-color: $color-neutral-50;
box-shadow: 0 3px 5px 0 rgba(0, 0, 0, 0.32);
}
}
.page-preview__scaled {
width: 100%;
height: 100%;
overflow: auto;
// We need to do this because the border of the preview is round on top and the first
// element has a blue border when selected. That border has to have the same shape.
> div:first-child::before {
border-top-right-radius: 15px;
border-top-left-radius: 15px;
}
}

View file

@ -230,6 +230,13 @@
line-height: 26px;
font-size: 13px;
&:only-child {
width: 100%;
flex: unset;
margin-right: unset !important;
padding-left: 6px;
}
&:nth-child(2n+1) {
margin-right: 20px;
}

View file

@ -0,0 +1,63 @@
<template>
<div class="input__with-icon" :class="iconPositionClass">
<input
type="text"
class="input"
:placeholder="placeholder"
@input="$emit('input', $event.target.value)"
/>
<i class="fas" :class="[iconSizeClass, iconClass]"></i>
</div>
</template>
<script>
const ICON_POSITION_CSS_MAP = {
left: 'input__with-icon--left',
right: null,
}
export default {
name: 'InputWithIcon',
inheritAttrs: false,
props: {
value: {
type: String,
required: false,
default: '',
},
icon: {
type: String,
required: true,
},
iconSize: {
type: String,
required: false,
default: null,
},
iconPosition: {
type: String,
required: false,
default: 'right',
validator: (value) => {
return Object.keys(ICON_POSITION_CSS_MAP).includes(value)
},
},
placeholder: {
type: String,
required: false,
default: null,
},
},
computed: {
iconClass() {
return `fa-${this.icon}`
},
iconSizeClass() {
return this.iconSize ? `fa-${this.iconSize}` : null
},
iconPositionClass() {
return ICON_POSITION_CSS_MAP[this.iconPosition]
},
},
}
</script>

View file

@ -35,6 +35,7 @@ import userFileUpload from '@baserow/modules/core/directives/userFileUpload'
import autoScroll from '@baserow/modules/core/directives/autoScroll'
import clickOutside from '@baserow/modules/core/directives/clickOutside'
import Badge from '@baserow/modules/core/components/Badge'
import InputWithIcon from '@baserow/modules/core/components/InputWithIcon'
Vue.component('Context', Context)
Vue.component('Modal', Modal)
@ -58,6 +59,7 @@ Vue.component('Tabs', Tabs)
Vue.component('List', List)
Vue.component('HelpIcon', HelpIcon)
Vue.component('Badge', Badge)
Vue.component('InputWithIcon', InputWithIcon)
Vue.filter('lowercase', lowercase)
Vue.filter('uppercase', uppercase)

View file

@ -120,3 +120,18 @@ export const getNextAvailableNameInSequence = (baseName, excludeNames) => {
}
return name
}
/**
* Checks if a search term is a substring of at least one string within a given list of
* strings.
*
* @param strings
* @param searchTerm
* @returns boolean
*/
export const isSubstringOfStrings = (strings, searchTerm) => {
const stringsSanitised = strings.map((s) => s.toLowerCase().trim())
const searchTermSanitised = searchTerm.toLowerCase().trim()
return stringsSanitised.some((s) => s.includes(searchTermSanitised))
}

View file

@ -0,0 +1,34 @@
import * as elementTypes from '@baserow/modules/builder/elementTypes'
const getPropsOfComponent = (component) => {
let props = Object.keys(component.props || [])
if (component.mixins) {
component.mixins.forEach((mixin) => {
props = props.concat(Object.keys(mixin.props || []))
})
}
return props
}
describe('elementTypes', () => {
test.each(Object.values(elementTypes))(
'test that properties mapped for the element type exist on the component as prop',
(ElementType) => {
const elementType = new ElementType(expect.anything())
if (elementType.component) {
const propsInMapping = Object.keys(
elementType.getComponentProps(expect.anything())
)
const propsOnComponent = getPropsOfComponent(elementType.component)
propsInMapping.forEach((prop) => {
expect(propsOnComponent).toContain(prop)
})
}
}
)
})

View file

@ -6,6 +6,7 @@ import {
isValidEmail,
isSecureURL,
isNumeric,
isSubstringOfStrings,
} from '@baserow/modules/core/utils/string'
describe('test string utils', () => {
@ -138,4 +139,12 @@ describe('test string utils', () => {
expect(isNumeric('9999')).toBe(true)
expect(isNumeric('-100')).toBe(true)
})
test('test isSubstringOfStrings', () => {
expect(isSubstringOfStrings(['hello'], 'hell')).toBe(true)
expect(isSubstringOfStrings(['test'], 'hell')).toBe(false)
expect(isSubstringOfStrings(['hello', 'test'], 'hell')).toBe(true)
expect(isSubstringOfStrings([], 'hell')).toBe(false)
expect(isSubstringOfStrings(['hello'], '')).toBe(true)
})
})