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

Add query_parameters to pages

This commit is contained in:
Evren Ozkan 2025-02-03 15:37:04 +00:00
parent 5170bc0760
commit 557b8966f8
59 changed files with 1183 additions and 117 deletions
backend
changelog/entries/unreleased/feature
e2e-tests
fixtures/builder
tests/builder
web-frontend/modules

View file

@ -10,7 +10,10 @@ from baserow.api.app_auth_providers.serializers import AppAuthProviderSerializer
from baserow.api.polymorphic import PolymorphicSerializer
from baserow.api.services.serializers import PublicServiceSerializer
from baserow.api.user_files.serializers import UserFileField, UserFileSerializer
from baserow.contrib.builder.api.pages.serializers import PathParamSerializer
from baserow.contrib.builder.api.pages.serializers import (
PathParamSerializer,
QueryParamSerializer,
)
from baserow.contrib.builder.api.theme.serializers import (
CombinedThemeConfigBlocksSerializer,
serialize_builder_theme,
@ -157,6 +160,7 @@ class PublicPageSerializer(serializers.ModelSerializer):
"""
path_params = PathParamSerializer(many=True, required=False)
query_params = QueryParamSerializer(many=True, required=False)
class Meta:
model = Page
@ -169,6 +173,7 @@ class PublicPageSerializer(serializers.ModelSerializer):
"visibility",
"role_type",
"roles",
"query_params",
)
extra_kwargs = {
"id": {"read_only": True},

View file

@ -49,3 +49,17 @@ ERROR_DUPLICATE_PATH_PARAMS_IN_PATH = (
HTTP_400_BAD_REQUEST,
"The path params {e.path_param_names} are defined multiple times in path {e.path}",
)
ERROR_INVALID_QUERY_PARAM_NAME = (
"ERROR_INVALID_QUERY_PARAM_NAME",
HTTP_400_BAD_REQUEST,
"The provided query parameter name {e.query_param_name} is invalid. Query parameter "
"names must contain only letters, numbers and underscores.",
)
ERROR_DUPLICATE_QUERY_PARAMS = (
"ERROR_DUPLICATE_QUERY_PARAMS",
HTTP_400_BAD_REQUEST,
"The query parameter {e.param} is either defined multiple times in {e.query_param_names} "
"or conflicts with path parameters {e.path_param_names}.",
)

View file

@ -1,8 +1,11 @@
from rest_framework import serializers
from baserow.contrib.builder.pages.constants import PAGE_PATH_PARAM_TYPE_CHOICES
from baserow.contrib.builder.pages.constants import PAGE_PARAM_TYPE_CHOICES
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.pages.validators import path_param_name_validation
from baserow.contrib.builder.pages.validators import (
path_param_name_validation,
query_param_name_validation,
)
class PathParamSerializer(serializers.Serializer):
@ -13,7 +16,19 @@ class PathParamSerializer(serializers.Serializer):
max_length=255,
)
type = serializers.ChoiceField(
choices=PAGE_PATH_PARAM_TYPE_CHOICES, help_text="The type of the parameter."
choices=PAGE_PARAM_TYPE_CHOICES, help_text="The type of the parameter."
)
class QueryParamSerializer(serializers.Serializer):
name = serializers.CharField(
required=True,
validators=[query_param_name_validation],
help_text="The name of the parameter.",
max_length=255,
)
type = serializers.ChoiceField(
choices=PAGE_PARAM_TYPE_CHOICES, help_text="The type of the parameter."
)
@ -25,6 +40,7 @@ class PageSerializer(serializers.ModelSerializer):
"""
path_params = PathParamSerializer(many=True, required=False)
query_params = QueryParamSerializer(many=True, required=False)
class Meta:
model = Page
@ -39,6 +55,7 @@ class PageSerializer(serializers.ModelSerializer):
"visibility",
"role_type",
"roles",
"query_params",
)
extra_kwargs = {
"id": {"read_only": True},
@ -53,18 +70,28 @@ class PageSerializer(serializers.ModelSerializer):
class CreatePageSerializer(serializers.ModelSerializer):
path_params = PathParamSerializer(many=True, required=False)
query_params = PathParamSerializer(many=True, required=False)
class Meta:
model = Page
fields = ("name", "path", "path_params")
fields = ("name", "path", "path_params", "query_params")
class UpdatePageSerializer(serializers.ModelSerializer):
path_params = PathParamSerializer(many=True, required=False)
query_params = QueryParamSerializer(many=True, required=False)
class Meta:
model = Page
fields = ("name", "path", "path_params", "visibility", "role_type", "roles")
fields = (
"name",
"path",
"path_params",
"visibility",
"role_type",
"roles",
"query_params",
)
extra_kwargs = {
"name": {"required": False},
"path": {"required": False},

View file

@ -16,6 +16,8 @@ from baserow.api.jobs.serializers import JobSerializer
from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema
from baserow.contrib.builder.api.pages.errors import (
ERROR_DUPLICATE_PATH_PARAMS_IN_PATH,
ERROR_DUPLICATE_QUERY_PARAMS,
ERROR_INVALID_QUERY_PARAM_NAME,
ERROR_PAGE_DOES_NOT_EXIST,
ERROR_PAGE_NAME_NOT_UNIQUE,
ERROR_PAGE_NOT_IN_BUILDER,
@ -32,7 +34,9 @@ from baserow.contrib.builder.api.pages.serializers import (
)
from baserow.contrib.builder.handler import BuilderHandler
from baserow.contrib.builder.pages.exceptions import (
DuplicatePageParams,
DuplicatePathParamsInPath,
InvalidQueryParamName,
PageDoesNotExist,
PageNameNotUnique,
PageNotInBuilder,
@ -76,6 +80,8 @@ class PagesView(APIView):
"ERROR_PAGE_PATH_NOT_UNIQUE",
"ERROR_PATH_PARAM_NOT_IN_PATH",
"ERROR_PATH_PARAM_NOT_DEFINED",
"ERROR_INVALID_QUERY_PARAM_NAME",
"ERROR_DUPLICATE_QUERY_PARAMS",
]
),
404: get_error_schema(["ERROR_APPLICATION_DOES_NOT_EXIST"]),
@ -90,6 +96,8 @@ class PagesView(APIView):
PathParamNotInPath: ERROR_PATH_PARAM_NOT_IN_PATH,
PathParamNotDefined: ERROR_PATH_PARAM_NOT_DEFINED,
DuplicatePathParamsInPath: ERROR_DUPLICATE_PATH_PARAMS_IN_PATH,
InvalidQueryParamName: ERROR_INVALID_QUERY_PARAM_NAME,
DuplicatePageParams: ERROR_DUPLICATE_QUERY_PARAMS,
}
)
@validate_body(CreatePageSerializer, return_validated=True)
@ -102,6 +110,7 @@ class PagesView(APIView):
data["name"],
path=data["path"],
path_params=data.get("path_params", None),
query_params=data.get("query_params", None),
)
serializer = PageSerializer(page)
@ -133,6 +142,8 @@ class PageView(APIView):
"ERROR_PATH_PARAM_NOT_IN_PATH",
"ERROR_PATH_PARAM_NOT_DEFINED",
"ERROR_SHARED_PAGE_READ_ONLY",
"ERROR_INVALID_QUERY_PARAM_NAME",
"ERROR_DUPLICATE_QUERY_PARAMS",
]
),
404: get_error_schema(
@ -151,6 +162,8 @@ class PageView(APIView):
PathParamNotDefined: ERROR_PATH_PARAM_NOT_DEFINED,
DuplicatePathParamsInPath: ERROR_DUPLICATE_PATH_PARAMS_IN_PATH,
SharedPageIsReadOnly: ERROR_SHARED_PAGE_READ_ONLY,
InvalidQueryParamName: ERROR_INVALID_QUERY_PARAM_NAME,
DuplicatePageParams: ERROR_DUPLICATE_QUERY_PARAMS,
}
)
@validate_body(UpdatePageSerializer, return_validated=True)

View file

@ -157,6 +157,16 @@ class LinkCollectionFieldType(CollectionFieldType):
collection_field.config["page_parameters"][index]["value"] = new_formula
yield collection_field
for index, query_parameter in enumerate(
collection_field.config.get("query_parameters") or []
):
new_formula = yield query_parameter.get("value")
if new_formula is not None:
collection_field.config["query_parameters"][index][
"value"
] = new_formula
yield collection_field
def deserialize_property(
self,
prop_name: str,

View file

@ -742,6 +742,7 @@ class NavigationElementManager:
"navigate_to_page_id",
"navigate_to_url",
"page_parameters",
"query_parameters",
"target",
]
allowed_fields = [
@ -749,6 +750,7 @@ class NavigationElementManager:
"navigate_to_page_id",
"navigate_to_url",
"page_parameters",
"query_parameters",
"target",
]
simple_formula_fields = ["navigate_to_url"]
@ -757,6 +759,7 @@ class NavigationElementManager:
navigation_type: str
navigate_to_page_id: int
page_parameters: List
query_parameters: List
navigate_to_url: BaserowFormula
target: str
@ -799,9 +802,16 @@ class NavigationElementManager:
),
"page_parameters": PageParameterValueSerializer(
many=True,
default=[],
help_text=LinkElement._meta.get_field("page_parameters").help_text,
required=False,
),
"query_parameters": PageParameterValueSerializer(
many=True,
default=[],
help_text=LinkElement._meta.get_field("query_parameters").help_text,
required=False,
),
"target": serializers.ChoiceField(
choices=NavigationElementMixin.TARGETS.choices,
help_text=LinkElement._meta.get_field("target").help_text,
@ -820,6 +830,7 @@ class NavigationElementManager:
"navigate_to_page_id": None,
"navigate_to_url": '"http://example.com"',
"page_parameters": [],
"query_parameters": [],
"target": "blank",
}
@ -857,7 +868,7 @@ class NavigationElementManager:
return ElementType.prepare_value_for_db(self, values, instance)
def _raise_if_path_params_are_invalid(self, path_params: Dict, page: Page) -> None:
def _raise_if_path_params_are_invalid(self, path_params: List, page: Page) -> None:
"""
Checks if the path parameters being set are correctly correlated to the
path parameters defined for the page.
@ -869,7 +880,6 @@ class NavigationElementManager:
"""
parameter_types = {p["name"]: p["type"] for p in page.path_params}
for page_parameter in path_params:
page_parameter_name = page_parameter["name"]
page_parameter_type = parameter_types.get(page_parameter_name, None)
@ -882,12 +892,11 @@ class NavigationElementManager:
class LinkElementType(ElementType):
"""
A simple paragraph element that can be used to display a paragraph of text.
A link element that can be used to navigate to a page or a URL.
"""
type = "link"
model_class = LinkElement
PATH_PARAM_TYPE_TO_PYTHON_TYPE_MAP = {"text": str, "numeric": int}
simple_formula_fields = NavigationElementManager.simple_formula_fields + ["value"]
@property
@ -923,7 +932,7 @@ class LinkElementType(ElementType):
Generator that returns formula fields for the LinkElementType.
Unlike other Element types, this one has its formula fields in the
page_parameters JSON field.
page_parameters and query_prameters JSON fields.
"""
yield from super().formula_generator(element)
@ -934,6 +943,12 @@ class LinkElementType(ElementType):
element.page_parameters[index]["value"] = new_formula
yield element
for index, data in enumerate(element.query_parameters or []):
new_formula = yield data["value"]
if new_formula is not None:
element.query_parameters[index]["value"] = new_formula
yield element
def deserialize_property(
self,
prop_name: str,

View file

@ -488,6 +488,12 @@ class NavigationElementMixin(models.Model):
help_text="The parameters for each parameters of the selected page if any.",
null=True,
)
query_parameters = models.JSONField(
db_default=[],
default=list,
help_text="The query parameters for each parameter of the selected page if any.",
null=True,
)
target = models.CharField(
choices=TARGETS.choices,
help_text="The target of the link when we click on it.",

View file

@ -0,0 +1,40 @@
# Generated by Django 5.0.9 on 2024-12-09 18:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"builder",
"0049_element_style_width_child_alter_element_style_width",
),
]
operations = [
migrations.AddField(
model_name="page",
name="query_params",
field=models.JSONField(blank=True, default=list, db_default=[]),
),
migrations.AddField(
model_name="linkelement",
name="query_parameters",
field=models.JSONField(
default=list,
db_default=[],
help_text="The query parameters for each parameter of the selected page if any.",
null=True,
),
),
migrations.AddField(
model_name="openpageworkflowaction",
name="query_parameters",
field=models.JSONField(
default=list,
db_default=[],
help_text="The query parameters for each parameter of the selected page if any.",
null=True,
),
),
]

View file

@ -1,20 +1,20 @@
import re
import typing
from baserow.contrib.builder.pages.types import PAGE_PATH_PARAM_TYPE_CHOICES_LITERAL
from baserow.contrib.builder.pages.types import PAGE_PARAM_TYPE_CHOICES_LITERAL
# Every page param in a page path needs to be prefixed by the below symbol
PAGE_PATH_PARAM_PREFIX = ":"
PAGE_PATH_PARAM_TYPE_CHOICES = list(
typing.get_args(PAGE_PATH_PARAM_TYPE_CHOICES_LITERAL)
)
PAGE_PARAM_TYPE_CHOICES = list(typing.get_args(PAGE_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_]+)$")
QUERY_PARAM_EXACT_MATCH_REGEX = re.compile(r"(^[A-Za-z][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.

View file

@ -1,6 +1,6 @@
from typing import List
from baserow.contrib.builder.pages.constants import PAGE_PATH_PARAM_TYPE_CHOICES
from baserow.contrib.builder.pages.constants import PAGE_PARAM_TYPE_CHOICES
class PageDoesNotExist(Exception):
@ -89,7 +89,7 @@ class InvalidPagePathParamType(Exception):
self.param_type = param_type
super().__init__(
f"The param type {param_type} is invalid, please chose from "
f"{PAGE_PATH_PARAM_TYPE_CHOICES}"
f"{PAGE_PARAM_TYPE_CHOICES}"
)
@ -103,3 +103,32 @@ class DuplicatePathParamsInPath(Exception):
f"The path params {path_param_names} are defined multiple times "
f"in path {path}"
)
class InvalidQueryParamName(Exception):
"""Raised when an invalid query param name is being set"""
def __init__(self, query_param_name: str, *args, **kwargs):
self.query_param_name = query_param_name
super().__init__(f"The query param {query_param_name} is invalid")
class DuplicatePageParams(Exception):
"""Raised when same query param is defined multiple times or query
param names clash with path param names."""
def __init__(
self,
param: str,
query_param_names: List[str],
path_param_names: List[str],
*args,
**kwargs,
):
self.query_param_names = query_param_names
self.path_param_names = path_param_names
self.param = param
super().__init__(
f"The query param {param} is defined multiple times in {query_param_names}"
f"or clash with path params {path_param_names}"
)

View file

@ -16,9 +16,12 @@ from baserow.contrib.builder.pages.constants import (
ILLEGAL_PATH_SAMPLE_CHARACTER,
PAGE_PATH_PARAM_PREFIX,
PATH_PARAM_REGEX,
QUERY_PARAM_EXACT_MATCH_REGEX,
)
from baserow.contrib.builder.pages.exceptions import (
DuplicatePageParams,
DuplicatePathParamsInPath,
InvalidQueryParamName,
PageDoesNotExist,
PageNameNotUnique,
PageNotInBuilder,
@ -28,7 +31,11 @@ from baserow.contrib.builder.pages.exceptions import (
SharedPageIsReadOnly,
)
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.pages.types import PagePathParams
from baserow.contrib.builder.pages.types import (
PagePathParams,
PageQueryParam,
PageQueryParams,
)
from baserow.contrib.builder.types import PageDict
from baserow.contrib.builder.workflow_actions.handler import (
BuilderWorkflowActionHandler,
@ -41,7 +48,7 @@ from baserow.core.utils import ChildProgressBuilder, MirrorDict, find_unused_nam
class PageHandler:
def get_page(self, page_id: int, base_queryset: Optional[QuerySet] = None) -> Page:
"""
Gets a page by ID
Gets a page by ID.
:param page_id: The ID of the page
:param base_queryset: Can be provided to already filter or apply performance
@ -97,15 +104,17 @@ class PageHandler:
name: str,
path: str,
path_params: PagePathParams = None,
query_params: PageQueryParam = None,
shared: bool = False,
) -> Page:
"""
Creates a new page
Creates a new page.
:param builder: The builder the page belongs to
:param name: The name of the page
:param path: The path of the page
:param path_params: The params of the path provided
:param query_params: The query params of the page provided
:param shared: If this is the shared page. They should be only one shared page
per builder application.
:return: The newly created page instance
@ -113,6 +122,7 @@ class PageHandler:
last_order = Page.get_last_order(builder)
path_params = path_params or []
query_params = query_params or []
self.is_page_path_valid(path, path_params, raises=True)
self.is_page_path_unique(builder, path, raises=True)
@ -124,6 +134,7 @@ class PageHandler:
order=last_order,
path=path,
path_params=path_params,
query_params=query_params,
shared=shared,
)
except IntegrityError as e:
@ -137,7 +148,7 @@ class PageHandler:
def delete_page(self, page: Page):
"""
Deletes the page provided
Deletes the page provided.
:param page: The page that must be deleted
"""
@ -149,7 +160,7 @@ class PageHandler:
def update_page(self, page: Page, **kwargs) -> Page:
"""
Updates fields of a page
Updates fields of a page.
:param page: The page that should be updated
:param kwargs: The fields that should be updated with their corresponding value
@ -172,6 +183,13 @@ class PageHandler:
), # We don't want to conflict with the current page
raises=True,
)
if "query_params" in kwargs:
query_params = kwargs.get("query_params")
self.validate_query_params(
kwargs.get("path", page.path),
kwargs.get("path_params", page.path_params),
query_params,
)
for key, value in kwargs.items():
setattr(page, key, value)
@ -215,7 +233,7 @@ class PageHandler:
self, page: Page, progress_builder: Optional[ChildProgressBuilder] = None
):
"""
Duplicates an existing page instance
Duplicates an existing page instance.
:param page: The page that is being duplicated
:param progress_builder: A progress object that can be used to report progress
@ -343,6 +361,47 @@ class PageHandler:
return True
def validate_query_params(
self, path: str, path_params: PagePathParams, query_params: PageQueryParams
) -> bool:
"""
Validates the query parameters of a page.
:param path: The path of the page.
:param path_params: The path parameters defined for the page.
:param query_params: The query parameters to validate.
:raises InvalidQueryParamName: If a query parameter name doesn't match the
required format.
:raises DuplicatePageParams: If a query parameter is defined multiple times
or clashes with path parameters.
:return: True if validation passes.
"""
# Extract path param names for checking duplicates
path_param_names = [p["name"] for p in path_params]
# Get list of query param names
query_param_names = [p["name"] for p in query_params]
# Check for duplicates within query params
seen_params = set()
for param_name in query_param_names:
# Validate query param name format using regex
if not QUERY_PARAM_EXACT_MATCH_REGEX.match(param_name):
raise InvalidQueryParamName(query_param_name=param_name)
# Check if param name already seen or conflicts with path param
if param_name in seen_params or param_name in path_param_names:
raise DuplicatePageParams(
param=param_name,
query_param_names=query_param_names,
path_param_names=path_param_names,
)
seen_params.add(param_name)
return True
def is_page_path_unique(
self,
builder: Builder,
@ -369,7 +428,6 @@ class PageHandler:
if raises:
raise PagePathNotUnique(path=path, builder_id=builder.id)
return False
return True
def generalise_path(self, path: str) -> str:
@ -445,6 +503,7 @@ class PageHandler:
order=page.order,
path=page.path,
path_params=page.path_params,
query_params=page.query_params,
shared=page.shared,
elements=serialized_elements,
data_sources=serialized_data_sources,
@ -613,6 +672,7 @@ class PageHandler:
order=serialized_page["order"],
path=serialized_page["path"],
path_params=serialized_page["path_params"],
query_params=serialized_page.get("query_params", []),
shared=False,
visibility=serialized_page.get("visibility", Page.VISIBILITY_TYPES.ALL),
role_type=serialized_page.get("role_type", Page.ROLE_TYPES.ALLOW_ALL),

View file

@ -54,6 +54,7 @@ class Page(
name = models.CharField(max_length=255)
path = models.CharField(max_length=255, validators=[path_validation])
path_params = models.JSONField(default=dict)
query_params = models.JSONField(default=list, blank=True, db_default=[])
# Shared page is invisible to the user but contains all shared data like
# shared data sources or shared elements. That way we keep everything working as

View file

@ -19,7 +19,7 @@ from baserow.contrib.builder.pages.signals import (
page_updated,
pages_reordered,
)
from baserow.contrib.builder.pages.types import PagePathParams
from baserow.contrib.builder.pages.types import PagePathParams, PageQueryParams
from baserow.core.handler import CoreHandler
from baserow.core.utils import ChildProgressBuilder, extract_allowed
@ -55,6 +55,7 @@ class PageService:
name: str,
path: str,
path_params: PagePathParams = None,
query_params: PageQueryParams = None,
) -> Page:
"""
Creates a new page
@ -74,7 +75,9 @@ class PageService:
context=builder,
)
page = self.handler.create_page(builder, name, path, path_params=path_params)
page = self.handler.create_page(
builder, name, path, path_params=path_params, query_params=query_params
)
page_created.send(self, page=page, user=user)
@ -119,7 +122,16 @@ class PageService:
)
allowed_updates = extract_allowed(
kwargs, ["name", "path", "path_params", "visibility", "role_type", "roles"]
kwargs,
[
"name",
"path",
"path_params",
"visibility",
"role_type",
"roles",
"query_params",
],
)
self.handler.update_page(page, **allowed_updates)

View file

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

View file

@ -4,6 +4,7 @@ from django.core.validators import URLValidator
from baserow.contrib.builder.pages.constants import (
PAGE_PATH_PARAM_PREFIX,
PATH_PARAM_EXACT_MATCH_REGEX,
QUERY_PARAM_EXACT_MATCH_REGEX,
)
@ -39,3 +40,15 @@ def path_param_name_validation(value: str):
if not PATH_PARAM_EXACT_MATCH_REGEX.match(full_path_param):
raise ValidationError(f"Path param {value} contains invalid characters")
def query_param_name_validation(value: str):
"""
Verifies that the path param is semantically valid.
:param value: The path param to check
:raises ValidationError: If the path param is not semantically valid
"""
if not QUERY_PARAM_EXACT_MATCH_REGEX.match(value):
raise ValidationError(f"Query param {value} contains invalid characters")

View file

@ -1,6 +1,6 @@
from typing import List, Optional, TypedDict
from baserow.contrib.builder.pages.types import PagePathParams
from baserow.contrib.builder.pages.types import PagePathParams, PageQueryParams
from baserow.core.integrations.types import IntegrationDictSubClass
from baserow.core.services.types import ServiceDictSubClass
from baserow.core.user_sources.types import UserSourceDictSubClass
@ -56,6 +56,7 @@ class PageDict(TypedDict):
order: int
path: str
path_params: PagePathParams
query_params: PageQueryParams
elements: List[ElementDict]
data_sources: List[DataSourceDict]
workflow_actions: List[WorkflowAction]

View file

@ -135,6 +135,12 @@ class OpenPageWorkflowActionType(BuilderWorkflowActionType):
workflow_action.page_parameters[index]["value"] = new_formula
yield workflow_action
for index, query_parameter in enumerate(workflow_action.query_parameters or []):
new_formula = yield query_parameter.get("value")
if new_formula is not None:
workflow_action.query_parameters[index]["value"] = new_formula
yield workflow_action
def deserialize_property(
self,
prop_name,

View file

@ -2,5 +2,5 @@ class FilterNotSupportedException(Exception):
def __str__(self):
return (
f"Filter doesn't support given field type: "
f"{self.args[0] if self.args else 'not specified' }"
f"{self.args[0] if self.args else 'not specified'}"
)

View file

@ -29,18 +29,24 @@ def ensure_boolean(value: Any) -> bool:
raise ValidationError("Value is not a valid boolean or convertible to a boolean.")
def ensure_integer(value: Any) -> int:
def ensure_integer(value: Any, allow_empty: bool = False) -> Optional[int]:
"""
Ensures that the value is an integer or can be converted to an integer.
Raises a ValidationError if the value is not a valid integer or convertible to an
integer.
:param value: The value to ensure as an integer.
:param allow_empty: Whether we should throw an error if `value` is empty.
:return: The value as an integer if conversion is successful.
:raises ValidationError: If the value is not a valid integer or convertible to an
integer.
"""
if value is None or value == "":
if not allow_empty:
raise ValidationError("The value is required.")
return None
try:
return int(value)
except (ValueError, TypeError) as exc:

View file

@ -147,6 +147,7 @@ def test_get_public_builder_by_domain_name(api_client, data_fixture):
"name": "__shared__",
"path": "__shared__",
"path_params": [],
"query_params": [],
"shared": True,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
@ -157,6 +158,7 @@ def test_get_public_builder_by_domain_name(api_client, data_fixture):
"name": page.name,
"path": page.path,
"path_params": [],
"query_params": [],
"shared": False,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
@ -167,6 +169,7 @@ def test_get_public_builder_by_domain_name(api_client, data_fixture):
"name": page2.name,
"path": page2.path,
"path_params": [],
"query_params": [],
"shared": False,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
@ -276,6 +279,7 @@ def test_get_public_builder_by_id(api_client, data_fixture):
"name": "__shared__",
"path": "__shared__",
"path_params": [],
"query_params": [],
"shared": True,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
@ -286,6 +290,7 @@ def test_get_public_builder_by_id(api_client, data_fixture):
"name": page.name,
"path": page.path,
"path_params": [],
"query_params": [],
"shared": False,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
@ -296,6 +301,7 @@ def test_get_public_builder_by_id(api_client, data_fixture):
"name": page2.name,
"path": page2.path,
"path_params": [],
"query_params": [],
"shared": False,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,

View file

@ -104,6 +104,8 @@ def test_can_update_a_table_element_fields(api_client, data_fixture):
"navigate_to_url": "get('test2')",
"link_name": "get('test3')",
"target": "self",
"page_parameters": [],
"query_parameters": [],
"variant": LinkElement.VARIANTS.BUTTON,
"styles": {},
"uid": uuids[1],

View file

@ -180,6 +180,7 @@ def test_get_builder_application(api_client, data_fixture):
"name": "__shared__",
"path": "__shared__",
"path_params": [],
"query_params": [],
"shared": True,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
@ -239,6 +240,7 @@ def test_list_builder_applications(api_client, data_fixture):
"name": "__shared__",
"path": "__shared__",
"path_params": [],
"query_params": [],
"shared": True,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,

View file

@ -203,6 +203,12 @@ def test_link_collection_field_import_export_formula(data_fixture):
"value": f"get('data_source.{data_source_1.id}.field_1')",
},
],
"query_parameters": [
{
"name": "fooQueryParam",
"value": f"get('data_source.{data_source_1.id}.field_2')",
},
],
"target": "",
"variant": LinkElement.VARIANTS.BUTTON,
},
@ -219,10 +225,14 @@ def test_link_collection_field_import_export_formula(data_fixture):
imported_element = field_type.import_serialized(page, serialized, id_mapping)
expected_formula = f"get('data_source.{data_source_2.id}.field_1')"
expected_query_formula = f"get('data_source.{data_source_2.id}.field_2')"
imported_field = imported_element.fields.all()[0]
assert imported_field.config["link_name"] == expected_formula
assert imported_field.config["navigate_to_url"] == expected_formula
assert imported_field.config["page_parameters"][0]["value"] == expected_formula
assert (
imported_field.config["query_parameters"][0]["value"] == expected_query_formula
)
@pytest.mark.django_db
@ -244,6 +254,12 @@ def test_link_element_import_export_formula(data_fixture):
"value": f"get('data_source.{data_source_1.id}.field_1')",
},
],
query_parameters=[
{
"name": "fooQueryParam",
"value": f"get('data_source.{data_source_1.id}.field_2')",
},
],
)
serialized = element_type.export_serialized(exported_element)
@ -253,14 +269,11 @@ def test_link_element_import_export_formula(data_fixture):
imported_element = element_type.import_serialized(page, serialized, id_mapping)
expected_formula = f"get('data_source.{data_source_2.id}.field_1')"
expected_query_formula = f"get('data_source.{data_source_2.id}.field_2')"
assert imported_element.navigate_to_url == expected_formula
assert imported_element.value == expected_formula
assert imported_element.page_parameters == [
{
"name": "fooPageParam",
"value": expected_formula,
},
]
assert imported_element.page_parameters[0]["value"] == expected_formula
assert imported_element.query_parameters[0]["value"] == expected_query_formula
@pytest.mark.django_db

View file

@ -73,6 +73,13 @@ def test_import_export_link_collection_field_type(data_fixture):
"value": f"get('data_source.{data_source.id}.field_1')",
},
],
"query_parameters": [
{
"name": "fooQueryParam",
"value": f"get('data_source.{data_source.id}.field_1')",
},
],
"target": "self",
"variant": LinkElement.VARIANTS.LINK,
},
},
@ -101,6 +108,12 @@ def test_import_export_link_collection_field_type(data_fixture):
"navigate_to_url": f"get('data_source.{data_source2.id}.0.{text_field.db_column}')",
"navigate_to_page_id": None,
"navigation_type": "page",
"query_parameters": [
{
"name": "fooQueryParam",
"value": f"get('data_source.{data_source2.id}.field_1')",
},
],
"page_parameters": [
{
"name": "fooPageParam",

View file

@ -112,6 +112,9 @@ def test_repeat_element_import_child_with_formula_with_current_record(data_fixtu
"page_parameters": [
{"name": "id", "value": "get('current_record.field_424')"}
],
"query_parameters": [
{"name": "query_id", "value": "get('current_record.field_424')"}
],
"roles": [],
"role_type": Element.ROLE_TYPES.ALLOW_ALL,
},
@ -169,6 +172,7 @@ def test_repeat_element_import_child_with_formula_with_current_record(data_fixtu
assert link.value == migrated_ref
assert link.navigate_to_url == migrated_ref
assert link.page_parameters[0]["value"] == migrated_ref
assert link.query_parameters[0]["value"] == migrated_ref
@pytest.mark.django_db

View file

@ -203,6 +203,7 @@ def test_duplicate_table_element_with_current_record_formulas(data_fixture):
"type": "link",
"config": {
"page_parameters": [],
"query_parameters": [],
"navigate_to_page_id": None,
"navigation_type": "custom",
"navigate_to_url": f"get('current_record.field_{fields[0].id}')",
@ -220,6 +221,7 @@ def test_duplicate_table_element_with_current_record_formulas(data_fixture):
{"value": f"get('current_record.field_{fields[0].id}')"},
{
"page_parameters": [],
"query_parameters": [],
"navigate_to_page_id": None,
"navigation_type": "custom",
"navigate_to_url": f"get('current_record.field_{fields[0].id}')",
@ -273,6 +275,7 @@ def test_import_table_element_with_current_record_formulas_with_update(data_fixt
"name": "Field 2",
"config": {
"page_parameters": [],
"query_parameters": [],
"navigate_to_page_id": None,
"navigation_type": "custom",
"navigate_to_url": f"get('current_record.field_42')",
@ -301,6 +304,7 @@ def test_import_table_element_with_current_record_formulas_with_update(data_fixt
{"value": f"get('current_record.field_{fields[0].id}')"},
{
"page_parameters": [],
"query_parameters": [],
"navigate_to_page_id": None,
"navigation_type": "custom",
"navigate_to_url": f"get('current_record.field_{fields[0].id}')",

View file

@ -4,7 +4,9 @@ from baserow.contrib.builder.elements.models import ColumnElement, TextElement
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.pages.constants import ILLEGAL_PATH_SAMPLE_CHARACTER
from baserow.contrib.builder.pages.exceptions import (
DuplicatePageParams,
DuplicatePathParamsInPath,
InvalidQueryParamName,
PageDoesNotExist,
PageNameNotUnique,
PageNotInBuilder,
@ -67,6 +69,30 @@ def test_create_page_page_path_not_unique(data_fixture):
PageHandler().create_page(page.builder, name="test", path="/test/test")
@pytest.mark.django_db
def test_create_page_with_query_params(data_fixture):
builder = data_fixture.create_builder_application()
# with pytest.raises(DuplicatePathParamsInPath):
PageHandler().create_page(
builder,
name="test",
path="/test/",
query_params=[{"name": "my_param", "param_type": "text"}],
)
@pytest.mark.django_db
def test_update_page_with_query_params(data_fixture):
page = data_fixture.create_builder_page(name="test")
update_data = [{"name": "my_param", "param_type": "text"}]
PageHandler().update_page(
page,
query_params=update_data,
)
page.refresh_from_db()
assert page.query_params == update_data
@pytest.mark.django_db
def test_create_page_duplicate_params_in_path(data_fixture):
builder = data_fixture.create_builder_application()
@ -376,3 +402,118 @@ def test_import_element_has_to_instance_already_created(data_fixture):
assert imported_text.parent_element_id != text_element.parent_element_id
assert imported_text.parent_element_id == imported_column.id
def test_validate_query_params_valid():
"""Test validation with valid query parameters."""
handler = PageHandler()
path = "/products/:id"
path_params = [{"name": "id", "type": "text"}]
query_params = [
{"name": "filter", "type": "text"},
{"name": "sort", "type": "text"},
]
# Should return True for valid params
assert handler.validate_query_params(path, path_params, query_params) is True
def test_validate_query_params_invalid_name():
"""Test validation with invalid query parameter names."""
handler = PageHandler()
path = "/products"
path_params = []
# Test with invalid characters
invalid_params = [{"name": "filter@", "type": "text"}]
with pytest.raises(InvalidQueryParamName):
handler.validate_query_params(path, path_params, invalid_params)
# Test with spaces
invalid_params = [{"name": "filter name", "type": "text"}]
with pytest.raises(InvalidQueryParamName):
handler.validate_query_params(path, path_params, invalid_params)
# Test with empty name
invalid_params = [{"name": "", "type": "text"}]
with pytest.raises(InvalidQueryParamName):
handler.validate_query_params(path, path_params, invalid_params)
def test_validate_query_params_duplicate_names():
"""Test validation with duplicate query parameter names."""
handler = PageHandler()
path = "/products"
path_params = []
# Test duplicate query param names
duplicate_params = [
{"name": "filter", "type": "text"},
{"name": "filter", "type": "text"},
]
with pytest.raises(DuplicatePageParams):
handler.validate_query_params(path, path_params, duplicate_params)
def test_validate_query_params_clash_with_path_params():
"""Test validation when query params clash with path params."""
handler = PageHandler()
path = "/products/:id"
path_params = [{"name": "id", "type": "text"}]
# Query param name matches path param name
clashing_params = [{"name": "id", "type": "text"}]
with pytest.raises(DuplicatePageParams):
handler.validate_query_params(path, path_params, clashing_params)
def test_validate_query_params_empty_lists():
"""Test validation with empty parameter lists."""
handler = PageHandler()
path = "/products"
# Should handle empty lists without errors
assert handler.validate_query_params(path, [], []) is True
def test_validate_query_params_special_cases():
"""Test validation with special but valid cases."""
handler = PageHandler()
path = "/products"
path_params = []
# Test with underscores and numbers (valid)
valid_params = [
{"name": "filter_1", "type": "text"},
{"name": "sort_by-2", "type": "text"},
{"name": "prefix", "type": "text"},
]
assert handler.validate_query_params(path, path_params, valid_params) is True
def test_validate_query_params_edge_cases():
"""Test validation with edge cases."""
handler = PageHandler()
path = "/products/:id"
path_params = [{"name": "id", "type": "text"}]
# Test with maximum allowed characters (assuming there's no specific limit)
long_name = "a" * 255
long_params = [{"name": long_name, "type": "text"}]
# This should pass as there's no explicit length limitation in the validation
assert handler.validate_query_params(path, path_params, long_params) is True
# Test with various special characters (should fail)
special_chars = ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "="]
for char in special_chars:
invalid_params = [{"name": f"filter{char}", "type": "text"}]
with pytest.raises(InvalidQueryParamName):
handler.validate_query_params(path, path_params, invalid_params)

View file

@ -199,6 +199,7 @@ def test_builder_application_export(data_fixture):
"order": page2.order,
"path": page2.path,
"path_params": page2.path_params,
"query_params": [],
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
@ -350,6 +351,7 @@ def test_builder_application_export(data_fixture):
"order": shared_page.order,
"path": shared_page.path,
"path_params": shared_page.path_params,
"query_params": [],
"shared": True,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
@ -381,6 +383,7 @@ def test_builder_application_export(data_fixture):
"order": page1.order,
"path": page1.path,
"path_params": page1.path_params,
"query_params": [],
"shared": False,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
@ -791,6 +794,7 @@ IMPORT_REFERENCE = {
"order": 1,
"path": "/test",
"path_params": {},
"query_params": [],
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
@ -876,6 +880,7 @@ IMPORT_REFERENCE = {
"uid": str(uuid.uuid4()),
"config": {
"page_parameters": [],
# "query_parameters": [],
"navigation_type": "custom",
"navigate_to_page_id": None,
"navigate_to_url": "get('current_record.field_25')",

View file

@ -129,6 +129,16 @@ def test_link_element_formula_generator(data_fixture, formula_generator_fixture)
"value": formula_generator_fixture["formula_1"],
},
],
query_parameters=[
{
"name": "query1",
"value": formula_generator_fixture["formula_1"],
},
{
"name": "query2",
"value": formula_generator_fixture["formula_1"],
},
],
)
serialized_element = LinkElementType().export_serialized(exported_element)
@ -143,6 +153,9 @@ def test_link_element_formula_generator(data_fixture, formula_generator_fixture)
for page_param in imported_element.page_parameters:
assert page_param.get("value") == formula_generator_fixture["formula_2"]
for query_param in imported_element.query_parameters:
assert query_param.get("value") == formula_generator_fixture["formula_2"]
@pytest.mark.django_db
def test_table_element_formula_generator(data_fixture, formula_generator_fixture):

View file

@ -338,7 +338,14 @@ def test_import_open_page_workflow_action(data_fixture):
"value": f"get('data_source.{data_source_1.id}.field_1')",
},
],
query_parameters=[
{
"name": "fooQueryParam",
"value": f"get('data_source.{data_source_1.id}.field_2')",
},
],
)
serialized = workflow_action_type.export_serialized(exported_workflow_action)
# After applying the ID mapping the imported formula should have updated
@ -350,12 +357,11 @@ def test_import_open_page_workflow_action(data_fixture):
imported_workflow_action = workflow_action_type.import_serialized(
page, serialized, id_mapping
)
expected_formula = f"get('data_source.{data_source_2.id}.field_1')"
assert imported_workflow_action.navigate_to_url == expected_formula
assert imported_workflow_action.page_parameters == [
{
"name": "fooPageParam",
"value": f"get('data_source.{data_source_2.id}.field_1')",
},
]
expected_formula = f"get('data_source.{data_source_2.id}.field_1')"
expected_query_formula = f"get('data_source.{data_source_2.id}.field_2')"
assert imported_workflow_action.navigate_to_url == expected_formula
assert imported_workflow_action.page_parameters[0]["value"] == expected_formula
assert (
imported_workflow_action.query_parameters[0]["value"] == expected_query_formula
)

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "[Builder] Introducing query parameters",
"issue_number": 1661,
"bullet_points": [],
"created_at": "2025-02-02"
}

View file

@ -22,6 +22,7 @@ export async function createBuilderPage(
name: pageName,
path,
path_params: [],
query_params: [],
}
);
return new BuilderPage(

View file

@ -1,11 +1,11 @@
import { expect, test } from "../baserowTest";
import {expect, test} from "../baserowTest";
test.describe("Builder page test suite", () => {
test.beforeEach(async ({ builderPagePage }) => {
test.beforeEach(async ({builderPagePage}) => {
await builderPagePage.goto();
});
test("Can create a page", async ({ page }) => {
test("Can create a page", async ({page}) => {
await page.getByText("New page").click();
await page.getByText("Create page").waitFor();
await page
@ -25,12 +25,12 @@ test.describe("Builder page test suite", () => {
).toBeVisible();
});
test("Can open page settings", async ({ page }) => {
test("Can open page settings", async ({page}) => {
await page.getByText("Page settings").click();
await expect(page.locator(".box__title").getByText("Page")).toBeVisible();
});
test("Can change page settings", async ({ page }) => {
test("Can change page settings", async ({page}) => {
await page.getByText("Page settings").click();
await page
@ -57,9 +57,9 @@ test.describe("Builder page test suite", () => {
).toBeVisible();
});
test("Can create an element from empty page", async ({ page }) => {
test("Can create an element from empty page", async ({page}) => {
await page.getByText("Click to create an element").click();
await page.getByText("Heading", { exact: true }).click();
await page.getByText("Heading", {exact: true}).click();
await expect(
page.locator(".modal__box").getByText("Add new element")
@ -69,13 +69,13 @@ test.describe("Builder page test suite", () => {
).toBeVisible();
});
test("Can create an element from element menu", async ({ page }) => {
test("Can create an element from element menu", async ({page}) => {
await page.locator(".header").getByText("Elements").click();
await page
.locator(".elements-context")
.getByText("Element", { exact: true })
.getByText("Element", {exact: true})
.click();
await page.getByText("Heading", { exact: true }).click();
await page.getByText("Heading", {exact: true}).click();
await expect(
page.locator(".modal__box").getByText("Add new element")
@ -84,4 +84,43 @@ test.describe("Builder page test suite", () => {
page.locator(".element-preview__name").getByText("Heading")
).toBeVisible();
});
test("Can add query parameter to page setting", async ({page}) => {
await page.getByText("Page settings").click();
await page
.locator(".modal__wrapper")
.getByPlaceholder("Enter a name...")
.fill("New page name");
await page.getByRole('button', {name: 'Add query string parameter'}).click();
await page
.locator(".page-settings-query-params .form-input__wrapper")
.getByRole('textbox')
.fill("my_param");
await page.locator(".button").getByText("Save").click();
await expect(
page.getByText("The page settings have been updated.")
).toBeVisible();
await page.getByTitle("Close").click();
await expect(page.locator(".box__title").getByText("Page")).toBeHidden();
await page.getByText('Click to create an element').click();
await page.getByText('Link A link to page/URL').click();
await page.getByRole('complementary').getByRole('textbox').click();
await page.getByRole('complementary').getByRole('textbox').locator('div').first().fill('linkim');
await page.locator('a').filter({hasText: 'Make a choice'}).click();
await page.locator('a').filter({hasText: '?my_param=*'}).click();
// click empty place to close tooltip from prev. step
await page.click('body')
await page.getByRole('textbox').nth(2).click();
await page.getByText('my_param', { exact: true }).first().click();
await page.click('body')
await expect(page.getByRole('link', {name: 'linkim'})).toHaveAttribute(
'href', /\?my_param=null/);
});
});

View file

@ -65,7 +65,7 @@ export default {
this.mode
)
} catch (e) {
return ''
return '#error'
}
},
},

View file

@ -11,11 +11,14 @@
<template v-if="destinationPage">
{{ destinationPage.name }}
<span
v-tooltip:[tooltipOptions]="destinationPagePathWithParams"
class="link-navigation-selection-form__navigate-option-page-path"
>
{{ destinationPage.path }}
</span></template
>
<span>
{{ destinationPagePathWithParams }}
</span>
</span>
</template>
<span v-else>{{
$t('linkNavigationSelection.navigateToCustom')
}}</span>
@ -27,12 +30,7 @@
:value="pageItem.id"
:name="pageItem.name"
>
{{ pageItem.name }}
<span
class="link-navigation-selection-form__navigate-option-page-path"
>
{{ pageItem.path }}
</span>
{{ pageItem.name }} {{ getPagePathWithParams(pageItem) }}
</DropdownItem>
<DropdownItem
:name="$t('linkNavigationSelection.navigateToCustom')"
@ -84,6 +82,20 @@
:placeholder="$t('linkNavigationSelection.paramPlaceholder')"
/>
</FormGroup>
<FormGroup
v-for="param in values.query_parameters"
:key="param.name"
small-label
:label="param.name"
class="margin-bottom-2"
required
horizontal
>
<InjectedFormulaInput
v-model="param.value"
:placeholder="$t('linkNavigationSelection.paramPlaceholder')"
/>
</FormGroup>
</div>
</FormGroup>
<FormGroup
@ -121,6 +133,7 @@ export default {
'navigate_to_page_id',
'navigate_to_url',
'page_parameters',
'query_parameters',
'target',
],
values: {
@ -128,6 +141,7 @@ export default {
navigate_to_page_id: null,
navigate_to_url: '',
page_parameters: [],
query_parameters: [],
target: 'self',
},
linkNavigationSelectionTargetOptions: [
@ -137,6 +151,9 @@ export default {
label: this.$t('linkNavigationSelection.targetNewTab'),
},
],
tooltipOptions: {
contentClasses: ['tooltip__content--expandable'],
},
}
},
computed: {
@ -149,6 +166,10 @@ export default {
}
return null
},
destinationPagePathWithParams() {
if (!this.destinationPage) return ''
return this.getPagePathWithParams(this.destinationPage)
},
},
watch: {
'destinationPage.path_params': {
@ -173,7 +194,6 @@ export default {
},
destinationPage(value) {
this.updatePageParameters()
// This means that the page select does not exist anymore
if (value === undefined) {
this.values.navigate_to_page_id = null
@ -196,16 +216,39 @@ export default {
this.refreshParametersInError()
},
methods: {
getPagePathWithParams(page) {
const shortTypes = {
text: '*',
numeric: '#',
}
let path = page.path
if (page.query_params.length) {
path += `?${page.query_params
.map((p) => `${p.name}=${shortTypes[p.type]}`)
.join('&')}`
}
return path
},
refreshParametersInError() {
this.parametersInError = pathParametersInError(this.values, this.pages)
},
updatePageParameters() {
// Update path parameters
this.values.page_parameters = (
this.destinationPage?.path_params || []
).map(({ name }, index) => {
const previousValue = this.values.page_parameters[index]?.value || ''
return { name, value: previousValue }
})
// Update query parameters
this.values.query_parameters = (
this.destinationPage?.query_params || []
).map(({ name }, index) => {
const previousValue = this.values.query_parameters[index]?.value || ''
return { name, value: previousValue }
})
this.parametersInError = false
},
},

View file

@ -50,7 +50,12 @@ export default {
}
},
methods: {
async addPage({ name, path, path_params: pathParams }) {
async addPage({
name,
path,
path_params: pathParams,
query_params: queryParams,
}) {
this.loading = true
try {
const page = await this.$store.dispatch('page/create', {
@ -58,6 +63,7 @@ export default {
name,
path,
pathParams,
queryParams,
})
this.$refs.pageForm.$v.$reset()
this.hide()

View file

@ -8,7 +8,7 @@
<PreviewNavigationBarInput
v-if="pathPart.type === 'variable'"
:key="pathPart.key"
:class="`preview-navigation-bar__address-bar-parameter-input--${
:class="`preview-navigation-bar__parameter-input--${
paramTypeMap[pathPart.value]
}`"
:validation-fn="pathPart.validationFn"
@ -29,6 +29,28 @@
{{ pathPart.value }}
</div>
</template>
<template v-for="(queryParam, index) in queryParams">
<span
:key="`separator-${queryParam.key}`"
class="preview-navigation-bar__query-separator"
>
{{ index === 0 ? '?' : '&' }}
</span>
<PreviewNavigationBarQueryParam
:key="`param-${queryParam.key}`"
:class="`preview-navigation-bar__query-parameter-input--${queryParam.type}`"
:validation-fn="queryParam.validationFn"
:default-value="pageParameters[queryParam.name]"
:name="queryParam.name"
@change="
actionSetParameter({
page,
name: queryParam.name,
value: $event,
})
"
/>
</template>
</div>
<div />
</div>
@ -40,9 +62,14 @@ import PreviewNavigationBarInput from '@baserow/modules/builder/components/page/
import UserSelector from '@baserow/modules/builder/components/page/UserSelector'
import { mapActions } from 'vuex'
import { PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS } from '@baserow/modules/builder/enums'
import PreviewNavigationBarQueryParam from '@baserow/modules/builder/components/page/PreviewNavigationBarQueryParam.vue'
export default {
components: { PreviewNavigationBarInput, UserSelector },
components: {
PreviewNavigationBarInput,
UserSelector,
PreviewNavigationBarQueryParam,
},
props: {
page: {
type: Object,
@ -53,6 +80,13 @@ export default {
pageParameters() {
return this.$store.getters['pageParameter/getParameters'](this.page)
},
queryParams() {
return this.page.query_params.map((queryParam, idx) => ({
...queryParam,
key: `query-param-${queryParam.name}-${idx}`,
validationFn: PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS[queryParam.type],
}))
},
splitPath() {
return splitPath(this.page.path).map((pathPart, index) => ({
...pathPart,

View file

@ -0,0 +1,48 @@
<template>
<div class="preview-navigation-bar-param">
<label :for="name" class="preview-navigation-bar-param__label">
{{ name }}=</label
><input
:id="name"
v-model="inputValue"
class="preview-navigation-bar-input"
/>
</div>
</template>
<script>
export default {
props: {
defaultValue: {
type: [String, Number],
required: false,
default: '',
},
name: {
type: String,
required: true,
},
},
data() {
return {
value: this.defaultValue,
}
},
computed: {
inputValue: {
get() {
return this.value
},
set(inputValue) {
this.value = inputValue
this.$emit('change', this.value)
},
},
},
watch: {
defaultValue(newValue) {
this.value = newValue
},
},
}
</script>

View file

@ -43,7 +43,12 @@ export default {
},
methods: {
...mapActions({ actionUpdatePage: 'page/update' }),
async updatePage({ name, path, path_params: pathPrams }) {
async updatePage({
name,
path,
path_params: pathParams,
query_params: queryParams,
}) {
this.success = false
this.loading = true
this.hideError()
@ -54,18 +59,26 @@ export default {
values: {
name,
path,
path_params: pathPrams,
path_params: pathParams,
query_params: queryParams,
},
})
await Promise.all(
pathPrams.map(({ name, type }) =>
await Promise.all([
...pathParams.map(({ name, type }) =>
this.$store.dispatch('pageParameter/setParameter', {
page: this.currentPage,
name,
value: defaultValueForParameterType(type),
})
)
)
),
...queryParams.map(({ name, type }) =>
this.$store.dispatch('pageParameter/setParameter', {
page: this.currentPage,
name,
value: null,
})
),
])
this.success = true
} catch (error) {
this.handleError(error)

View file

@ -23,6 +23,16 @@
</div>
</div>
<div class="row">
<div class="col col-6">
<PageSettingsQueryParamsFormElement
:disabled="!hasPermission"
:query-params="localQueryParams"
:has-errors="fieldHasErrors('query_params')"
:validation-state="$v.values.query_params"
@update="onQueryParamUpdate"
@add="addQueryParam"
/>
</div>
<div class="col col-6">
<PageSettingsPathParamsFormElement
:disabled="!hasPermission"
@ -30,7 +40,6 @@
@update="onPathParamUpdate"
/>
</div>
<div class="col col-6"></div>
</div>
<slot></slot>
</form>
@ -43,12 +52,14 @@ import form from '@baserow/modules/core/mixins/form'
import PageSettingsNameFormElement from '@baserow/modules/builder/components/page/settings/PageSettingsNameFormElement'
import PageSettingsPathFormElement from '@baserow/modules/builder/components/page/settings/PageSettingsPathFormElement'
import PageSettingsPathParamsFormElement from '@baserow/modules/builder/components/page/settings/PageSettingsPathParamsFormElement'
import PageSettingsQueryParamsFormElement from '@baserow/modules/builder/components/page/settings/PageSettingsQueryParamsFormElement'
import {
getPathParams,
PATH_PARAM_REGEX,
ILLEGAL_PATH_SAMPLE_CHARACTER,
VALID_PATH_CHARACTERS,
} from '@baserow/modules/builder/utils/path'
import { QUERY_PARAM_REGEX } from '@baserow/modules/builder/utils/params'
import {
getNextAvailableNameInSequence,
slugify,
@ -58,6 +69,7 @@ export default {
name: 'PageSettingsForm',
components: {
PageSettingsPathParamsFormElement,
PageSettingsQueryParamsFormElement,
PageSettingsPathFormElement,
PageSettingsNameFormElement,
},
@ -81,7 +93,9 @@ export default {
name: '',
path: '',
path_params: [],
query_params: [],
},
localQueryParams: [],
hasPathBeenEdited: false,
}
},
@ -200,6 +214,16 @@ export default {
},
immediate: true,
},
'values.query_params': {
handler(newQueryParams) {
this.localQueryParams = [...newQueryParams]
// Touch the validation when params change
if (this.$v.values.query_params) {
this.$v.values.query_params.$touch()
}
},
immediate: true,
},
},
created() {
if (this.isCreation) {
@ -226,6 +250,21 @@ export default {
}
})
},
onQueryParamUpdate(updatedQueryParams) {
this.localQueryParams = updatedQueryParams
this.values.query_params = updatedQueryParams
if (this.$v.values.query_params) {
this.$v.values.query_params.$touch()
}
},
getFormValues() {
return Object.assign({}, this.values, this.getChildFormsValues(), {
query_params: this.localQueryParams,
})
},
addQueryParam(newParam) {
this.localQueryParams.push(newParam)
},
isNameUnique(name) {
return !this.pageNames.includes(name) || name === this.page?.name
},
@ -249,6 +288,32 @@ export default {
const pathParams = getPathParams(path)
return new Set(pathParams).size === pathParams.length
},
areQueryParamsUnique(queryParams) {
const uniqueParams = new Set()
// First check if all query param names match the regex pattern
for (const param of queryParams) {
// Create a regex with ^ and $ to ensure full string match
const fullMatchRegex = new RegExp(`^${QUERY_PARAM_REGEX.source}$`)
if (!fullMatchRegex.test(param.name)) {
return false
}
}
// Then check for uniqueness against path params and other query params
for (const pathParam of this.values.path_params) {
uniqueParams.add(pathParam.name)
}
for (const param of queryParams) {
if (uniqueParams.has(param.name)) {
return false
}
uniqueParams.add(param.name)
}
return true
},
},
validations() {
return {
@ -258,6 +323,9 @@ export default {
isUnique: this.isNameUnique,
maxLength: maxLength(255),
},
query_params: {
uniqueQueryParams: this.areQueryParamsUnique,
},
path: {
required,
isUnique: this.isPathUnique,

View file

@ -0,0 +1,152 @@
<template>
<FormGroup
small-label
:label="$t('pageForm.queryParamsTitle')"
:error="
hasErrors ||
(validationState.$dirty && !validationState.uniqueQueryParams)
"
required
>
<div
v-for="(queryParam, index) in values.queryParams"
:key="index"
class="page-settings-query-params"
>
<FormInput
:value="queryParam.name"
class="page-settings-query-params__name"
@input="updateQueryParamName(index, $event)"
></FormInput>
<div class="page-settings-query-params__dropdown">
<Dropdown
:value="queryParam.type"
:disabled="disabled"
@input="updateQueryParamType(index, $event)"
>
<DropdownItem
v-for="queryParamType in queryParamTypes"
:key="queryParamType.getType()"
:name="queryParamType.name"
:value="queryParamType.getType()"
></DropdownItem>
</Dropdown>
</div>
<ButtonIcon
class="filters__remove page-settings-query-params__remove"
icon="iconoir-bin"
@click="deleteQueryParam(index)"
/>
</div>
<template #helper>
<template v-if="queryParams.length == 0">
{{ $t('pageForm.queryParamsSubtitleTutorial') }}
</template>
</template>
<div>
<ButtonText
class="page-settings-query-params__add-button"
icon="iconoir-plus"
@click.prevent="addParameter"
>
{{
values.queryParams.length > 0
? $t('pageForm.addAnotherParameter')
: $t('pageForm.addParameter')
}}
</ButtonText>
</div>
<span
v-if="validationState.$dirty && !validationState.uniqueQueryParams"
class="error"
>
{{ $t('pageErrors.errorUniqueValidQueryParams') }}
</span>
</FormGroup>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
import { getNextAvailableNameInSequence } from '@baserow/modules/core/utils/string'
export default {
name: 'PageSettingsQueryParamsFormElement',
mixins: [form],
props: {
queryParams: {
type: Array,
required: false,
default: () => [],
},
disabled: {
type: Boolean,
required: false,
default: false,
},
validationState: {
type: Object,
required: false,
default: () => ({}),
},
hasErrors: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
values: {
queryParams: [],
},
}
},
computed: {
queryParamTypes() {
return this.$registry.getOrderedList('queryParamType')
},
existingNames() {
return [...this.values.queryParams.map(({ name }) => name), 'param']
},
},
watch: {
queryParams: {
immediate: true,
handler(newParams) {
if (
JSON.stringify(this.values.queryParams) !== JSON.stringify(newParams)
) {
this.values.queryParams = JSON.parse(JSON.stringify(newParams))
}
},
},
},
methods: {
deleteQueryParam(index) {
this.values.queryParams.splice(index, 1)
this.$emit('update', this.values.queryParams)
},
updateQueryParamName(index, newName) {
this.values.queryParams[index].name = newName
this.$emit('update', this.values.queryParams)
},
updateQueryParamType(index, newType) {
this.values.queryParams[index].type = newType
this.$emit('update', this.values.queryParams)
},
addParameter() {
// Prevents name conflicts
const name = getNextAvailableNameInSequence('param', this.existingNames, {
pattern: (baseName, index) => `${baseName}${index + 1}`,
})
const newParam = {
name,
type: this.queryParamTypes[0].getType(),
}
this.values.queryParams.push(newParam)
this.$emit('update', this.values.queryParams)
},
},
}
</script>

View file

@ -4,7 +4,10 @@ import { getValueAtPath } from '@baserow/modules/core/utils/object'
import { defaultValueForParameterType } from '@baserow/modules/builder/utils/params'
import { DEFAULT_USER_ROLE_PREFIX } from '@baserow/modules/builder/constants'
import { PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS } from '@baserow/modules/builder/enums'
import {
PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS,
QUERY_PARAM_TYPE_VALIDATION_FUNCTIONS,
} from '@baserow/modules/builder/enums'
import { extractSubSchema } from '@baserow/modules/core/utils/schema'
export class DataSourceDataProviderType extends DataProviderType {
@ -288,30 +291,34 @@ export class PageParameterDataProviderType extends DataProviderType {
async init(applicationContext) {
const { page, mode, pageParamsValue } = applicationContext
const pageParams = [...page.path_params, ...page.query_params]
// Read parameters value from the application context
const queryParamNames = page.query_params.map((p) => p.name)
if (mode === 'editing') {
// Generate fake values for the parameters
await Promise.all(
page.path_params.map(({ name, type }) =>
this.app.store.dispatch('pageParameter/setParameter', {
pageParams.map(({ name, type }) => {
const isQuery = queryParamNames.includes(name)
return this.app.store.dispatch('pageParameter/setParameter', {
page,
name,
value: defaultValueForParameterType(type),
value: isQuery ? null : defaultValueForParameterType(type),
})
)
})
)
} else {
// Read parameters value from the application context
await Promise.all(
page.path_params.map(({ name, type }) =>
this.app.store.dispatch('pageParameter/setParameter', {
pageParams.map(({ name, type }) => {
const validators = queryParamNames.includes(name)
? QUERY_PARAM_TYPE_VALIDATION_FUNCTIONS
: PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS
return this.app.store.dispatch('pageParameter/setParameter', {
page,
name,
value: PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS[type](
pageParamsValue[name]
),
value: validators[type](pageParamsValue[name]),
})
)
})
)
}
}
@ -338,11 +345,15 @@ export class PageParameterDataProviderType extends DataProviderType {
getDataSchema(applicationContext) {
const page = applicationContext.page
const toJSONType = { text: 'string', numeric: 'number' }
const mergedParams = [
...(page?.path_params || []),
...(page?.query_params || []),
]
return {
type: 'object',
properties: Object.fromEntries(
(page?.path_params || []).map(({ name, type }) => [
mergedParams.map(({ name, type }) => [
name,
{
title: name,

View file

@ -1,4 +1,5 @@
import {
ensureString,
ensureNonEmptyString,
ensurePositiveInteger,
} from '@baserow/modules/core/utils/validator'
@ -22,6 +23,10 @@ export const PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS = {
numeric: ensurePositiveInteger,
text: ensureNonEmptyString,
}
export const QUERY_PARAM_TYPE_VALIDATION_FUNCTIONS = {
numeric: (n) => ensurePositiveInteger(n, { allowNull: true }),
text: ensureString,
}
export const ALLOWED_LINK_PROTOCOLS = [
'ftp:',

View file

@ -46,7 +46,8 @@
"errorPathNotUnique": "A path with this name already exists",
"errorStartingSlash": "A path needs to start with a '/'",
"errorValidPathCharacters": "The path contains invalid characters",
"errorUniquePathParams": "Path parameters have to be unique."
"errorUniquePathParams": "Path parameters have to be unique.",
"errorUniqueValidQueryParams": "Query parameter names have to be unique and valid."
},
"pageHeaderItemTypes": {
"labelElements": "Elements",
@ -380,9 +381,13 @@
"nameSubtitle": "Unique name of the page",
"namePlaceholder": "Enter a name...",
"pathTitle": "Path",
"addAnotherParameter": "Add another query string parameter",
"addParameter": "Add query string parameter",
"queryParamsSubtitleTutorial": "Query parameters can be used to dynamically load data, depending on the provided parameter.",
"pathSubtitle": "A parameter can be added via :parameter",
"pathPlaceholder": "Enter a path...",
"pathParamsTitle": "Path parameters",
"queryParamsTitle": "Query string 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."
},
@ -390,6 +395,10 @@
"textName": "Text",
"numericName": "Numeric"
},
"queryParamTypes": {
"textName": "Text",
"numericName": "Numeric"
},
"pageEditor": {
"pageNotFound": "Page not found"
},

View file

@ -79,7 +79,7 @@ export class PreviewPageActionType extends PageActionType {
generatePreviewUrl(builderId, page) {
/**
* Responsible for generating the preview URL for a given
* page using the page's path parameters.
* page using the page's path parameters and query string parameters.
*
* @param builderId The builder application ID.
* @param page The Page object.
@ -90,7 +90,18 @@ export class PreviewPageActionType extends PageActionType {
page.path_params.map(({ name, value }) => [name, page.parameters[name]])
)
const resolvedPagePath = toPath(pageParams)
return `/builder/${builderId}/preview${resolvedPagePath}`
const url = new URL(
`/builder/${builderId}/preview${resolvedPagePath}`,
window.location.origin
)
if (page.query_params && Array.isArray(page.query_params)) {
page.query_params.forEach(({ name }) => {
if (page.parameters[name]) {
url.searchParams.append(name, page.parameters[name])
}
})
}
return url.toString()
}
isActive({ workspace, page }) {

View file

@ -61,6 +61,7 @@ export default {
req,
redirect,
route,
query,
}) {
let mode = 'public'
const builderId = params.builderId ? parseInt(params.builderId, 10) : null
@ -190,7 +191,7 @@ export default {
})
}
const [pageFound, path, pageParamsValue] = found
const [pageFound, path, pageParams] = found
// Handle 404
if (pageFound.shared) {
return error({
@ -199,6 +200,18 @@ export default {
})
}
// Merge the query string values with the page parameters
const pageParamsValue = Object.assign({}, query, pageParams)
pageFound.query_params.forEach((queryParam) => {
if (queryParam.name in pageParamsValue) {
return
}
if (queryParam.type === 'text') {
pageParamsValue[queryParam.name] = ''
} else {
pageParamsValue[queryParam.name] = null
}
})
const page = await store.getters['page/getById'](builder, pageFound.id)
try {

View file

@ -134,6 +134,10 @@ import {
CourierNewFontFamilyType,
BrushScriptMTFontFamilyType,
} from '@baserow/modules/builder/fontFamilyTypes'
import {
TextQueryParamType,
NumericQueryParamType,
} from '@baserow/modules/builder/queryParamTypes'
export default (context) => {
const { store, app, isDev } = context
@ -257,6 +261,9 @@ export default (context) => {
app.$registry.register('pathParamType', new TextPathParamType(context))
app.$registry.register('pathParamType', new NumericPathParamType(context))
app.$registry.register('queryParamType', new TextQueryParamType(context))
app.$registry.register('queryParamType', new NumericQueryParamType(context))
app.$registry.register('pageAction', new PublishPageActionType(context))
app.$registry.register('pageAction', new PreviewPageActionType(context))

View file

@ -0,0 +1,47 @@
import { Registerable } from '@baserow/modules/core/registry'
export class QueryParamType extends Registerable {
get name() {
return null
}
get icon() {
return null
}
}
export class TextQueryParamType extends QueryParamType {
static getType() {
return 'text'
}
getOrder() {
return 10
}
get name() {
return this.app.i18n.t('queryParamTypes.textName')
}
get icon() {
return 'font'
}
}
export class NumericQueryParamType extends QueryParamType {
static getType() {
return 'numeric'
}
getOrder() {
return 20
}
get name() {
return this.app.i18n.t('queryParamTypes.numericName')
}
get icon() {
return 'hashtag'
}
}

View file

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

View file

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

View file

@ -11,6 +11,10 @@ export function defaultValueForParameterType(type) {
return type === 'numeric' ? 1 : 'test'
}
// The regex for query parameters. This is used to validate query parameter names.
// This needs to match QUERY_PARAM_EXACT_MATCH_REGEX from backend.
export const QUERY_PARAM_REGEX = /^([A-Za-z][A-Za-z0-9_-]*)$/g
/**
* Responsible for detecting if a navigable record's path parameters have diverged
* from the destination page's path parameters. This can happen if a record

View file

@ -61,7 +61,22 @@ export default function resolveElementUrl(
element.navigation_type,
editorMode
)
// Add query parameters if they exist
if (element.query_parameters && element.query_parameters.length > 0) {
const queryString = element.query_parameters
.map(({ name, value }) => {
if (!value) return null
const resolvedValue = resolveFormula(value)
return `${encodeURIComponent(name)}=${encodeURIComponent(
resolvedValue
)}`
})
.filter((param) => param !== null)
.join('&')
if (queryString) {
resolvedUrl = `${resolvedUrl}?${queryString}`
}
}
// If the protocol is a supported one, return early.
const protocolRegex = /^[A-Za-z]+:/
if (protocolRegex.test(resolvedUrl)) {
@ -74,7 +89,6 @@ export default function resolveElementUrl(
// Disallow unsupported protocols, e.g. `javascript:`
return ''
}
return resolvedUrl
}

View file

@ -13,6 +13,7 @@
@import 'empty_side_panel_state';
@import 'page_editor';
@import 'page_settings_path_params_form_element';
@import 'page_settings_query_params_form_element';
@import 'domain_card';
@import 'dns_status';
@import 'domains_settings';

View file

@ -1,5 +1,11 @@
.link-navigation-selection-form__navigate-option-page-path {
font-size: 12px;
color: $color-neutral-500;
margin-left: 5px;
overflow: hidden;
height: 36px;
width: 196px;
}
.link-navigation-selection-form__navigate-option-page-path > span {
height: 30px;
display: inline-block;
text-wrap: nowrap;
}

View file

@ -0,0 +1,29 @@
.page-settings-query-params {
display: flex;
align-items: center;
width: 100%;
&:not(:last-child) {
margin-bottom: 8px;
}
}
.page-settings-query-params__name {
@extend %ellipsis;
flex: 0 0 50%;
}
.page-settings-query-params__dropdown {
flex: 0 0 36%;
margin-left: 5%;
}
.page-settings-query-params__remove {
margin-left: 5%;
padding: 0 3%;
}
.page-settings-query-params__add-button {
margin-left: -5px;
}

View file

@ -5,24 +5,20 @@
padding: 6px 10px;
width: 100%;
border-bottom: solid 1px $color-neutral-200;
display: grid;
grid-auto-columns: 1fr;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
.preview-navigation-bar__address-bar-parameter-input--text {
width: 110px;
}
display: flex;
gap: 10px;
.page-preview__wrapper--smartphone & {
display: flex;
flex-direction: column;
align-items: center;
}
}
.preview-navigation-bar__user-selector {
width: fit-content;
flex-shrink: 0;
.page-preview__wrapper--smartphone & {
// Moving the user selector after the path when in smartphone mode
order: 2;
}
}
@ -31,10 +27,11 @@
border-radius: 2px;
padding: 3px 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 25px;
flex: 1;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.preview-navigation-bar__address-bar-path {

View file

@ -3,6 +3,7 @@
width: 40px;
padding: 0 3px;
border: 1px solid transparent;
position: relative;
@include rounded($rounded);
@ -10,3 +11,11 @@
border-color: $palette-red-600;
}
}
.preview-navigation-bar__parameter-input--numeric,
.preview-navigation-bar__parameter-input--text,
.preview-navigation-bar__query-parameter-input--text,
.preview-navigation-bar__query-parameter-input--numeric {
padding: 0 2px;
white-space: nowrap;
}

View file

@ -161,16 +161,25 @@ export const isNumeric = (value) => {
* @example
* // returns 'name 2'
* getNextAvailableNameInSequence('name', ['name', 'name 1', 'name 3']);
*
* @example
* // returns 'name__2'
* getNextAvailableNameInSequence('name', ['name', 'name 1', 'name 3'],
* {pattern: (baseName, index) => `${basename}__${index + 1}`});
*/
export const getNextAvailableNameInSequence = (baseName, excludeNames) => {
export const getNextAvailableNameInSequence = (
baseName,
excludeNames,
{ pattern = (baseName, index) => `${baseName} ${index + 1}` } = {}
) => {
let name = baseName
let i = 0
while (i < excludeNames.length) {
if (!excludeNames.includes(name)) {
return name
}
name = pattern(baseName, i)
i++
name = `${baseName} ${i + 1}`
}
return name
}

View file

@ -30,7 +30,10 @@ export const ensureInteger = (value) => {
* @returns {number} The value as an integer if conversion is successful.
* @throws {Error} If the value is not a valid integer or convertible to an integer.
*/
export const ensurePositiveInteger = (value) => {
export const ensurePositiveInteger = (value, { allowNull = false } = {}) => {
if (allowNull && value === null) {
return null
}
const validInteger = ensureInteger(value)
if (validInteger < 0) {
throw new Error('Value is not a positive integer.')