1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-05-12 04:11:49 +00:00

Add Link element

This commit is contained in:
Jérémie Pardou 2023-05-11 15:27:17 +00:00
parent 7309f56ad5
commit e45fd10652
49 changed files with 1484 additions and 249 deletions

View file

@ -80,3 +80,8 @@ class MoveElementSerializer(serializers.Serializer):
"Otherwise the element is placed at the end of the page."
),
)
class PageParameterValueSerializer(serializers.Serializer):
name = serializers.CharField()
value = ExpressionField(allow_blank=True)

View file

@ -101,11 +101,16 @@ class BuilderConfig(AppConfig):
permission_manager_type_registry.register(AllowPublicBuilderManagerType())
from .elements.element_types import HeadingElementType, ParagraphElementType
from .elements.element_types import (
HeadingElementType,
LinkElementType,
ParagraphElementType,
)
from .elements.registries import element_type_registry
element_type_registry.register(HeadingElementType())
element_type_registry.register(ParagraphElementType())
element_type_registry.register(LinkElementType())
from .domains.trash_types import DomainTrashableItemType

View file

@ -2,7 +2,11 @@ from abc import ABC
from rest_framework import serializers
from baserow.contrib.builder.elements.models import HeadingElement, ParagraphElement
from baserow.contrib.builder.elements.models import (
HeadingElement,
LinkElement,
ParagraphElement,
)
from baserow.contrib.builder.elements.registries import ElementType
from baserow.contrib.builder.elements.types import Expression
from baserow.contrib.builder.types import ElementDict
@ -89,3 +93,116 @@ class ParagraphElementType(BaseTextElementType):
"Asperiores corporis perspiciatis nam harum veritatis. "
"Impedit qui maxime aut illo quod ea molestias."
}
class LinkElementType(BaseTextElementType):
"""
A simple paragraph element that can be used to display a paragraph of text.
"""
type = "link"
model_class = LinkElement
class SerializedDict(ElementDict):
value: Expression
destination: Expression
open_new_tab: bool
@property
def serializer_field_names(self):
return super().serializer_field_names + [
"navigation_type",
"navigate_to_page_id",
"navigate_to_url",
"page_parameters",
"variant",
"target",
"width",
"alignment",
]
@property
def allowed_fields(self):
return super().allowed_fields + [
"navigation_type",
"navigate_to_page_id",
"navigate_to_url",
"page_parameters",
"variant",
"target",
"width",
"alignment",
]
def import_serialized(self, page, serialized_values, id_mapping):
serialized_copy = serialized_values.copy()
if serialized_copy["navigate_to_page_id"]:
serialized_copy["navigate_to_page_id"] = id_mapping["builder_pages"][
serialized_copy["navigate_to_page_id"]
]
return super().import_serialized(page, serialized_copy, id_mapping)
@property
def serializer_field_overrides(self):
from baserow.contrib.builder.api.elements.serializers import (
ExpressionField,
PageParameterValueSerializer,
)
overrides = {
"navigation_type": serializers.ChoiceField(
choices=LinkElement.NAVIGATION_TYPES.choices,
help_text=LinkElement._meta.get_field("navigation_type").help_text,
required=False,
),
"navigate_to_page_id": serializers.IntegerField(
allow_null=True,
help_text=LinkElement._meta.get_field("navigate_to_page").help_text,
required=False,
),
"navigate_to_url": ExpressionField(
help_text=LinkElement._meta.get_field("navigate_to_url").help_text,
default="",
allow_blank=True,
required=False,
),
"page_parameters": PageParameterValueSerializer(
many=True,
help_text=LinkElement._meta.get_field("navigate_to_url").help_text,
required=False,
),
"variant": serializers.ChoiceField(
choices=LinkElement.VARIANTS.choices,
help_text=LinkElement._meta.get_field("variant").help_text,
required=False,
),
"target": serializers.ChoiceField(
choices=LinkElement.TARGETS.choices,
help_text=LinkElement._meta.get_field("target").help_text,
required=False,
),
"width": serializers.ChoiceField(
choices=LinkElement.WIDTHS.choices,
help_text=LinkElement._meta.get_field("width").help_text,
required=False,
),
"alignment": serializers.ChoiceField(
choices=LinkElement.ALIGNMENTS.choices,
help_text=LinkElement._meta.get_field("alignment").help_text,
required=False,
),
}
overrides.update(super().serializer_field_overrides)
return overrides
def get_sample_params(self):
return {
"navigation_type": "custom",
"navigate_to_page_id": None,
"navigate_to_url": "http://example.com",
"page_parameters": [],
"variant": "link",
"target": "blank",
"width": "auto",
"alignment": "center",
}

View file

@ -111,6 +111,8 @@ class ElementHandler:
kwargs, shared_allowed_fields + element_type.allowed_fields
)
allowed_values = element_type.prepare_value_for_db(allowed_values)
model_class = cast(Element, element_type.model_class)
element = model_class(page=page, order=order, **allowed_values)
@ -144,6 +146,8 @@ class ElementHandler:
kwargs, shared_allowed_fields + element_type.allowed_fields
)
allowed_updates = element_type.prepare_value_for_db(allowed_updates)
for key, value in allowed_updates.items():
setattr(element, key, value)

View file

@ -116,3 +116,75 @@ class ParagraphElement(BaseTextElement):
"""
A simple paragraph.
"""
class LinkElement(BaseTextElement):
"""
A simple link.
"""
class NAVIGATION_TYPES(models.TextChoices):
PAGE = "page"
CUSTOM = "custom"
class VARIANTS(models.TextChoices):
LINK = "link"
BUTTON = "button"
class TARGETS(models.TextChoices):
SELF = "self"
BLANK = "blank"
class ALIGNMENTS(models.TextChoices):
LEFT = "left"
CENTER = "center"
RIGHT = "right"
class WIDTHS(models.TextChoices):
AUTO = "auto"
FULL = "full"
navigation_type = models.CharField(
choices=NAVIGATION_TYPES.choices,
help_text="The navigation type.",
max_length=10,
default=NAVIGATION_TYPES.PAGE,
)
navigate_to_page = models.ForeignKey(
Page,
null=True,
on_delete=models.SET_NULL,
help_text=(
"Destination page id for this link. If null then we use the "
"navigate_to_url property instead.",
),
)
navigate_to_url = ExpressionField(
default="",
help_text="If no page is selected, this indicate the destination of the link.",
)
page_parameters = models.JSONField(
default=list,
help_text="The parameters for each parameters of the selected page if any.",
)
variant = models.CharField(
choices=VARIANTS.choices,
help_text="The variant of the link.",
max_length=10,
default=VARIANTS.LINK,
)
target = models.CharField(
choices=TARGETS.choices,
help_text="The target of the link when we click on it.",
max_length=10,
default=TARGETS.SELF,
)
width = models.CharField(
choices=WIDTHS.choices,
max_length=10,
default=WIDTHS.AUTO,
)
alignment = models.CharField(
choices=ALIGNMENTS.choices, max_length=10, default=ALIGNMENTS.LEFT
)

View file

@ -29,6 +29,9 @@ class ElementType(
SerializedDict: Type[ElementDictSubClass]
def prepare_value_for_db(self, values):
return values
def export_serialized(
self,
element: Element,

View file

@ -0,0 +1,111 @@
# Generated by Django 3.2.18 on 2023-04-06 13:19
import django.db.models.deletion
from django.db import migrations, models
import baserow.contrib.builder.elements.models
class Migration(migrations.Migration):
dependencies = [
("builder", "0009_add_domain_publish"),
]
operations = [
migrations.CreateModel(
name="LinkElement",
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",
),
),
(
"value",
baserow.contrib.builder.elements.models.ExpressionField(default=""),
),
(
"navigation_type",
models.CharField(
choices=[("page", "Page"), ("custom", "Custom")],
default="page",
help_text="The navigation type.",
max_length=10,
),
),
(
"navigate_to_url",
baserow.contrib.builder.elements.models.ExpressionField(
default="",
help_text="If no page is selected, this indicate the destination of the link.",
),
),
(
"page_parameters",
models.JSONField(
default=list,
help_text="The parameters for each parameters of the selected page if any.",
),
),
(
"variant",
models.CharField(
choices=[("link", "Link"), ("button", "Button")],
default="link",
help_text="The variant of the link.",
max_length=10,
),
),
(
"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,
),
),
(
"width",
models.CharField(
choices=[("auto", "Auto"), ("full", "Full")],
default="auto",
max_length=10,
),
),
(
"alignment",
models.CharField(
choices=[
("left", "Left"),
("center", "Center"),
("right", "Right"),
],
default="left",
max_length=10,
),
),
(
"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",
),
),
],
options={
"abstract": False,
},
bases=("builder.element",),
),
]

View file

@ -29,7 +29,11 @@ def load_test_data():
)
Domain.objects.filter(domain_name="test1.getbaserow.io").delete()
Domain.objects.filter(domain_name="test2.getbaserow.io").delete()
Domain.objects.filter(domain_name="test3.getbaserow.io").delete()
Domain.objects.create(builder=builder, domain_name="test1.getbaserow.io", order=1)
Domain.objects.create(builder=builder, domain_name="test2.getbaserow.io", order=2)
Domain.objects.create(builder=builder, domain_name="test3.getbaserow.io", order=3)
try:
homepage = Page.objects.get(name="Homepage", builder=builder)
@ -38,6 +42,7 @@ def load_test_data():
heading_element_type = element_type_registry.get("heading")
paragraph_element_type = element_type_registry.get("paragraph")
link_element_type = element_type_registry.get("link")
ElementHandler().create_element(
heading_element_type, homepage, value="Back to local", level=1
@ -124,3 +129,35 @@ def load_test_data():
"sunt in culpa qui officia deserunt mollit anim id est laborum."
),
)
ElementHandler().create_element(
link_element_type,
terms,
value="Home",
variant="button",
alignment="right",
navigation_type="page",
navigate_to_page=homepage,
)
# Button for homepage
ElementHandler().create_element(
link_element_type,
homepage,
value="See terms",
variant="button",
alignment="right",
navigation_type="page",
navigate_to_page=terms,
)
ElementHandler().create_element(
link_element_type,
homepage,
value="Visit Baserow",
variant="link",
alignment="center",
navigation_type="custom",
target="blank",
navigate_to_url="https://baserow.io",
)

View file

@ -1,7 +1,7 @@
<template>
<div
class="element"
:class="{ 'element--active': active }"
:class="{ 'element--active': active, 'element--in-error': inError }"
@click="$emit('selected')"
>
<InsertElementButton
@ -19,10 +19,11 @@
@duplicate="$emit('duplicate')"
/>
<component
:is="elementType.component"
v-bind="elementType.getComponentProps(element)"
:is="elementType.editComponent"
class="element__component"
></component>
:element="element"
:builder="builder"
/>
<InsertElementButton
v-if="active"
class="element__insert--bottom"
@ -38,6 +39,7 @@ import { PLACEMENTS } from '@baserow/modules/builder/enums'
export default {
name: 'ElementPreview',
components: { ElementMenu, InsertElementButton },
inject: ['builder'],
props: {
element: {
type: Object,
@ -69,6 +71,12 @@ export default {
elementType() {
return this.$registry.get('element', this.element.type)
},
inError() {
return this.elementType.isInError({
element: this.element,
builder: this.builder,
})
},
},
}
</script>

View file

@ -1,10 +1,10 @@
<template>
<component
:is="`h${level}`"
:is="`h${element.level}`"
class="heading-element"
:class="{ 'element--no-value': !value }"
:class="{ 'element--no-value': !element.value }"
>
{{ value || $t('headingElement.noValue') }}
{{ element.value || $t('headingElement.noValue') }}
</component>
</template>
@ -15,8 +15,8 @@ export default {
name: 'HeaderElement',
mixins: [textElement],
props: {
level: {
type: Number,
element: {
type: Object,
required: true,
},
},

View file

@ -0,0 +1,115 @@
<template>
<div class="link-element" :class="classes">
<Button
v-if="element.variant === 'button'"
type="link"
v-bind="extraAttr"
:target="element.target"
:full-width="element.width === 'full'"
@click="onClick($event)"
>
{{ element.value || $t('linkElement.noValue') }}
</Button>
<a
v-else
class="link-element__link"
v-bind="extraAttr"
:target="`_${element.target}`"
@click="onClick($event)"
>
{{ element.value || $t('linkElement.noValue') }}
</a>
</div>
</template>
<script>
import textElement from '@baserow/modules/builder/mixins/elements/textElement'
import { LinkElementType } from '@baserow/modules/builder/elementTypes'
/**
* @typedef LinkElement
* @property {string} value The text inside the button
* @property {string} alignment left|center|right
* @property {string} variant link|button
* @property {string} navigation_type page|custom
* @property {string} navigate_to_page_id The page id for `page` navigation type.
* @property {object} page_parameters the page paramaters
* @property {string} navigate_to_url The URL for `custom` navigation type.
* @property {string} target self|blank
*/
export default {
name: 'LinkElement',
mixins: [textElement],
props: {
/**
* @type {LinkElement}
*/
element: {
type: Object,
required: true,
},
builder: { type: Object, required: true },
mode: { type: String, required: true },
},
computed: {
classes() {
return {
[`link-element--alignment-${this.element.alignment}`]: true,
'element--no-value': !this.element.value,
}
},
extraAttr() {
const attr = {}
if (this.url) {
attr.href = this.url
}
if (this.isExternalLink) {
attr.rel = 'noopener noreferrer'
}
return attr
},
originalUrl() {
try {
return LinkElementType.getUrlFromElement(this.element, this.builder)
} catch (e) {
return ''
}
},
url() {
if (
this.originalUrl &&
this.mode === 'preview' &&
(this.element.navigation_type === 'page' ||
(this.element.navigation_type === 'custom' &&
this.originalUrl.startsWith('/')))
) {
// Add prefix in preview mode for page navigation or custom URL starting
// with `/`
return `/builder/${this.builder.id}/preview${this.originalUrl}`
} else {
return this.originalUrl
}
},
isExternalLink() {
return (
this.element.navigation_type === 'custom' &&
!this.originalUrl.startsWith('/')
)
},
},
methods: {
onClick(event) {
if (!this.url) {
event.preventDefault()
} else if (
this.element.navigation_type === 'page' &&
this.element.target !== 'blank'
) {
event.preventDefault()
this.$router.push(this.url)
}
},
},
}
</script>

View file

@ -0,0 +1,65 @@
<template>
<div class="link-element" :class="classes">
<Button
v-if="element.variant === 'button'"
type="link"
v-bind="extraAttr"
:target="element.target"
:full-width="element.width === 'full'"
@click.prevent
>
{{ element.value || $t('linkElement.noValue') }}
</Button>
<a
v-else
class="link-element__link"
v-bind="extraAttr"
:target="`_${element.target}`"
@click.prevent
>
{{ element.value || $t('linkElement.noValue') }}
</a>
</div>
</template>
<script>
import textElement from '@baserow/modules/builder/mixins/elements/textElement'
import { LinkElementType } from '@baserow/modules/builder/elementTypes'
export default {
name: 'LinkElementEdit',
mixins: [textElement],
props: {
/**
* @type {LinkElement}
*/
element: {
type: Object,
required: true,
},
builder: { type: Object, required: true },
},
computed: {
classes() {
return {
[`link-element--alignment-${this.element.alignment}`]: true,
'element--no-value': !this.element.value,
}
},
extraAttr() {
const attr = {}
if (this.url) {
attr.href = this.url
}
return attr
},
url() {
try {
return LinkElementType.getUrlFromElement(this.element, this.builder)
} catch (e) {
return ''
}
},
},
}
</script>

View file

@ -20,9 +20,15 @@ import { generateHash } from '@baserow/modules/core/utils/hashing'
export default {
name: 'ParagraphElement',
mixins: [textElement],
props: {
element: {
type: Object,
required: true,
},
},
computed: {
paragraphs() {
return this.value
return this.element.value
.split('\n')
.map((line) => line.trim())
.filter((line) => line)

View file

@ -1,5 +1,5 @@
<template>
<form @submit.prevent="submit">
<form @submit.prevent>
<FormElement class="control">
<label class="control__label">
{{ $t('headingElementForm.levelTitle') }}

View file

@ -0,0 +1,256 @@
<template>
<form @submit.prevent>
<FormElement class="control">
<label class="control__label">
{{ $t('linkElementForm.text') }}
</label>
<div class="control__elements">
<input
v-model="values.value"
type="text"
class="input"
:placeholder="$t('linkElementForm.textPlaceholder')"
/>
</div>
</FormElement>
<FormElement class="control">
<label class="control__label">
{{ $t('linkElementForm.navigateTo') }}
</label>
<div class="control__elements">
<Dropdown v-model="navigateTo" :show-search="false">
<template #value>
<template v-if="destinationPage">
{{ destinationPage.name }}
<span class="link-element-form__navigate-option-page-path">
{{ destinationPage.path }}
</span></template
>
<span v-else>{{ $t('linkElementForm.navigateToCustom') }}</span>
</template>
<DropdownItem
v-for="page in pages"
:key="page.id"
:value="page.id"
:name="page.name"
>
{{ page.name }}
<span class="link-element-form__navigate-option-page-path">
{{ page.path }}
</span>
</DropdownItem>
<DropdownItem
:name="$t('linkElementForm.navigateToCustom')"
value="custom"
></DropdownItem>
</Dropdown>
</div>
</FormElement>
<FormElement v-if="navigateTo === 'custom'" class="control">
<label class="control__label">
{{ $t('linkElementForm.url') }}
</label>
<div class="control__elements">
<input
v-model="values.navigate_to_url"
type="text"
class="input"
:placeholder="$t('linkElementForm.urlPlaceholder')"
/>
</div>
</FormElement>
<FormElement
v-if="destinationPage"
class="control link-element-form__params"
>
<template v-if="parametersInError">
<Alert type="error" minimal>
<div class="link-element-form__params-error">
<div>
{{ $t('linkElementForm.paramsInErrorDescription') }}
</div>
<Button
color="error"
size="tiny"
@click.prevent="updatePageParameters"
>
{{ $t('linkElementForm.paramsInErrorButton') }}
</Button>
</div>
</Alert>
</template>
<div v-else class="control__elements link-element-form__params">
<template v-for="param in values.page_parameters">
<!-- eslint-disable-next-line vue/require-v-for-key -->
<label>{{ param.name }}</label>
<!-- eslint-disable-next-line vue/require-v-for-key -->
<input
v-model="param.value"
type="text"
class="input"
:placeholder="$t('linkElementForm.paramPlaceholder')"
/>
</template>
</div>
</FormElement>
<FormElement class="control">
<label class="control__label">
{{ $t('linkElementForm.variant') }}
</label>
<div class="control__elements">
<RadioButton v-model="values.variant" value="link">
{{ $t('linkElementForm.variantLink') }}
</RadioButton>
<RadioButton v-model="values.variant" value="button">
{{ $t('linkElementForm.variantButton') }}
</RadioButton>
</div>
</FormElement>
<FormElement class="control">
<label class="control__label">
{{ $t('linkElementForm.alignment') }}
</label>
<div class="control__elements">
<RadioButton
v-for="alignment in alignments"
:key="alignment.value"
v-model="values.alignment"
:value="alignment.value"
:icon="alignment.icon"
:title="alignment.name"
/>
</div>
</FormElement>
<FormElement class="control">
<label class="control__label">
{{ $t('linkElementForm.target') }}
</label>
<div class="control__elements">
<RadioButton v-model="values.target" value="self">
{{ $t('linkElementForm.targetSelf') }}
</RadioButton>
<RadioButton v-model="values.target" value="blank">
{{ $t('linkElementForm.targetNewTab') }}
</RadioButton>
</div>
</FormElement>
<FormElement v-if="values.variant === 'button'" class="control">
<label class="control__label">
{{ $t('linkElementForm.width') }}
</label>
<div class="control__elements">
<RadioButton v-model="values.width" value="auto">
{{ $t('linkElementForm.widthAuto') }}
</RadioButton>
<RadioButton v-model="values.width" value="full">
{{ $t('linkElementForm.widthFull') }}
</RadioButton>
</div>
</FormElement>
</form>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
import { LinkElementType } from '@baserow/modules/builder/elementTypes'
export default {
name: 'LinkElementForm',
mixins: [form],
props: { builder: { type: Object, required: true } },
data() {
let navigateTo = ''
if (this.defaultValues.navigation_type === 'page') {
if (this.defaultValues.navigate_to_page_id) {
navigateTo = this.defaultValues.navigate_to_page_id
}
} else {
navigateTo = 'custom'
}
return {
values: {
value: '',
alignment: 'left',
variant: 'link',
navigation_type: 'page',
navigate_to_page_id: null,
navigate_to_url: '',
page_parameters: [],
width: 'auto',
target: 'self',
},
parametersInError: false,
navigateTo,
alignments: [
{
name: this.$t('linkElementForm.alignmentLeft'),
value: 'left',
icon: 'align-left',
},
{
name: this.$t('linkElementForm.alignmentCenter'),
value: 'center',
icon: 'align-center',
},
{
name: this.$t('linkElementForm.alignmentRight'),
value: 'right',
icon: 'align-right',
},
],
}
},
computed: {
pages() {
return this.builder.pages
},
destinationPage() {
if (!isNaN(this.navigateTo)) {
return this.builder.pages.find(({ id }) => id === this.navigateTo)
}
return null
},
},
watch: {
'destinationPage.path_params': {
handler(value) {
this.updatePageParameters()
},
deep: true,
},
navigateTo(value) {
if (value === '') {
this.values.navigation_type = 'page'
this.values.navigate_to_page_id = null
this.values.navigate_to_url = ''
} else if (value === 'custom') {
this.values.navigation_type = 'custom'
} else if (!isNaN(value)) {
this.values.navigation_type = 'page'
this.values.navigate_to_page_id = value
this.updatePageParameters()
}
},
},
mounted() {
if (LinkElementType.arePathParametersInError(this.values, this.builder)) {
this.parametersInError = true
}
},
methods: {
updatePageParameters() {
this.values.page_parameters = (
this.destinationPage?.path_params || []
).map(({ name }, index) => {
let value = ''
// Naive way to keep data when we change the destination page.
if (this.values.page_parameters[index]) {
value = this.values.page_parameters[index].value
}
return { name, value }
})
this.parametersInError = false
},
},
}
</script>

View file

@ -1,5 +1,5 @@
<template>
<form @submit.prevent="submit">
<form @submit.prevent>
<FormElement class="control">
<label class="control__label">
{{ $t('paragraphElementForm.textTitle') }}

View file

@ -37,11 +37,8 @@
<script>
export default {
name: 'PageActions',
inject: ['builder'],
props: {
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,

View file

@ -4,14 +4,17 @@
:is="getType(element).component"
v-for="element in elements"
:key="element.id"
v-bind="getType(element).getComponentProps(element)"
:element="element"
class="element__component"
:builder="builder"
:mode="mode"
/>
</div>
</template>
<script>
export default {
inject: ['builder', 'mode'],
props: {
page: {
type: Object,

View file

@ -1,11 +1,11 @@
<template>
<header class="layout__col-2-1 header header--space-between">
<PageHeaderMenuItems :page="page" :builder="builder" />
<PageHeaderMenuItems :page="page" />
<DeviceSelector
:device-type-selected="deviceTypeSelected"
@selected="actionSetDeviceTypeSelected"
/>
<PageActions :builder="builder" :page="page" />
<PageActions :page="page" />
</header>
</template>
@ -23,10 +23,6 @@ export default {
PageActions,
},
props: {
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,

View file

@ -13,12 +13,7 @@
<i class="header__filter-icon fas" :class="`fa-${itemType.icon}`"></i>
<span class="header__filter-name">{{ itemType.label }}</span>
</a>
<component
:is="itemType.component"
ref="component"
:builder="builder"
:page="page"
/>
<component :is="itemType.component" ref="component" :page="page" />
</li>
</ul>
</template>
@ -31,10 +26,6 @@ export default {
type: Object,
required: true,
},
builder: {
type: Object,
required: true,
},
},
computed: {
pageHeaderItemTypes() {

View file

@ -37,11 +37,8 @@ export default {
name: 'PageSettings',
components: { PageSettingsForm },
mixins: [error],
inject: ['builder'],
props: {
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,

View file

@ -23,11 +23,7 @@
</ul>
</template>
<template v-if="settingSelected" #content>
<component
:is="settingSelected.component"
:builder="builder"
:page="page"
></component>
<component :is="settingSelected.component" :page="page"></component>
</template>
</Modal>
</template>
@ -39,10 +35,6 @@ export default {
name: 'PageSettingsModal',
mixins: [modal],
props: {
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,

View file

@ -10,15 +10,11 @@
<script>
import PageContent from '@baserow/modules/builder/components/page/PageContent'
import PublicBuilderService from '@baserow/modules/builder/services/publicBuilder.js'
import PublicBuilderService from '@baserow/modules/builder/services/publishedDomain.js'
export default {
components: { PageContent },
props: {
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,

View file

@ -85,7 +85,7 @@ import { mapActions, mapGetters } from 'vuex'
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import jobProgress from '@baserow/modules/core/mixins/jobProgress'
import BuilderService from '@baserow/modules/builder/services/publicBuilder.js'
import PublishedDomainService from '@baserow/modules/builder/services/publishedDomain.js'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { copyToClipboard } from '@baserow/modules/database/utils/clipboard'
import LastPublishedDomainDate from '@baserow/modules/builder/components/domain/LastPublishedDomainDate'
@ -134,7 +134,7 @@ export default {
async publishSite() {
this.loading = true
this.hideError()
const { data: job } = await BuilderService(this.$client).publish({
const { data: job } = await PublishedDomainService(this.$client).publish({
id: this.selectedDomain,
})

View file

@ -3,8 +3,9 @@
:is="elementType.formComponent"
:key="element.id"
ref="elementForm"
:default-values="defaultValues"
class="element-form"
:builder="builder"
:default-values="defaultValues"
@values-changed="onChange($event)"
/>
</template>
@ -12,13 +13,12 @@
<script>
import { mapActions, mapGetters } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { clone } from '@baserow/modules/core/utils/object'
import _ from 'lodash'
export default {
name: 'GeneralSidePanel',
data() {
return {}
},
inject: ['builder'],
computed: {
...mapGetters({
element: 'element/getSelected',
@ -32,7 +32,7 @@ export default {
},
defaultValues() {
return this.elementType.getComponentProps(this.element)
return this.element
},
},
methods: {
@ -40,11 +40,13 @@ export default {
actionDebouncedUpdateSelectedElement: 'element/debouncedUpdateSelected',
}),
async onChange(newValues) {
const oldValues = this.elementType.getComponentProps(this.element)
const oldValues = this.element
if (!_.isEqual(newValues, oldValues)) {
try {
await this.actionDebouncedUpdateSelectedElement({
values: newValues,
// Here we clone the values to prevent
// "modification oustide of the store" error
values: clone(newValues),
})
} catch (error) {
// Restore the previous saved values from the store

View file

@ -1,8 +1,15 @@
import { Registerable } from '@baserow/modules/core/registry'
import ParagraphElement from '@baserow/modules/builder/components/elements/components/ParagraphElement'
import HeadingElement from '@baserow/modules/builder/components/elements/components/HeadingElement'
import LinkElement from '@baserow/modules/builder/components/elements/components/LinkElement'
import LinkElementEdit from '@baserow/modules/builder/components/elements/components/LinkElementEdit'
import ParagraphElementForm from '@baserow/modules/builder/components/elements/components/forms/ParagraphElementForm'
import HeadingElementForm from '@baserow/modules/builder/components/elements/components/forms/HeadingElementForm'
import LinkElementForm from '@baserow/modules/builder/components/elements/components/forms/LinkElementForm'
import _ from 'lodash'
import { compile } from 'path-to-regexp'
export class ElementType extends Registerable {
get name() {
@ -21,30 +28,21 @@ export class ElementType extends Registerable {
return null
}
get editComponent() {
return this.component
}
get formComponent() {
return null
}
get properties() {
return []
}
/**
* Extracts the attributes of the element instance into attributes that the component
* can use. The returned object needs to be a mapping from the name of the property
* at the component level to the value in the element object.
*
* Example:
* - Let's say you have a prop called `level`
* - The element looks like this: { 'id': 'someId', 'level': 1 }
*
* Then you will have to return { 'level': element.level }
*
* @param element
* @returns {{}}
* Returns whether the element configuration is valid or not.
* @param {object} param An object containing the element and the builder
* @returns true if the element is in error
*/
getComponentProps(element) {
return {}
isInError({ element, builder }) {
return false
}
}
@ -72,13 +70,6 @@ export class HeadingElementType extends ElementType {
get formComponent() {
return HeadingElementForm
}
getComponentProps(element) {
return {
value: element.value,
level: element.level,
}
}
}
export class ParagraphElementType extends ElementType {
@ -105,10 +96,91 @@ export class ParagraphElementType extends ElementType {
get formComponent() {
return ParagraphElementForm
}
}
getComponentProps(element) {
return {
value: element.value,
export class LinkElementType extends ElementType {
getType() {
return 'link'
}
get name() {
return this.app.i18n.t('elementType.link')
}
get description() {
return this.app.i18n.t('elementType.linkDescription')
}
get iconClass() {
return 'link'
}
get component() {
return LinkElement
}
get editComponent() {
return LinkElementEdit
}
get formComponent() {
return LinkElementForm
}
isInError({ element, builder }) {
try {
LinkElementType.getUrlFromElement(element, builder)
} catch (e) {
// Error in path resolution
return true
}
return LinkElementType.arePathParametersInError(element, builder)
}
static arePathParametersInError(element, builder) {
if (
element.navigation_type === 'page' &&
!isNaN(element.navigate_to_page_id)
) {
const destinationPageParamNames = (
builder.pages.find(({ id }) => id === element.navigate_to_page_id)
?.path_params || []
).map(({ name }) => name)
const pageParams = element.page_parameters.map(({ name }) => name)
if (!_.isEqual(destinationPageParamNames, pageParams)) {
return true
}
}
return false
}
static getUrlFromElement(element, builder) {
if (element.navigation_type === 'page') {
if (!isNaN(element.navigate_to_page_id)) {
const page = builder.pages.find(
({ id }) => id === element.navigate_to_page_id
)
// The builder page list might be empty or the page has been deleted
if (!page) {
return ''
}
const toPath = compile(page.path, { encode: encodeURIComponent })
const pageParams = Object.fromEntries(
element.page_parameters.map(({ name, value }) => [name, value])
)
return toPath(pageParams)
}
} else if (!element.navigate_to_url.startsWith('http')) {
// add the https protocol if missing
return `https://${element.navigate_to_url}`
} else {
return element.navigate_to_url
}
return ''
}
}

View file

@ -57,7 +57,9 @@
"heading": "Heading",
"headingDescription": "Page heading title",
"paragraph": "Paragraph",
"paragraphDescription": "Single line text"
"paragraphDescription": "Single line text",
"link": "Link",
"linkDescription": "A link to page/URL"
},
"addElementButton": {
"label": "Element"
@ -125,6 +127,34 @@
"domainTypes": {
"customName": "Custom domain"
},
"linkElement": {
"noValue": "Unnamed..."
},
"linkElementForm": {
"text": "Text",
"textPlaceholder": "Enter text...",
"navigateTo": "Navigate to",
"navigateToNotSet": "No destination",
"navigateToCustom": "Custom URL",
"url": "Destination URL",
"urlPlaceholder": "Enter an URL...",
"variant": "Variant",
"variantLink": "Link",
"variantButton": "Button",
"alignment": "Alignment",
"alignmentLeft": "Link",
"alignmentCenter": "Center",
"alignmentRight": "Right",
"width": "Width",
"widthAuto": "Auto",
"widthFull": "Full width",
"target": "Open in...",
"targetSelf": "Same tab",
"targetNewTab": "New tab",
"paramPlaceholder": "Enter a value...",
"paramsInErrorDescription": "The saved parameters don't match the page parameters. The page has probably been deleted or updated.",
"paramsInErrorButton": "Update parameters"
},
"pageSettingsTypes": {
"pageName": "Page"
},

View file

@ -1,6 +1,6 @@
<template>
<div class="page-editor">
<PageHeader :builder="builder" :page="page" />
<PageHeader :page="page" />
<div class="layout__col-2-2 page-editor__content">
<div :style="{ width: `calc(100% - ${panelWidth}px)` }">
<PagePreview />
@ -21,6 +21,9 @@ import PageSidePanels from '@baserow/modules/builder/components/page/PageSidePan
export default {
name: 'PageEditor',
components: { PagePreview, PageHeader, PageSidePanels },
provide() {
return { builder: this.builder }
},
/**
* When the user leaves to another page we want to unselect the selected page. This
* way it will not be highlighted the left sidebar.

View file

@ -1,5 +1,5 @@
<template>
<PublicPage :builder="builder" :page="page" :path="path" :params="params" />
<PublicPage :page="page" :path="path" :params="params" />
</template>
<script>
@ -8,13 +8,16 @@ import { resolveApplicationRoute } from '@baserow/modules/builder/utils/routing'
export default {
components: { PublicPage },
provide() {
return { builder: this.builder, mode: this.mode }
},
async asyncData(context) {
let builder = context.store.getters['publicBuilder/getBuilder']
let mode = 'public'
const builderId = context.route.params.builderId
if (!builder) {
try {
const builderId = context.route.params.builderId
if (builderId) {
// We have the builderId in the params so this is a preview
// Must fetch the builder instance by this Id.
@ -42,6 +45,10 @@ export default {
}
}
if (builderId) {
mode = 'preview'
}
const found = resolveApplicationRoute(
builder.pages,
context.route.params.pathMatch
@ -62,6 +69,7 @@ export default {
page,
path,
params,
mode,
}
},
}

View file

@ -20,6 +20,7 @@ import { registerRealtimeEvents } from '@baserow/modules/builder/realtime'
import {
HeadingElementType,
ParagraphElementType,
LinkElementType,
} from '@baserow/modules/builder/elementTypes'
import {
DesktopDeviceType,
@ -103,6 +104,7 @@ export default (context) => {
app.$registry.register('element', new HeadingElementType(context))
app.$registry.register('element', new ParagraphElementType(context))
app.$registry.register('element', new LinkElementType(context))
app.$registry.register('device', new DesktopDeviceType(context))
app.$registry.register('device', new TabletDeviceType(context))

View file

@ -56,7 +56,10 @@ const actions = {
forceUpdate({ commit }, { element, values }) {
commit('UPDATE_ITEM', { element, values })
},
forceDelete({ commit }, { elementId }) {
forceDelete({ commit, getters }, { elementId }) {
if (getters.getSelected.id === elementId) {
commit('SELECT_ITEM', { element: null })
}
commit('DELETE_ITEM', { elementId })
},
forceMove({ commit, getters }, { elementId, beforeElementId }) {

View file

@ -89,6 +89,15 @@ const actions = {
commit('SET_SELECTED', { builder, page })
// Unselect previously selected element
dispatch(
'element/select',
{
element: null,
},
{ root: true }
)
return { builder, page }
},
unselect({ commit }) {

View file

@ -1,4 +1,4 @@
import PublicBuilderService from '@baserow/modules/builder/services/publicBuilder'
import PublishedDomainService from '@baserow/modules/builder/services/publishedDomain'
const state = {
// The public builder loaded
@ -18,7 +18,7 @@ const actions = {
async fetchById({ commit }, { builderId }) {
commit('CLEAR_ITEM')
const { data } = await PublicBuilderService(this.$client).fetchById(
const { data } = await PublishedDomainService(this.$client).fetchById(
builderId
)
commit('SET_ITEM', { builder: data })
@ -27,7 +27,7 @@ const actions = {
async fetchByDomain({ commit }, { domain }) {
commit('CLEAR_ITEM')
const { data } = await PublicBuilderService(this.$client).fetchByDomain(
const { data } = await PublishedDomainService(this.$client).fetchByDomain(
domain
)
commit('SET_ITEM', { builder: data })

View file

@ -1,4 +1,4 @@
import { pathToRegexp } from 'path-to-regexp'
import pathToRegexp from 'path-to-regexp'
export const resolveApplicationRoute = (pages, fullPath) => {
let found

View file

@ -9,7 +9,9 @@
@import "page";
@import "elements/headingElement";
@import "elements/paragraphElement";
@import "elements/linkElement";
@import "elements/forms/paragraphElementForm";
@import "elements/forms/linkElementForm";
@import "page_settings_path_params_form_element";
@import "domain_card";
@import "dns_status";

View file

@ -138,3 +138,20 @@
.element--no-value {
opacity: 0.3;
}
.element--in-error::after {
@extend .fas;
@include fa-icon;
@include absolute(0, 0, auto, auto);
content: fa-content($fa-var-exclamation-circle);
pointer-events: none;
width: 20px;
height: 20px;
line-height: 20px;
font-size: 20px;
margin-right: 5px;
margin-top: 5px;
color: $color-error-300;
}

View file

@ -0,0 +1,12 @@
.link-element-form__params {
display: grid;
grid-template-columns: auto auto;
grid-gap: 10px;
align-items: center;
}
.link-element-form__navigate-option-page-path {
font-size: 12px;
color: $color-neutral-500;
margin-left: 5px;
}

View file

@ -2,3 +2,11 @@
resize: vertical;
color: $color-primary-900;
}
.link-element-form__params-error {
display: flex;
flex-direction: column;
gap: 10px;
text-align: center;
align-items: center;
}

View file

@ -0,0 +1,37 @@
.link-element {
display: flex;
padding: 5px 82px;
}
.link-element--alignment-left {
justify-content: start;
.button--full-width {
text-align: left;
}
}
.link-element--alignment-center {
justify-content: center;
.button--full-width {
text-align: center;
}
}
.link-element--alignment-right {
justify-content: end;
.button--full-width {
text-align: right;
}
}
.link-element__link {
font-size: 14px;
font-weight: 700;
padding: 0;
height: 32px;
line-height: 32px;
border: 1px solid transparent;
}

View file

@ -60,6 +60,27 @@
border-color: $color-neutral-400;
}
.button--light {
@include button-style(transparent, $color-neutral-100, $color-primary-900);
&:hover,
&:focus,
&:active,
&.button-loading {
text-decoration: none;
box-shadow: none;
background-color: $color-neutral-100;
}
&.button--loading::after {
border-color: $color-neutral-900 transparent $color-neutral-900 transparent;
}
&.active {
background-color: $color-primary-100;
}
}
.button__icon {
margin: 0 2px;
@ -104,7 +125,6 @@
.button--full-width {
@include button-size(15px, 44px, 28px);
margin-bottom: 16px;
width: 100%;
font-weight: 400;
}

View file

@ -23,7 +23,6 @@
}
&.alert--minimal {
max-width: 228px;
white-space: normal;
margin: 16px 0;
padding: 14px;

View file

@ -0,0 +1,124 @@
<template>
<component
:is="type === 'link' || href ? 'a' : 'button'"
class="button"
:class="classes"
:disable="disabled"
:active="active"
v-bind.prop="customBind"
v-on="$listeners"
>
<i
v-if="prependIcon"
class="button__icon fas"
:class="`fa-${prependIcon}`"
/>
<slot />
<i
v-if="appendIcon || icon"
class="button__icon fas"
:class="appendIcon ? `fa-${appendIcon}` : `fa-${icon}`"
/>
</component>
</template>
<script>
export default {
props: {
type: {
// link - button
required: false,
type: String,
default: 'button',
},
size: {
// tiny - normal - large
required: false,
type: String,
default: '',
},
color: {
// primary - success - warning - error - ghost - light
required: false,
type: String,
default: '',
},
prependIcon: {
required: false,
type: String,
default: '',
},
appendIcon: {
required: false,
type: String,
default: '',
},
icon: {
required: false,
type: String,
default: '',
},
loading: {
required: false,
type: Boolean,
default: false,
},
disabled: {
required: false,
type: Boolean,
default: false,
},
fullWidth: {
required: false,
type: Boolean,
default: false,
},
active: {
required: false,
type: Boolean,
default: false,
},
overflow: {
required: false,
type: Boolean,
default: false,
},
href: {
required: false,
type: String,
default: '',
},
target: {
required: false,
type: String,
default: 'self',
},
},
computed: {
classes() {
const classObj = {
[`button--${this.size}`]: this.size,
[`button--${this.color}`]: this.color,
'button--primary': !this.color,
'button--full-width': this.fullWidth,
'button--icon': this.prependIcon || this.appendIcon || this.icon,
'button--loading': this.loading,
disabled: this.disabled,
active: this.active,
'button--overflow': this.overflow,
}
return classObj
},
customBind() {
const attr = {}
if (this.href) {
attr.href = this.href
}
if (this.target) {
attr.target = `_${this.target}`
}
return attr
},
},
}
</script>

View file

@ -0,0 +1,47 @@
<template>
<Button color="light" v-bind="restProps" @click.prevent="select(value)">
<slot></slot>
</Button>
</template>
<script>
export default {
name: 'RadioButton',
model: {
prop: 'modelValue',
event: 'input',
},
props: {
value: {
type: [String, Number, Boolean, Object],
required: false,
default: '',
},
modelValue: {
type: [String, Number, Boolean, Object],
required: false,
default: '',
},
},
computed: {
selected() {
return this.modelValue === this.value
},
restProps() {
const { value, modelValue, ...rest } = this.$attrs
if (this.selected) {
rest.active = true
}
return rest
},
},
methods: {
select(value) {
if (this.disabled || this.selected) {
return
}
this.$emit('input', value)
},
},
}
</script>

View file

@ -23,7 +23,7 @@
:href="getHref(index)"
class="tabs__link"
:class="{ 'tabs__link--disabled': tab.disabled }"
@click.prevent=""
@click.prevent
>
{{ tab.title }}
</a>

View file

@ -45,7 +45,7 @@ export default {
*/
getDefaultValues() {
if (this.allowedValues === null) {
return this.defaultValues
return clone(this.defaultValues)
}
return Object.keys(this.defaultValues).reduce((result, key) => {
if (this.allowedValues.includes(key)) {

View file

@ -144,6 +144,77 @@
<Radio v-model="radio" value="e" :loading="true">Option E</Radio>
</div>
</div>
<div class="control">
<label class="control__label">Radio buttons</label>
<div
class="control__elements"
:style="{ backgroundColor: 'white', padding: '5px' }"
>
value: {{ radioButton }}
<br />
<br />
<RadioButton v-model="radioButton" value="">None</RadioButton>
<RadioButton v-model="radioButton" value="a"
>Option A</RadioButton
>
<RadioButton v-model="radioButton" value="b"
>Option B</RadioButton
>
<RadioButton v-model="radioButton" value="c"
>Option C</RadioButton
>
<RadioButton v-model="radioButton" value="g" :disabled="true">
Option D
</RadioButton>
<RadioButton v-model="radioButton" value="h" :loading="true">
Option E
</RadioButton>
</div>
<div
class="control__elements"
:style="{ backgroundColor: 'white', padding: '5px' }"
>
<RadioButton
v-model="radioButton"
value="d"
icon="align-left"
></RadioButton>
<RadioButton
v-model="radioButton"
value="e"
icon="align-center"
></RadioButton>
<RadioButton
v-model="radioButton"
value="f"
icon="align-right"
></RadioButton>
</div>
<div
class="control__elements"
:style="{ backgroundColor: 'white', padding: '5px' }"
>
<RadioButton
v-model="radioButton"
value="d"
icon="align-left"
size="large"
></RadioButton>
<RadioButton
v-model="radioButton"
value="e"
icon="align-center"
size="large"
></RadioButton>
<RadioButton
v-model="radioButton"
value="f"
icon="align-right"
size="large"
></RadioButton>
</div>
</div>
<div class="control">
<label class="control__label">Switch field</label>
<div class="control__elements">
@ -408,100 +479,86 @@
urna. Praesent.
</Alert>
</div>
<div class="margin-bottom-3 style-guide__buttons">
<a class="button">a.button</a>
<a class="button disabled">a.button[disabled]</a>
<a class="button button--success">a.button.button-success</a>
<a class="button button--warning">a.button.button-warning</a>
<a class="button button--error">a.button.button-error</a>
<a class="button">
a.button
<i class="button__icon fas fa-lock-open"></i>
</a>
<a class="button">
<i class="button__icon fas fa-arrow-left"></i>
a.button
</a>
<a class="button button--ghost">a.button.button-ghosts</a>
<a class="button">
<i class="fas fa-user-check"></i>
</a>
<a class="button button--ghost">
<i class="fas fa-user-check"></i>
</a>
<h3>Tiny size</h3>
<Button size="tiny">button</Button>
<Button size="tiny" disabled>button[disabled]</Button>
<Button size="tiny" color="primary">button.button-success</Button>
<Button size="tiny" color="success">button.button-success</Button>
<Button size="tiny" color="warning">button.button-warning</Button>
<Button size="tiny" color="error">button.button-error</Button>
<Button size="tiny" append-icon="lock-open">button</Button>
<Button size="tiny" prepend-icon="arrow-left">button</Button>
<Button size="tiny" color="ghost">button.button-ghost</Button>
<Button size="tiny" icon="user-check"></Button>
<Button size="tiny" icon="user-check" color="ghost"></Button>
</div>
<div class="margin-bottom-3 style-guide__buttons">
<button class="button">button.button</button>
<button class="button" disabled>button.button[disabled]</button>
<button class="button button--success">
button.button.button-success
</button>
<button class="button button--warning">
button.button.button-warning
</button>
<button class="button button--error">
button.button.button-error
</button>
<button class="button">
button.button
<i class="button__icon fas fa-lock-open"></i>
</button>
<button class="button">
<i class="button__icon fas fa-arrow-left"></i>
button.button
</button>
<button class="button button--ghost">
button.button.ghost-button
</button>
<button class="button">
<i class="fas fa-user-check"></i>
</button>
<button class="button button--ghost">
<i class="fas fa-user-check"></i>
</button>
<h3>Normal link</h3>
<Button type="link" href="#">a.button</Button>
<Button type="link" disabled href="#">a.button[disabled]</Button>
<Button type="link" color="success" href="#">
a.button.button-success
</Button>
<Button type="link" color="warning">a.button.button-warning</Button>
<Button type="link" color="error">a.button.button-error</Button>
<Button type="link" append-icon="lock-open">a.button</Button>
<Button type="link" prepend-icon="arrow-left">a.button</Button>
<Button type="link" color="ghost">a.button.button-ghost</Button>
<Button type="link" icon="user-check"></Button>
<Button type="link" icon="user-check" color="ghost"></Button>
</div>
<div
class="margin-bottom-3 style-guide__buttons"
:style="{ backgroundColor: 'white', padding: '5px' }"
>
<h3>Normal button</h3>
<Button>button.button</Button>
<Button disabled>button[disabled]</Button>
<Button color="success">button.button-success</Button>
<Button color="warning">button.button-warning</Button>
<Button color="error">button.button-error</Button>
<Button append-icon="lock-open">button</Button>
<Button prepend-icon="arrow-left">button</Button>
<Button color="ghost">button.button-ghost</Button>
<Button icon="user-check"></Button>
<Button icon="user-check" color="ghost"></Button>
<Button color="light">button.light</Button>
<Button color="light" active>button.light.active</Button>
</div>
<div class="margin-bottom-3 style-guide__buttons">
<a class="button button--large">a.button.button-large</a>
<a class="button button--large disabled"
>a.button.disabled.button-large</a
>
<a class="button button--large button--success"
>a.button.button-large.button-success</a
>
<a class="button button--large button--warning"
>a.button.button-large.button-warning</a
>
<a class="button button--large button--error"
>a.button.button-large.button-error</a
>
<a class="button button--large">
a.button.button-large
<i class="button__icon fas fa-lock-open"></i>
</a>
<a class="button button--large">
<i class="button__icon fas fa-arrow-left"></i>
a.button.button-large
</a>
<a class="button button--large button--ghost"
>a.button.button-ghosts</a
>
<a class="button button--large">
<i class="fas fa-user-check"></i>
</a>
<a class="button button--large button--ghost">
<i class="fas fa-user-check"></i>
</a>
<h3>Large size</h3>
<Button size="large">button</Button>
<Button size="large" disabled>button[disabled]</Button>
<Button size="large" color="success">button.button-success</Button>
<Button size="large" color="warning">button.button-warning</Button>
<Button size="large" color="error">button.button-error</Button>
<Button size="large" append-icon="lock-open">button</Button>
<Button size="large" prepend-icon="arrow-left">button</Button>
<Button size="large" color="ghost">button.button-ghost</Button>
<Button size="large" icon="user-check"></Button>
<Button size="large" icon="user-check" color="ghost"></Button>
</div>
<div class="margin-bottom-3">
<a class="button button--large button--loading">Loading</a>
<a class="button button--loading">Loading</a>
<a class="button button--ghost button--loading">Loading</a>
<div
class="margin-bottom-3"
:style="{ backgroundColor: 'white', padding: '5px' }"
>
<h3>Loading</h3>
<Button size="tiny" loading>Loading</Button>
<Button loading>Loading</Button>
<Button size="large" loading>Loading</Button>
<Button size="large" color="ghost" loading>Loading</Button>
<Button size="large" icon="user-check" loading></Button>
<Button color="light" loading>button.light.loading</Button>
<Button color="light" active loading>
button.light.active.loading
</Button>
</div>
<div
class="margin-bottom-3"
style="background-color: #ffffff; padding: 20px"
>
<h3>Classic tabs</h3>
<Tabs>
<Tab :selected="true" :title="'Tab 1'">
<p>
@ -532,6 +589,41 @@
</Tab>
</Tabs>
</div>
<div
class="margin-bottom-3"
style="background-color: #ffffff; padding: 20px"
>
<h3>No separation</h3>
<Tabs no-separation>
<Tab :selected="true" :title="'Tab 1'">
<p>
Tab 1 content Lorem ipsum dolor sit amet,
<a href="#">consectetur</a> adipiscing elit. Sed quis gravida
ante. Nulla nec elit dui. Nam nec dui ligula. Pellentesque
feugiat erat vel porttitor euismod. Duis nec viverra urna.
Praesent.
</p>
</Tab>
<Tab :title="'Tab 2'">
<p>
Tab 2 content Lorem ipsum dolor sit amet,
<a href="#">consectetur</a> adipiscing elit. Sed quis gravida
ante. Nulla nec elit dui. Nam nec dui ligula. Pellentesque
feugiat erat vel porttitor euismod. Duis nec viverra urna.
Praesent.
</p>
</Tab>
<Tab :title="'Tab 3'">
<p>
Tab 3 content Lorem ipsum dolor sit amet,
<a href="#">consectetur</a> adipiscing elit. Sed quis gravida
ante. Nulla nec elit dui. Nam nec dui ligula. Pellentesque
feugiat erat vel porttitor euismod. Duis nec viverra urna.
Praesent.
</p>
</Tab>
</Tabs>
</div>
<div class="margin-bottom-3">
<div class="tooltip margin-bottom-2">
@ -909,10 +1001,12 @@
</div>
</div>
<div class="margin-bottom-3">
<a class="button" @click="$refs.context1.toggle($event.target)">
toggle context
<i class="fas fa-pencil"></i>
</a>
<Button
append-icon="pencil"
@click="$refs.context1.toggle($event.target)"
>
Toggle context
</Button>
<Context ref="context1">
<div class="context__menu-title">Vehicles</div>
<ul class="context__menu">
@ -937,13 +1031,11 @@
</li>
<Modal ref="modal1">
<h2 class="box__title">Modal inside a context</h2>
<a
class="button"
<Button
icon="pencil"
@click="$refs.context3.toggle($event.target)"
>Toggle context</Button
>
toggle context
<i class="fas fa-pencil"></i>
</a>
<Context ref="context3">
<div class="context__menu-title">Vehicles</div>
<ul class="context__menu">
@ -985,7 +1077,7 @@
</li>
</ul>
</Context>
<a class="button" @click="$refs.modal2.show()">show modal</a>
<Button @click="$refs.modal2.show()">show modal</Button>
<Modal ref="modal2">
<h2 class="box__title">An example modal</h2>
<p>
@ -1002,46 +1094,49 @@
fringilla. Praesent ut tincidunt dui.
</p>
</Modal>
<a
class="button button--success"
<Button
color="success"
@click="
$store.dispatch('notification/success', {
title: 'Custom success notification',
message: 'Mauris dignissim massa ac justo consequat porttitor.',
})
"
>toggle success notification</a
>
<a
class="button button--error"
toggle success notification
</Button>
<Button
color="error"
@click="
$store.dispatch('notification/error', {
title: 'Custom error notification',
message: 'Mauris dignissim massa ac justo consequat porttitor.',
})
"
>toggle error notification</a
>
<a
class="button button--warning"
toggle error notification
</Button>
<Button
color="warning"
@click="
$store.dispatch('notification/warning', {
title: 'Custom warning notification',
message: 'Mauris dignissim massa ac justo consequat porttitor.',
})
"
>toggle warning notification</a
>
<a
class="button"
toggle warning notification
</Button>
<Button
@click="
$store.dispatch('notification/info', {
title: 'Custom info notification',
message: 'Mauris dignissim massa ac justo consequat porttitor.',
})
"
>toggle info notification</a
>
toggle info notification
</Button>
</div>
<div class="margin-bottom-3">
<div class="modal__box">
@ -1089,9 +1184,7 @@
</ul>
</div>
<div class="actions actions--right">
<button class="button button--large button--overflow">
Validate
</button>
<Button size="large" overflow>Validate</Button>
</div>
</div>
<div class="modal__box modal__box--with-sidebar">
@ -1350,9 +1443,9 @@
/>
<i class="fas fa-search"></i>
</div>
<a href="#" class="button button--large margin-left-2"
>Invite member</a
>
<Button href="#" size="large" class="margin-left-2">
Invite member
</Button>
</div>
</div>
<div class="data-table__body">
@ -1504,6 +1597,7 @@ export default {
return {
checkbox: false,
radio: 'a',
radioButton: 'a',
switchValue: false,
switchUnknown: 2,
dropdown: '',

View file

@ -21,6 +21,7 @@ import Tabs from '@baserow/modules/core/components/Tabs'
import Tab from '@baserow/modules/core/components/Tab'
import List from '@baserow/modules/core/components/List'
import HelpIcon from '@baserow/modules/core/components/HelpIcon'
import Button from '@baserow/modules/core/components/Button'
import lowercase from '@baserow/modules/core/filters/lowercase'
import uppercase from '@baserow/modules/core/filters/uppercase'
@ -37,6 +38,7 @@ import clickOutside from '@baserow/modules/core/directives/clickOutside'
import Badge from '@baserow/modules/core/components/Badge'
import InputWithIcon from '@baserow/modules/core/components/InputWithIcon'
import ExpandableCard from '@baserow/modules/core/components/ExpandableCard'
import RadioButton from '@baserow/modules/core/components/RadioButton'
function setupVue(Vue) {
Vue.component('Context', Context)
@ -63,6 +65,8 @@ function setupVue(Vue) {
Vue.component('Badge', Badge)
Vue.component('InputWithIcon', InputWithIcon)
Vue.component('ExpandableCard', ExpandableCard)
Vue.component('Button', Button)
Vue.component('RadioButton', RadioButton)
Vue.filter('lowercase', lowercase)
Vue.filter('uppercase', uppercase)

View file

@ -1,34 +0,0 @@
import * as elementTypes from '@baserow/modules/builder/elementTypes'
const getPropsOfComponent = (component) => {
let props = Object.keys(component.props || [])
if (component.mixins) {
component.mixins.forEach((mixin) => {
props = props.concat(Object.keys(mixin.props || []))
})
}
return props
}
describe('elementTypes', () => {
test.each(Object.values(elementTypes))(
'test that properties mapped for the element type exist on the component as prop',
(ElementType) => {
const elementType = new ElementType(expect.anything())
if (elementType.component) {
const propsInMapping = Object.keys(
elementType.getComponentProps(expect.anything())
)
const propsOnComponent = getPropsOfComponent(elementType.component)
propsInMapping.forEach((prop) => {
expect(propsOnComponent).toContain(prop)
})
}
}
)
})