1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-07 06:15:36 +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:
page = PageHandler().get_page(
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?

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.registries import element_type_registry
from baserow.contrib.builder.models import Builder
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.pages.models import Page
from baserow.core.services.registries import service_type_registry
from baserow.core.user_sources.models import UserSource
@ -112,6 +113,7 @@ class PublicElementSerializer(serializers.ModelSerializer):
"page_id",
"type",
"order",
"page_id",
"parent_element_id",
"place_in_container",
"visibility",
@ -272,7 +274,7 @@ class PublicBuilderSerializer(serializers.ModelSerializer):
: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

View file

@ -133,6 +133,7 @@ class ElementsView(APIView):
@map_exceptions(
{
PageDoesNotExist: ERROR_PAGE_DOES_NOT_EXIST,
ElementNotInSamePage: ERROR_ELEMENT_NOT_IN_SAME_PAGE,
}
)
@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.
"""
pages = instance.page_set.all()
pages = PageHandler().get_pages(instance)
user = self.context.get("user")
request = self.context.get("request")

View file

@ -167,7 +167,12 @@ class BuilderApplicationType(ApplicationType):
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 = [
PageHandler().export_page(

View file

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

View file

@ -33,6 +33,7 @@ from baserow.contrib.builder.elements.mixins import (
CollectionElementWithFieldsTypeMixin,
ContainerElementTypeMixin,
FormElementTypeMixin,
MultiPageElementTypeMixin,
)
from baserow.contrib.builder.elements.models import (
INPUT_TEXT_TYPES,
@ -43,7 +44,9 @@ from baserow.contrib.builder.elements.models import (
ColumnElement,
DateTimePickerElement,
Element,
FooterElement,
FormContainerElement,
HeaderElement,
HeadingElement,
IFrameElement,
ImageElement,
@ -117,7 +120,7 @@ class ColumnElementType(ContainerElementTypeMixin, ElementType):
type = "column"
model_class = ColumnElement
class SerializedDict(ElementDict):
class SerializedDict(ContainerElementTypeMixin.SerializedDict):
column_amount: int
column_gap: int
alignment: str
@ -191,8 +194,8 @@ class ColumnElementType(ContainerElementTypeMixin, ElementType):
"""
return [
element_type.type
for element_type in element_type_registry.get_all()
element_type
for element_type in super().child_types_allowed
if element_type.type != self.type
]
@ -210,7 +213,7 @@ class FormContainerElementType(ContainerElementTypeMixin, ElementType):
]
simple_formula_fields = ["submit_button_label"]
class SerializedDict(ElementDict):
class SerializedDict(ContainerElementTypeMixin.SerializedDict):
submit_button_label: BaserowFormula
reset_initial_values_post_submission: bool
@ -261,8 +264,8 @@ class FormContainerElementType(ContainerElementTypeMixin, ElementType):
"""
return [
element_type.type
for element_type in element_type_registry.get_all()
element_type
for element_type in super().child_types_allowed
if element_type.type != self.type
]
@ -858,6 +861,16 @@ class NavigationElementManager:
"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(
self, values: Dict, instance: Optional[LinkElement] = None
):
@ -1939,3 +1952,35 @@ class DateTimePickerElementType(FormElementTypeMixin, ElementType):
"include_time": False,
"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,
)
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.database.fields.utils import get_field_id_from_field_key
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.
: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(
self, container_element: ContainerElement, places_removed: List[str]
@ -128,6 +133,8 @@ class ContainerElementTypeMixin:
:raises DRFValidationError: If the place in container is invalid
"""
return True
class CollectionElementTypeMixin:
is_collection_element = True
@ -738,3 +745,119 @@ class FormElementTypeMixin:
)
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,
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.mixins import BuilderInstanceWithFormulaMixin
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.database.db.functions import RandomUUID
from baserow.core.registry import (
CustomFieldsInstanceMixin,
@ -58,6 +59,9 @@ class ElementType(
parent_property_name = "page"
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`.
# By default, the priority is `0`, the lowest value. If this property is
# not overridden, then the instance is imported last.
@ -80,25 +84,62 @@ class ElementType(
parent_element_id = values.get(
"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:
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(
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}"
)
if place_in_container is not None:
parent_element_type.validate_place_in_container(
place_in_container, parent_element
# If we have a parent, we validate the place is accepted by this container.
parent_element.get_type().validate_place_in_container(
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):
"""
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:
base_queryset = Page.objects
base_queryset = Page.objects_with_shared
try:
return base_queryset.select_related("builder", "builder__workspace").get(
id=page_id
)
return base_queryset.select_related("builder__workspace").get(id=page_id)
except Page.DoesNotExist:
raise PageDoesNotExist()
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
)
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:
"""
Creates the shared page of the given builder.
@ -153,7 +167,7 @@ class PageHandler:
self.is_page_path_unique(
page.builder,
path,
base_queryset=Page.objects.exclude(
base_queryset=Page.objects_with_shared.exclude(
id=page.id
), # We don't want to conflict with the current page
raises=True,
@ -188,7 +202,7 @@ class PageHandler:
"""
if base_qs is None:
base_qs = Page.objects.filter(builder=builder, shared=False)
base_qs = Page.objects.filter(builder=builder)
try:
full_order = Page.order_objects(base_qs, order)
@ -345,7 +359,7 @@ class PageHandler:
: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)

View file

@ -20,6 +20,16 @@ if typing.TYPE_CHECKING:
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(
HierarchicalModelMixin,
TrashableModelMixin,
@ -36,6 +46,9 @@ class Page(
ALLOW_ALL_EXCEPT = "allow_all_except"
DISALLOW_ALL_EXCEPT = "disallow_all_except"
objects = PageWithoutSharedManager()
objects_with_shared = models.Manager()
builder = models.ForeignKey("builder.Builder", on_delete=models.CASCADE)
order = models.PositiveIntegerField()
name = models.CharField(max_length=255)

View file

@ -37,8 +37,7 @@ class PageService:
:return: The model instance of the Page
"""
base_queryset = Page.objects.select_related("builder", "builder__workspace")
page = self.handler.get_page(page_id, base_queryset=base_queryset)
page = self.handler.get_page(page_id)
CoreHandler().check_permissions(
user,
@ -148,7 +147,8 @@ class PageService:
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,
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(
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)
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(
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)
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.
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 == {
"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.
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 == {
"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):
user, token = data_fixture.create_user_and_token()
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})
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):
user, token = data_fixture.create_user_and_token()
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)
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):
user, token = data_fixture.create_user_and_token()
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})
response = api_client.delete(

View file

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

View file

@ -48,7 +48,7 @@ def test_validate_login_page_id_raises_error_if_shared_page(
builder = builder_fixture["builder"]
# 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(
reverse("api:applications:item", kwargs={"application_id": builder.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(
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(
user=user,

View file

@ -94,7 +94,7 @@ def test_get_data_sources(data_fixture, specific):
@pytest.mark.django_db
def test_get_data_sources_with_shared(data_fixture):
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(
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.workspace is None
assert domain1.published_to.page_set.count() == builder.page_set.count()
assert (
domain1.published_to.page_set.exclude(shared=True).first().element_set.count()
== 2
)
assert domain1.published_to.page_set.first().element_set.count() == 2
assert Builder.objects.count() == 2

View file

@ -1,6 +1,7 @@
from decimal import Decimal
import pytest
from rest_framework.exceptions import ValidationError as DRFValidationError
from baserow.contrib.builder.elements.element_types import (
ColumnElementType,
@ -27,9 +28,13 @@ def pytest_generate_tests(metafunc):
@pytest.mark.django_db
def test_create_element(data_fixture, element_type):
page = data_fixture.create_builder_page()
shared_page = page.builder.shared_page
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)
assert element.page.id == page.id
@ -41,6 +46,34 @@ def test_create_element(data_fixture, element_type):
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
def test_get_element(data_fixture):
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):
user = data_fixture.create_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")
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
for element_type in element_type_registry.get_all()
if element_type.type != FormContainerElementType.type
and not element_type.is_multi_page_element
],
)
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
@ -1347,10 +1350,13 @@ def test_choice_element_integer_option_values(data_fixture):
element_type.type
for element_type in element_type_registry.get_all()
if element_type.type != ColumnElementType.type
and not element_type.is_multi_page_element
],
)
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
@ -1513,7 +1519,7 @@ def test_repeat_element_import_export(data_fixture):
imported_field = imported_table.field_set.get()
# 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_root_repeat = imported_page.element_set.get(
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,
)
imported_builder = imported_apps[-1]
imported_element = (
imported_builder.page_set.filter(shared=False)
.first()
.element_set.first()
.specific
)
imported_element = imported_builder.page_set.first().element_set.first().specific
# Check that the formula for option name suffix was updated with the new mapping
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
def test_delete_shared_page(data_fixture):
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):
PageHandler().delete_page(shared_page)
@ -114,7 +114,7 @@ def test_update_page(data_fixture):
@pytest.mark.django_db
def test_update_shared_page(data_fixture):
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):
PageHandler().update_page(shared_page, name="new")
@ -158,7 +158,7 @@ def test_order_pages(data_fixture):
@pytest.mark.django_db
def test_order_pages_page_not_in_builder(data_fixture):
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_two = data_fixture.create_builder_page(builder=builder, order=2)
@ -189,7 +189,7 @@ def test_duplicate_page(data_fixture):
@pytest.mark.django_db
def test_duplicate_shared_page(data_fixture):
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):
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()
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)
assert Page.objects.count() == 3 # With demo data
assert Page.objects.count() == 2 # With demo data
@pytest.mark.django_db
@ -119,7 +119,7 @@ def test_builder_application_export(data_fixture):
user = data_fixture.create_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)
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.page_set.exclude(shared=True).count() == 2
assert builder.page_set.filter(shared=True).count() == 1
assert builder.page_set.count() == 2
# 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
first_integration = builder.integrations.first().specific
@ -1011,7 +1014,7 @@ def test_builder_application_import(data_fixture):
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 page2.element_set.count() == 1
@ -1371,7 +1374,7 @@ def test_builder_application_imports_correct_default_roles(data_fixture):
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]
# 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
# 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):
# Default Role's User Source should have changed for new elements
if role.startswith(prefix):
@ -1532,5 +1535,5 @@ def test_ensure_new_element_roles_are_sanitized_during_import_for_roles(
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

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(
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)
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()
data_fixture.create_template(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)
element_2 = data_fixture.create_builder_text_element(page=page_2)
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 = [
(
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],
),
(

View file

@ -603,7 +603,7 @@ def test_export_import_local_baserow_upsert_row_service(
imported_table = imported_database.table_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_integration = imported_builder.integrations.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()
# 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_filters = [
{"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 }) => {
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 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()
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()
url = reverse(

View file

@ -65,7 +65,7 @@ import { mapActions } from 'vuex'
export default {
name: 'AuthFormElement',
mixins: [element, form, error],
inject: ['page', 'builder'],
inject: ['elementPage', 'builder'],
props: {
/**
* @type {Object}
@ -128,7 +128,7 @@ export default {
if (!found) {
// If the user_source has been removed we need to update the element
this.actionForceUpdateElement({
page: this.page,
page: this.elementPage,
element: this.element,
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
if (!builder._loadedOnce) {
const sharedPage = store.getters['page/getSharedPage'](builder)
await Promise.all([
store.dispatch('userSource/fetch', {
application: builder,
@ -98,8 +99,12 @@ export class BuilderApplicationType extends ApplicationType {
}),
// Fetch shared data sources
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

View file

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

View file

@ -61,7 +61,16 @@ export default {
components: { DataSourceForm },
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: {
dataSourceId: { type: Number, required: false, default: null },
},
@ -74,7 +83,9 @@ export default {
},
computed: {
dataSources() {
return this.$store.getters['dataSource/getPageDataSources'](this.page)
return this.$store.getters['dataSource/getPageDataSources'](
this.currentPage
)
},
sharedPage() {
return this.$store.getters['page/getSharedPage'](this.builder)
@ -108,7 +119,7 @@ export default {
// edited. Sometimes it's the shared page.
dataSourcePage() {
if (!this.dataSource) {
return this.page
return this.currentPage
}
return this.$store.getters['page/getById'](
this.builder,
@ -116,7 +127,11 @@ export default {
)
},
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: {
@ -145,7 +160,7 @@ export default {
try {
if (this.create) {
const createdDataSource = await this.actionCreateDataSource({
page: this.page,
page: this.currentPage,
values,
})
this.actualDataSourceId = createdDataSource.id

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,15 @@
<template>
<li :key="element.id" class="elements-list__item">
<a
class="elements-list__item-link"
:class="{
'elements-list__item-link--selected': element.id === elementSelectedId,
}"
@click="$emit('select', element)"
>
<span class="elements-list__item-name">
<i :class="`${elementType.iconClass} elements-list__item-icon`"></i>
<span class="elements-list__item-name-text">{{
<li
:key="element.id"
class="elements-list-item"
:class="{
'elements-list-item--selected': element.id === elementSelectedId,
}"
>
<a class="elements-list-item__link" @click="$emit('select', element)">
<span class="elements-list-item__name">
<i :class="`${elementType.iconClass} elements-list-item__icon`"></i>
<span class="elements-list-item__name-text">{{
elementType.getDisplayName(element, applicationContext)
}}</span>
</span>
@ -33,7 +33,7 @@ export default {
ElementsList: () =>
import('@baserow/modules/builder/components/elements/ElementsList'),
},
inject: ['builder', 'page', 'mode'],
inject: ['builder', 'mode'],
props: {
element: {
type: Object,
@ -55,8 +55,18 @@ export default {
elementType() {
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() {
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`.
@ -76,7 +86,7 @@ export default {
applicationContext() {
return {
builder: this.builder,
page: this.page,
page: this.elementPage,
mode: this.mode,
element: this.element,
}

View file

@ -75,7 +75,9 @@ export default {
}
if (this.target === 'self' && this.url.startsWith('/')) {
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>
</template>
<template #empty-state>
<div class="ab-table__empty-message">
{{ emptyStateMessage }}
<div class="ab-table__empty-state">
<template v-if="contentLoading">
<div class="loading-spinner" />
</template>
<template v-else>
{{ $t('abTable.empty') }}
</template>
</div>
</template>
</BaserowTable>

View file

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

View file

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

View file

@ -4,14 +4,13 @@
v-if="
mode === 'editing' &&
children.length === 0 &&
$hasPermission('builder.page.create_element', page, workspace.id)
$hasPermission('builder.page.create_element', currentPage, workspace.id)
"
>
<AddElementZone @add-element="showAddElementModal"></AddElementZone>
<AddElementModal
ref="addElementModal"
:page="page"
:element-types-allowed="elementType.childElementTypes(page, element)"
:page="elementPage"
></AddElementModal>
</div>
<div v-else>
@ -20,7 +19,7 @@
v-if="mode === 'editing'"
:key="child.id"
:element="child"
@move="moveElement(child, $event)"
@move="$emit('move', $event)"
/>
<PageElement
v-else
@ -42,15 +41,12 @@
</template>
<script>
import { mapActions } from 'vuex'
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'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
name: 'FormContainerElement',
@ -64,7 +60,6 @@ export default {
props: {
/**
* @type {Object}
* @property button_color - The submit button's color.
* @property submit_button_label - The label of the submit button
* @property reset_initial_values_post_submission - Whether to reset the form
* elements to their initial value or not, following a successful submission.
@ -83,7 +78,7 @@ export default {
},
getFormElementDescendants() {
const descendants = this.$store.getters['element/getDescendants'](
this.page,
this.elementPage,
this.element
)
return descendants
@ -107,7 +102,7 @@ export default {
recordIndexPath
)
return this.$store.getters['formData/getElementInvalid'](
this.page,
this.elementPage,
uniqueElementId
)
}
@ -115,9 +110,6 @@ export default {
},
},
methods: {
...mapActions({
actionMoveElement: 'element/moveElement',
}),
/*
* Responsible for marking all form element descendents in this form container
* as touched, or not touched, depending on what we're achieving in validation.
@ -131,7 +123,7 @@ export default {
recordIndexPath
)
this.$store.dispatch('formData/setElementTouched', {
page: this.page,
page: this.elementPage,
wasTouched,
uniqueElementId,
})
@ -169,7 +161,7 @@ export default {
),
}
this.$store.dispatch('formData/setFormData', {
page: this.page,
page: this.elementPage,
payload,
uniqueElementId,
})
@ -197,17 +189,6 @@ export default {
parentElementId: this.element.id,
})
},
async moveElement(element, placement) {
try {
await this.actionMoveElement({
page: this.page,
element,
placement,
})
} catch (error) {
notifyIf(error)
}
},
},
}
</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,
],
}"
@move="moveElement(child, $event)"
@move="$emit('move', $event)"
/>
<!-- Other iterations are not editable -->
<!-- Override the mode so that any children are in public mode -->
@ -68,10 +68,7 @@
></AddElementZone>
<AddElementModal
ref="addElementModal"
:page="page"
:element-types-allowed="
elementType.childElementTypes(page, element)
"
:page="elementPage"
></AddElementModal>
</template>
</template>
@ -92,10 +89,7 @@
></AddElementZone>
<AddElementModal
ref="addElementModal"
:page="page"
:element-types-allowed="
elementType.childElementTypes(page, element)
"
:page="elementPage"
></AddElementModal>
</template>
<!-- We have no contents, but we do have children in edit mode -->
@ -106,7 +100,7 @@
v-for="child in children"
:key="child.id"
:element="child"
@move="moveElement(child, $event)"
@move="$emit('move', $event)"
/>
</template>
</template>
@ -127,7 +121,7 @@
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
import { mapGetters } from 'vuex'
import AddElementZone from '@baserow/modules/builder/components/elements/AddElementZone'
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 ElementPreview from '@baserow/modules/builder/components/elements/ElementPreview'
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 { RepeatElementType } from '@baserow/modules/builder/elementTypes'
import CollectionElementHeader from '@baserow/modules/builder/components/elements/components/CollectionElementHeader'
@ -173,7 +166,7 @@ export default {
},
repeatElementIsNested() {
return this.elementType.hasAncestorOfType(
this.page,
this.elementPage,
this.element,
RepeatElementType.getType()
)
@ -212,26 +205,12 @@ export default {
},
},
methods: {
...mapActions({
actionMoveElement: 'element/moveElement',
}),
showAddElementModal() {
this.$refs.addElementModal.show({
placeInContainer: null,
parentElementId: this.element.id,
})
},
async moveElement(element, placement) {
try {
await this.actionMoveElement({
page: this.page,
element,
placement,
})
} catch (error) {
notifyIf(error)
}
},
},
}
</script>

View file

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

View file

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

View file

@ -228,7 +228,7 @@ export default {
CHOICE_OPTION_TYPES: () => CHOICE_OPTION_TYPES,
element() {
return this.$store.getters['element/getElementById'](
this.page,
this.elementPage,
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
v-model="values.data_source_id"
small
:data-sources="listDataSources"
:page="page"
:shared-data-sources="listSharedDataSources"
:local-data-sources="listLocalDataSources"
>
<template #chooseValueState>
{{ $t('recordSelectorElementForm.noDataSourceMessage') }}
@ -186,10 +186,18 @@ export default {
},
computed: {
// For now, RecordSelector only supports data sources that return arrays
listDataSources() {
return this.dataSources.filter(
listLocalDataSources() {
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.type &&
this.$registry.get('service', dataSource.type).returnsList
)
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,24 +10,49 @@
ref="search"
v-model="search"
type="text"
class="elements-list__search-input"
class="elements-context__search-input"
:placeholder="$t('elementsContext.searchPlaceholder')"
/>
</div>
<ElementsList
v-if="elementsVisible"
:elements="rootElements"
:filtered-search-elements="filteredSearchElements"
@select="selectElement($event)"
/>
<div v-else class="context__description">
{{ $t('elementsContext.noElements') }}
<div class="elements-context__elements">
<ElementsList
v-if="headerElementsVisible"
:elements="headerElements"
:filtered-search-elements="filteredHeaderElements"
@select="selectElement($event)"
/>
<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
v-if="$hasPermission('builder.page.create_element', page, workspace.id)"
class="elements-list__footer"
v-if="
$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
:class="{
'margin-top-1': !elementsVisible,
@ -37,9 +62,11 @@
</div>
</div>
<AddElementModal
v-if="$hasPermission('builder.page.create_element', page, workspace.id)"
v-if="
$hasPermission('builder.page.create_element', currentPage, workspace.id)
"
ref="addElementModal"
:page="page"
:page="currentPage"
@element-added="onElementAdded"
/>
</Context>
@ -52,27 +79,110 @@ import AddElementButton from '@baserow/modules/builder/components/elements/AddEl
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal'
import { mapActions } from 'vuex'
import { isSubstringOfStrings } from '@baserow/modules/core/utils/string'
import { PAGE_PLACES } from '@baserow/modules/builder/enums'
export default {
name: 'ElementsContext',
components: { AddElementModal, AddElementButton, ElementsList },
mixins: [context],
inject: ['workspace', 'page', 'builder', 'mode'],
inject: ['workspace', 'currentPage', 'builder', 'mode'],
data() {
return {
search: null,
addingElementType: null,
}
},
computed: {
isSearching() {
return Boolean(this.search)
},
elementsVisible() {
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)
)
},
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() {
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
@ -102,34 +212,30 @@ export default {
* - Repeat
* - Image
*/
filteredSearchElements() {
filterElements(elements, page) {
let filteredToElementIds = []
if (
this.search === '' ||
this.search === null ||
this.search === undefined
) {
if (!this.search) {
// If there's no search query, then there are no
// elements to narrow the results down to.
return filteredToElementIds
}
// Iterate over all the root-level elements.
this.rootElements.forEach((rootElement) => {
elements.forEach((element) => {
// Find this element's descendants and loop over them.
const descendants = this.$store.getters['element/getDescendants'](
this.page,
rootElement
page,
element
)
descendants.forEach((descendant) => {
// Build this descendant's corpus (for now, display name only)
// and check if it matches the search query.
const descendantCorpus = this.getElementCorpus(descendant)
const descendantCorpus = this.getElementCorpus(descendant, page)
if (isSubstringOfStrings([descendantCorpus], this.search)) {
// The descendant matches. We need to include *this* element,
// and all its *ancestors* in our list of narrowed results.
const ascendants = this.$store.getters['element/getAncestors'](
this.page,
page,
descendant
)
filteredToElementIds.push(descendant.id)
@ -141,42 +247,15 @@ export default {
// Test of the root element itself matches the search query.
// 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)) {
// The root element matches.
filteredToElementIds.push(rootElement.id)
filteredToElementIds.push(element.id)
}
})
filteredToElementIds = [...new Set(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() {
this.search = null
this.$nextTick(() => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,14 +7,13 @@
class="margin-bottom-2"
>
<div class="control__elements">
<Dropdown v-model="values.data_source_id" :show-search="false">
<DropdownItem
v-for="dataSource in dataSources"
:key="dataSource.id"
:name="dataSource.name"
:value="dataSource.id"
/>
</Dropdown>
<DataSourceDropdown
v-model="values.data_source_id"
small
:shared-data-sources="sharedDataSources"
:local-data-sources="localDataSources"
>
</DataSourceDropdown>
</div>
</FormGroup>
</form>
@ -22,9 +21,11 @@
<script>
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import DataSourceDropdown from '@baserow/modules/builder/components/dataSource/DataSourceDropdown'
export default {
name: 'RefreshDataSourceWorkflowActionForm',
components: { DataSourceDropdown },
mixins: [elementForm],
props: {
workflowAction: {
@ -42,8 +43,39 @@ export default {
}
},
computed: {
dataSources() {
return this.$store.getters['dataSource/getPageDataSources'](this.page)
sharedPage() {
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,
} from '@baserow/modules/builder/dataProviderTypes'
export const PLACEMENTS = {
export const DIRECTIONS = {
BEFORE: 'before',
AFTER: 'after',
LEFT: 'left',
@ -83,6 +83,12 @@ export const BACKGROUND_MODES = {
FIT: 'fit',
}
export const PAGE_PLACES = {
HEADER: 'header',
CONTENT: 'content',
FOOTER: 'footer',
}
export const WIDTH_TYPES = {
SMALL: { value: 'small', name: 'widthTypes.small' },
MEDIUM: { value: 'medium', name: 'widthTypes.medium' },
@ -91,6 +97,12 @@ export const WIDTH_TYPES = {
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
* sidebar in the application builder.

View file

@ -76,6 +76,7 @@
},
"elementsContext": {
"searchPlaceholder": "Search elements",
"noPageElements": "No elements found for this page",
"noElements": "No elements found"
},
"elementType": {
@ -110,7 +111,18 @@
"recordSelector": "Record selector",
"recordSelectorDescription": "A related record selector",
"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": {
"label": "Element"
@ -118,7 +130,7 @@
"addElementModal": {
"title": "Add new element",
"searchPlaceholder": "Search elements",
"disabledElementTooltip": "Unavailable inside this element"
"elementInProgress": "Adding element..."
},
"elementMenu": {
"moveUp": "Move up",
@ -142,7 +154,9 @@
"message": "Click on one of the elements to see more details"
},
"pagePreview": {
"emptyMessage": "Click to create first element"
"emptyMessage": "Click to create an element",
"header": "HEADER",
"footer": "FOOTER"
},
"elementForms": {
"textInputPlaceholder": "Enter text...",
@ -375,6 +389,9 @@
"textName": "Text",
"numericName": "Numeric"
},
"pageEditor": {
"pageNotFound": "Page not found"
},
"publicPage": {
"siteNotFound": "Site not found",
"pageNotFound": "Page not found"
@ -842,7 +859,20 @@
"dataSourceDropdown": {
"label": "Data source",
"noDataSources": "No data sources available",
"noSharedDataSources": "No shared data sources available",
"shared": "shared",
"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) {
return null
}
const pages = [this.page, this.sharedPage]
const pages = [this.currentPage, this.sharedPage]
return this.getPagesDataSourceById(pages, this.element.data_source_id)
},
dataSourceType() {
@ -50,10 +50,7 @@ export default {
return this.getHasMorePage(this.element)
},
contentLoading() {
return (
this.$fetchState.pending ||
(this.getLoading(this.element) && !this.elementIsInError)
)
return this.getLoading(this.element) && !this.elementIsInError
},
dispatchContext() {
return DataProviderType.getAllDataSourceDispatchContext(
@ -73,7 +70,7 @@ export default {
},
elementIsInError() {
return this.elementType.isInError({
page: this.page,
page: this.elementPage,
element: this.element,
builder: this.builder,
})
@ -112,7 +109,7 @@ export default {
},
async fetch() {
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: {
@ -134,7 +131,7 @@ export default {
}
try {
await this.fetchElementContent({
page: this.page,
page: this.elementPage,
element: this.element,
dataSource: this.dataSource,
data: this.dispatchContext,

View file

@ -1,9 +1,10 @@
import { mapGetters } from 'vuex'
import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext'
import { CurrentRecordDataProviderType } from '@baserow/modules/builder/dataProviderTypes'
import elementForm from '@baserow/modules/builder/mixins/elementForm'
export default {
mixins: [applicationContextMixin],
mixins: [elementForm, applicationContextMixin],
computed: {
/**
* Returns the schema which the service schema property selector
@ -38,7 +39,7 @@ export default {
hasCollectionAncestor() {
const { element } = this.applicationContext
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
@ -96,25 +97,47 @@ export default {
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.
* @returns {Array} - The data sources the page designer can choose from.
*/
dataSources() {
const pages = [this.sharedPage, this.page]
return this.$store.getters['dataSource/getPagesDataSources'](
pages
).filter((dataSource) => {
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) => {
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 =
dataSource.type && this.$registry.get('service', dataSource.type)
return serviceType?.getDataSchema(dataSource)
})
},
dataSources() {
return [...(this.localDataSources || []), ...this.sharedDataSources]
},
selectedDataSource() {
if (!this.values.data_source_id) {
return null
}
const pages = [this.sharedPage, this.page]
const pages = [this.sharedPage, this.currentPage]
return this.$store.getters['dataSource/getPagesDataSourceById'](
pages,
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'
export default {
inject: ['workspace', 'builder', 'page', 'mode'],
inject: ['workspace', 'builder', 'elementPage', 'mode'],
mixins: [element, applicationContextMixin],
props: {
element: {

View file

@ -1,21 +1,20 @@
import element from '@baserow/modules/builder/mixins/element'
import { mapGetters } from 'vuex'
import { PLACEMENTS } from '@baserow/modules/builder/enums'
import { DIRECTIONS } from '@baserow/modules/builder/enums'
export default {
mixins: [element],
props: {
children: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
...mapGetters({
elementSelected: 'element/getSelected',
}),
PLACEMENTS: () => PLACEMENTS,
DIRECTIONS: () => DIRECTIONS,
children() {
return this.$store.getters['element/getChildren'](
this.elementPage,
this.element
)
},
elementSelectedId() {
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'
export default {
inject: ['workspace', 'builder', 'page', 'mode'],
inject: ['workspace', 'builder', 'currentPage', 'elementPage', 'mode'],
mixins: [applicationContextMixin],
props: {
element: {
@ -17,7 +17,7 @@ export default {
workflowActionsInProgress() {
const workflowActions = this.$store.getters[
'workflowAction/getElementWorkflowActions'
](this.page, this.element.id)
](this.elementPage, this.element.id)
const { recordIndexPath } = this.applicationContext
const dispatchedById = this.elementType.uniqueElementId(
this.element,
@ -38,7 +38,7 @@ export default {
},
elementIsInError() {
return this.elementType.isInError({
page: this.page,
page: this.elementPage,
element: this.element,
builder: this.builder,
})
@ -96,7 +96,7 @@ export default {
const workflowActions = this.$store.getters[
'workflowAction/getElementWorkflowActions'
](this.page, this.element.id).filter(
](this.elementPage, this.element.id).filter(
({ 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'
export default {
inject: ['workspace', 'builder', 'page', 'mode'],
inject: ['workspace', 'builder', 'currentPage', 'elementPage', 'mode'],
mixins: [form],
computed: {
themeConfigBlocks() {

View file

@ -5,7 +5,18 @@ import { clone } from '@baserow/modules/core/utils/object'
import { notifyIf } from '@baserow/modules/core/utils/error'
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: {
...mapGetters({
element: 'element/getSelected',
@ -20,11 +31,19 @@ export default {
parentElement() {
return this.$store.getters['element/getElementById'](
this.page,
this.elementPage,
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() {
return this.element
},
@ -57,7 +76,7 @@ export default {
if (Object.keys(differences).length > 0) {
try {
await this.actionDebouncedUpdateSelectedElement({
page: this.page,
page: this.elementPage,
// Here we clone the values to prevent
// "modification outside of the store" error
values: clone(differences),

View file

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

View file

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

View file

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

View file

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

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