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:
parent
3e43b4fb4f
commit
1eb25a29a8
95 changed files with 4876 additions and 353 deletions
backend
src/baserow
config/settings
contrib
builder
__init__.py
api
application_types.pyapps.pyelements
__init__.pyelement_types.pyexceptions.pyhandler.pymodels.pyobject_scopes.pyoperations.pyregistries.pyservice.pysignals.pytypes.py
migrations
pages
types.pyws
database
core
test_utils
tests/baserow
enterprise/backend
src/baserow_enterprise/role
tests/baserow_enterprise_tests
premium/backend/tests/baserow_premium_tests
web-frontend
modules
builder
components
elements
AddElementButton.vueAddElementCard.vueAddElementModal.vueElementMenu.vueElementPreview.vueElementsContext.vueElementsList.vueElementsListItem.vueInsertElementButton.vuePageHeaderElements.vue
components
page
locales
mixins/elements
pages
plugin.jsservices
store
core
assets/scss/components
builder
add_element_card.scssadd_element_modal.scssall.scsselement.scsselements_context.scsspage.scsspage_preview.scss
select.scsscomponents
plugins
utils
test/unit
|
@ -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": {
|
||||
|
|
0
backend/src/baserow/contrib/builder/__init__.py
Normal file
0
backend/src/baserow/contrib/builder/__init__.py
Normal file
0
backend/src/baserow/contrib/builder/api/__init__.py
Normal file
0
backend/src/baserow/contrib/builder/api/__init__.py
Normal file
13
backend/src/baserow/contrib/builder/api/elements/errors.py
Normal file
13
backend/src/baserow/contrib/builder/api/elements/errors.py
Normal 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.",
|
||||
)
|
|
@ -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",
|
||||
)
|
23
backend/src/baserow/contrib/builder/api/elements/urls.py
Normal file
23
backend/src/baserow/contrib/builder/api/elements/urls.py
Normal 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"),
|
||||
]
|
306
backend/src/baserow/contrib/builder/api/elements/views.py
Normal file
306
backend/src/baserow/contrib/builder/api/elements/views.py
Normal 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)
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
0
backend/src/baserow/contrib/builder/elements/__init__.py
Normal file
0
backend/src/baserow/contrib/builder/elements/__init__.py
Normal 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."
|
||||
}
|
14
backend/src/baserow/contrib/builder/elements/exceptions.py
Normal file
14
backend/src/baserow/contrib/builder/elements/exceptions.py
Normal 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,
|
||||
)
|
146
backend/src/baserow/contrib/builder/elements/handler.py
Normal file
146
backend/src/baserow/contrib/builder/elements/handler.py
Normal 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))
|
115
backend/src/baserow/contrib/builder/elements/models.py
Normal file
115
backend/src/baserow/contrib/builder/elements/models.py
Normal 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.
|
||||
"""
|
|
@ -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.")
|
34
backend/src/baserow/contrib/builder/elements/operations.py
Normal file
34
backend/src/baserow/contrib/builder/elements/operations.py
Normal 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"
|
107
backend/src/baserow/contrib/builder/elements/registries.py
Normal file
107
backend/src/baserow/contrib/builder/elements/registries.py
Normal 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()
|
232
backend/src/baserow/contrib/builder/elements/service.py
Normal file
232
backend/src/baserow/contrib/builder/elements/service.py
Normal 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)
|
7
backend/src/baserow/contrib/builder/elements/signals.py
Normal file
7
backend/src/baserow/contrib/builder/elements/signals.py
Normal 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()
|
10
backend/src/baserow/contrib/builder/elements/types.py
Normal file
10
backend/src/baserow/contrib/builder/elements/types.py
Normal 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)
|
|
@ -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",),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
22
backend/src/baserow/contrib/builder/types.py
Normal file
22
backend/src/baserow/contrib/builder/types.py
Normal 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]
|
92
backend/src/baserow/contrib/builder/ws/element/signals.py
Normal file
92
backend/src/baserow/contrib/builder/ws/element/signals.py
Normal 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),
|
||||
)
|
||||
)
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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 "
|
||||
|
|
|
@ -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.",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)))
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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
|
||||
|
|
29
backend/src/baserow/test_utils/fixtures/element.py
Normal file
29
backend/src/baserow/test_utils/fixtures/element.py
Normal 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
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
@ -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"
|
|
@ -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")
|
|
@ -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])
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
42
web-frontend/modules/builder/components/page/PageHeader.vue
Normal file
42
web-frontend/modules/builder/components/page/PageHeader.vue
Normal 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>
|
206
web-frontend/modules/builder/components/page/PagePreview.vue
Normal file
206
web-frontend/modules/builder/components/page/PagePreview.vue
Normal 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>
|
85
web-frontend/modules/builder/deviceTypes.js
Normal file
85
web-frontend/modules/builder/deviceTypes.js
Normal 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
|
||||
}
|
||||
}
|
100
web-frontend/modules/builder/elementTypes.js
Normal file
100
web-frontend/modules/builder/elementTypes.js
Normal 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',
|
||||
}
|
||||
}
|
||||
}
|
11
web-frontend/modules/builder/enums.js
Normal file
11
web-frontend/modules/builder/enums.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
export const DIRECTIONS = {
|
||||
UP: 'up',
|
||||
DOWN: 'down',
|
||||
LEFT: 'left',
|
||||
RIGHT: 'right',
|
||||
}
|
||||
|
||||
export const PLACEMENTS = {
|
||||
BEFORE: 'before',
|
||||
AFTER: 'after',
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'Some temp default value',
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
26
web-frontend/modules/builder/services/element.js
Normal file
26
web-frontend/modules/builder/services/element.js
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
149
web-frontend/modules/builder/store/element.js
Normal file
149
web-frontend/modules/builder/store/element.js
Normal 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,
|
||||
}
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.add-element-modal__element-cards {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
gap: 15px;
|
||||
}
|
|
@ -1 +1,6 @@
|
|||
@import 'page_builder';
|
||||
@import 'elements_context';
|
||||
@import 'add_element_card';
|
||||
@import 'add_element_modal';
|
||||
@import 'page_preview';
|
||||
@import 'element.scss';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.elements-context {
|
||||
width: 300px;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.page__wrapper {
|
||||
@include absolute(0, 0, 0, 0);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
63
web-frontend/modules/core/components/InputWithIcon.vue
Normal file
63
web-frontend/modules/core/components/InputWithIcon.vue
Normal 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>
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
34
web-frontend/test/unit/builder/elementTypes.spec.js
Normal file
34
web-frontend/test/unit/builder/elementTypes.spec.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Add table
Reference in a new issue