1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-10 23:50:12 +00:00

Add a menu element

This commit is contained in:
Tsering Paljor 2025-03-03 11:49:21 +00:00
parent 5412427ca8
commit 5d44cdd944
34 changed files with 2366 additions and 35 deletions

View file

@ -18,6 +18,9 @@ from baserow.contrib.builder.elements.models import (
CollectionElementPropertyOptions,
CollectionField,
Element,
LinkElement,
MenuItemElement,
NavigationElementMixin,
)
from baserow.contrib.builder.elements.registries import (
collection_field_type_registry,
@ -378,3 +381,103 @@ class CollectionElementPropertyOptionsSerializer(
class Meta:
model = CollectionElementPropertyOptions
fields = ["schema_property", "filterable", "sortable", "searchable"]
class MenuItemSerializer(serializers.ModelSerializer):
"""Serializes the MenuItemElement."""
children = serializers.ListSerializer(
child=serializers.DictField(),
required=False,
help_text="A MenuItemElement that is a child of this instance.",
)
navigation_type = serializers.ChoiceField(
choices=NavigationElementMixin.NAVIGATION_TYPES.choices,
help_text=LinkElement._meta.get_field("navigation_type").help_text,
required=False,
)
navigate_to_page_id = serializers.IntegerField(
allow_null=True,
default=None,
help_text=LinkElement._meta.get_field("navigate_to_page").help_text,
required=False,
)
navigate_to_url = FormulaSerializerField(
help_text=LinkElement._meta.get_field("navigate_to_url").help_text,
default="",
allow_blank=True,
required=False,
)
page_parameters = PageParameterValueSerializer(
many=True,
default=[],
help_text=LinkElement._meta.get_field("page_parameters").help_text,
required=False,
)
query_parameters = PageParameterValueSerializer(
many=True,
default=[],
help_text=LinkElement._meta.get_field("query_parameters").help_text,
required=False,
)
target = serializers.ChoiceField(
choices=NavigationElementMixin.TARGETS.choices,
help_text=LinkElement._meta.get_field("target").help_text,
required=False,
)
class Meta:
model = MenuItemElement
fields = [
"id",
"variant",
"type",
"menu_item_order",
"uid",
"name",
"navigation_type",
"navigate_to_page_id",
"navigate_to_url",
"page_parameters",
"query_parameters",
"parent_menu_item",
"target",
"children",
]
def to_representation(self, instance):
"""Recursively serializes child MenuItemElements."""
data = super().to_representation(instance)
all_items = self.context.get("all_items", [])
# Get children from all_items to save queries
children = [i for i in all_items if instance.id == i.parent_menu_item_id]
data["children"] = MenuItemSerializer(
children, many=True, context=self.context
).data
return data
class NestedMenuItemsMixin(serializers.Serializer):
menu_items = serializers.SerializerMethodField(
help_text="Menu items of the MenuElement."
)
@extend_schema_field(MenuItemSerializer)
def get_menu_items(self, obj):
"""Return the serialized version of the MenuItemElement."""
# Prefetches the child MenuItemElements for performance.
menu_items = obj.menu_items.all()
root_items = [
child for child in menu_items if child.parent_menu_item_id is None
]
return MenuItemSerializer(
root_items, many=True, context={"all_items": menu_items}
).data

View file

@ -1,5 +1,7 @@
from django.apps import AppConfig
from baserow.core.feature_flags import FF_MENU_ELEMENT, feature_flag_is_enabled
class BuilderConfig(AppConfig):
name = "baserow.contrib.builder"
@ -183,6 +185,7 @@ class BuilderConfig(AppConfig):
ImageElementType,
InputTextElementType,
LinkElementType,
MenuElementType,
RecordSelectorElementType,
RepeatElementType,
TableElementType,
@ -208,6 +211,9 @@ class BuilderConfig(AppConfig):
element_type_registry.register(HeaderElementType())
element_type_registry.register(FooterElementType())
if feature_flag_is_enabled(FF_MENU_ELEMENT):
element_type_registry.register(MenuElementType())
from .domains.domain_types import CustomDomainType, SubDomainType
from .domains.registries import domain_type_registry

View file

@ -15,13 +15,17 @@ from typing import (
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db.models import IntegerField, QuerySet
from django.db.models import IntegerField, Q, QuerySet
from django.db.models.functions import Cast
from rest_framework import serializers
from rest_framework.exceptions import ValidationError as DRFValidationError
from baserow.contrib.builder.api.elements.serializers import ChoiceOptionSerializer
from baserow.contrib.builder.api.elements.serializers import (
ChoiceOptionSerializer,
MenuItemSerializer,
NestedMenuItemsMixin,
)
from baserow.contrib.builder.data_providers.exceptions import (
FormDataProviderChunkInvalidException,
)
@ -51,6 +55,8 @@ from baserow.contrib.builder.elements.models import (
ImageElement,
InputTextElement,
LinkElement,
MenuElement,
MenuItemElement,
NavigationElementMixin,
RecordSelectorElement,
RepeatElement,
@ -70,6 +76,7 @@ from baserow.contrib.builder.theme.theme_config_block_types import (
TableThemeConfigBlockType,
)
from baserow.contrib.builder.types import ElementDict
from baserow.contrib.builder.workflow_actions.models import BuilderWorkflowAction
from baserow.core.constants import (
DATE_FORMAT,
DATE_FORMAT_CHOICES,
@ -1965,3 +1972,313 @@ class FooterElementType(MultiPageContainerElementType):
type = "footer"
model_class = FooterElement
class MenuElementType(ElementType):
"""
A Menu element that provides navigation capabilities to the application.
"""
type = "menu"
model_class = MenuElement
serializer_field_names = ["orientation", "menu_items"]
allowed_fields = ["orientation"]
serializer_mixins = [NestedMenuItemsMixin]
request_serializer_mixins = []
class SerializedDict(ElementDict):
orientation: str
menu_items: List[Dict]
@property
def serializer_field_overrides(self) -> Dict[str, Any]:
from baserow.contrib.builder.api.theme.serializers import (
DynamicConfigBlockSerializer,
)
from baserow.contrib.builder.theme.theme_config_block_types import (
ButtonThemeConfigBlockType,
)
overrides = {
**super().serializer_field_overrides,
"styles": DynamicConfigBlockSerializer(
required=False,
property_name="button",
theme_config_block_type_name=ButtonThemeConfigBlockType.type,
serializer_kwargs={"required": False},
),
}
return overrides
@property
def request_serializer_field_overrides(self) -> Dict[str, Any]:
return {
**self.serializer_field_overrides,
"menu_items": MenuItemSerializer(many=True, required=False),
}
def enhance_queryset(
self, queryset: QuerySet[MenuItemElement]
) -> QuerySet[MenuItemElement]:
return queryset.prefetch_related("menu_items")
def before_delete(self, instance: MenuElement) -> None:
"""
Handle any clean-up needed before the MenuElement is deleted.
Deletes all related objects of this MenuElement instance such as Menu
Items and Workflow actions.
"""
self.delete_workflow_actions(instance)
instance.menu_items.all().delete()
def after_create(self, instance: MenuItemElement, values: Dict[str, Any]) -> None:
"""
After a MenuElement is created, MenuItemElements are bulk-created
using the information in the "menu_items" array.
"""
menu_items = values.get("menu_items", [])
created_menu_items = MenuItemElement.objects.bulk_create(
[
MenuItemElement(**item, menu_item_order=index)
for index, item in enumerate(menu_items)
]
)
instance.menu_items.add(*created_menu_items)
def delete_workflow_actions(
self, instance: MenuElement, menu_item_uids_to_keep: Optional[List[str]] = None
) -> None:
"""
Deletes all Workflow actions related to a specific MenuElement instance.
:param instance: The MenuElement instance for which related Workflow
actions will be deleted.
:param menu_item_uids_to_keep: An optional list of UUIDs. If a related
Workflow action matches a UUID in this list, it will *not* be deleted.
:return: None
"""
# Get all workflow actions associated with this menu element.
all_workflow_actions = BuilderWorkflowAction.objects.filter(element=instance)
# If there are menu items, only keep workflow actions that match
# existing menu items.
if menu_item_uids_to_keep:
workflow_actions_to_keep_query = Q()
for uid in menu_item_uids_to_keep:
workflow_actions_to_keep_query |= Q(event__startswith=uid)
# Find Workflow actions to delete (those not matching any
# current Menu Item).
workflow_actions_to_delete = all_workflow_actions.exclude(
workflow_actions_to_keep_query
)
else:
# Since there are no Menu Items, delete all Workflow actions
# for this element.
workflow_actions_to_delete = all_workflow_actions
# Delete the workflow actions that are no longer associated with
# any menu item.
if workflow_actions_to_delete.exists():
workflow_actions_to_delete.delete()
def after_update(self, instance: MenuElement, values, changes: Dict[str, Tuple]):
"""
After the element has been updated we need to update the fields.
:param instance: The instance of the element that has been updated.
:param values: The values that have been updated.
:param changes: A dictionary containing all changes which were made to the
collection element prior to `after_update` being called.
:return: None
"""
if "menu_items" in values:
instance.menu_items.all().delete()
menu_item_uids_to_keep = [item["uid"] for item in values["menu_items"]]
self.delete_workflow_actions(instance, menu_item_uids_to_keep)
items_to_create = []
child_uids_parent_uids = {}
keys_to_remove = ["parent_menu_item", "menu_item_order"]
for index, item in enumerate(values["menu_items"]):
for key in keys_to_remove:
item.pop(key, None)
# Keep track of child-parent relationship via the uid
for child_index, child in enumerate(item.pop("children", [])):
for key in keys_to_remove + ["children"]:
child.pop(key, None)
items_to_create.append(
MenuItemElement(**child, menu_item_order=child_index)
)
child_uids_parent_uids[str(child["uid"])] = str(item["uid"])
items_to_create.append(MenuItemElement(**item, menu_item_order=index))
created_items = MenuItemElement.objects.bulk_create(items_to_create)
instance.menu_items.add(*created_items)
# Re-associate the child-parent
for item in instance.menu_items.all():
if parent_uid := child_uids_parent_uids.get(str(item.uid)):
parent_item = instance.menu_items.filter(uid=parent_uid).first()
item.parent_menu_item = parent_item
item.save()
super().after_update(instance, values, changes)
def get_pytest_params(self, pytest_data_fixture):
return {"orientation": RepeatElement.ORIENTATIONS.VERTICAL}
def deserialize_property(
self,
prop_name: str,
value: Any,
id_mapping: Dict[str, Any],
files_zip=None,
storage=None,
cache=None,
**kwargs,
) -> Any:
if prop_name == "menu_items":
updated_menu_items = []
for item in value:
updated = {}
for item_key, item_value in item.items():
new_value = super().deserialize_property(
item_key,
NavigationElementManager().deserialize_property(
item_key, item_value, id_mapping, **kwargs
),
id_mapping,
files_zip=files_zip,
storage=storage,
cache=cache,
**kwargs,
)
updated[item_key] = new_value
updated_menu_items.append(updated)
return updated_menu_items
return super().deserialize_property(
prop_name,
value,
id_mapping,
files_zip=files_zip,
storage=storage,
cache=cache,
**kwargs,
)
def serialize_property(
self,
element: MenuElement,
prop_name: str,
files_zip=None,
storage=None,
cache=None,
**kwargs,
) -> Any:
if prop_name == "menu_items":
return MenuItemSerializer(
element.menu_items.all(),
many=True,
).data
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,
) -> MenuElement:
menu_items = serialized_values.pop("menu_items", [])
instance = super().create_instance_from_serialized(
serialized_values,
id_mapping,
files_zip=files_zip,
storage=storage,
cache=cache,
**kwargs,
)
menu_items_to_create = []
child_uids_parent_uids = {}
ids_uids = {i["id"]: i["uid"] for i in menu_items}
keys_to_remove = ["id", "menu_item_order", "children"]
for index, item in enumerate(menu_items):
for key in keys_to_remove:
item.pop(key, None)
# Keep track of child-parent relationship via the uid
if parent_id := item.pop("parent_menu_item", None):
child_uids_parent_uids[item["uid"]] = ids_uids[parent_id]
menu_items_to_create.append(MenuItemElement(**item, menu_item_order=index))
created_menu_items = MenuItemElement.objects.bulk_create(menu_items_to_create)
instance.menu_items.add(*created_menu_items)
# Re-associate the child-parent
for item in instance.menu_items.all():
if parent_uid := child_uids_parent_uids.get(str(item.uid)):
parent_item = instance.menu_items.filter(uid=parent_uid).first()
item.parent_menu_item = parent_item
item.save()
return instance
def formula_generator(
self, element: Element
) -> Generator[str | Instance, str, None]:
"""
Generator that returns formula fields for the MenuElementType.
The MenuElement has a menu_items field, which is a many-to-many
relationship with MenuItemElement. The MenuItemElement has navigation
related fields like page_parameters, yet does not have a type of its
own.
This method ensures that any formulas found inside MenuItemElements
are extracted correctly. It ensures that when a formula is declared
in page_parameters, etc, the resolved formula value is available
in the frontend.
"""
yield from super().formula_generator(element)
for item in element.menu_items.all():
for index, data in enumerate(item.page_parameters or []):
new_formula = yield data["value"]
if new_formula is not None:
item.page_parameters[index]["value"] = new_formula
yield item
for index, data in enumerate(item.query_parameters or []):
new_formula = yield data["value"]
if new_formula is not None:
item.query_parameters[index]["value"] = new_formula
yield item

View file

@ -237,7 +237,14 @@ class ElementHandler:
"""
if specific:
elements = specific_iterator(base_queryset)
elements = specific_iterator(
base_queryset,
per_content_type_queryset_hook=(
lambda element, queryset: element_type_registry.get_by_model(
element
).enhance_queryset(queryset)
),
)
else:
elements = base_queryset

View file

@ -990,3 +990,72 @@ class FooterElement(MultiPageElement, ContainerElement):
"""
A multi-page container element positioned at the bottom of the page.
"""
class MenuItemElement(NavigationElementMixin):
"""
An item in a MenuElement.
"""
class VARIANTS(models.TextChoices):
LINK = "link"
BUTTON = "button"
variant = models.CharField(
choices=VARIANTS.choices,
help_text="The variant of the link.",
max_length=10,
default=VARIANTS.LINK,
)
class TYPES(models.TextChoices):
BUTTON = "button"
LINK = "link"
SEPARATOR = "separator"
SPACER = "spacer"
type = models.CharField(
choices=TYPES.choices,
help_text="The type of the Menu Item.",
max_length=9,
default=TYPES.LINK,
)
name = models.CharField(
max_length=225,
help_text="The name of the Menu Item.",
)
menu_item_order = models.PositiveIntegerField()
uid = models.UUIDField(default=uuid.uuid4)
parent_menu_item = models.ForeignKey(
"self",
on_delete=models.CASCADE,
null=True,
default=None,
help_text="The parent MenuItemElement element, if it is a nested item.",
related_name="menu_item_children",
)
class Meta:
ordering = ("menu_item_order",)
class MenuElement(Element):
"""
A menu element that helps with navigating the application.
"""
class ORIENTATIONS(models.TextChoices):
HORIZONTAL = "horizontal"
VERTICAL = "vertical"
orientation = models.CharField(
choices=ORIENTATIONS.choices,
max_length=10,
default=ORIENTATIONS.HORIZONTAL,
db_default=ORIENTATIONS.HORIZONTAL,
)
menu_items = models.ManyToManyField(MenuItemElement)

View file

@ -0,0 +1,165 @@
# Generated by Django 5.0.9 on 2025-02-25 09:11
import uuid
import django.db.models.deletion
from django.db import migrations, models
import baserow.core.formula.field
class Migration(migrations.Migration):
dependencies = [
("builder", "0051_alter_builderworkflowaction_options"),
]
operations = [
migrations.CreateModel(
name="MenuItemElement",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"navigation_type",
models.CharField(
choices=[("page", "Page"), ("custom", "Custom")],
default="page",
help_text="The navigation type.",
max_length=10,
null=True,
),
),
(
"navigate_to_url",
baserow.core.formula.field.FormulaField(
default="",
help_text="If no page is selected, this indicate the destination of the link.",
null=True,
),
),
(
"page_parameters",
models.JSONField(
default=list,
help_text="The parameters for each parameters of the selected page if any.",
null=True,
),
),
(
"query_parameters",
models.JSONField(
db_default=[],
default=list,
help_text="The query parameters for each parameter of the selected page if any.",
null=True,
),
),
(
"target",
models.CharField(
choices=[("self", "Self"), ("blank", "Blank")],
default="self",
help_text="The target of the link when we click on it.",
max_length=10,
null=True,
),
),
(
"variant",
models.CharField(
choices=[("link", "Link"), ("button", "Button")],
default="link",
help_text="The variant of the link.",
max_length=10,
),
),
(
"type",
models.CharField(
choices=[
("button", "Button"),
("link", "Link"),
("separator", "Separator"),
("spacer", "Spacer"),
],
default="link",
help_text="The type of the Menu Item.",
max_length=9,
),
),
(
"name",
models.CharField(
help_text="The name of the Menu Item.", max_length=225
),
),
("menu_item_order", models.PositiveIntegerField()),
("uid", models.UUIDField(default=uuid.uuid4)),
(
"navigate_to_page",
models.ForeignKey(
help_text=(
"Destination page id for this link. If null then we use the navigate_to_url property instead.",
),
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="builder.page",
),
),
(
"parent_menu_item",
models.ForeignKey(
default=None,
help_text="The parent MenuItemElement element, if it is a nested item.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="menu_item_children",
to="builder.menuitemelement",
),
),
],
options={
"ordering": ("menu_item_order",),
},
),
migrations.CreateModel(
name="MenuElement",
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",
),
),
(
"orientation",
models.CharField(
choices=[
("horizontal", "Horizontal"),
("vertical", "Vertical"),
],
db_default="horizontal",
default="horizontal",
max_length=10,
),
),
("menu_items", models.ManyToManyField(to="builder.menuitemelement")),
],
options={
"abstract": False,
},
bases=("builder.element",),
),
]

View file

@ -3,6 +3,7 @@ from django.conf import settings
from baserow.core.exceptions import FeatureDisabledException
FF_DASHBOARDS = "dashboards"
FF_MENU_ELEMENT = "menu_element"
FF_ENABLE_ALL = "*"

View file

@ -137,6 +137,12 @@ class CustomFieldsInstanceMixin:
useful if you want to add some custom SerializerMethodField for example.
"""
request_serializer_mixins = None
"""
The serializer mixins that must be added to the serializer for requests.
This property is useful if you want to add some custom behaviour for example.
"""
serializer_extra_kwargs = None
"""
The extra kwargs that must be added to the serializer fields. This property is
@ -189,7 +195,7 @@ class CustomFieldsInstanceMixin:
# as serializers are callable) which lazy loads a serializer mixin, or
# 2) Serializers can provide a serializer mixin directly.
dynamic_serializer_mixins = []
for serializer_mixin in self.serializer_mixins:
for serializer_mixin in self.get_serializer_mixins(request_serializer):
if isinstance(serializer_mixin, FunctionType):
dynamic_serializer_mixins.append(serializer_mixin())
else:
@ -249,6 +255,12 @@ class CustomFieldsInstanceMixin:
return serializer_class(model_instance_or_instances, context=context, **kwargs)
def get_serializer_mixins(self, request_serializer: bool) -> List:
if request_serializer and self.request_serializer_mixins is not None:
return self.request_serializer_mixins
else:
return self.serializer_mixins
def get_field_overrides(
self, request_serializer: bool, extra_params: Dict, **kwargs
) -> Dict:

View file

@ -10,6 +10,7 @@ from baserow.contrib.builder.elements.models import (
ImageElement,
InputTextElement,
LinkElement,
MenuElement,
RecordSelectorElement,
RepeatElement,
TableElement,
@ -116,6 +117,9 @@ class ElementFixtures:
)
return element
def create_builder_menu_element(self, user=None, page=None, **kwargs):
return self.create_builder_element(MenuElement, user, page, **kwargs)
def create_builder_element(self, model_class, user=None, page=None, **kwargs):
if user is None:
user = self.create_user()

View file

@ -0,0 +1,167 @@
import uuid
from django.urls import reverse
import pytest
from rest_framework.status import HTTP_200_OK
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.models import MenuItemElement
from baserow.test_utils.helpers import AnyInt, AnyStr
@pytest.fixture
def menu_element_fixture(data_fixture):
"""Fixture to help test the Menu element."""
user, token = data_fixture.create_user_and_token()
builder = data_fixture.create_builder_application(user=user)
page_a = data_fixture.create_builder_page(builder=builder, path="/page_a/:foo/")
page_b = data_fixture.create_builder_page(builder=builder, path="/page_b/")
menu_element = data_fixture.create_builder_menu_element(user=user, page=page_a)
return {
"token": token,
"page_a": page_a,
"page_b": page_b,
"menu_element": menu_element,
}
def create_menu_item(**kwargs):
menu_item = {
"name": "Link",
"type": MenuItemElement.TYPES.LINK,
"variant": MenuItemElement.VARIANTS.LINK,
"menu_item_order": 0,
"uid": uuid.uuid4(),
"navigation_type": "",
"navigate_to_page_id": None,
"navigate_to_url": "",
"page_parameters": [],
"query_parameters": [],
"parent_menu_item": None,
"target": "self",
"children": [],
}
menu_item.update(kwargs)
return menu_item
@pytest.mark.django_db
def test_get_menu_element(api_client, menu_element_fixture):
menu_element = menu_element_fixture["menu_element"]
# Add a Menu item
menu_item = create_menu_item()
data = {"menu_items": [menu_item]}
ElementHandler().update_element(menu_element, **data)
page = menu_element_fixture["page_a"]
token = menu_element_fixture["token"]
url = reverse("api:builder:element:list", kwargs={"page_id": page.id})
response = api_client.get(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
[menu] = response.json()
assert menu["id"] == menu_element.id
assert menu["type"] == "menu"
assert menu["orientation"] == "horizontal"
assert menu["menu_items"] == [
{
"children": [],
"id": menu_element.menu_items.all()[0].id,
"menu_item_order": AnyInt(),
"name": "Link",
"navigate_to_page_id": None,
"navigate_to_url": "",
"navigation_type": "",
"page_parameters": [],
"parent_menu_item": None,
"query_parameters": [],
"target": "self",
"type": "link",
"uid": AnyStr(),
"variant": "link",
},
]
@pytest.mark.django_db
def test_create_menu_element(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
page = data_fixture.create_builder_page(user=user)
url = reverse("api:builder:element:list", kwargs={"page_id": page.id})
response = api_client.post(
url,
{
"type": "menu",
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["type"] == "menu"
@pytest.mark.django_db
def test_can_update_a_table_element_fields(api_client, menu_element_fixture):
menu_element = menu_element_fixture["menu_element"]
token = menu_element_fixture["token"]
url = reverse("api:builder:element:item", kwargs={"element_id": menu_element.id})
response = api_client.patch(
url,
{
"menu_items": [
{
"name": "Foo Bar",
"variant": "link",
"value": "",
"type": "link",
"uid": uuid.uuid4(),
"children": [],
"navigation_type": "page",
"navigate_to_page_id": None,
"navigate_to_url": "",
"page_parameters": [],
"query_parameters": [],
"target": "self",
}
]
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
data = response.json()
assert data["id"] == menu_element.id
assert data["menu_items"] == [
{
"id": menu_element.menu_items.all()[0].id,
"menu_item_order": AnyInt(),
"name": "Foo Bar",
"variant": "link",
"type": "link",
"uid": AnyStr(),
"navigate_to_page_id": None,
"navigate_to_url": "",
"navigation_type": "page",
"page_parameters": [],
"parent_menu_item": None,
"query_parameters": [],
"target": "self",
"children": [],
},
]

View file

@ -0,0 +1,439 @@
import json
import uuid
from collections import defaultdict
from copy import deepcopy
import pytest
from baserow.contrib.builder.api.elements.serializers import MenuItemSerializer
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.models import MenuElement, MenuItemElement
from baserow.contrib.builder.workflow_actions.models import NotificationWorkflowAction
from baserow.core.utils import MirrorDict
from baserow.test_utils.helpers import AnyInt
@pytest.fixture
def menu_element_fixture(data_fixture):
"""Fixture to help test the Menu element."""
user = data_fixture.create_user()
builder = data_fixture.create_builder_application(user=user)
page_a = data_fixture.create_builder_page(builder=builder, path="/page_a/:foo/")
page_b = data_fixture.create_builder_page(builder=builder, path="/page_b/")
menu_element = data_fixture.create_builder_menu_element(user=user, page=page_a)
return {
"page_a": page_a,
"page_b": page_b,
"menu_element": menu_element,
}
@pytest.mark.django_db
def test_create_menu_element(menu_element_fixture):
menu_element = menu_element_fixture["menu_element"]
assert menu_element.menu_items.count() == 0
assert menu_element.orientation == MenuElement.ORIENTATIONS.HORIZONTAL
@pytest.mark.django_db
@pytest.mark.parametrize(
"orientation",
[
MenuElement.ORIENTATIONS.HORIZONTAL,
MenuElement.ORIENTATIONS.VERTICAL,
],
)
def test_update_menu_element(menu_element_fixture, orientation):
menu_element = menu_element_fixture["menu_element"]
data = {
"orientation": orientation,
"menu_items": [],
}
updated_menu_element = ElementHandler().update_element(menu_element, **data)
assert updated_menu_element.menu_items.count() == 0
assert updated_menu_element.orientation == orientation
@pytest.mark.django_db
@pytest.mark.parametrize(
"name,item_type,variant",
[
(
"Page 1",
MenuItemElement.TYPES.LINK,
MenuItemElement.VARIANTS.LINK,
),
(
"Page 2",
MenuItemElement.TYPES.LINK,
MenuItemElement.VARIANTS.BUTTON,
),
(
"Click me",
MenuItemElement.TYPES.BUTTON,
"",
),
(
"",
MenuItemElement.TYPES.SEPARATOR,
"",
),
(
"",
MenuItemElement.TYPES.SPACER,
"",
),
],
)
def test_add_menu_item(menu_element_fixture, name, item_type, variant):
menu_element = menu_element_fixture["menu_element"]
assert menu_element.menu_items.count() == 0
uid = uuid.uuid4()
data = {
"menu_items": [
{
"variant": variant,
"type": item_type,
"uid": uid,
"name": name,
"children": [],
}
]
}
updated_menu_element = ElementHandler().update_element(menu_element, **data)
assert updated_menu_element.menu_items.count() == 1
menu_item = updated_menu_element.menu_items.first()
assert menu_item.variant == variant
assert menu_item.type == item_type
assert menu_item.name == name
assert menu_item.menu_item_order == AnyInt()
assert menu_item.uid == uid
assert menu_item.parent_menu_item is None
@pytest.mark.django_db
def test_add_sub_link(menu_element_fixture):
menu_element = menu_element_fixture["menu_element"]
assert menu_element.menu_items.count() == 0
parent_uid = uuid.uuid4()
child_uid = uuid.uuid4()
data = {
"menu_items": [
{
"name": "Click for more links",
"type": MenuItemElement.TYPES.LINK,
"variant": MenuItemElement.VARIANTS.LINK,
"menu_item_order": 0,
"uid": parent_uid,
"navigation_type": "page",
"navigate_to_page_id": None,
"navigate_to_url": "",
"page_parameters": [],
"query_parameters": [],
"parent_menu_item": None,
"target": "self",
"children": [
{
"name": "Sublink",
"type": MenuItemElement.TYPES.LINK,
"variant": MenuItemElement.VARIANTS.LINK,
"uid": child_uid,
}
],
}
]
}
updated_menu_element = ElementHandler().update_element(menu_element, **data)
# Both parent and child are MenuItemElement instances
assert updated_menu_element.menu_items.count() == 2
parent_item = updated_menu_element.menu_items.get(uid=parent_uid)
assert parent_item.parent_menu_item is None
assert parent_item.uid == parent_uid
child_item = updated_menu_element.menu_items.get(uid=child_uid)
assert child_item.parent_menu_item == parent_item
assert child_item.uid == child_uid
assert child_item.type == MenuItemElement.TYPES.LINK
assert child_item.variant == MenuItemElement.VARIANTS.LINK
assert child_item.name == "Sublink"
assert child_item.menu_item_order == AnyInt()
@pytest.mark.django_db
@pytest.mark.parametrize(
"field,value",
[
("name", "New Page"),
("navigation_type", "link"),
# None is replaced with a valid page in the test
("navigate_to_page_id", None),
("navigate_to_url", "https://www.baserow.io"),
("page_parameters", [{"name": "foo", "value": "'bar'"}]),
("query_parameters", [{"name": "param", "value": "'baz'"}]),
("target", "_blank"),
],
)
def test_update_menu_item(menu_element_fixture, field, value):
menu_element = menu_element_fixture["menu_element"]
assert menu_element.menu_items.count() == 0
uid = uuid.uuid4()
if field == "navigate_to_page_id":
value = menu_element_fixture["page_b"].id
menu_item = {
"name": "Page",
"type": MenuItemElement.TYPES.LINK.value,
"variant": MenuItemElement.VARIANTS.LINK.value,
"menu_item_order": 0,
"uid": str(uid),
"navigation_type": "page",
"navigate_to_page_id": None,
"navigate_to_url": "",
"parent_menu_item": None,
"page_parameters": [],
"query_parameters": [],
"target": "self",
"children": [],
}
expected = deepcopy(menu_item)
expected[field] = value
expected["id"] = AnyInt()
expected["menu_item_order"] = AnyInt()
# Create the initial Menu item
data = {"menu_items": [menu_item]}
ElementHandler().update_element(menu_element, **data)
# Update a specific field
menu_item[field] = value
updated_menu_element = ElementHandler().update_element(menu_element, **data)
item = updated_menu_element.menu_items.first()
updated_menu_item = MenuItemSerializer(item).data
# Ensure that only that specific field was updated
assert updated_menu_item == expected
@pytest.mark.django_db
def test_workflow_action_removed_when_menu_item_deleted(
menu_element_fixture, data_fixture
):
menu_element = menu_element_fixture["menu_element"]
uid = uuid.uuid4()
menu_item = {
"name": "Greet",
"type": MenuItemElement.TYPES.BUTTON,
"menu_item_order": 0,
"uid": uid,
"children": [],
}
data = {"menu_items": [menu_item]}
ElementHandler().update_element(menu_element, **data)
data_fixture.create_workflow_action(
NotificationWorkflowAction,
page=menu_element_fixture["page_a"],
element=menu_element,
event=f"{uid}_click",
)
assert NotificationWorkflowAction.objects.count() == 1
# Delete the field
data = {"menu_items": []}
updated_menu_element = ElementHandler().update_element(menu_element, **data)
assert updated_menu_element.menu_items.exists() is False
assert NotificationWorkflowAction.objects.count() == 0
@pytest.mark.django_db
def test_specific_workflow_action_removed_when_menu_item_deleted(
menu_element_fixture, data_fixture
):
menu_element = menu_element_fixture["menu_element"]
uid_1 = uuid.uuid4()
uid_2 = uuid.uuid4()
menu_item_1 = {
"name": "Greet 1",
"type": MenuItemElement.TYPES.BUTTON,
"menu_item_order": 0,
"uid": uid_1,
"children": [],
}
menu_item_2 = {
"name": "Greet 2",
"type": MenuItemElement.TYPES.BUTTON,
"menu_item_order": 0,
"uid": uid_2,
"children": [],
}
data = {"menu_items": [menu_item_1, menu_item_2]}
updated_menu_element = ElementHandler().update_element(menu_element, **data)
assert updated_menu_element.menu_items.count() == 2
for uid in [uid_1, uid_2]:
data_fixture.create_workflow_action(
NotificationWorkflowAction,
page=menu_element_fixture["page_a"],
element=menu_element,
event=f"{uid}_click",
)
assert NotificationWorkflowAction.objects.count() == 2
# Delete the first menu item
data = {"menu_items": [menu_item_2]}
updated_menu_element = ElementHandler().update_element(menu_element, **data)
assert updated_menu_element.menu_items.count() == 1
# Ensure only the Notification for the first menu item exists
assert NotificationWorkflowAction.objects.filter(element=menu_element).count() == 1
assert (
NotificationWorkflowAction.objects.filter(element=menu_element).first().event
== f"{uid_2}_click"
)
@pytest.mark.django_db
def test_all_workflow_actions_removed_when_menu_element_deleted(
menu_element_fixture, data_fixture
):
menu_element = menu_element_fixture["menu_element"]
uid_1 = uuid.uuid4()
uid_2 = uuid.uuid4()
menu_item_1 = {
"name": "Greet 1",
"type": MenuItemElement.TYPES.BUTTON,
"menu_item_order": 0,
"uid": uid_1,
"children": [],
}
menu_item_2 = {
"name": "Greet 2",
"type": MenuItemElement.TYPES.BUTTON,
"menu_item_order": 0,
"uid": uid_2,
"children": [],
}
data = {"menu_items": [menu_item_1, menu_item_2]}
updated_menu_element = ElementHandler().update_element(menu_element, **data)
for uid in [uid_1, uid_2]:
data_fixture.create_workflow_action(
NotificationWorkflowAction,
page=menu_element_fixture["page_a"],
element=menu_element,
event=f"{uid}_click",
)
assert updated_menu_element.menu_items.count() == 2
assert NotificationWorkflowAction.objects.count() == 2
# Delete the Menu element, which will cascade delete all menu items
ElementHandler().delete_element(menu_element)
# There should be no Menu Element, Menu items, or Notifications remaining
assert MenuElement.objects.count() == 0
assert MenuItemElement.objects.count() == 0
assert NotificationWorkflowAction.objects.count() == 0
@pytest.mark.django_db
def test_import_export(menu_element_fixture, data_fixture):
page = menu_element_fixture["page_a"]
menu_element = menu_element_fixture["menu_element"]
# Create a Menu Element with Menu items.
uid_1 = uuid.uuid4()
uid_2 = uuid.uuid4()
uid_3 = uuid.uuid4()
uid_4 = uuid.uuid4()
menu_item_1 = {
"name": "Greet",
"type": MenuItemElement.TYPES.BUTTON,
"menu_item_order": 0,
"uid": uid_1,
"children": [],
}
menu_item_2 = {
"name": "Link A",
"type": MenuItemElement.TYPES.LINK,
"menu_item_order": 1,
"uid": uid_2,
"children": [],
}
menu_item_3 = {
"name": "Sublinks",
"type": MenuItemElement.TYPES.LINK,
"menu_item_order": 2,
"uid": uid_3,
"children": [
{
"name": "Sublink A",
"type": MenuItemElement.TYPES.LINK,
"menu_item_order": 3,
"uid": uid_4,
"navigate_to_page_id": page.id,
}
],
}
data = {"menu_items": [menu_item_1, menu_item_2, menu_item_3]}
ElementHandler().update_element(menu_element, **data)
menu_element_type = menu_element.get_type()
# Export the Menu element and ensure there are no Menu elements
# after deleting it.
exported = menu_element_type.export_serialized(menu_element)
assert json.dumps(exported)
ElementHandler().delete_element(menu_element)
assert MenuElement.objects.count() == 0
assert MenuItemElement.objects.count() == 0
assert NotificationWorkflowAction.objects.count() == 0
# After importing the Menu element the menu items should be correctly
# imported as well.
id_mapping = defaultdict(lambda: MirrorDict())
menu_element_type.import_serialized(page, exported, id_mapping)
menu_element = MenuElement.objects.first()
# Ensure the Menu Items have been imported correctly
button_item = menu_element.menu_items.get(uid=uid_1)
assert button_item.name == "Greet"
link_item = menu_element.menu_items.get(uid=uid_2)
assert link_item.name == "Link A"
sublinks_item = menu_element.menu_items.get(uid=uid_3)
assert sublinks_item.name == "Sublinks"
sublink_a = menu_element.menu_items.get(uid=uid_4)
assert sublink_a.name == "Sublink A"

View file

@ -38,6 +38,7 @@
:mode="mode"
class="element--read-only"
:application-context-additions="applicationContextAdditions"
:show-element-id="showElementId"
@move="$emit('move', $event)"
/>
@ -100,6 +101,11 @@ export default {
required: false,
default: null,
},
showElementId: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {

View file

@ -39,7 +39,7 @@
</template>
<script>
import { TABLE_ORIENTATION } from '@baserow/modules/builder/enums'
import { ORIENTATIONS } from '@baserow/modules/builder/enums'
import BaserowTable from '@baserow/modules/builder/components/elements/components/BaserowTable'
export default {
@ -57,7 +57,7 @@ export default {
orientation: {
type: String,
required: false,
default: TABLE_ORIENTATION.HORIZONTAL,
default: ORIENTATIONS.HORIZONTAL,
},
contentLoading: {
type: Boolean,

View file

@ -1,7 +1,7 @@
<template>
<div class="baserow-table-wrapper">
<table class="baserow-table" :class="`baserow-table--${orientation}`">
<template v-if="orientation === TABLE_ORIENTATION.HORIZONTAL">
<template v-if="orientation === ORIENTATIONS.HORIZONTAL">
<thead>
<tr class="baserow-table__row">
<template v-for="field in fields">
@ -84,7 +84,7 @@
</template>
<script>
import { TABLE_ORIENTATION } from '@baserow/modules/builder/enums'
import { ORIENTATIONS } from '@baserow/modules/builder/enums'
export default {
name: 'BaserowTable',
@ -100,12 +100,12 @@ export default {
},
orientation: {
type: String,
default: TABLE_ORIENTATION.HORIZONTAL,
default: ORIENTATIONS.HORIZONTAL,
},
},
computed: {
TABLE_ORIENTATION() {
return TABLE_ORIENTATION
ORIENTATIONS() {
return ORIENTATIONS
},
},
}

View file

@ -0,0 +1,196 @@
<template>
<div
:style="getStyleOverride(element.variant)"
:class="[
'menu-element__container',
element.orientation === 'horizontal' ? 'horizontal' : 'vertical',
]"
>
<div v-for="item in element.menu_items" :key="item.id">
<template v-if="item.type === 'separator'">
<div class="menu-element__menu-item-separator"></div>
</template>
<template v-else-if="item.type === 'link' && !item.parent_menu_item">
<div v-if="!item.children?.length">
<ABLink
:variant="item.variant"
:url="getItemUrl(item)"
:target="getMenuItem(item).target"
>
{{
item.name
? item.name ||
(mode === 'editing'
? $t('menuElement.emptyLinkValue')
: '&nbsp;')
: $t('menuElement.missingLinkValue')
}}
</ABLink>
</div>
<div
v-else
ref="menuSubLinkContainer"
@click="showSubMenu($event, item.id)"
>
<div class="menu-element__sub-link-menu--container">
<a>{{ item.name }}</a>
<div class="menu-element__sub-link-menu--spacer"></div>
<div>
<i
class="menu-element__sub-link--expanded-icon"
:class="
isExpanded(item.id)
? 'iconoir-nav-arrow-up'
: 'iconoir-nav-arrow-down'
"
></i>
</div>
</div>
<Context
:ref="`subLinkContext_${item.id}`"
:hide-on-click-outside="true"
@shown="toggleExpanded(item.id)"
@hidden="toggleExpanded(item.id)"
>
<ThemeProvider>
<div
v-for="child in item.children"
:key="child.id"
class="menu-element__sub-links"
:style="getStyleOverride(child.variant)"
>
<ABLink
:variant="child.variant"
:url="getItemUrl(child)"
:target="getMenuItem(child).target"
class="menu-element__sub-link"
>
{{
child.name
? child.name ||
(mode === 'editing'
? $t('menuElement.emptyLinkValue')
: '&nbsp;')
: $t('menuElement.missingLinkValue')
}}
</ABLink>
</div>
</ThemeProvider>
</Context>
</div>
</template>
<template v-else-if="item.type === 'button'">
<ABButton @click="onButtonClick(item)">
{{
item.name
? item.name ||
(mode === 'editing'
? $t('menuElement.emptyButtonValue')
: '&nbsp;')
: $t('menuElement.missingButtonValue')
}}
</ABButton>
</template>
</div>
<div v-if="!element.menu_items.length" class="element--no-value">
{{ $t('menuElement.missingValue') }}
</div>
</div>
</template>
<script>
import element from '@baserow/modules/builder/mixins/element'
import resolveElementUrl from '@baserow/modules/builder/utils/urlResolution'
import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider'
/**
* @typedef MenuElement
* @property {Array} menu_items Array of Menu items
*/
export default {
name: 'MenuElement',
components: { ThemeProvider },
mixins: [element],
props: {
element: {
type: Object,
required: true,
},
},
data() {
return {
expandedItems: {},
}
},
computed: {
pages() {
return this.$store.getters['page/getVisiblePages'](this.builder)
},
menuElementType() {
return this.$registry.get('element', 'menu')
},
},
methods: {
showSubMenu(event, itemId) {
const contextRef = this.$refs[`subLinkContext_${itemId}`][0]
if (contextRef?.isOpen()) {
contextRef.hide()
} else {
const containerRef = event.currentTarget
contextRef.show(containerRef, 'bottom', 'left', 0)
}
},
getItemUrl(item) {
try {
return resolveElementUrl(
this.getMenuItem(item),
this.builder,
this.pages,
this.resolveFormula,
this.mode
)
} catch (e) {
return '#error'
}
},
toggleExpanded(itemId) {
this.$set(this.expandedItems, itemId, !this.expandedItems[itemId])
},
/**
* Transforms a Menu Item into a valid object that can be passed as a prop
* to the ABLink component.
*/
getMenuItem(item) {
return {
id: this.element.id,
menu_item_id: item?.id,
uid: item?.uid,
target: item.target || 'self',
variant: item?.variant || 'link',
value: item.name,
navigation_type: item.navigation_type,
navigate_to_page_id: item.navigate_to_page_id || null,
page_parameters: item.page_parameters || {},
query_parameters: item.query_parameters || {},
navigate_to_url: item.navigate_to_url || '#',
page_id: this.element.page_id,
type: 'menu_item',
}
},
isExpanded(itemId) {
return !!this.expandedItems[itemId]
},
onButtonClick(item) {
const eventName = `${item.uid}_click`
this.fireEvent(
this.menuElementType.getEventByName(this.element, eventName)
)
},
},
}
</script>

View file

@ -135,6 +135,7 @@ import PageElement from '@baserow/modules/builder/components/page/PageElement'
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'
import { ORIENTATIONS } from '@baserow/modules/builder/enums'
export default {
name: 'RepeatElement',
@ -191,7 +192,7 @@ export default {
// `grid-template-columns` rule's `repeat`, it will cause a repaint
// following page load when the orientation is horizontal. Initially the
// page visitor will see repetitions vertically, then suddenly horizontally.
if (this.element.orientation === 'vertical') {
if (this.element.orientation === ORIENTATIONS.VERTICAL) {
return {
display: 'flex',
'flex-direction': 'column',

View file

@ -0,0 +1,178 @@
<template>
<form @submit.prevent @keydown.enter.prevent>
<FormGroup
:label="$t('orientations.label')"
small-label
required
class="margin-bottom-2"
>
<RadioGroup
v-model="values.orientation"
:options="orientationOptions"
type="button"
>
</RadioGroup>
</FormGroup>
<div
ref="menuItemAddContainer"
class="menu-element__form--add-item-container"
>
<div>
{{ $t('menuElementForm.menuItemsLabel') }}
</div>
<div>
<ButtonText
type="primary"
icon="iconoir-plus"
size="small"
@click="
$refs.menuItemAddContext.show(
$refs.menuItemAddContainer,
'bottom',
'right'
)
"
>
{{ $t('menuElementForm.addMenuItemLink') }}
</ButtonText>
</div>
</div>
<Context ref="menuItemAddContext" :hide-on-click-outside="true">
<div class="menu-element__form--add-item-context">
<ButtonText
v-for="(menuItemType, index) in addMenuItemTypes"
:key="index"
type="primary"
:icon="menuItemType.icon"
size="small"
@click="addMenuItem(menuItemType.type)"
>
{{ menuItemType.label }}
</ButtonText>
</div>
</Context>
<div v-for="item in values.menu_items" :key="item.uid">
<MenuElementItemForm
:default-values="item"
@remove-item="removeMenuItem($event)"
@values-changed="updateMenuItem"
></MenuElementItemForm>
</div>
</form>
</template>
<script>
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import { ORIENTATIONS } from '@baserow/modules/builder/enums'
import {
getNextAvailableNameInSequence,
uuid,
} from '@baserow/modules/core/utils/string'
import { mapGetters } from 'vuex'
import MenuElementItemForm from '@baserow/modules/builder/components/elements/components/forms/general/MenuElementItemForm'
export default {
name: 'MenuElementForm',
components: {
MenuElementItemForm,
},
mixins: [elementForm],
data() {
return {
values: {
value: '',
styles: {},
orientation: ORIENTATIONS.VERTICAL,
menu_items: [],
},
allowedValues: ['value', 'styles', 'menu_items', 'orientation'],
addMenuItemTypes: [
{
icon: 'iconoir-link',
label: this.$t('menuElementForm.menuItemAddLink'),
type: 'link',
},
{
icon: 'iconoir-cursor-pointer',
label: this.$t('menuElementForm.menuItemAddButton'),
type: 'button',
},
{
icon: 'baserow-icon-separator',
label: this.$t('menuElementForm.menuItemAddSeparator'),
type: 'separator',
},
],
}
},
computed: {
...mapGetters({
getElementSelected: 'element/getSelected',
}),
ORIENTATIONS() {
return ORIENTATIONS
},
element() {
return this.getElementSelected(this.builder)
},
orientationOptions() {
return [
{
label: this.$t('orientations.vertical'),
value: ORIENTATIONS.VERTICAL,
icon: 'iconoir-table-rows',
},
{
label: this.$t('orientations.horizontal'),
value: ORIENTATIONS.HORIZONTAL,
icon: 'iconoir-view-columns-3',
},
]
},
},
methods: {
addMenuItem(type) {
const name = getNextAvailableNameInSequence(
this.$t('menuElementForm.menuItemDefaultName'),
this.values.menu_items
.filter((item) => item.parent_menu_item === null)
.map(({ name }) => name)
)
this.values.menu_items = [
...this.values.menu_items,
{
name,
variant: 'link',
value: '',
type,
uid: uuid(),
children: [],
},
]
this.$refs.menuItemAddContext.hide()
},
/**
* When a menu item is removed, this method is responsible for removing it
* from the `MenuElement` itself.
*/
removeMenuItem(uidToRemove) {
this.values.menu_items = this.values.menu_items.filter(
(item) => item.uid !== uidToRemove
)
},
/**
* When a menu item is updated, this method is responsible for updating the
* `MenuElement` with the new values.
*/
updateMenuItem(newValues) {
this.values.menu_items = this.values.menu_items.map((item) => {
if (item.uid === newValues.uid) {
return { ...item, ...newValues }
}
return item
})
},
},
}
</script>

View file

@ -0,0 +1,240 @@
<template>
<Expandable>
<template #header="{ toggle, expanded }">
<div
:class="
isStyle
? 'menu-element__form--expandable-item-header-outline'
: 'menu-element__form--expandable-item-header'
"
@click.stop="!isStyle ? toggle() : null"
>
<div
class="menu-element__form--expandable-item-handle"
data-sortable-handle
/>
<div class="menu-element__form--expandable-item-name">
<template v-if="values.type === 'separator'">
{{ $t('menuElement.separator') }}
</template>
<template v-else-if="values.type === 'spacer'">
{{ $t('menuElement.spacer') }}
</template>
<template v-else>
{{ values.name }}
</template>
</div>
<template v-if="isStyle">
<ButtonIcon
size="small"
icon="iconoir-bin"
@click="removeMenuItem()"
/>
</template>
<template v-else>
<i
:class="
expanded ? 'iconoir-nav-arrow-down' : 'iconoir-nav-arrow-right'
"
/>
</template>
</div>
</template>
<template v-if="!isStyle" #default>
<div class="menu-element__form--expanded-item">
<FormGroup
small-label
horizontal
required
class="margin-bottom-2"
:label="$t('menuElementForm.menuItemLabelLabel')"
:error="fieldHasErrors('name')"
>
<FormInput
v-model="v$.values.name.$model"
:placeholder="$t('menuElementForm.namePlaceholder')"
:error="fieldHasErrors('name')"
/>
<template #error>
{{ v$.values.name.$errors[0]?.$message }}
</template>
<template #after-input>
<ButtonIcon icon="iconoir-bin" @click="removeMenuItem()" />
</template>
</FormGroup>
<template v-if="values.type === 'button'">
<Alert type="info-neutral">
<p>{{ $t('menuElementForm.eventDescription') }}</p>
</Alert>
</template>
<template v-else>
<FormGroup
small-label
horizontal
required
:label="$t('menuElementForm.menuItemVariantLabel')"
class="margin-bottom-2"
>
<Dropdown
:value="values.variant"
:show-search="false"
@input="values.variant = $event"
>
<DropdownItem
v-for="itemVariant in menuItemVariants"
:key="itemVariant.value"
:name="itemVariant.label"
:value="itemVariant.value"
/>
</Dropdown>
</FormGroup>
<LinkNavigationSelectionForm
v-if="!values.children.length"
:default-values="defaultValues"
@values-changed="values = { ...values, ...$event }"
/>
<div v-for="child in values.children" :key="child.uid">
<MenuElementItemForm
prevent-item-nesting
:default-values="child"
@remove-item="removeChildItem($event)"
@values-changed="updateChildItem"
></MenuElementItemForm>
</div>
<div
v-if="!preventItemNesting"
class="menu-element__add-sub-link-container"
>
<ButtonText
type="primary"
icon="iconoir-plus"
size="small"
@click="addSubLink()"
>
{{ $t('menuElementForm.addSubLink') }}
</ButtonText>
</div>
</template>
</div>
</template>
</Expandable>
</template>
<script>
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import LinkNavigationSelectionForm from '@baserow/modules/builder/components/elements/components/forms/general/LinkNavigationSelectionForm'
import { useVuelidate } from '@vuelidate/core'
import { helpers, required } from '@vuelidate/validators'
import {
getNextAvailableNameInSequence,
uuid,
} from '@baserow/modules/core/utils/string'
import { LINK_VARIANTS } from '@baserow/modules/builder/enums'
export default {
name: 'MenuElementItemForm',
components: {
LinkNavigationSelectionForm,
},
mixins: [elementForm],
props: {
/**
* Controls whether ror not this menu item can nest other menu items.
* By default, this is allowed, but if we are already in a nested menu,
* item we should prevent further nesting.
*/
preventItemNesting: {
type: Boolean,
default: false,
},
},
setup() {
return { v$: useVuelidate({ $lazy: true }) }
},
data() {
return {
values: {
uid: '',
name: '',
type: '',
variant: '',
children: [],
},
allowedValues: ['uid', 'name', 'type', 'variant', 'children'],
}
},
computed: {
isStyle() {
return ['separator', 'spacer'].includes(this.values.type)
},
menuItemVariants() {
return [
{
label: this.$t('menuElementForm.menuItemVariantLink'),
value: LINK_VARIANTS.LINK,
},
{
label: this.$t('menuElementForm.menuItemVariantButton'),
value: LINK_VARIANTS.BUTTON,
},
]
},
},
methods: {
/**
* Responsible for removing this menu item from the `MenuElement` itself.
*/
removeMenuItem() {
this.$emit('remove-item', this.values.uid)
},
/**
* Responsible for removing a nested menu item from a parent menu item.
*/
removeChildItem(uidToRemove) {
this.values.children = this.values.children.filter(
(child) => child.uid !== uidToRemove
)
},
/**
* When a nested meny item is updated, this method is responsible for updating the
* parent menu item with the new values.
*/
updateChildItem(newValues) {
this.values.children = this.values.children.map((item) => {
if (item.uid === newValues.uid) {
return { ...item, ...newValues }
}
return item
})
},
/**
* If this menu item is a parent menu item, this method is responsible for
* adding a child menu item to it.
*/
addSubLink() {
const name = getNextAvailableNameInSequence(
this.$t('menuElementForm.menuItemSubLinkDefaultName'),
this.values.children.map(({ name }) => name)
)
this.values.children.push({
name,
variant: LINK_VARIANTS.LINK,
type: 'link',
uid: uuid(),
})
},
},
validations() {
return {
values: {
name: {
required: helpers.withMessage(
this.$t('error.requiredField'),
required
),
},
},
}
},
}
</script>

View file

@ -75,7 +75,7 @@
/>
</FormGroup>
<FormGroup
:label="$t('repeatElementForm.orientationLabel')"
:label="$t('orientations.label')"
small-label
required
class="margin-bottom-2"
@ -178,6 +178,7 @@ import ServiceSchemaPropertySelector from '@baserow/modules/core/components/serv
import DataSourceDropdown from '@baserow/modules/builder/components/dataSource/DataSourceDropdown.vue'
import PropertyOptionForm from '@baserow/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm'
import PaddingSelector from '@baserow/modules/builder/components/PaddingSelector'
import { ORIENTATIONS } from '@baserow/modules/builder/enums'
const MAX_GAP_PX = 2000
@ -262,13 +263,13 @@ export default {
orientationOptions() {
return [
{
label: this.$t('repeatElementForm.orientationVertical'),
value: 'vertical',
label: this.$t('orientations.vertical'),
value: ORIENTATIONS.VERTICAL,
icon: 'iconoir-table-rows',
},
{
label: this.$t('repeatElementForm.orientationHorizontal'),
value: 'horizontal',
label: this.$t('orientations.horizontal'),
value: ORIENTATIONS.HORIZONTAL,
icon: 'iconoir-view-columns-3',
},
]

View file

@ -201,7 +201,7 @@
<p v-else>{{ $t('tableElementForm.selectSourceFirst') }}</p>
</FormSection>
<FormGroup
:label="$t('tableElementForm.orientation')"
:label="$t('orientations.label')"
small-label
required
class="margin-bottom-2"
@ -215,21 +215,22 @@
<RadioButton
v-model="values.orientation[deviceType.getType()]"
icon="iconoir-view-columns-3"
:value="TABLE_ORIENTATION.HORIZONTAL"
:value="ORIENTATIONS.HORIZONTAL"
>
{{ $t('tableElementForm.orientationHorizontal') }}
{{ $t('orientations.horizontal') }}
</RadioButton>
<RadioButton
v-model="values.orientation[deviceType.getType()]"
icon="iconoir-table-rows"
:value="TABLE_ORIENTATION.VERTICAL"
:value="ORIENTATIONS.VERTICAL"
>
{{ $t('tableElementForm.orientationVertical') }}
{{ $t('orientations.vertical') }}
</RadioButton>
</template>
</DeviceSelector>
</FormGroup>
<CustomStyle
v-if="propertyOptionsAvailable"
v-model="values.styles"
style-key="header_button"
:config-block-types="['button']"
@ -271,7 +272,7 @@ import {
helpers,
} from '@vuelidate/validators'
import collectionElementForm from '@baserow/modules/builder/mixins/collectionElementForm'
import { TABLE_ORIENTATION } from '@baserow/modules/builder/enums'
import { ORIENTATIONS } from '@baserow/modules/builder/enums'
import DeviceSelector from '@baserow/modules/builder/components/page/header/DeviceSelector.vue'
import { mapActions, mapGetters } from 'vuex'
import CustomStyle from '@baserow/modules/builder/components/elements/components/forms/style/CustomStyle'
@ -317,8 +318,8 @@ export default {
},
computed: {
...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected' }),
TABLE_ORIENTATION() {
return TABLE_ORIENTATION
ORIENTATIONS() {
return ORIENTATIONS
},
orderedCollectionTypes() {
return this.$registry.getOrderedList('collectionField')

View file

@ -6,6 +6,9 @@
:style="elementStyles"
>
<div class="element__inner-wrapper">
<span v-if="showElementId" class="element--element-id">{{
element.id
}}</span>
<component
:is="component"
:key="element._.uid"
@ -58,6 +61,11 @@ export default {
required: false,
default: null,
},
showElementId: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
BACKGROUND_TYPES: () => BACKGROUND_TYPES,

View file

@ -28,6 +28,7 @@
:is-first-element="index === 0"
:is-copying="copyingElementIndex === index"
:application-context-additions="contextAdditions"
:show-element-id="showElementId"
@move="moveElement($event)"
/>
</header>
@ -64,6 +65,7 @@
:is-first-element="index === 0 && headerElements.length === 0"
:is-copying="copyingElementIndex === index"
:application-context-additions="contextAdditions"
:show-element-id="showElementId"
@move="moveElement($event)"
/>
</div>
@ -92,6 +94,7 @@
"
:is-copying="copyingElementIndex === index"
:application-context-additions="contextAdditions"
:show-element-id="showElementId"
@move="moveElement($event)"
/>
</footer>
@ -129,6 +132,8 @@ export default {
// The resize observer to resize the preview when the wrapper size change
resizeObserver: null,
showElementId: false,
}
},
computed: {
@ -505,6 +510,11 @@ export default {
case 'p':
this.selectParentElement()
break
case 'E':
if (alternateAction && e.shiftKey) {
this.showElementId = !this.showElementId
}
break
default:
shouldPrevent = false
}

View file

@ -51,6 +51,8 @@ import MultiPageContainerElementForm from '@baserow/modules/builder/components/e
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 MenuElement from '@baserow/modules/builder/components/elements/components/MenuElement'
import MenuElementForm from '@baserow/modules/builder/components/elements/components/forms/general/MenuElementForm'
import { pathParametersInError } from '@baserow/modules/builder/utils/params'
import {
ContainerElementTypeMixin,
@ -1957,3 +1959,108 @@ export class FooterElementType extends HeaderElementType {
return null
}
}
export class MenuElementType extends ElementType {
static getType() {
return 'menu'
}
get name() {
return this.app.i18n.t('elementType.menu')
}
get description() {
return this.app.i18n.t('elementType.menuDescription')
}
get iconClass() {
return 'iconoir-menu'
}
get component() {
return MenuElement
}
get generalFormComponent() {
return MenuElementForm
}
getEventByName(element, name) {
return this.getEvents(element).find((event) => event.name === name)
}
getEvents(element) {
return (element.menu_items || [])
.map((item) => {
const { type: menuItemType, name, uid } = item
if (menuItemType === 'button') {
return [
new ClickEvent({
...this.app,
namePrefix: uid,
labelSuffix: `- ${name}`,
applicationContextAdditions: { allowSameElement: true },
}),
]
}
return []
})
.flat()
}
isInError({ page, element, builder }) {
// There must be at least one menu item
if (!element.menu_items?.length) {
return true
}
const workflowActions = this.app.store.getters[
'workflowAction/getElementWorkflowActions'
](page, element.id)
const hasInvalidMenuItem = element.menu_items.some((menuItem) => {
if (menuItem.children?.length) {
return menuItem.children.some((child) => {
return this.menuItemIsInError(child, builder, workflowActions)
})
} else {
return this.menuItemIsInError(menuItem, builder, workflowActions)
}
})
return hasInvalidMenuItem || super.isInError({ page, element, builder })
}
menuItemIsInError(element, builder, workflowActions) {
if (['separator', 'spacer'].includes(element.type)) {
return false
} else if (element.type === 'button') {
// For button variants, there must be at least one workflow action
return !element.name || !workflowActions.length
} else if (element.type === 'link') {
if (!element.name) {
return true
}
if (!element.children?.length) {
if (element.navigation_type === 'page') {
if (!element.navigate_to_page_id) {
return true
}
return pathParametersInError(
element,
this.app.store.getters['page/getVisiblePages'](builder)
)
} else if (element.navigation_type === 'custom') {
return !element.navigate_to_url
}
}
}
return false
}
getDisplayName(element, applicationContext) {
return this.name
}
}

View file

@ -162,7 +162,7 @@ export const ELEMENT_EVENTS = {
DATA_SOURCE_AFTER_UPDATE: 'DATA_SOURCE_AFTER_UPDATE',
}
export const TABLE_ORIENTATION = {
export const ORIENTATIONS = {
HORIZONTAL: 'horizontal',
VERTICAL: 'vertical',
}

View file

@ -123,7 +123,9 @@
"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"
"notAllowedLocation": "This element is not allowed at this location",
"menu": "Menu",
"menuDescription": "Menu element"
},
"addElementButton": {
"label": "Element"
@ -188,6 +190,40 @@
"textFormatTypePlain": "Plain text",
"textFormatTypeMarkdown": "Markdown"
},
"orientations": {
"label": "Orientation",
"horizontal": "Horizontal",
"vertical": "Vertical"
},
"menuElement": {
"missingValue": "Missing menu item",
"separator": "Separator",
"spacer": "Spacer",
"missingLinkValue": "Missing link name...",
"emptyLinkValue": "Empty link name...",
"missingButtonValue": "Missing button name...",
"emptyButtonValue": "Empty button name..."
},
"menuElementForm": {
"menuItemsLabel": "Menu items",
"addMenuItemLink": "Add...",
"menuItemDefaultName": "Page",
"menuItemLabelLabel": "Label",
"menuItemTypeLabel": "Type",
"menuItemTypeItem": "Item",
"menuItemTypeSeparator": "Separator",
"menuItemVariantLabel": "Variant",
"menuItemVariantLink": "Link",
"menuItemVariantButton": "Button",
"namePlaceholder": "Page",
"addSubLink": "Add sublink",
"menuItemSubLinkDefaultName": "Sublink",
"menuItemAddLink": "Link",
"menuItemAddButton": "Button",
"menuItemAddSeparator": "Separator",
"menuItemAddSpacer": "Spacer",
"eventDescription": "To configure actions for this button, open the Events tab of this element."
},
"imageElement": {
"missingValue": "Missing alt text...",
"emptyValue": "Empty alt text..."
@ -645,9 +681,6 @@
"selectSourceFirst": "Choose a data source and/or property to begin configuring your fields.",
"buttonColor": "Button color",
"refreshFieldsFromDataSource": "refresh fields from data source",
"orientation": "Orientation",
"orientationHorizontal": "Horizontal",
"orientationVertical": "Vertical",
"buttonLoadMoreLabel": "Show more label",
"propertySelectorMissingArrays": "No multiple valued fields found to use as rows."
},
@ -685,9 +718,6 @@
"itemsPerPagePlaceholder": "Enter value...",
"itemsPerRowLabel": "Items per row",
"itemsPerRowDescription": "Number of columns per row and device type.",
"orientationLabel": "Orientation",
"orientationVertical": "Vertical",
"orientationHorizontal": "Horizontal",
"buttonLoadMoreLabel": "Show more label",
"toggleEditorRepetitionsLabel": "Temporarily disable repetitions",
"propertySelectorMissingArrays": "No multiple valued fields found to repeat with.",

View file

@ -25,7 +25,7 @@ import elementContentStore from '@baserow/modules/builder/store/elementContent'
import themeStore from '@baserow/modules/builder/store/theme'
import workflowActionStore from '@baserow/modules/builder/store/workflowAction'
import formDataStore from '@baserow/modules/builder/store/formData'
import { FF_MENU_ELEMENT } from '@baserow/modules/core/plugins/featureFlags'
import { registerRealtimeEvents } from '@baserow/modules/builder/realtime'
import {
HeadingElementType,
@ -45,6 +45,7 @@ import {
RecordSelectorElementType,
HeaderElementType,
FooterElementType,
MenuElementType,
} from '@baserow/modules/builder/elementTypes'
import {
DesktopDeviceType,
@ -229,6 +230,10 @@ export default (context) => {
app.$registry.register('element', new RecordSelectorElementType(context))
app.$registry.register('element', new RepeatElementType(context))
if (app.$featureFlagIsEnabled(FF_MENU_ELEMENT)) {
app.$registry.register('element', new MenuElementType(context))
}
app.$registry.register('device', new DesktopDeviceType(context))
app.$registry.register('device', new TabletDeviceType(context))
app.$registry.register('device', new SmartphoneDeviceType(context))

View file

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5625 3V5.11538C3.5625 5.69923 4.0665 6.17307 4.6875 6.17307H19.3125C19.9335 6.17307 20.4375 5.69923 20.4375 5.11538V3" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.5625 20.9999V18.8845C3.5625 18.3006 4.0665 17.8268 4.6875 17.8268H19.3125C19.9335 17.8268 20.4375 18.3006 20.4375 18.8845V20.9999" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.125 12.8655C4.74632 12.8655 5.25 12.392 5.25 11.8078C5.25 11.2237 4.74632 10.7501 4.125 10.7501C3.50368 10.7501 3 11.2237 3 11.8078C3 12.392 3.50368 12.8655 4.125 12.8655Z" fill="black"/>
<path d="M14.625 12.8655C15.2463 12.8655 15.75 12.392 15.75 11.8078C15.75 11.2237 15.2463 10.7501 14.625 10.7501C14.0037 10.7501 13.5 11.2237 13.5 11.8078C13.5 12.392 14.0037 12.8655 14.625 12.8655Z" fill="black"/>
<path d="M9.375 12.8655C9.99632 12.8655 10.5 12.392 10.5 11.8078C10.5 11.2237 9.99632 10.7501 9.375 10.7501C8.75368 10.7501 8.25 11.2237 8.25 11.8078C8.25 12.392 8.75368 12.8655 9.375 12.8655Z" fill="black"/>
<path d="M19.875 12.8655C20.4963 12.8655 21 12.392 21 11.8078C21 11.2237 20.4963 10.7501 19.875 10.7501C19.2537 10.7501 18.75 11.2237 18.75 11.8078C18.75 12.392 19.2537 12.8655 19.875 12.8655Z" fill="black"/>
</svg>

After

(image error) Size: 1.3 KiB

View file

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 4H4V7" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 11V13" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 4H13" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 20H13" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 11V13" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 4H20V7" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 20H4V17" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 20H20V17" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

(image error) Size: 929 B

View file

@ -83,3 +83,8 @@
user-select: none;
}
}
.element--element-id {
font-size: 12px;
color: rgb(87, 86, 86);
}

View file

@ -12,3 +12,4 @@
@import 'repeat_element';
@import 'tag_field';
@import 'image_field';
@import 'menu_element';

View file

@ -0,0 +1,150 @@
.menu-element__container {
display: flex;
}
/**
Disable pointer events when in Page Editor.
*/
.element--read-only .menu-element__container {
pointer-events: none;
user-select: none;
}
.menu-element__container.vertical {
flex-direction: column;
gap: 20px;
width: 100%;
}
.menu-element__container.horizontal {
flex-direction: row;
align-items: center;
gap: 20px;
}
.horizontal .menu-element__menu-item-separator {
width: 1px;
height: 20px;
background-color: $palette-neutral-500;
}
.vertical .menu-element__menu-item-separator {
width: 100%;
height: 1px;
background-color: $palette-neutral-500;
margin: 0 5px;
}
.menu-element__sub-link-menu--container {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
}
.vertical .menu-element__sub-link-menu--spacer {
flex: 1;
}
.horizontal .menu-element__sub-link-menu--expanded-icon {
margin-left: 5px;
}
.menu-element__sub-links {
display: flex;
flex-direction: column;
width: 100%;
gap: 10px;
padding: 10px;
}
.menu-element__sub-link {
width: 100%;
display: block;
padding: 10px;
text-decoration: none;
}
.menu-element__sub-link:hover {
background: $palette-neutral-100;
}
/**
Menu Element Form styles.
*/
.menu-element__form--add-item-container {
display: flex;
align-items: center;
justify-content: space-between;
}
.menu-element__form--add-item-context {
display: flex;
flex-direction: column;
width: 100%;
gap: 10px;
padding: 10px;
}
%menu-element-form-expandable-item {
display: flex;
align-items: center;
margin: 5px 0;
user-select: none;
cursor: pointer;
}
.menu-element__form--expandable-item-header {
@extend %menu-element-form-expandable-item;
background-color: $palette-neutral-100;
}
.menu-element__form--expandable-item-header-outline {
@extend %menu-element-form-expandable-item;
background-color: $color-neutral-10;
border: 1px dashed $color-neutral-200;
}
.menu-element__form--expandable-item-error {
font-size: 20px;
color: #ffbdb4;
pointer-events: none;
padding-right: 3px;
}
.menu-element__form--expandable-item-name {
flex: 1;
padding-right: 5px;
@extend %ellipsis;
}
.menu-element__form--expandable-item-handle {
width: 8px;
height: 36px;
background-image: radial-gradient($color-neutral-200 40%, transparent 40%);
background-size: 4px 4px;
background-repeat: repeat;
margin: 2px;
margin-right: 12px;
cursor: grab;
visibility: hidden;
.menu-element__form--expandable-item-header:hover & {
visibility: visible;
}
}
.menu-element__form--expanded-item {
margin: 5px 10px;
width: 100%;
}
.menu-element__add-sub-link-container {
display: flex;
justify-content: flex-end;
margin-left: 15px;
}

View file

@ -80,7 +80,7 @@ $baserow-icons: 'circle-empty', 'circle-checked', 'check-square', 'formula',
'calendar', 'smile', 'smartphone', 'plus', 'heading-1', 'heading-2',
'heading-3', 'paragraph', 'ordered-list', 'enlarge', 'share', 'settings',
'up-down-arrows', 'application', 'groups', 'timeline', 'dashboard', 'jira',
'postgresql', 'hubspot';
'postgresql', 'hubspot', 'separator', 'spacer';
$grid-view-row-height-small: 33px;
$grid-view-row-height-medium: 55px;

View file

@ -1,6 +1,7 @@
const FF_ENABLE_ALL = '*'
export const FF_DASHBOARDS = 'dashboards'
export const FF_AB_SSO = 'ab_sso'
export const FF_MENU_ELEMENT = 'menu_element'
/**
* A comma separated list of feature flags used to enable in-progress or not ready

View file

@ -898,6 +898,89 @@ describe('elementTypes tests', () => {
})
})
describe('MenuElementType isInError tests', () => {
test('Returns true if Menu Element has errors, false otherwise', () => {
const elementType = testApp.getRegistry().get('element', 'menu')
const page = {
id: 1,
shared: false,
name: 'Foo Page',
workflowActions: [],
}
const element = {
id: 50,
page_id: page.id,
menu_items: [],
}
const builder = {
id: 1,
pages: [page],
}
// Menu element with zero Menu items is invalid.
expect(elementType.isInError({ page: {}, element, builder })).toBe(true)
const menuItem = {
type: 'button',
name: 'foo button',
}
element.menu_items = [menuItem]
// Button Menu item without workflow actions is invalid.
expect(elementType.isInError({ page, element, builder })).toBe(true)
page.workflowActions = [{ element_id: 50, type: 'open_page' }]
element.menu_items[0].name = ''
// Button Menu item with empty name is invalid.
expect(elementType.isInError({ page, element, builder })).toBe(true)
element.menu_items[0].type = 'link'
element.menu_items[0].name = ''
// Link Menu item with empty name is invalid.
expect(elementType.isInError({ page, element, builder })).toBe(true)
element.menu_items[0].name = 'sub link'
element.menu_items[0].navigation_type = 'page'
element.menu_items[0].navigate_to_page_id = ''
// Link Menu item - sublink with Page navigation but no page ID is invalid.
expect(elementType.isInError({ page, element, builder })).toBe(true)
element.menu_items[0].name = 'sub link'
element.menu_items[0].navigation_type = 'custom'
element.menu_items[0].navigate_to_url = ''
// Link Menu item - sublink with custom navigation but no URL is invalid.
expect(elementType.isInError({ page, element, builder })).toBe(true)
// Valid Button Menu item
element.menu_items[0].type = 'button'
element.menu_items[0].name = 'foo button'
page.workflowActions = [{ element_id: 50, type: 'open_page' }]
expect(elementType.isInError({ page, element, builder })).toBe(false)
// Valid Link Menu item - page
element.menu_items[0].type = 'link'
element.menu_items[0].name = 'foo link'
element.menu_items[0].navigation_type = 'page'
element.menu_items[0].navigate_to_page_id = 10
expect(elementType.isInError({ page, element, builder })).toBe(false)
// Valid Link Menu item - custom
element.menu_items[0].type = 'link'
element.menu_items[0].name = 'foo link'
element.menu_items[0].navigation_type = 'custom'
element.menu_items[0].navigate_to_url = 'https://www.baserow.io'
expect(elementType.isInError({ page, element, builder })).toBe(false)
})
})
describe('elementType elementAround tests', () => {
let page, sharedPage, builder
beforeEach(async () => {