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:
parent
5412427ca8
commit
5d44cdd944
34 changed files with 2366 additions and 35 deletions
backend
src/baserow
tests/baserow/contrib/builder
web-frontend
modules
builder
components
elementTypes.jsenums.jslocales
plugin.jscore
assets
icons
scss
plugins
test/unit/builder
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",),
|
||||
),
|
||||
]
|
|
@ -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 = "*"
|
||||
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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": [],
|
||||
},
|
||||
]
|
|
@ -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"
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
: ' ')
|
||||
: $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')
|
||||
: ' ')
|
||||
: $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')
|
||||
: ' ')
|
||||
: $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>
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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))
|
||||
|
|
8
web-frontend/modules/core/assets/icons/separator.svg
Normal file
8
web-frontend/modules/core/assets/icons/separator.svg
Normal 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 |
10
web-frontend/modules/core/assets/icons/spacer.svg
Normal file
10
web-frontend/modules/core/assets/icons/spacer.svg
Normal 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 |
|
@ -83,3 +83,8 @@
|
|||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.element--element-id {
|
||||
font-size: 12px;
|
||||
color: rgb(87, 86, 86);
|
||||
}
|
||||
|
|
|
@ -12,3 +12,4 @@
|
|||
@import 'repeat_element';
|
||||
@import 'tag_field';
|
||||
@import 'image_field';
|
||||
@import 'menu_element';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
Loading…
Add table
Reference in a new issue