1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-15 01:28:30 +00:00

Merge branch '3477-menu-element-improvements-1' into 'develop'

Improvements to Menu element

Closes 

See merge request 
This commit is contained in:
Tsering Paljor 2025-03-06 09:38:07 +04:00
commit 8d58b2e3f1
27 changed files with 1017 additions and 205 deletions

View file

@ -1,7 +1,5 @@
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"
@ -210,9 +208,7 @@ class BuilderConfig(AppConfig):
element_type_registry.register(DateTimePickerElementType())
element_type_registry.register(HeaderElementType())
element_type_registry.register(FooterElementType())
if feature_flag_is_enabled(FF_MENU_ELEMENT):
element_type_registry.register(MenuElementType())
element_type_registry.register(MenuElementType())
from .domains.domain_types import CustomDomainType, SubDomainType
from .domains.registries import domain_type_registry
@ -259,6 +255,7 @@ class BuilderConfig(AppConfig):
ImageThemeConfigBlockType,
InputThemeConfigBlockType,
LinkThemeConfigBlockType,
MenuThemeConfigBlockType,
PageThemeConfigBlockType,
TableThemeConfigBlockType,
TypographyThemeConfigBlockType,
@ -272,6 +269,7 @@ class BuilderConfig(AppConfig):
theme_config_block_registry.register(PageThemeConfigBlockType())
theme_config_block_registry.register(InputThemeConfigBlockType())
theme_config_block_registry.register(TableThemeConfigBlockType())
theme_config_block_registry.register(MenuThemeConfigBlockType())
from .workflow_actions.registries import builder_workflow_action_type_registry
from .workflow_actions.workflow_action_types import (

View file

@ -51,6 +51,7 @@ from baserow.contrib.builder.elements.models import (
FormContainerElement,
HeaderElement,
HeadingElement,
HorizontalAlignments,
IFrameElement,
ImageElement,
InputTextElement,
@ -1981,14 +1982,15 @@ class MenuElementType(ElementType):
type = "menu"
model_class = MenuElement
serializer_field_names = ["orientation", "menu_items"]
allowed_fields = ["orientation"]
serializer_field_names = ["orientation", "alignment", "menu_items"]
allowed_fields = ["orientation", "alignment"]
serializer_mixins = [NestedMenuItemsMixin]
request_serializer_mixins = []
class SerializedDict(ElementDict):
orientation: str
alignment: str
menu_items: List[Dict]
@property
@ -1997,15 +1999,15 @@ class MenuElementType(ElementType):
DynamicConfigBlockSerializer,
)
from baserow.contrib.builder.theme.theme_config_block_types import (
ButtonThemeConfigBlockType,
MenuThemeConfigBlockType,
)
overrides = {
**super().serializer_field_overrides,
"styles": DynamicConfigBlockSerializer(
required=False,
property_name="button",
theme_config_block_type_name=ButtonThemeConfigBlockType.type,
property_name="menu",
theme_config_block_type_name=MenuThemeConfigBlockType.type,
serializer_kwargs={"required": False},
),
}
@ -2138,7 +2140,10 @@ class MenuElementType(ElementType):
super().after_update(instance, values, changes)
def get_pytest_params(self, pytest_data_fixture):
return {"orientation": RepeatElement.ORIENTATIONS.VERTICAL}
return {
"orientation": RepeatElement.ORIENTATIONS.VERTICAL,
"alignment": HorizontalAlignments.LEFT,
}
def deserialize_property(
self,
@ -2282,3 +2287,10 @@ class MenuElementType(ElementType):
if new_formula is not None:
item.query_parameters[index]["value"] = new_formula
yield item
for formula_field in NavigationElementManager.simple_formula_fields:
formula = getattr(item, formula_field, "")
new_formula = yield formula
if new_formula is not None:
setattr(item, formula_field, new_formula)
yield item

View file

@ -9,6 +9,7 @@ from django.db.models import SET_NULL, QuerySet
from baserow.contrib.builder.constants import (
BACKGROUND_IMAGE_MODES,
COLOR_FIELD_MAX_LENGTH,
HorizontalAlignments,
VerticalAlignments,
)
from baserow.core.constants import DATE_FORMAT_CHOICES, DATE_TIME_FORMAT_CHOICES
@ -1058,4 +1059,10 @@ class MenuElement(Element):
db_default=ORIENTATIONS.HORIZONTAL,
)
alignment = models.CharField(
choices=HorizontalAlignments.choices,
max_length=10,
default=HorizontalAlignments.LEFT,
)
menu_items = models.ManyToManyField(MenuItemElement)

View file

@ -0,0 +1,312 @@
# Generated by Django 5.0.9 on 2025-03-05 10:12
import django.db.models.deletion
from django.db import migrations, models
import baserow.core.fields
class Migration(migrations.Migration):
dependencies = [
("builder", "0052_menuitemelement_menuelement"),
]
operations = [
migrations.AddField(
model_name="buttonthemeconfigblock",
name="button_active_background_color",
field=models.CharField(
blank=True,
default="#4783db",
help_text="The background color of buttons when active",
max_length=255,
),
),
migrations.AddField(
model_name="buttonthemeconfigblock",
name="button_active_border_color",
field=models.CharField(
blank=True,
default="#275d9f",
help_text="The border color of buttons when active",
max_length=255,
),
),
migrations.AddField(
model_name="buttonthemeconfigblock",
name="button_active_text_color",
field=models.CharField(
blank=True,
default="#ffffffff",
help_text="The text color of buttons when active",
max_length=255,
),
),
migrations.AddField(
model_name="linkthemeconfigblock",
name="link_active_text_color",
field=models.CharField(
blank=True,
default="#275d9f",
help_text="The hover color of links when active",
max_length=255,
),
),
migrations.AddField(
model_name="menuelement",
name="alignment",
field=models.CharField(
choices=[("left", "Left"), ("center", "Center"), ("right", "Right")],
default="left",
max_length=10,
),
),
migrations.CreateModel(
name="MenuThemeConfigBlock",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"button_font_family",
models.CharField(default="inter", max_length=250),
),
("button_font_size", models.SmallIntegerField(default=13)),
(
"button_font_weight",
models.CharField(
choices=[
("thin", "Thin"),
("extra-light", "Extra Light"),
("light", "Light"),
("regular", "Regular"),
("medium", "Medium"),
("semi-bold", "Semi Bold"),
("bold", "Bold"),
("extra-bold", "Extra Bold"),
("heavy", "Heavy"),
("black", "Black"),
("extra-black", "Extra Black"),
],
db_default="regular",
default="regular",
max_length=11,
),
),
(
"button_alignment",
models.CharField(
choices=[
("left", "Left"),
("center", "Center"),
("right", "Right"),
],
default="left",
max_length=10,
),
),
(
"button_text_alignment",
models.CharField(
choices=[
("left", "Left"),
("center", "Center"),
("right", "Right"),
],
default="center",
max_length=10,
),
),
(
"button_width",
models.CharField(
choices=[("auto", "Auto"), ("full", "Full")],
default="auto",
max_length=10,
),
),
(
"button_background_color",
models.CharField(
blank=True,
default="primary",
help_text="The background color of buttons",
max_length=255,
),
),
(
"button_text_color",
models.CharField(
blank=True,
default="#ffffffff",
help_text="The text color of buttons",
max_length=255,
),
),
(
"button_border_color",
models.CharField(
blank=True,
default="border",
help_text="The border color of buttons",
max_length=255,
),
),
(
"button_border_size",
models.SmallIntegerField(default=0, help_text="Button border size"),
),
(
"button_border_radius",
models.SmallIntegerField(
default=4, help_text="Button border radius"
),
),
(
"button_vertical_padding",
models.SmallIntegerField(
default=12, help_text="Button vertical padding"
),
),
(
"button_horizontal_padding",
models.SmallIntegerField(
default=12, help_text="Button horizontal padding"
),
),
(
"button_hover_background_color",
models.CharField(
blank=True,
default="#96baf6ff",
help_text="The background color of buttons when hovered",
max_length=255,
),
),
(
"button_hover_text_color",
models.CharField(
blank=True,
default="#ffffffff",
help_text="The text color of buttons when hovered",
max_length=255,
),
),
(
"button_hover_border_color",
models.CharField(
blank=True,
default="border",
help_text="The border color of buttons when hovered",
max_length=255,
),
),
(
"button_active_background_color",
models.CharField(
blank=True,
default="#4783db",
help_text="The background color of buttons when active",
max_length=255,
),
),
(
"button_active_text_color",
models.CharField(
blank=True,
default="#ffffffff",
help_text="The text color of buttons when active",
max_length=255,
),
),
(
"button_active_border_color",
models.CharField(
blank=True,
default="#275d9f",
help_text="The border color of buttons when active",
max_length=255,
),
),
("link_font_family", models.CharField(default="inter", max_length=250)),
("link_font_size", models.SmallIntegerField(default=13)),
(
"link_font_weight",
models.CharField(
choices=[
("thin", "Thin"),
("extra-light", "Extra Light"),
("light", "Light"),
("regular", "Regular"),
("medium", "Medium"),
("semi-bold", "Semi Bold"),
("bold", "Bold"),
("extra-bold", "Extra Bold"),
("heavy", "Heavy"),
("black", "Black"),
("extra-black", "Extra Black"),
],
db_default="regular",
default="regular",
max_length=11,
),
),
(
"link_text_alignment",
models.CharField(
choices=[
("left", "Left"),
("center", "Center"),
("right", "Right"),
],
default="left",
max_length=10,
),
),
(
"link_text_color",
models.CharField(
blank=True,
default="primary",
help_text="The text color of links",
max_length=255,
),
),
(
"link_hover_text_color",
models.CharField(
blank=True,
default="#96baf6ff",
help_text="The hover color of links when hovered",
max_length=255,
),
),
(
"link_active_text_color",
models.CharField(
blank=True,
default="#275d9f",
help_text="The hover color of links when active",
max_length=255,
),
),
(
"builder",
baserow.core.fields.AutoOneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s",
to="builder.builder",
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -181,7 +181,7 @@ class TypographyThemeConfigBlock(ThemeConfigBlock):
)
class ButtonThemeConfigBlock(ThemeConfigBlock):
class ButtonThemeConfigBlockMixin(models.Model):
button_font_family = models.CharField(
max_length=250,
default="inter",
@ -256,9 +256,34 @@ class ButtonThemeConfigBlock(ThemeConfigBlock):
blank=True,
help_text="The border color of buttons when hovered",
)
button_active_background_color = models.CharField(
max_length=COLOR_FIELD_MAX_LENGTH,
default="#4783db",
blank=True,
help_text="The background color of buttons when active",
)
button_active_text_color = models.CharField(
max_length=COLOR_FIELD_MAX_LENGTH,
default="#ffffffff",
blank=True,
help_text="The text color of buttons when active",
)
button_active_border_color = models.CharField(
max_length=COLOR_FIELD_MAX_LENGTH,
default="#275d9f",
blank=True,
help_text="The border color of buttons when active",
)
class Meta:
abstract = True
class LinkThemeConfigBlock(ThemeConfigBlock):
class ButtonThemeConfigBlock(ButtonThemeConfigBlockMixin, ThemeConfigBlock):
pass
class LinkThemeConfigBlockMixin(models.Model):
link_font_family = models.CharField(
max_length=250,
default="inter",
@ -287,6 +312,19 @@ class LinkThemeConfigBlock(ThemeConfigBlock):
blank=True,
help_text="The hover color of links when hovered",
)
link_active_text_color = models.CharField(
max_length=COLOR_FIELD_MAX_LENGTH,
default="#275d9f",
blank=True,
help_text="The hover color of links when active",
)
class Meta:
abstract = True
class LinkThemeConfigBlock(LinkThemeConfigBlockMixin, ThemeConfigBlock):
pass
class ImageThemeConfigBlock(ThemeConfigBlock):
@ -535,3 +573,9 @@ class TableThemeConfigBlock(ThemeConfigBlock):
table_horizontal_separator_size = models.SmallIntegerField(
default=1, help_text="Table horizontal separator size"
)
class MenuThemeConfigBlock(
LinkThemeConfigBlockMixin, ButtonThemeConfigBlockMixin, ThemeConfigBlock
):
pass

View file

@ -15,6 +15,7 @@ from .models import (
ImageThemeConfigBlock,
InputThemeConfigBlock,
LinkThemeConfigBlock,
MenuThemeConfigBlock,
PageThemeConfigBlock,
TableThemeConfigBlock,
ThemeConfigBlock,
@ -182,3 +183,8 @@ class InputThemeConfigBlockType(ThemeConfigBlockType):
class TableThemeConfigBlockType(ThemeConfigBlockType):
type = "table"
model_class = TableThemeConfigBlock
class MenuThemeConfigBlockType(ThemeConfigBlockType):
type = "menu"
model_class = MenuThemeConfigBlock

View file

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

View file

@ -1,3 +1,4 @@
import uuid
from copy import deepcopy
from baserow.contrib.builder.elements.models import (
@ -11,6 +12,7 @@ from baserow.contrib.builder.elements.models import (
InputTextElement,
LinkElement,
MenuElement,
MenuItemElement,
RecordSelectorElement,
RepeatElement,
TableElement,
@ -120,6 +122,34 @@ class ElementFixtures:
def create_builder_menu_element(self, user=None, page=None, **kwargs):
return self.create_builder_element(MenuElement, user, page, **kwargs)
def create_builder_menu_element_items(
self, user=None, page=None, menu_element=None, menu_items=None, **kwargs
):
if not menu_element:
menu_element = self.create_builder_menu_element(
user=user, page=page, **kwargs
)
if not menu_items:
menu_items = [
{
"variant": "link",
"type": "link",
"uid": uuid.uuid4(),
"name": "Test Link",
}
]
created_items = MenuItemElement.objects.bulk_create(
[
MenuItemElement(**item, menu_item_order=index)
for index, item in enumerate(menu_items)
]
)
menu_element.menu_items.add(*created_items)
return menu_element
def create_builder_element(self, model_class, user=None, page=None, **kwargs):
if user is None:
user = self.create_user()

View file

@ -115,7 +115,7 @@ def test_create_menu_element(api_client, data_fixture):
@pytest.mark.django_db
def test_can_update_a_table_element_fields(api_client, menu_element_fixture):
def test_can_update_menu_element_items(api_client, menu_element_fixture):
menu_element = menu_element_fixture["menu_element"]
token = menu_element_fixture["token"]

View file

@ -663,12 +663,16 @@ def test_builder_application_export(data_fixture):
"button_hover_background_color": "#96baf6ff",
"button_hover_text_color": "#ffffffff",
"button_hover_border_color": "border",
"button_active_background_color": "#4783db",
"button_active_text_color": "#ffffffff",
"button_active_border_color": "#275d9f",
"link_font_family": "inter",
"link_font_size": 13,
"link_font_weight": "regular",
"link_text_alignment": "left",
"link_text_color": "primary",
"link_hover_text_color": "#96baf6ff",
"link_active_text_color": "#275d9f",
"image_alignment": "left",
"image_border_radius": 0,
"image_max_width": 100,
@ -1038,6 +1042,7 @@ IMPORT_REFERENCE = {
"link_alignment": "left",
"link_text_color": "primary",
"link_hover_text_color": "#ccccccff",
"link_active_text_color": "#275d9f",
},
"id": 999,
"name": "Holly Sherman",

View file

@ -10,6 +10,7 @@ from baserow.contrib.builder.elements.element_types import (
ImageElementType,
InputTextElementType,
LinkElementType,
MenuElementType,
TableElementType,
TextElementType,
)
@ -208,3 +209,54 @@ def test_table_element_formula_generator(data_fixture, formula_generator_fixture
"label": "get('current_record.field_111')"
}
assert table_element.data_source_id == data_source.id
@pytest.mark.django_db
def test_menu_element_formula_generator(data_fixture, formula_generator_fixture):
"""
Test the MenuElementType's formula_generator().
The MenuElement can have one or more MenuItemElements. The MenuItemElement
has several formula fields but doesn't have a distinct type of its own.
Therefore its formula_generator() is overridden and must be tested.
"""
page = formula_generator_fixture["page"]
menu_element = data_fixture.create_builder_menu_element_items(
user=formula_generator_fixture["user"],
page=page,
)
menu_item = menu_element.menu_items.first()
menu_item.page_parameters = [
{
"name": "foo_param",
"value": formula_generator_fixture["formula_1"],
}
]
menu_item.query_parameters = [
{
"name": "bar_query",
"value": formula_generator_fixture["formula_1"],
},
]
menu_item.navigate_to_url = formula_generator_fixture["formula_1"]
menu_item.save()
serialized_element = MenuElementType().export_serialized(menu_element)
imported_element = MenuElementType().import_serialized(
formula_generator_fixture["page"],
serialized_element,
formula_generator_fixture["id_mapping"],
)
imported_menu_item = imported_element.menu_items.first()
assert (
imported_menu_item.page_parameters[0]["value"]
== formula_generator_fixture["formula_2"]
)
assert (
imported_menu_item.query_parameters[0]["value"]
== formula_generator_fixture["formula_2"]
)
assert imported_menu_item.navigate_to_url == formula_generator_fixture["formula_2"]

View file

@ -0,0 +1,8 @@
{
"type": "feature",
"message": "Added the Menu element.",
"domain": "builder",
"issue_number": 3477,
"bullet_points": [],
"created_at": "2025-03-05"
}

View file

@ -1,10 +1,13 @@
<template>
<a
:class="{
'ab-link': variant !== 'button',
'ab-button ab-button--medium': variant === 'button',
'ab-button--full-width': variant === 'button' && fullWidth === true,
}"
:class="[
{
'ab-link': variant !== 'button',
'ab-button ab-button--medium': variant === 'button',
'ab-button--full-width': variant === 'button' && fullWidth === true,
},
forceActiveClass,
]"
:target="`_${target}`"
:href="url"
:rel="isExternalLink ? 'noopener noreferrer' : null"
@ -36,7 +39,7 @@ export default {
},
},
/**
* @type {string} - Wheter the button should be full width.
* @type {Boolean} - Wheter the button should be full width.
*/
fullWidth: {
type: Boolean,
@ -61,11 +64,22 @@ export default {
type: String,
required: true,
},
/**
* @type {Boolean} - Whether the active class should be applied to the link.
*/
forceActive: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isExternalLink() {
return !this.url.startsWith('/')
},
forceActiveClass() {
return this.forceActive ? `ab-${this.variant}--force-active` : ''
},
},
methods: {
handleClick(event) {

View file

@ -1,21 +1,27 @@
<template>
<div
:style="getStyleOverride(element.variant)"
:style="{
'--alignment': menuAlignment,
}"
:class="[
'menu-element__container',
element.orientation === 'horizontal' ? 'horizontal' : 'vertical',
element.orientation === 'horizontal'
? 'menu-element__container--horizontal'
: 'menu-element__container--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">
<div
v-for="item in element.menu_items"
:key="item.id"
:class="`menu-element__menu-item-${item.type}`"
>
<template v-if="item.type === 'link' && !item.parent_menu_item">
<div v-if="!item.children?.length" :style="getStyleOverride('menu')">
<ABLink
:variant="item.variant"
:url="getItemUrl(item)"
:target="getMenuItem(item).target"
:force-active="menuItemIsActive(item)"
>
{{
item.name
@ -32,21 +38,27 @@
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 :style="getStyleOverride('menu')">
<ABLink
:variant="item.variant"
url=""
:force-active="sublinkIsActive(item)"
>
<div class="menu-element__sub-link-menu--container">
{{ item.name }}
<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>
</ABLink>
</div>
<Context
@ -60,13 +72,14 @@
v-for="child in item.children"
:key="child.id"
class="menu-element__sub-links"
:style="getStyleOverride(child.variant)"
:style="getStyleOverride('menu')"
>
<ABLink
:variant="child.variant"
:url="getItemUrl(child)"
:target="getMenuItem(child).target"
class="menu-element__sub-link"
:force-active="menuItemIsActive(child)"
>
{{
child.name
@ -83,7 +96,10 @@
</div>
</template>
<template v-else-if="item.type === 'button'">
<ABButton @click="onButtonClick(item)">
<ABButton
:style="getStyleOverride('menu')"
@click="onButtonClick(item)"
>
{{
item.name
? item.name ||
@ -103,9 +119,19 @@
</template>
<script>
import { resolveApplicationRoute } from '@baserow/modules/builder/utils/routing'
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'
import { HORIZONTAL_ALIGNMENTS } from '@baserow/modules/builder/enums'
/**
* CSS classes to force a Link variant to appear as active.
*/
const LINK_ACTIVE_CLASSES = {
link: 'ab-link--force-active',
button: 'ab-button--force-active',
}
/**
* @typedef MenuElement
@ -125,6 +151,7 @@ export default {
data() {
return {
expandedItems: {},
activeItem: {},
}
},
computed: {
@ -134,6 +161,42 @@ export default {
menuElementType() {
return this.$registry.get('element', 'menu')
},
menuAlignment() {
const alignmentsCSS = {
[HORIZONTAL_ALIGNMENTS.LEFT]: 'flex-start',
[HORIZONTAL_ALIGNMENTS.CENTER]: 'center',
[HORIZONTAL_ALIGNMENTS.RIGHT]: 'flex-end',
}
return alignmentsCSS[this.element.alignment]
},
},
mounted() {
/**
* If the current page matches a menu item, that menu item is set as the
* active item. This ensures that the active CSS style is applied to the
* correct menu item.
*/
const found = resolveApplicationRoute(
this.pages,
this.$route.params.pathMatch
)
if (!found?.length) return
const currentPageId = found[0].id
for (const item of this.element.menu_items) {
if (!item.children.length && item.navigate_to_page_id === currentPageId) {
this.activeItem = item
break
}
for (const child of item.children) {
if (child.navigate_to_page_id === currentPageId) {
this.activeItem = child
break
}
}
}
},
methods: {
showSubMenu(event, itemId) {
@ -191,6 +254,21 @@ export default {
this.menuElementType.getEventByName(this.element, eventName)
)
},
menuItemIsActive(item) {
return this.activeItem?.uid === item.uid
},
getActiveParentClass(item) {
if (item.children?.some((child) => child.uid === this.activeItem?.uid))
return LINK_ACTIVE_CLASSES[item.variant] || ''
return ''
},
sublinkIsActive(item) {
if (item.children?.some((child) => child.uid === this.activeItem?.uid))
return true
return false
},
},
}
</script>

View file

@ -1,5 +1,12 @@
<template>
<form @submit.prevent @keydown.enter.prevent>
<CustomStyle
v-model="values.styles"
style-key="menu"
:config-block-types="['button', 'link']"
:theme="builder.theme"
:extra-args="{ noAlignment: true, noWidth: true }"
/>
<FormGroup
:label="$t('orientations.label')"
small-label
@ -13,9 +20,20 @@
>
</RadioGroup>
</FormGroup>
<FormGroup
v-if="values.orientation === ORIENTATIONS.HORIZONTAL"
:label="$t('menuElementForm.alignment')"
small-label
required
class="margin-bottom-2"
>
<HorizontalAlignmentsSelector v-model="values.alignment" />
</FormGroup>
<div
ref="menuItemAddContainer"
class="menu-element__form--add-item-container"
class="menu-element-form__add-item-container"
>
<div>
{{ $t('menuElementForm.menuItemsLabel') }}
@ -37,8 +55,11 @@
</ButtonText>
</div>
</div>
<p v-if="!values.menu_items.length">
{{ $t('menuElementForm.noMenuItemsMessage') }}
</p>
<Context ref="menuItemAddContext" :hide-on-click-outside="true">
<div class="menu-element__form--add-item-context">
<div class="menu-element-form__add-item-context">
<ButtonText
v-for="(menuItemType, index) in addMenuItemTypes"
:key="index"
@ -51,30 +72,51 @@
</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>
<div class="menu-element-form__items">
<MenuElementItemForm
v-for="(item, index) in values.menu_items"
:key="`${item.uid}-${index}`"
v-sortable="{
id: item.uid,
update: orderRootItems,
enabled: $hasPermission(
'builder.page.element.update',
element,
workspace.id
),
handle: '[data-sortable-handle]',
}"
:default-values="item"
@remove-item="removeMenuItem($event)"
@values-changed="updateMenuItem"
></MenuElementItemForm>
</div>
</div>
</form>
</template>
<script>
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import { ORIENTATIONS } from '@baserow/modules/builder/enums'
import {
HORIZONTAL_ALIGNMENTS,
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'
import CustomStyle from '@baserow/modules/builder/components/elements/components/forms/style/CustomStyle'
import HorizontalAlignmentsSelector from '@baserow/modules/builder/components/HorizontalAlignmentsSelector'
export default {
name: 'MenuElementForm',
components: {
MenuElementItemForm,
CustomStyle,
HorizontalAlignmentsSelector,
},
mixins: [elementForm],
data() {
@ -83,9 +125,16 @@ export default {
value: '',
styles: {},
orientation: ORIENTATIONS.VERTICAL,
alignment: HORIZONTAL_ALIGNMENTS.LEFT,
menu_items: [],
},
allowedValues: ['value', 'styles', 'menu_items', 'orientation'],
allowedValues: [
'value',
'styles',
'menu_items',
'orientation',
'alignment',
],
addMenuItemTypes: [
{
icon: 'iconoir-link',
@ -102,6 +151,11 @@ export default {
label: this.$t('menuElementForm.menuItemAddSeparator'),
type: 'separator',
},
{
icon: 'baserow-icon-spacer',
label: this.$t('menuElementForm.menuItemAddSpacer'),
type: 'spacer',
},
],
}
},
@ -148,6 +202,7 @@ export default {
type,
uid: uuid(),
children: [],
parent_menu_item: null, // This is the root menu item.
},
]
this.$refs.menuItemAddContext.hide()
@ -173,6 +228,15 @@ export default {
return item
})
},
/**
* Responsible for sorting the root items of this menu item.
*/
orderRootItems(newOrder) {
const itemsByUid = Object.fromEntries(
this.values.menu_items.map((item) => [item.uid, item])
)
this.values.menu_items = newOrder.map((uid) => itemsByUid[uid])
},
},
}
</script>

View file

@ -2,18 +2,14 @@
<Expandable>
<template #header="{ toggle, expanded }">
<div
:class="
isStyle
? 'menu-element__form--expandable-item-header-outline'
: 'menu-element__form--expandable-item-header'
"
class="menu-element-form__item-header"
:class="{
'menu-element-form__item-header--outline': isStyle,
}"
@click.stop="!isStyle ? toggle() : null"
>
<div
class="menu-element__form--expandable-item-handle"
data-sortable-handle
/>
<div class="menu-element__form--expandable-item-name">
<div class="menu-element-form__item-handle" data-sortable-handle />
<div class="menu-element-form__item-name">
<template v-if="values.type === 'separator'">
{{ $t('menuElement.separator') }}
</template>
@ -24,11 +20,12 @@
{{ values.name }}
</template>
</div>
<template v-if="isStyle">
<ButtonIcon
size="small"
icon="iconoir-bin"
@click="removeMenuItem()"
@click="removeMenuItem(values)"
/>
</template>
<template v-else>
@ -41,33 +38,58 @@
</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')"
<div
class="menu-element-form__item"
:class="{ 'menu-element-form__item-child': preventItemNesting }"
>
<div v-if="values.type === 'button'">
<FormGroup
small-label
horizontal
required
class="margin-bottom-2"
:label="$t('menuElementForm.menuItemLabelLabel')"
: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'">
>
<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>
<Alert type="info-neutral">
<p>{{ $t('menuElementForm.eventDescription') }}</p>
</Alert>
</template>
<template v-else>
</div>
<div v-else>
<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>
<FormGroup
small-label
horizontal
@ -88,22 +110,36 @@
/>
</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">
<div class="menu-element-item-form__children">
<MenuElementItemForm
v-for="(child, index) in values.children"
:key="`${child.uid}-${index}`"
v-sortable="{
id: child.uid,
update: orderChildItems,
enabled: $hasPermission(
'builder.page.element.update',
element,
workspace.id
),
handle: '[data-sortable-handle]',
}"
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"
class="menu-element-form__add-sub-link-container"
>
<ButtonText
type="primary"
@ -114,13 +150,14 @@
{{ $t('menuElementForm.addSubLink') }}
</ButtonText>
</div>
</template>
</div>
</div>
</template>
</Expandable>
</template>
<script>
import { mapGetters } from 'vuex'
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'
@ -139,9 +176,9 @@ export default {
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.
* Controls whether this menu item can nest other menu items. This is
* allowed by default. Since we only allow one level of nesting for
* sublinks, this should be false when rendering sublinks.
*/
preventItemNesting: {
type: Boolean,
@ -164,9 +201,15 @@ export default {
}
},
computed: {
...mapGetters({
getElementSelected: 'element/getSelected',
}),
isStyle() {
return ['separator', 'spacer'].includes(this.values.type)
},
element() {
return this.getElementSelected(this.builder)
},
menuItemVariants() {
return [
{
@ -223,6 +266,15 @@ export default {
uid: uuid(),
})
},
/**
* Responsible for sorting the child items of this menu item.
*/
orderChildItems(newOrder) {
const itemsByUid = Object.fromEntries(
this.values.children.map((item) => [item.uid, item])
)
this.values.children = newOrder.map((uid) => itemsByUid[uid])
},
},
validations() {
return {

View file

@ -313,6 +313,73 @@
</ABButton>
</template>
</ThemeConfigBlockSection>
<ThemeConfigBlockSection :title="$t('buttonThemeConfigBlock.activeState')">
<template #default>
<FormGroup
horizontal-narrow
small-label
required
class="margin-bottom-2"
:label="$t('buttonThemeConfigBlock.backgroundColor')"
>
<ColorInput
v-model="v$.values.button_active_background_color.$model"
:color-variables="colorVariables"
:default-value="theme?.button_active_background_color"
small
/>
<template #after-input>
<ResetButton
v-model="v$.values.button_active_background_color.$model"
:default-value="theme?.button_active_background_color"
/>
</template>
</FormGroup>
<FormGroup
horizontal-narrow
small-label
class="margin-bottom-2"
:label="$t('buttonThemeConfigBlock.textColor')"
>
<ColorInput
v-model="v$.values.button_active_text_color.$model"
:color-variables="colorVariables"
:default-value="theme?.button_active_text_color"
small
/>
<template #after-input>
<ResetButton
v-model="v$.values.button_active_text_color.$model"
:default-value="theme?.button_active_text_color"
/>
</template>
</FormGroup>
<FormGroup
horizontal-narrow
small-label
class="margin-bottom-2"
:label="$t('buttonThemeConfigBlock.borderColor')"
>
<ColorInput
v-model="v$.values.button_active_border_color.$model"
:color-variables="colorVariables"
:default-value="theme?.button_active_border_color"
small
/>
<template #after-input>
<ResetButton
v-model="v$.values.button_active_border_color.$model"
:default-value="theme?.button_active_border_color"
/>
</template>
</FormGroup>
</template>
<template #preview>
<ABButton class="ab-button--force-active">
{{ $t('buttonThemeConfigBlock.button') }}
</ABButton>
</template>
</ThemeConfigBlockSection>
</div>
</template>
@ -403,6 +470,10 @@ export default {
this.theme?.button_hover_background_color,
button_hover_text_color: this.theme?.button_hover_text_color,
button_hover_border_color: this.theme?.button_hover_border_color,
button_active_background_color:
this.theme?.button_active_background_color,
button_active_text_color: this.theme?.button_active_text_color,
button_active_border_color: this.theme?.button_active_border_color,
},
}
},
@ -558,6 +629,9 @@ export default {
button_hover_text_color: {},
button_border_color: {},
button_hover_border_color: {},
button_active_background_color: {},
button_active_text_color: {},
button_active_border_color: {},
button_font_family: {},
button_font_weight: {},
button_text_color: {},

View file

@ -133,6 +133,35 @@
</ABLink>
</template>
</ThemeConfigBlockSection>
<ThemeConfigBlockSection :title="$t('linkThemeConfigBlock.activeState')">
<template #default>
<FormGroup
horizontal-narrow
small-label
required
class="margin-bottom-2"
:label="$t('linkThemeConfigBlock.color')"
>
<ColorInput
v-model="v$.values.link_active_text_color.$model"
:color-variables="colorVariables"
:default-value="theme?.link_active_text_color"
small
/>
<template #after-input>
<ResetButton
v-model="v$.values.link_active_text_color.$model"
:default-value="theme?.link_active_text_color"
/>
</template>
</FormGroup>
</template>
<template #preview>
<ABLink url="" class="ab-link--force-active">
{{ $t('linkThemeConfigBlock.link') }}
</ABLink>
</template>
</ThemeConfigBlockSection>
</form>
</template>
@ -193,6 +222,7 @@ export default {
link_text_color: this.theme?.link_text_color,
link_text_alignment: this.theme?.link_text_alignment,
link_hover_text_color: this.theme?.link_hover_text_color,
link_active_text_color: this.theme?.link_active_text_color,
link_font_family: this.theme?.link_font_family,
link_font_weight: this.theme?.link_font_weight,
link_font_size: this.theme?.link_font_size,
@ -234,6 +264,7 @@ export default {
link_text_color: {},
link_text_alignment: {},
link_hover_text_color: {},
link_active_text_color: {},
link_font_family: {},
link_font_weight: {},
},

View file

@ -207,6 +207,7 @@
"menuElementForm": {
"menuItemsLabel": "Menu items",
"addMenuItemLink": "Add...",
"alignment": "Alignment",
"menuItemDefaultName": "Page",
"menuItemLabelLabel": "Label",
"menuItemTypeLabel": "Type",
@ -222,7 +223,8 @@
"menuItemAddButton": "Button",
"menuItemAddSeparator": "Separator",
"menuItemAddSpacer": "Spacer",
"eventDescription": "To configure actions for this button, open the Events tab of this element."
"eventDescription": "To configure actions for this button, open the Events tab of this element.",
"noMenuItemsMessage": "Click 'Add' to add your first menu item."
},
"imageElement": {
"missingValue": "Missing alt text...",
@ -566,6 +568,7 @@
"button": "Button",
"defaultState": "Default state",
"hoverState": "Hover state",
"activeState": "Active state",
"textAlignment": "Text alignment",
"alignment": "Alignment",
"width": "Width",
@ -583,6 +586,7 @@
"link": "Link",
"defaultState": "Default state",
"hoverState": "Hover state",
"activeState": "Active state",
"alignment": "Alignment",
"fontFamily": "Font",
"size": "Font size",

View file

@ -25,7 +25,6 @@ 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,
@ -229,10 +228,7 @@ export default (context) => {
app.$registry.register('element', new DateTimePickerElementType(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('element', new MenuElementType(context))
app.$registry.register('device', new DesktopDeviceType(context))
app.$registry.register('device', new TabletDeviceType(context))

View file

@ -328,6 +328,10 @@ export class ButtonThemeConfigBlockType extends ThemeConfigBlockType {
style.addColorIfExists(theme, 'button_hover_text_color')
style.addColorIfExists(theme, 'button_border_color')
style.addColorIfExists(theme, 'button_hover_border_color')
style.addColorIfExists(theme, 'button_active_background_color')
style.addColorIfExists(theme, 'button_active_text_color')
style.addColorIfExists(theme, 'button_active_border_color')
style.addIfExists(theme, 'button_width', null, (v) =>
v === WIDTHS_NEW.FULL ? '100%' : 'auto'
)
@ -378,6 +382,7 @@ export class LinkThemeConfigBlockType extends ThemeConfigBlockType {
})
style.addColorIfExists(theme, 'link_text_color')
style.addColorIfExists(theme, 'link_hover_text_color')
style.addColorIfExists(theme, 'link_active_text_color')
style.addIfExists(
theme,
'link_text_alignment',

View file

@ -25,6 +25,13 @@
text-decoration: none;
}
&:not(.loading-spinner).ab-button--force-active {
background-color: var(--button-active-background-color, $black);
border-color: var(--button-active-border-color, $white);
color: var(--button-active-text-color, $white);
text-decoration: none;
}
&[disabled],
&[disabled]:hover {
cursor: not-allowed;

View file

@ -11,4 +11,9 @@
color: var(--link-hover-text-color, $black);
text-decoration: var(--link-hover-text-decoration, underline);
}
&--force-active {
color: var(--link-active-text-color, $black);
text-decoration: var(--link-active-text-decoration, underline);
}
}

View file

@ -5,3 +5,4 @@
@import 'visibility_form';
@import 'property_option_form';
@import 'multi_page_container_element_form';
@import 'menu_element_form';

View file

@ -0,0 +1,84 @@
/**
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__item-header {
@extend %menu-element-form-expandable-item;
background-color: $palette-neutral-100;
}
.menu-element-form__item-error {
font-size: 20px;
color: #ffbdb4;
pointer-events: none;
padding-right: 3px;
}
.menu-element-form__item-name {
flex: 1;
padding-right: 5px;
@extend %ellipsis;
}
.menu-element-form__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__item-header:hover & {
visibility: visible;
}
}
.menu-element-form__item-header--outline {
@extend %menu-element-form-expandable-item;
background-color: $color-neutral-10;
border: 1px dashed $color-neutral-200;
}
.menu-element-form__item {
margin: 5px 10px;
width: 100%;
}
.menu-element-form__item-child {
margin-right: -10px;
width: calc(100% - 10px);
}
.menu-element-form__add-sub-link-container {
display: flex;
justify-content: flex-end;
margin-left: 15px;
}

View file

@ -1,5 +1,6 @@
.menu-element__container {
display: flex;
justify-content: var(--alignment);
}
/**
@ -10,29 +11,41 @@
user-select: none;
}
.menu-element__container.vertical {
.menu-element__container--vertical {
flex-direction: column;
gap: 20px;
width: 100%;
.menu-element__menu-item-separator {
width: 100%;
height: 1px;
background-color: $palette-neutral-500;
margin: 0 5px;
}
}
.menu-element__container.horizontal {
.menu-element__menu-item-spacer {
flex: 1;
}
.menu-element__sub-link-menu-spacer {
flex: 1;
}
.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;
}
.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--expanded-icon {
margin-left: 5px;
}
}
.menu-element__sub-link-menu--container {
@ -42,20 +55,12 @@
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;
padding: 5px;
}
.menu-element__sub-link {
@ -68,83 +73,3 @@
.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

@ -1,7 +1,6 @@
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