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:
parent
5170bc0760
commit
557b8966f8
59 changed files with 1183 additions and 117 deletions
backend
src/baserow
contrib
core/formula
tests/baserow/contrib/builder
changelog/entries/unreleased/feature
e2e-tests
web-frontend/modules
builder
components
elements/components
page
locales
pageActionTypes.jspages
plugin.jsqueryParamTypes.jsservices
store
utils
core
assets/scss/components/builder
all.scss
elements/forms
page_settings_query_params_form_element.scsspreview_navigation_bar.scsspreview_navigation_bar_input.scssutils
|
@ -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},
|
||||
|
|
|
@ -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}.",
|
||||
)
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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.
|
||||
|
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'}"
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}')",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')",
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "feature",
|
||||
"message": "[Builder] Introducing query parameters",
|
||||
"issue_number": 1661,
|
||||
"bullet_points": [],
|
||||
"created_at": "2025-02-02"
|
||||
}
|
|
@ -22,6 +22,7 @@ export async function createBuilderPage(
|
|||
name: pageName,
|
||||
path,
|
||||
path_params: [],
|
||||
query_params: [],
|
||||
}
|
||||
);
|
||||
return new BuilderPage(
|
||||
|
|
|
@ -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/);
|
||||
|
||||
});
|
||||
});
|
||||
|
|
|
@ -65,7 +65,7 @@ export default {
|
|||
this.mode
|
||||
)
|
||||
} catch (e) {
|
||||
return ''
|
||||
return '#error'
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
|
|
|
@ -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:',
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
47
web-frontend/modules/builder/queryParamTypes.js
Normal file
47
web-frontend/modules/builder/queryParamTypes.js
Normal 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'
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.')
|
||||
|
|
Loading…
Add table
Reference in a new issue