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 bramw/baserow!1335
This commit is contained in:
commit
1fc9d5bff9
35 changed files with 1108 additions and 308 deletions
backend
src/baserow/contrib/builder
api/pages
migrations
pages
tests/baserow/contrib/builder
web-frontend
modules
builder
components
page
CreatePageModal.vuePageForm.vuePageHeader.vuePageHeaderMenuItems.vuePageSettings.vuePageSettingsForm.vuePageSettingsModal.vuePageSettingsNameFormElement.vuePageSettingsPathFormElement.vuePageSettingsPathParamsFormElement.vueSettingsModal.vue
settings
locales
pageHeaderItemTypes.jspageSettingsTypes.jspages
pathParamTypes.jsplugin.jsservices
store
utils
core/assets/scss/components
test/unit/builder/utils
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
]
|
|
@ -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 = "^"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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}",
|
||||
)
|
||||
|
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
|
@ -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() {
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -45,9 +45,6 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
setPage(page) {
|
||||
this.page = page
|
||||
},
|
||||
show(...args) {
|
||||
if (!this.settingSelected) {
|
||||
this.settingSelected = this.registeredSettings[0]
|
||||
|
|
|
@ -9,8 +9,3 @@ export const PLACEMENTS = {
|
|||
BEFORE: 'before',
|
||||
AFTER: 'after',
|
||||
}
|
||||
|
||||
export const VALID_PATH_CHARACTERS =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=".split(
|
||||
''
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
38
web-frontend/modules/builder/pageSettingsTypes.js
Normal file
38
web-frontend/modules/builder/pageSettingsTypes.js
Normal 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
|
||||
}
|
||||
}
|
|
@ -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 />
|
||||
|
|
35
web-frontend/modules/builder/pathParamTypes.js
Normal file
35
web-frontend/modules/builder/pathParamTypes.js
Normal 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')
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 })
|
||||
|
|
16
web-frontend/modules/builder/utils/path.js
Normal file
16
web-frontend/modules/builder/utils/path.js
Normal 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)
|
||||
)
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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%;
|
||||
}
|
|
@ -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;
|
||||
|
|
19
web-frontend/test/unit/builder/utils/path.spec.js
Normal file
19
web-frontend/test/unit/builder/utils/path.spec.js
Normal 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'])
|
||||
})
|
||||
})
|
Loading…
Add table
Reference in a new issue