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

Resolve "Create a multi-page container element"

This commit is contained in:
Jérémie Pardou 2024-11-28 08:47:54 +00:00
parent 05a33a182a
commit b10a65666a
124 changed files with 3474 additions and 1729 deletions
backend
changelog/entries/unreleased/feature
e2e-tests/tests/builder
enterprise
backend/tests/baserow_enterprise_tests/integrations/local_baserow
web-frontend/modules/baserow_enterprise/builder/components/elements
web-frontend/modules/builder

View file

@ -254,7 +254,9 @@ class DataSourceView(APIView):
if "page_id" in request.data: if "page_id" in request.data:
page = PageHandler().get_page( page = PageHandler().get_page(
int(request.data["page_id"]), int(request.data["page_id"]),
base_queryset=Page.objects.filter(builder=data_source.page.builder), base_queryset=Page.objects_with_shared.filter(
builder=data_source.page.builder
),
) )
# Do we have a service? # Do we have a service?

View file

@ -24,6 +24,7 @@ from baserow.contrib.builder.domains.registries import domain_type_registry
from baserow.contrib.builder.elements.models import Element from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.registries import element_type_registry from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.models import Builder 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.models import Page
from baserow.core.services.registries import service_type_registry from baserow.core.services.registries import service_type_registry
from baserow.core.user_sources.models import UserSource from baserow.core.user_sources.models import UserSource
@ -112,6 +113,7 @@ class PublicElementSerializer(serializers.ModelSerializer):
"page_id", "page_id",
"type", "type",
"order", "order",
"page_id",
"parent_element_id", "parent_element_id",
"place_in_container", "place_in_container",
"visibility", "visibility",
@ -272,7 +274,7 @@ class PublicBuilderSerializer(serializers.ModelSerializer):
:return: A list of serialized pages that belong to this instance. :return: A list of serialized pages that belong to this instance.
""" """
pages = instance.page_set.all() pages = PageHandler().get_pages(instance)
return PublicPageSerializer(pages, many=True).data return PublicPageSerializer(pages, many=True).data

View file

@ -133,6 +133,7 @@ class ElementsView(APIView):
@map_exceptions( @map_exceptions(
{ {
PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST, PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST,
ElementNotInSamePage: ERROR_ELEMENT_NOT_IN_SAME_PAGE,
} }
) )
@validate_body_custom_fields( @validate_body_custom_fields(

View file

@ -48,7 +48,7 @@ class BuilderSerializer(serializers.ModelSerializer):
:return: A list of serialized pages that belong to this instance. :return: A list of serialized pages that belong to this instance.
""" """
pages = instance.page_set.all() pages = PageHandler().get_pages(instance)
user = self.context.get("user") user = self.context.get("user")
request = self.context.get("request") request = self.context.get("request")

View file

@ -167,7 +167,12 @@ class BuilderApplicationType(ApplicationType):
for us in UserSourceHandler().get_user_sources(builder) for us in UserSourceHandler().get_user_sources(builder)
] ]
pages = builder.page_set.all().prefetch_related("element_set", "datasource_set") pages = PageHandler().get_pages(
builder,
base_queryset=Page.objects_with_shared.prefetch_related(
"element_set", "datasource_set"
),
)
serialized_pages = [ serialized_pages = [
PageHandler().export_page( PageHandler().export_page(

View file

@ -175,7 +175,9 @@ class BuilderConfig(AppConfig):
ChoiceElementType, ChoiceElementType,
ColumnElementType, ColumnElementType,
DateTimePickerElementType, DateTimePickerElementType,
FooterElementType,
FormContainerElementType, FormContainerElementType,
HeaderElementType,
HeadingElementType, HeadingElementType,
IFrameElementType, IFrameElementType,
ImageElementType, ImageElementType,
@ -203,6 +205,8 @@ class BuilderConfig(AppConfig):
element_type_registry.register(CheckboxElementType()) element_type_registry.register(CheckboxElementType())
element_type_registry.register(IFrameElementType()) element_type_registry.register(IFrameElementType())
element_type_registry.register(DateTimePickerElementType()) element_type_registry.register(DateTimePickerElementType())
element_type_registry.register(HeaderElementType())
element_type_registry.register(FooterElementType())
from .domains.domain_types import CustomDomainType, SubDomainType from .domains.domain_types import CustomDomainType, SubDomainType
from .domains.registries import domain_type_registry from .domains.registries import domain_type_registry

View file

@ -33,6 +33,7 @@ from baserow.contrib.builder.elements.mixins import (
CollectionElementWithFieldsTypeMixin, CollectionElementWithFieldsTypeMixin,
ContainerElementTypeMixin, ContainerElementTypeMixin,
FormElementTypeMixin, FormElementTypeMixin,
MultiPageElementTypeMixin,
) )
from baserow.contrib.builder.elements.models import ( from baserow.contrib.builder.elements.models import (
INPUT_TEXT_TYPES, INPUT_TEXT_TYPES,
@ -43,7 +44,9 @@ from baserow.contrib.builder.elements.models import (
ColumnElement, ColumnElement,
DateTimePickerElement, DateTimePickerElement,
Element, Element,
FooterElement,
FormContainerElement, FormContainerElement,
HeaderElement,
HeadingElement, HeadingElement,
IFrameElement, IFrameElement,
ImageElement, ImageElement,
@ -117,7 +120,7 @@ class ColumnElementType(ContainerElementTypeMixin, ElementType):
type = "column" type = "column"
model_class = ColumnElement model_class = ColumnElement
class SerializedDict(ElementDict): class SerializedDict(ContainerElementTypeMixin.SerializedDict):
column_amount: int column_amount: int
column_gap: int column_gap: int
alignment: str alignment: str
@ -191,8 +194,8 @@ class ColumnElementType(ContainerElementTypeMixin, ElementType):
""" """
return [ return [
element_type.type element_type
for element_type in element_type_registry.get_all() for element_type in super().child_types_allowed
if element_type.type != self.type if element_type.type != self.type
] ]
@ -210,7 +213,7 @@ class FormContainerElementType(ContainerElementTypeMixin, ElementType):
] ]
simple_formula_fields = ["submit_button_label"] simple_formula_fields = ["submit_button_label"]
class SerializedDict(ElementDict): class SerializedDict(ContainerElementTypeMixin.SerializedDict):
submit_button_label: BaserowFormula submit_button_label: BaserowFormula
reset_initial_values_post_submission: bool reset_initial_values_post_submission: bool
@ -261,8 +264,8 @@ class FormContainerElementType(ContainerElementTypeMixin, ElementType):
""" """
return [ return [
element_type.type element_type
for element_type in element_type_registry.get_all() for element_type in super().child_types_allowed
if element_type.type != self.type if element_type.type != self.type
] ]
@ -858,6 +861,16 @@ class NavigationElementManager:
"target": "blank", "target": "blank",
} }
def validate_place(
self,
page: Page,
parent_element: Optional[Element],
place_in_container: str,
):
"""
We need it because it's called in the prepare_value_for_db.
"""
def prepare_value_for_db( def prepare_value_for_db(
self, values: Dict, instance: Optional[LinkElement] = None self, values: Dict, instance: Optional[LinkElement] = None
): ):
@ -1939,3 +1952,35 @@ class DateTimePickerElementType(FormElementTypeMixin, ElementType):
"include_time": False, "include_time": False,
"time_format": DATE_TIME_FORMAT_CHOICES[0][0], "time_format": DATE_TIME_FORMAT_CHOICES[0][0],
} }
class MultiPageContainerElementType(
ContainerElementTypeMixin, MultiPageElementTypeMixin, ElementType
):
"""
A base class container element that can be displayed on multiple pages.
"""
class SerializedDict(
MultiPageElementTypeMixin.SerializedDict,
ContainerElementTypeMixin.SerializedDict,
):
...
class HeaderElementType(MultiPageContainerElementType):
"""
A container element that can be displayed on multiple pages.
"""
type = "header"
model_class = HeaderElement
class FooterElementType(MultiPageContainerElementType):
"""
A container element that can be displayed on multiple pages.
"""
type = "footer"
model_class = FooterElement

View file

@ -40,6 +40,7 @@ from baserow.contrib.builder.elements.types import (
ElementSubClass, ElementSubClass,
) )
from baserow.contrib.builder.formula_importer import import_formula from baserow.contrib.builder.formula_importer import import_formula
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.types import ElementDict from baserow.contrib.builder.types import ElementDict
from baserow.contrib.database.fields.utils import get_field_id_from_field_key from baserow.contrib.database.fields.utils import get_field_id_from_field_key
from baserow.core.formula.types import BaserowFormula from baserow.core.formula.types import BaserowFormula
@ -59,10 +60,14 @@ class ContainerElementTypeMixin:
""" """
Lets you define which children types can be placed inside the container. Lets you define which children types can be placed inside the container.
:return: All the allowed children types By default, multi-page elements are not allowed inside any container.
""" """
return [element_type.type for element_type in element_type_registry.get_all()] return [
element_type
for element_type in element_type_registry.get_all()
if not element_type.is_multi_page_element
]
def get_new_place_in_container( def get_new_place_in_container(
self, container_element: ContainerElement, places_removed: List[str] self, container_element: ContainerElement, places_removed: List[str]
@ -128,6 +133,8 @@ class ContainerElementTypeMixin:
:raises DRFValidationError: If the place in container is invalid :raises DRFValidationError: If the place in container is invalid
""" """
return True
class CollectionElementTypeMixin: class CollectionElementTypeMixin:
is_collection_element = True is_collection_element = True
@ -738,3 +745,119 @@ class FormElementTypeMixin:
) )
return value return value
class MultiPageElementTypeMixin:
is_multi_page_element = True
@property
def serializer_field_names(self):
return super().serializer_field_names + [
"share_type",
"pages",
]
@property
def allowed_fields(self):
return super().allowed_fields + [
"share_type",
]
class SerializedDict(ElementDict):
share_type: str
pages: List[int]
def after_create(self, instance, values):
"""
Add the pages
"""
from baserow.contrib.builder.pages.models import Page
super().after_create(instance, values)
if "pages" in values:
pages = PageHandler().get_pages(
instance.page.builder,
base_queryset=Page.objects.filter(
id__in=[p.id for p in values["pages"]]
),
)
instance.pages.add(*pages)
def after_update(self, instance: Any, values: Dict, changes: Dict[str, Tuple]):
"""
Updates the pages.
"""
from baserow.contrib.builder.pages.models import Page
super().after_update(instance, values, changes)
if "pages" in values:
pages = PageHandler().get_pages(
instance.page.builder,
base_queryset=Page.objects.filter(
id__in=[p.id for p in values["pages"]]
),
)
instance.pages.clear()
instance.pages.add(*pages)
def serialize_property(
self,
element: "MultiPageElementTypeMixin",
prop_name: str,
files_zip=None,
storage=None,
cache=None,
**kwargs,
):
"""
You can customize the behavior of the serialization of a property with this
hook.
"""
if prop_name == "pages":
return [page.id for page in element.pages.all()]
return super().serialize_property(
element,
prop_name,
files_zip=files_zip,
storage=storage,
cache=cache,
**kwargs,
)
def create_instance_from_serialized(
self,
serialized_values: Dict[str, Any],
id_mapping,
files_zip=None,
storage=None,
cache=None,
**kwargs,
):
"""Deals with the fields"""
pages = serialized_values.pop("pages", [])
instance = super().create_instance_from_serialized(
serialized_values,
id_mapping,
files_zip=files_zip,
storage=storage,
cache=cache,
**kwargs,
)
pages = [id_mapping["builder_pages"][page_id] for page_id in pages]
if pages:
instance.pages.add(*pages)
return instance
def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]:
return {"share_type": "all"}

View file

@ -906,3 +906,38 @@ class DateTimePickerElement(FormElement):
max_length=32, max_length=32,
help_text="24 (14:00) or 12 (02:30) PM", help_text="24 (14:00) or 12 (02:30) PM",
) )
class MultiPageElement(Element):
"""
A container element that can contain other elements and be can shared across
multiple pages.
"""
class SHARE_TYPE(models.TextChoices):
ALL = "all"
ONLY = "only"
EXCEPT = "except"
share_type = models.CharField(
choices=SHARE_TYPE.choices,
max_length=10,
default=SHARE_TYPE.ALL,
)
pages = models.ManyToManyField("builder.Page", blank=True)
class Meta:
abstract = True
class HeaderElement(MultiPageElement, ContainerElement):
"""
A multi-page container element positioned at the top of the page.
"""
class FooterElement(MultiPageElement, ContainerElement):
"""
A multi-page container element positioned at the bottom of the page.
"""

View file

@ -21,6 +21,7 @@ from rest_framework.exceptions import ValidationError
from baserow.contrib.builder.formula_importer import import_formula from baserow.contrib.builder.formula_importer import import_formula
from baserow.contrib.builder.mixins import BuilderInstanceWithFormulaMixin from baserow.contrib.builder.mixins import BuilderInstanceWithFormulaMixin
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.database.db.functions import RandomUUID from baserow.contrib.database.db.functions import RandomUUID
from baserow.core.registry import ( from baserow.core.registry import (
CustomFieldsInstanceMixin, CustomFieldsInstanceMixin,
@ -58,6 +59,9 @@ class ElementType(
parent_property_name = "page" parent_property_name = "page"
id_mapping_name = BUILDER_PAGE_ELEMENTS id_mapping_name = BUILDER_PAGE_ELEMENTS
# Whether this element is a multi-page element and should be placed on shared page.
is_multi_page_element = False
# The order in which this element type is imported in `import_elements`. # The order in which this element type is imported in `import_elements`.
# By default, the priority is `0`, the lowest value. If this property is # By default, the priority is `0`, the lowest value. If this property is
# not overridden, then the instance is imported last. # not overridden, then the instance is imported last.
@ -80,25 +84,62 @@ class ElementType(
parent_element_id = values.get( parent_element_id = values.get(
"parent_element_id", getattr(instance, "parent_element_id", None) "parent_element_id", getattr(instance, "parent_element_id", None)
) )
place_in_container = values.get("place_in_container", None)
if instance:
place_in_container = values.get(
"place_in_container", instance.place_in_container
)
page = values.get("page", instance.page)
else:
place_in_container = values.get("place_in_container", None)
page = values["page"]
parent_element = None
if parent_element_id is not None: if parent_element_id is not None:
parent_element = ElementHandler().get_element(parent_element_id) parent_element = ElementHandler().get_element(parent_element_id)
parent_element_type = element_type_registry.get_by_model(parent_element)
if self.type not in parent_element_type.child_types_allowed: # Validate the place for this element
self.validate_place(page, parent_element, place_in_container)
return values
def validate_place(
self,
page: Page,
parent_element: Optional[ElementSubClass],
place_in_container: str,
):
"""
Validates the page/parent_element/place_in_container for this element.
Can be overridden to change the behaviour.
:param page: the page we want to add/move the element to.
:param parent_element: the parent_element if any.
:param place_in_container: the place in container in the parent.
:raises ValidationError: if the the element place is not allowed.
"""
if parent_element:
if self.type not in [
e.type for e in parent_element.get_type().child_types_allowed
]:
raise ValidationError( raise ValidationError(
f"Container of type {parent_element_type.type} can't have child of " f"Container of type {parent_element.get_type().type} can't have child of "
f"type {self.type}" f"type {self.type}"
) )
if place_in_container is not None: # If we have a parent, we validate the place is accepted by this container.
parent_element_type.validate_place_in_container( parent_element.get_type().validate_place_in_container(
place_in_container, parent_element place_in_container, parent_element
)
else:
if self.is_multi_page_element != page.shared:
raise ValidationError(
"This element type can't be added as root of a "
f"{'an unshared' if self.is_multi_page_element else 'the shared'} "
"page."
) )
return values
def after_create(self, instance: ElementSubClass, values: Dict): def after_create(self, instance: ElementSubClass, values: Dict):
""" """
This hook is called right after the element has been created. This hook is called right after the element has been created.

View file

@ -0,0 +1,79 @@
# Generated by Django 5.0.9 on 2024-10-17 08:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("builder", "0041_builder_login_page_page_role_type_page_roles_and_more"),
]
operations = [
migrations.CreateModel(
name="FooterElement",
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",
),
),
(
"share_type",
models.CharField(
choices=[
("all", "All"),
("only", "Only"),
("except", "Except"),
],
default="all",
max_length=10,
),
),
("pages", models.ManyToManyField(blank=True, to="builder.page")),
],
options={
"abstract": False,
},
bases=("builder.element",),
),
migrations.CreateModel(
name="HeaderElement",
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",
),
),
(
"share_type",
models.CharField(
choices=[
("all", "All"),
("only", "Only"),
("except", "Except"),
],
default="all",
max_length=10,
),
),
("pages", models.ManyToManyField(blank=True, to="builder.page")),
],
options={
"abstract": False,
},
bases=("builder.element",),
),
]

View file

@ -51,20 +51,34 @@ class PageHandler:
""" """
if base_queryset is None: if base_queryset is None:
base_queryset = Page.objects base_queryset = Page.objects_with_shared
try: try:
return base_queryset.select_related("builder", "builder__workspace").get( return base_queryset.select_related("builder__workspace").get(id=page_id)
id=page_id
)
except Page.DoesNotExist: except Page.DoesNotExist:
raise PageDoesNotExist() raise PageDoesNotExist()
def get_shared_page(self, builder: Builder) -> Page: def get_shared_page(self, builder: Builder) -> Page:
return Page.objects.select_related("builder", "builder__workspace").get( """
Returns the shared page for the given builder.
"""
return Page.objects_with_shared.select_related("builder__workspace").get(
builder=builder, shared=True builder=builder, shared=True
) )
def get_pages(self, builder, base_queryset: Optional[QuerySet] = None):
"""
Returns all the page in the current builder.
"""
if base_queryset is None:
base_queryset = Page.objects_with_shared.all()
return base_queryset.filter(builder=builder).select_related(
"builder__workspace"
)
def create_shared_page(self, builder: Builder) -> Page: def create_shared_page(self, builder: Builder) -> Page:
""" """
Creates the shared page of the given builder. Creates the shared page of the given builder.
@ -153,7 +167,7 @@ class PageHandler:
self.is_page_path_unique( self.is_page_path_unique(
page.builder, page.builder,
path, path,
base_queryset=Page.objects.exclude( base_queryset=Page.objects_with_shared.exclude(
id=page.id id=page.id
), # We don't want to conflict with the current page ), # We don't want to conflict with the current page
raises=True, raises=True,
@ -188,7 +202,7 @@ class PageHandler:
""" """
if base_qs is None: if base_qs is None:
base_qs = Page.objects.filter(builder=builder, shared=False) base_qs = Page.objects.filter(builder=builder)
try: try:
full_order = Page.order_objects(base_qs, order) full_order = Page.order_objects(base_qs, order)
@ -345,7 +359,7 @@ class PageHandler:
:return: If the path is unique :return: If the path is unique
""" """
queryset = Page.objects if base_queryset is None else base_queryset queryset = Page.objects_with_shared if base_queryset is None else base_queryset
existing_paths = queryset.filter(builder=builder).values_list("path", flat=True) existing_paths = queryset.filter(builder=builder).values_list("path", flat=True)

View file

@ -20,6 +20,16 @@ if typing.TYPE_CHECKING:
from baserow.contrib.builder.models import Builder from baserow.contrib.builder.models import Builder
class PageWithoutSharedManager(models.Manager):
"""
Manager for the Page model.
Excludes by default the shared page.
"""
def get_queryset(self):
return super().get_queryset().filter(shared=False)
class Page( class Page(
HierarchicalModelMixin, HierarchicalModelMixin,
TrashableModelMixin, TrashableModelMixin,
@ -36,6 +46,9 @@ class Page(
ALLOW_ALL_EXCEPT = "allow_all_except" ALLOW_ALL_EXCEPT = "allow_all_except"
DISALLOW_ALL_EXCEPT = "disallow_all_except" DISALLOW_ALL_EXCEPT = "disallow_all_except"
objects = PageWithoutSharedManager()
objects_with_shared = models.Manager()
builder = models.ForeignKey("builder.Builder", on_delete=models.CASCADE) builder = models.ForeignKey("builder.Builder", on_delete=models.CASCADE)
order = models.PositiveIntegerField() order = models.PositiveIntegerField()
name = models.CharField(max_length=255) name = models.CharField(max_length=255)

View file

@ -37,8 +37,7 @@ class PageService:
:return: The model instance of the Page :return: The model instance of the Page
""" """
base_queryset = Page.objects.select_related("builder", "builder__workspace") page = self.handler.get_page(page_id)
page = self.handler.get_page(page_id, base_queryset=base_queryset)
CoreHandler().check_permissions( CoreHandler().check_permissions(
user, user,
@ -148,7 +147,8 @@ class PageService:
context=builder, context=builder,
) )
all_pages = Page.objects.filter(builder_id=builder.id, shared=False) all_pages = self.handler.get_pages(builder, base_queryset=Page.objects)
user_pages = CoreHandler().filter_queryset( user_pages = CoreHandler().filter_queryset(
user, user,
OrderPagesBuilderOperationType.type, OrderPagesBuilderOperationType.type,

View file

@ -1620,7 +1620,7 @@ def test_dispatch_data_sources_with_formula_using_datasource_calling_a_shared_da
integration = data_fixture.create_local_baserow_integration( integration = data_fixture.create_local_baserow_integration(
user=user, application=builder user=user, application=builder
) )
shared_page = builder.page_set.first() shared_page = builder.shared_page
page = data_fixture.create_builder_page(user=user, builder=builder) page = data_fixture.create_builder_page(user=user, builder=builder)
data_source2 = data_fixture.create_builder_local_baserow_get_row_data_source( data_source2 = data_fixture.create_builder_local_baserow_get_row_data_source(
@ -1686,7 +1686,7 @@ def test_dispatch_only_shared_data_sources(data_fixture, api_client):
integration = data_fixture.create_local_baserow_integration( integration = data_fixture.create_local_baserow_integration(
user=user, application=builder user=user, application=builder
) )
shared_page = builder.page_set.first() shared_page = builder.shared_page
page = data_fixture.create_builder_page(user=user, builder=builder) page = data_fixture.create_builder_page(user=user, builder=builder)
shared_data_source = data_fixture.create_builder_local_baserow_get_row_data_source( shared_data_source = data_fixture.create_builder_local_baserow_get_row_data_source(

View file

@ -129,9 +129,12 @@ def test_get_public_builder_by_domain_name(api_client, data_fixture):
del response_json["theme"] # We are not testing the theme response here. del response_json["theme"] # We are not testing the theme response here.
assert builder_to.page_set.filter(shared=True).count() == 1 assert (
builder_to.page_set(manager="objects_with_shared").filter(shared=True).count()
== 1
)
shared_page = builder_to.page_set.get(shared=True) shared_page = builder_to.shared_page
assert response_json == { assert response_json == {
"favicon_file": UserFileSerializer(builder_to.favicon_file).data, "favicon_file": UserFileSerializer(builder_to.favicon_file).data,
@ -255,9 +258,12 @@ def test_get_public_builder_by_id(api_client, data_fixture):
del response_json["theme"] # We are not testing the theme response here. del response_json["theme"] # We are not testing the theme response here.
assert page.builder.page_set.filter(shared=True).count() == 1 assert (
page.builder.page_set(manager="objects_with_shared").filter(shared=True).count()
== 1
)
shared_page = page.builder.page_set.get(shared=True) shared_page = page.builder.shared_page
assert response_json == { assert response_json == {
"favicon_file": UserFileSerializer(page.builder.favicon_file).data, "favicon_file": UserFileSerializer(page.builder.favicon_file).data,

View file

@ -359,7 +359,7 @@ def test_update_page_page_does_not_exist(api_client, data_fixture):
def test_update_shared_page(api_client, data_fixture): def test_update_shared_page(api_client, data_fixture):
user, token = data_fixture.create_user_and_token() user, token = data_fixture.create_user_and_token()
builder = data_fixture.create_builder_application(user=user) builder = data_fixture.create_builder_application(user=user)
shared_page = builder.page_set.get(shared=True) shared_page = builder.shared_page
url = reverse("api:builder:pages:item", kwargs={"page_id": shared_page.id}) url = reverse("api:builder:pages:item", kwargs={"page_id": shared_page.id})
response = api_client.patch( response = api_client.patch(
@ -610,7 +610,7 @@ def test_order_pages_page_not_in_builder(api_client, data_fixture):
def test_order_pages_shared_page(api_client, data_fixture): def test_order_pages_shared_page(api_client, data_fixture):
user, token = data_fixture.create_user_and_token() user, token = data_fixture.create_user_and_token()
builder = data_fixture.create_builder_application(user=user) builder = data_fixture.create_builder_application(user=user)
shared_page = builder.page_set.get(shared=True) shared_page = builder.shared_page
page_one = data_fixture.create_builder_page(builder=builder, order=1) page_one = data_fixture.create_builder_page(builder=builder, order=1)
url = reverse( url = reverse(
@ -698,7 +698,7 @@ def test_delete_page_page_not_exist(api_client, data_fixture):
def test_delete_shared_page(api_client, data_fixture): def test_delete_shared_page(api_client, data_fixture):
user, token = data_fixture.create_user_and_token() user, token = data_fixture.create_user_and_token()
builder = data_fixture.create_builder_application(user=user) builder = data_fixture.create_builder_application(user=user)
shared_page = builder.page_set.get(shared=True) shared_page = builder.shared_page
url = reverse("api:builder:pages:item", kwargs={"page_id": shared_page.id}) url = reverse("api:builder:pages:item", kwargs={"page_id": shared_page.id})
response = api_client.delete( response = api_client.delete(

View file

@ -174,7 +174,7 @@ def test_get_builder_application(api_client, data_fixture):
"login_page_id": None, "login_page_id": None,
"pages": [ "pages": [
{ {
"id": application.page_set.get(shared=True).id, "id": application.shared_page.id,
"builder_id": application.id, "builder_id": application.id,
"order": 1, "order": 1,
"name": "__shared__", "name": "__shared__",
@ -233,7 +233,7 @@ def test_list_builder_applications(api_client, data_fixture):
"login_page_id": None, "login_page_id": None,
"pages": [ "pages": [
{ {
"id": application.page_set.get(shared=True).id, "id": application.shared_page.id,
"builder_id": application.id, "builder_id": application.id,
"order": 1, "order": 1,
"name": "__shared__", "name": "__shared__",

View file

@ -48,7 +48,7 @@ def test_validate_login_page_id_raises_error_if_shared_page(
builder = builder_fixture["builder"] builder = builder_fixture["builder"]
# Set the builder's page to be the shared page # Set the builder's page to be the shared page
shared_page = builder.page_set.get(shared=True) shared_page = builder.page_set(manager="objects_with_shared").get(shared=True)
response = api_client.patch( response = api_client.patch(
reverse("api:applications:item", kwargs={"application_id": builder.id}), reverse("api:applications:item", kwargs={"application_id": builder.id}),
{"login_page_id": shared_page.id}, {"login_page_id": shared_page.id},

View file

@ -717,7 +717,7 @@ def test_dispatch_local_baserow_update_row_workflow_action_using_formula_with_da
integration = data_fixture.create_local_baserow_integration( integration = data_fixture.create_local_baserow_integration(
user=user, application=builder user=user, application=builder
) )
shared_page = builder.page_set.first() shared_page = builder.shared_page
shared_data_source = data_fixture.create_builder_local_baserow_get_row_data_source( shared_data_source = data_fixture.create_builder_local_baserow_get_row_data_source(
user=user, user=user,

View file

@ -94,7 +94,7 @@ def test_get_data_sources(data_fixture, specific):
@pytest.mark.django_db @pytest.mark.django_db
def test_get_data_sources_with_shared(data_fixture): def test_get_data_sources_with_shared(data_fixture):
page = data_fixture.create_builder_page() page = data_fixture.create_builder_page()
shared_page = page.builder.page_set.get(shared=True) shared_page = page.builder.shared_page
data_source1 = data_fixture.create_builder_local_baserow_get_row_data_source( data_source1 = data_fixture.create_builder_local_baserow_get_row_data_source(
page=page page=page
) )

View file

@ -186,10 +186,7 @@ def test_domain_publishing(data_fixture):
assert domain1.published_to is not None assert domain1.published_to is not None
assert domain1.published_to.workspace is None assert domain1.published_to.workspace is None
assert domain1.published_to.page_set.count() == builder.page_set.count() assert domain1.published_to.page_set.count() == builder.page_set.count()
assert ( assert domain1.published_to.page_set.first().element_set.count() == 2
domain1.published_to.page_set.exclude(shared=True).first().element_set.count()
== 2
)
assert Builder.objects.count() == 2 assert Builder.objects.count() == 2

View file

@ -1,6 +1,7 @@
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from rest_framework.exceptions import ValidationError as DRFValidationError
from baserow.contrib.builder.elements.element_types import ( from baserow.contrib.builder.elements.element_types import (
ColumnElementType, ColumnElementType,
@ -27,9 +28,13 @@ def pytest_generate_tests(metafunc):
@pytest.mark.django_db @pytest.mark.django_db
def test_create_element(data_fixture, element_type): def test_create_element(data_fixture, element_type):
page = data_fixture.create_builder_page() page = data_fixture.create_builder_page()
shared_page = page.builder.shared_page
pytest_params = element_type.get_pytest_params(data_fixture) pytest_params = element_type.get_pytest_params(data_fixture)
if element_type.is_multi_page_element:
page = shared_page
element = ElementHandler().create_element(element_type, page=page, **pytest_params) element = ElementHandler().create_element(element_type, page=page, **pytest_params)
assert element.page.id == page.id assert element.page.id == page.id
@ -41,6 +46,34 @@ def test_create_element(data_fixture, element_type):
assert Element.objects.count() == 1 assert Element.objects.count() == 1
@pytest.mark.django_db
def test_create_element_and_shared_page(data_fixture):
page = data_fixture.create_builder_page()
shared_page = page.builder.shared_page
regular_element_type = next(
filter(lambda t: not t.is_multi_page_element, element_type_registry.get_all())
)
with pytest.raises(DRFValidationError):
ElementHandler().create_element(
regular_element_type,
page=shared_page,
**regular_element_type.get_pytest_params(data_fixture),
)
shared_element_type = next(
filter(lambda t: t.is_multi_page_element, element_type_registry.get_all())
)
with pytest.raises(DRFValidationError):
ElementHandler().create_element(
shared_element_type,
page=page,
**regular_element_type.get_pytest_params(data_fixture),
)
@pytest.mark.django_db @pytest.mark.django_db
def test_get_element(data_fixture): def test_get_element(data_fixture):
element = data_fixture.create_builder_heading_element() element = data_fixture.create_builder_heading_element()

View file

@ -26,6 +26,11 @@ def pytest_generate_tests(metafunc):
def test_create_element(element_created_mock, data_fixture, element_type): def test_create_element(element_created_mock, data_fixture, element_type):
user = data_fixture.create_user() user = data_fixture.create_user()
page = data_fixture.create_builder_page(user=user) page = data_fixture.create_builder_page(user=user)
shared_page = page.builder.shared_page
if element_type.is_multi_page_element:
page = shared_page
element1 = data_fixture.create_builder_heading_element(page=page, order="1.0000") element1 = data_fixture.create_builder_heading_element(page=page, order="1.0000")
element3 = data_fixture.create_builder_heading_element(page=page, order="2.0000") element3 = data_fixture.create_builder_heading_element(page=page, order="2.0000")

View file

@ -290,10 +290,13 @@ def test_form_container_element_import_export_formula(data_fixture):
element_type.type element_type.type
for element_type in element_type_registry.get_all() for element_type in element_type_registry.get_all()
if element_type.type != FormContainerElementType.type if element_type.type != FormContainerElementType.type
and not element_type.is_multi_page_element
], ],
) )
def test_form_container_child_types_allowed(allowed_element_type): def test_form_container_child_types_allowed(allowed_element_type):
assert allowed_element_type in FormContainerElementType().child_types_allowed assert allowed_element_type in [
e.type for e in FormContainerElementType().child_types_allowed
]
@pytest.mark.django_db @pytest.mark.django_db
@ -1347,10 +1350,13 @@ def test_choice_element_integer_option_values(data_fixture):
element_type.type element_type.type
for element_type in element_type_registry.get_all() for element_type in element_type_registry.get_all()
if element_type.type != ColumnElementType.type if element_type.type != ColumnElementType.type
and not element_type.is_multi_page_element
], ],
) )
def test_column_container_child_types_allowed(allowed_element_type): def test_column_container_child_types_allowed(allowed_element_type):
assert allowed_element_type in ColumnElementType().child_types_allowed assert allowed_element_type in [
e.type for e in ColumnElementType().child_types_allowed
]
@pytest.mark.django_db @pytest.mark.django_db
@ -1513,7 +1519,7 @@ def test_repeat_element_import_export(data_fixture):
imported_field = imported_table.field_set.get() imported_field = imported_table.field_set.get()
# Pluck out the imported builder records. # Pluck out the imported builder records.
imported_page = imported_builder.page_set.filter(shared=False).all()[0] imported_page = imported_builder.page_set.all()[0]
imported_data_source = imported_page.datasource_set.get() imported_data_source = imported_page.datasource_set.get()
imported_root_repeat = imported_page.element_set.get( imported_root_repeat = imported_page.element_set.get(
parent_element_id=None parent_element_id=None

View file

@ -0,0 +1,93 @@
import pytest
from baserow.contrib.builder.elements.element_types import (
FooterElementType,
HeaderElementType,
)
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.registries import element_type_registry
def test_header_footer_child_types_allowed():
assert sorted([e.type for e in HeaderElementType().child_types_allowed]) == sorted(
[
element_type.type
for element_type in element_type_registry.get_all()
if not element_type.is_multi_page_element
]
)
assert sorted([e.type for e in FooterElementType().child_types_allowed]) == sorted(
[
element_type.type
for element_type in element_type_registry.get_all()
if not element_type.is_multi_page_element
]
)
# Test prepare value
@pytest.mark.django_db
@pytest.mark.parametrize(
"element_type", [HeaderElementType.type, FooterElementType.type]
)
def test_header_footer_prepare_value_for_db(data_fixture, element_type):
page = data_fixture.create_builder_page()
page1 = data_fixture.create_builder_page(builder=page.builder)
page2 = data_fixture.create_builder_page(builder=page.builder)
page3 = data_fixture.create_builder_page(builder=page.builder)
page4 = data_fixture.create_builder_page()
shared_page = page.builder.shared_page
element_type = element_type_registry.get(element_type)
created_element = ElementHandler().create_element(
element_type,
page=shared_page,
share_type="only",
pages=[page1, page2, page4, shared_page],
)
assert sorted([p.id for p in created_element.pages.all()]) == sorted(
[page1.id, page2.id]
)
updated_element = ElementHandler().update_element(
created_element,
pages=[page1, page4, shared_page],
)
assert sorted([p.id for p in updated_element.pages.all()]) == sorted([page1.id])
@pytest.mark.django_db
@pytest.mark.parametrize(
"element_type", [HeaderElementType.type, FooterElementType.type]
)
def test_header_footer_import_with_id_mapping(data_fixture, element_type):
page = data_fixture.create_builder_page()
page42 = data_fixture.create_builder_page()
page43 = data_fixture.create_builder_page()
SERIALIZED_HEADER = {
"id": 42,
"type": element_type,
"share_type": "only",
"parent_element_id": None,
"pages": [42, 43],
}
cache = {}
id_mapping = {"builder_pages": {42: page42, 43: page43}}
created_element = ElementHandler().import_element(
page,
SERIALIZED_HEADER,
id_mapping,
cache=cache,
)
# We keep only the pages that are in the same builder
assert sorted([p.id for p in created_element.pages.all()]) == sorted(
[page42.id, page43.id]
)

View file

@ -93,12 +93,7 @@ def test_export_import_record_selector_element(data_fixture):
import_export_config=config, import_export_config=config,
) )
imported_builder = imported_apps[-1] imported_builder = imported_apps[-1]
imported_element = ( imported_element = imported_builder.page_set.first().element_set.first().specific
imported_builder.page_set.filter(shared=False)
.first()
.element_set.first()
.specific
)
# Check that the formula for option name suffix was updated with the new mapping # Check that the formula for option name suffix was updated with the new mapping
import_option_name_suffix = imported_element.option_name_suffix import_option_name_suffix = imported_element.option_name_suffix

View file

@ -94,7 +94,7 @@ def test_delete_page(data_fixture):
@pytest.mark.django_db @pytest.mark.django_db
def test_delete_shared_page(data_fixture): def test_delete_shared_page(data_fixture):
page = data_fixture.create_builder_page() page = data_fixture.create_builder_page()
shared_page = page.builder.page_set.get(shared=True) shared_page = page.builder.shared_page
with pytest.raises(SharedPageIsReadOnly): with pytest.raises(SharedPageIsReadOnly):
PageHandler().delete_page(shared_page) PageHandler().delete_page(shared_page)
@ -114,7 +114,7 @@ def test_update_page(data_fixture):
@pytest.mark.django_db @pytest.mark.django_db
def test_update_shared_page(data_fixture): def test_update_shared_page(data_fixture):
page = data_fixture.create_builder_page(name="test") page = data_fixture.create_builder_page(name="test")
shared_page = page.builder.page_set.get(shared=True) shared_page = page.builder.shared_page
with pytest.raises(SharedPageIsReadOnly): with pytest.raises(SharedPageIsReadOnly):
PageHandler().update_page(shared_page, name="new") PageHandler().update_page(shared_page, name="new")
@ -158,7 +158,7 @@ def test_order_pages(data_fixture):
@pytest.mark.django_db @pytest.mark.django_db
def test_order_pages_page_not_in_builder(data_fixture): def test_order_pages_page_not_in_builder(data_fixture):
builder = data_fixture.create_builder_application() builder = data_fixture.create_builder_application()
shared_page = builder.page_set.get(shared=True) shared_page = builder.shared_page
page_one = data_fixture.create_builder_page(builder=builder, order=1) page_one = data_fixture.create_builder_page(builder=builder, order=1)
page_two = data_fixture.create_builder_page(builder=builder, order=2) page_two = data_fixture.create_builder_page(builder=builder, order=2)
@ -189,7 +189,7 @@ def test_duplicate_page(data_fixture):
@pytest.mark.django_db @pytest.mark.django_db
def test_duplicate_shared_page(data_fixture): def test_duplicate_shared_page(data_fixture):
page = data_fixture.create_builder_page() page = data_fixture.create_builder_page()
shared_page = page.builder.page_set.get(shared=True) shared_page = page.builder.shared_page
with pytest.raises(SharedPageIsReadOnly): with pytest.raises(SharedPageIsReadOnly):
PageHandler().duplicate_page(shared_page) PageHandler().duplicate_page(shared_page)

View file

@ -60,11 +60,11 @@ def test_builder_application_type_init_application(data_fixture):
user = data_fixture.create_user() user = data_fixture.create_user()
builder = data_fixture.create_builder_application(user=user) builder = data_fixture.create_builder_application(user=user)
assert Page.objects.count() == 1 # The shared page must exists assert Page.objects.count() == 0
BuilderApplicationType().init_application(user, builder) BuilderApplicationType().init_application(user, builder)
assert Page.objects.count() == 3 # With demo data assert Page.objects.count() == 2 # With demo data
@pytest.mark.django_db @pytest.mark.django_db
@ -119,7 +119,7 @@ def test_builder_application_export(data_fixture):
user = data_fixture.create_user() user = data_fixture.create_user()
builder = data_fixture.create_builder_application(user=user) builder = data_fixture.create_builder_application(user=user)
shared_page = builder.page_set.get(shared=True) shared_page = builder.shared_page
page1 = data_fixture.create_builder_page(builder=builder) page1 = data_fixture.create_builder_page(builder=builder)
page2 = data_fixture.create_builder_page(builder=builder) page2 = data_fixture.create_builder_page(builder=builder)
@ -1002,8 +1002,11 @@ def test_builder_application_import(data_fixture):
) )
assert builder.id != serialized_values["id"] assert builder.id != serialized_values["id"]
assert builder.page_set.exclude(shared=True).count() == 2 assert builder.page_set.count() == 2
assert builder.page_set.filter(shared=True).count() == 1 # ensure we have the shared page even if it's not in the reference
assert (
builder.page_set(manager="objects_with_shared").filter(shared=True).count() == 1
)
assert builder.integrations.count() == 1 assert builder.integrations.count() == 1
first_integration = builder.integrations.first().specific first_integration = builder.integrations.first().specific
@ -1011,7 +1014,7 @@ def test_builder_application_import(data_fixture):
assert builder.user_sources.count() == 1 assert builder.user_sources.count() == 1
[page1, page2] = builder.page_set.exclude(shared=True) [page1, page2] = builder.page_set.all()
assert page1.element_set.count() == 6 assert page1.element_set.count() == 6
assert page2.element_set.count() == 1 assert page2.element_set.count() == 1
@ -1371,7 +1374,7 @@ def test_builder_application_imports_correct_default_roles(data_fixture):
workspace, serialized_values, config, {} workspace, serialized_values, config, {}
) )
new_element = builder.page_set.exclude(shared=True)[0].element_set.all()[0] new_element = builder.page_set.first().element_set.all()[0]
new_user_source = builder.user_sources.all()[0] new_user_source = builder.user_sources.all()[0]
# Ensure the "old" Default User Role doesn't exist # Ensure the "old" Default User Role doesn't exist
@ -1455,7 +1458,7 @@ def test_ensure_new_element_roles_are_sanitized_during_import_for_default_roles(
expected_roles = _expected_roles expected_roles = _expected_roles
# Ensure new element has roles updated # Ensure new element has roles updated
new_element = builder.page_set.exclude(shared=True)[0].element_set.all()[0] new_element = builder.page_set.all()[0].element_set.all()[0]
for index, role in enumerate(new_element.roles): for index, role in enumerate(new_element.roles):
# Default Role's User Source should have changed for new elements # Default Role's User Source should have changed for new elements
if role.startswith(prefix): if role.startswith(prefix):
@ -1532,5 +1535,5 @@ def test_ensure_new_element_roles_are_sanitized_during_import_for_roles(
workspace, serialized, config, {} workspace, serialized, config, {}
) )
new_element = builder.page_set.exclude(shared=True)[0].element_set.all()[0] new_element = builder.page_set.all()[0].element_set.all()[0]
assert new_element.roles == expected_roles assert new_element.roles == expected_roles

View file

@ -901,7 +901,7 @@ def test_get_builder_used_property_names_returns_merged_property_names_integrati
integration = data_fixture.create_local_baserow_integration( integration = data_fixture.create_local_baserow_integration(
user=user, application=builder user=user, application=builder
) )
shared_page = builder.page_set.get(shared=True) shared_page = builder.shared_page
page = data_fixture.create_builder_page(builder=builder) page = data_fixture.create_builder_page(builder=builder)
page2 = data_fixture.create_builder_page(builder=builder) page2 = data_fixture.create_builder_page(builder=builder)

View file

@ -182,7 +182,7 @@ def test_allow_if_template_permission_manager_filter_queryset(data_fixture):
workspace_2 = data_fixture.create_workspace() workspace_2 = data_fixture.create_workspace()
data_fixture.create_template(workspace=workspace_2) data_fixture.create_template(workspace=workspace_2)
application_2 = data_fixture.create_builder_application(workspace=workspace_2) application_2 = data_fixture.create_builder_application(workspace=workspace_2)
shared_page_2 = application_2.page_set.get(shared=True) shared_page_2 = application_2.shared_page
page_2 = data_fixture.create_builder_page(builder=application_2) page_2 = data_fixture.create_builder_page(builder=application_2)
element_2 = data_fixture.create_builder_text_element(page=page_2) element_2 = data_fixture.create_builder_text_element(page=page_2)
workflow_action_2 = data_fixture.create_local_baserow_update_row_workflow_action( workflow_action_2 = data_fixture.create_local_baserow_update_row_workflow_action(
@ -230,7 +230,7 @@ def test_allow_if_template_permission_manager_filter_queryset(data_fixture):
tests_w1 = [ tests_w1 = [
( (
ListPagesBuilderOperationType.type, ListPagesBuilderOperationType.type,
Page.objects.filter(builder__workspace=workspace_2), Page.objects_with_shared.filter(builder__workspace=workspace_2),
[shared_page_2.id, page_2.id], [shared_page_2.id, page_2.id],
), ),
( (

View file

@ -603,7 +603,7 @@ def test_export_import_local_baserow_upsert_row_service(
imported_table = imported_database.table_set.get() imported_table = imported_database.table_set.get()
imported_field = imported_table.field_set.get() imported_field = imported_table.field_set.get()
imported_page = imported_builder.page_set.exclude(shared=True).get() imported_page = imported_builder.page_set.get()
imported_data_source = imported_page.datasource_set.get() imported_data_source = imported_page.datasource_set.get()
imported_integration = imported_builder.integrations.get() imported_integration = imported_builder.integrations.get()
imported_upsert_row_service = LocalBaserowUpsertRow.objects.get( imported_upsert_row_service = LocalBaserowUpsertRow.objects.get(

View file

@ -169,7 +169,7 @@ def test_local_baserow_table_service_filterable_mixin_import_export(data_fixture
imported_select_option = imported_single_select_field.select_options.get() imported_select_option = imported_single_select_field.select_options.get()
# Pluck out the imported builder records. # Pluck out the imported builder records.
imported_page = imported_builder.page_set.filter(shared=False).get() imported_page = imported_builder.page_set.get()
imported_datasource = imported_page.datasource_set.get() imported_datasource = imported_page.datasource_set.get()
imported_filters = [ imported_filters = [
{"field_id": sf.field_id, "value": sf.value} {"field_id": sf.field_id, "value": sf.value}

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "[Builder] Add the multi-page header and footer containers",
"issue_number": 2486,
"bullet_points": [],
"created_at": "2024-10-29"
}

View file

@ -58,7 +58,7 @@ test.describe("Builder page test suite", () => {
}); });
test("Can create an element from empty page", async ({ page }) => { test("Can create an element from empty page", async ({ page }) => {
await page.getByText("Click to create first element").click(); await page.getByText("Click to create an element").click();
await page.getByText("Heading", { exact: true }).click(); await page.getByText("Heading", { exact: true }).click();
await expect( await expect(

View file

@ -957,7 +957,7 @@ def test_public_dispatch_data_source_with_ab_user_using_user_source(
refresh_token = user_source_user.get_refresh_token() refresh_token = user_source_user.get_refresh_token()
access_token = refresh_token.access_token access_token = refresh_token.access_token
published_page = domain1.published_to.page_set.exclude(shared=True).first() published_page = domain1.published_to.page_set.first()
published_data_source = published_page.datasource_set.first() published_data_source = published_page.datasource_set.first()
url = reverse( url = reverse(

View file

@ -65,7 +65,7 @@ import { mapActions } from 'vuex'
export default { export default {
name: 'AuthFormElement', name: 'AuthFormElement',
mixins: [element, form, error], mixins: [element, form, error],
inject: ['page', 'builder'], inject: ['elementPage', 'builder'],
props: { props: {
/** /**
* @type {Object} * @type {Object}
@ -128,7 +128,7 @@ export default {
if (!found) { if (!found) {
// If the user_source has been removed we need to update the element // If the user_source has been removed we need to update the element
this.actionForceUpdateElement({ this.actionForceUpdateElement({
page: this.page, page: this.elementPage,
element: this.element, element: this.element,
values: { user_source_id: null }, values: { user_source_id: null },
}) })

View file

@ -86,9 +86,10 @@ export class BuilderApplicationType extends ApplicationType {
} }
} }
async loadExtraData(builder, page, mode) { async loadExtraData(builder, mode) {
const { store, $registry } = this.app const { store, $registry } = this.app
if (!builder._loadedOnce) { if (!builder._loadedOnce) {
const sharedPage = store.getters['page/getSharedPage'](builder)
await Promise.all([ await Promise.all([
store.dispatch('userSource/fetch', { store.dispatch('userSource/fetch', {
application: builder, application: builder,
@ -98,8 +99,12 @@ export class BuilderApplicationType extends ApplicationType {
}), }),
// Fetch shared data sources // Fetch shared data sources
store.dispatch('dataSource/fetch', { store.dispatch('dataSource/fetch', {
page: store.getters['page/getSharedPage'](builder), page: sharedPage,
}), }),
store.dispatch('element/fetch', {
page: sharedPage,
}),
store.dispatch('workflowAction/fetch', { page: sharedPage }),
]) ])
// Initialize application shared stuff like data sources // Initialize application shared stuff like data sources

View file

@ -19,8 +19,8 @@ export default {
components: { FormulaInputField }, components: { FormulaInputField },
mixins: [applicationContext], mixins: [applicationContext],
inject: { inject: {
page: { elementPage: {
from: 'page', from: 'elementPage',
}, },
builder: { builder: {
from: 'builder', from: 'builder',
@ -38,10 +38,12 @@ export default {
}, },
computed: { computed: {
dataSourceLoading() { dataSourceLoading() {
return this.$store.getters['dataSource/getLoading'](this.page) return this.$store.getters['dataSource/getLoading'](this.elementPage)
}, },
dataSourceContentLoading() { dataSourceContentLoading() {
return this.$store.getters['dataSourceContent/getLoading'](this.page) return this.$store.getters['dataSourceContent/getLoading'](
this.elementPage
)
}, },
dataProviders() { dataProviders() {
return this.dataProvidersAllowed.map((dataProviderName) => return this.dataProvidersAllowed.map((dataProviderName) =>

View file

@ -61,7 +61,16 @@ export default {
components: { DataSourceForm }, components: { DataSourceForm },
mixins: [modal, error], mixins: [modal, error],
inject: ['builder', 'page'], provide() {
// I know, it's not the page of the element but it's injected into the
// ApplicationBuilderFormulaInput for data source loading states,
// and we need the right page which can be in fact the data source page in this
// case, so it works.
// May be we could change the name of the elementPage but it would be only for
// this exception.
return { elementPage: this.dataSourcePage }
},
inject: ['builder', 'currentPage'],
props: { props: {
dataSourceId: { type: Number, required: false, default: null }, dataSourceId: { type: Number, required: false, default: null },
}, },
@ -74,7 +83,9 @@ export default {
}, },
computed: { computed: {
dataSources() { dataSources() {
return this.$store.getters['dataSource/getPageDataSources'](this.page) return this.$store.getters['dataSource/getPageDataSources'](
this.currentPage
)
}, },
sharedPage() { sharedPage() {
return this.$store.getters['page/getSharedPage'](this.builder) return this.$store.getters['page/getSharedPage'](this.builder)
@ -108,7 +119,7 @@ export default {
// edited. Sometimes it's the shared page. // edited. Sometimes it's the shared page.
dataSourcePage() { dataSourcePage() {
if (!this.dataSource) { if (!this.dataSource) {
return this.page return this.currentPage
} }
return this.$store.getters['page/getById']( return this.$store.getters['page/getById'](
this.builder, this.builder,
@ -116,7 +127,11 @@ export default {
) )
}, },
elements() { elements() {
return this.$store.getters['element/getElementsOrdered'](this.page) // This is used when we want to dispatch the data source update
return [
...this.$store.getters['element/getElementsOrdered'](this.currentPage),
...this.$store.getters['element/getElementsOrdered'](this.sharedPage),
]
}, },
}, },
methods: { methods: {
@ -145,7 +160,7 @@ export default {
try { try {
if (this.create) { if (this.create) {
const createdDataSource = await this.actionCreateDataSource({ const createdDataSource = await this.actionCreateDataSource({
page: this.page, page: this.currentPage,
values, values,
}) })
this.actualDataSourceId = createdDataSource.id this.actualDataSourceId = createdDataSource.id

View file

@ -17,18 +17,24 @@
:icon-tooltip="$t('dataSourceDropdown.shared')" :icon-tooltip="$t('dataSourceDropdown.shared')"
> >
</DropdownItem> </DropdownItem>
<DropdownItem <template v-if="localDataSources">
v-for="dataSource in pageDataSources" <DropdownItem
:key="dataSource.id" v-for="dataSource in localDataSources"
:name="getDataSourceLabel(dataSource)" :key="dataSource.id"
:value="dataSource.id" :name="getDataSourceLabel(dataSource)"
icon="iconoir-empty-page" :value="dataSource.id"
:icon-tooltip="$t('dataSourceDropdown.pageOnly')" icon="iconoir-empty-page"
> :icon-tooltip="$t('dataSourceDropdown.pageOnly')"
</DropdownItem> >
</DropdownItem
></template>
<template #emptyState> <template #emptyState>
<slot name="emptyState" <slot name="emptyState">
>{{ $t('dataSourceDropdown.noDataSources') }} {{
isOnSharedPage
? $t('dataSourceDropdown.noSharedDataSources')
: $t('dataSourceDropdown.noDataSources')
}}
</slot> </slot>
</template> </template>
</Dropdown> </Dropdown>
@ -44,30 +50,24 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
dataSources: { sharedDataSources: {
type: Array, type: Array,
required: true, required: true,
}, },
localDataSources: {
type: Array,
required: false,
default: null,
},
small: { small: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
page: {
type: Object,
required: true,
},
}, },
computed: { computed: {
pageDataSources() { isOnSharedPage() {
return this.dataSources.filter( return this.localDataSources === null
({ page_id: pageId }) => pageId === this.page.id
)
},
sharedDataSources() {
return this.dataSources.filter(
({ page_id: pageId }) => pageId !== this.page.id
)
}, },
}, },
methods: { methods: {

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
:key="elementType.name" :key="elementType.name"
v-tooltip="disallowedTypeForAncestry ? disabledElementMessage : null" v-tooltip="disabled ? disabledMessage : null"
class="add-element-card" class="add-element-card"
:class="{ 'add-element-card--disabled': disabled }" :class="{ 'add-element-card--disabled': disabled }"
v-on="$listeners" v-on="$listeners"
@ -30,20 +30,15 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
parentType: {
type: Object,
required: false,
default: null,
},
loading: { loading: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
disallowedTypeForAncestry: { disabledMessage: {
type: Boolean, type: String,
required: false, required: false,
default: false, default: '',
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
@ -51,10 +46,5 @@ export default {
default: false, default: false,
}, },
}, },
computed: {
disabledElementMessage() {
return this.$t('addElementModal.disabledElementTooltip')
},
},
} }
</script> </script>

View file

@ -13,10 +13,9 @@
v-for="elementType in elementTypes" v-for="elementType in elementTypes"
:key="elementType.getType()" :key="elementType.getType()"
:element-type="elementType" :element-type="elementType"
:parent-type="parentElementType"
:disallowed-type-for-ancestry="isDisallowedByParent(elementType)"
:loading="addingElementType === elementType.getType()" :loading="addingElementType === elementType.getType()"
:disabled="isCardDisabled(elementType)" :disabled="isElementTypeDisabled(elementType)"
:disabled-message="getElementTypeDisabledMessage(elementType)"
@click="addElement(elementType)" @click="addElement(elementType)"
/> />
</div> </div>
@ -29,21 +28,18 @@ import AddElementCard from '@baserow/modules/builder/components/elements/AddElem
import { isSubstringOfStrings } from '@baserow/modules/core/utils/string' import { isSubstringOfStrings } from '@baserow/modules/core/utils/string'
import { notifyIf } from '@baserow/modules/core/utils/error' import { notifyIf } from '@baserow/modules/core/utils/error'
import { mapActions } from 'vuex' import { mapActions } from 'vuex'
import { PAGE_PLACES } from '../../enums'
export default { export default {
name: 'AddElementModal', name: 'AddElementModal',
components: { AddElementCard }, components: { AddElementCard },
mixins: [modal], mixins: [modal],
inject: ['builder', 'currentPage'],
props: { props: {
page: { page: {
type: Object, type: Object,
required: true, required: true,
}, },
elementTypesAllowed: {
type: Array,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
@ -51,6 +47,7 @@ export default {
placeInContainer: null, placeInContainer: null,
beforeId: null, beforeId: null,
parentElementId: null, parentElementId: null,
pagePlace: null,
addingElementType: null, addingElementType: null,
} }
}, },
@ -64,57 +61,101 @@ export default {
) )
) )
}, },
parentElementType() { sharedPage() {
const parentElement = this.$store.getters['element/getElementById']( return this.$store.getters['page/getSharedPage'](this.builder)
this.page, },
this.parentElementId parentElement() {
) if (this.parentElementId) {
return parentElement return this.$store.getters['element/getElementByIdInPages'](
? this.$registry.get('element', parentElement.type) [this.currentPage, this.sharedPage],
: null this.parentElementId
)
}
return null
},
beforeElement() {
if (this.beforeId) {
return this.$store.getters['element/getElementByIdInPages'](
[this.currentPage, this.sharedPage],
this.beforeId
)
}
return null
}, },
}, },
methods: { methods: {
isDisallowedByParent(elementType) { getElementTypeDisabledMessage(elementType) {
return ( if (elementType.getType() === this.addingElementType) {
this.elementTypesAllowed !== null && // This type is disabled while we add it.
!this.elementTypesAllowed.includes(elementType) return this.$t('addElementModal.elementInProgress')
) }
return elementType.isDisallowedReason({
builder: this.builder,
page: this.page,
placeInContainer: this.placeInContainer,
parentElement: this.parentElement,
beforeElement: this.beforeElement,
pagePlace: this.pagePlace,
})
}, },
isCardDisabled(elementType) { isElementTypeDisabled(elementType) {
const isAddingElementType = return !!this.getElementTypeDisabledMessage(elementType)
this.addingElementType !== null &&
elementType.getType() === this.addingElementType
return isAddingElementType || this.isDisallowedByParent(elementType)
}, },
...mapActions({ ...mapActions({
actionCreateElement: 'element/create', actionCreateElement: 'element/create',
}), }),
show({ placeInContainer, beforeId, parentElementId } = {}, ...args) { show(
{ placeInContainer, beforeId, parentElementId, pagePlace } = {},
...args
) {
this.placeInContainer = placeInContainer this.placeInContainer = placeInContainer
this.beforeId = beforeId this.beforeId = beforeId
this.parentElementId = parentElementId this.parentElementId = parentElementId
this.pagePlace = pagePlace
modal.methods.show.bind(this)(...args) modal.methods.show.bind(this)(...args)
}, },
async addElement(elementType) { async addElement(elementType) {
if (this.isCardDisabled(elementType)) { if (this.isElementTypeDisabled(elementType)) {
return false return false
} }
this.addingElementType = elementType.getType() this.addingElementType = elementType.getType()
const configuration = this.parentElementId
? { let beforeId = this.beforeId
parent_element_id: this.parentElementId, let destinationPage
place_in_container: this.placeInContainer,
} if (this.parentElementId) {
: null // The page must be the same as the parent one
destinationPage =
this.parentElement.page_id === this.currentPage.id
? this.currentPage
: this.sharedPage
} else {
// The page is forced by the element type page place
destinationPage =
elementType.getPagePlace() === PAGE_PLACES.CONTENT
? this.currentPage
: this.sharedPage
// If the before element doesn't belong to the same page we must ignore it
if (
this.beforeElement &&
this.beforeElement.page_id !== destinationPage.id
) {
beforeId = null
}
}
try { try {
await this.actionCreateElement({ await this.actionCreateElement({
page: this.page, page: destinationPage,
elementType: elementType.getType(), elementType: elementType.getType(),
beforeId: this.beforeId, beforeId,
configuration, values: {
parent_element_id: this.parentElementId,
place_in_container: this.placeInContainer,
},
}) })
this.$emit('element-added') this.$emit('element-added')

View file

@ -1,9 +1,12 @@
<template> <template>
<div class="add-element-zone" @click="!disabled && $emit('add-element')"> <div
class="add-element-zone"
:class="{ 'add-element-zone--disabled': disabled }"
>
<div <div
v-tooltip="disabled ? tooltip : null" v-tooltip="disabled ? tooltip : null"
class="add-element-zone__content" class="add-element-zone__button"
:class="{ 'add-element-zone__button--disabled': disabled }" @click="!disabled && $emit('add-element')"
> >
<i class="iconoir-plus add-element-zone__icon"></i> <i class="iconoir-plus add-element-zone__icon"></i>
</div> </div>

View file

@ -20,69 +20,62 @@
</span> </span>
</a> </a>
<a <a
v-if="isPlacementVisible(PLACEMENTS.LEFT)" v-if="isDirectionVisible(DIRECTIONS.LEFT)"
class="element-preview__menu-item" class="element-preview__menu-item"
:class="{ disabled: isPlacementDisabled(PLACEMENTS.LEFT) }" :class="{
@click=" 'element-preview__menu-item--disabled': !isAllowedDirection(
!isPlacementDisabled(PLACEMENTS.LEFT) && $emit('move', PLACEMENTS.LEFT) DIRECTIONS.LEFT
" ),
}"
@click="$emit('move', DIRECTIONS.LEFT)"
> >
<i class="iconoir-nav-arrow-left"></i> <i class="iconoir-nav-arrow-left"></i>
<span <span class="element-preview__menu-item-description">
v-if="!isPlacementDisabled(PLACEMENTS.LEFT)"
class="element-preview__menu-item-description"
>
{{ $t('elementMenu.moveLeft') }} {{ $t('elementMenu.moveLeft') }}
</span> </span>
</a> </a>
<a <a
v-if="isPlacementVisible(PLACEMENTS.RIGHT)" v-if="isDirectionVisible(DIRECTIONS.RIGHT)"
class="element-preview__menu-item" class="element-preview__menu-item"
:class="{ disabled: isPlacementDisabled(PLACEMENTS.RIGHT) }" :class="{
@click=" 'element-preview__menu-item--disabled': !isAllowedDirection(
!isPlacementDisabled(PLACEMENTS.RIGHT) && DIRECTIONS.RIGHT
$emit('move', PLACEMENTS.RIGHT) ),
" }"
@click="$emit('move', DIRECTIONS.RIGHT)"
> >
<i class="iconoir-nav-arrow-right"></i> <i class="iconoir-nav-arrow-right"></i>
<span <span class="element-preview__menu-item-description">
v-if="!isPlacementDisabled(PLACEMENTS.RIGHT)"
class="element-preview__menu-item-description"
>
{{ $t('elementMenu.moveRight') }} {{ $t('elementMenu.moveRight') }}
</span> </span>
</a> </a>
<a <a
v-if="isPlacementVisible(PLACEMENTS.BEFORE)" v-if="isDirectionVisible(DIRECTIONS.BEFORE)"
class="element-preview__menu-item" class="element-preview__menu-item"
:class="{ disabled: isPlacementDisabled(PLACEMENTS.BEFORE) }" :class="{
@click=" 'element-preview__menu-item--disabled': !isAllowedDirection(
!isPlacementDisabled(PLACEMENTS.BEFORE) && DIRECTIONS.BEFORE
$emit('move', PLACEMENTS.BEFORE) ),
" }"
@click="$emit('move', DIRECTIONS.BEFORE)"
> >
<i class="iconoir-nav-arrow-up"></i> <i class="iconoir-nav-arrow-up"></i>
<span <span class="element-preview__menu-item-description">
v-if="!isPlacementDisabled(PLACEMENTS.BEFORE)"
class="element-preview__menu-item-description"
>
{{ $t('elementMenu.moveUp') }} {{ $t('elementMenu.moveUp') }}
</span> </span>
</a> </a>
<a <a
v-if="isPlacementVisible(PLACEMENTS.AFTER)" v-if="isDirectionVisible(DIRECTIONS.AFTER)"
class="element-preview__menu-item" class="element-preview__menu-item"
:class="{ disabled: isPlacementDisabled(PLACEMENTS.AFTER) }" :class="{
@click=" 'element-preview__menu-item--disabled': !isAllowedDirection(
!isPlacementDisabled(PLACEMENTS.AFTER) && DIRECTIONS.AFTER
$emit('move', PLACEMENTS.AFTER) ),
" }"
@click="$emit('move', DIRECTIONS.AFTER)"
> >
<i class="iconoir-nav-arrow-down"></i> <i class="iconoir-nav-arrow-down"></i>
<span <span class="element-preview__menu-item-description">
v-if="!isPlacementDisabled(PLACEMENTS.AFTER)"
class="element-preview__menu-item-description"
>
{{ $t('elementMenu.moveDown') }} {{ $t('elementMenu.moveDown') }}
</span> </span>
</a> </a>
@ -96,7 +89,7 @@
</template> </template>
<script> <script>
import { PLACEMENTS } from '@baserow/modules/builder/enums' import { DIRECTIONS } from '@baserow/modules/builder/enums'
export default { export default {
name: 'ElementMenu', name: 'ElementMenu',
@ -111,26 +104,26 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
placements: { directions: {
type: Array, type: Array,
required: false, required: false,
default: () => [PLACEMENTS.BEFORE, PLACEMENTS.AFTER], default: () => [DIRECTIONS.BEFORE, DIRECTIONS.AFTER],
}, },
placementsDisabled: { allowedDirections: {
type: Array, type: Array,
required: false, required: false,
default: () => [], default: () => [],
}, },
}, },
computed: { computed: {
PLACEMENTS: () => PLACEMENTS, DIRECTIONS: () => DIRECTIONS,
}, },
methods: { methods: {
isPlacementVisible(placement) { isDirectionVisible(direction) {
return this.placements.includes(placement) return this.directions.includes(direction)
}, },
isPlacementDisabled(placement) { isAllowedDirection(direction) {
return this.placementsDisabled.includes(placement) return this.allowedDirections.includes(direction)
}, },
}, },
} }

View file

@ -20,38 +20,37 @@
v-show="isSelected" v-show="isSelected"
v-if="canCreate" v-if="canCreate"
class="element-preview__insert element-preview__insert--top" class="element-preview__insert element-preview__insert--top"
@click="showAddElementModal(PLACEMENTS.BEFORE)" @click="showAddElementModal(DIRECTIONS.BEFORE)"
/> />
<ElementMenu <ElementMenu
v-if="isSelected && canUpdate" v-if="isSelected && canUpdate"
:placements="placements" :directions="directions"
:placements-disabled="placementsDisabled" :allowed-directions="allowedMoveDirections"
:is-duplicating="isDuplicating" :is-duplicating="isDuplicating"
:has-parent="!!parentElement" :has-parent="!!parentElement"
@delete="deleteElement" @delete="deleteElement"
@move="$emit('move', $event)" @move="onMove"
@duplicate="duplicateElement" @duplicate="duplicateElement"
@select-parent="selectParentElement()" @select-parent="selectParentElement()"
/> />
<PageElement <PageElement
:element="element" :element="element"
:mode="mode" :mode="mode"
class="element--read-only" class="element--read-only"
:application-context-additions="applicationContextAdditions" :application-context-additions="applicationContextAdditions"
v-on="$listeners"
/> />
<InsertElementButton <InsertElementButton
v-show="isSelected" v-show="isSelected"
v-if="canCreate" v-if="canCreate"
class="element-preview__insert element-preview__insert--bottom" class="element-preview__insert element-preview__insert--bottom"
@click="showAddElementModal(PLACEMENTS.AFTER)" @click="showAddElementModal(DIRECTIONS.AFTER)"
/> />
<AddElementModal <AddElementModal
v-if="canCreate" v-if="canCreate"
ref="addElementModal" ref="addElementModal"
:element-types-allowed="elementTypesAllowed" :page="elementPage"
:page="page"
/> />
<i <i
@ -65,7 +64,7 @@
import ElementMenu from '@baserow/modules/builder/components/elements/ElementMenu' import ElementMenu from '@baserow/modules/builder/components/elements/ElementMenu'
import InsertElementButton from '@baserow/modules/builder/components/elements/InsertElementButton' import InsertElementButton from '@baserow/modules/builder/components/elements/InsertElementButton'
import PageElement from '@baserow/modules/builder/components/page/PageElement' import PageElement from '@baserow/modules/builder/components/page/PageElement'
import { PLACEMENTS } from '@baserow/modules/builder/enums' import { DIRECTIONS } from '@baserow/modules/builder/enums'
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal' import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal'
import { notifyIf } from '@baserow/modules/core/utils/error' import { notifyIf } from '@baserow/modules/core/utils/error'
import { mapActions, mapGetters } from 'vuex' import { mapActions, mapGetters } from 'vuex'
@ -85,27 +84,17 @@ export default {
InsertElementButton, InsertElementButton,
PageElement, PageElement,
}, },
inject: ['workspace', 'builder', 'page', 'mode'], inject: ['workspace', 'builder', 'mode', 'currentPage'],
props: { props: {
element: { element: {
type: Object, type: Object,
required: true, required: true,
}, },
isLastElement: {
type: Boolean,
required: false,
default: false,
},
isFirstElement: { isFirstElement: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
isRootElement: {
type: Boolean,
required: false,
default: false,
},
applicationContextAdditions: { applicationContextAdditions: {
type: Object, type: Object,
required: false, required: false,
@ -124,7 +113,23 @@ export default {
getClosestSiblingElement: 'element/getClosestSiblingElement', getClosestSiblingElement: 'element/getClosestSiblingElement',
loggedUser: 'userSourceUser/getUser', loggedUser: 'userSourceUser/getUser',
}), }),
elementPage() {
// We use the page from the element itself
return this.$store.getters['page/getById'](
this.builder,
this.element.page_id
)
},
isVisible() { isVisible() {
if (
!this.elementType.isVisible({
element: this.element,
currentPage: this.currentPage,
})
) {
return false
}
const isAuthenticated = this.$store.getters[ const isAuthenticated = this.$store.getters[
'userSourceUser/isAuthenticated' 'userSourceUser/isAuthenticated'
](this.builder) ](this.builder)
@ -151,13 +156,13 @@ export default {
return true return true
} }
}, },
PLACEMENTS: () => PLACEMENTS, DIRECTIONS: () => DIRECTIONS,
placements() { directions() {
return [ return [
PLACEMENTS.BEFORE, DIRECTIONS.BEFORE,
PLACEMENTS.AFTER, DIRECTIONS.AFTER,
PLACEMENTS.LEFT, DIRECTIONS.LEFT,
PLACEMENTS.RIGHT, DIRECTIONS.RIGHT,
] ]
}, },
parentOfElementSelected() { parentOfElementSelected() {
@ -165,24 +170,34 @@ export default {
return null return null
} }
return this.$store.getters['element/getElementById']( return this.$store.getters['element/getElementById'](
this.page, this.elementPage,
this.elementSelected.parent_element_id this.elementSelected.parent_element_id
) )
}, },
placementsDisabled() { elementsAround() {
const elementType = this.$registry.get('element', this.element.type) return this.elementType.getElementsAround({
return elementType.getPlacementsDisabled(this.page, this.element) builder: this.builder,
page: this.currentPage,
withSharedPage: true,
element: this.element,
})
}, },
elementTypesAllowed() { nextPlaces() {
return ( return this.elementType.getNextPlaces({
this.parentElementType?.childElementTypes(this.page, this.element) || builder: this.builder,
null page: this.elementPage,
) element: this.element,
})
},
allowedMoveDirections() {
return Object.entries(this.nextPlaces)
.filter(([, nextPlace]) => !!nextPlace)
.map(([direction]) => direction)
}, },
canCreate() { canCreate() {
return this.$hasPermission( return this.$hasPermission(
'builder.page.create_element', 'builder.page.create_element',
this.page, this.currentPage,
this.workspace.id this.workspace.id
) )
}, },
@ -200,7 +215,7 @@ export default {
if (!this.elementSelected) { if (!this.elementSelected) {
return [] return []
} }
return this.elementAncestors(this.page, this.elementSelected).map( return this.elementAncestors(this.elementPage, this.elementSelected).map(
({ id }) => id ({ id }) => id
) )
}, },
@ -215,7 +230,7 @@ export default {
return null return null
} }
return this.$store.getters['element/getElementById']( return this.$store.getters['element/getElementById'](
this.page, this.elementPage,
this.element.parent_element_id this.element.parent_element_id
) )
}, },
@ -224,15 +239,9 @@ export default {
? this.$registry.get('element', this.parentElement?.type) ? this.$registry.get('element', this.parentElement?.type)
: null : null
}, },
nextElement() {
return this.$store.getters['element/getNextElement'](
this.page,
this.element
)
},
inError() { inError() {
return this.elementType.isInError({ return this.elementType.isInError({
page: this.page, page: this.elementPage,
element: this.element, element: this.element,
builder: this.builder, builder: this.builder,
}) })
@ -284,6 +293,9 @@ export default {
actionDeleteElement: 'element/delete', actionDeleteElement: 'element/delete',
actionSelectElement: 'element/select', actionSelectElement: 'element/select',
}), }),
onMove(direction) {
this.$emit('move', { element: this.element, direction })
},
onSelect($event) { onSelect($event) {
// Here we check that the event has been emitted for this particular element // Here we check that the event has been emitted for this particular element
// If we found an intermediate DOM element with the class `element-preview`, // If we found an intermediate DOM element with the class `element-preview`,
@ -300,23 +312,32 @@ export default {
this.actionSelectElement({ element: this.element }) this.actionSelectElement({ element: this.element })
} }
}, },
showAddElementModal(placement) { showAddElementModal(direction) {
const rootElement = this.$store.getters['element/getAncestors'](
this.elementPage,
this.element,
{ includeSelf: true }
)[0]
const rootElementType = this.$registry.get('element', rootElement.type)
const pagePlace = rootElementType.getPagePlace()
this.$refs.addElementModal.show({ this.$refs.addElementModal.show({
placeInContainer: this.element.place_in_container, placeInContainer: this.element.place_in_container,
parentElementId: this.element.parent_element_id, parentElementId: this.element.parent_element_id,
beforeId: this.getBeforeId(placement), beforeId: this.getBeforeId(direction),
pagePlace,
}) })
}, },
getBeforeId(placement) { getBeforeId(direction) {
return placement === PLACEMENTS.BEFORE return direction === DIRECTIONS.BEFORE
? this.element.id ? this.element.id
: this.nextElement?.id || null : this.elementsAround[DIRECTIONS.AFTER]?.id || null
}, },
async duplicateElement() { async duplicateElement() {
this.isDuplicating = true this.isDuplicating = true
try { try {
await this.actionDuplicateElement({ await this.actionDuplicateElement({
page: this.page, page: this.elementPage,
elementId: this.element.id, elementId: this.element.id,
}) })
} catch (error) { } catch (error) {
@ -326,12 +347,15 @@ export default {
}, },
async deleteElement() { async deleteElement() {
try { try {
const siblingElementToSelect = this.getClosestSiblingElement( const siblingElementToSelect =
this.page, this.elementsAround[DIRECTIONS.AFTER] ||
this.elementSelected this.elementsAround[DIRECTIONS.BEFORE] ||
) this.elementsAround[DIRECTIONS.LEFT] ||
this.elementsAround[DIRECTIONS.RIGHT] ||
this.parentOfElementSelected
await this.actionDeleteElement({ await this.actionDeleteElement({
page: this.page, page: this.elementPage,
elementId: this.element.id, elementId: this.element.id,
}) })
if (siblingElementToSelect?.id) { if (siblingElementToSelect?.id) {

View file

@ -1,8 +1,5 @@
<template> <template>
<ul <ul class="elements-list">
v-auto-overflow-scroll
class="elements-list__items elements-list__items--no-max-height"
>
<ElementsListItem <ElementsListItem
v-for="element in filteredElements" v-for="element in filteredElements"
:key="element.id" :key="element.id"
@ -19,7 +16,6 @@ import ElementsListItem from '@baserow/modules/builder/components/elements/Eleme
export default { export default {
name: 'ElementsList', name: 'ElementsList',
components: { ElementsListItem }, components: { ElementsListItem },
inject: ['page'],
props: { props: {
elements: { elements: {
type: Array, type: Array,

View file

@ -1,15 +1,15 @@
<template> <template>
<li :key="element.id" class="elements-list__item"> <li
<a :key="element.id"
class="elements-list__item-link" class="elements-list-item"
:class="{ :class="{
'elements-list__item-link--selected': element.id === elementSelectedId, 'elements-list-item--selected': element.id === elementSelectedId,
}" }"
@click="$emit('select', element)" >
> <a class="elements-list-item__link" @click="$emit('select', element)">
<span class="elements-list__item-name"> <span class="elements-list-item__name">
<i :class="`${elementType.iconClass} elements-list__item-icon`"></i> <i :class="`${elementType.iconClass} elements-list-item__icon`"></i>
<span class="elements-list__item-name-text">{{ <span class="elements-list-item__name-text">{{
elementType.getDisplayName(element, applicationContext) elementType.getDisplayName(element, applicationContext)
}}</span> }}</span>
</span> </span>
@ -33,7 +33,7 @@ export default {
ElementsList: () => ElementsList: () =>
import('@baserow/modules/builder/components/elements/ElementsList'), import('@baserow/modules/builder/components/elements/ElementsList'),
}, },
inject: ['builder', 'page', 'mode'], inject: ['builder', 'mode'],
props: { props: {
element: { element: {
type: Object, type: Object,
@ -55,8 +55,18 @@ export default {
elementType() { elementType() {
return this.$registry.get('element', this.element.type) return this.$registry.get('element', this.element.type)
}, },
elementPage() {
// We use the page from the element itself
return this.$store.getters['page/getById'](
this.builder,
this.element.page_id
)
},
children() { children() {
return this.$store.getters['element/getChildren'](this.page, this.element) return this.$store.getters['element/getChildren'](
this.elementPage,
this.element
)
}, },
/** /**
* Responsible for returning elements to display in `ElementsList`. * Responsible for returning elements to display in `ElementsList`.
@ -76,7 +86,7 @@ export default {
applicationContext() { applicationContext() {
return { return {
builder: this.builder, builder: this.builder,
page: this.page, page: this.elementPage,
mode: this.mode, mode: this.mode,
element: this.element, element: this.element,
} }

View file

@ -75,7 +75,9 @@ export default {
} }
if (this.target === 'self' && this.url.startsWith('/')) { if (this.target === 'self' && this.url.startsWith('/')) {
event.preventDefault() event.preventDefault()
this.$router.push(this.url) if (this.$route.path !== this.url) {
this.$router.push(this.url)
}
} }
}, },
}, },

View file

@ -25,8 +25,13 @@
</slot> </slot>
</template> </template>
<template #empty-state> <template #empty-state>
<div class="ab-table__empty-message"> <div class="ab-table__empty-state">
{{ emptyStateMessage }} <template v-if="contentLoading">
<div class="loading-spinner" />
</template>
<template v-else>
{{ $t('abTable.empty') }}
</template>
</div> </div>
</template> </template>
</BaserowTable> </BaserowTable>

View file

@ -20,7 +20,7 @@
<script> <script>
export default { export default {
inject: ['builder', 'page'], inject: ['builder', 'currentPage'],
props: { props: {
element: { element: {
type: Object, type: Object,
@ -33,7 +33,7 @@ export default {
}, },
dataSource() { dataSource() {
return this.$store.getters['dataSource/getPagesDataSourceById']( return this.$store.getters['dataSource/getPagesDataSourceById'](
[this.page, this.sharedPage], [this.currentPage, this.sharedPage],
this.element.data_source_id this.element.data_source_id
) )
}, },

View file

@ -22,7 +22,7 @@
v-if="mode === 'editing'" v-if="mode === 'editing'"
:element="childCurrent" :element="childCurrent"
:application-context-additions="applicationContextAdditions" :application-context-additions="applicationContextAdditions"
@move="move(childCurrent, $event)" @move="$emit('move', $event)"
></ElementPreview> ></ElementPreview>
<PageElement <PageElement
v-else v-else
@ -35,21 +35,21 @@
<AddElementZone <AddElementZone
v-else-if=" v-else-if="
mode === 'editing' && mode === 'editing' &&
$hasPermission('builder.page.create_element', page, workspace.id) $hasPermission(
'builder.page.create_element',
elementPage,
workspace.id
)
" "
:page="elementPage"
@add-element="showAddElementModal(columnIndex)" @add-element="showAddElementModal(columnIndex)"
/> />
</div> </div>
<AddElementModal <AddElementModal ref="addElementModal" :page="elementPage" />
ref="addElementModal"
:page="page"
:element-types-allowed="elementType.childElementTypes(page, element)"
/>
</div> </div>
</template> </template>
<script> <script>
import { mapActions } from 'vuex'
import _ from 'lodash' import _ from 'lodash'
import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone' import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone'
@ -58,7 +58,6 @@ import containerElement from '@baserow/modules/builder/mixins/containerElement'
import PageElement from '@baserow/modules/builder/components/page/PageElement' import PageElement from '@baserow/modules/builder/components/page/PageElement'
import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview' import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview'
import { VERTICAL_ALIGNMENTS } from '@baserow/modules/builder/enums' import { VERTICAL_ALIGNMENTS } from '@baserow/modules/builder/enums'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { dimensionMixin } from '@baserow/modules/core/mixins/dimensions' import { dimensionMixin } from '@baserow/modules/core/mixins/dimensions'
export default { export default {
@ -136,26 +135,12 @@ export default {
this.dimensions.targetElement = this.$el.parentElement this.dimensions.targetElement = this.$el.parentElement
}, },
methods: { methods: {
...mapActions({
actionMoveElement: 'element/moveElement',
}),
showAddElementModal(columnIndex) { showAddElementModal(columnIndex) {
this.$refs.addElementModal.show({ this.$refs.addElementModal.show({
placeInContainer: `${columnIndex}`, placeInContainer: `${columnIndex}`,
parentElementId: this.element.id, parentElementId: this.element.id,
}) })
}, },
async move(element, placement) {
try {
await this.actionMoveElement({
page: this.page,
element,
placement,
})
} catch (error) {
notifyIf(error)
}
},
}, },
} }
</script> </script>

View file

@ -4,14 +4,13 @@
v-if=" v-if="
mode === 'editing' && mode === 'editing' &&
children.length === 0 && children.length === 0 &&
$hasPermission('builder.page.create_element', page, workspace.id) $hasPermission('builder.page.create_element', currentPage, workspace.id)
" "
> >
<AddElementZone @add-element="showAddElementModal"></AddElementZone> <AddElementZone @add-element="showAddElementModal"></AddElementZone>
<AddElementModal <AddElementModal
ref="addElementModal" ref="addElementModal"
:page="page" :page="elementPage"
:element-types-allowed="elementType.childElementTypes(page, element)"
></AddElementModal> ></AddElementModal>
</div> </div>
<div v-else> <div v-else>
@ -20,7 +19,7 @@
v-if="mode === 'editing'" v-if="mode === 'editing'"
:key="child.id" :key="child.id"
:element="child" :element="child"
@move="moveElement(child, $event)" @move="$emit('move', $event)"
/> />
<PageElement <PageElement
v-else v-else
@ -42,15 +41,12 @@
</template> </template>
<script> <script>
import { mapActions } from 'vuex'
import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone.vue' import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone.vue'
import containerElement from '@baserow/modules/builder/mixins/containerElement' import containerElement from '@baserow/modules/builder/mixins/containerElement'
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal.vue' import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal.vue'
import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview.vue' import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview.vue'
import PageElement from '@baserow/modules/builder/components/page/PageElement.vue' import PageElement from '@baserow/modules/builder/components/page/PageElement.vue'
import { ensureString } from '@baserow/modules/core/utils/validator' import { ensureString } from '@baserow/modules/core/utils/validator'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default { export default {
name: 'FormContainerElement', name: 'FormContainerElement',
@ -64,7 +60,6 @@ export default {
props: { props: {
/** /**
* @type {Object} * @type {Object}
* @property button_color - The submit button's color.
* @property submit_button_label - The label of the submit button * @property submit_button_label - The label of the submit button
* @property reset_initial_values_post_submission - Whether to reset the form * @property reset_initial_values_post_submission - Whether to reset the form
* elements to their initial value or not, following a successful submission. * elements to their initial value or not, following a successful submission.
@ -83,7 +78,7 @@ export default {
}, },
getFormElementDescendants() { getFormElementDescendants() {
const descendants = this.$store.getters['element/getDescendants']( const descendants = this.$store.getters['element/getDescendants'](
this.page, this.elementPage,
this.element this.element
) )
return descendants return descendants
@ -107,7 +102,7 @@ export default {
recordIndexPath recordIndexPath
) )
return this.$store.getters['formData/getElementInvalid']( return this.$store.getters['formData/getElementInvalid'](
this.page, this.elementPage,
uniqueElementId uniqueElementId
) )
} }
@ -115,9 +110,6 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions({
actionMoveElement: 'element/moveElement',
}),
/* /*
* Responsible for marking all form element descendents in this form container * Responsible for marking all form element descendents in this form container
* as touched, or not touched, depending on what we're achieving in validation. * as touched, or not touched, depending on what we're achieving in validation.
@ -131,7 +123,7 @@ export default {
recordIndexPath recordIndexPath
) )
this.$store.dispatch('formData/setElementTouched', { this.$store.dispatch('formData/setElementTouched', {
page: this.page, page: this.elementPage,
wasTouched, wasTouched,
uniqueElementId, uniqueElementId,
}) })
@ -169,7 +161,7 @@ export default {
), ),
} }
this.$store.dispatch('formData/setFormData', { this.$store.dispatch('formData/setFormData', {
page: this.page, page: this.elementPage,
payload, payload,
uniqueElementId, uniqueElementId,
}) })
@ -197,17 +189,6 @@ export default {
parentElementId: this.element.id, parentElementId: this.element.id,
}) })
}, },
async moveElement(element, placement) {
try {
await this.actionMoveElement({
page: this.page,
element,
placement,
})
} catch (error) {
notifyIf(error)
}
},
}, },
} }
</script> </script>

View file

@ -0,0 +1,82 @@
<template>
<div>
<template
v-if="
mode === 'editing' &&
children.length === 0 &&
$hasPermission('builder.page.create_element', currentPage, workspace.id)
"
>
<AddElementZone @add-element="showAddElementModal"></AddElementZone>
<AddElementModal
ref="addElementModal"
:page="elementPage"
></AddElementModal>
</template>
<template v-else>
<template v-for="child in children">
<ElementPreview
v-if="mode === 'editing'"
:key="child.id"
:element="child"
@move="$emit('move', $event)"
/>
<PageElement
v-else
:key="`${child.id}else`"
:element="child"
:mode="mode"
/>
</template>
</template>
</div>
</template>
<script>
import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone.vue'
import containerElement from '@baserow/modules/builder/mixins/containerElement'
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal.vue'
import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview.vue'
import PageElement from '@baserow/modules/builder/components/page/PageElement.vue'
import { ensureString } from '@baserow/modules/core/utils/validator'
export default {
name: 'MultiPageContainerElement',
components: {
PageElement,
ElementPreview,
AddElementModal,
AddElementZone,
},
mixins: [containerElement],
props: {
/**
* @type {Object}
* @property page_position - [header|footer|left|right]
* Position of this element on the page.
* @property behaviour - [scroll|fixed|sticky]
* How this element follow the scroll of the page.
* @property shared_type - [all_pages|only_pages|except_pages] Type of share
* @property pages - List of pages the element is visible or excluded depending on
* the share_type.
*/
element: {
type: Object,
required: true,
},
},
computed: {
submitButtonLabelResolved() {
return ensureString(this.resolveFormula(this.element.submit_button_label))
},
},
methods: {
showAddElementModal() {
this.$refs.addElementModal.show({
placeInContainer: null,
parentElementId: this.element.id,
})
},
},
}
</script>

View file

@ -34,7 +34,7 @@
index, index,
], ],
}" }"
@move="moveElement(child, $event)" @move="$emit('move', $event)"
/> />
<!-- Other iterations are not editable --> <!-- Other iterations are not editable -->
<!-- Override the mode so that any children are in public mode --> <!-- Override the mode so that any children are in public mode -->
@ -68,10 +68,7 @@
></AddElementZone> ></AddElementZone>
<AddElementModal <AddElementModal
ref="addElementModal" ref="addElementModal"
:page="page" :page="elementPage"
:element-types-allowed="
elementType.childElementTypes(page, element)
"
></AddElementModal> ></AddElementModal>
</template> </template>
</template> </template>
@ -92,10 +89,7 @@
></AddElementZone> ></AddElementZone>
<AddElementModal <AddElementModal
ref="addElementModal" ref="addElementModal"
:page="page" :page="elementPage"
:element-types-allowed="
elementType.childElementTypes(page, element)
"
></AddElementModal> ></AddElementModal>
</template> </template>
<!-- We have no contents, but we do have children in edit mode --> <!-- We have no contents, but we do have children in edit mode -->
@ -106,7 +100,7 @@
v-for="child in children" v-for="child in children"
:key="child.id" :key="child.id"
:element="child" :element="child"
@move="moveElement(child, $event)" @move="$emit('move', $event)"
/> />
</template> </template>
</template> </template>
@ -127,7 +121,7 @@
</template> </template>
<script> <script>
import { mapActions, mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone' import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone'
import containerElement from '@baserow/modules/builder/mixins/containerElement' import containerElement from '@baserow/modules/builder/mixins/containerElement'
@ -135,7 +129,6 @@ import collectionElement from '@baserow/modules/builder/mixins/collectionElement
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal' import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal'
import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview' import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview'
import PageElement from '@baserow/modules/builder/components/page/PageElement' import PageElement from '@baserow/modules/builder/components/page/PageElement'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { ensureString } from '@baserow/modules/core/utils/validator' import { ensureString } from '@baserow/modules/core/utils/validator'
import { RepeatElementType } from '@baserow/modules/builder/elementTypes' import { RepeatElementType } from '@baserow/modules/builder/elementTypes'
import CollectionElementHeader from '@baserow/modules/builder/components/elements/components/CollectionElementHeader' import CollectionElementHeader from '@baserow/modules/builder/components/elements/components/CollectionElementHeader'
@ -173,7 +166,7 @@ export default {
}, },
repeatElementIsNested() { repeatElementIsNested() {
return this.elementType.hasAncestorOfType( return this.elementType.hasAncestorOfType(
this.page, this.elementPage,
this.element, this.element,
RepeatElementType.getType() RepeatElementType.getType()
) )
@ -212,26 +205,12 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions({
actionMoveElement: 'element/moveElement',
}),
showAddElementModal() { showAddElementModal() {
this.$refs.addElementModal.show({ this.$refs.addElementModal.show({
placeInContainer: null, placeInContainer: null,
parentElementId: this.element.id, parentElementId: this.element.id,
}) })
}, },
async moveElement(element, placement) {
try {
await this.actionMoveElement({
page: this.page,
element,
placement,
})
} catch (error) {
notifyIf(error)
}
},
}, },
} }
</script> </script>

View file

@ -31,7 +31,7 @@ export default {
) )
const workflowActions = this.$store.getters[ const workflowActions = this.$store.getters[
'workflowAction/getElementWorkflowActions' 'workflowAction/getElementWorkflowActions'
](this.page, this.element.id) ](this.elementPage, this.element.id)
return workflowActions return workflowActions
.filter((wa) => wa.event === this.eventName) .filter((wa) => wa.event === this.eventName)
.some((workflowAction) => .some((workflowAction) =>

View file

@ -31,7 +31,7 @@
<div <div
v-if="allowAllRolesExceptSelected || disallowAllRolesExceptSelected" v-if="allowAllRolesExceptSelected || disallowAllRolesExceptSelected"
class="visibility-form__role-checkbox-container" class="visibility-form__role-list"
> >
<template v-if="loadingRoles"> <template v-if="loadingRoles">
<div class="loading margin-bottom-1"></div> <div class="loading margin-bottom-1"></div>
@ -40,7 +40,7 @@
<div <div
v-for="roleName in allRoles" v-for="roleName in allRoles"
:key="roleName" :key="roleName"
class="visibility-form__role-checkbox-div" class="visibility-form__role-checkbox"
> >
<Checkbox <Checkbox
:checked="isChecked(roleName)" :checked="isChecked(roleName)"
@ -50,14 +50,11 @@
</Checkbox> </Checkbox>
</div> </div>
<div class="visibility-form__role-links"> <div class="visibility-form__actions">
<a @click.prevent="selectAllRoles"> <a @click.prevent="selectAllRoles">
{{ $t('visibilityForm.rolesSelectAll') }} {{ $t('visibilityForm.rolesSelectAll') }}
</a> </a>
<a <a @click.prevent="deselectAllRoles">
class="visibility-form__role-links-deselect-all"
@click.prevent="deselectAllRoles"
>
{{ $t('visibilityForm.rolesDeselectAll') }} {{ $t('visibilityForm.rolesDeselectAll') }}
</a> </a>
</div> </div>
@ -79,6 +76,7 @@
<script> <script>
import visibilityForm from '@baserow/modules/builder/mixins/visibilityForm' import visibilityForm from '@baserow/modules/builder/mixins/visibilityForm'
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import { import {
VISIBILITY_ALL, VISIBILITY_ALL,
@ -87,7 +85,7 @@ import {
export default { export default {
name: 'VisibilityForm', name: 'VisibilityForm',
mixins: [visibilityForm], mixins: [elementForm, visibilityForm],
data() { data() {
return { return {
values: { values: {

View file

@ -228,7 +228,7 @@ export default {
CHOICE_OPTION_TYPES: () => CHOICE_OPTION_TYPES, CHOICE_OPTION_TYPES: () => CHOICE_OPTION_TYPES,
element() { element() {
return this.$store.getters['element/getElementById']( return this.$store.getters['element/getElementById'](
this.page, this.elementPage,
this.values.id this.values.id
) )
}, },

View file

@ -0,0 +1,131 @@
<template>
<form @submit.prevent @keydown.enter.prevent>
<FormGroup
small-label
:label="$t('multiPageContainerElementForm.display')"
class="margin-bottom-2"
required
>
<Dropdown v-model="computedPageShareType" :show-search="false" small>
<DropdownItem
v-for="item in pageShareTypes"
:key="item.value"
:name="item.label"
:value="item.value"
>
{{ item.label }}
</DropdownItem>
</Dropdown>
<template v-if="values.share_type !== 'all'">
<div class="multi-page-container-element-form__page-list">
<div
v-for="page in pages"
:key="page.id"
class="multi-page-container-element-form__page-checkbox"
>
<Checkbox
:checked="values.pages.includes(page.id)"
@input="togglePage(page)"
>
{{ page.name }}
</Checkbox>
</div>
<div class="multi-page-container-element-form__actions">
<a @click.prevent="selectAll">
{{ $t('multiPageContainerElementForm.selectAll') }}
</a>
<a @click.prevent="deselectAll">
{{ $t('multiPageContainerElementForm.deselectAll') }}
</a>
</div>
</div>
</template>
</FormGroup>
</form>
</template>
<script>
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import { SHARE_TYPES } from '@baserow/modules/builder/enums'
export default {
name: 'MultiPageContainerElementForm',
mixins: [elementForm],
data() {
return {
values: {
share_type: '',
pages: [],
styles: {},
},
allowedValues: ['share_type', 'pages', 'styles'],
}
},
computed: {
computedPageShareType: {
get() {
return this.values.share_type
},
set(newValue) {
if (
[SHARE_TYPES.ONLY, SHARE_TYPES.EXCEPT].includes(newValue) &&
newValue !== this.values.share_type
) {
if (![SHARE_TYPES.ALL, undefined].includes(this.values.share_type)) {
// We want to invert the page selection if we change from except <-> only
this.values.pages = this.pageIds.filter(
(id) => !this.values.pages.includes(id)
)
} else {
// Otherwise we want to select all or none.
this.values.pages =
newValue === SHARE_TYPES.ONLY ? [...this.pageIds] : []
}
}
this.values.share_type = newValue
},
},
pageShareTypes() {
return [
{
label: this.$t('pageShareType.all'),
value: SHARE_TYPES.ALL,
},
{
label: this.$t('pageShareType.only'),
value: SHARE_TYPES.ONLY,
},
{
label: this.$t('pageShareType.except'),
value: SHARE_TYPES.EXCEPT,
},
]
},
pages() {
return this.$store.getters['page/getVisiblePages'](this.builder)
},
pageIds() {
return this.pages.map(({ id }) => id)
},
},
methods: {
togglePage(page) {
if (!this.values.pages.includes(page.id)) {
this.values.pages.push(page.id)
} else {
this.values.pages = this.values.pages.filter(
(pageId) => pageId !== page.id
)
}
},
selectAll() {
this.values.pages = this.pageIds
},
deselectAll() {
this.values.pages = []
},
},
}
</script>

View file

@ -15,8 +15,8 @@
<DataSourceDropdown <DataSourceDropdown
v-model="values.data_source_id" v-model="values.data_source_id"
small small
:data-sources="listDataSources" :shared-data-sources="listSharedDataSources"
:page="page" :local-data-sources="listLocalDataSources"
> >
<template #chooseValueState> <template #chooseValueState>
{{ $t('recordSelectorElementForm.noDataSourceMessage') }} {{ $t('recordSelectorElementForm.noDataSourceMessage') }}
@ -186,10 +186,18 @@ export default {
}, },
computed: { computed: {
// For now, RecordSelector only supports data sources that return arrays // For now, RecordSelector only supports data sources that return arrays
listDataSources() { listLocalDataSources() {
return this.dataSources.filter( if (this.localDataSources === null) {
return null
}
return this.localDataSources.filter(
(dataSource) =>
this.$registry.get('service', dataSource.type).returnsList
)
},
listSharedDataSources() {
return this.sharedDataSources.filter(
(dataSource) => (dataSource) =>
dataSource.type &&
this.$registry.get('service', dataSource.type).returnsList this.$registry.get('service', dataSource.type).returnsList
) )
}, },

View file

@ -10,8 +10,8 @@
<DataSourceDropdown <DataSourceDropdown
v-model="values.data_source_id" v-model="values.data_source_id"
small small
:data-sources="dataSources" :shared-data-sources="sharedDataSources"
:page="page" :local-data-sources="localDataSources"
> >
<template #chooseValueState> <template #chooseValueState>
{{ $t('collectionElementForm.noDataSourceMessage') }} {{ $t('collectionElementForm.noDataSourceMessage') }}
@ -150,7 +150,6 @@
<script> <script>
import _ from 'lodash' import _ from 'lodash'
import { required, integer, minValue, maxValue } from 'vuelidate/lib/validators' import { required, integer, minValue, maxValue } from 'vuelidate/lib/validators'
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import collectionElementForm from '@baserow/modules/builder/mixins/collectionElementForm' import collectionElementForm from '@baserow/modules/builder/mixins/collectionElementForm'
import DeviceSelector from '@baserow/modules/builder/components/page/header/DeviceSelector.vue' import DeviceSelector from '@baserow/modules/builder/components/page/header/DeviceSelector.vue'
import { mapActions, mapGetters } from 'vuex' import { mapActions, mapGetters } from 'vuex'
@ -170,7 +169,7 @@ export default {
InjectedFormulaInput, InjectedFormulaInput,
ServiceSchemaPropertySelector, ServiceSchemaPropertySelector,
}, },
mixins: [elementForm, collectionElementForm], mixins: [collectionElementForm],
inject: ['applicationContext'], inject: ['applicationContext'],
data() { data() {
return { return {

View file

@ -17,8 +17,8 @@
<DataSourceDropdown <DataSourceDropdown
v-model="computedDataSourceId" v-model="computedDataSourceId"
small small
:data-sources="dataSources" :shared-data-sources="sharedDataSources"
:page="page" :local-data-sources="localDataSources"
> >
<template #chooseValueState> <template #chooseValueState>
{{ $t('collectionElementForm.noDataSourceMessage') }} {{ $t('collectionElementForm.noDataSourceMessage') }}
@ -259,7 +259,6 @@ import {
minValue, minValue,
maxValue, maxValue,
} from 'vuelidate/lib/validators' } from 'vuelidate/lib/validators'
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import collectionElementForm from '@baserow/modules/builder/mixins/collectionElementForm' import collectionElementForm from '@baserow/modules/builder/mixins/collectionElementForm'
import { TABLE_ORIENTATION } from '@baserow/modules/builder/enums' import { TABLE_ORIENTATION } from '@baserow/modules/builder/enums'
import DeviceSelector from '@baserow/modules/builder/components/page/header/DeviceSelector.vue' import DeviceSelector from '@baserow/modules/builder/components/page/header/DeviceSelector.vue'
@ -279,7 +278,7 @@ export default {
DeviceSelector, DeviceSelector,
CustomStyle, CustomStyle,
}, },
mixins: [elementForm, collectionElementForm], mixins: [collectionElementForm],
data() { data() {
return { return {
allowedValues: [ allowedValues: [

View file

@ -60,7 +60,6 @@ export default {
name: 'PropertyOptionForm', name: 'PropertyOptionForm',
components: { BaserowTable }, components: { BaserowTable },
mixins: [form], mixins: [form],
inject: ['page'],
props: { props: {
dataSource: { dataSource: {
type: Object, type: Object,

View file

@ -78,7 +78,7 @@ export default {
name: 'Event', name: 'Event',
components: { WorkflowAction }, components: { WorkflowAction },
mixins: [applicationContext], mixins: [applicationContext],
inject: ['workspace', 'builder', 'page'], inject: ['workspace', 'builder', 'elementPage'],
props: { props: {
event: { event: {
type: Event, type: Event,
@ -128,7 +128,7 @@ export default {
this.addingAction = true this.addingAction = true
try { try {
await this.actionCreateWorkflowAction({ await this.actionCreateWorkflowAction({
page: this.page, page: this.elementPage,
workflowActionType: DEFAULT_WORKFLOW_ACTION_TYPE, workflowActionType: DEFAULT_WORKFLOW_ACTION_TYPE,
eventType: this.event.name, eventType: this.event.name,
configuration: { configuration: {
@ -143,7 +143,7 @@ export default {
async deleteWorkflowAction(workflowAction) { async deleteWorkflowAction(workflowAction) {
try { try {
await this.actionDeleteWorkflowAction({ await this.actionDeleteWorkflowAction({
page: this.page, page: this.elementPage,
workflowAction, workflowAction,
}) })
} catch (error) { } catch (error) {
@ -153,7 +153,7 @@ export default {
async orderWorkflowActions(order) { async orderWorkflowActions(order) {
try { try {
await this.actionOrderWorkflowActions({ await this.actionOrderWorkflowActions({
page: this.page, page: this.elementPage,
element: this.element, element: this.element,
order, order,
}) })

View file

@ -29,7 +29,7 @@ export default {
mixins: [modal], mixins: [modal],
provide() { provide() {
return { return {
page: null, currentPage: null,
builder: this.builder, builder: this.builder,
workspace: this.workspace, workspace: this.workspace,
} }

View file

@ -1,5 +1,14 @@
<template> <template>
<ThemeProvider class="page"> <ThemeProvider class="page">
<PageElement
v-for="element in headerElements"
:key="element.id"
:element="element"
:mode="mode"
:application-context-additions="{
recordIndexPath: [],
}"
/>
<PageElement <PageElement
v-for="element in elements" v-for="element in elements"
:key="element.id" :key="element.id"
@ -9,6 +18,15 @@
recordIndexPath: [], recordIndexPath: [],
}" }"
/> />
<PageElement
v-for="element in footerElements"
:key="element.id"
:element="element"
:mode="mode"
:application-context-additions="{
recordIndexPath: [],
}"
/>
</ThemeProvider> </ThemeProvider>
</template> </template>
@ -17,16 +35,13 @@ import PageElement from '@baserow/modules/builder/components/page/PageElement'
import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider' import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider'
import { dimensionMixin } from '@baserow/modules/core/mixins/dimensions' import { dimensionMixin } from '@baserow/modules/core/mixins/dimensions'
import _ from 'lodash' import _ from 'lodash'
import { PAGE_PLACES } from '@baserow/modules/builder/enums'
export default { export default {
components: { ThemeProvider, PageElement }, components: { ThemeProvider, PageElement },
mixins: [dimensionMixin], mixins: [dimensionMixin],
inject: ['builder', 'mode'], inject: ['builder', 'mode'],
props: { props: {
page: {
type: Object,
required: true,
},
path: { path: {
type: String, type: String,
required: true, required: true,
@ -39,6 +54,26 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
sharedElements: {
type: Array,
required: true,
},
},
computed: {
headerElements() {
return this.sharedElements.filter(
(element) =>
this.$registry.get('element', element.type).getPagePlace() ===
PAGE_PLACES.HEADER
)
},
footerElements() {
return this.sharedElements.filter(
(element) =>
this.$registry.get('element', element.type).getPagePlace() ===
PAGE_PLACES.FOOTER
)
},
}, },
watch: { watch: {
'dimensions.width': { 'dimensions.width': {

View file

@ -17,12 +17,14 @@
<div class="element__inner-wrapper"> <div class="element__inner-wrapper">
<component <component
:is="component" :is="component"
:key="element._.uid"
:element="element" :element="element"
:children="children"
:application-context-additions="{ :application-context-additions="{
element, element,
page: elementPage,
}" }"
class="element" class="element"
v-on="$listeners"
/> />
</div> </div>
</div> </div>
@ -49,9 +51,9 @@ import { mapGetters } from 'vuex'
export default { export default {
name: 'PageElement', name: 'PageElement',
mixins: [applicationContextMixin], mixins: [applicationContextMixin],
inject: ['builder', 'page', 'mode'], inject: ['builder', 'mode', 'currentPage'],
provide() { provide() {
return { mode: this.elementMode } return { mode: this.elementMode, elementPage: this.elementPage }
}, },
props: { props: {
element: { element: {
@ -79,16 +81,23 @@ export default {
this.elementMode === 'editing' ? 'editComponent' : 'component' this.elementMode === 'editing' ? 'editComponent' : 'component'
return elementType[componentName] return elementType[componentName]
}, },
children() {
return this.$store.getters['element/getChildren'](this.page, this.element)
},
...mapGetters({ ...mapGetters({
loggedUser: 'userSourceUser/getUser', loggedUser: 'userSourceUser/getUser',
}), }),
elementPage() {
// We use the page from the element itself
return this.$store.getters['page/getById'](
this.builder,
this.element.page_id
)
},
elementType() {
return this.$registry.get('element', this.element.type)
},
isVisible() { isVisible() {
const elementType = this.$registry.get('element', this.element.type) const elementType = this.$registry.get('element', this.element.type)
const isInError = elementType.isInError({ const isInError = elementType.isInError({
page: this.page, page: this.elementPage,
element: this.element, element: this.element,
builder: this.builder, builder: this.builder,
}) })
@ -97,6 +106,15 @@ export default {
return false return false
} }
if (
!this.elementType.isVisible({
element: this.element,
currentPage: this.currentPage,
})
) {
return false
}
const isAuthenticated = this.$store.getters[ const isAuthenticated = this.$store.getters[
'userSourceUser/isAuthenticated' 'userSourceUser/isAuthenticated'
](this.builder) ](this.builder)

View file

@ -1,10 +1,10 @@
<template> <template>
<ThemeProvider <div
class="page-preview__wrapper" class="page-preview__wrapper"
:class="`page-preview__wrapper--${deviceType.type}`" :class="`page-preview__wrapper--${deviceType.type}`"
@click.self="actionSelectElement({ element: null })" @click.self="actionSelectElement({ element: null })"
> >
<PreviewNavigationBar :page="page" :style="{ maxWidth }" /> <PreviewNavigationBar :page="currentPage" :style="{ maxWidth }" />
<div ref="preview" class="page-preview" :style="{ 'max-width': maxWidth }"> <div ref="preview" class="page-preview" :style="{ 'max-width': maxWidth }">
<div <div
ref="previewScaled" ref="previewScaled"
@ -12,36 +12,101 @@
tabindex="0" tabindex="0"
@keydown="handleKeyDown" @keydown="handleKeyDown"
> >
<CallToAction <ThemeProvider class="page">
v-if="!elements.length" <template v-if="headerElements.length !== 0">
class="page-preview__empty" <header
icon="baserow-icon-plus" class="page__header"
icon-color="neutral" :class="{
icon-size="large" 'page__header--element-selected':
icon-rounded pageSectionWithSelectedElement === 'header',
@click="$refs.addElementModal.show()" }"
> >
{{ $t('pagePreview.emptyMessage') }} <ElementPreview
</CallToAction> v-for="(element, index) in headerElements"
<div v-else class="page"> :key="element.id"
<ElementPreview :element="element"
v-for="(element, index) in elements" :is-first-element="index === 0"
:key="element.id" :is-copying="copyingElementIndex === index"
is-root-element :application-context-additions="{
:element="element" recordIndexPath: [],
:is-first-element="index === 0" }"
:is-last-element="index === elements.length - 1" @move="moveElement($event)"
:is-copying="copyingElementIndex === index" />
:application-context-additions="{ </header>
recordIndexPath: [], <div class="page-preview__separator">
}" <span class="page-preview__separator-label">
@move="moveElement($event)" {{ $t('pagePreview.header') }}
/> </span>
</div> </div>
</template>
<template v-if="elements.length === 0">
<CallToAction
class="page-preview__empty"
icon="baserow-icon-plus"
icon-color="neutral"
icon-size="large"
icon-rounded
@click="$refs.addElementModal.show()"
>
{{ $t('pagePreview.emptyMessage') }}
</CallToAction>
</template>
<template v-else>
<div
class="page__content"
:class="{
'page__content--element-selected':
pageSectionWithSelectedElement === 'content',
}"
>
<ElementPreview
v-for="(element, index) in elements"
:key="element.id"
:element="element"
:is-first-element="index === 0 && headerElements.length === 0"
:is-copying="copyingElementIndex === index"
:application-context-additions="{
recordIndexPath: [],
}"
@move="moveElement($event)"
/>
</div>
</template>
<template v-if="footerElements.length !== 0">
<div class="page-preview__separator">
<span class="page-preview__separator-label">
{{ $t('pagePreview.footer') }}
</span>
</div>
<footer
class="page__footer"
:class="{
'page__footer--element-selected':
pageSectionWithSelectedElement === 'footer',
}"
>
<ElementPreview
v-for="(element, index) in footerElements"
:key="element.id"
:element="element"
:is-first-element="
index === 0 &&
headerElements.length === 0 &&
elements.length === 0
"
:is-copying="copyingElementIndex === index"
:application-context-additions="{
recordIndexPath: [],
}"
@move="moveElement($event)"
/>
</footer>
</template>
</ThemeProvider>
</div> </div>
<AddElementModal ref="addElementModal" :page="page" /> <AddElementModal ref="addElementModal" :page="currentPage" />
</div> </div>
</ThemeProvider> </div>
</template> </template>
<script> <script>
@ -50,7 +115,7 @@ import { mapActions, mapGetters } from 'vuex'
import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview' import ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview'
import { notifyIf } from '@baserow/modules/core/utils/error' import { notifyIf } from '@baserow/modules/core/utils/error'
import PreviewNavigationBar from '@baserow/modules/builder/components/page/PreviewNavigationBar' import PreviewNavigationBar from '@baserow/modules/builder/components/page/PreviewNavigationBar'
import { PLACEMENTS } from '@baserow/modules/builder/enums' import { DIRECTIONS, PAGE_PLACES } from '@baserow/modules/builder/enums'
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal.vue' import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal.vue'
import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider.vue' import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider.vue'
@ -62,7 +127,7 @@ export default {
ElementPreview, ElementPreview,
PreviewNavigationBar, PreviewNavigationBar,
}, },
inject: ['page', 'workspace'], inject: ['builder', 'currentPage', 'workspace'],
data() { data() {
return { return {
// The element that is currently being copied // The element that is currently being copied
@ -73,7 +138,7 @@ export default {
} }
}, },
computed: { computed: {
PLACEMENTS: () => PLACEMENTS, DIRECTIONS: () => DIRECTIONS,
...mapGetters({ ...mapGetters({
deviceTypeSelected: 'page/getDeviceTypeSelected', deviceTypeSelected: 'page/getDeviceTypeSelected',
elementSelected: 'element/getSelected', elementSelected: 'element/getSelected',
@ -81,11 +146,84 @@ export default {
getClosestSiblingElement: 'element/getClosestSiblingElement', getClosestSiblingElement: 'element/getClosestSiblingElement',
}), }),
elements() { elements() {
return this.$store.getters['element/getRootElements'](this.page) return this.$store.getters['element/getRootElements'](this.currentPage)
},
sharedPage() {
return this.$store.getters['page/getSharedPage'](this.builder)
},
sharedElements() {
return this.$store.getters['element/getRootElements'](this.sharedPage)
},
headerElements() {
return this.sharedElements.filter(
(element) =>
this.$registry.get('element', element.type).getPagePlace() ===
PAGE_PLACES.HEADER
)
},
footerElements() {
return this.sharedElements.filter(
(element) =>
this.$registry.get('element', element.type).getPagePlace() ===
PAGE_PLACES.FOOTER
)
}, },
elementSelectedId() { elementSelectedId() {
return this.elementSelected?.id return this.elementSelected?.id
}, },
elementSelectedType() {
if (!this.elementSelected) {
return null
}
return this.$registry.get('element', this.elementSelected.type)
},
pageSectionWithSelectedElement() {
if (!this.elementSelected) {
return null
}
if (this.elementSelected.page_id === this.currentPage.id) {
return PAGE_PLACES.CONTENT
}
const ancestorWithPagePlace = this.$store.getters['element/getAncestors'](
this.elementSelectedPage,
this.elementSelected,
{
includeSelf: true,
predicate: (parentElement) => {
return (
this.$registry
.get('element', parentElement.type)
.getPagePlace() !== PAGE_PLACES.CONTENT
)
},
}
)[0]
return this.$registry
.get('element', ancestorWithPagePlace.type)
.getPagePlace()
},
elementsAround() {
if (!this.elementSelected) {
return null
}
return this.elementSelectedType.getElementsAround({
builder: this.builder,
page: this.currentPage,
element: this.elementSelected,
withSharedPage: true,
})
},
elementSelectedPage() {
if (this.elementSelected) {
// We use the page from the element itself
return this.$store.getters['page/getById'](
this.builder,
this.elementSelected.page_id
)
}
return null
},
deviceType() { deviceType() {
return this.deviceTypeSelected return this.deviceTypeSelected
? this.$registry.get('device', this.deviceTypeSelected) ? this.$registry.get('device', this.deviceTypeSelected)
@ -101,14 +239,14 @@ export default {
return null return null
} }
return this.$store.getters['element/getElementById']( return this.$store.getters['element/getElementById'](
this.page, this.elementSelectedPage,
this.elementSelected.parent_element_id this.elementSelected.parent_element_id
) )
}, },
canCreateElement() { canCreateElement() {
return this.$hasPermission( return this.$hasPermission(
'builder.page.create_element', 'builder.page.create_element',
this.page, this.currentPage,
this.workspace.id this.workspace.id
) )
}, },
@ -156,9 +294,8 @@ export default {
...mapActions({ ...mapActions({
actionDuplicateElement: 'element/duplicate', actionDuplicateElement: 'element/duplicate',
actionDeleteElement: 'element/delete', actionDeleteElement: 'element/delete',
actionMoveElement: 'element/moveElement',
actionSelectElement: 'element/select', actionSelectElement: 'element/select',
actionSelectNextElement: 'element/selectNextElement', actionMoveElement: 'element/move',
}), }),
preventScrollIfFocused(e) { preventScrollIfFocused(e) {
if (this.$refs.previewScaled === document.activeElement) { if (this.$refs.previewScaled === document.activeElement) {
@ -199,62 +336,57 @@ export default {
previewScaled.style.width = `${currentWidth / scale}px` previewScaled.style.width = `${currentWidth / scale}px`
previewScaled.style.height = `${currentHeight / scale}px` previewScaled.style.height = `${currentHeight / scale}px`
}, },
async moveElement({ element, direction }) {
if (
!this.$hasPermission(
'builder.page.element.update',
element,
this.workspace.id
)
) {
return
}
async moveElement(placement) { const elementPage = this.$store.getters['page/getById'](
this.builder,
element.page_id
)
const elementType = this.$registry.get('element', element.type)
const nextPlaces = elementType.getNextPlaces({
builder: this.builder,
page: this.currentPage,
element,
})
if (nextPlaces[direction]) {
try {
await this.actionMoveElement({
page: elementPage,
elementId: element.id,
...nextPlaces[direction],
})
} catch (error) {
notifyIf(error)
}
}
},
async moveSelectedElement(direction) {
if (!this.elementSelected?.id || !this.canUpdateSelectedElement) { if (!this.elementSelected?.id || !this.canUpdateSelectedElement) {
return return
} }
await this.moveElement({
const elementType = this.$registry.get( element: this.elementSelected,
'element', direction,
this.elementSelected.type })
)
const placementsDisabled = elementType.getPlacementsDisabled(
this.page,
this.elementSelected
)
if (placementsDisabled.includes(placement)) {
return
}
try {
await this.actionMoveElement({
page: this.page,
element: this.elementSelected,
placement,
})
await this.actionSelectElement({ element: this.elementSelected })
} catch (error) {
notifyIf(error)
}
}, },
async selectNextElement(placement) { async moveSelection(direction) {
if (!this.elementSelected?.id) { if (!this.elementSelected?.id) {
return return
} }
const nextElement = this.elementsAround[direction]
const elementType = this.$registry.get( if (nextElement) {
'element', await this.actionSelectElement({ element: nextElement })
this.elementSelected.type
)
const placementsDisabled = elementType.getPlacementsDisabled(
this.page,
this.elementSelected
)
if (placementsDisabled.includes(placement)) {
return
}
try {
await this.actionSelectNextElement({
page: this.page,
element: this.elementSelected,
placement,
})
} catch (error) {
notifyIf(error)
} }
}, },
async duplicateElement() { async duplicateElement() {
@ -265,7 +397,7 @@ export default {
this.isDuplicating = true this.isDuplicating = true
try { try {
await this.actionDuplicateElement({ await this.actionDuplicateElement({
page: this.page, page: this.elementSelectedPage,
elementId: this.elementSelected.id, elementId: this.elementSelected.id,
}) })
} catch (error) { } catch (error) {
@ -278,12 +410,15 @@ export default {
return return
} }
try { try {
const siblingElementToSelect = this.getClosestSiblingElement( const siblingElementToSelect =
this.page, this.elementsAround[DIRECTIONS.AFTER] ||
this.elementSelected this.elementsAround[DIRECTIONS.BEFORE] ||
) this.elementsAround[DIRECTIONS.LEFT] ||
this.elementsAround[DIRECTIONS.RIGHT] ||
this.parentOfElementSelected
await this.actionDeleteElement({ await this.actionDeleteElement({
page: this.page, page: this.elementSelectedPage,
elementId: this.elementSelected.id, elementId: this.elementSelected.id,
}) })
if (siblingElementToSelect?.id) { if (siblingElementToSelect?.id) {
@ -299,7 +434,10 @@ export default {
} }
}, },
selectChildElement() { selectChildElement() {
const children = this.getChildren(this.page, this.elementSelected) const children = this.getChildren(
this.elementSelectedPage,
this.elementSelected
)
if (children.length) { if (children.length) {
this.actionSelectElement({ element: children[0] }) this.actionSelectElement({ element: children[0] })
} }
@ -310,30 +448,30 @@ export default {
switch (e.key) { switch (e.key) {
case 'ArrowUp': case 'ArrowUp':
if (alternateAction) { if (alternateAction) {
this.moveElement(PLACEMENTS.BEFORE) this.moveSelectedElement(DIRECTIONS.BEFORE)
} else { } else {
this.selectNextElement(PLACEMENTS.BEFORE) this.moveSelection(DIRECTIONS.BEFORE)
} }
break break
case 'ArrowDown': case 'ArrowDown':
if (alternateAction) { if (alternateAction) {
this.moveElement(PLACEMENTS.AFTER) this.moveSelectedElement(DIRECTIONS.AFTER)
} else { } else {
this.selectNextElement(PLACEMENTS.AFTER) this.moveSelection(DIRECTIONS.AFTER)
} }
break break
case 'ArrowLeft': case 'ArrowLeft':
if (alternateAction) { if (alternateAction) {
this.moveElement(PLACEMENTS.LEFT) this.moveSelectedElement(DIRECTIONS.LEFT)
} else { } else {
this.selectNextElement(PLACEMENTS.LEFT) this.moveSelection(DIRECTIONS.LEFT)
} }
break break
case 'ArrowRight': case 'ArrowRight':
if (alternateAction) { if (alternateAction) {
this.moveElement(PLACEMENTS.RIGHT) this.moveSelectedElement(DIRECTIONS.RIGHT)
} else { } else {
this.selectNextElement(PLACEMENTS.RIGHT) this.moveSelection(DIRECTIONS.RIGHT)
} }
break break
case 'Backspace': case 'Backspace':

View file

@ -1,9 +1,9 @@
<template> <template>
<PageTemplateContent <PageTemplateContent
v-if="!loading && workspace && page && builder" v-if="!loading && workspace && currentPage && builder"
:workspace="workspace" :workspace="workspace"
:builder="builder" :builder="builder"
:page="page" :current-page="currentPage"
:mode="mode" :mode="mode"
/> />
<PageSkeleton v-else /> <PageSkeleton v-else />
@ -32,7 +32,7 @@ export default {
return { return {
workspace: null, workspace: null,
builder: null, builder: null,
page: null, currentPage: null,
mode, mode,
loading: true, loading: true,
} }
@ -104,7 +104,7 @@ export default {
) )
this.builder = builder this.builder = builder
this.page = page this.currentPage = page
this.workspace = builder.workspace this.workspace = builder.workspace
} catch (e) { } catch (e) {
// In case of a network error we want to fail hard. // In case of a network error we want to fail hard.

View file

@ -1,6 +1,6 @@
<template> <template>
<div v-if="page" :key="page.id" class="page-template"> <div v-if="currentPage" :key="currentPage.id" class="page-template">
<PageHeader :page="page" /> <PageHeader />
<div class="layout__col-2-2 page-editor__content"> <div class="layout__col-2-2 page-editor__content">
<div :style="{ width: `calc(100% - ${panelWidth}px)` }"> <div :style="{ width: `calc(100% - ${panelWidth}px)` }">
<PagePreview /> <PagePreview />
@ -32,10 +32,13 @@ export default {
return { return {
workspace: this.workspace, workspace: this.workspace,
builder: this.builder, builder: this.builder,
page: this.page, currentPage: this.currentPage,
mode, mode,
formulaComponent: ApplicationBuilderFormulaInput, formulaComponent: ApplicationBuilderFormulaInput,
applicationContext: { builder: this.builder, page: this.page, mode }, applicationContext: {
builder: this.builder,
mode,
},
} }
}, },
props: { props: {
@ -47,7 +50,7 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
page: { currentPage: {
type: Object, type: Object,
required: true, required: true,
}, },
@ -63,7 +66,7 @@ export default {
applicationContext() { applicationContext() {
return { return {
builder: this.builder, builder: this.builder,
page: this.page, page: this.currentPage,
mode, mode,
} }
}, },
@ -76,7 +79,9 @@ export default {
) )
}, },
dataSources() { dataSources() {
return this.$store.getters['dataSource/getPageDataSources'](this.page) return this.$store.getters['dataSource/getPageDataSources'](
this.currentPage
)
}, },
dispatchContext() { dispatchContext() {
return DataProviderType.getAllDataSourceDispatchContext( return DataProviderType.getAllDataSourceDispatchContext(
@ -96,7 +101,7 @@ export default {
this.$store.dispatch( this.$store.dispatch(
'dataSourceContent/debouncedFetchPageDataSourceContent', 'dataSourceContent/debouncedFetchPageDataSourceContent',
{ {
page: this.page, page: this.currentPage,
data: newDispatchContext, data: newDispatchContext,
mode: this.mode, mode: this.mode,
} }

View file

@ -79,7 +79,7 @@ import { DEFAULT_USER_ROLE_PREFIX } from '@baserow/modules/builder/constants'
export default { export default {
name: 'UserSourceUsersContext', name: 'UserSourceUsersContext',
mixins: [context], mixins: [context],
inject: ['page', 'builder'], inject: ['builder'],
data() { data() {
return { return {
state: null, state: null,

View file

@ -10,24 +10,49 @@
ref="search" ref="search"
v-model="search" v-model="search"
type="text" type="text"
class="elements-list__search-input" class="elements-context__search-input"
:placeholder="$t('elementsContext.searchPlaceholder')" :placeholder="$t('elementsContext.searchPlaceholder')"
/> />
</div> </div>
<ElementsList <div class="elements-context__elements">
v-if="elementsVisible" <ElementsList
:elements="rootElements" v-if="headerElementsVisible"
:filtered-search-elements="filteredSearchElements" :elements="headerElements"
@select="selectElement($event)" :filtered-search-elements="filteredHeaderElements"
/> @select="selectElement($event)"
<div v-else class="context__description"> />
{{ $t('elementsContext.noElements') }} <ElementsList
v-if="contentElementsVisible"
:elements="rootElements"
:filtered-search-elements="filteredContentElements"
@select="selectElement($event)"
/>
<div
v-if="!contentElementsVisible && !isSearching"
class="elements-list elements-list--empty"
>
{{ $t('elementsContext.noPageElements') }}
</div>
<ElementsList
v-if="footerElementsVisible"
:elements="footerElements"
:filtered-search-elements="filteredFooterElements"
@select="selectElement($event)"
/>
<div
v-if="!elementsVisible && isSearching"
class="elements-list elements-list--empty"
>
{{ $t('elementsContext.noElements') }}
</div>
</div> </div>
<div <div
v-if="$hasPermission('builder.page.create_element', page, workspace.id)" v-if="
class="elements-list__footer" $hasPermission('builder.page.create_element', currentPage, workspace.id)
"
class="elements-context__footer"
> >
<div class="elements-list__footer-create"> <div class="elements-context__footer-create">
<AddElementButton <AddElementButton
:class="{ :class="{
'margin-top-1': !elementsVisible, 'margin-top-1': !elementsVisible,
@ -37,9 +62,11 @@
</div> </div>
</div> </div>
<AddElementModal <AddElementModal
v-if="$hasPermission('builder.page.create_element', page, workspace.id)" v-if="
$hasPermission('builder.page.create_element', currentPage, workspace.id)
"
ref="addElementModal" ref="addElementModal"
:page="page" :page="currentPage"
@element-added="onElementAdded" @element-added="onElementAdded"
/> />
</Context> </Context>
@ -52,27 +79,110 @@ import AddElementButton from '@baserow/modules/builder/components/elements/AddEl
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal' import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal'
import { mapActions } from 'vuex' import { mapActions } from 'vuex'
import { isSubstringOfStrings } from '@baserow/modules/core/utils/string' import { isSubstringOfStrings } from '@baserow/modules/core/utils/string'
import { PAGE_PLACES } from '@baserow/modules/builder/enums'
export default { export default {
name: 'ElementsContext', name: 'ElementsContext',
components: { AddElementModal, AddElementButton, ElementsList }, components: { AddElementModal, AddElementButton, ElementsList },
mixins: [context], mixins: [context],
inject: ['workspace', 'page', 'builder', 'mode'], inject: ['workspace', 'currentPage', 'builder', 'mode'],
data() { data() {
return { return {
search: null, search: null,
addingElementType: null,
} }
}, },
computed: { computed: {
isSearching() {
return Boolean(this.search)
},
elementsVisible() { elementsVisible() {
return ( return (
(this.search && this.filteredSearchElements.length) || (this.search &&
(this.filteredContentElements.length ||
this.filteredHeaderElements.length ||
this.filteredFooterElements.length)) ||
(!this.search &&
(this.rootElements.length ||
this.headerElements.length ||
this.footerElements.length))
)
},
contentElementsVisible() {
return (
(this.search && this.filteredContentElements.length) ||
(!this.search && this.rootElements.length) (!this.search && this.rootElements.length)
) )
}, },
headerElementsVisible() {
return (
(this.search && this.filteredHeaderElements.length) ||
(!this.search && this.headerElements.length)
)
},
footerElementsVisible() {
return (
(this.search && this.filteredFooterElements.length) ||
(!this.search && this.footerElements.length)
)
},
rootElements() { rootElements() {
return this.$store.getters['element/getRootElements'](this.page) return this.$store.getters['element/getRootElements'](this.currentPage)
},
sharedPage() {
return this.$store.getters['page/getSharedPage'](this.builder)
},
sharedElements() {
return this.$store.getters['element/getRootElements'](this.sharedPage)
},
headerElements() {
return this.sharedElements.filter(
(element) =>
this.$registry.get('element', element.type).getPagePlace() ===
PAGE_PLACES.HEADER
)
},
footerElements() {
return this.sharedElements.filter(
(element) =>
this.$registry.get('element', element.type).getPagePlace() ===
PAGE_PLACES.FOOTER
)
},
filteredContentElements() {
return this.filterElements(this.rootElements, this.currentPage)
},
filteredHeaderElements() {
return this.filterElements(this.headerElements, this.sharedPage)
},
filteredFooterElements() {
return this.filterElements(this.footerElements, this.sharedPage)
},
},
methods: {
...mapActions({
actionSelectElement: 'element/select',
}),
/*
* Given an element, this method will return the corpus we want to
* search against when a user enters a search query. Should we want
* to search both the 'display name' and the element type name, this
* method can be easily adapted to combine the two and return it.
*/
getElementCorpus(element, page) {
const elementType = this.$registry.get('element', element.type)
return elementType.getDisplayName(element, {
builder: this.builder,
page,
mode: this.mode,
element,
})
},
onElementAdded() {
this.hide()
},
selectElement(element) {
this.actionSelectElement({ element })
this.hide()
}, },
/* /*
* When a user searches for elements in the list, this computed method * When a user searches for elements in the list, this computed method
@ -102,34 +212,30 @@ export default {
* - Repeat * - Repeat
* - Image * - Image
*/ */
filteredSearchElements() { filterElements(elements, page) {
let filteredToElementIds = [] let filteredToElementIds = []
if ( if (!this.search) {
this.search === '' ||
this.search === null ||
this.search === undefined
) {
// If there's no search query, then there are no // If there's no search query, then there are no
// elements to narrow the results down to. // elements to narrow the results down to.
return filteredToElementIds return filteredToElementIds
} }
// Iterate over all the root-level elements. // Iterate over all the root-level elements.
this.rootElements.forEach((rootElement) => { elements.forEach((element) => {
// Find this element's descendants and loop over them. // Find this element's descendants and loop over them.
const descendants = this.$store.getters['element/getDescendants']( const descendants = this.$store.getters['element/getDescendants'](
this.page, page,
rootElement element
) )
descendants.forEach((descendant) => { descendants.forEach((descendant) => {
// Build this descendant's corpus (for now, display name only) // Build this descendant's corpus (for now, display name only)
// and check if it matches the search query. // and check if it matches the search query.
const descendantCorpus = this.getElementCorpus(descendant) const descendantCorpus = this.getElementCorpus(descendant, page)
if (isSubstringOfStrings([descendantCorpus], this.search)) { if (isSubstringOfStrings([descendantCorpus], this.search)) {
// The descendant matches. We need to include *this* element, // The descendant matches. We need to include *this* element,
// and all its *ancestors* in our list of narrowed results. // and all its *ancestors* in our list of narrowed results.
const ascendants = this.$store.getters['element/getAncestors']( const ascendants = this.$store.getters['element/getAncestors'](
this.page, page,
descendant descendant
) )
filteredToElementIds.push(descendant.id) filteredToElementIds.push(descendant.id)
@ -141,42 +247,15 @@ export default {
// Test of the root element itself matches the search query. // Test of the root element itself matches the search query.
// if it does, it gets included in the narrowed results too. // if it does, it gets included in the narrowed results too.
const rootCorpus = this.getElementCorpus(rootElement) const rootCorpus = this.getElementCorpus(element, page)
if (isSubstringOfStrings([rootCorpus], this.search)) { if (isSubstringOfStrings([rootCorpus], this.search)) {
// The root element matches. // The root element matches.
filteredToElementIds.push(rootElement.id) filteredToElementIds.push(element.id)
} }
}) })
filteredToElementIds = [...new Set(filteredToElementIds)] filteredToElementIds = [...new Set(filteredToElementIds)]
return filteredToElementIds return filteredToElementIds
}, },
},
methods: {
...mapActions({
actionSelectElement: 'element/select',
}),
/*
* Given an element, this method will return the corpus we want to
* search against when a user enters a search query. Should we want
* to search both the 'display name' and the element type name, this
* method can be easily adapted to combine the two and return it.
*/
getElementCorpus(element) {
const elementType = this.$registry.get('element', element.type)
return elementType.getDisplayName(element, {
builder: this.builder,
page: this.page,
mode: this.mode,
element,
})
},
onElementAdded() {
this.hide()
},
selectElement(element) {
this.actionSelectElement({ element })
this.hide()
},
shown() { shown() {
this.search = null this.search = null
this.$nextTick(() => { this.$nextTick(() => {

View file

@ -2,7 +2,7 @@
<ul class="header__filter"> <ul class="header__filter">
<template v-for="actionType in pageActionTypes"> <template v-for="actionType in pageActionTypes">
<li <li
v-if="actionType.isActive({ page, workspace })" v-if="actionType.isActive({ page: currentPage, workspace })"
:key="actionType.getType()" :key="actionType.getType()"
class="header__filter-item header__filter-item--right" class="header__filter-item header__filter-item--right"
> >
@ -14,7 +14,7 @@
component: $refs[`component_${actionType.type}`][0], component: $refs[`component_${actionType.type}`][0],
button: $refs[`button_${actionType.type}`][0], button: $refs[`button_${actionType.type}`][0],
builder: builder, builder: builder,
page: page, page: currentPage,
}) })
" "
> >
@ -29,7 +29,7 @@
:is="actionType.component" :is="actionType.component"
:ref="`component_${actionType.type}`" :ref="`component_${actionType.type}`"
:builder="builder" :builder="builder"
:page="page" :page="currentPage"
/> />
</li> </li>
</template> </template>
@ -39,7 +39,7 @@
<script> <script>
export default { export default {
name: 'PageActions', name: 'PageActions',
inject: ['workspace', 'builder', 'page'], inject: ['workspace', 'builder', 'currentPage'],
computed: { computed: {
pageActionTypes() { pageActionTypes() {
return Object.values(this.$registry.getOrderedList('pageAction')) return Object.values(this.$registry.getOrderedList('pageAction'))

View file

@ -22,12 +22,6 @@ export default {
DeviceSelector, DeviceSelector,
PageActions, PageActions,
}, },
props: {
page: {
type: Object,
required: true,
},
},
computed: { computed: {
...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected' }), ...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected' }),
deviceTypes() { deviceTypes() {

View file

@ -23,7 +23,7 @@
:is="itemType.component" :is="itemType.component"
:ref="`component_${itemType.type}`" :ref="`component_${itemType.type}`"
:data-item-type="itemType.type" :data-item-type="itemType.type"
:page="page" :page="currentPage"
/> />
</li> </li>
</ul> </ul>
@ -32,7 +32,7 @@
<script> <script>
export default { export default {
name: 'PageHeaderMenuItems', name: 'PageHeaderMenuItems',
inject: ['page'], inject: ['currentPage'],
computed: { computed: {
pageHeaderItemTypes() { pageHeaderItemTypes() {
return this.$registry.getOrderedList('pageHeaderItem') return this.$registry.getOrderedList('pageHeaderItem')

View file

@ -8,12 +8,12 @@
</Alert> </Alert>
<PageSettingsForm <PageSettingsForm
:builder="builder" :builder="builder"
:page="page" :page="currentPage"
:default-values="page" :default-values="currentPage"
@submitted="updatePage" @submitted="updatePage"
> >
<div <div
v-if="$hasPermission('builder.page.update', page, workspace.id)" v-if="$hasPermission('builder.page.update', currentPage, workspace.id)"
class="actions actions--right" class="actions actions--right"
> >
<Button size="large" :loading="loading" :disabled="loading"> <Button size="large" :loading="loading" :disabled="loading">
@ -34,7 +34,7 @@ export default {
name: 'PageSettings', name: 'PageSettings',
components: { PageSettingsForm }, components: { PageSettingsForm },
mixins: [error], mixins: [error],
inject: ['builder', 'page', 'workspace'], inject: ['builder', 'currentPage', 'workspace'],
data() { data() {
return { return {
loading: false, loading: false,
@ -50,7 +50,7 @@ export default {
try { try {
await this.actionUpdatePage({ await this.actionUpdatePage({
builder: this.builder, builder: this.builder,
page: this.page, page: this.currentPage,
values: { values: {
name, name,
path, path,
@ -60,7 +60,7 @@ export default {
await Promise.all( await Promise.all(
pathPrams.map(({ name, type }) => pathPrams.map(({ name, type }) =>
this.$store.dispatch('pageParameter/setParameter', { this.$store.dispatch('pageParameter/setParameter', {
page: this.page, page: this.currentPage,
name, name,
value: defaultValueForParameterType(type), value: defaultValueForParameterType(type),
}) })

View file

@ -62,8 +62,13 @@ export default {
PageSettingsNameFormElement, PageSettingsNameFormElement,
}, },
mixins: [form], mixins: [form],
inject: ['workspace', 'builder', 'page'], inject: ['workspace', 'builder'],
props: { props: {
page: {
type: Object,
required: false,
default: null,
},
isCreation: { isCreation: {
type: Boolean, type: Boolean,
required: false, required: false,

View file

@ -76,7 +76,7 @@
allowAllRolesExceptSelected || allowAllRolesExceptSelected ||
disallowAllRolesExceptSelected disallowAllRolesExceptSelected
" "
class="visibility-form__role-checkbox-container" class="visibility-form__role-list"
> >
<template v-if="loadingRoles"> <template v-if="loadingRoles">
<div class="loading margin-bottom-1"></div> <div class="loading margin-bottom-1"></div>
@ -85,7 +85,7 @@
<div <div
v-for="roleName in allRoles" v-for="roleName in allRoles"
:key="roleName" :key="roleName"
class="visibility-form__role-checkbox-div" class="visibility-form__role-checkbox"
> >
<Checkbox <Checkbox
:checked="isChecked(roleName)" :checked="isChecked(roleName)"
@ -95,14 +95,11 @@
</Checkbox> </Checkbox>
</div> </div>
<div class="visibility-form__role-links"> <div class="visibility-form__actions">
<a @click.prevent="selectAllRoles"> <a @click.prevent="selectAllRoles">
{{ $t('visibilityForm.rolesSelectAll') }} {{ $t('visibilityForm.rolesSelectAll') }}
</a> </a>
<a <a @click.prevent="deselectAllRoles">
class="visibility-form__role-links-deselect-all"
@click.prevent="deselectAllRoles"
>
{{ $t('visibilityForm.rolesDeselectAll') }} {{ $t('visibilityForm.rolesDeselectAll') }}
</a> </a>
</div> </div>
@ -120,12 +117,13 @@
<script> <script>
import { StoreItemLookupError } from '@baserow/modules/core/errors' import { StoreItemLookupError } from '@baserow/modules/core/errors'
import visibilityForm from '@baserow/modules/builder/mixins/visibilityForm' import visibilityForm from '@baserow/modules/builder/mixins/visibilityForm'
import form from '@baserow/modules/core/mixins/form'
import { VISIBILITY_LOGGED_IN } from '@baserow/modules/builder/constants' import { VISIBILITY_LOGGED_IN } from '@baserow/modules/builder/constants'
export default { export default {
name: 'PageVisibilityForm', name: 'PageVisibilityForm',
mixins: [visibilityForm], mixins: [form, visibilityForm],
data() { data() {
return { return {
values: { values: {

View file

@ -4,7 +4,7 @@
<div> <div>
<PageVisibilityForm <PageVisibilityForm
:default-values="page" :default-values="currentPage"
@values-changed="updatePageVisibility" @values-changed="updatePageVisibility"
/> />
</div> </div>
@ -20,7 +20,7 @@ export default {
name: 'PageVisibilitySettings', name: 'PageVisibilitySettings',
components: { PageVisibilityForm }, components: { PageVisibilityForm },
mixins: [error], mixins: [error],
inject: ['builder', 'page', 'workspace'], inject: ['builder', 'currentPage', 'workspace'],
data() { data() {
return {} return {}
}, },
@ -31,7 +31,7 @@ export default {
try { try {
await this.actionUpdatePage({ await this.actionUpdatePage({
builder: this.builder, builder: this.builder,
page: this.page, page: this.currentPage,
values, values,
}) })
} catch (error) { } catch (error) {

View file

@ -22,13 +22,8 @@ export default {
name: 'EventsSidePanel', name: 'EventsSidePanel',
components: { Event }, components: { Event },
mixins: [elementSidePanel], mixins: [elementSidePanel],
inject: ['applicationContext'],
provide() { provide() {
return { return {
applicationContext: {
...this.applicationContext,
element: this.element,
},
dataProvidersAllowed: DATA_PROVIDERS_ALLOWED_WORKFLOW_ACTIONS, dataProvidersAllowed: DATA_PROVIDERS_ALLOWED_WORKFLOW_ACTIONS,
} }
}, },
@ -38,7 +33,7 @@ export default {
}, },
workflowActions() { workflowActions() {
return this.$store.getters['workflowAction/getElementWorkflowActions']( return this.$store.getters['workflowAction/getElementWorkflowActions'](
this.page, this.elementPage,
this.element.id this.element.id
) )
}, },

View file

@ -16,13 +16,8 @@ import { DATA_PROVIDERS_ALLOWED_ELEMENTS } from '@baserow/modules/builder/enums'
export default { export default {
name: 'GeneralSidePanel', name: 'GeneralSidePanel',
mixins: [elementSidePanel], mixins: [elementSidePanel],
inject: ['applicationContext'],
provide() { provide() {
return { return {
applicationContext: {
...this.applicationContext,
element: this.element,
},
dataProvidersAllowed: DATA_PROVIDERS_ALLOWED_ELEMENTS, dataProvidersAllowed: DATA_PROVIDERS_ALLOWED_ELEMENTS,
} }
}, },

View file

@ -30,7 +30,6 @@ export default {
provide() { provide() {
return { return {
builder: this.builder, builder: this.builder,
page: null,
mode: null, mode: null,
} }
}, },

View file

@ -209,7 +209,6 @@ export default {
try { try {
await this.actionUpdateUserSource({ await this.actionUpdateUserSource({
application: this.builder, application: this.builder,
page: this.page,
userSourceId: this.editedUserSource.id, userSourceId: this.editedUserSource.id,
values: clone(newValues), values: clone(newValues),
}) })

View file

@ -7,14 +7,13 @@
class="margin-bottom-2" class="margin-bottom-2"
> >
<div class="control__elements"> <div class="control__elements">
<Dropdown v-model="values.data_source_id" :show-search="false"> <DataSourceDropdown
<DropdownItem v-model="values.data_source_id"
v-for="dataSource in dataSources" small
:key="dataSource.id" :shared-data-sources="sharedDataSources"
:name="dataSource.name" :local-data-sources="localDataSources"
:value="dataSource.id" >
/> </DataSourceDropdown>
</Dropdown>
</div> </div>
</FormGroup> </FormGroup>
</form> </form>
@ -22,9 +21,11 @@
<script> <script>
import elementForm from '@baserow/modules/builder/mixins/elementForm' import elementForm from '@baserow/modules/builder/mixins/elementForm'
import DataSourceDropdown from '@baserow/modules/builder/components/dataSource/DataSourceDropdown'
export default { export default {
name: 'RefreshDataSourceWorkflowActionForm', name: 'RefreshDataSourceWorkflowActionForm',
components: { DataSourceDropdown },
mixins: [elementForm], mixins: [elementForm],
props: { props: {
workflowAction: { workflowAction: {
@ -42,8 +43,39 @@ export default {
} }
}, },
computed: { computed: {
dataSources() { sharedPage() {
return this.$store.getters['dataSource/getPageDataSources'](this.page) return this.$store.getters['page/getSharedPage'](this.builder)
},
/**
* Returns all data sources that are available not on shared page.
* @returns {Array} - The data sources the page designer can choose from.
*/
localDataSources() {
if (this.elementPage.id === this.sharedPage.id) {
// If the element is on the shared page they are no local page but only
// shared page.
return null
} else {
return this.$store.getters['dataSource/getPagesDataSources']([
this.elementPage,
]).filter((dataSource) => dataSource.type)
}
},
/**
* Returns the shared data sources.
* @returns {Array} - The shared data sources the page designer can choose from.
*/
sharedDataSources() {
// We keep only data sources that have a type and a schema.
return this.$store.getters['dataSource/getPagesDataSources']([
this.sharedPage,
]).filter(
(dataSource) =>
dataSource.type &&
this.$registry
.get('service', dataSource.type)
.getDataSchema(dataSource)
)
}, },
}, },
} }

View file

@ -0,0 +1,364 @@
import { ELEMENT_EVENTS, SHARE_TYPES } from '@baserow/modules/builder/enums'
export const ContainerElementTypeMixin = (Base) =>
class extends Base {
isContainerElement = true
get elementTypesAll() {
return Object.values(this.app.$registry.getAll('element'))
}
/**
* Returns an array of style types that are not allowed as children of this element.
* @returns {Array}
*/
get childStylesForbidden() {
return []
}
get defaultPlaceInContainer() {
throw new Error('Not implemented')
}
/**
* Returns the default value when creating a child element to this container.
* @param {Object} page The current page object
* @param {Object} values The values of the to be created element
* @returns the default values for this element.
*/
getDefaultChildValues(page, values) {
// By default, an element inside a container should have no left and right padding
return { style_padding_left: 0, style_padding_right: 0 }
}
/**
* A Container element without any child elements is invalid. Return true
* if there are no children, otherwise return false.
*/
isInError({ page, element }) {
const children = this.app.store.getters['element/getChildren'](
page,
element
)
return !children.length
}
}
export const CollectionElementTypeMixin = (Base) =>
class extends Base {
isCollectionElement = true
/**
* A helper function responsible for returning this collection element's
* schema properties.
*/
getSchemaProperties(dataSource) {
const serviceType = this.app.$registry.get('service', dataSource.type)
const schema = serviceType.getDataSchema(dataSource)
if (!schema) {
return []
}
return schema.type === 'array'
? schema.items.properties
: schema.properties
}
/**
* Given a schema property name, is responsible for finding the matching
* property option in the element. If it doesn't exist, then we return
* an empty object, and it won't be included in the adhoc header.
* @param {object} element - the element we want to extract options from.
* @param {string} schemaProperty - the schema property name to check.
* @returns {object} - the matching property option, or an empty object.
*/
getPropertyOptionsByProperty(element, schemaProperty) {
return (
element.property_options.find((option) => {
return option.schema_property === schemaProperty
}) || {}
)
}
/**
* Responsible for iterating over the schema's properties, filtering
* the results down to the properties which are `filterable`, `sortable`,
* and `searchable`, and then returning the property value.
* @param {string} option - the `filterable`, `sortable` or `searchable`
* property option. If the value is `true` then the property will be
* included in the adhoc header component.
* @param {object} element - the element we want to extract options from.
* @param {object} dataSource - the dataSource used by `element`.
* @returns {array} - an array of schema properties which are present
* in the element's property options where `option` = `true`.
*/
getPropertyOptionByType(option, element, dataSource) {
const schemaProperties = dataSource
? this.getSchemaProperties(dataSource)
: []
return Object.entries(schemaProperties)
.filter(
([schemaProperty, _]) =>
this.getPropertyOptionsByProperty(element, schemaProperty)[
option
] || false
)
.map(([_, property]) => property)
}
/**
* An array of properties within this element which have been flagged
* as filterable by the page designer.
*/
adhocFilterableProperties(element, dataSource) {
return this.getPropertyOptionByType('filterable', element, dataSource)
}
/**
* An array of properties within this element which have been flagged
* as sortable by the page designer.
*/
adhocSortableProperties(element, dataSource) {
return this.getPropertyOptionByType('sortable', element, dataSource)
}
/**
* An array of properties within this element which have been flagged
* as searchable by the page designer.
*/
adhocSearchableProperties(element, dataSource) {
return this.getPropertyOptionByType('searchable', element, dataSource)
}
/**
* By default collection element will load their content at loading time
* but if you don't want that you can return false here.
*/
get fetchAtLoad() {
return true
}
hasCollectionAncestor(page, element) {
return this.app.store.getters['element/getAncestors'](page, element).some(
({ type }) => {
const ancestorType = this.app.$registry.get('element', type)
return ancestorType.isCollectionElement
}
)
}
/**
* A simple check to return whether this collection element has a
* "source of data" (i.e. a data source, or a schema property).
* Should not be used as an "in error" or validation check, use
* `isInError` for this purpose as it is more thorough.
* @param element - The element we want to check for a source of data.
* @returns {Boolean} - Whether the element has a source of data.
*/
hasSourceOfData(element) {
return Boolean(element.data_source_id || element.schema_property)
}
/**
* Collection elements by default will have three permutations of display names:
*
* 1. If no data source exists, on `element` or its ancestors, then:
* - "Repeat" is returned.
* 2. If a data source is found, and `element` has no `schema_property`, then:
* - "Repeat {dataSourceName}" is returned.
* 3. If a data source is found, `element` has a `schema_property`, and the integration is Baserow, then:
* - "Repeat {schemaPropertyTitle} ({fieldTypeName})" is returned
* 4. If a data source is found, `element` has a `schema_property`, and the integration isn't Baserow, then:
* - "Repeat {schemaPropertyTitle}" is returned
* @param element - The element we want to get a display name for.
* @param page - The page the element belongs to.
* @returns {string} - The display name for the element.
*/
getDisplayName(element, { page, builder }) {
let suffix = ''
const collectionAncestors = this.app.store.getters[
'element/getAncestors'
](page, element, {
predicate: (ancestor) =>
this.app.$registry.get('element', ancestor.type)
.isCollectionElement && ancestor.data_source_id !== null,
})
// If the collection element has ancestors, pluck out the first one, which
// will have a data source. Otherwise, use `element`, as this element is
// the root level element.
const collectionElement = collectionAncestors.length
? collectionAncestors[0]
: element
// If we find a collection ancestor which has a data source, we'll
// use the data source's name as part of the display name.
if (collectionElement?.data_source_id) {
const sharedPage = this.app.store.getters['page/getSharedPage'](builder)
const dataSource = this.app.store.getters[
'dataSource/getPagesDataSourceById'
]([page, sharedPage], collectionElement?.data_source_id)
suffix = dataSource ? dataSource.name : ''
// If we have a data source, and the element has a schema property,
// we'll find the property within the data source's schema and pluck
// out the title property.
if (element.schema_property) {
// Find the schema properties. They'll be in different places,
// depending on whether this is a list or single row data source.
const schemaProperties =
dataSource.schema.type === 'array'
? dataSource.schema?.items?.properties
: dataSource.schema.properties
const schemaField = schemaProperties[element.schema_property]
// Only Local/Remote Baserow table schemas will have `original_type`,
// which is the `FieldType`. If we find it, we can use it to display
// what kind of field type was used.
suffix = schemaField?.title || element.schema_property
if (schemaField.original_type) {
const fieldType = this.app.$registry.get(
'field',
schemaField.original_type
)
suffix = `${suffix} (${fieldType.getName()})`
}
}
}
return suffix ? `${this.name} - ${suffix}` : this.name
}
/**
* When a data source is modified or destroyed, we need to ensure that
* our collection elements respond accordingly.
*
* If the data source has been removed, we want to remove it from the
* collection element, and then clear its contents from the store.
*
* If the data source has been updated, we want to trigger a content reset.
*
* @param event - `ELEMENT_EVENTS.DATA_SOURCE_REMOVED` if a data source
* has been destroyed, or `ELEMENT_EVENTS.DATA_SOURCE_AFTER_UPDATE` if
* it's been updated.
* @param params - Context data which the element type can use.
*/
async onElementEvent(event, { builder, element, dataSourceId }) {
const page = this.app.store.getters['page/getById'](
builder,
element.page_id
)
if (event === ELEMENT_EVENTS.DATA_SOURCE_REMOVED) {
if (element.data_source_id === dataSourceId) {
// Remove the data_source_id
await this.app.store.dispatch('element/forceUpdate', {
page,
element,
values: { data_source_id: null },
})
// Empty the element content
await this.app.store.dispatch('elementContent/clearElementContent', {
element,
})
}
}
if (event === ELEMENT_EVENTS.DATA_SOURCE_AFTER_UPDATE) {
if (element.data_source_id === dataSourceId) {
await this.app.store.dispatch(
'elementContent/triggerElementContentReset',
{
element,
}
)
}
}
}
/**
* A collection element is in error if:
*
* - No parent (including self) collection elements have a valid data_source_id.
* - The parent with the valid data_source_id points to a data_source
* that !returnsList and `schema_property` is blank.
* - It is nested in another collection element, and we don't have a `schema_property`.
* @param {Object} page - The page the repeat element belongs to.
* @param {Object} element - The repeat element
* @param {Object} builder - The builder
* @returns {Boolean} - Whether the element is in error.
*/
isInError({ page, element, builder }) {
// We get all parents with a valid data_source_id
const collectionAncestorsWithDataSource = this.app.store.getters[
'element/getAncestors'
](page, element, {
predicate: (ancestor) =>
this.app.$registry.get('element', ancestor.type)
.isCollectionElement && ancestor.data_source_id,
includeSelf: true,
})
// No parent with a data_source_id means we are in error
if (collectionAncestorsWithDataSource.length === 0) {
return true
}
// We consider the closest parent collection element with a data_source_id
// The closest parent might be the current element itself
const parentWithDataSource = collectionAncestorsWithDataSource.at(-1)
// We now check if the parent element configuration is correct.
const sharedPage = this.app.store.getters['page/getSharedPage'](builder)
const dataSource = this.app.store.getters[
'dataSource/getPagesDataSourceById'
]([page, sharedPage], parentWithDataSource.data_source_id)
// The data source is missing. May be it has been removed.
if (!dataSource) {
return true
}
const serviceType = this.app.$registry.get('service', dataSource.type)
// If the data source type doesn't return a list, we should have a schema_property
if (!serviceType.returnsList && !parentWithDataSource.schema_property) {
return true
}
// If the current element is not the one with the data source it should have
// a schema_property
if (parentWithDataSource.id !== element.id && !element.schema_property) {
return true
}
return super.isInError({ page, element, builder })
}
}
export const MultiPageElementTypeMixin = (Base) =>
class extends Base {
isMultiPageElement = true
get onSharedPage() {
return true
}
isVisible({ element, currentPage }) {
if (!super.isVisible({ element, currentPage })) {
return false
}
switch (element.share_type) {
case SHARE_TYPES.ALL:
return true
case SHARE_TYPES.ONLY:
return element.pages.includes(currentPage.id)
case SHARE_TYPES.EXCEPT:
return !element.pages.includes(currentPage.id)
default:
return false
}
}
get childStylesForbidden() {
return ['style_width']
}
}

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ import {
UserDataProviderType, UserDataProviderType,
} from '@baserow/modules/builder/dataProviderTypes' } from '@baserow/modules/builder/dataProviderTypes'
export const PLACEMENTS = { export const DIRECTIONS = {
BEFORE: 'before', BEFORE: 'before',
AFTER: 'after', AFTER: 'after',
LEFT: 'left', LEFT: 'left',
@ -83,6 +83,12 @@ export const BACKGROUND_MODES = {
FIT: 'fit', FIT: 'fit',
} }
export const PAGE_PLACES = {
HEADER: 'header',
CONTENT: 'content',
FOOTER: 'footer',
}
export const WIDTH_TYPES = { export const WIDTH_TYPES = {
SMALL: { value: 'small', name: 'widthTypes.small' }, SMALL: { value: 'small', name: 'widthTypes.small' },
MEDIUM: { value: 'medium', name: 'widthTypes.medium' }, MEDIUM: { value: 'medium', name: 'widthTypes.medium' },
@ -91,6 +97,12 @@ export const WIDTH_TYPES = {
FULL_WIDTH: { value: 'full-width', name: 'widthTypes.fullWidth' }, FULL_WIDTH: { value: 'full-width', name: 'widthTypes.fullWidth' },
} }
export const SHARE_TYPES = {
ALL: 'all',
ONLY: 'only',
EXCEPT: 'except',
}
/** /**
* A list of all the data providers that can be used in the formula field on the right * A list of all the data providers that can be used in the formula field on the right
* sidebar in the application builder. * sidebar in the application builder.

View file

@ -76,6 +76,7 @@
}, },
"elementsContext": { "elementsContext": {
"searchPlaceholder": "Search elements", "searchPlaceholder": "Search elements",
"noPageElements": "No elements found for this page",
"noElements": "No elements found" "noElements": "No elements found"
}, },
"elementType": { "elementType": {
@ -110,7 +111,18 @@
"recordSelector": "Record selector", "recordSelector": "Record selector",
"recordSelectorDescription": "A related record selector", "recordSelectorDescription": "A related record selector",
"dateTimePicker": "Date time picker", "dateTimePicker": "Date time picker",
"dateTimePickerDescription": "A date and time input field" "dateTimePickerDescription": "A date and time input field",
"header": "Multi-page header",
"headerDescription": "A container shared across pages",
"footer": "Multi-page footer",
"footerDescription": "A container shared across pages",
"notAllowedUnlessTop": "This element is allowed only at the top of the page",
"notAllowedUnlessBottom": "This element is allowed only at the bottom of the page",
"notAllowedUnlessHeader": "This element is allowed only inside the page header",
"notAllowedUnlessFooter": "This element is allowed only inside the page footer",
"notAllowedInsideContainer": "This element is not allowed inside a container",
"notAllowedInsideSameType": "This element is not allowed in a container of the same type",
"notAllowedLocation": "This element is not allowed at this location"
}, },
"addElementButton": { "addElementButton": {
"label": "Element" "label": "Element"
@ -118,7 +130,7 @@
"addElementModal": { "addElementModal": {
"title": "Add new element", "title": "Add new element",
"searchPlaceholder": "Search elements", "searchPlaceholder": "Search elements",
"disabledElementTooltip": "Unavailable inside this element" "elementInProgress": "Adding element..."
}, },
"elementMenu": { "elementMenu": {
"moveUp": "Move up", "moveUp": "Move up",
@ -142,7 +154,9 @@
"message": "Click on one of the elements to see more details" "message": "Click on one of the elements to see more details"
}, },
"pagePreview": { "pagePreview": {
"emptyMessage": "Click to create first element" "emptyMessage": "Click to create an element",
"header": "HEADER",
"footer": "FOOTER"
}, },
"elementForms": { "elementForms": {
"textInputPlaceholder": "Enter text...", "textInputPlaceholder": "Enter text...",
@ -375,6 +389,9 @@
"textName": "Text", "textName": "Text",
"numericName": "Numeric" "numericName": "Numeric"
}, },
"pageEditor": {
"pageNotFound": "Page not found"
},
"publicPage": { "publicPage": {
"siteNotFound": "Site not found", "siteNotFound": "Site not found",
"pageNotFound": "Page not found" "pageNotFound": "Page not found"
@ -842,7 +859,20 @@
"dataSourceDropdown": { "dataSourceDropdown": {
"label": "Data source", "label": "Data source",
"noDataSources": "No data sources available", "noDataSources": "No data sources available",
"noSharedDataSources": "No shared data sources available",
"shared": "shared", "shared": "shared",
"pageOnly": "this page" "pageOnly": "this page"
},
"multiPageContainerElementForm": {
"pagePosition": "Position",
"behaviour": "Behaviour",
"display": "Display",
"selectAll": "Select all",
"deselectAll": "Deselect all"
},
"pageShareType": {
"all": "On all pages",
"only": "Only on selected pages",
"except": "Exclude selected pages"
} }
} }

View file

@ -34,7 +34,7 @@ export default {
if (!this.element.data_source_id) { if (!this.element.data_source_id) {
return null return null
} }
const pages = [this.page, this.sharedPage] const pages = [this.currentPage, this.sharedPage]
return this.getPagesDataSourceById(pages, this.element.data_source_id) return this.getPagesDataSourceById(pages, this.element.data_source_id)
}, },
dataSourceType() { dataSourceType() {
@ -50,10 +50,7 @@ export default {
return this.getHasMorePage(this.element) return this.getHasMorePage(this.element)
}, },
contentLoading() { contentLoading() {
return ( return this.getLoading(this.element) && !this.elementIsInError
this.$fetchState.pending ||
(this.getLoading(this.element) && !this.elementIsInError)
)
}, },
dispatchContext() { dispatchContext() {
return DataProviderType.getAllDataSourceDispatchContext( return DataProviderType.getAllDataSourceDispatchContext(
@ -73,7 +70,7 @@ export default {
}, },
elementIsInError() { elementIsInError() {
return this.elementType.isInError({ return this.elementType.isInError({
page: this.page, page: this.elementPage,
element: this.element, element: this.element,
builder: this.builder, builder: this.builder,
}) })
@ -112,7 +109,7 @@ export default {
}, },
async fetch() { async fetch() {
if (!this.elementIsInError && this.elementType.fetchAtLoad) { if (!this.elementIsInError && this.elementType.fetchAtLoad) {
await this.fetchContent([0, this.element.items_per_page], true) await this.fetchContent([0, this.element.items_per_page])
} }
}, },
methods: { methods: {
@ -134,7 +131,7 @@ export default {
} }
try { try {
await this.fetchElementContent({ await this.fetchElementContent({
page: this.page, page: this.elementPage,
element: this.element, element: this.element,
dataSource: this.dataSource, dataSource: this.dataSource,
data: this.dispatchContext, data: this.dispatchContext,

View file

@ -1,9 +1,10 @@
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext' import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext'
import { CurrentRecordDataProviderType } from '@baserow/modules/builder/dataProviderTypes' import { CurrentRecordDataProviderType } from '@baserow/modules/builder/dataProviderTypes'
import elementForm from '@baserow/modules/builder/mixins/elementForm'
export default { export default {
mixins: [applicationContextMixin], mixins: [elementForm, applicationContextMixin],
computed: { computed: {
/** /**
* Returns the schema which the service schema property selector * Returns the schema which the service schema property selector
@ -38,7 +39,7 @@ export default {
hasCollectionAncestor() { hasCollectionAncestor() {
const { element } = this.applicationContext const { element } = this.applicationContext
const elementType = this.$registry.get('element', element.type) const elementType = this.$registry.get('element', element.type)
return elementType.hasCollectionAncestor(this.page, element) return elementType.hasCollectionAncestor(this.elementPage, element)
}, },
/** /**
* In collection element forms, the ability to configure property options * In collection element forms, the ability to configure property options
@ -96,25 +97,47 @@ export default {
return this.$store.getters['page/getSharedPage'](this.builder) return this.$store.getters['page/getSharedPage'](this.builder)
}, },
/** /**
* Returns all data sources that are available to the current page. * Returns all data sources that are available not on shared page.
* The data source will need a `type` and a valid schema. * The data source will need a `type` and a valid schema.
* @returns {Array} - The data sources the page designer can choose from. * @returns {Array} - The data sources the page designer can choose from.
*/ */
dataSources() { localDataSources() {
const pages = [this.sharedPage, this.page] if (this.elementPage.id === this.sharedPage.id) {
return this.$store.getters['dataSource/getPagesDataSources']( // If the element is on the shared page they are no local page but only
pages // shared page.
).filter((dataSource) => { return null
} else {
return this.$store.getters['dataSource/getPagesDataSources']([
this.elementPage,
]).filter((dataSource) => {
const serviceType =
dataSource.type && this.$registry.get('service', dataSource.type)
return serviceType?.getDataSchema(dataSource)
})
}
},
/**
* Returns the shared data sources.
* @returns {Array} - The shared data sources the page designer can choose from.
*/
sharedDataSources() {
// We keep only data sources that have a type and a schema.
return this.$store.getters['dataSource/getPagesDataSources']([
this.sharedPage,
]).filter((dataSource) => {
const serviceType = const serviceType =
dataSource.type && this.$registry.get('service', dataSource.type) dataSource.type && this.$registry.get('service', dataSource.type)
return serviceType?.getDataSchema(dataSource) return serviceType?.getDataSchema(dataSource)
}) })
}, },
dataSources() {
return [...(this.localDataSources || []), ...this.sharedDataSources]
},
selectedDataSource() { selectedDataSource() {
if (!this.values.data_source_id) { if (!this.values.data_source_id) {
return null return null
} }
const pages = [this.sharedPage, this.page] const pages = [this.sharedPage, this.currentPage]
return this.$store.getters['dataSource/getPagesDataSourceById']( return this.$store.getters['dataSource/getPagesDataSourceById'](
pages, pages,
this.values.data_source_id this.values.data_source_id

View file

@ -4,7 +4,7 @@ import { ThemeConfigBlockType } from '@baserow/modules/builder/themeConfigBlockT
import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext' import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext'
export default { export default {
inject: ['workspace', 'builder', 'page', 'mode'], inject: ['workspace', 'builder', 'elementPage', 'mode'],
mixins: [element, applicationContextMixin], mixins: [element, applicationContextMixin],
props: { props: {
element: { element: {

View file

@ -1,21 +1,20 @@
import element from '@baserow/modules/builder/mixins/element' import element from '@baserow/modules/builder/mixins/element'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { PLACEMENTS } from '@baserow/modules/builder/enums' import { DIRECTIONS } from '@baserow/modules/builder/enums'
export default { export default {
mixins: [element], mixins: [element],
props: {
children: {
type: Array,
required: false,
default: () => [],
},
},
computed: { computed: {
...mapGetters({ ...mapGetters({
elementSelected: 'element/getSelected', elementSelected: 'element/getSelected',
}), }),
PLACEMENTS: () => PLACEMENTS, DIRECTIONS: () => DIRECTIONS,
children() {
return this.$store.getters['element/getChildren'](
this.elementPage,
this.element
)
},
elementSelectedId() { elementSelectedId() {
return this.elementSelected?.id return this.elementSelected?.id
}, },

View file

@ -5,7 +5,7 @@ import applicationContextMixin from '@baserow/modules/builder/mixins/application
import { ThemeConfigBlockType } from '@baserow/modules/builder/themeConfigBlockTypes' import { ThemeConfigBlockType } from '@baserow/modules/builder/themeConfigBlockTypes'
export default { export default {
inject: ['workspace', 'builder', 'page', 'mode'], inject: ['workspace', 'builder', 'currentPage', 'elementPage', 'mode'],
mixins: [applicationContextMixin], mixins: [applicationContextMixin],
props: { props: {
element: { element: {
@ -17,7 +17,7 @@ export default {
workflowActionsInProgress() { workflowActionsInProgress() {
const workflowActions = this.$store.getters[ const workflowActions = this.$store.getters[
'workflowAction/getElementWorkflowActions' 'workflowAction/getElementWorkflowActions'
](this.page, this.element.id) ](this.elementPage, this.element.id)
const { recordIndexPath } = this.applicationContext const { recordIndexPath } = this.applicationContext
const dispatchedById = this.elementType.uniqueElementId( const dispatchedById = this.elementType.uniqueElementId(
this.element, this.element,
@ -38,7 +38,7 @@ export default {
}, },
elementIsInError() { elementIsInError() {
return this.elementType.isInError({ return this.elementType.isInError({
page: this.page, page: this.elementPage,
element: this.element, element: this.element,
builder: this.builder, builder: this.builder,
}) })
@ -96,7 +96,7 @@ export default {
const workflowActions = this.$store.getters[ const workflowActions = this.$store.getters[
'workflowAction/getElementWorkflowActions' 'workflowAction/getElementWorkflowActions'
](this.page, this.element.id).filter( ](this.elementPage, this.element.id).filter(
({ event: eventName }) => eventName === event.name ({ event: eventName }) => eventName === event.name
) )

View file

@ -3,7 +3,7 @@ import { ThemeConfigBlockType } from '@baserow/modules/builder/themeConfigBlockT
import form from '@baserow/modules/core/mixins/form' import form from '@baserow/modules/core/mixins/form'
export default { export default {
inject: ['workspace', 'builder', 'page', 'mode'], inject: ['workspace', 'builder', 'currentPage', 'elementPage', 'mode'],
mixins: [form], mixins: [form],
computed: { computed: {
themeConfigBlocks() { themeConfigBlocks() {

View file

@ -5,7 +5,18 @@ import { clone } from '@baserow/modules/core/utils/object'
import { notifyIf } from '@baserow/modules/core/utils/error' import { notifyIf } from '@baserow/modules/core/utils/error'
export default { export default {
inject: ['workspace', 'builder', 'page'], inject: ['workspace', 'builder', 'applicationContext'],
provide() {
return {
applicationContext: {
...this.applicationContext,
element: this.element,
page: this.elementPage,
},
// We add the current element page
elementPage: this.elementPage,
}
},
computed: { computed: {
...mapGetters({ ...mapGetters({
element: 'element/getSelected', element: 'element/getSelected',
@ -20,11 +31,19 @@ export default {
parentElement() { parentElement() {
return this.$store.getters['element/getElementById']( return this.$store.getters['element/getElementById'](
this.page, this.elementPage,
this.element?.parent_element_id this.element?.parent_element_id
) )
}, },
elementPage() {
// We use the page from the element itself
return this.$store.getters['page/getById'](
this.builder,
this.element.page_id
)
},
defaultValues() { defaultValues() {
return this.element return this.element
}, },
@ -57,7 +76,7 @@ export default {
if (Object.keys(differences).length > 0) { if (Object.keys(differences).length > 0) {
try { try {
await this.actionDebouncedUpdateSelectedElement({ await this.actionDebouncedUpdateSelectedElement({
page: this.page, page: this.elementPage,
// Here we clone the values to prevent // Here we clone the values to prevent
// "modification outside of the store" error // "modification outside of the store" error
values: clone(differences), values: clone(differences),

View file

@ -29,7 +29,7 @@ export default {
}, },
formElementData() { formElementData() {
return this.$store.getters['formData/getElementFormEntry']( return this.$store.getters['formData/getElementFormEntry'](
this.page, this.elementPage,
this.uniqueElementId this.uniqueElementId
) )
}, },
@ -38,7 +38,7 @@ export default {
}, },
formElementInvalid() { formElementInvalid() {
return this.$store.getters['formData/getElementInvalid']( return this.$store.getters['formData/getElementInvalid'](
this.page, this.elementPage,
this.uniqueElementId this.uniqueElementId
) )
}, },
@ -52,7 +52,7 @@ export default {
}, },
formElementTouched() { formElementTouched() {
return this.$store.getters['formData/getElementTouched']( return this.$store.getters['formData/getElementTouched'](
this.page, this.elementPage,
this.uniqueElementId this.uniqueElementId
) )
}, },
@ -62,7 +62,7 @@ export default {
*/ */
isDescendantOfFormContainer() { isDescendantOfFormContainer() {
return this.$store.getters['element/getAncestors']( return this.$store.getters['element/getAncestors'](
this.page, this.elementPage,
this.element this.element
).some(({ type }) => type === FormContainerElementType.getType()) ).some(({ type }) => type === FormContainerElementType.getType())
}, },
@ -85,7 +85,7 @@ export default {
}, },
setFormData(value) { setFormData(value) {
return this.actionSetFormData({ return this.actionSetFormData({
page: this.page, page: this.elementPage,
uniqueElementId: this.uniqueElementId, uniqueElementId: this.uniqueElementId,
payload: { payload: {
value, value,
@ -106,7 +106,7 @@ export default {
*/ */
onFormElementTouch() { onFormElementTouch() {
this.$store.dispatch('formData/setElementTouched', { this.$store.dispatch('formData/setElementTouched', {
page: this.page, page: this.elementPage,
wasTouched: true, wasTouched: true,
uniqueElementId: this.uniqueElementId, uniqueElementId: this.uniqueElementId,
}) })

View file

@ -2,7 +2,7 @@ import elementForm from '@baserow/modules/builder/mixins/elementForm'
import { DATA_PROVIDERS_ALLOWED_FORM_ELEMENTS } from '@baserow/modules/builder/enums' import { DATA_PROVIDERS_ALLOWED_FORM_ELEMENTS } from '@baserow/modules/builder/enums'
export default { export default {
inject: ['workspace', 'builder', 'page', 'mode'], inject: ['workspace', 'builder', 'currentPage', 'elementPage', 'mode'],
mixins: [elementForm], mixins: [elementForm],
provide() { provide() {
return { return {

View file

@ -1,5 +1,4 @@
import UserSourceService from '@baserow/modules/core/services/userSource' import UserSourceService from '@baserow/modules/core/services/userSource'
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import { import {
DEFAULT_USER_ROLE_PREFIX, DEFAULT_USER_ROLE_PREFIX,
@ -12,7 +11,7 @@ import {
} from '@baserow/modules/builder/constants' } from '@baserow/modules/builder/constants'
export default { export default {
mixins: [elementForm], inject: ['builder'],
data() { data() {
return { return {
allRoles: [], allRoles: [],

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="page-editor"> <div class="page-editor">
<PageHeader :page="page" /> <PageHeader />
<div class="layout__col-2-2 page-editor__content"> <div class="layout__col-2-2 page-editor__content">
<div :style="{ width: `calc(100% - ${panelWidth}px)` }"> <div :style="{ width: `calc(100% - ${panelWidth}px)` }">
<PagePreview /> <PagePreview />
@ -34,7 +34,7 @@ export default {
return { return {
workspace: this.workspace, workspace: this.workspace,
builder: this.builder, builder: this.builder,
page: this.page, currentPage: this.currentPage,
mode, mode,
formulaComponent: ApplicationBuilderFormulaInput, formulaComponent: ApplicationBuilderFormulaInput,
applicationContext: this.applicationContext, applicationContext: this.applicationContext,
@ -92,7 +92,7 @@ export default {
next() next()
}, },
layout: 'app', layout: 'app',
async asyncData({ store, params, error, $registry }) { async asyncData({ store, params, error, $registry, app }) {
const builderId = parseInt(params.builderId) const builderId = parseInt(params.builderId)
const pageId = parseInt(params.pageId) const pageId = parseInt(params.pageId)
@ -115,7 +115,14 @@ export default {
const page = store.getters['page/getById'](builder, pageId) const page = store.getters['page/getById'](builder, pageId)
await builderApplicationType.loadExtraData(builder, page, mode) if (page.shared) {
return error({
statusCode: 404,
message: app.i18n.t('pageEditor.pageNotFound'),
})
}
await builderApplicationType.loadExtraData(builder, mode)
await Promise.all([ await Promise.all([
store.dispatch('dataSource/fetch', { store.dispatch('dataSource/fetch', {
@ -139,14 +146,17 @@ export default {
data.workspace = workspace data.workspace = workspace
data.builder = builder data.builder = builder
data.page = page data.currentPage = page
} catch (e) { } catch (e) {
// In case of a network error we want to fail hard. // In case of a network error we want to fail hard.
if (e.response === undefined && !(e instanceof StoreItemLookupError)) { if (e.response === undefined && !(e instanceof StoreItemLookupError)) {
throw e throw e
} }
return error({ statusCode: 404, message: 'page not found.' }) return error({
statusCode: 404,
message: app.i18n.t('pageEditor.pageNotFound'),
})
} }
return data return data
@ -155,12 +165,13 @@ export default {
applicationContext() { applicationContext() {
return { return {
builder: this.builder, builder: this.builder,
page: this.page,
mode, mode,
} }
}, },
dataSources() { dataSources() {
return this.$store.getters['dataSource/getPageDataSources'](this.page) return this.$store.getters['dataSource/getPageDataSources'](
this.currentPage
)
}, },
sharedPage() { sharedPage() {
return this.$store.getters['page/getSharedPage'](this.builder) return this.$store.getters['page/getSharedPage'](this.builder)
@ -173,7 +184,7 @@ export default {
dispatchContext() { dispatchContext() {
return DataProviderType.getAllDataSourceDispatchContext( return DataProviderType.getAllDataSourceDispatchContext(
this.$registry.getAll('builderDataProvider'), this.$registry.getAll('builderDataProvider'),
this.applicationContext { ...this.applicationContext, page: this.currentPage }
) )
}, },
// Separate dispatch context for application level shared data sources // Separate dispatch context for application level shared data sources
@ -195,7 +206,7 @@ export default {
this.$store.dispatch( this.$store.dispatch(
'dataSourceContent/debouncedFetchPageDataSourceContent', 'dataSourceContent/debouncedFetchPageDataSourceContent',
{ {
page: this.page, page: this.currentPage,
data: this.dispatchContext, data: this.dispatchContext,
mode: this.mode, mode: this.mode,
} }
@ -227,7 +238,7 @@ export default {
this.$store.dispatch( this.$store.dispatch(
'dataSourceContent/debouncedFetchPageDataSourceContent', 'dataSourceContent/debouncedFetchPageDataSourceContent',
{ {
page: this.page, page: this.currentPage,
data: newDispatchContext, data: newDispatchContext,
mode: this.mode, mode: this.mode,
} }

View file

@ -3,10 +3,10 @@
<Toasts></Toasts> <Toasts></Toasts>
<PageContent <PageContent
v-if="canViewPage" v-if="canViewPage"
:page="page"
:path="path" :path="path"
:params="params" :params="params"
:elements="elements" :elements="elements"
:shared-elements="sharedElements"
/> />
</div> </div>
</template> </template>
@ -45,7 +45,7 @@ export default {
return { return {
workspace: this.workspace, workspace: this.workspace,
builder: this.builder, builder: this.builder,
page: this.page, currentPage: this.currentPage,
mode: this.mode, mode: this.mode,
formulaComponent: ApplicationBuilderFormulaInput, formulaComponent: ApplicationBuilderFormulaInput,
applicationContext: this.applicationContext, applicationContext: this.applicationContext,
@ -111,6 +111,12 @@ export default {
store.dispatch('dataSource/fetchPublished', { store.dispatch('dataSource/fetchPublished', {
page: sharedPage, page: sharedPage,
}), }),
store.dispatch('element/fetchPublished', {
page: sharedPage,
}),
store.dispatch('workflowAction/fetchPublished', {
page: sharedPage,
}),
]) ])
await DataProviderType.initOnceAll( await DataProviderType.initOnceAll(
@ -170,6 +176,13 @@ export default {
} }
const [pageFound, path, pageParamsValue] = found const [pageFound, path, pageParamsValue] = found
// Handle 404
if (pageFound.shared) {
return error({
statusCode: 404,
message: app.i18n.t('publicPage.pageNotFound'),
})
}
const page = await store.getters['page/getById'](builder, pageFound.id) const page = await store.getters['page/getById'](builder, pageFound.id)
@ -217,7 +230,7 @@ export default {
return { return {
builder, builder,
page, currentPage: page,
path, path,
params, params,
mode, mode,
@ -226,7 +239,7 @@ export default {
head() { head() {
return { return {
titleTemplate: '', titleTemplate: '',
title: this.page.name, title: this.currentPage.name,
bodyAttrs: { bodyAttrs: {
class: 'public-page', class: 'public-page',
}, },
@ -235,12 +248,11 @@ export default {
}, },
computed: { computed: {
elements() { elements() {
return this.$store.getters['element/getRootElements'](this.page) return this.$store.getters['element/getRootElements'](this.currentPage)
}, },
applicationContext() { applicationContext() {
return { return {
builder: this.builder, builder: this.builder,
page: this.page,
pageParamsValue: this.params, pageParamsValue: this.params,
mode: this.mode, mode: this.mode,
} }
@ -253,13 +265,13 @@ export default {
return userCanViewPage( return userCanViewPage(
this.$store.getters['userSourceUser/getUser'](this.builder), this.$store.getters['userSourceUser/getUser'](this.builder),
this.$store.getters['userSourceUser/isAuthenticated'](this.builder), this.$store.getters['userSourceUser/isAuthenticated'](this.builder),
this.page this.currentPage
) )
}, },
dispatchContext() { dispatchContext() {
return DataProviderType.getAllDataSourceDispatchContext( return DataProviderType.getAllDataSourceDispatchContext(
this.$registry.getAll('builderDataProvider'), this.$registry.getAll('builderDataProvider'),
this.applicationContext { ...this.applicationContext, page: this.currentPage }
) )
}, },
// Separate dispatch context for application level data sources // Separate dispatch context for application level data sources
@ -277,6 +289,9 @@ export default {
this.sharedPage this.sharedPage
) )
}, },
sharedElements() {
return this.$store.getters['element/getRootElements'](this.sharedPage)
},
isAuthenticated() { isAuthenticated() {
return this.$store.getters['userSourceUser/isAuthenticated'](this.builder) return this.$store.getters['userSourceUser/isAuthenticated'](this.builder)
}, },
@ -307,7 +322,7 @@ export default {
this.$store.dispatch( this.$store.dispatch(
'dataSourceContent/debouncedFetchPageDataSourceContent', 'dataSourceContent/debouncedFetchPageDataSourceContent',
{ {
page: this.page, page: this.currentPage,
data: newDispatchContext, data: newDispatchContext,
mode: this.mode, mode: this.mode,
} }
@ -327,16 +342,27 @@ export default {
{ {
page: this.sharedPage, page: this.sharedPage,
data: newDispatchContext, data: newDispatchContext,
mode: this.mode,
} }
) )
} }
}, },
}, },
async isAuthenticated() { async isAuthenticated() {
// When the user logs in or out, we need to refetch the elements and actions // When the user login or logout, we need to refetch the elements and actions
// as they might have changed. // as they might have changed
this.$store.dispatch('element/fetchPublished', { page: this.page }) await this.$store.dispatch('element/fetchPublished', {
this.$store.dispatch('workflowAction/fetchPublished', { page: this.page }) page: this.sharedPage,
})
await this.$store.dispatch('element/fetchPublished', {
page: this.currentPage,
})
await this.$store.dispatch('workflowAction/fetchPublished', {
page: this.currentPage,
})
await this.$store.dispatch('workflowAction/fetchPublished', {
page: this.sharedPage,
})
// If the user is on a hidden page, redirect them to the Login page if possible. // If the user is on a hidden page, redirect them to the Login page if possible.
await this.maybeRedirectUserToLoginPage() await this.maybeRedirectUserToLoginPage()

Some files were not shown because too many files have changed in this diff Show more