From b10a65666a521a863440ecc79349f3bbe5bab5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <jeremie@baserow.io> Date: Thu, 28 Nov 2024 08:47:54 +0000 Subject: [PATCH] Resolve "Create a multi-page container element" --- .../contrib/builder/api/data_sources/views.py | 4 +- .../builder/api/domains/serializers.py | 4 +- .../contrib/builder/api/elements/views.py | 1 + .../contrib/builder/api/serializers.py | 2 +- .../contrib/builder/application_types.py | 7 +- backend/src/baserow/contrib/builder/apps.py | 4 + .../contrib/builder/elements/element_types.py | 57 +- .../contrib/builder/elements/mixins.py | 127 +- .../contrib/builder/elements/models.py | 35 + .../contrib/builder/elements/registries.py | 59 +- .../0042_footerelement_headerelement.py | 79 + .../baserow/contrib/builder/pages/handler.py | 30 +- .../baserow/contrib/builder/pages/models.py | 13 + .../baserow/contrib/builder/pages/service.py | 6 +- .../data_sources/test_data_source_views.py | 4 +- .../api/domains/test_domain_public_views.py | 14 +- .../builder/api/pages/test_page_views.py | 6 +- .../api/test_builder_application_views.py | 4 +- .../builder/api/test_builder_serializer.py | 2 +- .../test_workflow_actions_views.py | 2 +- .../data_sources/test_data_source_handler.py | 2 +- .../builder/domains/test_domain_handler.py | 5 +- .../builder/elements/test_element_handler.py | 33 + .../builder/elements/test_element_service.py | 5 + .../builder/elements/test_element_types.py | 12 +- .../test_header_footer_element_type.py | 93 ++ .../test_record_selector_element_type.py | 7 +- .../builder/pages/test_page_handler.py | 8 +- .../builder/test_builder_application_type.py | 21 +- .../test_formula_property_extractor.py | 2 +- .../builder/test_permissions_manager.py | 4 +- .../test_upsert_row_service_type.py | 2 +- .../integrations/local_baserow/test_mixins.py | 2 +- ...ultipage_header_and_footer_containers.json | 7 + e2e-tests/tests/builder/builderPage.spec.ts | 2 +- .../local_baserow/test_user_source_types.py | 2 +- .../components/elements/AuthFormElement.vue | 4 +- .../modules/builder/applicationTypes.js | 9 +- .../ApplicationBuilderFormulaInput.vue | 10 +- .../dataSource/DataSourceCreateEditModal.vue | 25 +- .../dataSource/DataSourceDropdown.vue | 50 +- .../components/elements/AddElementCard.vue | 18 +- .../components/elements/AddElementModal.vue | 115 +- .../components/elements/AddElementZone.vue | 9 +- .../components/elements/ElementMenu.vue | 89 +- .../components/elements/ElementPreview.vue | 136 +- .../components/elements/ElementsList.vue | 6 +- .../components/elements/ElementsListItem.vue | 38 +- .../elements/baseComponents/ABLink.vue | 4 +- .../elements/baseComponents/ABTable.vue | 9 +- .../components/CollectionElementHeader.vue | 4 +- .../elements/components/ColumnElement.vue | 31 +- .../components/FormContainerElement.vue | 33 +- .../components/MultiPageContainerElement.vue | 82 + .../elements/components/RepeatElement.vue | 33 +- .../collectionField/ButtonField.vue | 2 +- .../components/forms/VisibilityForm.vue | 14 +- .../forms/general/ChoiceElementForm.vue | 2 +- .../general/MultiPageContainerElementForm.vue | 131 ++ .../general/RecordSelectorElementForm.vue | 18 +- .../forms/general/RepeatElementForm.vue | 7 +- .../forms/general/TableElementForm.vue | 7 +- .../general/settings/PropertyOptionForm.vue | 1 - .../builder/components/event/Event.vue | 8 +- .../components/page/CreatePageModal.vue | 2 +- .../builder/components/page/PageContent.vue | 43 +- .../builder/components/page/PageElement.vue | 32 +- .../builder/components/page/PagePreview.vue | 340 +++-- .../builder/components/page/PageTemplate.vue | 8 +- .../components/page/PageTemplateContent.vue | 21 +- .../page/UserSourceUsersContext.vue | 2 +- .../page/header/ElementsContext.vue | 195 ++- .../components/page/header/PageActions.vue | 8 +- .../components/page/header/PageHeader.vue | 6 - .../page/header/PageHeaderMenuItems.vue | 4 +- .../components/page/settings/PageSettings.vue | 12 +- .../page/settings/PageSettingsForm.vue | 7 +- .../page/settings/PageVisibilityForm.vue | 14 +- .../page/settings/PageVisibilitySettings.vue | 6 +- .../page/sidePanels/EventsSidePanel.vue | 7 +- .../page/sidePanels/GeneralSidePanel.vue | 5 - .../components/settings/GeneralSettings.vue | 1 - .../settings/UserSourcesSettings.vue | 1 - .../RefreshDataSourceWorkflowActionForm.vue | 52 +- .../modules/builder/elementTypeMixins.js | 364 +++++ web-frontend/modules/builder/elementTypes.js | 1323 ++++++++--------- web-frontend/modules/builder/enums.js | 14 +- web-frontend/modules/builder/locales/en.json | 36 +- .../builder/mixins/collectionElement.js | 13 +- .../builder/mixins/collectionElementForm.js | 41 +- .../modules/builder/mixins/collectionField.js | 2 +- .../builder/mixins/containerElement.js | 17 +- .../modules/builder/mixins/element.js | 8 +- .../modules/builder/mixins/elementForm.js | 2 +- .../builder/mixins/elementSidePanel.js | 25 +- .../modules/builder/mixins/formElement.js | 12 +- .../modules/builder/mixins/formElementForm.js | 2 +- .../modules/builder/mixins/visibilityForm.js | 3 +- .../modules/builder/pages/pageEditor.vue | 33 +- .../modules/builder/pages/publicPage.vue | 52 +- web-frontend/modules/builder/plugin.js | 4 + web-frontend/modules/builder/store/element.js | 35 +- .../modules/builder/store/elementContent.js | 4 +- web-frontend/modules/builder/store/page.js | 5 + .../modules/builder/workflowActionTypes.js | 1 - .../components/builder/add_element_zone.scss | 51 +- .../assets/scss/components/builder/all.scss | 1 + .../components/builder/element_preview.scss | 16 +- .../elements/ab_components/ab_table.scss | 2 +- .../builder/elements/forms/all.scss | 1 + .../multi_page_container_element_form.scss | 14 + .../elements/forms/visibility_form.scss | 25 +- .../components/builder/elements_context.scss | 30 + .../components/builder/elements_list.scss | 158 +- .../builder/elements_list_item.scss | 90 ++ .../scss/components/builder/page_preview.scss | 38 + .../workflowActions/WorkflowAction.vue | 4 +- .../mixins/localBaserowService.js | 10 - .../elements/components/ChoiceElement.spec.js | 3 +- .../components/DateTimePickerElement.spec.js | 3 +- .../components/HeadingElement.spec.js | 8 +- .../components/RecordSelectorElement.spec.js | 6 +- .../__snapshots__/HeadingElement.spec.js.snap | 2 +- .../test/unit/builder/elementTypes.spec.js | 417 +++++- 124 files changed, 3474 insertions(+), 1729 deletions(-) create mode 100644 backend/src/baserow/contrib/builder/migrations/0042_footerelement_headerelement.py create mode 100644 backend/tests/baserow/contrib/builder/elements/test_header_footer_element_type.py create mode 100644 changelog/entries/unreleased/feature/2486_builder_add_the_multipage_header_and_footer_containers.json create mode 100644 web-frontend/modules/builder/components/elements/components/MultiPageContainerElement.vue create mode 100644 web-frontend/modules/builder/components/elements/components/forms/general/MultiPageContainerElementForm.vue create mode 100644 web-frontend/modules/builder/elementTypeMixins.js create mode 100644 web-frontend/modules/core/assets/scss/components/builder/elements/forms/multi_page_container_element_form.scss create mode 100644 web-frontend/modules/core/assets/scss/components/builder/elements_list_item.scss diff --git a/backend/src/baserow/contrib/builder/api/data_sources/views.py b/backend/src/baserow/contrib/builder/api/data_sources/views.py index 8269b8a92..975307f1d 100644 --- a/backend/src/baserow/contrib/builder/api/data_sources/views.py +++ b/backend/src/baserow/contrib/builder/api/data_sources/views.py @@ -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? diff --git a/backend/src/baserow/contrib/builder/api/domains/serializers.py b/backend/src/baserow/contrib/builder/api/domains/serializers.py index 7cdfae260..0c09df93a 100644 --- a/backend/src/baserow/contrib/builder/api/domains/serializers.py +++ b/backend/src/baserow/contrib/builder/api/domains/serializers.py @@ -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 diff --git a/backend/src/baserow/contrib/builder/api/elements/views.py b/backend/src/baserow/contrib/builder/api/elements/views.py index 2fe28479f..630c6b90e 100644 --- a/backend/src/baserow/contrib/builder/api/elements/views.py +++ b/backend/src/baserow/contrib/builder/api/elements/views.py @@ -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( diff --git a/backend/src/baserow/contrib/builder/api/serializers.py b/backend/src/baserow/contrib/builder/api/serializers.py index 4e7dcb393..7189b1e34 100644 --- a/backend/src/baserow/contrib/builder/api/serializers.py +++ b/backend/src/baserow/contrib/builder/api/serializers.py @@ -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") diff --git a/backend/src/baserow/contrib/builder/application_types.py b/backend/src/baserow/contrib/builder/application_types.py index 0732a5df8..ae9793f44 100755 --- a/backend/src/baserow/contrib/builder/application_types.py +++ b/backend/src/baserow/contrib/builder/application_types.py @@ -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( diff --git a/backend/src/baserow/contrib/builder/apps.py b/backend/src/baserow/contrib/builder/apps.py index d314521d9..0d2c695b2 100644 --- a/backend/src/baserow/contrib/builder/apps.py +++ b/backend/src/baserow/contrib/builder/apps.py @@ -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 diff --git a/backend/src/baserow/contrib/builder/elements/element_types.py b/backend/src/baserow/contrib/builder/elements/element_types.py index cfae85581..c7679df2f 100644 --- a/backend/src/baserow/contrib/builder/elements/element_types.py +++ b/backend/src/baserow/contrib/builder/elements/element_types.py @@ -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 diff --git a/backend/src/baserow/contrib/builder/elements/mixins.py b/backend/src/baserow/contrib/builder/elements/mixins.py index ca7dcd090..5542f690f 100644 --- a/backend/src/baserow/contrib/builder/elements/mixins.py +++ b/backend/src/baserow/contrib/builder/elements/mixins.py @@ -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"} diff --git a/backend/src/baserow/contrib/builder/elements/models.py b/backend/src/baserow/contrib/builder/elements/models.py index 3e78c7e1d..75435893e 100644 --- a/backend/src/baserow/contrib/builder/elements/models.py +++ b/backend/src/baserow/contrib/builder/elements/models.py @@ -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. + """ diff --git a/backend/src/baserow/contrib/builder/elements/registries.py b/backend/src/baserow/contrib/builder/elements/registries.py index 8c5d8a236..2b09989ee 100644 --- a/backend/src/baserow/contrib/builder/elements/registries.py +++ b/backend/src/baserow/contrib/builder/elements/registries.py @@ -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. diff --git a/backend/src/baserow/contrib/builder/migrations/0042_footerelement_headerelement.py b/backend/src/baserow/contrib/builder/migrations/0042_footerelement_headerelement.py new file mode 100644 index 000000000..5ee6b1d5f --- /dev/null +++ b/backend/src/baserow/contrib/builder/migrations/0042_footerelement_headerelement.py @@ -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",), + ), + ] diff --git a/backend/src/baserow/contrib/builder/pages/handler.py b/backend/src/baserow/contrib/builder/pages/handler.py index 34da0e525..c6d51ccb5 100644 --- a/backend/src/baserow/contrib/builder/pages/handler.py +++ b/backend/src/baserow/contrib/builder/pages/handler.py @@ -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) diff --git a/backend/src/baserow/contrib/builder/pages/models.py b/backend/src/baserow/contrib/builder/pages/models.py index 759563a6b..8c8cf2d7c 100644 --- a/backend/src/baserow/contrib/builder/pages/models.py +++ b/backend/src/baserow/contrib/builder/pages/models.py @@ -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) diff --git a/backend/src/baserow/contrib/builder/pages/service.py b/backend/src/baserow/contrib/builder/pages/service.py index 272c6196b..f8c54d964 100644 --- a/backend/src/baserow/contrib/builder/pages/service.py +++ b/backend/src/baserow/contrib/builder/pages/service.py @@ -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, diff --git a/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py b/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py index 227d365c4..b173498c8 100644 --- a/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py +++ b/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py @@ -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( diff --git a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py index 27728e347..35d4b168a 100644 --- a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py +++ b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py @@ -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, diff --git a/backend/tests/baserow/contrib/builder/api/pages/test_page_views.py b/backend/tests/baserow/contrib/builder/api/pages/test_page_views.py index cecab34eb..e6360b7bb 100644 --- a/backend/tests/baserow/contrib/builder/api/pages/test_page_views.py +++ b/backend/tests/baserow/contrib/builder/api/pages/test_page_views.py @@ -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( diff --git a/backend/tests/baserow/contrib/builder/api/test_builder_application_views.py b/backend/tests/baserow/contrib/builder/api/test_builder_application_views.py index 1f921a2bf..339ca8e46 100644 --- a/backend/tests/baserow/contrib/builder/api/test_builder_application_views.py +++ b/backend/tests/baserow/contrib/builder/api/test_builder_application_views.py @@ -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__", diff --git a/backend/tests/baserow/contrib/builder/api/test_builder_serializer.py b/backend/tests/baserow/contrib/builder/api/test_builder_serializer.py index 3230400a8..4e34d4888 100644 --- a/backend/tests/baserow/contrib/builder/api/test_builder_serializer.py +++ b/backend/tests/baserow/contrib/builder/api/test_builder_serializer.py @@ -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}, diff --git a/backend/tests/baserow/contrib/builder/api/workflow_actions/test_workflow_actions_views.py b/backend/tests/baserow/contrib/builder/api/workflow_actions/test_workflow_actions_views.py index 50e785235..4741570f4 100644 --- a/backend/tests/baserow/contrib/builder/api/workflow_actions/test_workflow_actions_views.py +++ b/backend/tests/baserow/contrib/builder/api/workflow_actions/test_workflow_actions_views.py @@ -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, diff --git a/backend/tests/baserow/contrib/builder/data_sources/test_data_source_handler.py b/backend/tests/baserow/contrib/builder/data_sources/test_data_source_handler.py index 2f0b6df7a..fc941962f 100644 --- a/backend/tests/baserow/contrib/builder/data_sources/test_data_source_handler.py +++ b/backend/tests/baserow/contrib/builder/data_sources/test_data_source_handler.py @@ -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 ) diff --git a/backend/tests/baserow/contrib/builder/domains/test_domain_handler.py b/backend/tests/baserow/contrib/builder/domains/test_domain_handler.py index 69d83f095..347135208 100644 --- a/backend/tests/baserow/contrib/builder/domains/test_domain_handler.py +++ b/backend/tests/baserow/contrib/builder/domains/test_domain_handler.py @@ -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 diff --git a/backend/tests/baserow/contrib/builder/elements/test_element_handler.py b/backend/tests/baserow/contrib/builder/elements/test_element_handler.py index a9e1f026c..dc959f5c6 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_element_handler.py +++ b/backend/tests/baserow/contrib/builder/elements/test_element_handler.py @@ -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() diff --git a/backend/tests/baserow/contrib/builder/elements/test_element_service.py b/backend/tests/baserow/contrib/builder/elements/test_element_service.py index 2475c48cb..a4f8fd207 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_element_service.py +++ b/backend/tests/baserow/contrib/builder/elements/test_element_service.py @@ -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") diff --git a/backend/tests/baserow/contrib/builder/elements/test_element_types.py b/backend/tests/baserow/contrib/builder/elements/test_element_types.py index d44e642f4..ed5303982 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_element_types.py +++ b/backend/tests/baserow/contrib/builder/elements/test_element_types.py @@ -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 diff --git a/backend/tests/baserow/contrib/builder/elements/test_header_footer_element_type.py b/backend/tests/baserow/contrib/builder/elements/test_header_footer_element_type.py new file mode 100644 index 000000000..8893fb114 --- /dev/null +++ b/backend/tests/baserow/contrib/builder/elements/test_header_footer_element_type.py @@ -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] + ) diff --git a/backend/tests/baserow/contrib/builder/elements/test_record_selector_element_type.py b/backend/tests/baserow/contrib/builder/elements/test_record_selector_element_type.py index 04e556828..27e4cbb31 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_record_selector_element_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_record_selector_element_type.py @@ -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 diff --git a/backend/tests/baserow/contrib/builder/pages/test_page_handler.py b/backend/tests/baserow/contrib/builder/pages/test_page_handler.py index 39929d49e..e9affb3b0 100644 --- a/backend/tests/baserow/contrib/builder/pages/test_page_handler.py +++ b/backend/tests/baserow/contrib/builder/pages/test_page_handler.py @@ -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) diff --git a/backend/tests/baserow/contrib/builder/test_builder_application_type.py b/backend/tests/baserow/contrib/builder/test_builder_application_type.py index eb13e20cc..bef68741e 100644 --- a/backend/tests/baserow/contrib/builder/test_builder_application_type.py +++ b/backend/tests/baserow/contrib/builder/test_builder_application_type.py @@ -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 diff --git a/backend/tests/baserow/contrib/builder/test_formula_property_extractor.py b/backend/tests/baserow/contrib/builder/test_formula_property_extractor.py index f5254016f..68249ab43 100644 --- a/backend/tests/baserow/contrib/builder/test_formula_property_extractor.py +++ b/backend/tests/baserow/contrib/builder/test_formula_property_extractor.py @@ -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) diff --git a/backend/tests/baserow/contrib/builder/test_permissions_manager.py b/backend/tests/baserow/contrib/builder/test_permissions_manager.py index 7f6979471..344ee0af1 100755 --- a/backend/tests/baserow/contrib/builder/test_permissions_manager.py +++ b/backend/tests/baserow/contrib/builder/test_permissions_manager.py @@ -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], ), ( diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_upsert_row_service_type.py b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_upsert_row_service_type.py index 8343ddc1a..13b5b89c7 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_upsert_row_service_type.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_upsert_row_service_type.py @@ -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( diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/test_mixins.py b/backend/tests/baserow/contrib/integrations/local_baserow/test_mixins.py index 1fedc71d8..79a679004 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/test_mixins.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/test_mixins.py @@ -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} diff --git a/changelog/entries/unreleased/feature/2486_builder_add_the_multipage_header_and_footer_containers.json b/changelog/entries/unreleased/feature/2486_builder_add_the_multipage_header_and_footer_containers.json new file mode 100644 index 000000000..aaae36dd3 --- /dev/null +++ b/changelog/entries/unreleased/feature/2486_builder_add_the_multipage_header_and_footer_containers.json @@ -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" +} \ No newline at end of file diff --git a/e2e-tests/tests/builder/builderPage.spec.ts b/e2e-tests/tests/builder/builderPage.spec.ts index 05b50a350..ce85491aa 100644 --- a/e2e-tests/tests/builder/builderPage.spec.ts +++ b/e2e-tests/tests/builder/builderPage.spec.ts @@ -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( diff --git a/enterprise/backend/tests/baserow_enterprise_tests/integrations/local_baserow/test_user_source_types.py b/enterprise/backend/tests/baserow_enterprise_tests/integrations/local_baserow/test_user_source_types.py index fe4a2220d..e87b70786 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/integrations/local_baserow/test_user_source_types.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/integrations/local_baserow/test_user_source_types.py @@ -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( diff --git a/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElement.vue b/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElement.vue index 664b8f650..54c6a1ec1 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElement.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElement.vue @@ -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 }, }) diff --git a/web-frontend/modules/builder/applicationTypes.js b/web-frontend/modules/builder/applicationTypes.js index 1ea0e8172..6018ed554 100644 --- a/web-frontend/modules/builder/applicationTypes.js +++ b/web-frontend/modules/builder/applicationTypes.js @@ -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 diff --git a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue index aaf8b46ae..ed990aece 100644 --- a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue +++ b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue @@ -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) => diff --git a/web-frontend/modules/builder/components/dataSource/DataSourceCreateEditModal.vue b/web-frontend/modules/builder/components/dataSource/DataSourceCreateEditModal.vue index 19a5bb106..b7be94dc5 100644 --- a/web-frontend/modules/builder/components/dataSource/DataSourceCreateEditModal.vue +++ b/web-frontend/modules/builder/components/dataSource/DataSourceCreateEditModal.vue @@ -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 diff --git a/web-frontend/modules/builder/components/dataSource/DataSourceDropdown.vue b/web-frontend/modules/builder/components/dataSource/DataSourceDropdown.vue index eb0e3125b..05fd06423 100644 --- a/web-frontend/modules/builder/components/dataSource/DataSourceDropdown.vue +++ b/web-frontend/modules/builder/components/dataSource/DataSourceDropdown.vue @@ -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: { diff --git a/web-frontend/modules/builder/components/elements/AddElementCard.vue b/web-frontend/modules/builder/components/elements/AddElementCard.vue index 5cf6b5d12..5d93fcdcc 100644 --- a/web-frontend/modules/builder/components/elements/AddElementCard.vue +++ b/web-frontend/modules/builder/components/elements/AddElementCard.vue @@ -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> diff --git a/web-frontend/modules/builder/components/elements/AddElementModal.vue b/web-frontend/modules/builder/components/elements/AddElementModal.vue index 4c4cf6761..591a25cc3 100644 --- a/web-frontend/modules/builder/components/elements/AddElementModal.vue +++ b/web-frontend/modules/builder/components/elements/AddElementModal.vue @@ -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') diff --git a/web-frontend/modules/builder/components/elements/AddElementZone.vue b/web-frontend/modules/builder/components/elements/AddElementZone.vue index 7f29253a1..ab608b675 100644 --- a/web-frontend/modules/builder/components/elements/AddElementZone.vue +++ b/web-frontend/modules/builder/components/elements/AddElementZone.vue @@ -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> diff --git a/web-frontend/modules/builder/components/elements/ElementMenu.vue b/web-frontend/modules/builder/components/elements/ElementMenu.vue index ab17280a4..1f92b9f1c 100644 --- a/web-frontend/modules/builder/components/elements/ElementMenu.vue +++ b/web-frontend/modules/builder/components/elements/ElementMenu.vue @@ -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) }, }, } diff --git a/web-frontend/modules/builder/components/elements/ElementPreview.vue b/web-frontend/modules/builder/components/elements/ElementPreview.vue index 598f4d517..c1b658d6e 100644 --- a/web-frontend/modules/builder/components/elements/ElementPreview.vue +++ b/web-frontend/modules/builder/components/elements/ElementPreview.vue @@ -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) { diff --git a/web-frontend/modules/builder/components/elements/ElementsList.vue b/web-frontend/modules/builder/components/elements/ElementsList.vue index 7271614ff..c0af251b9 100644 --- a/web-frontend/modules/builder/components/elements/ElementsList.vue +++ b/web-frontend/modules/builder/components/elements/ElementsList.vue @@ -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, diff --git a/web-frontend/modules/builder/components/elements/ElementsListItem.vue b/web-frontend/modules/builder/components/elements/ElementsListItem.vue index 9dc2f58c6..00941273e 100644 --- a/web-frontend/modules/builder/components/elements/ElementsListItem.vue +++ b/web-frontend/modules/builder/components/elements/ElementsListItem.vue @@ -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, } diff --git a/web-frontend/modules/builder/components/elements/baseComponents/ABLink.vue b/web-frontend/modules/builder/components/elements/baseComponents/ABLink.vue index d5d7c8c12..6a81d6515 100644 --- a/web-frontend/modules/builder/components/elements/baseComponents/ABLink.vue +++ b/web-frontend/modules/builder/components/elements/baseComponents/ABLink.vue @@ -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) + } } }, }, diff --git a/web-frontend/modules/builder/components/elements/baseComponents/ABTable.vue b/web-frontend/modules/builder/components/elements/baseComponents/ABTable.vue index db2696734..2b657c740 100644 --- a/web-frontend/modules/builder/components/elements/baseComponents/ABTable.vue +++ b/web-frontend/modules/builder/components/elements/baseComponents/ABTable.vue @@ -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> diff --git a/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue b/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue index 185565291..596ce6f4e 100644 --- a/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue +++ b/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue @@ -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 ) }, diff --git a/web-frontend/modules/builder/components/elements/components/ColumnElement.vue b/web-frontend/modules/builder/components/elements/components/ColumnElement.vue index bc4e7fa81..3dfb3d8dc 100644 --- a/web-frontend/modules/builder/components/elements/components/ColumnElement.vue +++ b/web-frontend/modules/builder/components/elements/components/ColumnElement.vue @@ -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> diff --git a/web-frontend/modules/builder/components/elements/components/FormContainerElement.vue b/web-frontend/modules/builder/components/elements/components/FormContainerElement.vue index 9c3352b5e..1c8b6091b 100644 --- a/web-frontend/modules/builder/components/elements/components/FormContainerElement.vue +++ b/web-frontend/modules/builder/components/elements/components/FormContainerElement.vue @@ -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> diff --git a/web-frontend/modules/builder/components/elements/components/MultiPageContainerElement.vue b/web-frontend/modules/builder/components/elements/components/MultiPageContainerElement.vue new file mode 100644 index 000000000..aefb79ad9 --- /dev/null +++ b/web-frontend/modules/builder/components/elements/components/MultiPageContainerElement.vue @@ -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> diff --git a/web-frontend/modules/builder/components/elements/components/RepeatElement.vue b/web-frontend/modules/builder/components/elements/components/RepeatElement.vue index 2be484f84..ac8826e36 100644 --- a/web-frontend/modules/builder/components/elements/components/RepeatElement.vue +++ b/web-frontend/modules/builder/components/elements/components/RepeatElement.vue @@ -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> diff --git a/web-frontend/modules/builder/components/elements/components/collectionField/ButtonField.vue b/web-frontend/modules/builder/components/elements/components/collectionField/ButtonField.vue index 854c97ffb..e58368c6d 100644 --- a/web-frontend/modules/builder/components/elements/components/collectionField/ButtonField.vue +++ b/web-frontend/modules/builder/components/elements/components/collectionField/ButtonField.vue @@ -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) => diff --git a/web-frontend/modules/builder/components/elements/components/forms/VisibilityForm.vue b/web-frontend/modules/builder/components/elements/components/forms/VisibilityForm.vue index c45704ef7..f69fe8607 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/VisibilityForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/VisibilityForm.vue @@ -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: { diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue index 336377efb..740fa31d3 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue @@ -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 ) }, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/MultiPageContainerElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/MultiPageContainerElementForm.vue new file mode 100644 index 000000000..68a1fd50e --- /dev/null +++ b/web-frontend/modules/builder/components/elements/components/forms/general/MultiPageContainerElementForm.vue @@ -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> diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue index bec26096e..adaec1a5d 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue @@ -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 ) }, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue index d9fc825e5..dbda96f46 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue @@ -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 { diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue index 1acfed0f6..87efe5b15 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue @@ -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: [ diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue index 42abaa59e..66f4ba246 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue @@ -60,7 +60,6 @@ export default { name: 'PropertyOptionForm', components: { BaserowTable }, mixins: [form], - inject: ['page'], props: { dataSource: { type: Object, diff --git a/web-frontend/modules/builder/components/event/Event.vue b/web-frontend/modules/builder/components/event/Event.vue index f9125a028..19ca028e1 100644 --- a/web-frontend/modules/builder/components/event/Event.vue +++ b/web-frontend/modules/builder/components/event/Event.vue @@ -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, }) diff --git a/web-frontend/modules/builder/components/page/CreatePageModal.vue b/web-frontend/modules/builder/components/page/CreatePageModal.vue index eb80716b8..1390df462 100644 --- a/web-frontend/modules/builder/components/page/CreatePageModal.vue +++ b/web-frontend/modules/builder/components/page/CreatePageModal.vue @@ -29,7 +29,7 @@ export default { mixins: [modal], provide() { return { - page: null, + currentPage: null, builder: this.builder, workspace: this.workspace, } diff --git a/web-frontend/modules/builder/components/page/PageContent.vue b/web-frontend/modules/builder/components/page/PageContent.vue index 9d25c1fb0..06985588f 100644 --- a/web-frontend/modules/builder/components/page/PageContent.vue +++ b/web-frontend/modules/builder/components/page/PageContent.vue @@ -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': { diff --git a/web-frontend/modules/builder/components/page/PageElement.vue b/web-frontend/modules/builder/components/page/PageElement.vue index bd249fc09..6b024e946 100644 --- a/web-frontend/modules/builder/components/page/PageElement.vue +++ b/web-frontend/modules/builder/components/page/PageElement.vue @@ -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) diff --git a/web-frontend/modules/builder/components/page/PagePreview.vue b/web-frontend/modules/builder/components/page/PagePreview.vue index 854b0a87b..d1933b3c8 100644 --- a/web-frontend/modules/builder/components/page/PagePreview.vue +++ b/web-frontend/modules/builder/components/page/PagePreview.vue @@ -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': diff --git a/web-frontend/modules/builder/components/page/PageTemplate.vue b/web-frontend/modules/builder/components/page/PageTemplate.vue index 147ba75ea..bbba222ca 100644 --- a/web-frontend/modules/builder/components/page/PageTemplate.vue +++ b/web-frontend/modules/builder/components/page/PageTemplate.vue @@ -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. diff --git a/web-frontend/modules/builder/components/page/PageTemplateContent.vue b/web-frontend/modules/builder/components/page/PageTemplateContent.vue index bc2f2ce9b..1959b2923 100644 --- a/web-frontend/modules/builder/components/page/PageTemplateContent.vue +++ b/web-frontend/modules/builder/components/page/PageTemplateContent.vue @@ -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, } diff --git a/web-frontend/modules/builder/components/page/UserSourceUsersContext.vue b/web-frontend/modules/builder/components/page/UserSourceUsersContext.vue index 912db8468..de99c2e8c 100644 --- a/web-frontend/modules/builder/components/page/UserSourceUsersContext.vue +++ b/web-frontend/modules/builder/components/page/UserSourceUsersContext.vue @@ -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, diff --git a/web-frontend/modules/builder/components/page/header/ElementsContext.vue b/web-frontend/modules/builder/components/page/header/ElementsContext.vue index c9efac12f..95fc7f4cc 100644 --- a/web-frontend/modules/builder/components/page/header/ElementsContext.vue +++ b/web-frontend/modules/builder/components/page/header/ElementsContext.vue @@ -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(() => { diff --git a/web-frontend/modules/builder/components/page/header/PageActions.vue b/web-frontend/modules/builder/components/page/header/PageActions.vue index 2a7e82fa4..b45621ecd 100644 --- a/web-frontend/modules/builder/components/page/header/PageActions.vue +++ b/web-frontend/modules/builder/components/page/header/PageActions.vue @@ -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')) diff --git a/web-frontend/modules/builder/components/page/header/PageHeader.vue b/web-frontend/modules/builder/components/page/header/PageHeader.vue index b1c062736..b89763006 100644 --- a/web-frontend/modules/builder/components/page/header/PageHeader.vue +++ b/web-frontend/modules/builder/components/page/header/PageHeader.vue @@ -22,12 +22,6 @@ export default { DeviceSelector, PageActions, }, - props: { - page: { - type: Object, - required: true, - }, - }, computed: { ...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected' }), deviceTypes() { diff --git a/web-frontend/modules/builder/components/page/header/PageHeaderMenuItems.vue b/web-frontend/modules/builder/components/page/header/PageHeaderMenuItems.vue index b93e4a843..ee926943c 100644 --- a/web-frontend/modules/builder/components/page/header/PageHeaderMenuItems.vue +++ b/web-frontend/modules/builder/components/page/header/PageHeaderMenuItems.vue @@ -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') diff --git a/web-frontend/modules/builder/components/page/settings/PageSettings.vue b/web-frontend/modules/builder/components/page/settings/PageSettings.vue index 1c30794be..84baca588 100644 --- a/web-frontend/modules/builder/components/page/settings/PageSettings.vue +++ b/web-frontend/modules/builder/components/page/settings/PageSettings.vue @@ -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), }) diff --git a/web-frontend/modules/builder/components/page/settings/PageSettingsForm.vue b/web-frontend/modules/builder/components/page/settings/PageSettingsForm.vue index 214fd20e6..f7eb0a06e 100644 --- a/web-frontend/modules/builder/components/page/settings/PageSettingsForm.vue +++ b/web-frontend/modules/builder/components/page/settings/PageSettingsForm.vue @@ -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, diff --git a/web-frontend/modules/builder/components/page/settings/PageVisibilityForm.vue b/web-frontend/modules/builder/components/page/settings/PageVisibilityForm.vue index b12523f50..267626d7a 100644 --- a/web-frontend/modules/builder/components/page/settings/PageVisibilityForm.vue +++ b/web-frontend/modules/builder/components/page/settings/PageVisibilityForm.vue @@ -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: { diff --git a/web-frontend/modules/builder/components/page/settings/PageVisibilitySettings.vue b/web-frontend/modules/builder/components/page/settings/PageVisibilitySettings.vue index 0ed6a8c63..6f2c24376 100644 --- a/web-frontend/modules/builder/components/page/settings/PageVisibilitySettings.vue +++ b/web-frontend/modules/builder/components/page/settings/PageVisibilitySettings.vue @@ -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) { diff --git a/web-frontend/modules/builder/components/page/sidePanels/EventsSidePanel.vue b/web-frontend/modules/builder/components/page/sidePanels/EventsSidePanel.vue index 109c8263d..286dd07bf 100644 --- a/web-frontend/modules/builder/components/page/sidePanels/EventsSidePanel.vue +++ b/web-frontend/modules/builder/components/page/sidePanels/EventsSidePanel.vue @@ -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 ) }, diff --git a/web-frontend/modules/builder/components/page/sidePanels/GeneralSidePanel.vue b/web-frontend/modules/builder/components/page/sidePanels/GeneralSidePanel.vue index ffc02bd6f..de83d4549 100644 --- a/web-frontend/modules/builder/components/page/sidePanels/GeneralSidePanel.vue +++ b/web-frontend/modules/builder/components/page/sidePanels/GeneralSidePanel.vue @@ -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, } }, diff --git a/web-frontend/modules/builder/components/settings/GeneralSettings.vue b/web-frontend/modules/builder/components/settings/GeneralSettings.vue index 2ef7ae596..be50c0052 100644 --- a/web-frontend/modules/builder/components/settings/GeneralSettings.vue +++ b/web-frontend/modules/builder/components/settings/GeneralSettings.vue @@ -30,7 +30,6 @@ export default { provide() { return { builder: this.builder, - page: null, mode: null, } }, diff --git a/web-frontend/modules/builder/components/settings/UserSourcesSettings.vue b/web-frontend/modules/builder/components/settings/UserSourcesSettings.vue index 431cbfcf3..3be1ffede 100644 --- a/web-frontend/modules/builder/components/settings/UserSourcesSettings.vue +++ b/web-frontend/modules/builder/components/settings/UserSourcesSettings.vue @@ -209,7 +209,6 @@ export default { try { await this.actionUpdateUserSource({ application: this.builder, - page: this.page, userSourceId: this.editedUserSource.id, values: clone(newValues), }) diff --git a/web-frontend/modules/builder/components/workflowAction/RefreshDataSourceWorkflowActionForm.vue b/web-frontend/modules/builder/components/workflowAction/RefreshDataSourceWorkflowActionForm.vue index 4d4927b47..8466e2013 100644 --- a/web-frontend/modules/builder/components/workflowAction/RefreshDataSourceWorkflowActionForm.vue +++ b/web-frontend/modules/builder/components/workflowAction/RefreshDataSourceWorkflowActionForm.vue @@ -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) + ) }, }, } diff --git a/web-frontend/modules/builder/elementTypeMixins.js b/web-frontend/modules/builder/elementTypeMixins.js new file mode 100644 index 000000000..8000952f5 --- /dev/null +++ b/web-frontend/modules/builder/elementTypeMixins.js @@ -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'] + } + } diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js index b56e81dc4..0506a2905 100644 --- a/web-frontend/modules/builder/elementTypes.js +++ b/web-frontend/modules/builder/elementTypes.js @@ -21,15 +21,14 @@ import { import { CHOICE_OPTION_TYPES, DATE_FORMATS, - ELEMENT_EVENTS, - PLACEMENTS, TIME_FORMATS, IMAGE_SOURCE_TYPES, IFRAME_SOURCE_TYPES, + DIRECTIONS, + PAGE_PLACES, } from '@baserow/modules/builder/enums' import ColumnElement from '@baserow/modules/builder/components/elements/components/ColumnElement' import ColumnElementForm from '@baserow/modules/builder/components/elements/components/forms/general/ColumnElementForm' -import _ from 'lodash' import DefaultStyleForm from '@baserow/modules/builder/components/elements/components/forms/style/DefaultStyleForm' import ButtonElement from '@baserow/modules/builder/components/elements/components/ButtonElement' import ButtonElementForm from '@baserow/modules/builder/components/elements/components/forms/general/ButtonElementForm' @@ -47,11 +46,18 @@ import IFrameElementForm from '@baserow/modules/builder/components/elements/comp import RepeatElement from '@baserow/modules/builder/components/elements/components/RepeatElement' import RepeatElementForm from '@baserow/modules/builder/components/elements/components/forms/general/RepeatElementForm' import RecordSelectorElement from '@baserow/modules/builder/components/elements/components/RecordSelectorElement.vue' +import RecordSelectorElementForm from '@baserow/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm' +import MultiPageContainerElementForm from '@baserow/modules/builder/components/elements/components/forms/general/MultiPageContainerElementForm' +import MultiPageContainerElement from '@baserow/modules/builder/components/elements/components/MultiPageContainerElement' +import DateTimePickerElement from '@baserow/modules/builder/components/elements/components/DateTimePickerElement' +import DateTimePickerElementForm from '@baserow/modules/builder/components/elements/components/forms/general/DateTimePickerElementForm' import { pathParametersInError } from '@baserow/modules/builder/utils/params' +import { + ContainerElementTypeMixin, + CollectionElementTypeMixin, + MultiPageElementTypeMixin, +} from '@baserow/modules/builder/elementTypeMixins' import { isNumeric, isValidEmail } from '@baserow/modules/core/utils/string' -import RecordSelectorElementForm from '@baserow/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue' -import DateTimePickerElement from '@baserow/modules/builder/components/elements/components/DateTimePickerElement.vue' -import DateTimePickerElementForm from '@baserow/modules/builder/components/elements/components/forms/general/DateTimePickerElementForm.vue' import { FormattedDate, FormattedDateTime } from '@baserow/modules/builder/date' export class ElementType extends Registerable { @@ -105,6 +111,71 @@ export class ElementType extends Registerable { ] } + /** + * Returns the current place of the given element. + * + * @param {Object} element + * @returns a PAGE_PLACES enum + */ + getPagePlace() { + return PAGE_PLACES.CONTENT + } + + /** + * Returns the reason why this element type is disallowed for the given location. + * @param {Object} builder the current builder object + * @param {Object} page the current page + * @param {Object} parentElement the parent container element in which we want to + * add the element in if any. + * @param {Object} beforeElement the element before which we want to add the element. + * @param {Object} placeInContainer The place in the container if we are in a + * container + * @param {Object} pagePlace the page place we want to add the element to. + * @returns null if the element type is allowed or a string explaining the reason why + * the element type is not allowed at the given location. + */ + isDisallowedReason({ + builder, + page: destinationPage, + parentElement, + beforeElement, + placeInContainer, + pagePlace, + }) { + if (!parentElement) { + const sharedPage = this.app.store.getters['page/getSharedPage'](builder) + + if (pagePlace === PAGE_PLACES.HEADER) { + if (beforeElement && beforeElement.page_id === sharedPage.id) { + // It's not allowed to add these elements as root inside header before + // another multi page element + return this.app.i18n.t('elementType.notAllowedLocation') + } + } + + if (pagePlace === PAGE_PLACES.FOOTER) { + if (!beforeElement) { + // Not allowed as last child of footer + return this.app.i18n.t('elementType.notAllowedLocation') + } else { + const footerElements = this.app.store.getters[ + 'element/getRootElements' + ](sharedPage).filter( + (element) => + this.app.$registry.get('element', element.type).getPagePlace() === + PAGE_PLACES.FOOTER + ) + if (beforeElement.id !== footerElements[0].id) { + // It's not allowed to add these elements as root inside footer after + // another multi page element + return this.app.i18n.t('elementType.notAllowedLocation') + } + } + } + } + return null + } + get styles() { return this.stylesAll } @@ -128,6 +199,16 @@ export class ElementType extends Registerable { return this.getEvents(element).find((event) => event.name === name) } + /** + * Should return whether this element is visible. + * @param {Object} element the element to check + * @param {Object} currentPage the current displayed page + * @returns + */ + isVisible({ element, currentPage }) { + return true + } + /** * Returns whether the element configuration is valid or not. * @param {object} param An object containing the page, element, and builder @@ -243,185 +324,299 @@ export class ElementType extends Registerable { afterUpdate(element, page) {} /** - * Move a component in the same place. - * @param {Object} page - The page the element belongs to - * @param {Object} element - The element to move - * @param {String} placement - The direction of the move + * Returns the places for this element. The places are the location where we can place + * a child element. Only collection elements can have places. + * + * @param {Object} element + * @returns an array of allowed places for the given collection element. */ - async moveElementInSamePlace(page, element, placement) { - let beforeElementId = null - - switch (placement) { - case PLACEMENTS.BEFORE: { - const previousElement = this.app.store.getters[ - 'element/getPreviousElement' - ](page, element) - - beforeElementId = previousElement ? previousElement.id : null - break - } - case PLACEMENTS.AFTER: { - const nextElement = this.app.store.getters['element/getNextElement']( - page, - element - ) - - if (nextElement) { - const nextNextElement = this.app.store.getters[ - 'element/getNextElement' - ](page, nextElement) - beforeElementId = nextNextElement ? nextNextElement.id : null - } - break - } - } - - await this.app.store.dispatch('element/move', { - page, - elementId: element.id, - beforeElementId, - parentElementId: element.parent_element_id - ? element.parent_element_id - : null, - placeInContainer: element.place_in_container, - }) + getElementPlaces(element) { + return [] } /** - * Move an element according to the new placement. - * @param {Object} page - The page the element belongs to - * @param {Object} element - The element to move - * @param {String} placement - The direction of the move + * Returns the places if we move an element in the four directions. Used when we want + * to now if we can move an element in a certain direction and what place it will be. + * + * @param {Object} page the page we want the places for. Usually the current page. + * @param {Object} element the element we want the next places for. + * @returns an object keyed by the four direction and for each `null` or an object + * containing: + * { + * beforeElementId, + * parentElementId, + * placeInContainer, + * } + * that can be used to move the element. */ - async moveElement(page, element, placement) { - if (element.parent_element_id !== null) { - const parentElement = this.app.store.getters['element/getElementById']( - page, - element.parent_element_id + getNextPlaces({ builder, page, element }) { + let placeInContainer = element.place_in_container + const parentElementId = element.parent_element_id + ? element.parent_element_id + : null + + const elementPage = this.app.store.getters['page/getById']( + builder, + element.page_id + ) + + const parentElement = element.parent_element_id + ? this.app.store.getters['element/getElementById']( + elementPage, + element.parent_element_id + ) + : null + const parentElementType = parentElement + ? this.app.$registry.get('element', parentElement.type) + : null + + const elementsAround = this.getElementsAround({ builder, page, element }) + + const nextPlaces = { + [DIRECTIONS.BEFORE]: null, + [DIRECTIONS.AFTER]: null, + [DIRECTIONS.LEFT]: null, + [DIRECTIONS.RIGHT]: null, + } + + // BEFORE + const previousElement = elementsAround[DIRECTIONS.BEFORE] + if (previousElement) { + // If we have a previous element, let place it before it. + nextPlaces[DIRECTIONS.BEFORE] = { + beforeElementId: previousElement.id, + parentElementId, + placeInContainer: previousElement.place_in_container, + } + } + + // AFTER + const nextElement = elementsAround[DIRECTIONS.AFTER] + if (nextElement) { + const nextNextElement = this.app.store.getters['element/getNextElement']( + elementPage, + nextElement + ) + if (!nextNextElement) { + // We have to place this element as last element in the given place + nextPlaces[DIRECTIONS.AFTER] = { + beforeElementId: null, + parentElementId, + placeInContainer: element.place_in_container, + } + } else { + // Otherwise it must be placed just before the next next element + nextPlaces[DIRECTIONS.AFTER] = { + beforeElementId: nextNextElement.id, + parentElementId, + placeInContainer: nextNextElement.place_in_container, + } + } + } + + // LEFT + if (parentElement) { + const places = parentElementType.getElementPlaces(parentElement) + const placeIndex = places.findIndex( + (place) => place === element.place_in_container ) + if (placeIndex > 0) { + // Let's move it as last of the previous container place + nextPlaces[DIRECTIONS.LEFT] = { + beforeElementId: null, + parentElementId, + placeInContainer: places[placeIndex - 1], + } + } + } + + // RIGHT + if (parentElement) { + const places = parentElementType.getElementPlaces(parentElement) + const placeIndex = places.findIndex( + (place) => place === element.place_in_container + ) + if (placeIndex < places.length - 1) { + placeInContainer = places[placeIndex + 1] + const elementsInNextPlace = this.app.store.getters[ + 'element/getElementsInPlace' + ](elementPage, element.parent_element_id, placeInContainer) + if (elementsInNextPlace.length) { + // Let's place it as first element in the next container place + nextPlaces[DIRECTIONS.RIGHT] = { + beforeElementId: elementsInNextPlace[0].id, + parentElementId, + placeInContainer, + } + } else { + nextPlaces[DIRECTIONS.RIGHT] = { + beforeElementId: null, + parentElementId, + placeInContainer, + } + } + } + } + return nextPlaces + } + + /** + * Returns the elements around the current element in the four directions if the + * element exists. The simplified base logic is the following: + * - If the element is at root level: + * - The BEFORE element is the previous sibling in the element order + * - The AFTER element is the next sibling in the element order + * - No LEFT nor RIGHT elements. + * - If the element is inside container + * - BEFORE and AFTER are the previous and next in order + * - LEFT if the last element of the container previous place if it exists + * - RIGHT is the first element of the container next place if it exists + * if `withSharedPage` is true, we consider the header and footer elements as if + * they were on the same page respectively before and after the current page elements. + * @param {Object} page the current page + * @param {Object} element the element we want the elements around + * @param {Boolean} withSharedPage whether we want to consider the shared page or not. + * @returns an object keyed by the directions and valued by the elements + * or null if there is no element in that direction. + */ + getElementsAround({ builder, page, element, withSharedPage = false }) { + const elementType = this.app.$registry.get('element', element.type) + const elementPlace = elementType.getPagePlace() + const isRootElement = !element.parent_element_id + + const elementPage = this.app.store.getters['page/getById']( + builder, + element.page_id + ) + + const siblings = this.app.store.getters['element/getElementsInPlace']( + elementPage, + element.parent_element_id, + element.place_in_container + ).filter( + (sibling) => + Boolean(element.parent_element_id) || + this.app.$registry.get('element', sibling.type).getPagePlace() === + elementPlace + ) + + const elementIndex = siblings.findIndex((e) => e.id === element.id) + + let previousElement = null + let nextElement = null + + if (elementIndex > 0) { + previousElement = siblings[elementIndex - 1] + } + + if (elementIndex < siblings.length - 1) { + nextElement = siblings[elementIndex + 1] + } + + // If we are considering the shared page and we have no previous or next element + // we want to potentially use the elements from the shared page + if (withSharedPage && isRootElement) { + const sharedPage = this.app.store.getters['page/getSharedPage'](builder) + + if (!previousElement) { + // no previous element and we are in the page content, then previous element + // could come from the HEADER + if (elementPlace === PAGE_PLACES.CONTENT) { + const headerElements = this.app.store.getters[ + 'element/getRootElements' + ](sharedPage).filter( + (element) => + this.app.$registry.get('element', element.type).getPagePlace() === + PAGE_PLACES.HEADER + ) + if (headerElements.length) { + previousElement = headerElements.at(-1) + } + } else if (elementPlace === PAGE_PLACES.FOOTER) { + // previous element could come from the page CONTENT if we don't have previous + // yet + const contentElements = + this.app.store.getters['element/getRootElements'](page) + if (contentElements.length) { + previousElement = contentElements.at(-1) + } + } + } + + // Let's consider the shared page element as next element. + // If the current element is in the HEADER, it might be a CONTENT element + // If it's a CONTENT element it could be in the FOOTER. + if (!nextElement) { + if (elementPlace === PAGE_PLACES.HEADER) { + const contentElements = + this.app.store.getters['element/getRootElements'](page) + if (contentElements.length) { + nextElement = contentElements[0] + } + } else if (elementPlace === PAGE_PLACES.CONTENT) { + const footerElements = this.app.store.getters[ + 'element/getRootElements' + ](sharedPage).filter( + (element) => + this.app.$registry.get('element', element.type).getPagePlace() === + PAGE_PLACES.FOOTER + ) + if (footerElements.length) { + nextElement = footerElements[0] + } + } + } + } + + let leftElement = null + let rightElement = null + + // We have a parent, so we can find left and right elements. + if (element.parent_element_id) { + const parentElement = this.app.store.getters['element/getElementById']( + elementPage, + element.parent_element_id + ) const parentElementType = this.app.$registry.get( 'element', parentElement.type ) - await parentElementType.moveChildElement( - page, - parentElement, - element, - placement + const places = parentElementType.getElementPlaces(parentElement) + const placeIndex = places.findIndex( + (place) => place === element.place_in_container ) - } else { - await this.moveElementInSamePlace(page, element, placement) - } - } - /** - * Identify and select the next element according to the new placement. - * - * @param {Object} page - The page the element belongs to - * @param {Object} element - The element on which the selection should be based on - * @param {String} placement - The direction of the selection - */ - async selectNextElement(page, element, placement) { - let elementToBeSelected = null - if (placement === PLACEMENTS.BEFORE) { - elementToBeSelected = this.app.store.getters[ - 'element/getPreviousElement' - ](page, element) - } else if (placement === PLACEMENTS.AFTER) { - elementToBeSelected = this.app.store.getters['element/getNextElement']( - page, - element - ) - } else { - const containerElement = this.app.store.getters['element/getElementById']( - page, - element.parent_element_id - ) - const containerElementType = this.app.$registry.get( - 'element', - containerElement.type - ) - elementToBeSelected = - containerElementType.getNextHorizontalElementToSelect( - page, - element, - placement - ) + let placeLeftIndex = placeIndex - 1 + while (placeLeftIndex >= 0) { + const elementsInNextPlace = this.app.store.getters[ + 'element/getElementsInPlace' + ](elementPage, element.parent_element_id, places[placeLeftIndex]) + if (elementsInNextPlace.length > 0) { + leftElement = elementsInNextPlace.at(-1) + break + } + placeLeftIndex -= 1 + } + let placeRightIndex = placeIndex + 1 + while (placeRightIndex <= places.length - 1) { + const elementsInNextPlace = this.app.store.getters[ + 'element/getElementsInPlace' + ](elementPage, element.parent_element_id, places[placeRightIndex]) + if (elementsInNextPlace.length > 0) { + rightElement = elementsInNextPlace[0] + break + } + placeRightIndex += 1 + } } - if (!elementToBeSelected) { - return + return { + [DIRECTIONS.BEFORE]: previousElement, + [DIRECTIONS.AFTER]: nextElement, + [DIRECTIONS.LEFT]: leftElement, + [DIRECTIONS.RIGHT]: rightElement, } - - try { - await this.app.store.dispatch('element/select', { - element: elementToBeSelected, - }) - } catch {} - } - - /** - * Returns vertical placement disabled. - * @param {Object} page - The page the element belongs to - * @param {Object} element - The element to move - * @param {String} placement - The direction of the move - */ - getVerticalPlacementsDisabled(page, element) { - const previousElement = this.app.store.getters[ - 'element/getPreviousElement' - ](page, element) - const nextElement = this.app.store.getters['element/getNextElement']( - page, - element - ) - - const placementsDisabled = [] - - if (!previousElement) { - placementsDisabled.push(PLACEMENTS.BEFORE) - } - - if (!nextElement) { - placementsDisabled.push(PLACEMENTS.AFTER) - } - - return placementsDisabled - } - - /** - * Return an array of placements that are disallowed for the element to move - * in their container (or root page). - * - * @param {Object} page The page that is the parent component. - * @param {Number} element The element for which the placements should be - * calculated. - * @returns {Array} An array of placements that are disallowed for the element. - */ - getPlacementsDisabled(page, element) { - // If the element has a parent, let the parent container type derive the - // disabled placements. - if (element.parent_element_id) { - const containerElement = this.app.store.getters['element/getElementById']( - page, - element.parent_element_id - ) - const elementType = this.app.$registry.get( - 'element', - containerElement.type - ) - return elementType.getPlacementsDisabledForChild( - page, - containerElement, - element - ) - } - - return [ - PLACEMENTS.LEFT, - PLACEMENTS.RIGHT, - ...this.getVerticalPlacementsDisabled(page, element), - ] } /** @@ -449,33 +644,6 @@ export class ElementType extends Registerable { return {} } - /** - * Given an element, iterates over the element's ancestors and finds the - * first collection element. An optional function can be passed to map over - * each ancestor element. - * - * @param {Object} page - The page the element belongs to. - * @param {Object} element - The element to start the search from. - * @param {Function} ancestorMapFn - An optional function which will be - * called for each ancestor element, after ensuring it's a collection element. - */ - firstCollectionAncestor(page, element, ancestorMapFn = (element) => true) { - const elementType = this.app.$registry.get('element', element.type) - if (elementType.isCollectionElement && ancestorMapFn(element)) { - return element - } - const ancestors = this.app.store.getters['element/getAncestors']( - page, - element - ) - for (const ancestor of ancestors) { - const ancestorType = this.app.$registry.get('element', ancestor.type) - if (ancestorType.isCollectionElement && ancestorMapFn(ancestor)) { - return ancestor - } - } - } - /** * Given a `page` and an `element`, and `ancestorType`, returns whether the * element has an ancestor of a specified element type. @@ -492,125 +660,6 @@ export class ElementType extends Registerable { } } -const ContainerElementTypeMixin = (Base) => - class extends Base { - isContainerElement = true - - get elementTypesAll() { - return Object.values(this.app.$registry.getAll('element')) - } - - /** - * Returns an array of element types that are not allowed as children of this element type. - * @param {object} page - The page the element belongs to. - * @param {Object} element The element in question, it can be used to - * determine in a more dynamic way if specific children are permitted. - * @returns {Array} An array of forbidden child element types. - */ - childElementTypesForbidden(page, element) { - return [] - } - - /** - * Returns an array of element types that are allowed as children of this element. - * If the parent element we're trying to add a child to has a parent, we'll check - * each parent until the root element if they have any forbidden element types to - * include as well. - * @param page - * @param element - * @returns {Array} An array of permitted child element types. - */ - childElementTypes(page, element) { - if (element.parent_element_id) { - const parentElement = this.app.store.getters['element/getElementById']( - page, - element.parent_element_id - ) - const parentElementType = this.app.$registry.get( - 'element', - parentElement.type - ) - return _.difference( - parentElementType.childElementTypes(page, parentElement), - this.childElementTypesForbidden(page, element) - ) - } - return _.difference( - this.elementTypesAll, - this.childElementTypesForbidden(page, 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 } - } - - /** - * Given a `page` and an `element`, move the child element of a container - * in the direction specified by the `placement`. - * - * The default implementation only supports moving the element vertically. - * - * @param {Object} page The page that is the parent component. - * @param {Number} element The child element to be moved. - * @param {String} placement The direction in which the element should move. - */ - async moveChildElement(page, parentElement, element, placement) { - if (placement === PLACEMENTS.AFTER || placement === PLACEMENTS.BEFORE) { - await this.moveElementInSamePlace(page, element, placement) - } - } - - /** - * Return an array of placements that are disallowed for the elements to move - * in their container. - * - * @param {Object} page The page that is the parent component. - * @param {Number} element The child element for which the placements should - * be calculated. - * @returns {Array} An array of placements that are disallowed for the element. - */ - getPlacementsDisabledForChild(page, containerElement, element) { - this.getPlacementsDisabled(page, element) - } - - getNextHorizontalElementToSelect(page, element, placement) { - return null - } - - /** - * 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 class FormContainerElementType extends ContainerElementTypeMixin( ElementType ) { @@ -638,17 +687,40 @@ export class FormContainerElementType extends ContainerElementTypeMixin( return FormContainerElementForm } + getElementPlaces(element) { + return [null] + } + /** - * Only disallow form containers as nested elements. - * @param {object} page - The page the element belongs to. - * @param {Object} element The element in question, it can be used to - * determine in a more dynamic way if specific children are permitted. - * @returns {Array} An array containing the `FormContainerElementType`. + * This element is not allowed in another form container. */ - childElementTypesForbidden(page, element) { - return this.elementTypesAll.filter( - (elementType) => elementType.type === this.getType() - ) + isDisallowedReason({ + builder, + page, + parentElement, + beforeElement, + placeInContainer, + pagePlace, + }) { + if (parentElement) { + const hasSameTypeAncestor = !!this.app.store.getters[ + 'element/getAncestors' + ](page, parentElement, { + predicate: (ancestor) => ancestor.type === this.type, + includeSelf: true, + }).length + if (hasSameTypeAncestor) { + return this.app.i18n.t('elementType.notAllowedInsideSameType') + } + } + return super.isDisallowedReason({ + builder, + page, + parentElement, + beforeElement, + placeInContainer, + pagePlace, + }) } get childStylesForbidden() { @@ -659,23 +731,6 @@ export class FormContainerElementType extends ContainerElementTypeMixin( return [new SubmitEvent({ ...this.app })] } - /** - * Return an array of placements that are disallowed for the elements to move - * in their container. - * - * @param {Object} page The page that is the parent component. - * @param {Number} element The child element for which the placements should - * be calculated. - * @returns {Array} An array of placements that are disallowed for the element. - */ - getPlacementsDisabledForChild(page, containerElement, element) { - return [ - PLACEMENTS.LEFT, - PLACEMENTS.RIGHT, - ...this.getVerticalPlacementsDisabled(page, element), - ] - } - /** * A form container is invalid if it has no workflow actions, or it has no * children. @@ -718,17 +773,40 @@ export class ColumnElementType extends ContainerElementTypeMixin(ElementType) { return ColumnElementForm } + getElementPlaces(element) { + return [...Array(element.column_amount)].map((_, index) => `${index}`) + } + /** - * Only disallow column elements as nested elements. - * @param {object} page - The page the element belongs to. - * @param {Object} element The element in question, it can be used to - * determine in a more dynamic way if specific children are permitted. - * @returns {Array} An array containing the `ColumnElementType`. + * This element is not allowed in another column container. */ - childElementTypesForbidden(page, element) { - return this.elementTypesAll.filter( - (elementType) => elementType.type === this.getType() - ) + isDisallowedReason({ + builder, + page, + parentElement, + beforeElement, + placeInContainer, + pagePlace, + }) { + if (parentElement) { + const hasSameTypeAncestor = !!this.app.store.getters[ + 'element/getAncestors' + ](page, parentElement, { + predicate: (ancestor) => ancestor.type === this.type, + includeSelf: true, + }).length + if (hasSameTypeAncestor) { + return this.app.i18n.t('elementType.notAllowedInsideSameType') + } + } + return super.isDisallowedReason({ + builder, + page, + parentElement, + beforeElement, + placeInContainer, + pagePlace, + }) } get childStylesForbidden() { @@ -738,380 +816,8 @@ export class ColumnElementType extends ContainerElementTypeMixin(ElementType) { get defaultPlaceInContainer() { return '0' } - - /** - * Given a `page` and an `element`, move the child element of a container - * in the direction specified by the `placement`. - * - * @param {Object} page The page that is the parent component. - * @param {Number} element The child element to be moved. - * @param {String} placement The direction in which the element should move. - */ - async moveChildElement(page, parentElement, element, placement) { - if (placement === PLACEMENTS.AFTER || placement === PLACEMENTS.BEFORE) { - await super.moveChildElement(page, parentElement, element, placement) - } else { - const placeInContainer = parseInt(element.place_in_container) - const newPlaceInContainer = - placement === PLACEMENTS.LEFT - ? placeInContainer - 1 - : placeInContainer + 1 - - if (newPlaceInContainer >= 0) { - await this.app.store.dispatch('element/move', { - page, - elementId: element.id, - beforeElementId: null, - parentElementId: parentElement.id, - placeInContainer: `${newPlaceInContainer}`, - }) - } - } - } - - /** - * Return an array of placements that are disallowed for the elements to move - * in their container. - * - * @param {Object} page The page that is the parent component. - * @param {Number} element The child element for which the placements should - * be calculated. - * @returns {Array} An array of placements that are disallowed for the element. - */ - getPlacementsDisabledForChild(page, containerElement, element) { - const columnIndex = parseInt(element.place_in_container) - - const placementsDisabled = [] - - if (columnIndex === 0) { - placementsDisabled.push(PLACEMENTS.LEFT) - } - - if (columnIndex === containerElement.column_amount - 1) { - placementsDisabled.push(PLACEMENTS.RIGHT) - } - - return [ - ...placementsDisabled, - ...this.getVerticalPlacementsDisabled(page, element), - ] - } - - getNextHorizontalElementToSelect(page, element, placement) { - const offset = placement === PLACEMENTS.LEFT ? -1 : 1 - const containerElement = this.app.store.getters['element/getElementById']( - page, - element.parent_element_id - ) - - let elementsInPlace = [] - let nextPlaceInContainer = parseInt(element.place_in_container) - for (let i = 0; i < containerElement.column_amount; i++) { - nextPlaceInContainer += offset - elementsInPlace = this.app.store.getters['element/getElementsInPlace']( - page, - containerElement.id, - nextPlaceInContainer.toString() - ) - - if (elementsInPlace.length) { - return elementsInPlace[elementsInPlace.length - 1] - } - } - - return null - } } -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 class TableElementType extends CollectionElementTypeMixin(ElementType) { static getType() { return 'table' @@ -1205,21 +911,8 @@ export class RepeatElementType extends CollectionElementTypeMixin( return RepeatElementForm } - /** - * Return an array of placements that are disallowed for the elements to move - * in their container. - * - * @param {Object} page The page that is the parent component. - * @param {Number} element The child element for which the placements should - * be calculated. - * @returns {Array} An array of placements that are disallowed for the element. - */ - getPlacementsDisabledForChild(page, containerElement, element) { - return [ - PLACEMENTS.LEFT, - PLACEMENTS.RIGHT, - ...this.getVerticalPlacementsDisabled(page, element), - ] + getElementPlaces(element) { + return [null] } /** @@ -1290,10 +983,6 @@ export class FormElementType extends ElementType { }) } - getNextHorizontalElementToSelect(page, element, placement) { - return null - } - getDataSchema(element) { return { type: this.formDataType(element), @@ -1976,7 +1665,7 @@ export class RecordSelectorElementType extends CollectionElementTypeMixin( /** * This element is a special collection element. It's in error if it's data_source_id * is null. - * @param {*} param0 + * @param {Object} element the element to check the error * @returns */ isInError({ element }) { @@ -2075,3 +1764,153 @@ export class DateTimePickerElementType extends FormElementType { return this.parseElementDateTime(element, value).isValid() } } + +export class HeaderElementType extends MultiPageElementTypeMixin( + ContainerElementTypeMixin(ElementType) +) { + static getType() { + return 'header' + } + + get name() { + return this.app.i18n.t('elementType.header') + } + + get description() { + return this.app.i18n.t('elementType.headerDescription') + } + + get iconClass() { + return 'iconoir-align-top-box' + } + + get component() { + return MultiPageContainerElement + } + + get generalFormComponent() { + return MultiPageContainerElementForm + } + + getPagePlace() { + return PAGE_PLACES.HEADER + } + + getDefaultChildValues(page, values) { + return {} + } + + getDefaultValues(page, values) { + const superValues = super.getDefaultValues(page, values) + return { + ...superValues, + style_padding_left: 0, + style_padding_right: 0, + } + } + + /** + * We can't have this element inside another container. Not allowed outside of HEADER. + * We can add id before the first element though. + */ + isDisallowedReason({ + builder, + page, + parentElement, + beforeElement, + placeInContainer, + pagePlace, + }) { + if (parentElement) { + // Can't be inserted inside another container + return this.app.i18n.t('elementType.notAllowedInsideContainer') + } + + const sharedPage = this.app.store.getters['page/getSharedPage'](builder) + + if ( + page.id === sharedPage.id && + pagePlace && + pagePlace !== PAGE_PLACES.HEADER + ) { + // can't be inserted outside of header + return this.app.i18n.t('elementType.notAllowedUnlessHeader') + } + + if (page.id !== sharedPage.id) { + const orderedElements = + this.app.store.getters['element/getElementsOrdered'](page) + // Can't be inserted after the first element of the page + if (beforeElement && beforeElement.id !== orderedElements[0].id) { + return this.app.i18n.t('elementType.notAllowedUnlessTop') + } + } + return null + } +} + +export class FooterElementType extends HeaderElementType { + static getType() { + return 'footer' + } + + getPagePlace() { + return PAGE_PLACES.FOOTER + } + + get name() { + return this.app.i18n.t('elementType.footer') + } + + get description() { + return this.app.i18n.t('elementType.footerDescription') + } + + get iconClass() { + return 'iconoir-align-bottom-box' + } + + get component() { + return MultiPageContainerElement + } + + get generalFormComponent() { + return MultiPageContainerElementForm + } + + /** + * We can't have this element inside another container. Not allowed outside of FOOTER. + * We can add id after the element of the page though. + */ + isDisallowedReason({ + builder, + page, + parentElement, + beforeElement, + placeInContainer, + pagePlace, + }) { + if (parentElement) { + // Can't be inserted inside another container + return this.app.i18n.t('elementType.notAllowedInsideContainer') + } + + const sharedPage = this.app.store.getters['page/getSharedPage'](builder) + if ( + page.id === sharedPage.id && + pagePlace && + pagePlace !== PAGE_PLACES.FOOTER + ) { + // can't be inserted outside of header + return this.app.i18n.t('elementType.notAllowedUnlessFooter') + } + + if (page.id !== sharedPage.id) { + // Can't be inserted before the end of the page + if (beforeElement && beforeElement.page_id !== sharedPage.id) { + return this.app.i18n.t('elementType.notAllowedUnlessBottom') + } + } + return null + } +} diff --git a/web-frontend/modules/builder/enums.js b/web-frontend/modules/builder/enums.js index b0ca016ad..d49cc97af 100644 --- a/web-frontend/modules/builder/enums.js +++ b/web-frontend/modules/builder/enums.js @@ -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. diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json index ba7903048..6496950a7 100644 --- a/web-frontend/modules/builder/locales/en.json +++ b/web-frontend/modules/builder/locales/en.json @@ -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" } } diff --git a/web-frontend/modules/builder/mixins/collectionElement.js b/web-frontend/modules/builder/mixins/collectionElement.js index 7c4c4b99c..fa6ee13be 100644 --- a/web-frontend/modules/builder/mixins/collectionElement.js +++ b/web-frontend/modules/builder/mixins/collectionElement.js @@ -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, diff --git a/web-frontend/modules/builder/mixins/collectionElementForm.js b/web-frontend/modules/builder/mixins/collectionElementForm.js index b800e7a60..6b3ef8313 100644 --- a/web-frontend/modules/builder/mixins/collectionElementForm.js +++ b/web-frontend/modules/builder/mixins/collectionElementForm.js @@ -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 diff --git a/web-frontend/modules/builder/mixins/collectionField.js b/web-frontend/modules/builder/mixins/collectionField.js index 392aa7570..cc56b6f2a 100644 --- a/web-frontend/modules/builder/mixins/collectionField.js +++ b/web-frontend/modules/builder/mixins/collectionField.js @@ -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: { diff --git a/web-frontend/modules/builder/mixins/containerElement.js b/web-frontend/modules/builder/mixins/containerElement.js index 87fc4b3a4..f152f81e5 100644 --- a/web-frontend/modules/builder/mixins/containerElement.js +++ b/web-frontend/modules/builder/mixins/containerElement.js @@ -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 }, diff --git a/web-frontend/modules/builder/mixins/element.js b/web-frontend/modules/builder/mixins/element.js index 02620cf3d..0ab37d5e9 100644 --- a/web-frontend/modules/builder/mixins/element.js +++ b/web-frontend/modules/builder/mixins/element.js @@ -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 ) diff --git a/web-frontend/modules/builder/mixins/elementForm.js b/web-frontend/modules/builder/mixins/elementForm.js index 01679d2d8..39c589dc8 100644 --- a/web-frontend/modules/builder/mixins/elementForm.js +++ b/web-frontend/modules/builder/mixins/elementForm.js @@ -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() { diff --git a/web-frontend/modules/builder/mixins/elementSidePanel.js b/web-frontend/modules/builder/mixins/elementSidePanel.js index f6443a5a0..2bf280389 100644 --- a/web-frontend/modules/builder/mixins/elementSidePanel.js +++ b/web-frontend/modules/builder/mixins/elementSidePanel.js @@ -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), diff --git a/web-frontend/modules/builder/mixins/formElement.js b/web-frontend/modules/builder/mixins/formElement.js index e72f61a30..290b2356f 100644 --- a/web-frontend/modules/builder/mixins/formElement.js +++ b/web-frontend/modules/builder/mixins/formElement.js @@ -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, }) diff --git a/web-frontend/modules/builder/mixins/formElementForm.js b/web-frontend/modules/builder/mixins/formElementForm.js index 31d8bb044..3edfa426e 100644 --- a/web-frontend/modules/builder/mixins/formElementForm.js +++ b/web-frontend/modules/builder/mixins/formElementForm.js @@ -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 { diff --git a/web-frontend/modules/builder/mixins/visibilityForm.js b/web-frontend/modules/builder/mixins/visibilityForm.js index 942578e24..4be2363e8 100644 --- a/web-frontend/modules/builder/mixins/visibilityForm.js +++ b/web-frontend/modules/builder/mixins/visibilityForm.js @@ -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: [], diff --git a/web-frontend/modules/builder/pages/pageEditor.vue b/web-frontend/modules/builder/pages/pageEditor.vue index abb21ffdd..0faf74c81 100644 --- a/web-frontend/modules/builder/pages/pageEditor.vue +++ b/web-frontend/modules/builder/pages/pageEditor.vue @@ -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, } diff --git a/web-frontend/modules/builder/pages/publicPage.vue b/web-frontend/modules/builder/pages/publicPage.vue index 33f5f3746..18c2063e6 100644 --- a/web-frontend/modules/builder/pages/publicPage.vue +++ b/web-frontend/modules/builder/pages/publicPage.vue @@ -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() diff --git a/web-frontend/modules/builder/plugin.js b/web-frontend/modules/builder/plugin.js index 0a09fc4f2..2d7da12f5 100644 --- a/web-frontend/modules/builder/plugin.js +++ b/web-frontend/modules/builder/plugin.js @@ -43,6 +43,8 @@ import { IFrameElementType, RepeatElementType, RecordSelectorElementType, + HeaderElementType, + FooterElementType, } from '@baserow/modules/builder/elementTypes' import { DesktopDeviceType, @@ -209,6 +211,8 @@ export default (context) => { app.$registry.register('element', new ButtonElementType(context)) app.$registry.register('element', new TableElementType(context)) app.$registry.register('element', new ColumnElementType(context)) + app.$registry.register('element', new HeaderElementType(context)) + app.$registry.register('element', new FooterElementType(context)) app.$registry.register('element', new FormContainerElementType(context)) app.$registry.register('element', new InputTextElementType(context)) app.$registry.register('element', new ChoiceElementType(context)) diff --git a/web-frontend/modules/builder/store/element.js b/web-frontend/modules/builder/store/element.js index 9df48435e..564507555 100644 --- a/web-frontend/modules/builder/store/element.js +++ b/web-frontend/modules/builder/store/element.js @@ -3,6 +3,7 @@ import BigNumber from 'bignumber.js' import ElementService from '@baserow/modules/builder/services/element' import PublicBuilderService from '@baserow/modules/builder/services/publishedBuilder' import { calculateTempOrder } from '@baserow/modules/core/utils/order' +import { uuid } from '@baserow/modules/core/utils/string' const populateElement = (element, registry) => { const elementType = registry.get('element', element.type) @@ -13,6 +14,11 @@ const populateElement = (element, registry) => { reset: 0, shouldBeFocused: false, elementNamespacePath: null, + // This uid ensure that when we refresh the elements from the server when we + // authenticate that it didn't reuse some of the store values + // It breaks collection element reload after authentication for instance + // This uid is used as key in the PageElement component + uid: uuid(), ...elementType.getPopulateStoreProperties(), } @@ -202,20 +208,22 @@ const actions = { page, elementType: elementTypeName, beforeId = null, - configuration = null, + values = null, forceCreate = true, } ) { const elementType = this.$registry.get('element', elementTypeName) + const updatedValues = elementType.getDefaultValues(page, values) const { data: element } = await ElementService(this.$client).create( page.id, elementTypeName, beforeId, - elementType.getDefaultValues(page, configuration) + updatedValues ) if (forceCreate) { await dispatch('forceCreate', { page, element }) + await dispatch('select', { element }) } @@ -386,7 +394,11 @@ const actions = { dispatch('forceUpdate', { page, element: elementUpdated, - values: { ...elementUpdated }, + values: { + order: elementUpdated.order, + place_in_container: elementUpdated.place_in_container, + parent_element_id: elementUpdated.parent_element_id, + }, }) } catch (error) { // Restore previous order and place_in_container properties @@ -436,14 +448,6 @@ const actions = { elementType.onElementEvent(event, { element, ...rest }) }) }, - async moveElement({ dispatch, getters }, { page, element, placement }) { - const elementType = this.$registry.get('element', element.type) - await elementType.moveElement(page, element, placement) - }, - async selectNextElement({ dispatch, getters }, { page, element, placement }) { - const elementType = this.$registry.get('element', element.type) - await elementType.selectNextElement(page, element, placement) - }, _setElementNamespacePath({ commit, dispatch, getters }, { page, element }) { const elementType = this.$registry.get('element', element.type) const elementNamespacePath = elementType.getElementNamespacePath( @@ -470,6 +474,15 @@ const getters = { } return null }, + getElementByIdInPages: (state, getters) => (pages, id) => { + for (const page of pages) { + const found = getters.getElementById(page, id) + if (found) { + return found + } + } + return null + }, getElementsOrdered: (state, getters) => (page) => { return page.orderedElements }, diff --git a/web-frontend/modules/builder/store/elementContent.js b/web-frontend/modules/builder/store/elementContent.js index ebdbb783f..090c7e6cb 100644 --- a/web-frontend/modules/builder/store/elementContent.js +++ b/web-frontend/modules/builder/store/elementContent.js @@ -183,8 +183,6 @@ const actions = { return } - commit('SET_LOADING', { element, value: true }) - try { if (serviceType.isValid(dataSource)) { let rangeToFetch = range @@ -198,7 +196,6 @@ const actions = { // Everything is already loaded we can quit now if (!rangeToFetch) { - commit('SET_LOADING', { element, value: false }) return } rangeToFetch = [rangeToFetch[0], rangeToFetch[1] - rangeToFetch[0]] @@ -209,6 +206,7 @@ const actions = { service = PublishedBuilderService } + commit('SET_LOADING', { element, value: true }) const { data } = await service(this.app.$client).dispatch( dataSource.id, dispatchContext, diff --git a/web-frontend/modules/builder/store/page.js b/web-frontend/modules/builder/store/page.js index 5277af572..0d2d18797 100644 --- a/web-frontend/modules/builder/store/page.js +++ b/web-frontend/modules/builder/store/page.js @@ -16,6 +16,7 @@ export function populatePage(page) { elementMap: {}, orderedElements: [], workflowActions: [], + elementTree: [], contents: null, } } @@ -41,6 +42,10 @@ const mutations = { }, DELETE_ITEM(state, { builder, id }) { const index = builder.pages.findIndex((item) => item.id === id) + // Clear the elements to void the page and prevent errors + builder.pages[index].elements = [] + builder.pages[index].elementMap = {} + builder.pages[index].orderedElements = [] builder.pages.splice(index, 1) }, SET_SELECTED(state, { builder, page }) { diff --git a/web-frontend/modules/builder/workflowActionTypes.js b/web-frontend/modules/builder/workflowActionTypes.js index ddb7c3d31..140c50a1f 100644 --- a/web-frontend/modules/builder/workflowActionTypes.js +++ b/web-frontend/modules/builder/workflowActionTypes.js @@ -130,7 +130,6 @@ export class RefreshDataSourceWorkflowActionType extends WorkflowActionType { async execute({ workflowAction, applicationContext }) { applicationContext.page.elements .filter((element) => { - // Only refresh elements that use the data source return element.data_source_id === workflowAction.data_source_id }) .map(async (element) => { diff --git a/web-frontend/modules/core/assets/scss/components/builder/add_element_zone.scss b/web-frontend/modules/core/assets/scss/components/builder/add_element_zone.scss index 3f24c8fad..71f3a9806 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/add_element_zone.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/add_element_zone.scss @@ -1,30 +1,41 @@ -.add-element-zone__icon { - border: solid 1px $color-neutral-400; - border-radius: 100%; - padding: 8px; -} - .add-element-zone { display: flex; align-items: center; justify-content: center; - width: 100%; - height: 70px; - border: dashed 2px $color-neutral-400; - cursor: pointer; + width: calc(100% - 4px); + height: 120px; + border: 1px dashed $color-neutral-200; + background-color: $color-neutral-10; + margin: 2px; // To make sure we have enough space to see the border @include rounded($rounded-md); &:hover { - color: $color-primary-500; - border-color: $color-primary-500; - - .add-element-zone__icon { - border-color: $color-primary-500; - } - - .add-element-zone__button--disabled { - cursor: not-allowed; - } + border: 1px solid $color-primary-500; + background-color: $palette-blue-50; + border-color: $palette-blue-300; + } +} + +.add-element-zone__button { + padding: 10px; // Add more space around to make the click easier +} + +.add-element-zone__icon { + font-size: 16px; + border: solid 1px $color-neutral-400; + border-radius: 100%; + padding: 8px; + line-height: 1em; + cursor: pointer; + + @include elevation($elevation-low); + + .add-element-zone:hover & { + color: $color-primary-600; + } + + .add-element-zone.add-element-zone--disabled & { + cursor: not-allowed; } } diff --git a/web-frontend/modules/core/assets/scss/components/builder/all.scss b/web-frontend/modules/core/assets/scss/components/builder/all.scss index 3acdd6ac7..8814c7f38 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/all.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/all.scss @@ -8,6 +8,7 @@ @import 'element'; @import 'element_preview'; @import 'elements_list'; +@import 'elements_list_item'; @import 'side_panels'; @import 'empty_side_panel_state'; @import 'page_editor'; diff --git a/web-frontend/modules/core/assets/scss/components/builder/element_preview.scss b/web-frontend/modules/core/assets/scss/components/builder/element_preview.scss index c1460de48..817fd1c30 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/element_preview.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/element_preview.scss @@ -70,6 +70,11 @@ content: ''; border: solid 1px $color-primary-500; pointer-events: none; + + .page__header &, + .page__footer & { + border-color: $palette-purple-500; + } } } @@ -96,12 +101,18 @@ @include absolute(-26px, auto, auto, 5px); @include rounded($rounded-3xl); + font-size: 10px; background-color: $palette-blue-500; color: $white; cursor: default; - padding: 3px 6px; + padding: 4px 8px; z-index: 1; + .page__header &, + .page__footer & { + background-color: $palette-purple-500; + } + .element-preview--first-element & { @include absolute(auto, auto, -26px, 5px); } @@ -154,9 +165,10 @@ border-bottom-right-radius: 3px; } - &.disabled { + &--disabled { cursor: inherit; color: $color-neutral-400; + pointer-events: none; &:hover { background-color: $white; diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements/ab_components/ab_table.scss b/web-frontend/modules/core/assets/scss/components/builder/elements/ab_components/ab_table.scss index 851272dea..2c4563d3e 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/elements/ab_components/ab_table.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/elements/ab_components/ab_table.scss @@ -102,7 +102,7 @@ var(--table-cell-horizontal-padding, 20px); } -.ab-table__empty-message { +.ab-table__empty-state { text-align: center; padding: 50px 0; font-weight: 600; diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements/forms/all.scss b/web-frontend/modules/core/assets/scss/components/builder/elements/forms/all.scss index c33d9ecbb..c7f9436b3 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/elements/forms/all.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/elements/forms/all.scss @@ -4,3 +4,4 @@ @import 'form_container_element'; @import 'visibility_form'; @import 'property_option_form'; +@import 'multi_page_container_element_form'; diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements/forms/multi_page_container_element_form.scss b/web-frontend/modules/core/assets/scss/components/builder/elements/forms/multi_page_container_element_form.scss new file mode 100644 index 000000000..7ddbbc209 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/builder/elements/forms/multi_page_container_element_form.scss @@ -0,0 +1,14 @@ +.multi-page-container-element-form__page-list { + display: block; + margin: 16px 0; +} + +.multi-page-container-element-form__page-checkbox { + display: flex; + margin-bottom: 12px; +} + +.multi-page-container-element-form__actions { + display: flex; + gap: 15px; +} diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements/forms/visibility_form.scss b/web-frontend/modules/core/assets/scss/components/builder/elements/forms/visibility_form.scss index b235e14e8..2a61a4e86 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/elements/forms/visibility_form.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/elements/forms/visibility_form.scss @@ -3,24 +3,19 @@ margin-top: 10px; } -.visibility-form__role-links { - margin-top: 10px; - font-size: 12px; +.visibility-form__role-list { + display: block; + margin: 16px 0; } -.visibility-form__role-links-deselect-all { - margin-left: 10px; -} - -.visibility-form__role-checkbox-container { - display: inline-block; - margin-top: 10px; - margin-left: 2px; -} - -.visibility-form__role-checkbox-div { +.visibility-form__role-checkbox { display: flex; - margin-bottom: 5px; + margin-bottom: 12px; +} + +.visibility-form__actions { + display: flex; + gap: 15px; } .visibility-form__visibility-all { diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements_context.scss b/web-frontend/modules/core/assets/scss/components/builder/elements_context.scss index e851856f7..734fcf533 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/elements_context.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/elements_context.scss @@ -1,3 +1,33 @@ .elements-context { width: 300px; + padding-right: 4px; +} + +.elements-context__footer { + flex: 0 0; + border-top: 1px solid $palette-neutral-200; + padding: 4px 4px 0; +} + +.elements-context__footer-create { + display: flex; + flex-wrap: wrap; +} + +.elements-context__search-input { + display: block; + width: 100%; + border: none; + padding: 0 12px 0 0; + + @include rounded($rounded); + @include fixed-height(36px, 12px); + + &::placeholder { + color: $palette-neutral-700; + } +} + +.elements-context__elements { + overflow: auto; } diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements_list.scss b/web-frontend/modules/core/assets/scss/components/builder/elements_list.scss index 11759dbb3..ae3282fce 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/elements_list.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/elements_list.scss @@ -1,151 +1,25 @@ .elements-list { - @extend %context; - - max-width: 360px; - display: flex; - flex-direction: column; -} - -.elements-list__search-input { - display: block; - width: 100%; - border: none; - padding: 0 12px 0 0; - - @include rounded($rounded); - @include fixed-height(36px, 12px); - - &::placeholder { - color: $palette-neutral-700; - } -} - -.elements-list__items { position: relative; list-style: none; padding: 0; - margin: 0; + margin: 4px 0; + padding-bottom: 4px; min-height: 0; - max-height: (4 * 36px) + 20px; // we show max 4 items - // note that the value is `scroll` and not `auto` because it depends on the - // v-auto-overflow-scroll directive. - overflow-y: scroll; + border-bottom: 1px solid $palette-neutral-200; - &::before, - &::after { - content: ''; - display: block; - height: 4px; - width: 100%; - } - - // This class can be set if the max-height is managed by a container element. - &.elements-list__items--no-max-height { - max-height: none; - } - - ul { - list-style: none; - margin: 0 0 0 20px; + .elements-list & { + margin: 4px 0 4px 18px; padding: 0; + border-bottom: none; + border-left: 1px solid $palette-neutral-100; + } + + .elements-context__elements &:last-child { + border-bottom: none; + } + + &--empty { + padding: 32px 0; + text-align: center; } } - -.elements-list__item-label { - color: $color-neutral-600; - margin: 10px 0 10px 10px; - font-size: 14px; -} - -.elements-list__item { - position: relative; - margin: 0 0 4px 3px; - user-select: none; - - @include rounded($rounded); - - &:last-child { - margin-bottom: 0; - } - - &.elements-list__item--loading::before { - content: ' '; - - @include loading(14px); - @include absolute(9px, 9px, auto, auto); - } - - &.active:not(.elements-list__item--loading) { - background-color: rgba($palette-neutral-1300, 0.04); - } - - &.disabled { - background-color: transparent; - cursor: not-allowed; - - &:hover { - background-color: transparent; - } - } -} - -.elements-list__item-link { - display: block; - color: $palette-neutral-1300; - padding: 8px 32px 8px 10px; - - &--selected { - background-color: $palette-neutral-100; - } - - &:hover { - text-decoration: none; - background-color: $palette-neutral-100; - } - - .elements-list__item.disabled & { - color: $palette-neutral-700; - - &:hover { - cursor: inherit; - } - } -} - -.elements-list__item-name { - display: flex; - align-items: center; - font-weight: 500; - line-height: 15px; - gap: 6px; - - @extend %ellipsis; - - .elements-list__item-link:active & { - color: $palette-neutral-900; - } -} - -.elements-list__item-name-text { - @extend %ellipsis; -} - -.elements-list__item-icon { - font-size: 16px; - - .elements-list__item.disabled &, - .elements-list__item-link:active & { - color: $palette-neutral-900; - } -} - -.elements-list__footer { - flex: 0 0; - border-top: 1px solid $palette-neutral-200; - padding: 4px 4px 0; -} - -.elements-list__footer-create { - display: flex; - flex-wrap: wrap; -} diff --git a/web-frontend/modules/core/assets/scss/components/builder/elements_list_item.scss b/web-frontend/modules/core/assets/scss/components/builder/elements_list_item.scss new file mode 100644 index 000000000..7016f3826 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/builder/elements_list_item.scss @@ -0,0 +1,90 @@ +.elements-list-item { + position: relative; + margin: 0; + margin-left: 4px; + user-select: none; + + @include rounded($rounded); + + &:last-child { + margin-bottom: 0; + } + + &.elements-list-item--loading::before { + content: ' '; + + @include loading(14px); + @include absolute(9px, 9px, auto, auto); + } + + &.active:not(.elements-list-item--loading) { + background-color: rgba($palette-neutral-1300, 0.04); + } + + &.disabled { + background-color: transparent; + cursor: not-allowed; + + &:hover { + background-color: transparent; + } + } +} + +.elements-list-item__link { + display: block; + color: $palette-neutral-1300; + padding: 8px 32px 8px 10px; + + @include rounded($rounded-md); + + .elements-list-item--selected & { + background-color: $palette-neutral-100; + } + + &:hover { + text-decoration: none; + background-color: $palette-neutral-100; + } + + .elements-list-item.disabled & { + color: $palette-neutral-700; + + &:hover { + cursor: inherit; + } + } +} + +.elements-list-item__name { + display: flex; + align-items: center; + font-weight: 500; + line-height: 20px; + gap: 6px; + + @extend %ellipsis; + + .elements-list-item__link:active & { + color: $palette-neutral-900; + } +} + +.elements-list-item__name-text { + @extend %ellipsis; +} + +.elements-list-item__icon { + font-size: 16px; + color: $palette-neutral-900; + + .elements-list-item--selected &, + .elements-list-item__link:hover & { + color: inherit; + } + + .elements-list-item.disabled &, + .elements-list-item__link:active & { + color: $palette-neutral-900; + } +} diff --git a/web-frontend/modules/core/assets/scss/components/builder/page_preview.scss b/web-frontend/modules/core/assets/scss/components/builder/page_preview.scss index 2bacdf2ca..7c4b2134d 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/page_preview.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/page_preview.scss @@ -14,6 +14,22 @@ width: 100%; height: 100%; overflow: hidden; + + .page__header, + .page__content, + .page__footer { + // page parts are under the page separators... + z-index: 0; + position: relative; + } + + .page__header--element-selected, + .page__content--element-selected, + .page__footer--element-selected { + // ...Except if they contain the selected element + z-index: 2; + position: relative; + } } .page-preview__add { @@ -66,3 +82,25 @@ max-width: 400px; margin: 120px auto; } + +.page-preview__separator { + width: 100%; + border-bottom: 1px solid $palette-neutral-200; + display: flex; + justify-content: center; + height: 0; + position: relative; + z-index: 1; +} + +.page-preview__separator-label { + position: absolute; + top: -8px; + padding: 4px 8px; + border-radius: 16px; + background: $palette-neutral-50; + color: $palette-neutral-700; + font-size: 8px; + font-weight: 700; + line-height: 10px; +} diff --git a/web-frontend/modules/core/components/workflowActions/WorkflowAction.vue b/web-frontend/modules/core/components/workflowActions/WorkflowAction.vue index fa57fd013..9afd9ca3b 100644 --- a/web-frontend/modules/core/components/workflowActions/WorkflowAction.vue +++ b/web-frontend/modules/core/components/workflowActions/WorkflowAction.vue @@ -39,7 +39,7 @@ export default { name: 'WorkflowAction', components: { WorkflowActionSelector }, mixins: [applicationContext], - inject: ['page', 'builder', 'mode'], + inject: ['builder', 'elementPage', 'mode'], props: { availableWorkflowActionTypes: { type: Array, @@ -85,7 +85,7 @@ export default { try { await this.actionUpdateWorkflowAction({ - page: this.page, + page: this.elementPage, workflowAction: this.workflowAction, values, }) diff --git a/web-frontend/modules/integrations/localBaserow/mixins/localBaserowService.js b/web-frontend/modules/integrations/localBaserow/mixins/localBaserowService.js index fa48e4266..43baefeb8 100644 --- a/web-frontend/modules/integrations/localBaserow/mixins/localBaserowService.js +++ b/web-frontend/modules/integrations/localBaserow/mixins/localBaserowService.js @@ -1,5 +1,4 @@ export default { - inject: ['page'], props: { builder: { type: Object, @@ -18,9 +17,6 @@ export default { }, }, computed: { - dataSourceLoading() { - return this.$store.getters['dataSource/getLoading'](this.page) - }, /** * Used by `LocalBaserowTableServiceConditionalForm` so that when read, * we only provide filters which are from untrashed fields. When writing, @@ -87,12 +83,6 @@ export default { this.tableLoading = true } }, - dataSourceLoading: { - handler() { - this.tableLoading = false - }, - immediate: true, - }, }, methods: { /** diff --git a/web-frontend/test/unit/builder/components/elements/components/ChoiceElement.spec.js b/web-frontend/test/unit/builder/components/elements/components/ChoiceElement.spec.js index 6234286bd..4e34dc2b5 100644 --- a/web-frontend/test/unit/builder/components/elements/components/ChoiceElement.spec.js +++ b/web-frontend/test/unit/builder/components/elements/components/ChoiceElement.spec.js @@ -53,7 +53,8 @@ describe('ChoiceElement', () => { }, provide: { builder, - page, + currentPage: page, + elementPage: page, mode, applicationContext: { builder, page, mode }, element, diff --git a/web-frontend/test/unit/builder/components/elements/components/DateTimePickerElement.spec.js b/web-frontend/test/unit/builder/components/elements/components/DateTimePickerElement.spec.js index 5d3bf65fe..dd1cdd9a6 100644 --- a/web-frontend/test/unit/builder/components/elements/components/DateTimePickerElement.spec.js +++ b/web-frontend/test/unit/builder/components/elements/components/DateTimePickerElement.spec.js @@ -85,7 +85,8 @@ describe('DateTimePickerElement', () => { }, provide: { builder, - page, + currentPage: page, + elementPage: page, mode, applicationContext: { builder, page, mode }, element, diff --git a/web-frontend/test/unit/builder/components/elements/components/HeadingElement.spec.js b/web-frontend/test/unit/builder/components/elements/components/HeadingElement.spec.js index 0b0863286..9268943c0 100644 --- a/web-frontend/test/unit/builder/components/elements/components/HeadingElement.spec.js +++ b/web-frontend/test/unit/builder/components/elements/components/HeadingElement.spec.js @@ -36,8 +36,9 @@ describe('HeadingElement', () => { }, provide: { builder, - page, mode, + currentPage: page, + elementPage: page, applicationContext: { builder, page, mode }, element, workspace, @@ -46,7 +47,7 @@ describe('HeadingElement', () => { expect(wrapper.element).toMatchSnapshot() }) - test('Default HeadingElement component', async () => { + test('Default HeadingElement component v2', async () => { const builder = { id: 1, theme: { primary_color: '#ccc' } } const page = {} const workspace = {} @@ -59,8 +60,9 @@ describe('HeadingElement', () => { }, provide: { builder, - page, mode, + currentPage: page, + elementPage: page, applicationContext: { builder, page, mode }, element, workspace, diff --git a/web-frontend/test/unit/builder/components/elements/components/RecordSelectorElement.spec.js b/web-frontend/test/unit/builder/components/elements/components/RecordSelectorElement.spec.js index 2c40d88b2..129cfd077 100644 --- a/web-frontend/test/unit/builder/components/elements/components/RecordSelectorElement.spec.js +++ b/web-frontend/test/unit/builder/components/elements/components/RecordSelectorElement.spec.js @@ -68,7 +68,8 @@ describe('RecordSelectorElement', () => { }, provide: { builder, - page, + currentPage: page, + elementPage: page, mode, applicationContext: { builder, page, mode }, element, @@ -161,7 +162,8 @@ describe('RecordSelectorElement', () => { }, provide: { builder, - page, + currentPage: page, + elementPage: page, mode, applicationContext: { builder, page, mode, element }, element, diff --git a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/HeadingElement.spec.js.snap b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/HeadingElement.spec.js.snap index f0f9fbec5..54f28f4b2 100644 --- a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/HeadingElement.spec.js.snap +++ b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/HeadingElement.spec.js.snap @@ -14,7 +14,7 @@ exports[`HeadingElement Default HeadingElement component 1`] = ` </div> `; -exports[`HeadingElement Default HeadingElement component 2`] = ` +exports[`HeadingElement Default HeadingElement component v2 1`] = ` <div class="" > diff --git a/web-frontend/test/unit/builder/elementTypes.spec.js b/web-frontend/test/unit/builder/elementTypes.spec.js index 05ecdd615..1f899429e 100644 --- a/web-frontend/test/unit/builder/elementTypes.spec.js +++ b/web-frontend/test/unit/builder/elementTypes.spec.js @@ -6,7 +6,6 @@ import { RecordSelectorElementType, } from '@baserow/modules/builder/elementTypes' import { TestApp } from '@baserow/test/helpers/testApp' -import _ from 'lodash' import { IFRAME_SOURCE_TYPES, @@ -469,75 +468,138 @@ describe('elementTypes tests', () => { }) }) - describe('elementType childElementTypesForbidden tests', () => { - test('FormContainerElementType only itself as a nested child.', () => { - const formContainerElementType = testApp - .getRegistry() - .get('element', 'form_container') - const forbiddenChildTypes = formContainerElementType - .childElementTypesForbidden({}, {}) - .map((el) => el.getType()) - expect(forbiddenChildTypes).toEqual(['form_container']) - }) - test('ColumnElementType forbids only itself as a nested child.', () => { - const columnElementType = testApp.getRegistry().get('element', 'column') - const forbiddenChildTypes = columnElementType - .childElementTypesForbidden({}, {}) - .map((el) => el.getType()) - expect(forbiddenChildTypes).toEqual(['column']) - }) - test('RepeatElementType forbids nothing as a child.', () => { - const repeatElementType = testApp.getRegistry().get('element', 'repeat') - const forbiddenChildTypes = repeatElementType - .childElementTypesForbidden({}, {}) - .map((el) => el.getType()) - expect(forbiddenChildTypes).toEqual([]) + describe('elementType isDisallowedReason for base elements', () => { + test("Heading can't be placed on header nor footer if before/after another element", () => { + const headingElementType = testApp.getRegistry().get('element', 'heading') + + const page = { id: 123 } + const sharedPage = { id: 124, shared: true } + const anotherMultiPage = { + id: 111, + type: 'header', + page_id: sharedPage.id, + } + page.elementMap = {} + sharedPage.elementMap = { 111: anotherMultiPage } + + expect( + headingElementType.isDisallowedReason({ + builder: { id: 1, pages: [sharedPage, page] }, + page: sharedPage, + parentElement: null, + beforeElement: anotherMultiPage, + placeInContainer: null, + pagePlace: 'header', + }) + ).toEqual('elementType.notAllowedLocation') + + expect( + headingElementType.isDisallowedReason({ + builder: { id: 1, pages: [sharedPage, page] }, + page: sharedPage, + parentElement: null, + beforeElement: null, + placeInContainer: null, + pagePlace: 'footer', + }) + ).toEqual('elementType.notAllowedLocation') }) }) - describe('elementType childElementTypes tests', () => { - test('childElementTypes called with element with parent restricts child types using all ancestor childElementTypes requirements.', () => { - const page = { id: 1, name: 'Contact Us' } - const parentElement = { - id: 1, - page_id: page.id, - parent_element_id: null, - type: 'repeat', - } - const element = { - id: 2, - page_id: page.id, - parent_element_id: parentElement.id, + describe('elementType isDisallowedReason tests', () => { + test('FormContainerElementType itself as a nested child.', () => { + const formContainerElementType = testApp + .getRegistry() + .get('element', 'form_container') + + const page = { id: 123 } + const formAncestor = { id: 111, type: 'form_container', page_id: page.id } + const columnAncestor1 = { + id: 112, type: 'column', + page_id: page.id, + parent_element_id: 111, + } + const columnAncestor2 = { + id: 113, + type: 'column', + page_id: page.id, } - page.elementMap = { 1: parentElement, 2: element } - const allElementTypes = Object.values( - testApp.getRegistry().getAll('element') - ).map((el) => el.getType()) + page.elementMap = { + 111: formAncestor, + 112: columnAncestor1, + 113: columnAncestor2, + } - const columnElementType = testApp.getRegistry().get('element', 'column') - const forbiddenColumnChildTypes = columnElementType - .childElementTypesForbidden(page, element) - .map((el) => el.getType()) + expect( + formContainerElementType.isDisallowedReason({ + builder: { id: 1 }, + page, + parentElement: formAncestor, + beforeElement: null, + placeInContainer: 'content', + }) + ).toEqual('elementType.notAllowedInsideSameType') + expect( + formContainerElementType.isDisallowedReason({ + builder: { id: 1 }, + page, + parentElement: columnAncestor1, + beforeElement: null, + placeInContainer: 'content', + }) + ).toEqual('elementType.notAllowedInsideSameType') + // We check a top level column element + expect( + formContainerElementType.isDisallowedReason({ + builder: { id: 1 }, + page, + parentElement: columnAncestor2, + beforeElement: null, + placeInContainer: 'content', + }) + ).toEqual(null) + }) + test('ColumnElementType itself as a nested child.', () => { + const columnContainerElementType = testApp + .getRegistry() + .get('element', 'column') - const repeatElementType = testApp.getRegistry().get('element', 'repeat') - const forbiddenRepeatChildTypes = repeatElementType - .childElementTypesForbidden(page, {}) - .map((el) => el.getType()) + const page = { id: 123 } + const columnAncestor = { id: 111, type: 'column', page_id: page.id } - const allExpectedForbiddenChildTypes = forbiddenColumnChildTypes.concat( - forbiddenRepeatChildTypes - ) - const expectedAllowedChildTypes = _.difference( - allElementTypes, - allExpectedForbiddenChildTypes - ) + page.elementMap = { 111: columnAncestor } - const childElementTypes = columnElementType - .childElementTypes(page, element) - .map((el) => el.getType()) - expect(childElementTypes).toEqual(expectedAllowedChildTypes) + expect( + columnContainerElementType.isDisallowedReason({ + builder: { id: 1 }, + page, + parentElement: columnAncestor, + beforeElement: null, + placeInContainer: 'content', + }) + ).toEqual('elementType.notAllowedInsideSameType') + }) + test('RepeatElementType allow itself as a nested child.', () => { + const repeatContainerElementType = testApp + .getRegistry() + .get('element', 'repeat') + + const page = { id: 123 } + const repeatAncestor = { id: 111, type: 'repeat', page_id: page.id } + + page.elementMap = { 111: repeatAncestor } + + expect( + repeatContainerElementType.isDisallowedReason({ + builder: { id: 1 }, + page, + parentElement: repeatAncestor, + beforeElement: null, + placeInContainer: 'content', + }) + ).toEqual(null) }) }) @@ -754,4 +816,239 @@ describe('elementTypes tests', () => { expect(elementType.isInError({ page, element })).toBe(false) }) }) + + describe.only('elementType elementAround tests', () => { + let page, sharedPage, builder + beforeEach(async () => { + // Populate a page with a bunch of elements + page = { id: 123, elements: [], orderedElements: [], elementMap: {} } + sharedPage = { + id: 124, + shared: true, + elements: [], + orderedElements: [], + elementMap: {}, + } + builder = { id: 1, pages: [sharedPage, page] } + + const heading1 = { + id: 42, + type: 'heading', + } + const heading2 = { + id: 43, + type: 'heading', + } + const elements = [heading1, heading2] + + await Promise.all( + elements.map(async (element, index) => { + await testApp.getStore().dispatch('element/forceCreate', { + page, + element: { + place_in_container: null, + parent_element_id: null, + ...element, + page_id: page.id, + order: `${index}.0000`, + }, + }) + }) + ) + + const header1 = { + id: 111, + type: 'header', + } + const header2 = { + id: 112, + type: 'header', + } + const footer1 = { + id: 113, + type: 'footer', + } + const footer2 = { + id: 114, + type: 'footer', + } + + const sharedPageElements = [header1, footer1, footer2, header2] + + await Promise.all( + sharedPageElements.map(async (element, index) => { + await testApp.getStore().dispatch('element/forceCreate', { + page: sharedPage, + element: { + place_in_container: null, + parent_element_id: null, + ...element, + page_id: sharedPage.id, + order: `${index}.0000`, + }, + }) + }) + ) + }) + test('for first header.', () => { + const elementType = testApp.getRegistry().get('element', 'header') + const firstHeader = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 111) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: firstHeader, + withSharedPage: false, + }) + expect(elementsAround.before).toBeNull() + expect(elementsAround.after?.id).toEqual(112) + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + test('for second header.', () => { + const elementType = testApp.getRegistry().get('element', 'header') + const secondHeader = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 112) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: secondHeader, + withSharedPage: false, + }) + expect(elementsAround.before?.id).toEqual(111) + expect(elementsAround.after).toBeNull() + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + test('for second header with sharedPage.', () => { + const elementType = testApp.getRegistry().get('element', 'header') + const secondHeader = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 112) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: secondHeader, + withSharedPage: true, + }) + expect(elementsAround.before?.id).toEqual(111) + expect(elementsAround.after?.id).toEqual(42) + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + test('for first footer.', () => { + const elementType = testApp.getRegistry().get('element', 'footer') + const firstFooter = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 113) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: firstFooter, + withSharedPage: false, + }) + expect(elementsAround.before).toBeNull() + expect(elementsAround.after?.id).toEqual(114) + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + test('for first footer with shared page.', () => { + const elementType = testApp.getRegistry().get('element', 'footer') + const firstFooter = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 113) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: firstFooter, + withSharedPage: true, + }) + expect(elementsAround.before?.id).toEqual(43) + expect(elementsAround.after?.id).toEqual(114) + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + test('for second footer.', () => { + const elementType = testApp.getRegistry().get('element', 'footer') + const secondFooter = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 114) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: secondFooter, + withSharedPage: false, + }) + expect(elementsAround.before?.id).toEqual(113) + expect(elementsAround.after).toBeNull() + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + test('for first heading.', () => { + const elementType = testApp.getRegistry().get('element', 'heading') + const firstHeading = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 42) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: firstHeading, + withSharedPage: false, + }) + expect(elementsAround.before).toBeNull() + expect(elementsAround.after?.id).toEqual(43) + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + test('for second heading.', () => { + const elementType = testApp.getRegistry().get('element', 'heading') + const secondHeading = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 43) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: secondHeading, + withSharedPage: false, + }) + expect(elementsAround.before?.id).toEqual(42) + expect(elementsAround.after).toBeNull() + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + test('for first heading with shared page.', () => { + const elementType = testApp.getRegistry().get('element', 'heading') + const firstHeading = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 42) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: firstHeading, + withSharedPage: true, + }) + expect(elementsAround.before?.id).toEqual(112) + expect(elementsAround.after?.id).toEqual(43) + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + test('for second heading with shared page.', () => { + const elementType = testApp.getRegistry().get('element', 'heading') + const secondHeading = testApp + .getStore() + .getters['element/getElementByIdInPages']([page, sharedPage], 43) + const elementsAround = elementType.getElementsAround({ + builder, + page, + element: secondHeading, + withSharedPage: true, + }) + expect(elementsAround.before?.id).toEqual(42) + expect(elementsAround.after?.id).toEqual(113) + expect(elementsAround.left).toBeNull() + expect(elementsAround.right).toBeNull() + }) + }) })