1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 17:18:33 +00:00

Merge branch 'page-path-frontend' into 'develop'

Page path frontend

See merge request 
This commit is contained in:
Jrmi 2023-04-11 13:41:43 +00:00
commit 1fc9d5bff9
35 changed files with 1108 additions and 308 deletions

View file

@ -2,17 +2,23 @@ from rest_framework import serializers
from baserow.contrib.builder.pages.constants import PAGE_PATH_PARAM_TYPE_CHOICES
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.pages.validators import path_params_validation
from baserow.contrib.builder.pages.validators import path_param_name_validation
class PathParamSerializer(serializers.Serializer):
param_type = serializers.ChoiceField(choices=PAGE_PATH_PARAM_TYPE_CHOICES)
name = serializers.CharField(
required=True,
validators=[path_param_name_validation],
help_text="The name of the parameter.",
max_length=255,
)
type = serializers.ChoiceField(
choices=PAGE_PATH_PARAM_TYPE_CHOICES, help_text="The type of the parameter."
)
class PageSerializer(serializers.ModelSerializer):
path_params = serializers.DictField(
child=PathParamSerializer(), required=False, validators=[path_params_validation]
)
path_params = PathParamSerializer(many=True, required=False)
class Meta:
model = Page
@ -25,9 +31,8 @@ class PageSerializer(serializers.ModelSerializer):
class CreatePageSerializer(serializers.ModelSerializer):
path_params = serializers.DictField(
child=PathParamSerializer(), required=False, validators=[path_params_validation]
)
path_params = PathParamSerializer(many=True, required=False)
class Meta:
model = Page
@ -35,9 +40,8 @@ class CreatePageSerializer(serializers.ModelSerializer):
class UpdatePageSerializer(serializers.ModelSerializer):
path_params = serializers.DictField(
child=PathParamSerializer(), required=False, validators=[path_params_validation]
)
path_params = PathParamSerializer(many=True, required=False)
class Meta:
model = Page

View file

@ -0,0 +1,27 @@
# Generated by Django 3.2.18 on 2023-04-06 09:30
from django.db import migrations
def migrate_params(apps, schema_editor):
Page = apps.get_model("builder", "Page")
for page in Page.objects.all():
old_path_params = page.path_params
if isinstance(old_path_params, dict):
page.path_params = [
{"name": key, "type": value["param_type"]}
for key, value in old_path_params.items()
]
page.save()
class Migration(migrations.Migration):
dependencies = [
("builder", "0007_add_path_to_page"),
]
operations = [
migrations.RunPython(migrate_params, reverse_code=migrations.RunPython.noop),
]

View file

@ -10,5 +10,12 @@ PAGE_PATH_PARAM_TYPE_CHOICES = list(
typing.get_args(PAGE_PATH_PARAM_TYPE_CHOICES_LITERAL)
)
# This regex needs to match the regex in `getPathParams` in the frontend
# (builder/utils/path.js)
PATH_PARAM_REGEX = re.compile("(:[A-Za-z0-9_]+)")
PATH_PARAM_EXACT_MATCH_REGEX = re.compile("(^:[A-Za-z0-9_]+)$")
# This constant can be used to be inserted into a path temporarily as a unique
# placeholder since we already know the character can't be in the path (given it's
# illegal) we can establish uniqueness.
ILLEGAL_PATH_SAMPLE_CHARACTER = "^"

View file

@ -5,6 +5,7 @@ from django.db.models import QuerySet
from baserow.contrib.builder.models import Builder
from baserow.contrib.builder.pages.constants import (
ILLEGAL_PATH_SAMPLE_CHARACTER,
PAGE_PATH_PARAM_PREFIX,
PATH_PARAM_REGEX,
)
@ -68,6 +69,7 @@ class PageHandler:
path_params = path_params or {}
self.is_page_path_valid(path, path_params, raises=True)
self.is_page_path_unique(builder, path, raises=True)
try:
page = Page.objects.create(
@ -109,6 +111,14 @@ class PageHandler:
path_params = kwargs.get("path_params", page.path_params)
self.is_page_path_valid(path, path_params, raises=True)
self.is_page_path_unique(
page.builder,
path,
base_queryset=Page.objects.exclude(
id=page.id
), # We don't want to conflict with the current page
raises=True,
)
for key, value in kwargs.items():
setattr(page, key, value)
@ -219,7 +229,7 @@ class PageHandler:
self, path: str, path_params: PagePathParams, raises: bool = False
) -> bool:
"""
Checks if a path object is constructed correctly. If there is a missmatch
Checks if a path object is constructed correctly. If there is a mismatch
between the path itself and the path params for example, it becomes an invalid
path.
@ -232,7 +242,7 @@ class PageHandler:
:return: If the path is valid
"""
path_param_names = path_params.keys()
path_param_names = [p["name"] for p in path_params]
# Make sure all path params are also in the path
for path_param_name in path_param_names:
@ -263,3 +273,59 @@ class PageHandler:
return False
return True
def is_page_path_unique(
self,
builder: Builder,
path: str,
base_queryset: QuerySet = None,
raises: bool = False,
) -> bool:
"""
Checks if a page path is unique.
:param builder: The builder that the page belongs to
:param path: The path it is trying to set
:param raises: If true will raise an exception when the path isn't unique
:return: If the path is unique
"""
queryset = base_queryset or Page.objects
existing_paths = queryset.filter(builder=builder).values_list("path", flat=True)
path_generalised = self.generalise_path(path)
for existing_path in existing_paths:
if self.generalise_path(existing_path) == path_generalised:
if raises:
raise PagePathNotUnique(path=path, builder_id=builder.id)
return False
return True
def generalise_path(self, path: str) -> str:
"""
Returns a generalised version of a path. This can be useful if we are trying to
understand if 2 paths are equivalent even if their path params have different
names.
For 2 paths to be equivalent they need to have the same static parts of the path
and the same amount and position of path parameters.
Equivalent:
/product/:id, /product/:new
/product/:id/hello/:new, /product/:new/hello/:id
Not equivalent:
/product/:id, /product/:id/:new
/product/:id/hello/:new, /product/:id/:new/hello
By replacing all the path parameters in the path with an illegal path character
we can make sure that we can match 2 paths and, they will be the same string if
they are indeed a duplicate given the above rules.
:param path: The path that is being generalised
:return: The generalised path
"""
return PATH_PARAM_REGEX.sub(ILLEGAL_PATH_SAMPLE_CHARACTER, path)

View file

@ -1,11 +1,11 @@
from typing import Dict, Literal, TypedDict
from typing import List, Literal, TypedDict
PAGE_PATH_PARAM_TYPE_CHOICES_LITERAL = Literal["text", "numeric"]
class PagePathParam(TypedDict):
name: str
param_type: Literal[PAGE_PATH_PARAM_TYPE_CHOICES_LITERAL]
param_name = str
PagePathParams = Dict[param_name, PagePathParam]
PagePathParams = List[PagePathParam]

View file

@ -5,7 +5,6 @@ from baserow.contrib.builder.pages.constants import (
PAGE_PATH_PARAM_PREFIX,
PATH_PARAM_EXACT_MATCH_REGEX,
)
from baserow.contrib.builder.pages.types import PagePathParams
def path_validation(value: str):
@ -28,18 +27,15 @@ def path_validation(value: str):
validator(full_path)
def path_params_validation(value: PagePathParams):
def path_param_name_validation(value: str):
"""
Verifies that all path params are semantically valid.
Verifies that the path param is semantically valid.
:param value: The path params to check
:raises ValidationError: If a path param is not semantically valid
:param value: The path param to check
:raises ValidationError: If the path param is not semantically valid
"""
for path_param in value.keys():
full_path_param = f"{PAGE_PATH_PARAM_PREFIX}{path_param}"
full_path_param = f"{PAGE_PATH_PARAM_PREFIX}{value}"
if not PATH_PARAM_EXACT_MATCH_REGEX.match(full_path_param):
raise ValidationError(
f"Path param {path_param} contains invalid characters"
)
if not PATH_PARAM_EXACT_MATCH_REGEX.match(full_path_param):
raise ValidationError(f"Path param {value} contains invalid characters")

View file

@ -17,7 +17,7 @@ def test_create_page(api_client, data_fixture):
name = "test"
path = "/test/:id/"
path_params = {"id": {"param_type": "text"}}
path_params = [{"name": "id", "type": "text"}]
url = reverse(
"api:builder:builder_id:pages:list", kwargs={"builder_id": builder.id}
@ -103,7 +103,7 @@ def test_create_page_invalid_page_path_param(api_client, data_fixture):
{
"name": "test",
"path": "/test/:id",
"path_params": {"id": {"param_type": "unsupported"}},
"path_params": [{"name": "id", "type": "unsupported"}],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
@ -126,7 +126,7 @@ def test_create_page_invalid_page_path_param_key(api_client, data_fixture):
{
"name": "test",
"path": "/test/:id",
"path_params": {"test": {"test": "hello"}},
"path_params": [{"name": "test", "test": "hello"}],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
@ -151,7 +151,7 @@ def test_create_page_invalid_page_path_param_semantics(api_client, data_fixture)
{
"name": "test",
"path": "/test/:^test",
"path_params": {"^test": {"param_type": "text"}},
"path_params": [{"name": "^test", "type": "text"}],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
@ -160,7 +160,7 @@ def test_create_page_invalid_page_path_param_semantics(api_client, data_fixture)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert "^test" in response_json["detail"]["path_params"][0]["error"]
assert "^test" in response_json["detail"]["path_params"][0]["name"][0]["error"]
@pytest.mark.django_db
@ -209,6 +209,32 @@ def test_create_page_duplicate_page_path(api_client, data_fixture):
assert response.json()["error"] == "ERROR_PAGE_PATH_NOT_UNIQUE"
@pytest.mark.django_db
def test_create_page_duplicate_page_path_advanced(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
builder = data_fixture.create_builder_application(user=user)
path_params = [
{"name": "new", "type": "text"},
{"name": "id", "type": "text"},
]
data_fixture.create_builder_page(
builder=builder, path="/test/:id/hello/:new", path_params=path_params
)
url = reverse(
"api:builder:builder_id:pages:list", kwargs={"builder_id": builder.id}
)
response = api_client.post(
url,
{"name": "test", "path": "/test/:new/hello/:id", "path_params": path_params},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_PAGE_PATH_NOT_UNIQUE"
@pytest.mark.django_db
def test_create_page_path_param_not_in_path(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
@ -222,7 +248,7 @@ def test_create_page_path_param_not_in_path(api_client, data_fixture):
{
"name": "test",
"path": "/test/test",
"path_params": {"id": {"param_type": "text"}},
"path_params": [{"name": "id", "type": "text"}],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
@ -245,7 +271,7 @@ def test_create_page_path_param_not_defined(api_client, data_fixture):
{
"name": "test",
"path": "/test/:id",
"path_params": {},
"path_params": [],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
@ -291,7 +317,7 @@ def test_create_page_duplicate_path_params_in_path(api_client, data_fixture):
{
"name": "test",
"path": "/test/:test/:test",
"path_params": {"test": {"param_type": "text"}},
"path_params": [{"name": "test", "type": "text"}],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
@ -371,6 +397,31 @@ def test_update_page_duplicate_page_path(api_client, data_fixture):
assert response.json()["error"] == "ERROR_PAGE_PATH_NOT_UNIQUE"
@pytest.mark.django_db
def test_update_page_duplicate_page_path_advanced(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
builder = data_fixture.create_builder_application(user=user)
path_params = [
{"name": "id", "type": "text"},
{"name": "new", "type": "text"},
]
page = data_fixture.create_builder_page(
builder=builder, path="/test/:id/hello/:new", path_params=path_params
)
page_two = data_fixture.create_builder_page(builder=builder, path="/test2")
url = reverse("api:builder:pages:item", kwargs={"page_id": page_two.id})
response = api_client.patch(
url,
{"path": "/test/:new/hello/:id", "path_params": path_params},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_PAGE_PATH_NOT_UNIQUE"
@pytest.mark.django_db
def test_update_page_path_param_not_in_path_existing_path(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
@ -380,7 +431,7 @@ def test_update_page_path_param_not_in_path_existing_path(api_client, data_fixtu
url = reverse("api:builder:pages:item", kwargs={"page_id": page.id})
response = api_client.patch(
url,
{"path_params": {"id": {"param_type": "text"}}},
{"path_params": [{"name": "id", "type": "text"}]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -398,7 +449,7 @@ def test_update_page_path_param_not_in_path_new_path(api_client, data_fixture):
url = reverse("api:builder:pages:item", kwargs={"page_id": page.id})
response = api_client.patch(
url,
{"path": "/test/test", "path_params": {"id": {"param_type": "text"}}},
{"path": "/test/test", "path_params": [{"name": "id", "type": "text"}]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -442,7 +493,7 @@ def test_update_page_invalid_page_path_param_semantics(api_client, data_fixture)
url = reverse("api:builder:pages:item", kwargs={"page_id": page.id})
response = api_client.patch(
url,
{"path": "/test/:^test", "path_params": {"^test": {"param_type": "text"}}},
{"path": "/test/:^test", "path_params": [{"name": "^test", "type": "text"}]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -450,7 +501,7 @@ def test_update_page_invalid_page_path_param_semantics(api_client, data_fixture)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert "^test" in response_json["detail"]["path_params"][0]["error"]
assert "^test" in response_json["detail"]["path_params"][0]["name"][0]["error"]
@pytest.mark.django_db
@ -465,7 +516,10 @@ def test_update_page_duplicate_path_params_in_path(api_client, data_fixture):
url = reverse("api:builder:pages:item", kwargs={"page_id": page.id})
response = api_client.patch(
url,
{"path": "/test/:test/:test", "path_params": {"test": {"param_type": "text"}}},
{
"path": "/test/:test/:test",
"path_params": [{"name": "test", "type": "text"}],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)

View file

@ -1,5 +1,6 @@
import pytest
from baserow.contrib.builder.pages.constants import ILLEGAL_PATH_SAMPLE_CHARACTER
from baserow.contrib.builder.pages.exceptions import (
DuplicatePathParamsInPath,
PageDoesNotExist,
@ -77,7 +78,7 @@ def test_create_page_duplicate_params_in_path(data_fixture):
builder,
name="test",
path="/test/:test/:test",
path_params={"test": {"param_type": "text"}},
path_params=[{"name": "test", "param_type": "text"}],
)
@ -162,32 +163,40 @@ def test_duplicate_page(data_fixture):
def test_is_page_path_valid():
assert PageHandler().is_page_path_valid("test/", {}) is True
assert PageHandler().is_page_path_valid("test/:id", {}) is False
assert PageHandler().is_page_path_valid("test/", []) is True
assert PageHandler().is_page_path_valid("test/:id", []) is False
assert (
PageHandler().is_page_path_valid("test/", {"id": {"param_type": "text"}})
PageHandler().is_page_path_valid(
"test/", [{"name": "id", "param_type": "text"}]
)
is False
)
assert (
PageHandler().is_page_path_valid("test/:", {"id": {"param_type": "text"}})
PageHandler().is_page_path_valid(
"test/:", [{"name": "id", "param_type": "text"}]
)
is False
)
assert PageHandler().is_page_path_valid("test/:", {}) is True
assert (
PageHandler().is_page_path_valid("test/::id", {"id": {"param_type": "text"}})
is True
)
assert (
PageHandler().is_page_path_valid(
"product/:id-:slug",
{"id": {"param_type": "text"}, "slug": {"param_type": "text"}},
"test/::id", [{"name": "id", "param_type": "text"}]
)
is True
)
assert (
PageHandler().is_page_path_valid(
"product/:test/:test",
{"test": {"param_type": "text"}},
"product/:id-:slug",
[
{"name": "id", "param_type": "text"},
{"name": "slug", "param_type": "text"},
],
)
is True
)
assert (
PageHandler().is_page_path_valid(
"product/:test/:test", [{"name": "test", "param_type": "text"}]
)
is False
)
@ -196,11 +205,11 @@ def test_is_page_path_valid():
def test_is_page_path_valid_raises():
with pytest.raises(PathParamNotInPath):
PageHandler().is_page_path_valid(
"test", {"id": {"param_type": "text"}}, raises=True
"test", [{"name": "id", "param_type": "text"}], raises=True
)
with pytest.raises(PathParamNotDefined):
PageHandler().is_page_path_valid("test/:id", {}, raises=True)
PageHandler().is_page_path_valid("test/:id", [], raises=True)
@pytest.mark.django_db
@ -208,3 +217,51 @@ def test_find_unused_page_path(data_fixture):
page = data_fixture.create_builder_page(path="/test")
assert PageHandler().find_unused_page_path(page.builder, "/test") == "/test2"
@pytest.mark.django_db
def test_is_page_path_unique(data_fixture):
builder = data_fixture.create_builder_application()
data_fixture.create_builder_page(builder=builder, path="/test/:id")
assert PageHandler().is_page_path_unique(builder, "/new") is True
assert PageHandler().is_page_path_unique(builder, "/test/:id") is False
assert PageHandler().is_page_path_unique(builder, "/test/:id/:hello") is True
assert PageHandler().is_page_path_unique(builder, "/test/:id-:hello") is True
@pytest.mark.django_db
def test_is_page_path_unique_different_param_position(data_fixture):
builder = data_fixture.create_builder_application()
data_fixture.create_builder_page(builder=builder, path="/test/:id/hello/:new")
assert PageHandler().is_page_path_unique(builder, "/test/:new/hello/:id") is False
assert PageHandler().is_page_path_unique(builder, "/test/:new/:id/hello") is True
@pytest.mark.django_db
def test_is_page_path_unique_raises(data_fixture):
builder = data_fixture.create_builder_application()
data_fixture.create_builder_page(builder=builder, path="/test/:id")
with pytest.raises(PagePathNotUnique):
PageHandler().is_page_path_unique(builder, "/test/:id", raises=True)
def test_generalise_path():
assert PageHandler().generalise_path("/test") == "/test"
assert (
PageHandler().generalise_path("/test/:id")
== f"/test/{ILLEGAL_PATH_SAMPLE_CHARACTER}"
)
assert (
PageHandler().generalise_path("/test/:id/hello/:test")
== f"/test/{ILLEGAL_PATH_SAMPLE_CHARACTER}/hello/{ILLEGAL_PATH_SAMPLE_CHARACTER}"
)
assert (
PageHandler().generalise_path("/test/:id-:hello")
== f"/test/{ILLEGAL_PATH_SAMPLE_CHARACTER}-{ILLEGAL_PATH_SAMPLE_CHARACTER}"
)

View file

@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
import pytest
from baserow.contrib.builder.pages.validators import (
path_params_validation,
path_param_name_validation,
path_validation,
)
@ -26,20 +26,15 @@ def test_page_path_validation():
def test_path_params_validation():
try:
path_params_validation({"test": {"param_type": "text"}})
path_param_name_validation("test")
except ValidationError:
pytest.fail("Should have not raised for valid path param")
with pytest.raises(ValidationError):
path_params_validation({"^test": {"param_type": "text"}})
path_param_name_validation("^test")
with pytest.raises(ValidationError):
path_params_validation(
{"test": {"param_type": "text"}, "^test": {"param_type": "text"}}
)
path_param_name_validation(" ")
with pytest.raises(ValidationError):
path_params_validation({" ": {"param_type": "text"}})
with pytest.raises(ValidationError):
path_params_validation({"test**11": {"param_type": "text"}})
path_param_name_validation("test**11")

View file

@ -3,10 +3,10 @@
<h2 class="box__title">
{{ $t('createPageModal.header') }}
</h2>
<PageForm
<PageSettingsForm
ref="pageForm"
:creation="true"
:builder="builder"
is-creation
@submitted="addPage"
>
<div class="actions actions--right">
@ -18,18 +18,18 @@
{{ $t('createPageModal.submit') }}
</button>
</div>
</PageForm>
</PageSettingsForm>
</Modal>
</template>
<script>
import modal from '@baserow/modules/core/mixins/modal'
import { notifyIf } from '@baserow/modules/core/utils/error'
import PageForm from '@baserow/modules/builder/components/page/PageForm'
import PageSettingsForm from '@baserow/modules/builder/components/page/PageSettingsForm'
export default {
name: 'CreatePageModal',
components: { PageForm },
components: { PageSettingsForm },
mixins: [modal],
props: {
builder: {
@ -43,13 +43,14 @@ export default {
}
},
methods: {
async addPage({ name, path }) {
async addPage({ name, path, path_params: pathParams }) {
this.loading = true
try {
const page = await this.$store.dispatch('page/create', {
builder: this.builder,
name,
path,
pathParams,
})
this.$refs.pageForm.$v.$reset()
this.hide()

View file

@ -1,185 +0,0 @@
<template>
<form @submit.prevent="submit">
<FormElement :error="fieldHasErrors('name')" class="control">
<label class="control__label">
<i class="fas fa-font"></i>
{{ $t('pageForm.nameLabel') }}
</label>
<input
ref="name"
v-model="values.name"
type="text"
class="input input--large"
:class="{ 'input--error': fieldHasErrors('name') }"
@focus.once="$event.target.select()"
@blur="$v.values.name.$touch()"
/>
<div
v-if="$v.values.name.$dirty && !$v.values.name.required"
class="error"
>
{{ $t('error.requiredField') }}
</div>
<div
v-if="$v.values.name.$dirty && !$v.values.name.maxLength"
class="error"
>
{{ $t('error.maxLength', { max: 255 }) }}
</div>
<div
v-if="$v.values.name.$dirty && !$v.values.name.isUnique"
class="error"
>
{{ $t('pageForm.errorNameNotUnique') }}
</div>
</FormElement>
<FormElement :error="fieldHasErrors('path')" class="control">
<label class="control__label">
<i class="fas fa-font"></i>
{{ $t('pageForm.pathLabel') }}
</label>
<input
ref="path"
v-model="values.path"
type="text"
class="input input--large"
:class="{ 'input--error': fieldHasErrors('path') }"
@input="hasPathBeenEdited = true"
@focus.once="$event.target.select()"
@blur="$v.values.path.$touch()"
/>
<div
v-if="$v.values.path.$dirty && !$v.values.path.required"
class="error"
>
{{ $t('error.requiredField') }}
</div>
<div
v-if="$v.values.path.$dirty && !$v.values.path.isUnique"
class="error"
>
{{ $t('pageForm.errorPathNotUnique') }}
</div>
<div
v-if="$v.values.path.$dirty && !$v.values.path.maxLength"
class="error"
>
{{ $t('error.maxLength', { max: 255 }) }}
</div>
<div
v-if="$v.values.path.$dirty && !$v.values.path.startingSlash"
class="error"
>
{{ $t('pageForm.errorStartingSlash') }}
</div>
<div
v-if="$v.values.path.$dirty && !$v.values.path.validPathCharacters"
class="error"
>
{{ $t('pageForm.errorValidPathCharacters') }}
</div>
</FormElement>
<slot></slot>
</form>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
import { maxLength, required } from 'vuelidate/lib/validators'
import {
getNextAvailableNameInSequence,
slugify,
} from '@baserow/modules/core/utils/string'
import { VALID_PATH_CHARACTERS } from '@baserow/modules/builder/enums'
export default {
name: 'PageForm',
mixins: [form],
props: {
builder: {
type: Object,
required: true,
},
creation: {
type: Boolean,
default: true,
},
},
data() {
return {
allowedValues: ['name', 'path'],
values: {
name: '',
path: '',
},
hasPathBeenEdited: false,
}
},
computed: {
pageNames() {
return this.builder.pages.map((page) => page.name)
},
pagePaths() {
return this.builder.pages.map((page) => page.path)
},
defaultName() {
const baseName = this.$t('pageForm.defaultName')
return getNextAvailableNameInSequence(baseName, this.pageNames)
},
},
watch: {
'values.name': {
handler(value) {
if (!this.hasPathBeenEdited && this.creation) {
this.values.path = `/${slugify(value)}`
}
},
immediate: true,
},
},
created() {
if (this.creation) {
this.values.name = this.defaultName
}
},
mounted() {
if (this.creation) {
this.$refs.name.focus()
}
},
methods: {
isNameUnique(name) {
return !this.pageNames.includes(name)
},
isPathUnique(path) {
return !this.pagePaths.includes(path)
},
pathStartsWithSlash(path) {
return path[0] === '/'
},
pathHasValidCharacters(path) {
return !path
.split('')
.some((letter) => !VALID_PATH_CHARACTERS.includes(letter))
},
},
validations() {
return {
values: {
name: {
required,
isUnique: this.isNameUnique,
maxLength: maxLength(255),
},
path: {
required,
isUnique: this.isPathUnique,
maxLength: maxLength(255),
startingSlash: this.pathStartsWithSlash,
validPathCharacters: this.pathHasValidCharacters,
},
},
}
},
}
</script>

View file

@ -1,6 +1,6 @@
<template>
<header class="layout__col-2-1 header header--space-between">
<PageHeaderMenuItems />
<PageHeaderMenuItems :page="page" :builder="builder" />
<DeviceSelector
:device-type-selected="deviceTypeSelected"
@selected="actionSetDeviceTypeSelected"
@ -19,6 +19,16 @@ export default {
PageHeaderMenuItems,
DeviceSelector,
},
props: {
page: {
type: Object,
required: true,
},
builder: {
type: Object,
required: true,
},
},
computed: {
...mapGetters({ deviceTypeSelected: 'page/getDeviceTypeSelected' }),
deviceTypes() {

View file

@ -13,7 +13,12 @@
<i class="header__filter-icon fas" :class="`fa-${item.icon}`"></i>
<span class="header__filter-name">{{ item.label }}</span>
</a>
<component :is="item.component" ref="component" />
<component
:is="item.component"
ref="component"
:page="page"
:builder="builder"
/>
</li>
</ul>
</template>
@ -21,6 +26,16 @@
<script>
export default {
name: 'PageHeaderMenuItems',
props: {
page: {
type: Object,
required: true,
},
builder: {
type: Object,
required: true,
},
},
computed: {
pageHeaderItemTypes() {
return Object.values(this.$registry.getAll('pageHeaderItem'))

View file

@ -0,0 +1,80 @@
<template>
<div>
<h2 class="box__title">{{ $t('pageSettings.title') }}</h2>
<Error :error="error"></Error>
<Alert
v-if="success"
type="success"
icon="check"
:title="$t('pageSettings.pageUpdatedTitle')"
>{{ $t('pageSettings.pageUpdatedDescription') }}
</Alert>
<PageSettingsForm
:builder="builder"
:page="page"
:default-values="page"
@submitted="updatePage"
>
<div class="actions actions--right">
<button
:class="{ 'button--loading': loading }"
class="button button--large"
type="submit"
>
{{ $t('action.save') }}
</button>
</div>
</PageSettingsForm>
</div>
</template>
<script>
import error from '@baserow/modules/core/mixins/error'
import PageSettingsForm from '@baserow/modules/builder/components/page/PageSettingsForm'
import { mapActions } from 'vuex'
export default {
name: 'PageSettings',
components: { PageSettingsForm },
mixins: [error],
props: {
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
success: false,
}
},
methods: {
...mapActions({ actionUpdatePage: 'page/update' }),
async updatePage({ name, path, path_params: pathPrams }) {
this.success = false
this.loading = true
this.hideError()
try {
await this.actionUpdatePage({
builder: this.builder,
page: this.page,
values: {
name,
path,
path_params: pathPrams,
},
})
this.success = true
} catch (error) {
this.handleError(error)
}
this.loading = false
},
},
}
</script>

View file

@ -0,0 +1,256 @@
<template>
<form @submit.prevent="submit">
<div class="row margin-bottom-1">
<div class="col col-6">
<PageSettingsNameFormElement
ref="name"
v-model="values.name"
:has-errors="fieldHasErrors('name')"
:validation-state="$v.values.name"
:is-creation="isCreation"
@blur="$v.values.name.$touch()"
/>
</div>
<div class="col col-6">
<PageSettingsPathFormElement
v-model="values.path"
:has-errors="fieldHasErrors('path')"
:validation-state="$v.values.path"
@blur="onPathBlur"
/>
</div>
</div>
<div class="row">
<div class="col col-6">
<PageSettingsPathParamsFormElement
:path-params="values.path_params"
@update="onPathParamUpdate"
/>
</div>
<div class="col col-6"></div>
</div>
<slot></slot>
</form>
</template>
<script>
import { maxLength, required } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form'
import PageSettingsNameFormElement from '@baserow/modules/builder/components/page/PageSettingsNameFormElement'
import PageSettingsPathFormElement from '@baserow/modules/builder/components/page/PageSettingsPathFormElement'
import PageSettingsPathParamsFormElement from '@baserow/modules/builder/components/page/PageSettingsPathParamsFormElement'
import {
getPathParams,
PATH_PARAM_REGEX,
ILLEGAL_PATH_SAMPLE_CHARACTER,
VALID_PATH_CHARACTERS,
} from '@baserow/modules/builder/utils/path'
import {
getNextAvailableNameInSequence,
slugify,
} from '@baserow/modules/core/utils/string'
export default {
name: 'PageSettingsForm',
components: {
PageSettingsPathParamsFormElement,
PageSettingsPathFormElement,
PageSettingsNameFormElement,
},
mixins: [form],
props: {
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: false,
default: null,
},
isCreation: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
values: {
name: '',
path: '',
path_params: [],
},
hasPathBeenEdited: false,
}
},
computed: {
defaultPathParamType() {
return this.$registry.getOrderedList('pathParamType')[0].getType()
},
defaultName() {
const baseName = this.$t('pageForm.defaultName')
return getNextAvailableNameInSequence(baseName, this.pageNames)
},
pageNames() {
return this.builder.pages.map((page) => page.name)
},
otherPagePaths() {
return this.builder.pages
.filter((page) => page.id !== this.page?.id)
.map((page) => page.path)
},
currentPathParams() {
return getPathParams(this.values.path)
},
},
watch: {
// When the path change we want to update the value.path_params value
// but try to keep the previous configuration as much as possible
currentPathParams(paramNames, oldParamNames) {
const result = paramNames.map((param) => ({
name: param,
type: 'text',
}))
const pathParamIndexesByName = this.values.path_params.reduce(
(prev, { name }, index) => {
if (!prev[name]) {
prev[name] = []
}
prev[name].push(index)
return prev
},
{}
)
// List of used index of existing params to use them once only.
const usedIndex = []
// An index is ok if it has already been associated with an existing param
// to prevent double association.
const okIndex = []
// First match same names at same position
paramNames.forEach((paramName, index) => {
if (paramName === oldParamNames[index]) {
Object.assign(result[index], this.values.path_params[index])
pathParamIndexesByName[paramName] = pathParamIndexesByName[
paramName
].filter((i) => i !== index)
usedIndex.push(index)
okIndex.push(index)
}
})
// Then match previously existing names at another position
paramNames.forEach((paramName, index) => {
if (okIndex.includes(index)) {
return
}
if (pathParamIndexesByName[paramName]?.length) {
const paramIndex = pathParamIndexesByName[paramName].shift()
Object.assign(result[index], this.values.path_params[paramIndex])
usedIndex.push(paramIndex)
okIndex.push(index)
}
})
// Then match remaining existing params in same relative order
paramNames.forEach((paramName, index) => {
if (okIndex.includes(index)) {
return
}
const freeIndex = this.values.path_params.findIndex(
(_, index) => !usedIndex.includes(index)
)
if (freeIndex !== -1) {
Object.assign(result[index], this.values.path_params[freeIndex], {
name: paramName,
})
usedIndex.push(freeIndex)
}
})
this.values.path_params = result
},
'values.name': {
handler(value) {
if (!this.hasPathBeenEdited && this.isCreation) {
this.values.path = `/${slugify(value)}`
}
},
immediate: true,
},
},
created() {
if (this.isCreation) {
this.values.name = this.defaultName
}
},
mounted() {
if (this.isCreation) {
this.$refs.name.$refs.input.focus()
}
},
methods: {
generalisePath(path) {
return path.replace(PATH_PARAM_REGEX, ILLEGAL_PATH_SAMPLE_CHARACTER)
},
onPathBlur() {
this.$v.values.path.$touch()
this.hasPathBeenEdited = true
},
onPathParamUpdate(paramTypeName, paramType) {
this.values.path_params.forEach((pathParam) => {
if (pathParam.name === paramTypeName) {
pathParam.type = paramType
}
})
// this.values.path_params[paramTypeName].param_type = paramType
},
isNameUnique(name) {
return !this.pageNames.includes(name) || name === this.page?.name
},
isPathUnique(path) {
const pathGeneralised = this.generalisePath(path)
return (
!this.otherPagePaths.some(
(pathCurrent) => this.generalisePath(pathCurrent) === pathGeneralised
) || path === this.page?.path
)
},
pathStartsWithSlash(path) {
return path[0] === '/'
},
pathHasValidCharacters(path) {
return !path
.split('')
.some((letter) => !VALID_PATH_CHARACTERS.includes(letter))
},
arePathParamsUnique(path) {
const pathParams = getPathParams(path)
return new Set(pathParams).size === pathParams.length
},
},
validations() {
return {
values: {
name: {
required,
isUnique: this.isNameUnique,
maxLength: maxLength(255),
},
path: {
required,
isUnique: this.isPathUnique,
maxLength: maxLength(255),
startingSlash: this.pathStartsWithSlash,
validPathCharacters: this.pathHasValidCharacters,
uniquePathParams: this.arePathParamsUnique,
},
},
}
},
}
</script>

View file

@ -0,0 +1,70 @@
<template>
<Modal left-sidebar left-sidebar-scrollable>
<template #sidebar>
<div class="modal-sidebar__head">
<div class="modal-sidebar__head-name">
{{ page.name }}
</div>
</div>
<ul class="modal-sidebar__nav">
<li v-for="setting in registeredSettings" :key="setting.getType()">
<a
class="modal-sidebar__nav-link"
:class="{ active: setting === settingSelected }"
@click="settingSelected = setting"
>
<i
class="fas modal-sidebar__nav-icon"
:class="'fa-' + setting.icon"
></i>
{{ setting.name }}
</a>
</li>
</ul>
</template>
<template v-if="settingSelected" #content>
<component
:is="settingSelected.component"
:builder="builder"
:page="page"
></component>
</template>
</Modal>
</template>
<script>
import modal from '@baserow/modules/core/mixins/modal'
export default {
name: 'PageSettingsModal',
mixins: [modal],
props: {
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,
},
},
data() {
return {
settingSelected: null,
}
},
computed: {
registeredSettings() {
return this.$registry.getOrderedList('pageSettings')
},
},
methods: {
show(...args) {
if (!this.settingSelected) {
this.settingSelected = this.registeredSettings[0]
}
return modal.methods.show.call(this, ...args)
},
},
}
</script>

View file

@ -0,0 +1,68 @@
<template>
<FormElement :error="hasErrors" class="control">
<label class="control__label">
{{ $t('pageForm.nameTitle') }}
</label>
<div class="control__description">
{{ $t('pageForm.nameSubtitle') }}
</div>
<div class="control__elements">
<input
ref="input"
:value="value"
class="input"
:class="{ 'input--error': hasErrors }"
type="text"
@input="$emit('input', $event.target.value)"
@blur="$emit('blur')"
@focus.once="isCreation && $event.target.select()"
/>
<div
v-if="validationState.$dirty && !validationState.required"
class="error"
>
{{ $t('error.requiredField') }}
</div>
<div
v-if="validationState.$dirty && !validationState.maxLength"
class="error"
>
{{ $t('error.maxLength', { max: 255 }) }}
</div>
<div
v-if="validationState.$dirty && !validationState.isUnique"
class="error"
>
{{ $t('pageErrors.errorNameNotUnique') }}
</div>
</div>
</FormElement>
</template>
<script>
export default {
name: 'PageSettingsNameFormElement',
props: {
value: {
type: String,
required: false,
default: '',
},
hasErrors: {
type: Boolean,
required: false,
default: false,
},
validationState: {
type: Object,
required: false,
default: () => {},
},
isCreation: {
type: Boolean,
required: false,
default: false,
},
},
}
</script>

View file

@ -0,0 +1,79 @@
<template>
<FormElement :error="hasErrors" class="control">
<label class="control__label">
{{ $t('pageForm.pathTitle') }}
</label>
<div class="control__description">
{{ $t('pageForm.pathSubtitle') }}
</div>
<div class="control__elements">
<input
:value="value"
class="input"
:class="{ 'input--error': hasErrors }"
type="text"
@input="$emit('input', $event.target.value)"
@blur="$emit('blur')"
/>
<div
v-if="validationState.$dirty && !validationState.required"
class="error"
>
{{ $t('error.requiredField') }}
</div>
<div
v-if="validationState.$dirty && !validationState.isUnique"
class="error"
>
{{ $t('pageErrors.errorPathNotUnique') }}
</div>
<div
v-if="validationState.$dirty && !validationState.maxLength"
class="error"
>
{{ $t('error.maxLength', { max: 255 }) }}
</div>
<div
v-if="validationState.$dirty && !validationState.startingSlash"
class="error"
>
{{ $t('pageErrors.errorStartingSlash') }}
</div>
<div
v-if="validationState.$dirty && !validationState.validPathCharacters"
class="error"
>
{{ $t('pageErrors.errorValidPathCharacters') }}
</div>
<div
v-if="validationState.$dirty && !validationState.uniquePathParams"
class="error"
>
{{ $t('pageErrors.errorUniquePathParams') }}
</div>
</div>
</FormElement>
</template>
<script>
export default {
name: 'PageSettingsPathFormElement',
props: {
value: {
type: String,
required: false,
default: '',
},
hasErrors: {
type: Boolean,
required: false,
default: false,
},
validationState: {
type: Object,
required: false,
default: () => {},
},
},
}
</script>

View file

@ -0,0 +1,53 @@
<template>
<div>
<label class="control__label">
{{ $t('pageForm.pathParamsTitle') }}
</label>
<div class="control__description">
<template v-if="Object.keys(pathParams).length > 0">
{{ $t('pageForm.pathParamsSubtitle') }}
</template>
<template v-else>
{{ $t('pageForm.pathParamsSubtitleTutorial') }}
</template>
</div>
<div
v-for="pathParam in pathParams"
:key="pathParam.name"
class="page-settings-path-params"
>
<span class="page-settings-path-params__name">{{ pathParam.name }}</span>
<div class="page-settings-path-params__dropdown">
<Dropdown
:value="pathParam.type"
@input="$emit('update', pathParam.name, $event)"
>
<DropdownItem
v-for="pathParamType in pathParamTypes"
:key="pathParamType.getType()"
:name="pathParamType.name"
:value="pathParamType.getType()"
></DropdownItem>
</Dropdown>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PageSettingsPathParamsFormElement',
props: {
pathParams: {
type: Array,
required: false,
default: () => {},
},
},
computed: {
pathParamTypes() {
return this.$registry.getOrderedList('pathParamType')
},
},
}
</script>

View file

@ -1,15 +0,0 @@
<template>
<Modal class="settings-modal"> Page settings </Modal>
</template>
<script>
import Modal from '@baserow/modules/core/mixins/modal'
export default {
name: 'SettingsModal',
mixins: [Modal],
data() {
return {}
},
}
</script>

View file

@ -45,9 +45,6 @@ export default {
},
},
methods: {
setPage(page) {
this.page = page
},
show(...args) {
if (!this.settingSelected) {
this.settingSelected = this.registeredSettings[0]

View file

@ -9,8 +9,3 @@ export const PLACEMENTS = {
BEFORE: 'before',
AFTER: 'after',
}
export const VALID_PATH_CHARACTERS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=".split(
''
)

View file

@ -17,14 +17,12 @@
"header": "Create page",
"submit": "Add page"
},
"pageForm": {
"nameLabel": "Name",
"pathLabel": "Path",
"pageErrors": {
"errorNameNotUnique": "A page with this name already exists",
"errorPathNotUnique": "A path with this name already exists",
"errorStartingSlash": "A path needs to start with a '/'",
"errorValidPathCharacters": "The path contains invalid characters",
"defaultName": "Page"
"errorUniquePathParams": "Path parameters have to be unique."
},
"pageHeaderItemTypes": {
"labelElements": "Elements",
@ -82,5 +80,27 @@
"textTitle": "Text",
"textPlaceholder": "Enter text...",
"textError": "The value is invalid."
},
"pageSettingsTypes": {
"pageName": "Page"
},
"pageSettings": {
"title": "Page",
"pageUpdatedTitle": "Changed",
"pageUpdatedDescription": "The page settings have been updated."
},
"pageForm": {
"defaultName": "Page",
"nameTitle": "Name",
"nameSubtitle": "Unique name of the page",
"pathTitle": "Path",
"pathSubtitle": "A parameter can be added via :parameter",
"pathParamsTitle": "Path parameters",
"pathParamsSubtitle": "Are defined by :parameter in the path",
"pathParamsSubtitleTutorial": "Path parameters can be used to dynamically load data, depending on the provided parameter. Add :parameter to the path to add one."
},
"pathParamTypes": {
"textName": "Text",
"numericName": "Numeric"
}
}

View file

@ -2,7 +2,7 @@ import { Registerable } from '@baserow/modules/core/registry'
import ElementsContext from '@baserow/modules/builder/components/page/ElementsContext'
import DataSourceContext from '@baserow/modules/builder/components/page/DataSourceContext'
import VariablesContext from '@baserow/modules/builder/components/page/VariablesContext'
import SettingsModal from '@baserow/modules/builder/components/page/SettingsModal'
import PageSettingsModal from '@baserow/modules/builder/components/page/PageSettingsModal'
export class PageHeaderItemType extends Registerable {
get label() {
@ -94,11 +94,11 @@ export class SettingsPageHeaderItemType extends PageHeaderItemType {
}
get icon() {
return 'square-root-alt'
return 'cogs'
}
get component() {
return SettingsModal
return PageSettingsModal
}
onClick(component, button) {

View file

@ -0,0 +1,38 @@
import { Registerable } from '@baserow/modules/core/registry'
import PageSettings from '@baserow/modules/builder/components/page/PageSettings'
export class PageSettingType extends Registerable {
getType() {
return null
}
get name() {
return null
}
get icon() {
return null
}
get component() {
return null
}
}
export class PagePageSettingsType extends PageSettingType {
getType() {
return 'page'
}
get name() {
return this.app.i18n.t('pageSettingsTypes.pageName')
}
get icon() {
return 'cogs'
}
get component() {
return PageSettings
}
}

View file

@ -1,6 +1,6 @@
<template>
<div class="page-editor">
<PageHeader />
<PageHeader :page="page" :builder="builder" />
<div class="layout__col-2-2 page-editor__content">
<div :style="{ width: `calc(100% - ${panelWidth}px)` }">
<PagePreview />

View file

@ -0,0 +1,35 @@
import { Registerable } from '@baserow/modules/core/registry'
export class PathParamType extends Registerable {
get name() {
return null
}
}
export class TextPathParamType extends PathParamType {
getType() {
return 'text'
}
getOrder() {
return 10
}
get name() {
return this.app.i18n.t('pathParamTypes.textName')
}
}
export class NumericPathParamType extends PathParamType {
getType() {
return 'numeric'
}
getOrder() {
return 20
}
get name() {
return this.app.i18n.t('pathParamTypes.numericName')
}
}

View file

@ -37,6 +37,11 @@ import {
VisibilityPageSidePanelType,
StylePageSidePanelType,
} from '@baserow/modules/builder/pageSidePanelTypes'
import { PagePageSettingsType } from '@baserow/modules/builder/pageSettingsTypes'
import {
TextPathParamType,
NumericPathParamType,
} from '@baserow/modules/builder/pathParamTypes'
export default (context) => {
const { store, app, isDev } = context
@ -62,6 +67,8 @@ export default (context) => {
app.$registry.registerNamespace('element')
app.$registry.registerNamespace('device')
app.$registry.registerNamespace('pageHeaderItem')
app.$registry.registerNamespace('pageSettings')
app.$registry.registerNamespace('pathParamType')
app.$registry.register('application', new BuilderApplicationType(context))
app.$registry.register('job', new DuplicatePageJobType(context))
@ -107,4 +114,9 @@ export default (context) => {
new VisibilityPageSidePanelType(context)
)
app.$registry.register('pageSidePanel', new EventsPageSidePanelType(context))
app.$registry.register('pageSettings', new PagePageSettingsType(context))
app.$registry.register('pathParamType', new TextPathParamType(context))
app.$registry.register('pathParamType', new NumericPathParamType(context))
}

View file

@ -1,7 +1,11 @@
export default (client) => {
return {
create(builderId, name, path) {
return client.post(`builder/${builderId}/pages/`, { name, path })
create(builderId, name, path, pathParams = {}) {
return client.post(`builder/${builderId}/pages/`, {
name,
path,
path_params: pathParams,
})
},
update(pageId, values) {
return client.patch(`builder/pages/${pageId}/`, values)

View file

@ -102,11 +102,12 @@ const actions = {
commit('DELETE_ITEM', { builder, id: page.id })
},
async create({ commit, dispatch }, { builder, name, path }) {
async create({ commit, dispatch }, { builder, name, path, pathParams }) {
const { data: page } = await PageService(this.$client).create(
builder.id,
name,
path
path,
pathParams
)
commit('ADD_ITEM', { builder, page })

View file

@ -0,0 +1,16 @@
export const VALID_PATH_CHARACTERS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=".split(
''
)
// This regex needs to be matching PATH_PARAM_REGEX in the backend
// (baserow / contrib / builder / pages / constants.py)
export const PATH_PARAM_REGEX = /(:[A-Za-z0-9_]+)/g
export const ILLEGAL_PATH_SAMPLE_CHARACTER = '^'
export function getPathParams(path) {
return [...path.matchAll(PATH_PARAM_REGEX)].map((pathParam) =>
pathParam[0].substring(1)
)
}

View file

@ -3,10 +3,11 @@
@import 'add_element_card';
@import 'add_element_modal';
@import 'page_preview';
@import 'element.scss';
@import 'side_panels.scss';
@import 'empty_side_panel_state.scss';
@import 'page.scss';
@import 'elements/headingElement.scss';
@import 'elements/paragraphElement.scss';
@import 'elements/forms/paragraphElementForm.scss';
@import 'element';
@import 'side_panels';
@import 'empty_side_panel_state';
@import 'page';
@import 'elements/headingElement';
@import 'elements/paragraphElement';
@import 'elements/forms/paragraphElementForm';
@import 'page_settings_path_params_form_element';

View file

@ -0,0 +1,20 @@
.page-settings-path-params {
display: flex;
align-items: center;
width: 100%;
&:not(:last-child) {
margin-bottom: 8px;
}
}
.page-settings-path-params__name {
@extend %ellipsis;
flex: 0 0 50%;
}
.page-settings-path-params__dropdown {
flex: 0 0 45%;
margin-left: 5%;
}

View file

@ -7,17 +7,28 @@
}
}
.control__description {
font-size: 13px;
line-height: 160%;
color: $color-neutral-600;
margin-bottom: 16px;
}
.control__label {
display: block;
font-size: 14px;
font-weight: 700;
margin-bottom: 16px;
margin-bottom: 8px;
&.control__label--small {
font-size: 13px;
font-weight: 700;
margin-bottom: 4px;
}
& + :not(.control__description) {
margin-top: 16px;
}
}
.control__label-icon {
@ -31,13 +42,6 @@
position: relative;
}
.control__description {
font-size: 13px;
line-height: 160%;
color: $color-neutral-600;
margin-bottom: 16px;
}
.control__context {
color: $color-primary-900;
margin-left: 6px;

View file

@ -0,0 +1,19 @@
import { getPathParams } from '@baserow/modules/builder/utils/path'
describe('getPathParams', () => {
test('that it can find no params', () => {
expect(getPathParams('/test')).toEqual([])
})
test('that it can find one param', () => {
expect(getPathParams('/test/:id')).toEqual(['id'])
})
test('that it can find multiple params', () => {
expect(getPathParams('/test/:id/:test')).toEqual(['id', 'test'])
})
test('that it can find multiple params without slashes', () => {
expect(getPathParams('/test/:id-:test')).toEqual(['id', 'test'])
})
test('that it can find multiple params with double colons', () => {
expect(getPathParams('/test/::test')).toEqual(['test'])
})
})