diff --git a/backend/pytest.ini b/backend/pytest.ini index 9712440084..1b4b0963b8 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -50,3 +50,4 @@ markers = websockets: All tests related to handeling web socket connections import_export_workspace: All tests related to importing and exporting workspaces data_sync: All tests related to data sync functionality + workspace_search: All tests related to workspace search functionality diff --git a/backend/src/baserow/api/search/constants.py b/backend/src/baserow/api/search/constants.py new file mode 100644 index 0000000000..ca89f074b2 --- /dev/null +++ b/backend/src/baserow/api/search/constants.py @@ -0,0 +1 @@ +DEFAULT_SEARCH_LIMIT = 20 diff --git a/backend/src/baserow/api/search/serializers.py b/backend/src/baserow/api/search/serializers.py index 73674f7775..3668ab7e05 100644 --- a/backend/src/baserow/api/search/serializers.py +++ b/backend/src/baserow/api/search/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers +from baserow.api.search.constants import DEFAULT_SEARCH_LIMIT from baserow.contrib.database.search.handler import ALL_SEARCH_MODES @@ -10,3 +11,43 @@ class SearchQueryParamSerializer(serializers.Serializer): default=None, choices=ALL_SEARCH_MODES, ) + + +class WorkspaceSearchSerializer(serializers.Serializer): + """Serializer for workspace search requests.""" + + query = serializers.CharField(min_length=1, max_length=100) + limit = serializers.IntegerField( + default=DEFAULT_SEARCH_LIMIT, + min_value=1, + max_value=100, + help_text="Maximum number of results per type", + ) + offset = serializers.IntegerField( + default=0, min_value=0, help_text="Number of results to skip" + ) + + +class SearchResultSerializer(serializers.Serializer): + """Serializer for individual search results.""" + + type = serializers.CharField() + id = serializers.IntegerField() + title = serializers.CharField() + subtitle = serializers.CharField(required=False, allow_null=True) + description = serializers.CharField(required=False, allow_null=True) + metadata = serializers.DictField(required=False, allow_null=True) + created_on = serializers.CharField(required=False, allow_null=True) + updated_on = serializers.CharField(required=False, allow_null=True) + + +class WorkspaceSearchResponseSerializer(serializers.Serializer): + """Serializer for workspace search responses.""" + + results = serializers.ListField( + child=SearchResultSerializer(), + help_text="Priority-ordered search results", + ) + has_more = serializers.BooleanField( + help_text="Whether there are more results available for pagination" + ) diff --git a/backend/src/baserow/api/search/urls.py b/backend/src/baserow/api/search/urls.py new file mode 100644 index 0000000000..a218b71064 --- /dev/null +++ b/backend/src/baserow/api/search/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from baserow.api.search.views import WorkspaceSearchView +from baserow.core.feature_flags import FF_WORKSPACE_SEARCH, feature_flag_is_enabled + +app_name = "baserow.api.search" + +urlpatterns = [] + +if feature_flag_is_enabled(FF_WORKSPACE_SEARCH): + urlpatterns = [ + path( + "workspace//", + WorkspaceSearchView.as_view(), + name="workspace_search", + ), + ] diff --git a/backend/src/baserow/api/search/views.py b/backend/src/baserow/api/search/views.py new file mode 100644 index 0000000000..4757c8c9af --- /dev/null +++ b/backend/src/baserow/api/search/views.py @@ -0,0 +1,74 @@ +from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from baserow.api.decorators import map_exceptions, validate_query_parameters +from baserow.api.errors import ERROR_GROUP_DOES_NOT_EXIST, ERROR_USER_NOT_IN_GROUP +from baserow.api.schemas import get_error_schema +from baserow.api.search.serializers import ( + WorkspaceSearchResponseSerializer, + WorkspaceSearchSerializer, +) +from baserow.core.exceptions import UserNotInWorkspace, WorkspaceDoesNotExist +from baserow.core.handler import CoreHandler +from baserow.core.operations import ReadWorkspaceOperationType +from baserow.core.search.handler import WorkspaceSearchHandler + + +class WorkspaceSearchView(APIView): + """ + API view for workspace search functionality. + """ + + permission_classes = [IsAuthenticated] + + @extend_schema( + parameters=[ + OpenApiParameter( + name="workspace_id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.INT, + description="Workspace ID to search within", + ), + ], + request=WorkspaceSearchSerializer, + responses={ + 200: WorkspaceSearchResponseSerializer, + 400: get_error_schema( + ["ERROR_USER_NOT_IN_GROUP", "ERROR_INVALID_SEARCH_QUERY"] + ), + 404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]), + }, + tags=["Search"], + operation_id="workspace_search", + description="Search across all searchable content within a workspace", + ) + @map_exceptions( + { + UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP, + WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST, + } + ) + @validate_query_parameters(WorkspaceSearchSerializer, return_validated=True) + def get(self, request, workspace_id, query_params): + workspace = CoreHandler().get_workspace(workspace_id) + CoreHandler().check_permissions( + request.user, + ReadWorkspaceOperationType.type, + workspace=workspace, + context=workspace, + ) + + handler = WorkspaceSearchHandler() + result_data = handler.search_workspace( + user=request.user, + workspace=workspace, + query=query_params["query"], + limit=query_params["limit"], + offset=query_params["offset"], + ) + serializer = WorkspaceSearchResponseSerializer(data=result_data) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) diff --git a/backend/src/baserow/api/urls.py b/backend/src/baserow/api/urls.py index fc888ad6f0..68b9d22f7e 100755 --- a/backend/src/baserow/api/urls.py +++ b/backend/src/baserow/api/urls.py @@ -17,6 +17,7 @@ from .jobs import urls as jobs_urls from .mcp import urls as mcp_urls from .notifications import urls as notifications_urls +from .search import urls as search_urls from .settings import urls as settings_urls from .snapshots import urls as snapshots_urls from .spectacular.views import CachedSpectacularJSONAPIView @@ -50,6 +51,7 @@ path("snapshots/", include(snapshots_urls, namespace="snapshots")), path("_health/", include(health_urls, namespace="health")), path("notifications/", include(notifications_urls, namespace="notifications")), + path("search/", include(search_urls, namespace="search")), path("admin/", include(admin_urls, namespace="admin")), path("mcp/", include(mcp_urls, namespace="mcp")), path( diff --git a/backend/src/baserow/contrib/automation/apps.py b/backend/src/baserow/contrib/automation/apps.py index 61f325f2b8..3bfe4b090b 100644 --- a/backend/src/baserow/contrib/automation/apps.py +++ b/backend/src/baserow/contrib/automation/apps.py @@ -213,3 +213,9 @@ def ready(self): ) connect_to_node_pre_delete_signal() + + from baserow.core.search.registries import workspace_search_registry + + from .search_types import AutomationSearchType + + workspace_search_registry.register(AutomationSearchType()) diff --git a/backend/src/baserow/contrib/automation/formula_importer.py b/backend/src/baserow/contrib/automation/formula_importer.py index c2e8d41dc5..4e741df095 100644 --- a/backend/src/baserow/contrib/automation/formula_importer.py +++ b/backend/src/baserow/contrib/automation/formula_importer.py @@ -1,9 +1,9 @@ -from typing import Dict +from typing import Dict, Union from baserow.contrib.automation.data_providers.registries import ( automation_data_provider_type_registry, ) -from baserow.core.formula import get_parse_tree_for_formula +from baserow.core.formula import BaserowFormulaObject, get_parse_tree_for_formula from baserow.core.services.formula_importer import BaserowFormulaImporter @@ -18,7 +18,9 @@ def get_data_provider_type_registry(self): return automation_data_provider_type_registry -def import_formula(formula: str, id_mapping: Dict[str, str], **kwargs) -> str: +def import_formula( + formula: Union[str, BaserowFormulaObject], id_mapping: Dict[str, str], **kwargs +) -> str: """ When a formula is used in an automation, it must be migrated when we import it because it could contain IDs referencing other objects. @@ -30,8 +32,11 @@ def import_formula(formula: str, id_mapping: Dict[str, str], **kwargs) -> str: :return: The updated path. """ - if not formula: - return formula + # Figure out what our formula string is. + formula_str = formula if isinstance(formula, str) else formula["formula"] - tree = get_parse_tree_for_formula(formula) + if not formula_str: + return formula_str + + tree = get_parse_tree_for_formula(formula_str) return AutomationFormulaImporter(id_mapping, **kwargs).visit(tree) diff --git a/backend/src/baserow/contrib/automation/search_types.py b/backend/src/baserow/contrib/automation/search_types.py new file mode 100644 index 0000000000..6c1346d45d --- /dev/null +++ b/backend/src/baserow/contrib/automation/search_types.py @@ -0,0 +1,13 @@ +from baserow.contrib.automation.models import Automation +from baserow.core.search.search_types import ApplicationSearchType + + +class AutomationSearchType(ApplicationSearchType): + """ + Searchable item type specifically for automations. + """ + + type = "automation" + name = "Automation" + model_class = Automation + priority = 4 diff --git a/backend/src/baserow/contrib/builder/api/elements/serializers.py b/backend/src/baserow/contrib/builder/api/elements/serializers.py index a33dee7c89..9925fbf7c0 100644 --- a/backend/src/baserow/contrib/builder/api/elements/serializers.py +++ b/backend/src/baserow/contrib/builder/api/elements/serializers.py @@ -264,7 +264,7 @@ def get_workflow_actions(self, obj: ElementsAndWorkflowActions): class PageParameterValueSerializer(serializers.Serializer): name = serializers.CharField() - value = FormulaSerializerField(allow_blank=True) + value = FormulaSerializerField() @extend_schema_serializer(exclude_fields=("config",)) @@ -363,7 +363,7 @@ class UpdateCollectionFieldSerializer(serializers.ModelSerializer): help_text=CollectionField._meta.get_field("type").help_text, ) - value = FormulaSerializerField(allow_blank=True) + value = FormulaSerializerField() class ChoiceOptionSerializer(serializers.ModelSerializer): @@ -408,8 +408,6 @@ class MenuItemSerializer(serializers.ModelSerializer): ) navigate_to_url = FormulaSerializerField( help_text=LinkElement._meta.get_field("navigate_to_url").help_text, - default="", - allow_blank=True, required=False, ) page_parameters = PageParameterValueSerializer( diff --git a/backend/src/baserow/contrib/builder/apps.py b/backend/src/baserow/contrib/builder/apps.py index 9f023aa5a5..85ee9f7a38 100644 --- a/backend/src/baserow/contrib/builder/apps.py +++ b/backend/src/baserow/contrib/builder/apps.py @@ -337,3 +337,8 @@ def ready(self): # which need to be filled first. import baserow.contrib.builder.signals # noqa: F403, F401 import baserow.contrib.builder.ws.signals # noqa: F403, F401 + from baserow.core.search.registries import workspace_search_registry + + from .search_types import BuilderSearchType + + workspace_search_registry.register(BuilderSearchType()) diff --git a/backend/src/baserow/contrib/builder/elements/collection_field_types.py b/backend/src/baserow/contrib/builder/elements/collection_field_types.py index b71cf37004..3fcf251e4e 100644 --- a/backend/src/baserow/contrib/builder/elements/collection_field_types.py +++ b/backend/src/baserow/contrib/builder/elements/collection_field_types.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Generator, TypedDict, Union +from typing import Any, Dict, Generator, TypedDict from django.core.validators import MinValueValidator @@ -13,7 +13,7 @@ FormulaSerializerField, OptionalFormulaSerializerField, ) -from baserow.core.formula.types import BaserowFormula +from baserow.core.formula.types import BaserowFormulaObject from baserow.core.registry import Instance @@ -24,7 +24,7 @@ class BooleanCollectionFieldType(CollectionFieldType): simple_formula_fields = ["value"] class SerializedDict(TypedDict): - value: bool + value: BaserowFormulaObject @property def serializer_field_overrides(self): @@ -32,8 +32,6 @@ def serializer_field_overrides(self): "value": FormulaSerializerField( help_text="The boolean value.", required=False, - allow_blank=True, - default=False, ), } @@ -45,7 +43,7 @@ class RatingCollectionFieldType(CollectionFieldType): simple_formula_fields = ["value"] class SerializedDict(TypedDict): - value: BaserowFormula + value: BaserowFormulaObject color: str rating_style: str max_value: int @@ -56,8 +54,6 @@ def serializer_field_overrides(self): "value": FormulaSerializerField( help_text="The rating value.", required=False, - allow_blank=True, - default="", ), "color": serializers.CharField( help_text="The color of the rating.", @@ -87,7 +83,7 @@ class TextCollectionFieldType(CollectionFieldType): simple_formula_fields = ["value"] class SerializedDict(TypedDict): - value: str + value: BaserowFormulaObject @property def serializer_field_overrides(self): @@ -95,8 +91,6 @@ def serializer_field_overrides(self): "value": FormulaSerializerField( help_text="The formula for the text.", required=False, - allow_blank=True, - default="", ), } @@ -171,8 +165,6 @@ def serializer_field_overrides(self): "link_name": FormulaSerializerField( help_text="The formula for the link name.", required=False, - allow_blank=True, - default="", ), "variant": serializers.ChoiceField( choices=LinkElement.VARIANTS.choices, @@ -238,9 +230,9 @@ class TagsCollectionFieldType(CollectionFieldType): simple_formula_fields = ["values"] class SerializedDict(TypedDict): - values: str + values: BaserowFormulaObject colors_is_formula: bool - colors: Union[BaserowFormula, str] + colors: BaserowFormulaObject @property def serializer_field_overrides(self): @@ -248,14 +240,10 @@ def serializer_field_overrides(self): "values": FormulaSerializerField( help_text="The formula for the tags values", required=False, - allow_blank=True, - default="", ), "colors": OptionalFormulaSerializerField( help_text="The formula or value for the tags colors", required=False, - allow_blank=True, - default="", is_formula_field_name="colors_is_formula", ), "colors_is_formula": serializers.BooleanField( @@ -291,7 +279,7 @@ class ButtonCollectionFieldType(CollectionFieldType): simple_formula_fields = ["label"] class SerializedDict(TypedDict): - label: str + label: BaserowFormulaObject @property def serializer_field_overrides(self): @@ -299,8 +287,6 @@ def serializer_field_overrides(self): "label": FormulaSerializerField( help_text="The string value.", required=False, - allow_blank=True, - default="", ), } @@ -316,8 +302,8 @@ class ImageCollectionFieldType(CollectionFieldType): simple_formula_fields = ["src", "alt"] class SerializedDict(TypedDict): - src: BaserowFormula - alt: BaserowFormula + src: BaserowFormulaObject + alt: BaserowFormulaObject @property def serializer_field_overrides(self): @@ -325,13 +311,9 @@ def serializer_field_overrides(self): "src": FormulaSerializerField( help_text="A link to the image file", required=False, - allow_blank=True, - default="", ), "alt": FormulaSerializerField( help_text="A brief text description of the image", required=False, - allow_blank=True, - default="", ), } diff --git a/backend/src/baserow/contrib/builder/elements/element_types.py b/backend/src/baserow/contrib/builder/elements/element_types.py index c41495b399..c08e8e9469 100644 --- a/backend/src/baserow/contrib/builder/elements/element_types.py +++ b/backend/src/baserow/contrib/builder/elements/element_types.py @@ -94,8 +94,13 @@ get_parse_tree_for_formula, resolve_formula, ) +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL from baserow.core.formula.registries import formula_runtime_function_registry -from baserow.core.formula.types import BaserowFormula +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_SIMPLE, + BaserowFormula, + BaserowFormulaObject, +) from baserow.core.formula.validator import ( ensure_array, ensure_boolean, @@ -227,12 +232,16 @@ class FormContainerElementType(ContainerElementTypeMixin, ElementType): simple_formula_fields = ["submit_button_label"] class SerializedDict(ContainerElementTypeMixin.SerializedDict): - submit_button_label: BaserowFormula + submit_button_label: BaserowFormulaObject reset_initial_values_post_submission: bool def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]: return { - "submit_button_label": "'Submit'", + "submit_button_label": BaserowFormulaObject( + formula="'Submit'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "reset_initial_values_post_submission": True, } @@ -251,9 +260,6 @@ def serializer_field_overrides(self): help_text=FormContainerElement._meta.get_field( "submit_button_label" ).help_text, - required=False, - allow_blank=True, - default="", ), "reset_initial_values_post_submission": serializers.BooleanField( help_text=FormContainerElement._meta.get_field( @@ -343,7 +349,11 @@ def enhance_queryset(self, queryset): def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]: return { "data_source_id": None, - "button_load_more_label": "'test'", + "button_load_more_label": BaserowFormulaObject( + formula="'test'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "orientation": get_default_table_orientation(), } @@ -406,7 +416,11 @@ def serializer_field_overrides(self): def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]: return { "data_source_id": None, - "button_load_more_label": "'test'", + "button_load_more_label": BaserowFormulaObject( + formula="'test'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "orientation": RepeatElement.ORIENTATIONS.VERTICAL, } @@ -416,7 +430,7 @@ class RecordSelectorElementType( ): type = "record_selector" model_class = RecordSelectorElement - simple_formula_fields = [ + simple_formula_fields = CollectionElementTypeMixin.simple_formula_fields + [ "label", "default_value", "placeholder", @@ -429,11 +443,11 @@ class RecordSelectorElementType( class SerializedDict(CollectionElementTypeMixin.SerializedDict): required: bool - label: BaserowFormula - default_value: BaserowFormula - placeholder: BaserowFormula + label: BaserowFormulaObject + default_value: BaserowFormulaObject + placeholder: BaserowFormulaObject multiple: bool - option_name_suffix: BaserowFormula + option_name_suffix: BaserowFormulaObject @property def serializer_field_overrides(self): @@ -455,25 +469,16 @@ def serializer_field_overrides(self): ), "label": FormulaSerializerField( help_text=RecordSelectorElement._meta.get_field("label").help_text, - required=False, - allow_blank=True, - default="", ), "default_value": FormulaSerializerField( help_text=RecordSelectorElement._meta.get_field( "default_value" ).help_text, - required=False, - allow_blank=True, - default="", ), "placeholder": FormulaSerializerField( help_text=RecordSelectorElement._meta.get_field( "placeholder" ).help_text, - required=False, - allow_blank=True, - default="", ), "multiple": serializers.BooleanField( help_text=RecordSelectorElement._meta.get_field("multiple").help_text, @@ -484,9 +489,6 @@ def serializer_field_overrides(self): help_text=RecordSelectorElement._meta.get_field( "option_name_suffix" ).help_text, - required=False, - allow_blank=True, - default="", ), } @@ -552,7 +554,9 @@ def extract_properties(self, instance: Element, **kwargs) -> Dict[int, List[str] # to populate the formula context with the `data_source_id` # of the element so that we can resolve them. formula_context = kwargs | self.import_context_addition(instance) - tree = get_parse_tree_for_formula(instance.option_name_suffix) + tree = get_parse_tree_for_formula( + instance.option_name_suffix["formula"] + ) properties = merge_dicts_no_duplicates( properties, FormulaFieldVisitor(**formula_context).visit(tree), @@ -589,11 +593,27 @@ def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]: return { "data_source_id": None, "required": False, - "label": "", - "default_value": "", - "placeholder": "", + "label": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "default_value": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "placeholder": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "multiple": False, - "option_name_suffix": "", + "option_name_suffix": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), } def is_valid( @@ -664,7 +684,7 @@ class HeadingElementType(ElementType): simple_formula_fields = ["value"] class SerializedDict(ElementDict): - value: BaserowFormula + value: BaserowFormulaObject level: int @property @@ -680,9 +700,6 @@ def serializer_field_overrides(self): overrides = { "value": FormulaSerializerField( help_text="The value of the element. Must be an formula.", - required=False, - allow_blank=True, - default="", ), "level": serializers.IntegerField( help_text="The level of the heading from 1 to 6.", @@ -701,7 +718,14 @@ def serializer_field_overrides(self): return overrides def get_pytest_params(self, pytest_data_fixture): - return {"value": "'Corporis perspiciatis'", "level": 2} + return { + "value": BaserowFormulaObject( + formula="'Corporis perspiciatis'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "level": 2, + } class TextElementType(ElementType): @@ -716,16 +740,20 @@ class TextElementType(ElementType): simple_formula_fields = ["value"] class SerializedDict(ElementDict): - value: BaserowFormula + value: BaserowFormulaObject format: str def get_pytest_params(self, pytest_data_fixture): return { - "value": "'Suscipit maxime eos ea vel commodi dolore. " - "Eum dicta sit rerum animi. Sint sapiente eum cupiditate nobis vel. " - "Maxime qui nam consequatur. " - "Asperiores corporis perspiciatis nam harum veritatis. " - "Impedit qui maxime aut illo quod ea molestias.'", + "value": BaserowFormulaObject( + formula="'Suscipit maxime eos ea vel commodi dolore. " + "Eum dicta sit rerum animi. Sint sapiente eum cupiditate nobis vel. " + "Maxime qui nam consequatur. " + "Asperiores corporis perspiciatis nam harum veritatis. " + "Impedit qui maxime aut illo quod ea molestias.'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "format": TextElement.TEXT_FORMATS.PLAIN, } @@ -742,9 +770,6 @@ def serializer_field_overrides(self): return { "value": FormulaSerializerField( help_text="The value of the element. Must be a formula.", - required=False, - allow_blank=True, - default="", ), "format": serializers.ChoiceField( choices=TextElement.TEXT_FORMATS.choices, @@ -791,7 +816,7 @@ class SerializedDict(TypedDict): navigate_to_page_id: int page_parameters: List query_parameters: List - navigate_to_url: BaserowFormula + navigate_to_url: BaserowFormulaObject target: str def deserialize_property( @@ -834,9 +859,6 @@ def serializer_field_overrides(self): help_text=NavigationElementMixin._meta.get_field( "navigate_to_url" ).help_text, - default="", - allow_blank=True, - required=False, ), "page_parameters": PageParameterValueSerializer( many=True, @@ -871,7 +893,11 @@ def get_pytest_params(self, pytest_data_fixture): return { "navigation_type": "custom", "navigate_to_page_id": None, - "navigate_to_url": '"http://example.com"', + "navigate_to_url": BaserowFormulaObject( + formula='"http://example.com"', + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "page_parameters": [], "query_parameters": [], "target": "blank", @@ -965,7 +991,7 @@ def allowed_fields(self): ) class SerializedDict(ElementDict, NavigationElementManager.SerializedDict): - value: BaserowFormula + value: BaserowFormulaObject variant: str def formula_generator( @@ -981,13 +1007,21 @@ def formula_generator( yield from super().formula_generator(element) for index, data in enumerate(element.page_parameters): - new_formula = yield data["value"] + new_formula = ( + yield data["value"] + if isinstance(data["value"], str) + else data["value"]["formula"] + ) if new_formula is not None: element.page_parameters[index]["value"] = new_formula yield element for index, data in enumerate(element.query_parameters or []): - new_formula = yield data["value"] + new_formula = ( + yield data["value"] + if isinstance(data["value"], str) + else data["value"]["formula"] + ) if new_formula is not None: element.query_parameters[index]["value"] = new_formula yield element @@ -1031,9 +1065,6 @@ def serializer_field_overrides(self): | { "value": FormulaSerializerField( help_text="The value of the element. Must be an formula.", - required=False, - allow_blank=True, - default="", ), "variant": serializers.ChoiceField( choices=LinkElement.VARIANTS.choices, @@ -1056,7 +1087,11 @@ def serializer_field_overrides(self): def get_pytest_params(self, pytest_data_fixture): return NavigationElementManager().get_pytest_params(pytest_data_fixture) | { - "value": "'test'", + "value": BaserowFormulaObject( + formula="'test'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "variant": "link", } @@ -1099,15 +1134,23 @@ class ImageElementType(ElementType): class SerializedDict(ElementDict): image_source_type: str image_file_id: int - image_url: BaserowFormula - alt_text: BaserowFormula + image_url: BaserowFormulaObject + alt_text: BaserowFormulaObject def get_pytest_params(self, pytest_data_fixture): return { "image_source_type": ImageElement.IMAGE_SOURCE_TYPES.UPLOAD, "image_file_id": None, - "image_url": "'https://test.com/image.png'", - "alt_text": "'some alt text'", + "image_url": BaserowFormulaObject( + formula="'https://test.com/image.png'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "alt_text": BaserowFormulaObject( + formula="'some alt text'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), } @property @@ -1125,15 +1168,9 @@ def serializer_field_overrides(self): "image_file": UserFileSerializer(required=False), "image_url": FormulaSerializerField( help_text=ImageElement._meta.get_field("image_url").help_text, - required=False, - allow_blank=True, - default="", ), "alt_text": FormulaSerializerField( help_text=ImageElement._meta.get_field("alt_text").help_text, - required=False, - allow_blank=True, - default="", ), "styles": DynamicConfigBlockSerializer( required=False, @@ -1156,8 +1193,15 @@ def request_serializer_field_overrides(self): from baserow.contrib.builder.theme.theme_config_block_types import ( ImageThemeConfigBlockType, ) + from baserow.core.formula.serializers import FormulaSerializerField overrides = { + "image_url": FormulaSerializerField( + help_text=ImageElement._meta.get_field("image_url").help_text + ), + "alt_text": FormulaSerializerField( + help_text=ImageElement._meta.get_field("alt_text").help_text + ), "image_file": UserFileField( allow_null=True, required=False, @@ -1245,7 +1289,7 @@ class RatingElementType(ElementType): simple_formula_fields = ["value"] class SerializedDict(ElementDict): - value: BaserowFormula + value: BaserowFormulaObject max_value: str color: str rating_style: str @@ -1253,7 +1297,11 @@ class SerializedDict(ElementDict): def get_pytest_params(self, pytest_data_fixture): return { "max_value": 5, - "value": "5", + "value": BaserowFormulaObject( + formula="5", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "color": "dark-orange", "rating_style": "star", } @@ -1264,10 +1312,7 @@ def serializer_field_overrides(self): return { "value": FormulaSerializerField( - help_text=RatingElement._meta.get_field("value").help_text, - required=False, - allow_blank=True, - default="", + help_text=RatingElement._meta.get_field("value").help_text ), } @@ -1294,9 +1339,9 @@ class RatingInputElementType(InputElementType): simple_formula_fields = ["value", "label"] class SerializedDict(ElementDict): - label: BaserowFormula + label: BaserowFormulaObject required: bool - value: BaserowFormula + value: BaserowFormulaObject max_value: str color: str rating_style: str @@ -1304,10 +1349,18 @@ class SerializedDict(ElementDict): def get_pytest_params(self, pytest_data_fixture): return { "max_value": 5, - "value": "5", + "value": BaserowFormulaObject( + formula="5", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "color": "dark-orange", "rating_style": "star", - "label": "", + "label": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "required": False, } @@ -1317,10 +1370,7 @@ def serializer_field_overrides(self): return super().serializer_field_overrides | { "label": FormulaSerializerField( - help_text=RatingInputElement._meta.get_field("label").help_text, - required=False, - allow_blank=True, - default="", + help_text=RatingInputElement._meta.get_field("label").help_text ), "required": serializers.BooleanField( help_text=RatingInputElement._meta.get_field("required").help_text, @@ -1329,9 +1379,6 @@ def serializer_field_overrides(self): ), "value": FormulaSerializerField( help_text=RatingInputElement._meta.get_field("value").help_text, - required=False, - allow_blank=True, - default="", ), } @@ -1344,8 +1391,8 @@ def is_valid( """ :param element: The element we're trying to use form data in. :param value: The form data value, which may be invalid. + :param dispatch_context: The context the element is being used in. :return: Whether the value is valid or not for this element. - """ if (element.required and value is None) or not ( @@ -1381,11 +1428,11 @@ class InputTextElementType(InputElementType): simple_formula_fields = ["label", "default_value", "placeholder"] class SerializedDict(ElementDict): - label: BaserowFormula + label: BaserowFormulaObject required: bool validation_type: str placeholder: str - default_value: BaserowFormula + default_value: BaserowFormulaObject is_multiline: bool rows: int input_type: str @@ -1403,15 +1450,9 @@ def serializer_field_overrides(self): overrides = { "label": FormulaSerializerField( help_text=InputTextElement._meta.get_field("label").help_text, - required=False, - allow_blank=True, - default="", ), "default_value": FormulaSerializerField( help_text=InputTextElement._meta.get_field("default_value").help_text, - required=False, - allow_blank=True, - default="", ), "required": serializers.BooleanField( help_text=InputTextElement._meta.get_field("required").help_text, @@ -1419,10 +1460,7 @@ def serializer_field_overrides(self): required=False, ), "placeholder": FormulaSerializerField( - help_text=InputTextElement._meta.get_field("placeholder").help_text, - required=False, - allow_blank=True, - default="", + help_text=InputTextElement._meta.get_field("placeholder").help_text ), "is_multiline": serializers.BooleanField( help_text=InputTextElement._meta.get_field("is_multiline").help_text, @@ -1454,10 +1492,22 @@ def serializer_field_overrides(self): def get_pytest_params(self, pytest_data_fixture): return { - "label": "", + "label": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "required": False, - "placeholder": "", - "default_value": "'Corporis perspiciatis'", + "placeholder": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "default_value": BaserowFormulaObject( + formula="'Corporis perspiciatis'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "is_multiline": False, "rows": 1, "input_type": "text", @@ -1498,7 +1548,7 @@ class ButtonElementType(ElementType): simple_formula_fields = ["value"] class SerializedDict(ElementDict): - value: BaserowFormula + value: BaserowFormulaObject @property def serializer_field_overrides(self): @@ -1513,9 +1563,6 @@ def serializer_field_overrides(self): overrides = { "value": FormulaSerializerField( help_text=ButtonElement._meta.get_field("value").help_text, - required=False, - allow_blank=True, - default="", ), "styles": DynamicConfigBlockSerializer( required=False, @@ -1528,7 +1575,13 @@ def serializer_field_overrides(self): return overrides def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]: - return {"value": "'Some value'"} + return { + "value": BaserowFormulaObject( + formula="'Some value'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + } class CheckboxElementType(InputElementType): @@ -1539,9 +1592,9 @@ class CheckboxElementType(InputElementType): simple_formula_fields = ["label", "default_value"] class SerializedDict(ElementDict): - label: BaserowFormula + label: BaserowFormulaObject required: bool - default_value: BaserowFormula + default_value: BaserowFormulaObject @property def serializer_field_overrides(self): @@ -1556,15 +1609,9 @@ def serializer_field_overrides(self): overrides = { "label": FormulaSerializerField( help_text=CheckboxElement._meta.get_field("label").help_text, - required=False, - allow_blank=True, - default="", ), "default_value": FormulaSerializerField( help_text=CheckboxElement._meta.get_field("default_value").help_text, - required=False, - allow_blank=True, - default="", ), "required": serializers.BooleanField( help_text=CheckboxElement._meta.get_field("required").help_text, @@ -1596,9 +1643,17 @@ def is_valid( def get_pytest_params(self, pytest_data_fixture): return { - "label": "", + "label": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "required": False, - "default_value": "", + "default_value": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), } @@ -1649,16 +1704,16 @@ class ChoiceElementType(FormElementTypeMixin, ElementType): ] class SerializedDict(ElementDict): - label: BaserowFormula + label: BaserowFormulaObject required: bool - placeholder: BaserowFormula - default_value: BaserowFormula + placeholder: BaserowFormulaObject + default_value: BaserowFormulaObject options: List multiple: bool show_as_dropdown: bool option_type: str - formula_value: BaserowFormula - formula_name: BaserowFormula + formula_value: BaserowFormulaObject + formula_name: BaserowFormulaObject @property def serializer_field_overrides(self): @@ -1673,26 +1728,17 @@ def serializer_field_overrides(self): overrides = { "label": FormulaSerializerField( help_text=ChoiceElement._meta.get_field("label").help_text, - required=False, - allow_blank=True, - default="", ), "default_value": FormulaSerializerField( help_text=ChoiceElement._meta.get_field("default_value").help_text, - required=False, - allow_blank=True, - default="", ), "required": serializers.BooleanField( help_text=ChoiceElement._meta.get_field("required").help_text, default=False, required=False, ), - "placeholder": serializers.CharField( - help_text=ChoiceElement._meta.get_field("placeholder").help_text, - required=False, - allow_blank=True, - default="", + "placeholder": FormulaSerializerField( + help_text=ChoiceElement._meta.get_field("placeholder").help_text ), "options": ChoiceOptionSerializer( source="choiceelementoption_set", many=True, required=False @@ -1715,15 +1761,9 @@ def serializer_field_overrides(self): ), "formula_value": FormulaSerializerField( help_text=ChoiceElement._meta.get_field("formula_value").help_text, - required=False, - allow_blank=True, - default="", ), "formula_name": FormulaSerializerField( help_text=ChoiceElement._meta.get_field("formula_name").help_text, - required=False, - allow_blank=True, - default="", ), "styles": DynamicConfigBlockSerializer( required=False, @@ -1821,15 +1861,35 @@ def deserialize_option(self, value: Dict): def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]: return { - "label": "'test'", - "default_value": "'option 1'", + "label": BaserowFormulaObject( + formula="'test'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "default_value": BaserowFormulaObject( + formula="'option 1'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "required": False, - "placeholder": "'some placeholder'", + "placeholder": BaserowFormulaObject( + formula="'some placeholder'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "multiple": False, "show_as_dropdown": True, "option_type": ChoiceElement.OPTION_TYPE.MANUAL, - "formula_value": "", - "formula_name": "", + "formula_value": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "formula_name": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), } def after_create(self, instance: ChoiceElement, values: Dict): @@ -1917,8 +1977,8 @@ class IFrameElementType(ElementType): class SerializedDict(ElementDict): source_type: str - url: BaserowFormula - embed: BaserowFormula + url: BaserowFormulaObject + embed: BaserowFormulaObject height: int @property @@ -1934,15 +1994,9 @@ def serializer_field_overrides(self): ), "url": FormulaSerializerField( help_text=IFrameElement._meta.get_field("url").help_text, - required=False, - allow_blank=True, - default="", ), "embed": FormulaSerializerField( help_text=IFrameElement._meta.get_field("embed").help_text, - required=False, - allow_blank=True, - default="", ), "height": serializers.IntegerField( help_text=IFrameElement._meta.get_field("height").help_text, @@ -1958,8 +2012,16 @@ def serializer_field_overrides(self): def get_pytest_params(self, pytest_data_fixture): return { "source_type": IFrameElement.IFRAME_SOURCE_TYPE.URL, - "url": "", - "embed": "", + "url": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "embed": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "height": 300, } @@ -1989,9 +2051,9 @@ class DateTimePickerElementType(FormElementTypeMixin, ElementType): ] class SerializedDict(ElementDict): - label: BaserowFormula + label: BaserowFormulaObject required: bool - default_value: BaserowFormula + default_value: BaserowFormulaObject date_format: str include_time: bool time_format: str @@ -2003,9 +2065,6 @@ def serializer_field_overrides(self): overrides = { "label": FormulaSerializerField( help_text=DateTimePickerElement._meta.get_field("label").help_text, - required=False, - allow_blank=True, - default="", ), "required": serializers.BooleanField( help_text=DateTimePickerElement._meta.get_field("required").help_text, @@ -2015,10 +2074,7 @@ def serializer_field_overrides(self): "default_value": FormulaSerializerField( help_text=DateTimePickerElement._meta.get_field( "default_value" - ).help_text, - required=False, - allow_blank=True, - default="", + ).help_text ), "date_format": serializers.ChoiceField( help_text=DateTimePickerElement._meta.get_field( @@ -2080,8 +2136,16 @@ def is_valid( def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]: return { "required": False, - "label": "", - "default_value": "", + "label": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "default_value": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "date_format": DATE_FORMAT_CHOICES[0][0], "include_time": False, "time_format": DATE_TIME_FORMAT_CHOICES[0][0], diff --git a/backend/src/baserow/contrib/builder/elements/mixins.py b/backend/src/baserow/contrib/builder/elements/mixins.py index 4d96954854..c35569ad5f 100644 --- a/backend/src/baserow/contrib/builder/elements/mixins.py +++ b/backend/src/baserow/contrib/builder/elements/mixins.py @@ -144,6 +144,8 @@ class CollectionElementTypeMixin: is_publicly_filterable = True is_publicly_searchable = True + simple_formula_fields = ["button_load_more_label"] + allowed_fields = [ "data_source", "data_source_id", @@ -252,8 +254,6 @@ def serializer_field_overrides(self): "button_load_more_label" ).help_text, required=False, - allow_blank=True, - default="", ), "property_options": CollectionElementPropertyOptionsSerializer( many=True, diff --git a/backend/src/baserow/contrib/builder/elements/models.py b/backend/src/baserow/contrib/builder/elements/models.py index 5bcb38cbc0..2803289d68 100644 --- a/backend/src/baserow/contrib/builder/elements/models.py +++ b/backend/src/baserow/contrib/builder/elements/models.py @@ -1,10 +1,11 @@ import uuid -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, List, Optional from django.contrib.contenttypes.models import ContentType from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import SET_NULL, QuerySet +from django.utils.functional import lazy from baserow.contrib.builder.constants import ( BACKGROUND_IMAGE_MODES, @@ -17,7 +18,8 @@ DATE_TIME_FORMAT_CHOICES, RatingStyleChoices, ) -from baserow.core.formula.field import FormulaField +from baserow.core.formula.field import FormulaField, JSONFormulaField +from baserow.core.formula.serializers import collect_json_formula_field_properties from baserow.core.mixins import ( CreatedAndUpdatedOnMixin, FractionOrderableMixin, @@ -69,6 +71,19 @@ def get_default_table_orientation(): } +def get_collection_field_config_formula_properties() -> List[str]: + """ + Returns the list of properties in the collection field config that are formulas. + :return: A list of property names. + """ + + from baserow.contrib.builder.elements.registries import ( + collection_field_type_registry, + ) + + return collect_json_formula_field_properties(collection_field_type_registry) + + class Element( HierarchicalModelMixin, TrashableModelMixin, @@ -438,7 +453,7 @@ class HeadingLevel(models.IntegerChoices): H4 = 4 H5 = 5 - value = FormulaField(default="") + value = FormulaField() level = models.IntegerField( choices=HeadingLevel.choices, default=1, help_text="The level of the heading" ) @@ -453,7 +468,7 @@ class TEXT_FORMATS(models.TextChoices): PLAIN = "plain" MARKDOWN = "markdown" - value = FormulaField(default="") + value = FormulaField() format = models.CharField( choices=TEXT_FORMATS.choices, help_text="The format of the text", @@ -492,16 +507,16 @@ class TARGETS(models.TextChoices): ), ) navigate_to_url = FormulaField( - default="", help_text="If no page is selected, this indicate the destination of the link.", - null=True, ) - page_parameters = models.JSONField( + page_parameters = JSONFormulaField( + properties=["value"], default=list, help_text="The parameters for each parameters of the selected page if any.", null=True, ) - query_parameters = models.JSONField( + query_parameters = JSONFormulaField( + properties=["value"], db_default=[], default=list, help_text="The query parameters for each parameter of the selected page if any.", @@ -528,7 +543,7 @@ class VARIANTS(models.TextChoices): LINK = "link" BUTTON = "button" - value = FormulaField(default="") + value = FormulaField() variant = models.CharField( choices=VARIANTS.choices, help_text="The variant of the link.", @@ -564,13 +579,9 @@ class IMAGE_CONSTRAINT_TYPES(models.TextChoices): related_name="image_element_image_file", help_text="An image file uploaded by the user to be used by the element", ) - image_url = FormulaField( - help_text="A link to the image file", blank=True, default="", max_length=1000 - ) + image_url = FormulaField(help_text="A link to the image file") alt_text = FormulaField( - help_text="Text that is displayed when the image can't load", - default="", - blank=True, + help_text="Text that is displayed when the image can't load" ) @@ -579,7 +590,7 @@ class FormContainerElement(ContainerElement): A form element """ - submit_button_label = FormulaField(default="") + submit_button_label = FormulaField() reset_initial_values_post_submission = models.BooleanField( default=False, help_text="Whether to reset the form to using its initial " @@ -606,7 +617,7 @@ class BaseRatingElement(Element): A Rating element to display a rating. """ - value = FormulaField(default="") + value = FormulaField() max_value = models.PositiveSmallIntegerField( default=5, @@ -640,7 +651,6 @@ class RatingElement(BaseRatingElement): class RatingInputElement(BaseRatingElement, FormElement): label = FormulaField( - default="", help_text="The text label for this field", ) @@ -656,12 +666,9 @@ class INPUT_TEXT_VALIDATION_TYPES(models.TextChoices): INTEGER = "integer" label = FormulaField( - default="", help_text="The text label for this input", ) - default_value = FormulaField( - default="", help_text="This text input's default value." - ) + default_value = FormulaField(help_text="This text input's default value.") validation_type = models.CharField( max_length=15, choices=INPUT_TEXT_VALIDATION_TYPES.choices, @@ -669,7 +676,6 @@ class INPUT_TEXT_VALIDATION_TYPES(models.TextChoices): help_text="Optionally set the validation type to use when applying form data.", ) placeholder = FormulaField( - default="", help_text="The placeholder text which should be applied to the element.", ) is_multiline = models.BooleanField( @@ -694,15 +700,12 @@ class OPTION_TYPE(models.TextChoices): FORMULAS = "formulas" label = FormulaField( - default="", help_text="The text label for this choice", ) default_value = FormulaField( - default="", help_text="This choice's input default value.", ) placeholder = FormulaField( - default="", help_text="The placeholder text which should be applied to the element.", ) multiple = models.BooleanField( @@ -719,11 +722,9 @@ class OPTION_TYPE(models.TextChoices): default=OPTION_TYPE.MANUAL, ) formula_value = FormulaField( - default="", help_text="The value of the option if it is a formula", ) formula_name = FormulaField( - default="", help_text="The display name of the option if it is a formula", ) @@ -756,10 +757,9 @@ class CheckboxElement(FormElement): """ label = FormulaField( - default="", help_text="The text label for this input", ) - default_value = FormulaField(default="", help_text="The input's default value.") + default_value = FormulaField(help_text="The input's default value.") class ButtonElement(Element): @@ -767,7 +767,7 @@ class ButtonElement(Element): A button element """ - value = FormulaField(default="", help_text="The caption of the button.") + value = FormulaField(help_text="The caption of the button.") class CollectionField(models.Model): @@ -786,8 +786,9 @@ class CollectionField(models.Model): help_text="The type of the field.", ) - config = models.JSONField( + config = JSONFormulaField( default=dict, + properties=lazy(get_collection_field_config_formula_properties, list)(), help_text="The configuration of the field.", ) @@ -833,8 +834,6 @@ class CollectionElement(Element): button_load_more_label = FormulaField( help_text="The label of the show more button", - blank=True, - default="", ) class Meta: @@ -905,10 +904,8 @@ class IFRAME_SOURCE_TYPE(models.TextChoices): ) url = FormulaField( help_text="A link to the page to embed", - blank=True, - default="", ) - embed = FormulaField(help_text="Inline HTML to embed", blank=True, default="") + embed = FormulaField(help_text="Inline HTML to embed") height = models.PositiveIntegerField( help_text="Height in pixels of the iframe", default=300, @@ -959,15 +956,12 @@ class RecordSelectorElement(CollectionElement, FormElement): """A collection element that displays a list of records for the user to select.""" label = FormulaField( - default="", help_text="The text label for this record selector", ) default_value = FormulaField( - default="", help_text="This record selector default value.", ) placeholder = FormulaField( - default="", help_text="The placeholder text which should be applied to the element.", ) multiple = models.BooleanField( @@ -976,8 +970,6 @@ class RecordSelectorElement(CollectionElement, FormElement): ) option_name_suffix = FormulaField( help_text="The formula to generate the displayed option name suffix", - blank=True, - default="", ) @@ -987,11 +979,9 @@ class DateTimePickerElement(FormElement): """ label = FormulaField( - default="", help_text="The text label for this date time picker", ) default_value = FormulaField( - default="", help_text="This date time picker input's default value.", ) date_format = models.CharField( diff --git a/backend/src/baserow/contrib/builder/formula_importer.py b/backend/src/baserow/contrib/builder/formula_importer.py index ac67f21b3d..50a3ad879f 100644 --- a/backend/src/baserow/contrib/builder/formula_importer.py +++ b/backend/src/baserow/contrib/builder/formula_importer.py @@ -1,9 +1,9 @@ -from typing import Dict +from typing import Dict, Union from baserow.contrib.builder.data_providers.registries import ( builder_data_provider_type_registry, ) -from baserow.core.formula import get_parse_tree_for_formula +from baserow.core.formula import BaserowFormulaObject, get_parse_tree_for_formula from baserow.core.services.formula_importer import BaserowFormulaImporter @@ -18,7 +18,9 @@ def get_data_provider_type_registry(self): return builder_data_provider_type_registry -def import_formula(formula: str, id_mapping: Dict[str, str], **kwargs) -> str: +def import_formula( + formula: Union[str, BaserowFormulaObject], id_mapping: Dict[str, str], **kwargs +) -> str: """ When a formula is used in a service, it must be migrated when we import it because it could contain IDs referencing other objects. For example, the formula @@ -36,15 +38,18 @@ def import_formula(formula: str, id_mapping: Dict[str, str], **kwargs) -> str: ) ``` - :param formula: The formula to import. + :param formula: The formula to import (can be a string or BaserowFormulaObject dict) :param id_mapping: The Id map between old and new instances used during import. :param kwargs: Sometimes more parameters are needed by the import formula process. Extra kwargs are then passed to the underlying migration process. - :return: The updated path. + :return: The updated formula (same type as input - string or object). """ - if not formula: - return formula + # Figure out what our formula string is. + formula_str = formula if isinstance(formula, str) else formula["formula"] - tree = get_parse_tree_for_formula(formula) + if not formula_str: + return formula_str + + tree = get_parse_tree_for_formula(formula_str) return BuilderFormulaImporter(id_mapping, **kwargs).visit(tree) diff --git a/backend/src/baserow/contrib/builder/migrations/0064_migrate_to_formula_field_objects.py b/backend/src/baserow/contrib/builder/migrations/0064_migrate_to_formula_field_objects.py new file mode 100644 index 0000000000..25584e2fe4 --- /dev/null +++ b/backend/src/baserow/contrib/builder/migrations/0064_migrate_to_formula_field_objects.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.14 on 2025-10-15 10:50 + +from django.db import migrations + +import baserow.core.formula.field + + +class Migration(migrations.Migration): + dependencies = [ + ("builder", "0063_element_html_id"), + ] + + operations = [ + migrations.AlterField( + model_name="imageelement", + name="image_url", + field=baserow.core.formula.field.FormulaField( + blank=True, default="", help_text="A link to the image file", null=True + ), + ), + ] diff --git a/backend/src/baserow/contrib/builder/mixins.py b/backend/src/baserow/contrib/builder/mixins.py index a0968cc226..492503cd94 100644 --- a/backend/src/baserow/contrib/builder/mixins.py +++ b/backend/src/baserow/contrib/builder/mixins.py @@ -10,11 +10,14 @@ def extract_properties(self, instance, **kwargs): result = {} for formula in self.formula_generator(instance): - if not formula: + # Figure out what our formula string is. + formula_str = formula if isinstance(formula, str) else formula["formula"] + + if not formula_str: continue try: - tree = get_parse_tree_for_formula(formula) + tree = get_parse_tree_for_formula(formula_str) except BaserowFormulaSyntaxError: continue diff --git a/backend/src/baserow/contrib/builder/search_types.py b/backend/src/baserow/contrib/builder/search_types.py new file mode 100644 index 0000000000..ed25c1a30f --- /dev/null +++ b/backend/src/baserow/contrib/builder/search_types.py @@ -0,0 +1,36 @@ +from typing import Optional + +from baserow.contrib.builder.models import Builder +from baserow.core.models import Workspace +from baserow.core.search.data_types import SearchResult +from baserow.core.search.search_types import ApplicationSearchType + + +class BuilderSearchType(ApplicationSearchType): + """ + Searchable item type specifically for builders. + """ + + type = "builder" + name = "Builder" + model_class = Builder + priority = 2 + + def serialize_result( + self, result, user=None, workspace: "Workspace" = None + ) -> Optional[SearchResult]: + """Convert builder to search result with builder_id in metadata.""" + + return SearchResult( + type=self.type, + id=result.id, + title=result.name, + subtitle=self.type, + created_on=result.created_on, + updated_on=result.updated_on, + metadata={ + "workspace_id": workspace.id, + "workspace_name": workspace.name, + "builder_id": result.id, + }, + ) diff --git a/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py b/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py index daca2f2bfb..bf2e58fede 100644 --- a/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py +++ b/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py @@ -39,8 +39,9 @@ LocalBaserowUpsertRowServiceType, ) from baserow.core.db import specific_queryset +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL from baserow.core.formula.serializers import FormulaSerializerField -from baserow.core.formula.types import BaserowFormula +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE, BaserowFormulaObject from baserow.core.integrations.models import Integration from baserow.core.registry import Instance from baserow.core.services.handler import ServiceHandler @@ -59,27 +60,34 @@ class NotificationWorkflowActionType(BuilderWorkflowActionType): "title": FormulaSerializerField( help_text="The title of the notification. Must be an formula.", required=False, - allow_blank=True, - default="", ), "description": FormulaSerializerField( help_text="The description of the notification. Must be an formula.", required=False, - allow_blank=True, - default="", ), } class SerializedDict(BuilderWorkflowActionDict): - title: BaserowFormula - description: BaserowFormula + title: BaserowFormulaObject + description: BaserowFormulaObject @property def allowed_fields(self): return super().allowed_fields + ["title", "description"] - def get_pytest_params(self, pytest_data_fixture) -> Dict[str, Any]: - return {"title": "'hello'", "description": "'there'"} + def get_pytest_params(self, pytest_data_fixture) -> Dict[str, BaserowFormulaObject]: + return { + "title": BaserowFormulaObject( + formula="'hello'", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), + "description": BaserowFormulaObject( + formula="'there'", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), + } class OpenPageWorkflowActionType(BuilderWorkflowActionType): diff --git a/backend/src/baserow/contrib/dashboard/apps.py b/backend/src/baserow/contrib/dashboard/apps.py index 71db1c2e71..b55076e25c 100644 --- a/backend/src/baserow/contrib/dashboard/apps.py +++ b/backend/src/baserow/contrib/dashboard/apps.py @@ -115,3 +115,9 @@ def ready(self): action_type_registry.register(UpdateWidgetActionType()) action_type_registry.register(DeleteWidgetActionType()) action_type_registry.register(UpdateDashboardDataSourceActionType()) + + from baserow.core.search.registries import workspace_search_registry + + from .search_types import DashboardSearchType + + workspace_search_registry.register(DashboardSearchType()) diff --git a/backend/src/baserow/contrib/dashboard/search_types.py b/backend/src/baserow/contrib/dashboard/search_types.py new file mode 100644 index 0000000000..d6cbd93671 --- /dev/null +++ b/backend/src/baserow/contrib/dashboard/search_types.py @@ -0,0 +1,13 @@ +from baserow.contrib.dashboard.models import Dashboard +from baserow.core.search.search_types import ApplicationSearchType + + +class DashboardSearchType(ApplicationSearchType): + """ + Searchable item type specifically for dashboards. + """ + + type = "dashboard" + name = "Dashboard" + model_class = Dashboard + priority = 3 diff --git a/backend/src/baserow/contrib/dashboard/widgets/service.py b/backend/src/baserow/contrib/dashboard/widgets/service.py index 664b3e76b9..9f072d7e5e 100644 --- a/backend/src/baserow/contrib/dashboard/widgets/service.py +++ b/backend/src/baserow/contrib/dashboard/widgets/service.py @@ -135,6 +135,7 @@ def update_widget( Updates a widget given the user permissions. :param user: The user trying to update the widget. + :param widget_id: The ID of the widget to update. :param kwargs: Attributes of the widget. :raises WidgetDoesNotExist: If the widget can't be found. :raises PermissionException: Raised when user doesn't have the diff --git a/backend/src/baserow/contrib/dashboard/widgets/widget_types.py b/backend/src/baserow/contrib/dashboard/widgets/widget_types.py index 79ec7a6fde..7b26ec846a 100644 --- a/backend/src/baserow/contrib/dashboard/widgets/widget_types.py +++ b/backend/src/baserow/contrib/dashboard/widgets/widget_types.py @@ -1,3 +1,5 @@ +from typing import Any + from rest_framework import serializers from baserow.contrib.dashboard.data_sources.handler import DashboardDataSourceHandler @@ -62,10 +64,10 @@ def after_delete(self, instance: Widget): def deserialize_property( self, prop_name: str, - value: any, - id_mapping: dict[str, any], + value: Any, + id_mapping: dict[str, Any], **kwargs, - ) -> any: + ) -> Any: if prop_name == "data_source_id" and value: return id_mapping["dashboard_data_sources"][value] diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index e80109ae9a..1bcf845542 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -1052,6 +1052,20 @@ def ready(self): row_history_provider_registry.register(UpdateRowsHistoryProvider()) row_history_provider_registry.register(RestoreFromTrashHistoryProvider()) + from baserow.core.search.registries import workspace_search_registry + + from .search_types import ( + DatabaseSearchType, + FieldDefinitionSearchType, + RowSearchType, + TableSearchType, + ) + + workspace_search_registry.register(DatabaseSearchType()) + workspace_search_registry.register(FieldDefinitionSearchType()) + workspace_search_registry.register(RowSearchType()) + workspace_search_registry.register(TableSearchType()) + from baserow.contrib.database.fields.field_constraints import ( RatingTypeUniqueWithEmptyConstraint, TextTypeUniqueWithEmptyConstraint, diff --git a/backend/src/baserow/contrib/database/search_base.py b/backend/src/baserow/contrib/database/search_base.py new file mode 100644 index 0000000000..19fdacf44a --- /dev/null +++ b/backend/src/baserow/contrib/database/search_base.py @@ -0,0 +1,126 @@ +from typing import TYPE_CHECKING, List + +from django.contrib.auth.models import AbstractUser +from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector +from django.db import models +from django.db.models import CharField, F, Q, TextField, Value +from django.db.models.functions import Cast, JSONObject + +from baserow.core.search.data_types import SearchContext +from baserow.core.search.model_search_base import ModelSearchableItemType + +if TYPE_CHECKING: + from baserow.core.models import Workspace + + +class DatabaseSearchableItemType(ModelSearchableItemType): + """ + Base class for searchable item types that use Django ORM for database operations. + """ + + search_fields: List[str] = [] + result_fields: List[str] = [] + supports_full_text: bool = False + + def build_search_query(self, query: str) -> Q: + """ + Build Django Q object for searching across search_fields. + + Default implementation searches across all search_fields using icontains. + Override for custom search logic. + + param query: The search query string + return Q: Django Q object for the search + """ + + if not self.search_fields: + return Q() + + search_conditions = Q() + for field in self.search_fields: + search_conditions |= Q(**{f"{field}__icontains": query}) + + return search_conditions + + def get_search_queryset( + self, + user: "AbstractUser", + workspace: "Workspace", + context: SearchContext, + ) -> models.QuerySet: + """ + Build search queryset using Django ORM and search_fields. + + Default implementation that works for most Django model-based searches. + Override for custom search logic (like RowSearchType). + + param user: The user requesting search + param workspace: The workspace being searched + param context: Search context with query, limit, offset + return models.QuerySet: Prepared queryset ready for execution + """ + + queryset = self.get_base_queryset(user, workspace) + + search_q = self.build_search_query(context.query) + if search_q: + queryset = queryset.filter(search_q) + + queryset = queryset.annotate( + search_type=Value(self.type, output_field=CharField()) + ) + return queryset + + def build_payload(self): + """ + Default payload for name-based searchable items. + """ + + return JSONObject() + + def build_title_annotation(self): + return Cast(F("name"), output_field=TextField()) + + def build_subtitle_annotation(self): + return Value(self.type, output_field=TextField()) + + def get_union_values_queryset( + self, + user: "AbstractUser", + workspace: "Workspace", + context: SearchContext, + ) -> models.QuerySet: + """ + Default union values queryset: ranks by SearchRank on name and emits + standardized fields including a JSON payload. Subclasses can override + build_payload() to customize the payload. + """ + + qs = self.get_search_queryset(user, workspace, context) + + search_query = SearchQuery( + context.query, search_type="websearch", config="english" + ) + search_vector = SearchVector("name", config="english") + + qs = qs.annotate( + search_type=Value(self.type, output_field=TextField()), + object_id=Cast(F("id"), output_field=TextField()), + sort_key=F("id"), + rank=SearchRank(search_vector, search_query), + priority=Value(self.priority), + title=self.build_title_annotation(), + subtitle=self.build_subtitle_annotation(), + payload=self.build_payload(), + ) + + return qs.values( + "search_type", + "object_id", + "sort_key", + "rank", + "priority", + "title", + "subtitle", + "payload", + ) diff --git a/backend/src/baserow/contrib/database/search_types.py b/backend/src/baserow/contrib/database/search_types.py new file mode 100644 index 0000000000..6e3db200fc --- /dev/null +++ b/backend/src/baserow/contrib/database/search_types.py @@ -0,0 +1,516 @@ +from typing import Dict, Iterable, List, Optional + +from django.contrib.auth.models import AbstractUser +from django.contrib.postgres.search import SearchQuery, SearchRank +from django.db.models import ( + Case, + CharField, + F, + FloatField, + IntegerField, + Prefetch, + QuerySet, + TextField, + Value, + When, + Window, +) +from django.db.models.functions import Cast, Concat, JSONObject, RowNumber + +from baserow.contrib.database.fields.handler import FieldHandler +from baserow.contrib.database.fields.models import Field +from baserow.contrib.database.fields.operations import ListFieldsOperationType +from baserow.contrib.database.models import Database +from baserow.contrib.database.search.handler import SearchHandler +from baserow.contrib.database.search_base import DatabaseSearchableItemType +from baserow.contrib.database.table.models import Table +from baserow.contrib.database.table.operations import ReadDatabaseTableOperationType +from baserow.core.handler import CoreHandler +from baserow.core.models import Workspace +from baserow.core.search.data_types import SearchResult +from baserow.core.search.registries import SearchableItemType +from baserow.core.search.search_types import ApplicationSearchType + + +def _empty_annotated_table_queryset(search_type: str, priority: int): + return ( + Table.objects.none() + .annotate( + search_type=Value(search_type, output_field=TextField()), + object_id=Value("", output_field=TextField()), + sort_key=Value(0, output_field=IntegerField()), + rank=Value(None, output_field=FloatField()), + priority=Value(priority), + title=Value(None, output_field=TextField()), + subtitle=Value(None, output_field=TextField()), + payload=JSONObject(), + ) + .values( + "search_type", + "object_id", + "sort_key", + "rank", + "priority", + "title", + "subtitle", + "payload", + ) + ) + + +class DatabaseSearchType(ApplicationSearchType): + """ + Searchable item type specifically for databases. + """ + + priority = 1 + + type = "database" + name = "Database" + model_class = Database + + def serialize_result( + self, result: Database, user: "AbstractUser", workspace: "Workspace" + ) -> Optional[SearchResult]: + """Convert database to search result with database_id in metadata.""" + + return SearchResult( + type=self.type, + id=result.id, + title=result.name, + subtitle=self.name, + created_on=result.created_on, + updated_on=result.updated_on, + metadata={ + "workspace_id": workspace.id, + "workspace_name": workspace.name, + "database_id": result.id, + }, + ) + + def build_subtitle_annotation(self): + return Value(self.name, output_field=TextField()) + + +class TableSearchType(DatabaseSearchableItemType): + """ + Searchable item type for database tables. + """ + + type = "database_table" + name = "Tables" + model_class = Table + priority = 2 + + search_fields = ["name"] + result_fields = ["id", "name", "created_on", "updated_on"] + supports_full_text = False + + def get_base_queryset(self, user, workspace) -> QuerySet: + return ( + Table.objects.filter( + trashed=False, + database__trashed=False, + database__workspace=workspace, + ) + .select_related("database__workspace") + .order_by("database__order", "order", "id") + ) + + def get_search_queryset(self, user, workspace, context) -> QuerySet: + queryset = self.get_base_queryset(user, workspace) + + queryset = CoreHandler().filter_queryset( + user, + ReadDatabaseTableOperationType.type, + queryset, + workspace=workspace, + ) + + search_q = self.build_search_query(context.query) + if search_q: + queryset = queryset.filter(search_q) + return queryset.annotate(search_type=Value(self.type, output_field=CharField())) + + def build_payload(self): + return JSONObject( + title=F("name"), + subtitle=F("database__name"), + workspace_id=F("database__workspace_id"), + database_id=F("database_id"), + table_id=F("id"), + table_name=F("name"), + database_name=F("database__name"), + ) + + def build_subtitle_annotation(self): + return Concat( + Value("Table in ", output_field=TextField()), + Cast(F("database__name"), output_field=TextField()), + output_field=TextField(), + ) + + def serialize_result(self, item, user, workspace) -> Optional[SearchResult]: + database = item.database + return SearchResult( + type=self.type, + id=item.id, + title=item.name, + subtitle=f"{database.name}", + metadata={ + "workspace_id": workspace.id, + "database_id": database.id, + "table_id": item.id, + }, + ) + + +class FieldDefinitionSearchType(DatabaseSearchableItemType): + """ + Searchable item type for database fields (definitions only). + """ + + type = "database_field" + name = "Fields" + model_class = Field + priority = 6 + + search_fields = ["name", "description"] + result_fields = ["id", "name", "created_on", "updated_on"] + supports_full_text = False + + def get_base_queryset(self, user, workspace) -> QuerySet: + return FieldHandler().get_base_fields_queryset() + + def get_search_queryset(self, user, workspace, context) -> QuerySet: + allowed_tables_qs = Table.objects.filter( + trashed=False, + database__trashed=False, + database__workspace=workspace, + ).values("id") + + queryset = self.get_base_queryset(user, workspace).filter( + trashed=False, + table__trashed=False, + table__database__trashed=False, + table_id__in=allowed_tables_qs, + ) + + queryset = CoreHandler().filter_queryset( + user, + ListFieldsOperationType.type, + queryset, + workspace=workspace, + ) + + search_q = self.build_search_query(context.query) + if search_q: + queryset = queryset.filter(search_q) + return queryset.annotate( + search_type=Value(self.type, output_field=CharField()), + title=F("name"), + subtitle=self.build_subtitle_annotation(), + payload=self.build_payload(), + ).order_by("id") + + def build_subtitle_annotation(self): + return Concat( + Value("Field in ", output_field=TextField()), + Cast(F("table__database__name"), output_field=TextField()), + Value(" / ", output_field=TextField()), + Cast(F("table__name"), output_field=TextField()), + output_field=TextField(), + ) + + def build_payload(self): + return JSONObject( + workspace_id=F("table__database__workspace_id"), + database_id=F("table__database_id"), + table_id=F("table_id"), + field_id=F("id"), + ) + + def serialize_result(self, item, user, workspace) -> Optional[SearchResult]: + database = item.table.database + table = item.table + return SearchResult( + type=self.type, + id=item.id, + title=item.name, + subtitle=f"{database.name} / {table.name}", + metadata={ + "workspace_id": workspace.id, + "database_id": database.id, + "table_id": table.id, + }, + ) + + +class RowSearchType(SearchableItemType): + """ + Searchable item type for rows across all tables in a workspace using full text. + """ + + type = "database_row" + name = "Rows" + model_class = Table + supports_full_text = True + priority = 7 + + def get_search_queryset(self, user, workspace, context) -> QuerySet: + tables = ( + Table.objects.filter( + trashed=False, + database__trashed=False, + database__workspace=workspace, + ) + .select_related("database", "database__workspace") + .prefetch_related(Prefetch("field_set", queryset=Field.objects.all())) + .order_by("database__order", "order", "id") + ) + + tables = CoreHandler().filter_queryset( + user, + ReadDatabaseTableOperationType.type, + tables, + workspace=workspace, + ) + return tables + + def get_union_values_queryset(self, user, workspace, context) -> QuerySet: + """ + Optimized approach using window function to pick best field per row. + Uses ROW_NUMBER() OVER ( + PARTITION BY table_id, row_id ORDER BY rank DESC, field_id ASC + ) + to select only the highest-ranking field for each row. + """ + + if not SearchHandler.workspace_search_table_exists(workspace.id): + return _empty_annotated_table_queryset( + self.type, getattr(self, "priority", 10) + ) + + sanitized_query = SearchHandler.escape_postgres_query(context.query) + if not sanitized_query: + return _empty_annotated_table_queryset( + self.type, getattr(self, "priority", 10) + ) + + # Limit to tables user can read + tables_qs = Table.objects.filter( + trashed=False, + database__trashed=False, + database__workspace=workspace, + ) + tables_qs = CoreHandler().filter_queryset( + user, + ReadDatabaseTableOperationType.type, + tables_qs, + workspace=workspace, + ) + allowed_table_ids = list(tables_qs.values_list("id", flat=True)) + if not allowed_table_ids: + return _empty_annotated_table_queryset( + self.type, getattr(self, "priority", 10) + ) + + search_model = SearchHandler.get_workspace_search_table_model(workspace.id) + search_query = SearchQuery( + sanitized_query, search_type="raw", config=SearchHandler.search_config() + ) + + # Get permission-filtered fields with table_id using the handler without + # deferring fields to avoid conflicts with select_related + base_fields_qs = ( + FieldHandler() + .get_base_fields_queryset() + .filter( + trashed=False, + table__trashed=False, + table__database__trashed=False, + table__database__workspace=workspace, + table_id__in=allowed_table_ids, + ) + ) + + # Apply permission filtering to fields query before reducing to tuples + fields_qs = CoreHandler().filter_queryset( + user, + ListFieldsOperationType.type, + base_fields_qs, + workspace=workspace, + ) + base_fields = list(fields_qs.values_list("id", "table_id")) + if not base_fields: + return _empty_annotated_table_queryset( + self.type, getattr(self, "priority", 10) + ) + + # Build field_id -> table_id mapping for CASE expression + when_clauses = [ + When(field_id=f_id, then=Value(t_id)) for (f_id, t_id) in base_fields + ] + table_id_case = Case( + *when_clauses, default=Value(0), output_field=IntegerField() + ) + + # Use window function to pick best field per row (highest rank, lowest field_id) + qs = ( + search_model.objects.filter( + field_id__in=[f_id for (f_id, _t_id) in base_fields], value=search_query + ) + .annotate( + rank=SearchRank(F("value"), search_query), + table_id=table_id_case, + rn=Window( + expression=RowNumber(), + partition_by=[F("table_id"), F("row_id")], + order_by=[F("rank").desc(), F("field_id").asc()], + ), + ) + .filter(rn=1) # Only keep the best field per row + .annotate( + search_type=Value(self.type, output_field=TextField()), + object_id=Concat( + Cast(F("table_id"), output_field=TextField()), + Value("_", output_field=TextField()), + Cast(F("row_id"), output_field=TextField()), + output_field=TextField(), + ), + sort_key=F("row_id"), + priority=Value(self.priority), + title=Concat( + Value("row "), + Cast(F("row_id"), output_field=TextField()), + output_field=TextField(), + ), + subtitle=Value(None, output_field=TextField()), + payload=JSONObject( + table_id=F("table_id"), + row_id=F("row_id"), + field_id=F("field_id"), + query=Value(context.query), + ), + ) + .values( + "search_type", + "object_id", + "sort_key", + "rank", + "priority", + "title", + "subtitle", + "payload", + ) + ) + + return qs + + def postprocess(self, rows: Iterable[Dict]) -> List[SearchResult]: + """ + Return minimal row results + """ + + if not rows: + return [] + + rows_list = list(rows) + if not rows_list: + return [] + + field_ids = sorted( + { + int(r.get("payload", {}).get("field_id")) + for r in rows_list + if r.get("payload", {}).get("field_id") is not None + } + ) + + # Early return if no field IDs to process + if not field_ids: + return [] + + fields_qs = ( + FieldHandler() + .get_base_fields_queryset() + .filter(id__in=field_ids) + .select_related("table__database") + ) + + field_id_to_name = {} + field_id_to_table_id = {} + table_id_to_name = {} + table_id_to_database_id = {} + database_id_to_name = {} + database_id_to_workspace_id = {} + + for f in fields_qs: + field_id_to_name[f.id] = f.name + field_id_to_table_id[f.id] = f.table_id + if f.table_id: + table_id_to_name[f.table_id] = getattr(f.table, "name", None) + table_id_to_database_id[f.table_id] = getattr( + f.table, "database_id", None + ) + if getattr(f.table, "database_id", None): + database = getattr(f.table, "database", None) + if database is not None: + database_id_to_name[f.table.database_id] = getattr( + database, "name", None + ) + database_id_to_workspace_id[f.table.database_id] = getattr( + database, "workspace_id", None + ) + + results_list = [] + for r in rows_list: + payload = r.get("payload", {}) + table_id = payload.get("table_id") + row_id = payload.get("row_id") + field_id = payload.get("field_id") + + if not all(x is not None for x in [table_id, row_id, field_id]): + continue + + object_id = r.get("object_id") + rank = r.get("rank") + + field_id_int = int(field_id) + table_id_int = field_id_to_table_id.get(field_id_int) or int(table_id) + database_id = table_id_to_database_id.get(table_id_int) + database_name = database_id_to_name.get(database_id) + workspace_id = database_id_to_workspace_id.get(database_id) + table_name = table_id_to_name.get(table_id_int) + field_name = field_id_to_name.get(field_id_int) + + parts = [] + if database_name: + parts.append(database_name) + if table_name: + parts.append(table_name) + subtitle_suffix = " / ".join(parts) if parts else None + subtitle = f"Row in {subtitle_suffix}" if subtitle_suffix else None + + results_list.append( + SearchResult( + type=self.type, + id=object_id, + title=f"Row #{row_id}", + subtitle=subtitle, + description=None, + metadata={ + "workspace_id": workspace_id, + "database_id": database_id, + "table_id": int(table_id), + "row_id": int(row_id), + "field_id": int(field_id), + "database_name": database_name, + "table_name": table_name, + "field_name": field_name, + "rank": rank, + }, + ) + ) + + return results_list diff --git a/backend/src/baserow/contrib/integrations/api/core/serializers.py b/backend/src/baserow/contrib/integrations/api/core/serializers.py index 8b9e3eb37a..bbade5ac12 100644 --- a/backend/src/baserow/contrib/integrations/api/core/serializers.py +++ b/backend/src/baserow/contrib/integrations/api/core/serializers.py @@ -5,7 +5,7 @@ class CoreRouterServiceEdgeSerializer(serializers.ModelSerializer): - condition = FormulaSerializerField(allow_blank=True) + condition = FormulaSerializerField() class Meta: model = CoreRouterServiceEdge diff --git a/backend/src/baserow/contrib/integrations/core/api/serializers.py b/backend/src/baserow/contrib/integrations/core/api/serializers.py index 28b5e708ff..c0654a0b2d 100644 --- a/backend/src/baserow/contrib/integrations/core/api/serializers.py +++ b/backend/src/baserow/contrib/integrations/core/api/serializers.py @@ -44,7 +44,7 @@ class HTTPFormDataSerializer(serializers.ModelSerializer): key = serializers.CharField( allow_blank=True, max_length=255, validators=[validate_form_data_key] ) - value = FormulaSerializerField(allow_blank=True) + value = FormulaSerializerField() class Meta: model = HTTPFormData @@ -59,7 +59,7 @@ class HTTPHeaderSerializer(serializers.ModelSerializer): key = serializers.CharField( allow_blank=True, max_length=255, validators=[validate_param_or_header_name] ) - value = FormulaSerializerField(allow_blank=True) + value = FormulaSerializerField() class Meta: model = HTTPHeader @@ -74,7 +74,7 @@ class HTTPQueryParamSerializer(serializers.ModelSerializer): key = serializers.CharField( allow_blank=True, max_length=255, validators=[validate_param_or_header_name] ) - value = FormulaSerializerField(allow_blank=True) + value = FormulaSerializerField() class Meta: model = HTTPQueryParam diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py index 9339a143dc..473fcf9dcd 100644 --- a/backend/src/baserow/contrib/integrations/core/service_types.py +++ b/backend/src/baserow/contrib/integrations/core/service_types.py @@ -151,8 +151,6 @@ def serializer_field_overrides(self): "url": FormulaSerializerField( help_text=CoreHTTPRequestService._meta.get_field("url").help_text, default="", - allow_blank=True, - required=False, ), "body_type": serializers.ChoiceField( choices=BODY_TYPE.choices, @@ -165,8 +163,6 @@ def serializer_field_overrides(self): "body_content" ).help_text, default="", - allow_blank=True, - required=False, ), "headers": HTTPHeaderSerializer( many=True, @@ -694,39 +690,21 @@ def serializer_field_overrides(self): ), "from_email": FormulaSerializerField( help_text=CoreSMTPEmailService._meta.get_field("from_email").help_text, - allow_blank=True, - required=False, - default="", ), "from_name": FormulaSerializerField( help_text=CoreSMTPEmailService._meta.get_field("from_name").help_text, - allow_blank=True, - required=False, - default="", ), "to_emails": FormulaSerializerField( help_text=CoreSMTPEmailService._meta.get_field("to_emails").help_text, - allow_blank=True, - required=False, - default="", ), "cc_emails": FormulaSerializerField( help_text=CoreSMTPEmailService._meta.get_field("cc_emails").help_text, - allow_blank=True, - required=False, - default="", ), "bcc_emails": FormulaSerializerField( help_text=CoreSMTPEmailService._meta.get_field("bcc_emails").help_text, - allow_blank=True, - required=False, - default="", ), "subject": FormulaSerializerField( help_text=CoreSMTPEmailService._meta.get_field("subject").help_text, - allow_blank=True, - required=False, - default="", ), "body_type": serializers.ChoiceField( choices=[ @@ -739,9 +717,6 @@ def serializer_field_overrides(self): ), "body": FormulaSerializerField( help_text=CoreSMTPEmailService._meta.get_field("body").help_text, - allow_blank=True, - required=False, - default="", ), } diff --git a/backend/src/baserow/contrib/integrations/local_baserow/api/serializers.py b/backend/src/baserow/contrib/integrations/local_baserow/api/serializers.py index 819ffa2dff..8bcba5fb5f 100644 --- a/backend/src/baserow/contrib/integrations/local_baserow/api/serializers.py +++ b/backend/src/baserow/contrib/integrations/local_baserow/api/serializers.py @@ -55,7 +55,6 @@ def to_internal_value(self, data): class LocalBaserowTableServiceFilterSerializer(serializers.ModelSerializer): value = OptionalFormulaSerializerField( - allow_blank=True, help_text="A formula for the filter's value.", is_formula_field_name="value_is_formula", ) @@ -119,4 +118,4 @@ class LocalBaserowTableServiceFieldMappingSerializer(serializers.Serializer): enabled = serializers.BooleanField( help_text="Indicates whether the field mapping is enabled or not." ) - value = FormulaSerializerField(allow_blank=True) + value = FormulaSerializerField() diff --git a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py index 87ba6c8192..5ab46cba4a 100644 --- a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py +++ b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py @@ -20,7 +20,7 @@ LocalBaserowTableServiceSort, LocalBaserowViewService, ) -from baserow.core.formula import BaserowFormula, resolve_formula +from baserow.core.formula import BaserowFormulaObject, resolve_formula from baserow.core.formula.registries import formula_runtime_function_registry from baserow.core.formula.serializers import FormulaSerializerField from baserow.core.formula.validator import ensure_integer, ensure_string @@ -140,12 +140,12 @@ def deserialize_filters(self, value, id_mapping): ), "value": ( id_mapping["database_field_select_options"].get( - int(f["value"]), f["value"] + int(f["value"]["formula"]), f["value"]["formula"] ) if "database_field_select_options" in id_mapping - and f["value"].isdigit() + and f["value"]["formula"].isdigit() and not f["value_is_formula"] - else f["value"] + else f["value"]["formula"] ), } for f in value @@ -273,7 +273,7 @@ def get_dispatch_filters( f"The {field_name} service filter formula can't be resolved: {exc}" ) from exc else: - resolved_value = service_filter.value + resolved_value = service_filter.value["formula"] service_filter_builder.filter( view_filter_type.get_filter( @@ -676,8 +676,6 @@ class LocalBaserowTableServiceSearchableMixin: mixin_serializer_field_names = ["search_query"] mixin_serializer_field_overrides = { "search_query": FormulaSerializerField( - required=False, - allow_blank=True, help_text="Any search queries to apply to the " "service when it is dispatched.", ) @@ -788,14 +786,12 @@ class LocalBaserowTableServiceSpecificRowMixin: mixin_serializer_field_names = ["row_id"] mixin_serializer_field_overrides = { "row_id": FormulaSerializerField( - required=False, - allow_blank=True, help_text="A formula for defining the intended row.", ), } class SerializedDict(ServiceDict): - row_id: BaserowFormula + row_id: BaserowFormulaObject def formulas_to_resolve(self, service: ServiceSubClass) -> list[FormulaToResolve]: """ @@ -805,7 +801,7 @@ def formulas_to_resolve(self, service: ServiceSubClass) -> list[FormulaToResolve super_formulas = super().formulas_to_resolve(service) # Ignore empty formulas - if not service.row_id: + if not service.row_id["formula"]: return super_formulas return super_formulas + [ diff --git a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py index 7c8f0d4e0c..84f6a250cd 100644 --- a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py +++ b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py @@ -1227,6 +1227,7 @@ def serializer_field_overrides(self): return { **super().serializer_field_overrides, **LocalBaserowTableServiceFilterableMixin.mixin_serializer_field_overrides, + **LocalBaserowTableServiceSearchableMixin.mixin_serializer_field_overrides, "field_id": serializers.IntegerField( required=False, allow_null=True, diff --git a/backend/src/baserow/contrib/integrations/migrations/0021_migrate_to_formula_field_objects.py b/backend/src/baserow/contrib/integrations/migrations/0021_migrate_to_formula_field_objects.py new file mode 100644 index 0000000000..0d488d6527 --- /dev/null +++ b/backend/src/baserow/contrib/integrations/migrations/0021_migrate_to_formula_field_objects.py @@ -0,0 +1,51 @@ +# Generated by Django 5.0.14 on 2025-10-15 10:50 + +from django.db import migrations + +import baserow.core.formula.field + + +class Migration(migrations.Migration): + dependencies = [ + ("integrations", "0020_corehttptriggerservice"), + ] + + operations = [ + migrations.AlterField( + model_name="localbaserowaggregaterows", + name="search_query", + field=baserow.core.formula.field.FormulaField( + blank=True, + default="", + help_text="The query to apply to the service to narrow the results down.", + null=True, + ), + ), + migrations.AlterField( + model_name="localbaserowgetrow", + name="search_query", + field=baserow.core.formula.field.FormulaField( + blank=True, + default="", + help_text="The query to apply to the service to narrow the results down.", + null=True, + ), + ), + migrations.AlterField( + model_name="localbaserowlistrows", + name="search_query", + field=baserow.core.formula.field.FormulaField( + blank=True, + default="", + help_text="The query to apply to the service to narrow the results down.", + null=True, + ), + ), + migrations.AlterField( + model_name="localbaserowupsertrow", + name="row_id", + field=baserow.core.formula.field.FormulaField( + blank=True, default="", null=True + ), + ), + ] diff --git a/backend/src/baserow/core/feature_flags.py b/backend/src/baserow/core/feature_flags.py index e5a016de8d..ba9ecb7f51 100644 --- a/backend/src/baserow/core/feature_flags.py +++ b/backend/src/baserow/core/feature_flags.py @@ -4,6 +4,7 @@ FF_AUTOMATION = "automation" FF_ASSISTANT = "assistant" +FF_WORKSPACE_SEARCH = "workspace-search" FF_DATE_DEPENDENCY = "date_dependency" FF_ENABLE_ALL = "*" diff --git a/backend/src/baserow/core/formula/__init__.py b/backend/src/baserow/core/formula/__init__.py index c2992e8cb9..fd4f19084c 100644 --- a/backend/src/baserow/core/formula/__init__.py +++ b/backend/src/baserow/core/formula/__init__.py @@ -9,7 +9,11 @@ from baserow.core.formula.parser.generated.BaserowFormulaVisitor import ( BaserowFormulaVisitor, ) -from baserow.core.formula.types import FormulaContext, FunctionCollection +from baserow.core.formula.types import ( + BaserowFormulaObject, + FormulaContext, + FunctionCollection, +) __all__ = [ BaserowFormulaException, @@ -24,20 +28,23 @@ def resolve_formula( - formula: str, functions: FunctionCollection, formula_context: FormulaContext + formula: BaserowFormulaObject, + functions: FunctionCollection, + formula_context: FormulaContext, ) -> Any: """ Helper to resolve a formula given the formula_context. :param formula: the formula itself. + :param functions: The collection of functions that can be used in formulas. :param formula_context: A dict like object that contains the data that can be accessed in from the formulas. :return: the formula result. """ # If we receive a blank formula string, don't attempt to parse it. - if not formula: + if not formula["formula"]: return "" - tree = get_parse_tree_for_formula(formula) + tree = get_parse_tree_for_formula(formula["formula"]) return BaserowPythonExecutor(functions, formula_context).visit(tree) diff --git a/backend/src/baserow/core/formula/field.py b/backend/src/baserow/core/formula/field.py index b780572137..988f41cc4d 100644 --- a/backend/src/baserow/core/formula/field.py +++ b/backend/src/baserow/core/formula/field.py @@ -1,11 +1,488 @@ -from django.db import models +import json +import logging +from typing import Dict, List, Union + +from django.db import connection, models + +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_SIMPLE, + BaserowFormulaMinified, + FormulaFieldDatabaseValue, + JSONFormulaFieldDatabaseValue, + JSONFormulaFieldResult, +) + +logger = logging.getLogger(__name__) + + +BASEROW_FORMULA_VERSION_INITIAL = "0.1" class FormulaField(models.TextField): """ - A formula field contains the text value of a runtime formula like: - - concat("test:", get("page_parameter.id"), "-") - - get("data_source.Product.id") + A formula field which can contain: - For now it's just a text field but we can add layer of validation later. + - A JSON-serialized formula string: + - E.g. 'get(\"data_source.123.field_123\")' + - This can happen if a user fetches a row with one or more formula fields + which were not yet migrated to the new formula context format. + - A JSON-serialized formula context: + - E.g. {"f":"get(\"data_source.123.field_123\")\","m": "simple","v":"0.1"} + - This is the new format which contains the formula string, the mode (simple, + advanced, raw) and the version of the formula context. """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # For compat reasons, applied the parameters we used to receive. + # These can be altered once we inherit from `JSONField`. + self.default = "" + self.null = True + self.blank = True + + def _value_is_serialized_object(self, value: FormulaFieldDatabaseValue) -> bool: + return isinstance(value, str) and value[:1] == "{" and value[-1:] == "}" + + def _transform_db_value_to_dict( + self, value: FormulaFieldDatabaseValue + ) -> BaserowFormulaObject: + """ + Responsible for taking a `value` from our database, which could be a string + or dictionary, and transforming it into a `BaserowFormulaObject`. + + :param value: The value from the database, either a string or dictionary. + :return: A `BaserowFormulaObject`. + """ + + # If the column type is "text", then we haven't yet migrated the schema. + if self.db_type(connection) == "text": + if isinstance(value, int): + # A small hack for our backend tests: if we + # receive an integer, we convert it to a string. + value = str(value) + # We could encounter a serialized object... + if self._value_is_serialized_object(value): + # If we have, then we can parse it and return the `BaserowFormulaObject` + context = json.loads(value) + return BaserowFormulaObject( + mode=context["m"], version=context["v"], formula=context["f"] + ) + elif isinstance(value, str): + # Otherwise, it's a raw formula string, which we can wrap in a + # `BaserowFormulaObject` and return. + return BaserowFormulaObject( + formula=value, + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + # It's a dictionary, so we can assume it's already a formula context. + # We just wrap it in a `BaserowFormulaObject` for typing purposes. + return BaserowFormulaObject(**value) + else: + # We either have a serialized formula context, or a raw formula string. + # Either way, we need to load it as JSON as the `FormulaField` does not + # yet inherit from `JSONField`. + try: + value = json.loads(value) + except (TypeError, json.JSONDecodeError): + logger.error( + "FormulaField was unable to deserialize " + f"value '{value}' when the column type was `json`.", + exc_info=True, + ) + return BaserowFormulaObject( + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + formula="", + ) + + if isinstance(value, str): + return BaserowFormulaObject( + formula=value, + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + + return BaserowFormulaObject( + mode=value["m"], version=value["v"], formula=value["f"] + ) + + def contribute_to_class(self, cls, name, **kwargs): + """ + Due to a limitation of Django's ORM after saving, it keeps the original value + in memory without re-processing it through `to_python`. We need to override the + save method to ensure the value is transformed correctly after each save. + """ + + super().contribute_to_class(cls, name, **kwargs) + + # Store references for closure + field_name = name + field_instance = self + original_save = cls.save + + def save_with_to_python(instance, *args, **kwargs): + # Perform the original save operation + result = original_save(instance, *args, **kwargs) + # Get the intended formula field value, and process it + # with `to_python` to ensure it's in the correct format. + value = getattr(instance, field_name, None) + setattr(instance, field_name, field_instance.to_python(value)) + return result + + cls.save = save_with_to_python + + def to_python(self, value: FormulaFieldDatabaseValue) -> BaserowFormulaObject: + """ + Called during create/update and deserialization. We will call + `_transform_db_value_to_dict` to ensure we always return a + `BaserowFormulaObject`. + + :param value: The value from the database, either a string or dictionary. + :return: A `BaserowFormulaObject`. + """ + + return self._transform_db_value_to_dict(value) + + def from_db_value( + self, value: FormulaFieldDatabaseValue, *args + ) -> BaserowFormulaObject: + """ + Called when reading from the database. We will call + `_transform_db_value_to_dict` to ensure we always return a + `BaserowFormulaObject`. + + :param value: The value from the database, either a string or dictionary. + :return: A `BaserowFormulaObject`. + """ + + return self._transform_db_value_to_dict(value) + + def get_prep_value( + self, value: Union[str, BaserowFormulaObject] + ) -> Union[str, BaserowFormulaMinified]: + """ + Responsible for converting a Python value to database value. Our Python + value could be a string (a raw formula string), or a `BaserowFormulaObject`. + We need to convert both of these into a `BaserowFormulaMinified` object + (or its JSON-serialized string representation, depending on the column type). + + :param value: The value to convert, either a string or `BaserowFormulaObject`. + :return: Either a JSON-serialized string (if the column type is `text`) + or a `BaserowFormulaMinified` object (if the column type is `json`). + """ + + # Mainly for defensive programming purposes: if we + # receive `None`, we return a default empty formula context. + # We should always be receiving a string or dictionary here. + if value is None: + return json.dumps( + BaserowFormulaMinified( + m=BASEROW_FORMULA_MODE_SIMPLE, + v=BASEROW_FORMULA_VERSION_INITIAL, + f="", + ) + ) + + # v2/v2.1: if we've received a dictionary... + if isinstance(value, dict): + # Ensure we have proper defaults for None values + mode = value.get("mode") or BASEROW_FORMULA_MODE_SIMPLE + version = value.get("version") or BASEROW_FORMULA_VERSION_INITIAL + formula = value.get("formula") or "" + + # v2: the column type is `text`, so we need to + # serialize the object and store it in our text field. + if self.db_type(connection) == "text": + return json.dumps(BaserowFormulaMinified(m=mode, v=version, f=formula)) + # v2.1: the column type is `json`, so we can store a dict. + return BaserowFormulaMinified(m=mode, v=version, f=formula) + + # In v1.x the frontend will keep sending a formula , + # string so we need to convert it to the new format. + return json.dumps( + BaserowFormulaMinified( + f=str(value), + m=BASEROW_FORMULA_MODE_SIMPLE, + v=BASEROW_FORMULA_VERSION_INITIAL, + ) + ) + + +class JSONFormulaField(models.JSONField): + def __init__(self, *args, **kwargs): + self.properties = kwargs.pop("properties", []) + super().__init__(*args, **kwargs) + + def deconstruct(self): + """ + Deconstruct as a regular JSONField to avoid migration detection, this + class should be a drop-in replacement for JSONField. + """ + + name, path, args, kwargs = super().deconstruct() + path = "django.db.models.JSONField" + return name, path, args, kwargs + + def contribute_to_class(self, cls, name, **kwargs): + """ + Due to a limitation of Django's ORM after saving, it keeps the original value + in memory without re-processing it through `to_python`. We need to override the + save method to ensure the value is transformed correctly after each save. + """ + + super().contribute_to_class(cls, name, **kwargs) + + # Store references for closure + field_name = name + field_instance = self + original_save = cls.save + + def save_with_to_python(instance, *args, **kwargs): + # Perform the original save operation + result = original_save(instance, *args, **kwargs) + # Get the intended formula field value... + value = getattr(instance, field_name, None) + # Process it with `to_python` to ensure it's in the correct format. + setattr(instance, field_name, field_instance.to_python(value)) + return result + + cls.save = save_with_to_python + + def _transform_db_property( + self, + value: Union[str, BaserowFormulaMinified, BaserowFormulaObject], + ) -> BaserowFormulaObject: + """ + Responsible for taking a `value` from our database, which will be a string or + `BaserowFormulaMinified`, and transforming it into a `BaserowFormulaObject`. + + - We will receive a formula string if we've got a legacy `JSONField` which + hasn't yet been migrated to an object. + - We will receive a `BaserowFormulaMinified` if we've got a migrated object + which we've persisted to the database. + - We will receive a `BaserowFormulaObject` if we're being called via + `to_python`. + + :param value: The value from the database, either a string or dictionary. + :return: A `BaserowFormulaObject`. + """ + + if isinstance(value, str): + return BaserowFormulaObject( + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + formula=value, + ) + return BaserowFormulaObject( + mode=value.get("m", value.get("mode")), + version=value.get("v", value.get("version")), + formula=value.get("f", value.get("formula")), + ) + + def _transform_db_properties( + self, value: JSONFormulaFieldDatabaseValue + ) -> JSONFormulaFieldResult: + """ + Responsible for taking a `value` from our database, which could be a + string, dictionary, or list of dictionaries, and transforming it into a + `JSONFormulaFieldResult` (either a `BaserowFormulaObject` or list + of dictionaries containing `BaserowFormulaObject`s) at their designated paths. + + :param value: The value from the database. + :return: A `JSONFormulaFieldResult`. + """ + + # Iterate over the properties bound to this field. + # Each property represents a path to a formula field + # we need to convert from minified to full. + for path in self.properties: + # A path can be nested, e.g. "parent.child", + # or just a single level, e.g. "parent". + parent_path, child_path = ( + path.split(".", 1) if "." in path else (path, None) + ) + # If `value` is a dictionary, we'll extract the nested value from it. + # However, if it's a list, then the `value` itself is our property value. + property_value = ( + value.get(parent_path) if isinstance(value, dict) else value + ) + + # Sometimes in tests we don't set all formula properties correctly. + if property_value is None: + continue + + # If we have a list of values to work with... + if isinstance(property_value, list): + # Iterate over each item in this list, transforming the + # relevant property to a `BaserowFormulaObject`. + object_list_value = [] + for item in property_value: + # If there's no `child_path` (i.e. it's just "parent"), + # then we transform the `item[parent_path]` value. E.g. + # [{parent: "formula"}] > [{parent: BaserowFormulaObject}] + if child_path is None: + object_value = self._transform_db_property(item[parent_path]) + object_list_value.append(item | {parent_path: object_value}) + else: + # However if we have a `child_path` (i.e. it's "parent.child"), + # then we transform the `item[child_path]` value. E.g. + # [{parent: {child: "'formula'"}}] > + # [{parent: {child: BaserowFormulaObject}}] + object_value = self._transform_db_property(item[child_path]) + # Rebuild the item with the transformed value, making sure + # we preserve any other keys in the item. + object_list_value.append(item | {child_path: object_value}) + + # If we have a `child_path`, then we set the transformed list + # on `value[parent_path]`. Otherwise, we set the entire `value + # to be the transformed list. + if child_path: + value[parent_path] = object_list_value + else: + value = object_list_value + else: + # Otherwise, we have a dictionary, so we transform the + # `property_value` directly. + object_value = self._transform_db_property(property_value) + value[path] = object_value + + return value + + def to_python(self, value: JSONFormulaFieldDatabaseValue) -> JSONFormulaFieldResult: + """ + Called during create/update and deserialization. We will call + `_transform_db_properties` to ensure we always return a + `JSONFormulaFieldResult`. + + :param value: The value from the database, either a string or dictionary. + :return: A `JSONFormulaFieldResult` nested inside a dictionary. + """ + + value = super().to_python(value) + return self._transform_db_properties(value) + + def from_db_value( + self, value: JSONFormulaFieldDatabaseValue, *args + ) -> JSONFormulaFieldResult: + """ + Called when reading from the database. We will call + `_transform_db_properties` to ensure we always return a + `JSONFormulaFieldResult` nested inside a dictionary. + + :param value: The value from the database, either a string or dictionary. + :return: A `JSONFormulaFieldResult` nested inside a dictionary. + """ + + value = super().from_db_value(value, *args) + return self._transform_db_properties(value) + + def _transform_python_property( + self, value: Union[str, BaserowFormulaObject] + ) -> BaserowFormulaMinified: + """ + Responsible for taking a `value`, which could be a string + or dictionary, and transforming it into a `BaserowFormulaMinified` + for `get_prep_value` to persist in our database. + + :param value: The value from the database, either a string or dictionary. + :return: A `BaserowFormulaMinified`. + """ + + if isinstance(value, str): + return BaserowFormulaMinified( + m=BASEROW_FORMULA_MODE_SIMPLE, + v=BASEROW_FORMULA_VERSION_INITIAL, + f=value, + ) + return BaserowFormulaMinified( + m=value.get("mode", BASEROW_FORMULA_MODE_SIMPLE), + v=value.get("version", BASEROW_FORMULA_VERSION_INITIAL), + f=value.get("formula", ""), + ) + + def get_prep_value( + self, value: Union[BaserowFormulaObject, List[Dict[str, BaserowFormulaObject]]] + ) -> JSONFormulaFieldDatabaseValue: + """ + Responsible for converting a Python value to database value. Our Python + value could be a dictionary (a `BaserowFormulaObject`), or a list + of dictionaries (a list of `BaserowFormulaObject`s). We need to convert + both of these into a `BaserowFormulaMinified` object, or a list of + `BaserowFormulaMinified` objects at their designated paths. + + :param value: The value to convert, either a string or `BaserowFormulaObject`. + :return: A `JSONFormulaFieldDatabaseValue`. + """ + + # If we receive an empty dict or string, just return early. + # We'll be back in a moment with a value (e.g. creating a + # record with no json value, then updating it). + if not value: + return value + + # We only process dictionaries and lists. + # If we receive anything else (e.g. via a receiver such as + # `page_deleted_update_link_collection_fields`), then return its value. + if not isinstance(value, (dict, list)): + return value + + # Iterate over the properties bound to this field. + # Each property represents a path to a formula field + # we need to convert from full to minified. + for path in self.properties: + # A path can be nested, e.g. "parent.child", + # or just a single level, e.g. "parent". + parent_path, child_path = ( + path.split(".", 1) if "." in path else (path, None) + ) + # If `value` is a dictionary, we'll extract the nested value from it. + # However, if it's a list, then the `value` itself is our property value. + property_value: Union[BaserowFormulaObject, List] = ( + value.get(parent_path) if isinstance(value, dict) else value + ) + + # Sometimes in tests we don't set all formula properties correctly. + if property_value is None: + continue + + # If we have a list of values to work with... + if isinstance(property_value, list): + # Iterate over each item in this list, transforming the + # relevant property to a `BaserowFormulaMinified`. + minified_list_value = [] + for item in property_value: + # If there's no `child_path` (i.e. it's just "parent"), + # then we transform the `item[parent_path]` value. E.g. + # [{parent: BaserowFormulaObject}] -> + # [{parent: BaserowFormulaMinified}] + if child_path is None: + minified_value = self._transform_python_property( + item[parent_path] + ) + minified_list_value.append(item | {parent_path: minified_value}) + else: + # However if we have a `child_path` (i.e. it's "parent.child"), + # then we transform the `item[child_path]` value. E.g. + # [{parent: {child: BaserowFormulaObject}}] -> + # [{parent: {child: BaserowFormulaMinified}}] + minified_value = self._transform_python_property( + item[child_path] + ) + minified_list_value.append(item | {child_path: minified_value}) + + # If we have a `child_path`, then we set the transformed list + # on `value[parent_path]`. Otherwise, we set the entire `value + # to be the transformed list. + if child_path: + value[parent_path] = minified_list_value + else: + value = minified_list_value + else: + # Otherwise, we have a dictionary, so we transform the + # `property_value` directly. + minified_value = self._transform_python_property(property_value) + value[path] = minified_value + + return value diff --git a/backend/src/baserow/core/formula/serializers.py b/backend/src/baserow/core/formula/serializers.py index 4d7c0345e6..4695fc207c 100644 --- a/backend/src/baserow/core/formula/serializers.py +++ b/backend/src/baserow/core/formula/serializers.py @@ -1,26 +1,119 @@ +from typing import Dict, List, Type, Union + from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import ValidationError +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL from baserow.core.formula.parser.exceptions import BaserowFormulaSyntaxError from baserow.core.formula.parser.parser import get_parse_tree_for_formula +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_ADVANCED, + BASEROW_FORMULA_MODE_RAW, + BASEROW_FORMULA_MODE_SIMPLE, + BaserowFormulaObject, +) +from baserow.core.registry import Registry -@extend_schema_field(OpenApiTypes.STR) -class FormulaSerializerField(serializers.CharField): +def collect_json_formula_field_properties(registry: Type[Registry]) -> List[str]: + """ + Returns a list of all the properties in the serializers of the given registry that + are of type FormulaSerializerField. This is used by the `JSONFormulaField` to + know which properties to parse for formulas when writing or reading to/from the + database. + + :param registry: The registry to get the serializers from. + :return: A list of property names. If a property is nested, it will be in the + format "property.child", otherwise just "property". + """ + + properties: List[str] = [] + for instance in registry.get_all(): + serializer = instance.get_serializer_class() + for field_name, field in serializer().get_fields().items(): + child = getattr(field, "child", None) + if isinstance(field, FormulaSerializerField): + properties.append(field_name) + elif child is not None: + for child_name, child_field in child.get_fields().items(): + if isinstance(child_field, FormulaSerializerField): + properties.append(f"{field_name}.{child_name}") + + return list(set(properties)) + + +class BaserowFormulaObjectSerializer(serializers.Serializer): + formula = serializers.CharField(required=True, allow_blank=True) + version = serializers.CharField( + required=False, default=BASEROW_FORMULA_VERSION_INITIAL + ) + mode = serializers.ChoiceField( + required=False, + default=BASEROW_FORMULA_MODE_SIMPLE, + choices=[ + BASEROW_FORMULA_MODE_SIMPLE, + BASEROW_FORMULA_MODE_ADVANCED, + BASEROW_FORMULA_MODE_RAW, + ], + ) + + +@extend_schema_field(OpenApiTypes.OBJECT) +class FormulaSerializerField(serializers.JSONField): """ This field can be used to store a formula in the database. """ - def to_internal_value(self, data): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.required = False + self.default = BaserowFormulaObject( + formula="", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ) + + def to_internal_value(self, data: Union[str, Dict[str, str]]): data = super().to_internal_value(data) + # The formula serializer does not require a value, but if this + # value is a blank string or object, we need to construct the + # default value. if not data: + data = self.default + + # For compatibility reasons: we have a value, but if it's not + # a dict, we expect it to be a string. For example: if we receive + # a `row_id` formula of 5, an integer, we need to convert it to + # a string. + if not isinstance(data, dict): + data = str(data) + else: + # It's a dictionary, so validate its structure. + bfo_serializer = BaserowFormulaObjectSerializer(data=data) + bfo_serializer.is_valid(raise_exception=True) + data = bfo_serializer.validated_data + + # For compatibility reasons: if we receive a string, we will + # construct a BaserowFormulaObject with it, and assume the + # mode is 'simple', and the version is the initial version. + # TODO: we should infer the `mode` differently, once we know + # what an advanced/raw formula looks like. Or: just force the + # user to tell us? + if isinstance(data, str): + data = BaserowFormulaObject( + formula=data, + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ) + + if not data["formula"]: return data try: - get_parse_tree_for_formula(data) + get_parse_tree_for_formula(data["formula"]) return data except BaserowFormulaSyntaxError as e: raise ValidationError(f"The formula is invalid: {e}", code="invalid") diff --git a/backend/src/baserow/core/formula/types.py b/backend/src/baserow/core/formula/types.py index cf9989adfe..205664a767 100644 --- a/backend/src/baserow/core/formula/types.py +++ b/backend/src/baserow/core/formula/types.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, List +from typing import Any, Dict, List, Literal, TypedDict, Union from baserow.core.formula.exceptions import RuntimeFormulaRecursion @@ -69,3 +69,32 @@ def parse_args(self, args: FormulaArgs) -> FormulaArgs: @abstractmethod def execute(self, context: FormulaContext, args: FormulaArgs) -> Any: """Executes the function""" + + +BASEROW_FORMULA_MODE_SIMPLE: Literal["simple"] = "simple" +BASEROW_FORMULA_MODE_ADVANCED: Literal["advanced"] = "advanced" +BASEROW_FORMULA_MODE_RAW: Literal["raw"] = "raw" +BaserowFormulaMode = Literal["simple", "advanced", "raw"] + + +class BaserowFormulaObject(TypedDict): + version: str + mode: BaserowFormulaMode + formula: BaserowFormula + + +class BaserowFormulaMinified(TypedDict): + v: str + m: BaserowFormulaMode + f: BaserowFormula + + +FormulaFieldDatabaseValue = Union[str, BaserowFormulaMinified] + +JSONFormulaFieldDatabaseValue = Union[ + BaserowFormulaMinified, List[Dict[str, BaserowFormulaMinified]] +] + +JSONFormulaFieldResult = Union[ + BaserowFormulaObject, List[Dict[str, BaserowFormulaObject]] +] diff --git a/backend/src/baserow/core/registry.py b/backend/src/baserow/core/registry.py index 92a60ccb95..4f8c5eda8e 100644 --- a/backend/src/baserow/core/registry.py +++ b/backend/src/baserow/core/registry.py @@ -37,6 +37,7 @@ from baserow.core.storage import ExportZipFile from .exceptions import InstanceTypeAlreadyRegistered, InstanceTypeDoesNotExist +from .formula import BaserowFormulaObject if typing.TYPE_CHECKING: from django.contrib.contenttypes.models import ContentType @@ -1031,8 +1032,8 @@ def formula_generator( """ for formula_field in self.simple_formula_fields: - formula = getattr(instance, formula_field) - new_formula = yield formula + formula: Union[str, BaserowFormulaObject] = getattr(instance, formula_field) + new_formula = yield formula if isinstance(formula, str) else formula if new_formula is not None: setattr(instance, formula_field, new_formula) yield instance diff --git a/backend/src/baserow/core/search/__init__.py b/backend/src/baserow/core/search/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/core/search/constants.py b/backend/src/baserow/core/search/constants.py new file mode 100644 index 0000000000..71486ec336 --- /dev/null +++ b/backend/src/baserow/core/search/constants.py @@ -0,0 +1,10 @@ +STANDARD_UNION_FIELDS = [ + "search_type", + "object_id", + "sort_key", + "rank", + "priority", + "title", + "subtitle", + "payload", +] diff --git a/backend/src/baserow/core/search/data_types.py b/backend/src/baserow/core/search/data_types.py new file mode 100644 index 0000000000..fcff9f4f82 --- /dev/null +++ b/backend/src/baserow/core/search/data_types.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import Any, Dict, Optional + +OPTIONAL_FIELDS = [ + "subtitle", + "description", + "metadata", + "created_on", + "updated_on", +] + + +@dataclass +class SearchResult: + """ + Represents a single search result item from workspace search. + """ + + type: str + id: int + title: str + subtitle: Optional[str] = None + description: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + created_on: Optional[str] = None + updated_on: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API response.""" + + result = { + "type": self.type, + "id": self.id, + "title": self.title, + } + + for field in OPTIONAL_FIELDS: + value = getattr(self, field) + if value is not None: + result[field] = value + + return result + + +@dataclass +class SearchContext: + """ + Context information for search operations. + """ + + query: str + limit: int = 20 + offset: int = 0 diff --git a/backend/src/baserow/core/search/handler.py b/backend/src/baserow/core/search/handler.py new file mode 100644 index 0000000000..7c5b7b3038 --- /dev/null +++ b/backend/src/baserow/core/search/handler.py @@ -0,0 +1,147 @@ +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple + +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.db.models import QuerySet + +from loguru import logger +from typing_extensions import List + +from baserow.api.search.constants import DEFAULT_SEARCH_LIMIT +from baserow.core.search.data_types import SearchContext, SearchResult +from baserow.core.search.registries import workspace_search_registry + +if TYPE_CHECKING: + from baserow.core.models import Workspace + + +class WorkspaceSearchHandler: + """ + Handler for workspace search operations across all registered search types. + """ + + def search_all_types( + self, user: "AbstractUser", workspace: "Workspace", context: SearchContext + ) -> Tuple[List[SearchResult], bool]: + """ + Build a single UNION ALL over all registered types standardized value + querysets, apply global ordering and pagination, then postprocess the + paginated slice per type. + """ + + # Build union of standardized values querysets + type_items = [ + (name, workspace_search_registry.get(name)) + for name in workspace_search_registry.registry.keys() + ] + # No need to sort before union; priority is part of rows and used in ordering. + + union_qs: Optional[QuerySet] = None + for _name, st in type_items: + try: + qs = st.get_union_values_queryset(user, workspace, context) + except Exception: + logger.exception( + "Workspace search failed building queryset for type {type}", + type=st.type, + ) + continue + if union_qs is None: + union_qs = qs + else: + union_qs = union_qs.union(qs, all=True) + + if union_qs is None: + return [], False + + # Global ordering: priority asc first (type-level ordering), + # then rank desc within each type, then object_id asc for determinism. + union_qs = union_qs.order_by( + "priority", + models.F("rank").desc(nulls_last=True), + "sort_key", + ) + + # Global pagination (+1 for has_more handled by caller/handler) + start = context.offset + end = start + context.limit + page_rows: List[Dict] = list(union_qs[start:end]) + has_more = len(page_rows) == (end - start) + + # Group by search_type and postprocess + rows_by_type: Dict[str, List[Dict]] = {} + for result in page_rows: + rows_by_type.setdefault(result["search_type"], []).append(result) + + results_by_type: Dict[str, List[SearchResult]] = {} + for type_name, results in rows_by_type.items(): + st = workspace_search_registry.get(type_name) + try: + results_by_type[type_name] = st.postprocess(results) + except Exception: + logger.exception( + "Workspace search postprocess failed for type {type}", + type=type_name, + ) + results_by_type[type_name] = [] + + # Index results by id per type to ensure stable ordering matching page_rows + results_by_type_and_id: Dict[str, Dict[Any, SearchResult]] = {} + for type_name, results in results_by_type.items(): + by_id: Dict[Any, SearchResult] = {} + for result in results: + by_id[str(result.id)] = result + results_by_type_and_id[type_name] = by_id + + # Merge back in the original order + flat_results: List[SearchResult] = [] + for result in page_rows: + type_name = result["search_type"] + by_id = results_by_type_and_id.get(type_name, {}) + result = by_id.pop(result["object_id"], None) + if result is not None: + flat_results.append(result) + + return flat_results, has_more + + def search_workspace( + self, + user: "AbstractUser", + workspace: "Workspace", + query: str, + limit: int = DEFAULT_SEARCH_LIMIT, + offset: int = 0, + ) -> Dict[str, Any]: + """ + Execute workspace search within a workspace. + + param user: The user performing the search + param workspace: The workspace to search within + param query: Search query string + param limit: Maximum number of results to return + param offset: Result offset for pagination + + return Dict with a flat, priority-ordered list of search results + and has_more flag + """ + + # Use limit+1 to detect if there are more results overall + search_limit = limit + 1 + + context = SearchContext( + query=query, + limit=search_limit, + offset=offset, + ) + + raw_results, has_more = self.search_all_types(user, workspace, context) + + results_list = [result.to_dict() for result in raw_results] + + if len(results_list) > limit: + results_list = results_list[:limit] + + return { + "results": results_list, + "has_more": has_more, + } diff --git a/backend/src/baserow/core/search/model_search_base.py b/backend/src/baserow/core/search/model_search_base.py new file mode 100644 index 0000000000..6b0eaeae3c --- /dev/null +++ b/backend/src/baserow/core/search/model_search_base.py @@ -0,0 +1,85 @@ +from typing import TYPE_CHECKING, List + +from django.contrib.auth.models import AbstractUser +from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector +from django.db import models +from django.db.models import CharField, F, Q, TextField, Value +from django.db.models.functions import Cast, JSONObject + +from baserow.core.search.data_types import SearchContext +from baserow.core.search.registries import SearchableItemType + +if TYPE_CHECKING: + from baserow.core.models import Workspace + + +class ModelSearchableItemType(SearchableItemType): + """ + Generic model-backed searchable type with shared ORM search logic and hooks. + Lives in core to avoid core -> contrib imports. + """ + + search_fields: List[str] = [] + supports_full_text: bool = False + + def build_search_query(self, query: str) -> Q: + if not self.search_fields: + return Q() + conditions = Q() + for field in self.search_fields: + conditions |= Q(**{f"{field}__icontains": query}) + return conditions + + def get_base_queryset( + self, user: "AbstractUser", workspace: "Workspace" + ) -> models.QuerySet: + return self.model_class.objects.none() + + def get_search_queryset( + self, user: "AbstractUser", workspace: "Workspace", context: SearchContext + ) -> models.QuerySet: + qs = self.get_base_queryset(user, workspace) + search_q = self.build_search_query(context.query) + if search_q: + qs = qs.filter(search_q) + return qs.annotate(search_type=Value(self.type, output_field=CharField())) + + def build_payload(self): + return JSONObject() + + def build_title_annotation(self): + return Cast(F("name"), output_field=TextField()) + + def build_subtitle_annotation(self): + return Value(self.name, output_field=TextField()) + + def get_union_values_queryset( + self, user: "AbstractUser", workspace: "Workspace", context: SearchContext + ) -> models.QuerySet: + qs = self.get_search_queryset(user, workspace, context) + + search_query = SearchQuery( + context.query, search_type="websearch", config="english" + ) + search_vector = SearchVector("name", config="english") + + qs = qs.annotate( + search_type=Value(self.type, output_field=TextField()), + object_id=Cast(F("id"), output_field=TextField()), + sort_key=F("id"), + rank=SearchRank(search_vector, search_query), + priority=Value(self.priority), + title=self.build_title_annotation(), + subtitle=self.build_subtitle_annotation(), + payload=self.build_payload(), + ) + return qs.values( + "search_type", + "object_id", + "sort_key", + "rank", + "priority", + "title", + "subtitle", + "payload", + ) diff --git a/backend/src/baserow/core/search/registries.py b/backend/src/baserow/core/search/registries.py new file mode 100644 index 0000000000..073c277fb6 --- /dev/null +++ b/backend/src/baserow/core/search/registries.py @@ -0,0 +1,150 @@ +from typing import TYPE_CHECKING, Dict, Iterable, List, Optional + +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.db.models import QuerySet + +from baserow.core.registry import Instance, ModelInstanceMixin, Registry +from baserow.core.search.data_types import SearchContext, SearchResult + +if TYPE_CHECKING: + from baserow.core.models import Workspace + + +class SearchableItemType(ModelInstanceMixin, Instance): + """ + Base class for all searchable item types in workspace search. + + Each searchable item type represents a different type of content + that can be searched (tables, applications, rows, etc.). + """ + + type: str = None + name: str = None + model_class = None + priority: int = 10 + + def __init__(self): + super().__init__() + if not self.name: + raise ValueError(f"SearchableItemType {self.type} must define a name") + + def get_base_queryset( + self, user: "AbstractUser", workspace: "Workspace" + ) -> models.QuerySet: + """ + Get the base queryset for searching this item type in a workspace. + + param user: The user requesting the search (for permission filtering) + param workspace: The workspace to search in + return models.QuerySet: Base queryset for this item type + """ + + pass + + def get_search_queryset( + self, + user: "AbstractUser", + workspace: "Workspace", + context: SearchContext, + ) -> models.QuerySet: + """ + Build search queryset without executing it for optimal query combining. + + param user: The user requesting search + param workspace: The workspace being searched + param context: Search context with query, limit, offset + return models.QuerySet: Prepared queryset ready for execution + """ + + pass + + def get_union_values_queryset( + self, + user: "AbstractUser", + workspace: "Workspace", + context: SearchContext, + ) -> QuerySet: + """ + Return a queryset of dict rows with the standardized fields defined in + STANDARD_UNION_FIELDS. Implementations must ensure the queryset only + returns rows the user has permission to see and filters by the query. + No limit/offset should be applied here. + """ + + raise NotImplementedError("This method must be implemented by the subclass") + + def execute_search( + self, user: "AbstractUser", workspace: "Workspace", context: SearchContext + ) -> List[SearchResult]: + """ + Execute search with user and workspace objects. + + param user: The user requesting search + param workspace: The workspace being searched + param context: Search context with query, limit, offset + return List[SearchResult]: List of search results + """ + + queryset = self.get_search_queryset(user, workspace, context) + + start = context.offset + end = start + context.limit + items = queryset[start:end] + + results = [] + for item in items: + result = self.serialize_result(item, user, workspace) + if result: + results.append(result) + + return results + + def postprocess(self, rows: Iterable[Dict]) -> List[SearchResult]: + """ + Convert standardized union rows belonging to this type into SearchResult + objects. Implementations can override to bulk-fetch extra data or compute + additional fields (like highlights). Default maps directly. + """ + + results: List[SearchResult] = [] + for row in rows: + payload = row.get("payload") or {} + results.append( + SearchResult( + type=row["search_type"], + id=row["object_id"], + title=row.get("title") or str(row.get("object_id")), + subtitle=row.get("subtitle"), + description=payload.get("description"), + created_on=payload.get("created_on"), + updated_on=payload.get("updated_on"), + metadata=payload, + ) + ) + return results + + def serialize_result( + self, item: models.Model, user: "AbstractUser", workspace: "Workspace" + ) -> Optional[SearchResult]: + """ + Convert a model instance to a SearchResult. + + param item: The model instance to serialize + param user: The user requesting the search + param workspace: The workspace context + return Optional[SearchResult]: Serialized search result, or None to exclude + """ + + pass + + +class WorkspaceSearchRegistry(Registry): + """ + Registry for all searchable item types in workspace search. + """ + + name = "workspace_search" + + +workspace_search_registry = WorkspaceSearchRegistry() diff --git a/backend/src/baserow/core/search/search_types.py b/backend/src/baserow/core/search/search_types.py new file mode 100644 index 0000000000..d55ad3a1f2 --- /dev/null +++ b/backend/src/baserow/core/search/search_types.py @@ -0,0 +1,99 @@ +from typing import Optional + +from django.contrib.auth.models import AbstractUser +from django.db.models import CharField, QuerySet, Value + +from baserow.core.handler import CoreHandler +from baserow.core.models import Application, Workspace +from baserow.core.operations import ReadApplicationOperationType +from baserow.core.search.data_types import SearchContext, SearchResult +from baserow.core.search.model_search_base import ModelSearchableItemType + + +class ApplicationSearchType(ModelSearchableItemType): + """ + Searchable item type for applications (databases, builders, etc.). + """ + + type = "application" + name = "Applications" + model_class = Application + search_fields = ["name"] + + def get_base_queryset( + self, user: "AbstractUser", workspace: "Workspace" + ) -> QuerySet: + """Get applications in the workspace. + + param user: The user requesting the search + param workspace: The workspace being searched + + return QuerySet: Queryset of applications in the workspace + """ + + return ( + self.model_class.objects.filter( + workspace=workspace, workspace__trashed=False + ) + .select_related("workspace") + .order_by("order", "id") + ) + + def get_search_queryset( + self, + user: "AbstractUser", + workspace: "Workspace", + context: SearchContext, + ) -> QuerySet: + """Build search queryset for applications. + + param user: The user requesting the search + param workspace: The workspace being searched + param context: The search context + + return QuerySet: Queryset of applications in the workspace + """ + + queryset = self.get_base_queryset(user, workspace) + + # Apply permission filtering using the existing system, scoping to workspace. + # This ensures we only return applications the user can read, in a single + # queryset. + queryset = CoreHandler().filter_queryset( + user, + ReadApplicationOperationType.type, + queryset, + workspace=workspace, + ) + search_q = self.build_search_query(context.query) + if search_q: + queryset = queryset.filter(search_q) + queryset = queryset.annotate( + search_type=Value(self.type, output_field=CharField()) + ) + return queryset + + def serialize_result( + self, result: Application, user: "AbstractUser", workspace: "Workspace" + ) -> Optional[SearchResult]: + """Convert application to search result. + + param item: The application to serialize + param user: The user requesting the search + param workspace: The workspace being searched + + return Optional[SearchResult]: Serialized search result + """ + + return SearchResult( + type=self.type, + id=result.id, + title=result.name, + subtitle=self.name, + created_on=result.created_on, + updated_on=result.updated_on, + metadata={ + "workspace_id": workspace.id, + "workspace_name": workspace.name, + }, + ) diff --git a/backend/src/baserow/core/services/models.py b/backend/src/baserow/core/services/models.py index 65df279998..ad36cc3f50 100644 --- a/backend/src/baserow/core/services/models.py +++ b/backend/src/baserow/core/services/models.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models +from baserow.core.formula.field import FormulaField from baserow.core.integrations.models import Integration from baserow.core.mixins import ( HierarchicalModelMixin, @@ -67,11 +68,8 @@ class SearchableServiceMixin(models.Model): and to add a `search_query` field to it. """ - search_query = models.TextField( - default="", - max_length=225, + search_query = FormulaField( help_text="The query to apply to the service to narrow the results down.", - blank=True, ) class Meta: diff --git a/backend/src/baserow/core/services/registries.py b/backend/src/baserow/core/services/registries.py index 364e75af64..bccf4ac661 100644 --- a/backend/src/baserow/core/services/registries.py +++ b/backend/src/baserow/core/services/registries.py @@ -285,11 +285,11 @@ def resolve_service_formulas( """ resolved_values = {} - for key, formula, ensurer, label in self.formulas_to_resolve(service): + for key, formula_ctx, ensurer, label in self.formulas_to_resolve(service): try: resolved_values[key] = ensurer( resolve_formula( - formula, + formula_ctx, formula_runtime_function_registry, dispatch_context.clone(), ) diff --git a/backend/src/baserow/core/services/types.py b/backend/src/baserow/core/services/types.py index 1ee53103b6..942343996c 100644 --- a/backend/src/baserow/core/services/types.py +++ b/backend/src/baserow/core/services/types.py @@ -2,7 +2,7 @@ from typing import NamedTuple, NewType, Optional, TypedDict, TypeVar from baserow.core.formula.runtime_formula_context import RuntimeFormulaContext -from baserow.core.formula.types import BaserowFormula +from baserow.core.formula.types import BaserowFormulaObject from baserow.core.services.models import Service @@ -43,7 +43,7 @@ class UpdatedService: class FormulaToResolve(NamedTuple): key: str - formula: BaserowFormula + formula: BaserowFormulaObject ensurer: callable label: str diff --git a/backend/src/baserow/test_utils/helpers.py b/backend/src/baserow/test_utils/helpers.py index d837f1d071..4234d6f6e5 100644 --- a/backend/src/baserow/test_utils/helpers.py +++ b/backend/src/baserow/test_utils/helpers.py @@ -1,12 +1,13 @@ import json import os import uuid -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from datetime import timedelta, timezone from decimal import Decimal from ipaddress import ip_network from socket import AF_INET, AF_INET6, IPPROTO_TCP, SOCK_STREAM from typing import Any, Dict, Generator, List, Optional, Type, Union +from unittest.mock import patch from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser @@ -479,6 +480,32 @@ def setup_interesting_test_database( return database +@contextmanager +def defer_signals(dotted_names: List[str]): + """Temporarily no-op a list of callable paths during test setup. + + Pass fully qualified dotted names of callables (e.g. Celery task .delay + methods or websocket broadcasters). While inside the context these + callables are patched to no-op to avoid a storm of side-effects while + bulk-creating data. After exiting, perform any needed one-shot processing + (e.g. initialize/process search data once per table). + + Example: + with defer_signals([ + "baserow.ws.tasks.broadcast_to_channel_group.delay", + "baserow.contrib.database.search.tasks.schedule_update_search_data.delay", + "baserow.contrib.database.search.tasks.update_search_data.delay", + ]): + # create lots of data without triggering tasks/broadcasts + ... + """ + + with ExitStack() as stack: + for name in dotted_names: + stack.enter_context(patch(name, lambda *args, **kwargs: None)) + yield + + @contextmanager def register_instance_temporarily(registry, instance): """ diff --git a/backend/tests/baserow/api/search/test_workspace_search_views.py b/backend/tests/baserow/api/search/test_workspace_search_views.py new file mode 100644 index 0000000000..bc718beb3d --- /dev/null +++ b/backend/tests/baserow/api/search/test_workspace_search_views.py @@ -0,0 +1,574 @@ +from django.shortcuts import reverse + +import pytest +from rest_framework.status import ( + HTTP_200_OK, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_404_NOT_FOUND, +) + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_requires_authentication(api_client, data_fixture): + workspace = data_fixture.create_workspace() + + url = reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}) + response = api_client.get(url, {"query": "test"}) + + assert response.status_code == HTTP_401_UNAUTHORIZED + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_requires_workspace_membership(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace() # User is not a member + + url = reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}) + response = api_client.get(url, {"query": "test"}, HTTP_AUTHORIZATION=f"JWT {token}") + + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP" + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_workspace_not_found(api_client, data_fixture): + _, token = data_fixture.create_user_and_token() + + url = reverse("api:search:workspace_search", kwargs={"workspace_id": 99999}) + response = api_client.get(url, {"query": "test"}, HTTP_AUTHORIZATION=f"JWT {token}") + + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_missing_query_parameter(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + response = api_client.get(url, HTTP_AUTHORIZATION=f"JWT {token}") + + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_QUERY_PARAMETER_VALIDATION" + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_empty_query_parameter(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + response = api_client.get(url, {"query": ""}, HTTP_AUTHORIZATION=f"JWT {token}") + + assert response.status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_basic_success(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + database = data_fixture.create_database_application( + workspace=user_workspace.workspace, name="Test Database" + ) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + response = api_client.get(url, {"query": "Test"}, HTTP_AUTHORIZATION=f"JWT {token}") + + assert response.status_code == HTTP_200_OK + response_json = response.json() + + assert "results" in response_json + assert "has_more" in response_json + + results = response_json["results"] + assert len(results) == 1 + assert results[0]["id"] == database.id + assert results[0]["title"] == "Test Database" + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_with_limit_parameter(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + for i in range(5): + data_fixture.create_database_application( + workspace=user_workspace.workspace, name=f"Database {i}" + ) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + response = api_client.get( + url, {"query": "Database", "limit": 3}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + + assert response.status_code == HTTP_200_OK + response_json = response.json() + + assert len(response_json["results"]) <= 3 + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_with_offset_parameter(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + databases = [] + for i in range(5): + databases.append( + data_fixture.create_database_application( + workspace=user_workspace.workspace, name=f"Search DB {i:02d}" + ) + ) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + + response1 = api_client.get( + url, + {"query": "Search DB", "limit": 2, "offset": 0}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response2 = api_client.get( + url, + {"query": "Search DB", "limit": 2, "offset": 2}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response1.status_code == HTTP_200_OK + assert response2.status_code == HTTP_200_OK + + results1 = response1.json()["results"] + results2 = response2.json()["results"] + + assert len(results1) == 2 + assert len(results2) == 2 + + assert results1[0]["id"] == databases[0].id + assert results1[1]["id"] == databases[1].id + assert results2[0]["id"] == databases[2].id + assert results2[1]["id"] == databases[3].id + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_invalid_limit_parameter(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + + response = api_client.get( + url, {"query": "test", "limit": -1}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert response.status_code == HTTP_400_BAD_REQUEST + + response = api_client.get( + url, {"query": "test", "limit": 1000}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert response.status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_invalid_offset_parameter(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + + response = api_client.get( + url, {"query": "test", "offset": -1}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert response.status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_case_insensitive(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + database = data_fixture.create_database_application( + workspace=user_workspace.workspace, name="CamelCase Database" + ) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + + for query in ["camelcase", "CAMELCASE", "CamelCase", "camelCASE"]: + response = api_client.get( + url, {"query": query}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + + assert response.status_code == HTTP_200_OK + response_json = response.json() + + database_results = response_json["results"] + assert len(database_results) == 1 + assert database_results[0]["id"] == database.id + assert database_results[0]["title"] == database.name + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_partial_match(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + database = data_fixture.create_database_application( + workspace=user_workspace.workspace, name="Very Long Database Name" + ) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + + for query in ["Very", "Long", "Database", "Name", "Very Long", "Database Name"]: + response = api_client.get( + url, {"query": query}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + + assert response.status_code == HTTP_200_OK + response_json = response.json() + + database_results = response_json["results"] + assert len(database_results) == 1 + assert database_results[0]["id"] == database.id + assert database_results[0]["title"] == database.name + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_no_results(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + + response = api_client.get( + url, {"query": "nonexistent search term"}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + + assert response.status_code == HTTP_200_OK + response_json = response.json() + + assert len(response_json["results"]) == 0 + assert response_json["has_more"] is False + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_scoped_to_requested_workspace(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + + ws1 = data_fixture.create_workspace(name="WS 1") + ws2 = data_fixture.create_workspace(name="WS 2") + data_fixture.create_user_workspace(user=user, workspace=ws1) + data_fixture.create_user_workspace(user=user, workspace=ws2) + + db1 = data_fixture.create_database_application(workspace=ws1, name="Common DB") + db2 = data_fixture.create_database_application(workspace=ws2, name="Common DB") + + table1 = data_fixture.create_database_table(database=db1, name="Common Table") + table2 = data_fixture.create_database_table(database=db2, name="Common Table") + + text_field1 = data_fixture.create_text_field(table=table1, name="Text") + text_field2 = data_fixture.create_text_field(table=table2, name="Text") + + from baserow.contrib.database.rows.handler import RowHandler + from baserow.contrib.database.search.handler import SearchHandler + + RowHandler().create_rows( + user=user, table=table1, rows_values=[{f"field_{text_field1.id}": "needle"}] + ) + RowHandler().create_rows( + user=user, table=table2, rows_values=[{f"field_{text_field2.id}": "needle"}] + ) + + SearchHandler.create_workspace_search_table_if_not_exists(ws1.id) + SearchHandler.initialize_missing_search_data(table1) + SearchHandler.process_search_data_updates(table1) + + SearchHandler.create_workspace_search_table_if_not_exists(ws2.id) + SearchHandler.initialize_missing_search_data(table2) + SearchHandler.process_search_data_updates(table2) + + # Query workspace 1 - database + url_ws1 = reverse("api:search:workspace_search", kwargs={"workspace_id": ws1.id}) + resp_db_ws1 = api_client.get( + url_ws1, {"query": "Common DB"}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert resp_db_ws1.status_code == HTTP_200_OK + db_results_ws1 = resp_db_ws1.json()["results"] + assert len(db_results_ws1) == 1 + db_result_ws1 = db_results_ws1[0] + assert db_result_ws1["type"] == "database" + assert db_result_ws1["id"] == db1.id + + # Query workspace 1 - table + resp_table_ws1 = api_client.get( + url_ws1, {"query": "Common Table"}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert resp_table_ws1.status_code == HTTP_200_OK + table_results_ws1 = resp_table_ws1.json()["results"] + assert len(table_results_ws1) == 1 + table_result_ws1 = table_results_ws1[0] + assert table_result_ws1["type"] == "database_table" + assert table_result_ws1["id"] == table1.id + + # Query workspace 1 - field + resp_field_ws1 = api_client.get( + url_ws1, {"query": "Text"}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert resp_field_ws1.status_code == HTTP_200_OK + field_results_ws1 = resp_field_ws1.json()["results"] + assert len(field_results_ws1) == 1 + field_result_ws1 = field_results_ws1[0] + assert field_result_ws1["type"] == "database_field" + assert field_result_ws1.get("metadata", {}).get("workspace_id") == ws1.id + + # Query workspace 1 - row (by value) + url_ws1 = reverse("api:search:workspace_search", kwargs={"workspace_id": ws1.id}) + resp_ws1 = api_client.get( + url_ws1, {"query": "needle"}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert resp_ws1.status_code == HTTP_200_OK + results_ws1 = resp_ws1.json()["results"] + assert len(results_ws1) == 1 + result_ws1 = results_ws1[0] + assert result_ws1.get("metadata", {}).get("workspace_id") == ws1.id + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_admin_permissions(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user, permissions="ADMIN") + + database = data_fixture.create_database_application( + workspace=user_workspace.workspace, name="Admin Test Database" + ) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + response = api_client.get( + url, {"query": "Admin Test"}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + + assert response.status_code == HTTP_200_OK + response_json = response.json() + assert len(response_json["results"]) == 1 + assert response_json["results"][0]["id"] == database.id + assert response_json["results"][0]["title"] == database.name + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_member_permissions(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user, permissions="MEMBER") + + database = data_fixture.create_database_application( + workspace=user_workspace.workspace, name="Member Test Database" + ) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + response = api_client.get( + url, {"query": "Member Test"}, HTTP_AUTHORIZATION=f"JWT {token}" + ) + + assert response.status_code == HTTP_200_OK + response_json = response.json() + assert len(response_json["results"]) == 1 + assert response_json["results"][0]["id"] == database.id + assert response_json["results"][0]["title"] == database.name + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_workspace_search_trashed_workspace(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + + from baserow.core.trash.handler import TrashHandler + + TrashHandler.trash(user, user_workspace.workspace, None, user_workspace.workspace) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": user_workspace.workspace.id}, + ) + response = api_client.get(url, {"query": "test"}, HTTP_AUTHORIZATION=f"JWT {token}") + + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_pagination_across_multiple_types(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_workspace = data_fixture.create_user_workspace(user=user) + workspace = user_workspace.workspace + + databases = [] + for i in range(2): + databases.append( + data_fixture.create_database_application( + workspace=workspace, name=f"Search DB {i:02d}" + ) + ) + + tables = [] + for i, database in enumerate(databases): + for j in range(2 if i == 0 else 1): # 2 tables in first DB, 1 in second + tables.append( + data_fixture.create_database_table( + database=database, name=f"Search Table {i}{j}" + ) + ) + + table_fields = [] + for i, table in enumerate(tables): + table_field_list = [] + for j in range(2 if i < 2 else 1): + field = data_fixture.create_text_field( + table=table, name=f"Search Field {i}{j}" + ) + table_field_list.append(field) + table_fields.append(table_field_list) + + # Create 7 rows with search content (priority 7) + from baserow.contrib.database.rows.handler import RowHandler + from baserow.contrib.database.search.handler import SearchHandler + + row_handler = RowHandler() + rows = [] + for i, table in enumerate(tables): + for j in range(3 if i == 0 else 2): # 3 rows in first table, 2 in others + field = table_fields[i][0] + row_data = row_handler.create_rows( + user=user, + table=table, + rows_values=[{field.db_column: f"Search Row {i}{j}"}], + ) + rows.append(row_data.created_rows[0]) + + for table in tables: + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table) + SearchHandler.process_search_data_updates(table) + + table_rows = [ + row + for row in rows + if row._meta.model._meta.db_table == table.get_model()._meta.db_table + ] + if table_rows: + SearchHandler.update_search_data( + table=table, + field_ids=[field.id for field in table_fields[tables.index(table)]], + row_ids=[row.id for row in table_rows], + ) + + url = reverse( + "api:search:workspace_search", + kwargs={"workspace_id": workspace.id}, + ) + + response = api_client.get( + url, + {"query": "Search", "offset": 10, "limit": 5}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response.status_code == HTTP_200_OK + response_json = response.json() + results = response_json["results"] + + # Should return exactly 5 results (or fewer if we've reached the end) + assert len(results) <= 5 + + if len(results) > 0: + # Results should be ordered by priority: + # databases (1), tables (2), fields (6), rows (7) + result_types = [result["type"] for result in results] + + assert all( + result_type + in ["database", "database_table", "database_field", "database_row"] + for result_type in result_types + ) + + response_page1 = api_client.get( + url, + {"query": "Search", "offset": 0, "limit": 10}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_page2 = api_client.get( + url, + {"query": "Search", "offset": 10, "limit": 10}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response_page1.status_code == HTTP_200_OK + assert response_page2.status_code == HTTP_200_OK + + page1_results = response_page1.json()["results"] + page2_results = response_page2.json()["results"] + + page1_keys = {(result["type"], result["id"]) for result in page1_results} + page2_keys = {(result["type"], result["id"]) for result in page2_results} + assert len(page1_keys.intersection(page2_keys)) == 0 + + response_beyond = api_client.get( + url, + {"query": "Search", "offset": 100, "limit": 5}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response_beyond.status_code == HTTP_200_OK + beyond_results = response_beyond.json()["results"] + assert len(beyond_results) == 0 diff --git a/backend/tests/baserow/contrib/automation/api/nodes/test_nodes_views.py b/backend/tests/baserow/contrib/automation/api/nodes/test_nodes_views.py index 2bba6c309b..028985a19b 100644 --- a/backend/tests/baserow/contrib/automation/api/nodes/test_nodes_views.py +++ b/backend/tests/baserow/contrib/automation/api/nodes/test_nodes_views.py @@ -693,7 +693,7 @@ def test_create_router_node(api_client, data_fixture): "default_edge_label": "", "edges": [ { - "condition": "", + "condition": {"formula": "", "mode": "simple", "version": "0.1"}, "label": "Branch", "order": AnyStr(), "uid": AnyStr(), diff --git a/backend/tests/baserow/contrib/automation/search/test_automation_search_types.py b/backend/tests/baserow/contrib/automation/search/test_automation_search_types.py new file mode 100644 index 0000000000..58745677cd --- /dev/null +++ b/backend/tests/baserow/contrib/automation/search/test_automation_search_types.py @@ -0,0 +1,28 @@ +import pytest + +from baserow.contrib.automation.search_types import AutomationSearchType +from baserow.core.search.data_types import SearchContext + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_automation_search_type_basic_functionality(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + automation = data_fixture.create_automation_application( + workspace=workspace, name="Test Automation" + ) + + search_type = AutomationSearchType() + + queryset = search_type.get_base_queryset(user, workspace) + assert automation in queryset + + search_context = SearchContext(query="Test", limit=10, offset=0) + search_results = search_type.get_search_queryset(user, workspace, search_context) + assert automation in search_results + + search_result = search_type.serialize_result(automation, user, workspace) + assert search_result.id == automation.id + assert search_result.title == "Test Automation" + assert search_result.type == "automation" diff --git a/backend/tests/baserow/contrib/automation/test_automation_application_types.py b/backend/tests/baserow/contrib/automation/test_automation_application_types.py index e3badbded0..a80a0f9986 100644 --- a/backend/tests/baserow/contrib/automation/test_automation_application_types.py +++ b/backend/tests/baserow/contrib/automation/test_automation_application_types.py @@ -144,7 +144,11 @@ def test_automation_export_serialized(data_fixture): "integration_id": first_action.service.specific.integration_id, "type": "local_baserow_upsert_row", "table_id": first_action.service.specific.table_id, - "row_id": "", + "row_id": { + "formula": "", + "mode": "simple", + "version": "0.1", + }, "field_mappings": [], "sample_data": None, }, @@ -221,7 +225,7 @@ def test_automation_application_import(data_fixture): create_row_mapping = create_row_service.field_mappings.get(enabled=True) assert create_row_mapping.field_id == text_field.id assert ( - create_row_mapping.value + create_row_mapping.value["formula"] == f"get('previous_node.{trigger.id}.0.field_{text_field.id}')" ) diff --git a/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py b/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py index 2ba5d73895..46f77a92bc 100644 --- a/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py +++ b/backend/tests/baserow/contrib/builder/api/data_sources/test_data_source_views.py @@ -17,6 +17,8 @@ from baserow.contrib.builder.data_sources.models import DataSource from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.views.models import SORT_ORDER_ASC +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE, BaserowFormulaObject from baserow.core.services.models import Service from baserow.core.user_sources.user_source_user import UserSourceUser from baserow.test_utils.helpers import AnyInt, AnyStr, setup_interesting_test_table @@ -247,7 +249,7 @@ def test_update_data_source(api_client, data_fixture): assert response.status_code == HTTP_200_OK assert response.json()["view_id"] == view.id assert response.json()["table_id"] == table.id - assert response.json()["row_id"] == '"test"' + assert response.json()["row_id"]["formula"] == '"test"' assert response.json()["name"] == "name test" @@ -355,13 +357,21 @@ def test_update_data_source_with_filters(api_client, data_fixture): { "field": text_field.id, "type": "equals", - "value": "foobar", + "value": BaserowFormulaObject( + formula="foobar", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "value_is_formula": False, }, { "field": formula_field.id, "type": "equals", - "value": "get('page_parameter.id')", + "value": BaserowFormulaObject( + formula="get('page_parameter.id')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "value_is_formula": True, }, ] @@ -378,7 +388,11 @@ def test_update_data_source_with_filters(api_client, data_fixture): "order": service_filters[0].order, "field": text_field.id, "type": "equals", - "value": "foobar", + "value": BaserowFormulaObject( + formula="foobar", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "trashed": False, "value_is_formula": False, }, @@ -388,7 +402,11 @@ def test_update_data_source_with_filters(api_client, data_fixture): "field": formula_field.id, "type": "equals", "trashed": False, - "value": "get('page_parameter.id')", + "value": BaserowFormulaObject( + formula="get('page_parameter.id')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "value_is_formula": True, }, ] @@ -415,7 +433,11 @@ def test_update_data_source_with_filters(api_client, data_fixture): "service": data_source1.service_id, "field": text_field.id, "type": "equals", - "value": "foobar", + "value": BaserowFormulaObject( + formula="foobar", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "value_is_formula": False, } ] @@ -432,7 +454,11 @@ def test_update_data_source_with_filters(api_client, data_fixture): "order": 0, "field": text_field.id, "type": "equals", - "value": "foobar", + "value": BaserowFormulaObject( + formula="foobar", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "trashed": False, "value_is_formula": False, } diff --git a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py index f2e9ed6a5c..47673f1216 100644 --- a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py +++ b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py @@ -18,6 +18,9 @@ from baserow.contrib.builder.pages.models import Page from baserow.contrib.database.views.models import SORT_ORDER_ASC from baserow.core.exceptions import PermissionException +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE from baserow.core.models import Workspace from baserow.core.services.exceptions import ( DoesNotExist, @@ -432,7 +435,11 @@ def test_get_elements_of_public_builder(api_client, data_fixture): "style_width_child": "normal", "role_type": "allow_all", "roles": [], - "value": "", + "value": BaserowFormulaObject( + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + formula="", + ), "level": 1, } diff --git a/backend/tests/baserow/contrib/builder/api/elements/test_element_views.py b/backend/tests/baserow/contrib/builder/api/elements/test_element_views.py index 3b63d7f917..3a66b7f3dc 100644 --- a/backend/tests/baserow/contrib/builder/api/elements/test_element_views.py +++ b/backend/tests/baserow/contrib/builder/api/elements/test_element_views.py @@ -114,7 +114,7 @@ def test_create_element(api_client, data_fixture): response_json = response.json() assert response.status_code == HTTP_200_OK assert response_json["type"] == "heading" - assert response_json["value"] == "" + assert response_json["value"] == {"formula": "", "version": "0.1", "mode": "simple"} response = api_client.post( url, @@ -128,25 +128,11 @@ def test_create_element(api_client, data_fixture): response_json = response.json() assert response.status_code == HTTP_200_OK - assert response_json["value"] == '"test"' - - -@pytest.mark.django_db -def test_create_element_bad_request(api_client, data_fixture): - user, token = data_fixture.create_user_and_token() - page = data_fixture.create_builder_page(user=user) - - url = reverse("api:builder:element:list", kwargs={"page_id": page.id}) - response = api_client.post( - url, - {"type": "heading", "value": []}, - format="json", - HTTP_AUTHORIZATION=f"JWT {token}", - ) - - response_json = response.json() - assert response.status_code == HTTP_400_BAD_REQUEST - assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION" + assert response_json["value"] == { + "formula": '"test"', + "version": "0.1", + "mode": "simple", + } @pytest.mark.django_db @@ -268,7 +254,7 @@ def test_update_element(api_client, data_fixture): HTTP_AUTHORIZATION=f"JWT {token}", ) assert response.status_code == HTTP_200_OK - assert response.json()["value"] == '"unusual suspect"' + assert response.json()["value"]["formula"] == '"unusual suspect"' assert response.json()["level"] == 3 @@ -291,23 +277,6 @@ def test_update_element_styles(api_client, data_fixture): } -@pytest.mark.django_db -def test_update_element_bad_request(api_client, data_fixture): - user, token = data_fixture.create_user_and_token() - page = data_fixture.create_builder_page(user=user) - element1 = data_fixture.create_builder_heading_element(page=page) - - url = reverse("api:builder:element:item", kwargs={"element_id": element1.id}) - response = api_client.patch( - url, - {"value": []}, - format="json", - HTTP_AUTHORIZATION=f"JWT {token}", - ) - assert response.status_code == HTTP_400_BAD_REQUEST - assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION" - - @pytest.mark.django_db def test_update_element_does_not_exist(api_client, data_fixture): user, token = data_fixture.create_user_and_token() diff --git a/backend/tests/baserow/contrib/builder/api/elements/test_image_element.py b/backend/tests/baserow/contrib/builder/api/elements/test_image_element.py index 29a4189135..951f08d840 100644 --- a/backend/tests/baserow/contrib/builder/api/elements/test_image_element.py +++ b/backend/tests/baserow/contrib/builder/api/elements/test_image_element.py @@ -60,7 +60,7 @@ def test_create_image_element(api_client, data_fixture, tmpdir): "type": ImageElementType.type, "image_file": UserFileSerializer(user_file).data, "image_source_type": ImageElement.IMAGE_SOURCE_TYPES.UPLOAD, - "alt_text": "test", + "alt_text": "'test'", }, format="json", HTTP_AUTHORIZATION=f"JWT {token}", @@ -70,7 +70,7 @@ def test_create_image_element(api_client, data_fixture, tmpdir): assert response.status_code == 200 assert response_json["image_file"]["name"] == user_file.name assert response_json["image_source_type"] == ImageElement.IMAGE_SOURCE_TYPES.UPLOAD - assert response_json["alt_text"] == "test" + assert response_json["alt_text"]["formula"] == "'test'" @pytest.mark.django_db @@ -153,7 +153,7 @@ def test_image_file_is_not_deleted_when_not_explicitly_set( url = reverse("api:builder:element:item", kwargs={"element_id": element.id}) response = api_client.patch( url, - {"alt_text": "something"}, + {"alt_text": "'something'"}, format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) diff --git a/backend/tests/baserow/contrib/builder/api/elements/test_menu_element.py b/backend/tests/baserow/contrib/builder/api/elements/test_menu_element.py index 9f42b6836b..4ccd8eec15 100644 --- a/backend/tests/baserow/contrib/builder/api/elements/test_menu_element.py +++ b/backend/tests/baserow/contrib/builder/api/elements/test_menu_element.py @@ -7,6 +7,9 @@ from baserow.contrib.builder.elements.handler import ElementHandler from baserow.contrib.builder.elements.models import MenuItemElement +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE from baserow.test_utils.helpers import AnyInt, AnyStr @@ -81,7 +84,11 @@ def test_get_menu_element(api_client, menu_element_fixture): "menu_item_order": AnyInt(), "name": "Link", "navigate_to_page_id": None, - "navigate_to_url": "", + "navigate_to_url": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "navigation_type": "", "page_parameters": [], "parent_menu_item": None, @@ -156,7 +163,11 @@ def test_can_update_menu_element_items(api_client, menu_element_fixture): "type": "link", "uid": AnyStr(), "navigate_to_page_id": None, - "navigate_to_url": "", + "navigate_to_url": BaserowFormulaObject( + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + formula="", + ), "navigation_type": "page", "page_parameters": [], "parent_menu_item": None, diff --git a/backend/tests/baserow/contrib/builder/api/elements/test_table_element.py b/backend/tests/baserow/contrib/builder/api/elements/test_table_element.py index aa36e3b46c..68835a6c66 100644 --- a/backend/tests/baserow/contrib/builder/api/elements/test_table_element.py +++ b/backend/tests/baserow/contrib/builder/api/elements/test_table_element.py @@ -6,6 +6,9 @@ from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST from baserow.contrib.builder.elements.models import LinkElement, NavigationElementMixin +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE @pytest.mark.django_db @@ -93,7 +96,11 @@ def test_can_update_a_table_element_fields(api_client, data_fixture): { "name": "Name", "type": "text", - "value": "get('test1')", + "value": BaserowFormulaObject( + formula="get('test1')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "uid": uuids[0], "styles": {}, }, @@ -102,8 +109,16 @@ def test_can_update_a_table_element_fields(api_client, data_fixture): "type": "link", "navigate_to_page_id": None, "navigation_type": NavigationElementMixin.NAVIGATION_TYPES.PAGE, - "navigate_to_url": "get('test2')", - "link_name": "get('test3')", + "navigate_to_url": BaserowFormulaObject( + formula="get('test2')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), + "link_name": BaserowFormulaObject( + formula="get('test3')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "target": "self", "page_parameters": [], "query_parameters": [], @@ -114,7 +129,11 @@ def test_can_update_a_table_element_fields(api_client, data_fixture): { "name": "Question", "type": "text", - "value": "get('test3')", + "value": BaserowFormulaObject( + formula="get('test3')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "uid": uuids[2], "styles": {}, }, diff --git a/backend/tests/baserow/contrib/builder/api/workflow_actions/test_workflow_actions_views.py b/backend/tests/baserow/contrib/builder/api/workflow_actions/test_workflow_actions_views.py index 3b54323133..f7132f0f0e 100644 --- a/backend/tests/baserow/contrib/builder/api/workflow_actions/test_workflow_actions_views.py +++ b/backend/tests/baserow/contrib/builder/api/workflow_actions/test_workflow_actions_views.py @@ -27,7 +27,9 @@ from baserow.contrib.integrations.local_baserow.service_types import ( LocalBaserowUpsertRowServiceType, ) +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL from baserow.core.formula.serializers import FormulaSerializerField +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE, BaserowFormulaObject @pytest.mark.django_db @@ -161,7 +163,7 @@ def test_patch_workflow_actions(api_client, data_fixture): response_json = response.json() assert response.status_code == HTTP_200_OK - assert response_json["description"] == "'hello'" + assert response_json["description"]["formula"] == "'hello'" class PublicTestWorkflowActionType(NotificationWorkflowActionType): @@ -171,8 +173,6 @@ class PublicTestWorkflowActionType(NotificationWorkflowActionType): public_serializer_field_overrides = { "test": FormulaSerializerField( required=False, - allow_blank=True, - default="", ), } @@ -359,7 +359,11 @@ def test_create_create_row_workflow_action(api_client, data_fixture): assert response_json["service"] == { "id": workflow_action.service_id, "integration_id": None, - "row_id": "", + "row_id": BaserowFormulaObject( + formula="", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "type": LocalBaserowUpsertRowServiceType.type, "schema": None, "table_id": None, @@ -419,7 +423,15 @@ def test_update_create_row_workflow_action(api_client, data_fixture): assert response_json["service"]["table_id"] == service.table_id assert response_json["service"]["integration_id"] == service.integration_id assert response_json["service"]["field_mappings"] == [ - {"field_id": field.id, "value": "'Pony'", "enabled": True} + { + "field_id": field.id, + "value": BaserowFormulaObject( + formula="'Pony'", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), + "enabled": True, + } ] @@ -455,7 +467,11 @@ def test_create_update_row_workflow_action(api_client, data_fixture): "integration_id": None, "type": LocalBaserowUpsertRowServiceType.type, "schema": None, - "row_id": "", + "row_id": BaserowFormulaObject( + formula="", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "table_id": None, "field_mappings": [], "context_data": None, @@ -518,13 +534,21 @@ def test_update_update_row_workflow_action(api_client, data_fixture): assert response_json["element_id"] == workflow_action.element_id assert response_json["service"]["table_id"] == table.id - assert response_json["service"]["row_id"] == str(first_row.id) + assert response_json["service"]["row_id"]["formula"] == str(first_row.id) assert ( response_json["service"]["integration_id"] == workflow_action.service.integration_id ) assert response_json["service"]["field_mappings"] == [ - {"field_id": field.id, "value": "'Pony'", "enabled": True} + { + "field_id": field.id, + "value": BaserowFormulaObject( + formula="'Pony'", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), + "enabled": True, + } ] @@ -1034,7 +1058,11 @@ def test_create_delete_row_workflow_action(api_client, data_fixture): assert response_json["service"] == { "id": workflow_action.service_id, "integration_id": None, - "row_id": "", + "row_id": BaserowFormulaObject( + formula="", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "type": DeleteRowWorkflowActionType.service_type, "schema": None, "table_id": None, diff --git a/backend/tests/baserow/contrib/builder/elements/test_boolean_collection_field_type.py b/backend/tests/baserow/contrib/builder/elements/test_boolean_collection_field_type.py index 7e454bed5c..ae42dd34e4 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_boolean_collection_field_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_boolean_collection_field_type.py @@ -10,7 +10,10 @@ BooleanCollectionFieldType, ) from baserow.contrib.builder.pages.service import PageService +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL from baserow.core.formula.serializers import FormulaSerializerField +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE MODULE_PATH = "baserow.contrib.builder.elements.collection_field_types" @@ -40,8 +43,7 @@ def test_serializer_field_overrides_returns_expected_value(): field = result["value"] assert type(field) is FormulaSerializerField - assert field.allow_blank is True - assert field.default is False + assert isinstance(field.default, dict) assert field.required is False assert field.help_text == "The boolean value." @@ -95,7 +97,11 @@ def test_import_export_boolean_collection_field_type(data_fixture): imported_bool_field = imported_table_element.fields.get(name="User Is Active") assert imported_bool_field.config == { - "value": f"get('data_source.{data_source2.id}.0.{bool_field.db_column}')" + "value": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.0.{bool_field.db_column}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ) } diff --git a/backend/tests/baserow/contrib/builder/elements/test_button_collection_field_type.py b/backend/tests/baserow/contrib/builder/elements/test_button_collection_field_type.py index aed542a9fa..95a1cda22f 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_button_collection_field_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_button_collection_field_type.py @@ -10,7 +10,10 @@ ButtonCollectionFieldType, ) from baserow.contrib.builder.pages.service import PageService +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL from baserow.core.formula.serializers import FormulaSerializerField +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE MODULE_PATH = "baserow.contrib.builder.elements.collection_field_types" @@ -40,8 +43,7 @@ def test_serializer_field_overrides_returns_expected_value(): field = result["label"] assert type(field) is FormulaSerializerField - assert field.allow_blank is True - assert field.default == "" + assert isinstance(field.default, dict) assert field.required is False assert field.help_text == "The string value." @@ -137,5 +139,9 @@ def test_import_export_button_collection_field_type(data_fixture): imported_field = imported_table_element.fields.get(name="Foo Button") assert imported_field.config == { - "label": f"get('data_source.{data_source2.id}.0.{text_field.db_column}')" + "label": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.0.{text_field.db_column}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ) } diff --git a/backend/tests/baserow/contrib/builder/elements/test_element_handler.py b/backend/tests/baserow/contrib/builder/elements/test_element_handler.py index e7702db6d4..585a9be710 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_element_handler.py +++ b/backend/tests/baserow/contrib/builder/elements/test_element_handler.py @@ -212,9 +212,9 @@ def test_update_element(data_fixture): user = data_fixture.create_user() element = data_fixture.create_builder_heading_element(user=user) - element_updated = ElementHandler().update_element(element, value="newValue") + element_updated = ElementHandler().update_element(element, value="'newValue'") - assert element_updated.value == "newValue" + assert element_updated.value["formula"] == "'newValue'" @pytest.mark.django_db diff --git a/backend/tests/baserow/contrib/builder/elements/test_element_receivers.py b/backend/tests/baserow/contrib/builder/elements/test_element_receivers.py index c9cb769a0e..44c405cfa3 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_element_receivers.py +++ b/backend/tests/baserow/contrib/builder/elements/test_element_receivers.py @@ -5,6 +5,8 @@ ) from baserow.contrib.builder.pages.models import Page from baserow.contrib.builder.pages.signals import page_deleted +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE @pytest.mark.django_db @@ -24,7 +26,7 @@ def test_page_deletion_updates_link_collection_navigate_to_page_id(data_fixture) "navigate_to_page_id": destination_page.id, "navigation_type": "page", "navigate_to_url": "", - "link_name": "Click me", + "link_name": "'Click me'", "target": "self", }, }, @@ -35,8 +37,16 @@ def test_page_deletion_updates_link_collection_navigate_to_page_id(data_fixture) field.refresh_from_db() assert field.config == { "target": "self", - "link_name": "Click me", - "navigate_to_url": "", + "link_name": { + "formula": "'Click me'", + "mode": BASEROW_FORMULA_MODE_SIMPLE, + "version": BASEROW_FORMULA_VERSION_INITIAL, + }, + "navigate_to_url": { + "formula": "", + "mode": BASEROW_FORMULA_MODE_SIMPLE, + "version": BASEROW_FORMULA_VERSION_INITIAL, + }, "navigation_type": "page", "page_parameters": [], "navigate_to_page_id": None, diff --git a/backend/tests/baserow/contrib/builder/elements/test_element_types.py b/backend/tests/baserow/contrib/builder/elements/test_element_types.py index fe15a7d91d..db4c9a7032 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_element_types.py +++ b/backend/tests/baserow/contrib/builder/elements/test_element_types.py @@ -226,11 +226,15 @@ def test_link_collection_field_import_export_formula(data_fixture): 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["link_name"]["formula"] == expected_formula + assert imported_field.config["navigate_to_url"]["formula"] == expected_formula assert ( - imported_field.config["query_parameters"][0]["value"] == expected_query_formula + imported_field.config["page_parameters"][0]["value"]["formula"] + == expected_formula + ) + assert ( + imported_field.config["query_parameters"][0]["value"]["formula"] + == expected_query_formula ) @@ -269,10 +273,13 @@ def test_link_element_import_export_formula(data_fixture): 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[0]["value"] == expected_formula - assert imported_element.query_parameters[0]["value"] == expected_query_formula + assert imported_element.navigate_to_url["formula"] == expected_formula + assert imported_element.value["formula"] == expected_formula + assert imported_element.page_parameters[0]["value"]["formula"] == expected_formula + assert ( + imported_element.query_parameters[0]["value"]["formula"] + == expected_query_formula + ) @pytest.mark.django_db @@ -296,7 +303,7 @@ def test_form_container_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')" - assert imported_element.submit_button_label == expected_formula + assert imported_element.submit_button_label["formula"] == expected_formula @pytest.mark.parametrize( @@ -335,7 +342,7 @@ def test_text_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')" - assert imported_element.value == expected_formula + assert imported_element.value["formula"] == expected_formula @pytest.mark.django_db @@ -359,9 +366,9 @@ def test_input_text_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')" - assert imported_element.label == expected_formula - assert imported_element.default_value == expected_formula - assert imported_element.placeholder == expected_formula + assert imported_element.label["formula"] == expected_formula + assert imported_element.default_value["formula"] == expected_formula + assert imported_element.placeholder["formula"] == expected_formula @pytest.mark.django_db @@ -384,8 +391,8 @@ def test_image_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')" - assert imported_element.image_url == expected_formula - assert imported_element.alt_text == expected_formula + assert imported_element.image_url["formula"] == expected_formula + assert imported_element.alt_text["formula"] == expected_formula @pytest.mark.django_db @@ -407,7 +414,7 @@ def test_button_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')" - assert imported_element.value == expected_formula + assert imported_element.value["formula"] == expected_formula def test_rating_input_element_type_is_valid_with_valid_value(): @@ -496,11 +503,11 @@ def test_choice_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')" - assert imported_element.label == expected_formula - assert imported_element.default_value == expected_formula - assert imported_element.placeholder == expected_formula - assert imported_element.formula_name == expected_formula - assert imported_element.formula_value == expected_formula + assert imported_element.label["formula"] == expected_formula + assert imported_element.default_value["formula"] == expected_formula + assert imported_element.placeholder["formula"] == expected_formula + assert imported_element.formula_name["formula"] == expected_formula + assert imported_element.formula_value["formula"] == expected_formula @pytest.mark.django_db @@ -820,7 +827,7 @@ def test_page_with_element_using_form_data_has_dependencies_import_first(data_fi form_input_clone = InputTextElement.objects.get(page=page_clone) heading_clone = HeadingElement.objects.get(page=page_clone) - assert heading_clone.value == f"get('form_data.{form_input_clone.id}')" + assert heading_clone.value["formula"] == f"get('form_data.{form_input_clone.id}')" @pytest.mark.django_db @@ -843,8 +850,8 @@ def test_checkbox_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')" - assert imported_element.label == expected_formula - assert imported_element.default_value == expected_formula + assert imported_element.label["formula"] == expected_formula + assert imported_element.default_value["formula"] == expected_formula @pytest.mark.django_db @@ -901,8 +908,8 @@ def test_iframe_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')" - assert imported_element.url == expected_formula - assert imported_element.embed == expected_formula + assert imported_element.url["formula"] == expected_formula + assert imported_element.embed["formula"] == expected_formula @pytest.mark.django_db @@ -954,7 +961,7 @@ def test_image_element_import_export(data_fixture, fake, storage): ) expected_formula = f"get('data_source.{data_source_2.id}.field_1')" - assert imported_element.image_url == expected_formula + assert imported_element.image_url["formula"] == expected_formula assert ( imported_element.image_file_id is not None and imported_element.image_file_id != element_to_export.image_file_id @@ -986,9 +993,9 @@ def test_choice_element_import_export(data_fixture): imported_element = element_type.import_serialized(page, serialized, id_mapping) expected_formula = f"get('data_source.{data_source_2.id}.field_1')" - assert imported_element.label == expected_formula - assert imported_element.default_value == expected_formula - assert imported_element.placeholder == expected_formula + assert imported_element.label["formula"] == expected_formula + assert imported_element.default_value["formula"] == expected_formula + assert imported_element.placeholder["formula"] == expected_formula assert imported_element.multiple is True diff --git a/backend/tests/baserow/contrib/builder/elements/test_image_collection_field_type.py b/backend/tests/baserow/contrib/builder/elements/test_image_collection_field_type.py index dc5e3e5a50..c9b9a43d69 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_image_collection_field_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_image_collection_field_type.py @@ -6,6 +6,8 @@ import pytest from baserow.contrib.builder.pages.service import PageService +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE, BaserowFormulaObject from baserow.core.user_files.handler import UserFileHandler @@ -70,6 +72,14 @@ def test_import_export_image_collection_field_type(data_fixture, fake, storage): images = imported_table_element.fields.get(name="Images") assert images.config == { - "src": f"get('data_source.{data_source_2.id}.*.{fields[0].db_column}.url')", - "alt": f"get('data_source.{data_source_2.id}.*.{fields[0].db_column}.name')", + "src": BaserowFormulaObject( + formula=f"get('data_source.{data_source_2.id}.*.{fields[0].db_column}.url')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), + "alt": BaserowFormulaObject( + formula=f"get('data_source.{data_source_2.id}.*.{fields[0].db_column}.name')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), } diff --git a/backend/tests/baserow/contrib/builder/elements/test_link_collection_field_type.py b/backend/tests/baserow/contrib/builder/elements/test_link_collection_field_type.py index 3d1ae07d1c..43108558d2 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_link_collection_field_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_link_collection_field_type.py @@ -15,6 +15,9 @@ from baserow.contrib.builder.pages.service import PageService from baserow.contrib.builder.pages.signals import page_deleted from baserow.core.exceptions import InstanceTypeDoesNotExist +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE def test_registering_link_collection_field_type_connects_to_page_deleted_signal(): @@ -62,7 +65,6 @@ def test_import_export_link_collection_field_type(data_fixture): "name": "Foo Link Field", "type": "link", "config": { - "target": "self", "link_name": f"get('data_source.{data_source.id}.0.{text_field.db_column}')", "navigate_to_url": f"get('data_source.{data_source.id}.0.{text_field.db_column}')", "navigation_type": "page", @@ -104,20 +106,36 @@ def test_import_export_link_collection_field_type(data_fixture): imported_field = imported_table_element.fields.get(name="Foo Link Field") assert imported_field.config == { - "link_name": f"get('data_source.{data_source2.id}.0.{text_field.db_column}')", - "navigate_to_url": f"get('data_source.{data_source2.id}.0.{text_field.db_column}')", + "link_name": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.0.{text_field.db_column}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), + "navigate_to_url": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.0.{text_field.db_column}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "navigate_to_page_id": None, "navigation_type": "page", "query_parameters": [ { "name": "fooQueryParam", - "value": f"get('data_source.{data_source2.id}.field_1')", + "value": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.field_1')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), }, ], "page_parameters": [ { "name": "fooPageParam", - "value": f"get('data_source.{data_source2.id}.field_1')", + "value": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.field_1')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), }, ], "target": "self", diff --git a/backend/tests/baserow/contrib/builder/elements/test_menu_element_type.py b/backend/tests/baserow/contrib/builder/elements/test_menu_element_type.py index 01e705ef93..1252a89cb3 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_menu_element_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_menu_element_type.py @@ -9,6 +9,9 @@ from baserow.contrib.builder.elements.handler import ElementHandler from baserow.contrib.builder.elements.models import MenuElement, MenuItemElement from baserow.contrib.builder.workflow_actions.models import NotificationWorkflowAction +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE from baserow.core.utils import MirrorDict from baserow.test_utils.helpers import AnyInt @@ -181,9 +184,40 @@ def test_add_sub_link(menu_element_fixture): ("navigation_type", "link"), # None is replaced with a valid page in the test ("navigate_to_page_id", None), - ("navigate_to_url", "https://www.baserow.io"), - ("page_parameters", [{"name": "foo", "value": "'bar'"}]), - ("query_parameters", [{"name": "param", "value": "'baz'"}]), + ( + "navigate_to_url", + BaserowFormulaObject( + formula="https://www.baserow.io", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + ), + ( + "page_parameters", + [ + { + "name": "foo", + "value": BaserowFormulaObject( + formula="'bar'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + } + ], + ), + ( + "query_parameters", + [ + { + "name": "param", + "value": BaserowFormulaObject( + formula="'baz'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + } + ], + ), ("target", "_blank"), ], ) @@ -205,7 +239,11 @@ def test_update_menu_item(menu_element_fixture, field, value): "uid": str(uid), "navigation_type": "page", "navigate_to_page_id": None, - "navigate_to_url": "", + "navigate_to_url": { + "formula": "", + "mode": BASEROW_FORMULA_MODE_SIMPLE, + "version": BASEROW_FORMULA_VERSION_INITIAL, + }, "parent_menu_item": None, "page_parameters": [], "query_parameters": [], diff --git a/backend/tests/baserow/contrib/builder/elements/test_rating_collection_field_type.py b/backend/tests/baserow/contrib/builder/elements/test_rating_collection_field_type.py index 801d071cf9..a0a822476d 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_rating_collection_field_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_rating_collection_field_type.py @@ -6,6 +6,9 @@ from baserow.contrib.builder.elements.registries import element_type_registry from baserow.contrib.builder.pages.service import PageService +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE @pytest.mark.django_db @@ -38,7 +41,11 @@ def test_import_export_rating_collection_field_type(data_fixture): "name": "Rating Field", "type": "rating", "config": { - "value": f"get('data_source.{data_source.id}.0.{rating_field.db_column}')", + "value": BaserowFormulaObject( + formula=f"get('data_source.{data_source.id}.0.{rating_field.db_column}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "max_value": 5, "rating_style": "star", "color": "", @@ -76,7 +83,11 @@ def test_import_export_rating_collection_field_type(data_fixture): # with updated data source ID imported_field = imported_element.fields.get(name="Rating Field") assert imported_field.config == { - "value": f"get('data_source.{data_source2.id}.0.{rating_field.db_column}')", + "value": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.0.{rating_field.db_column}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "max_value": 5, "rating_style": "star", "color": "", diff --git a/backend/tests/baserow/contrib/builder/elements/test_rating_element_types.py b/backend/tests/baserow/contrib/builder/elements/test_rating_element_types.py index 7ee263e68d..707db90180 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_rating_element_types.py +++ b/backend/tests/baserow/contrib/builder/elements/test_rating_element_types.py @@ -4,6 +4,9 @@ from baserow.contrib.builder.elements.models import RatingElement, RatingStyleChoices from baserow.contrib.builder.elements.registries import element_type_registry +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE from baserow.core.utils import MirrorDict @@ -13,7 +16,7 @@ def test_rating_element_type_export_import(data_fixture): element = data_fixture.create_builder_element( RatingElement, page=page, - value="4", + value="'4'", max_value=5, color="#FF0000", rating_style=RatingStyleChoices.STAR, @@ -23,7 +26,11 @@ def test_rating_element_type_export_import(data_fixture): assert exported["id"] == element.id assert exported["type"] == "rating" - assert exported["value"] == "4" + assert exported["value"] == BaserowFormulaObject( + formula="'4'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) assert exported["max_value"] == 5 assert exported["color"] == "#FF0000" assert exported["rating_style"] == RatingStyleChoices.STAR diff --git a/backend/tests/baserow/contrib/builder/elements/test_record_selector_element_type.py b/backend/tests/baserow/contrib/builder/elements/test_record_selector_element_type.py index 95b6f27ac9..ca8042ea0b 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_record_selector_element_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_record_selector_element_type.py @@ -98,10 +98,10 @@ def test_export_import_record_selector_element(data_fixture): ) # Check that the formula for option name suffix was updated with the new mapping - import_option_name_suffix = imported_element.option_name_suffix + import_option_name_suffix = imported_element.option_name_suffix["formula"] import_option_name_suffix_field_id = str( id_mapping["database_fields"][fields[-1].id] ) - assert import_option_name_suffix == element.option_name_suffix.replace( + assert import_option_name_suffix == element.option_name_suffix["formula"].replace( option_name_suffix_field_id, import_option_name_suffix_field_id ) diff --git a/backend/tests/baserow/contrib/builder/elements/test_repeat_element_type.py b/backend/tests/baserow/contrib/builder/elements/test_repeat_element_type.py index 8d80991301..4efbdb8635 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_repeat_element_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_repeat_element_type.py @@ -160,19 +160,19 @@ def test_repeat_element_import_child_with_formula_with_current_record(data_fixtu migrated_ref = f"get('current_record.{field.db_column}')" button = ButtonElement.objects.all().first() - assert button.value == migrated_ref + assert button.value["formula"] == migrated_ref heading = HeadingElement.objects.all().first() - assert heading.value == migrated_ref + assert heading.value["formula"] == migrated_ref action = NotificationWorkflowAction.objects.first() - assert action.title == migrated_ref + assert action.title["formula"] == migrated_ref link = LinkElement.objects.all().first() - 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 + assert link.value["formula"] == migrated_ref + assert link.navigate_to_url["formula"] == migrated_ref + assert link.page_parameters[0]["value"]["formula"] == migrated_ref + assert link.query_parameters[0]["value"]["formula"] == migrated_ref @pytest.mark.django_db diff --git a/backend/tests/baserow/contrib/builder/elements/test_table_element_type.py b/backend/tests/baserow/contrib/builder/elements/test_table_element_type.py index 38c705f40b..bb902ec592 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_table_element_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_table_element_type.py @@ -14,6 +14,9 @@ from baserow.contrib.builder.elements.registries import element_type_registry from baserow.contrib.builder.elements.service import ElementService from baserow.contrib.builder.workflow_actions.models import NotificationWorkflowAction +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE from baserow.core.utils import MirrorDict @@ -218,14 +221,28 @@ def test_duplicate_table_element_with_current_record_formulas(data_fixture): result = ElementHandler().duplicate_element(table_element) assert [f.config for f in result["elements"][0].fields.all()] == [ - {"value": f"get('current_record.field_{fields[0].id}')"}, + { + "value": BaserowFormulaObject( + formula=f"get('current_record.field_{fields[0].id}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ) + }, { "page_parameters": [], "query_parameters": [], "navigate_to_page_id": None, "navigation_type": "custom", - "navigate_to_url": f"get('current_record.field_{fields[0].id}')", - "link_name": f"get('current_record.field_{fields[0].id}')", + "navigate_to_url": BaserowFormulaObject( + formula=f"get('current_record.field_{fields[0].id}')", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "link_name": BaserowFormulaObject( + formula=f"get('current_record.field_{fields[0].id}')", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "target": "self", "variant": LinkElement.VARIANTS.BUTTON, }, @@ -267,7 +284,13 @@ def test_import_table_element_with_current_record_formulas_with_update(data_fixt "fields": [ { "name": "Field 1", - "config": {"value": f"get('current_record.field_42')"}, + "config": { + "value": BaserowFormulaObject( + formula="get('current_record.field_42')", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + }, "type": "text", "uid": uuids[0], }, @@ -278,8 +301,16 @@ def test_import_table_element_with_current_record_formulas_with_update(data_fixt "query_parameters": [], "navigate_to_page_id": None, "navigation_type": "custom", - "navigate_to_url": f"get('current_record.field_42')", - "link_name": f"get('current_record.field_42')", + "navigate_to_url": BaserowFormulaObject( + formula="get('current_record.field_42')", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "link_name": BaserowFormulaObject( + formula="get('current_record.field_42')", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "target": "self", "variant": LinkElement.VARIANTS.BUTTON, }, @@ -301,14 +332,28 @@ def test_import_table_element_with_current_record_formulas_with_update(data_fixt assert imported_table_element.specific.data_source_id == data_source1.id assert [f.config for f in imported_table_element.specific.fields.all()] == [ - {"value": f"get('current_record.field_{fields[0].id}')"}, + { + "value": BaserowFormulaObject( + formula=f"get('current_record.field_{fields[0].id}')", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + }, { "page_parameters": [], "query_parameters": [], "navigate_to_page_id": None, "navigation_type": "custom", - "navigate_to_url": f"get('current_record.field_{fields[0].id}')", - "link_name": f"get('current_record.field_{fields[0].id}')", + "navigate_to_url": BaserowFormulaObject( + formula=f"get('current_record.field_{fields[0].id}')", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "link_name": BaserowFormulaObject( + formula=f"get('current_record.field_{fields[0].id}')", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "target": "self", "variant": LinkElement.VARIANTS.BUTTON, }, @@ -491,7 +536,7 @@ def test_table_element_import_field_with_formula_with_current_record(data_fixtur table_element = table_element_type.import_serialized(page, exported, id_mapping) assert ( - table_element.fields.first().config["label"] + table_element.fields.first().config["label"]["formula"] == f"get('current_record.field_{fields[0].id}')" ) diff --git a/backend/tests/baserow/contrib/builder/elements/test_tags_collection_field_type.py b/backend/tests/baserow/contrib/builder/elements/test_tags_collection_field_type.py index 709334a234..7f3cfbe974 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_tags_collection_field_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_tags_collection_field_type.py @@ -1,6 +1,9 @@ import pytest from baserow.contrib.builder.pages.service import PageService +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE @pytest.mark.django_db @@ -63,14 +66,30 @@ def test_import_export_tags_collection_field_type(data_fixture): tags_with_formula = imported_table_element.fields.get(name="Colors as formula") assert tags_with_formula.config == { - "values": f"get('data_source.{data_source2.id}.0.{name_field.db_column}')", - "colors": f"get('data_source.{data_source2.id}.0.{color_field.db_column}')", + "values": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.0.{name_field.db_column}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), + "colors": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.0.{color_field.db_column}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "colors_is_formula": True, } tags_without_formula = imported_table_element.fields.get(name="Colors as hex") assert tags_without_formula.config == { - "values": "'a,b,c'", + "values": BaserowFormulaObject( + formula="'a,b,c'", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "colors_is_formula": False, - "colors": "#d06060ff", + "colors": BaserowFormulaObject( + formula="#d06060ff", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), } diff --git a/backend/tests/baserow/contrib/builder/elements/test_text_collection_field_type.py b/backend/tests/baserow/contrib/builder/elements/test_text_collection_field_type.py index 5d63962c76..2cfff8f4f3 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_text_collection_field_type.py +++ b/backend/tests/baserow/contrib/builder/elements/test_text_collection_field_type.py @@ -10,7 +10,10 @@ TextCollectionFieldType, ) from baserow.contrib.builder.pages.service import PageService +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL from baserow.core.formula.serializers import FormulaSerializerField +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE MODULE_PATH = "baserow.contrib.builder.elements.collection_field_types" @@ -40,8 +43,7 @@ def test_serializer_field_overrides_returns_expected_value(): field = result["value"] assert type(field) is FormulaSerializerField - assert field.allow_blank is True - assert field.default == "" + assert isinstance(field.default, dict) assert field.required is False assert field.help_text == "The formula for the text." @@ -139,5 +141,9 @@ def test_import_export_text_collection_field_type(data_fixture): imported_field = imported_table_element.fields.get(name="Foo Field") assert imported_field.config == { - "value": f"get('data_source.{data_source2.id}.0.{text_field.db_column}')" + "value": BaserowFormulaObject( + formula=f"get('data_source.{data_source2.id}.0.{text_field.db_column}')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ) } diff --git a/backend/tests/baserow/contrib/builder/search/test_builder_search_types.py b/backend/tests/baserow/contrib/builder/search/test_builder_search_types.py new file mode 100644 index 0000000000..8a82f446d6 --- /dev/null +++ b/backend/tests/baserow/contrib/builder/search/test_builder_search_types.py @@ -0,0 +1,28 @@ +import pytest + +from baserow.contrib.builder.search_types import BuilderSearchType +from baserow.core.search.data_types import SearchContext + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_builder_search_type_basic_functionality(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + builder = data_fixture.create_builder_application( + workspace=workspace, name="Test Builder" + ) + + search_type = BuilderSearchType() + + queryset = search_type.get_base_queryset(user, workspace) + assert builder in queryset + + search_context = SearchContext(query="Test", limit=10, offset=0) + search_results = search_type.get_search_queryset(user, workspace, search_context) + assert builder in search_results + + search_result = search_type.serialize_result(builder, user, workspace) + assert search_result.id == builder.id + assert search_result.title == "Test Builder" + assert search_result.type == "builder" diff --git a/backend/tests/baserow/contrib/builder/test_builder_application_type.py b/backend/tests/baserow/contrib/builder/test_builder_application_type.py index f90db01371..f51b33bf93 100644 --- a/backend/tests/baserow/contrib/builder/test_builder_application_type.py +++ b/backend/tests/baserow/contrib/builder/test_builder_application_type.py @@ -214,10 +214,10 @@ def test_builder_application_export(data_fixture): "integration_id": integration.id, "filter_type": "AND", "filters": [], - "row_id": "", + "row_id": datasource2.service.row_id, "view_id": None, "table_id": None, - "search_query": "", + "search_query": datasource2.service.search_query, "type": "local_baserow_get_row", }, }, @@ -234,7 +234,7 @@ def test_builder_application_export(data_fixture): "sortings": [], "view_id": None, "table_id": None, - "search_query": "", + "search_query": datasource3.service.search_query, "filter_type": "AND", "type": "local_baserow_list_rows", }, @@ -283,7 +283,7 @@ def test_builder_application_export(data_fixture): "id": element4.id, "type": "table", "schema_property": None, - "button_load_more_label": "", + "button_load_more_label": element4.button_load_more_label, "order": str(element4.order), "roles": [], "role_type": "allow_all", @@ -371,10 +371,10 @@ def test_builder_application_export(data_fixture): "integration_id": integration.id, "filter_type": "AND", "filters": [], - "row_id": "", + "row_id": shared_datasource.service.row_id, "view_id": None, "table_id": None, - "search_query": "", + "search_query": shared_datasource.service.search_query, "type": "local_baserow_get_row", }, }, @@ -400,8 +400,8 @@ def test_builder_application_export(data_fixture): "element_id": element1.id, "event": EventTypes.CLICK.value, "page_id": page1.id, - "description": "hello", - "title": "there", + "description": workflow_action_1.description, + "title": workflow_action_1.title, } ], "data_sources": [ @@ -415,10 +415,10 @@ def test_builder_application_export(data_fixture): "integration_id": integration.id, "filter_type": "AND", "filters": [], - "row_id": "", + "row_id": datasource1.service.row_id, "view_id": None, "table_id": None, - "search_query": "", + "search_query": datasource1.service.search_query, "type": "local_baserow_get_row", }, }, @@ -1151,8 +1151,8 @@ def test_builder_application_import(data_fixture): [workflow_action] = BuilderWorkflowActionHandler().get_workflow_actions(page1) assert workflow_action.element_id == element1.id - assert workflow_action.description == "'hello'" - assert workflow_action.title == "'there'" + assert workflow_action.description["formula"] == "'hello'" + assert workflow_action.title["formula"] == "'there'" IMPORT_REFERENCE_COMPLEX = { diff --git a/backend/tests/baserow/contrib/builder/test_element_formula_mixin.py b/backend/tests/baserow/contrib/builder/test_element_formula_mixin.py index 90271a5f1f..75d17ba22e 100644 --- a/backend/tests/baserow/contrib/builder/test_element_formula_mixin.py +++ b/backend/tests/baserow/contrib/builder/test_element_formula_mixin.py @@ -27,6 +27,9 @@ LinkElement, TextElement, ) +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE @pytest.fixture @@ -101,10 +104,8 @@ def test_element_formula_generator_mixin( ) for formula_field in element_type.simple_formula_fields: - assert ( - getattr(imported_element, formula_field) - == formula_generator_fixture["formula_2"] - ) + formula_obj = getattr(imported_element, formula_field) + assert formula_obj["formula"] == formula_generator_fixture["formula_2"] @pytest.mark.django_db @@ -149,13 +150,13 @@ def test_link_element_formula_generator(data_fixture, formula_generator_fixture) formula_generator_fixture["id_mapping"], ) - assert imported_element.value == formula_generator_fixture["formula_2"] + assert imported_element.value["formula"] == formula_generator_fixture["formula_2"] for page_param in imported_element.page_parameters: - assert page_param.get("value") == formula_generator_fixture["formula_2"] + assert page_param["value"]["formula"] == formula_generator_fixture["formula_2"] for query_param in imported_element.query_parameters: - assert query_param.get("value") == formula_generator_fixture["formula_2"] + assert query_param["value"]["formula"] == formula_generator_fixture["formula_2"] @pytest.mark.django_db @@ -206,7 +207,11 @@ def test_table_element_formula_generator(data_fixture, formula_generator_fixture ) assert table_element.fields.get().config == { - "label": "get('current_record.field_111')" + "label": BaserowFormulaObject( + formula="get('current_record.field_111')", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ) } assert table_element.data_source_id == data_source.id @@ -252,11 +257,14 @@ def test_menu_element_formula_generator(data_fixture, formula_generator_fixture) imported_menu_item = imported_element.menu_items.first() assert ( - imported_menu_item.page_parameters[0]["value"] + imported_menu_item.page_parameters[0]["value"]["formula"] + == formula_generator_fixture["formula_2"] + ) + assert ( + imported_menu_item.query_parameters[0]["value"]["formula"] == formula_generator_fixture["formula_2"] ) assert ( - imported_menu_item.query_parameters[0]["value"] + imported_menu_item.navigate_to_url["formula"] == formula_generator_fixture["formula_2"] ) - assert imported_menu_item.navigate_to_url == formula_generator_fixture["formula_2"] diff --git a/backend/tests/baserow/contrib/builder/workflow_actions/test_workflow_action_types.py b/backend/tests/baserow/contrib/builder/workflow_actions/test_workflow_action_types.py index ff442f63c6..e747ec0119 100644 --- a/backend/tests/baserow/contrib/builder/workflow_actions/test_workflow_action_types.py +++ b/backend/tests/baserow/contrib/builder/workflow_actions/test_workflow_action_types.py @@ -15,6 +15,9 @@ OpenPageWorkflowActionType, RefreshDataSourceWorkflowActionType, ) +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE from baserow.core.services.exceptions import InvalidServiceTypeDispatchSource from baserow.core.utils import MirrorDict from baserow.core.workflow_actions.registries import WorkflowActionType @@ -144,10 +147,18 @@ def test_export_import_upsert_row_workflow_action_type(data_fixture): "id": service.id, "integration_id": integration.id, "type": "local_baserow_upsert_row", - "row_id": "", + "row_id": BaserowFormulaObject( + formula="", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "table_id": table.id, "field_mappings": [ - {"field_id": field.id, "value": field_mapping.value, "enabled": True} + { + "field_id": field.id, + "value": field_mapping.value, + "enabled": True, + } ], "sample_data": None, }, @@ -181,7 +192,7 @@ def test_export_import_upsert_row_workflow_action_type(data_fixture): # in its formula. imported_field_mapping = new_workflow_action.service.field_mappings.all()[0] assert ( - imported_field_mapping.value + imported_field_mapping.value["formula"] == f"get('data_source.{data_source2.id}.{field.db_column}')" ) @@ -228,7 +239,7 @@ def test_builder_local_baserow_workflow_service_type_prepare_values_with_instanc user, workflow_action, ) - assert service.row_id == row2.id + assert service.row_id["formula"] == str(row2.id) assert service.table_id == table.id assert service.integration_id == integration.id @@ -330,8 +341,8 @@ def test_import_notification_workflow_action(data_fixture): ) expected_formula = f"get('data_source.{data_source_2.id}.field_1')" - assert imported_workflow_action.title == expected_formula - assert imported_workflow_action.description == expected_formula + assert imported_workflow_action.title["formula"] == expected_formula + assert imported_workflow_action.description["formula"] == expected_formula @pytest.mark.django_db @@ -376,10 +387,14 @@ def test_import_open_page_workflow_action(data_fixture): 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.navigate_to_url["formula"] == expected_formula assert ( - imported_workflow_action.query_parameters[0]["value"] == expected_query_formula + imported_workflow_action.page_parameters[0]["value"]["formula"] + == expected_formula + ) + assert ( + imported_workflow_action.query_parameters[0]["value"]["formula"] + == expected_query_formula ) diff --git a/backend/tests/baserow/contrib/dashboard/api/data_sources/test_dashboard_data_source_views.py b/backend/tests/baserow/contrib/dashboard/api/data_sources/test_dashboard_data_source_views.py index 760c34b281..2c511ebcb0 100644 --- a/backend/tests/baserow/contrib/dashboard/api/data_sources/test_dashboard_data_source_views.py +++ b/backend/tests/baserow/contrib/dashboard/api/data_sources/test_dashboard_data_source_views.py @@ -50,7 +50,7 @@ def test_get_dashboard_data_sources(api_client, data_fixture): "name": "Name 1", "order": "1.00000000000000000000", "schema": None, - "search_query": "", + "search_query": {"formula": "", "mode": "simple", "version": "0.1"}, "table_id": None, "type": "local_baserow_aggregate_rows", "view_id": None, @@ -69,7 +69,7 @@ def test_get_dashboard_data_sources(api_client, data_fixture): "name": "Name 2", "order": "2.00000000000000000000", "schema": None, - "search_query": "", + "search_query": {"formula": "", "mode": "simple", "version": "0.1"}, "table_id": None, "type": "local_baserow_list_rows", "view_id": None, diff --git a/backend/tests/baserow/contrib/dashboard/search/test_dashboard_search_types.py b/backend/tests/baserow/contrib/dashboard/search/test_dashboard_search_types.py new file mode 100644 index 0000000000..d19ffe73ab --- /dev/null +++ b/backend/tests/baserow/contrib/dashboard/search/test_dashboard_search_types.py @@ -0,0 +1,28 @@ +import pytest + +from baserow.contrib.dashboard.search_types import DashboardSearchType +from baserow.core.search.data_types import SearchContext + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_dashboard_search_type_basic_functionality(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + dashboard = data_fixture.create_dashboard_application( + workspace=workspace, name="Test Dashboard" + ) + + search_type = DashboardSearchType() + + queryset = search_type.get_base_queryset(user, workspace) + assert dashboard in queryset + + search_context = SearchContext(query="Test", limit=10, offset=0) + search_results = search_type.get_search_queryset(user, workspace, search_context) + assert dashboard in search_results + + search_result = search_type.serialize_result(dashboard, user, workspace) + assert search_result.id == dashboard.id + assert search_result.title == "Test Dashboard" + assert search_result.type == "dashboard" diff --git a/backend/tests/baserow/contrib/dashboard/test_dashboard_application_types.py b/backend/tests/baserow/contrib/dashboard/test_dashboard_application_types.py index 8f2252f524..197be08a48 100644 --- a/backend/tests/baserow/contrib/dashboard/test_dashboard_application_types.py +++ b/backend/tests/baserow/contrib/dashboard/test_dashboard_application_types.py @@ -117,7 +117,7 @@ def test_dashboard_export_serialized_with_widgets(data_fixture): "filters": [], "id": dashboard_widget.data_source.service.id, "integration_id": integration.id, - "search_query": "", + "search_query": {"formula": "", "mode": "simple", "version": "0.1"}, "table_id": None, "type": "local_baserow_aggregate_rows", "view_id": None, @@ -135,7 +135,7 @@ def test_dashboard_export_serialized_with_widgets(data_fixture): "filters": [], "id": dashboard_widget_2.data_source.service.id, "integration_id": integration.id, - "search_query": "", + "search_query": {"formula": "", "mode": "simple", "version": "0.1"}, "table_id": table.id, "type": "local_baserow_aggregate_rows", "view_id": view.id, @@ -317,7 +317,7 @@ def test_dashboard_import_serialized_with_widgets(data_fixture): assert ds1_service.integration_id == integration.id assert ds1_service.aggregation_type == "" assert ds1_service.filter_type == "AND" - assert ds1_service.search_query == "" + assert ds1_service.search_query["formula"] == "" assert ds1_service.table_id is None assert ds1_service.view_id is None assert ds1_service.field_id is None @@ -333,7 +333,7 @@ def test_dashboard_import_serialized_with_widgets(data_fixture): assert ds2_service.integration_id == integration.id assert ds2_service.aggregation_type == "sum" assert ds2_service.filter_type == "AND" - assert ds2_service.search_query == "" + assert ds2_service.search_query["formula"] == "" assert ds2_service.table_id == table.id assert ds2_service.view_id == view.id assert ds2_service.field_id == number_field.id diff --git a/backend/tests/baserow/contrib/database/search/test_database_search_types.py b/backend/tests/baserow/contrib/database/search/test_database_search_types.py new file mode 100644 index 0000000000..45e2a0d2c6 --- /dev/null +++ b/backend/tests/baserow/contrib/database/search/test_database_search_types.py @@ -0,0 +1,219 @@ +import pytest + +from baserow.contrib.database.search.handler import SearchHandler +from baserow.contrib.database.search_types import ( + DatabaseSearchType, + FieldDefinitionSearchType, +) +from baserow.core.search.data_types import SearchContext + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_database_search_type_basic_functionality(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application( + workspace=workspace, name="Test Database" + ) + table = data_fixture.create_database_table(database=database, name="Table") + + search_type = DatabaseSearchType() + + queryset = search_type.get_base_queryset(user, workspace) + assert database in queryset + + search_context = SearchContext(query="Test", limit=10, offset=0) + search_results = search_type.get_search_queryset(user, workspace, search_context) + assert database in search_results + + search_result = search_type.serialize_result(database, user, workspace) + assert search_result.id == database.id + assert search_result.title == "Test Database" + assert search_result.type == "database" + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_database_search_excludes_trashed_workspaces(data_fixture): + user = data_fixture.create_user() + + workspace = data_fixture.create_workspace(user=user) + database1 = data_fixture.create_database_application( + workspace=workspace, + name="Normal Database", + ) + + database2 = data_fixture.create_database_application( + workspace=workspace, name="Trashed Database", trashed=True + ) + + context = SearchContext(query="Database", limit=10, offset=0) + + search_type = DatabaseSearchType() + results = search_type.execute_search(user, workspace, context) + + assert len(results) == 1 + assert results[0].id == database1.id + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_database_search_with_permissions(data_fixture): + user1 = data_fixture.create_user() + user2 = data_fixture.create_user() + + workspace = data_fixture.create_user_workspace(user=user1, permissions="MEMBER") + + database = data_fixture.create_database_application( + workspace=workspace.workspace, name="Protected Database" + ) + + search_type = DatabaseSearchType() + context = SearchContext(query="Protected", limit=10, offset=0) + + user1_results = search_type.execute_search(user1, workspace.workspace, context) + assert len(user1_results) == 1 + assert user1_results[0].id == database.id + + user2_results = search_type.execute_search(user2, workspace.workspace, context) + assert len(user2_results) == 0 + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_field_definition_search_type_basic_functionality(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Test Table") + field = data_fixture.create_text_field(table=table, name="Test Field") + field2 = data_fixture.create_text_field(table=table, name="Test Field 2") + + search_type = FieldDefinitionSearchType() + + context = SearchContext(query="Test Field", limit=10, offset=0) + + results = search_type.execute_search(user, workspace, context) + + assert len(results) == 2 + assert results[0].id == field.id + assert results[1].id == field2.id + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_field_definition_search_excludes_trashed_items(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Test Table") + field = data_fixture.create_text_field(table=table, name="Test Field") + field2 = data_fixture.create_text_field(table=table, name="Test Field 2") + field3 = data_fixture.create_text_field( + table=table, name="Test Field 3", trashed=True + ) + + search_type = FieldDefinitionSearchType() + + context = SearchContext(query="Test Field", limit=10, offset=0) + + results = search_type.execute_search(user, workspace, context) + + assert len(results) == 2 + assert results[0].id == field.id + assert results[1].id == field2.id + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_row_search_type_basic_functionality(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database, name="Test Table") + text_field = data_fixture.create_text_field(table=table, name="Text Field") + + from baserow.contrib.database.rows.handler import RowHandler + + row_handler = RowHandler() + row1_data = row_handler.create_rows( + user=user, table=table, rows_values=[{f"field_{text_field.id}": "Test content"}] + ) + row2_data = row_handler.create_rows( + user=user, + table=table, + rows_values=[{f"field_{text_field.id}": "Other content"}], + ) + + row1 = row1_data.created_rows[0] + row2 = row2_data.created_rows[0] + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table) + SearchHandler.process_search_data_updates(table) + + from baserow.core.search.handler import WorkspaceSearchHandler + + context = SearchContext(query="Test content", limit=10, offset=0) + + results, _ = WorkspaceSearchHandler().search_all_types(user, workspace, context) + + assert len(results) >= 1 + assert results[0].id == f"{table.id}_{row1.id}" + assert results[0].title == "Row #1" + assert results[0].subtitle == f"Row in {database.name} / {table.name}" + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_row_search_multiple_fields(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + + text_field1 = data_fixture.create_text_field(table=table, name="Field 1") + text_field2 = data_fixture.create_text_field(table=table, name="Field 2") + + from baserow.contrib.database.rows.handler import RowHandler + + row_handler = RowHandler() + row1_data = row_handler.create_rows( + user=user, + table=table, + rows_values=[ + { + f"field_{text_field1.id}": "Unique search term", + f"field_{text_field2.id}": "Other content", + } + ], + ) + + row2_data = row_handler.create_rows( + user=user, + table=table, + rows_values=[ + { + f"field_{text_field1.id}": "Different content", + f"field_{text_field2.id}": "Unique search term", + } + ], + ) + + row1 = row1_data.created_rows[0] + row2 = row2_data.created_rows[0] + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table) + SearchHandler.process_search_data_updates(table) + + from baserow.core.search.handler import WorkspaceSearchHandler + + context = SearchContext(query="Unique", limit=10, offset=0) + + results, _ = WorkspaceSearchHandler().search_all_types(user, workspace, context) + + assert len(results) >= 2 + assert results[0].id == f"{table.id}_{row1.id}" + assert results[1].id == f"{table.id}_{row2.id}" diff --git a/backend/tests/baserow/contrib/database/search/test_workspace_search_handler.py b/backend/tests/baserow/contrib/database/search/test_workspace_search_handler.py new file mode 100644 index 0000000000..3e8117bd84 --- /dev/null +++ b/backend/tests/baserow/contrib/database/search/test_workspace_search_handler.py @@ -0,0 +1,365 @@ +from unittest.mock import patch + +import pytest + +from baserow.contrib.database.search.handler import SearchHandler +from baserow.core.search.data_types import SearchContext +from baserow.core.search.handler import WorkspaceSearchHandler +from baserow.test_utils.helpers import defer_signals, setup_interesting_test_database + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_handler_basic_search_workflow(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + database = data_fixture.create_database_application( + workspace=workspace, name="Test Database" + ) + + result_data = WorkspaceSearchHandler().search_workspace( + user=user, workspace=workspace, query="Database", limit=10, offset=0 + ) + + assert "results" in result_data + assert "has_more" in result_data + assert len(result_data["results"]) == 1 + + first = result_data["results"][0] + assert first["id"] == str(database.id) + assert first["title"] == database.name + assert first["type"] == database.get_type().type + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_search_handler_query_count(data_fixture, django_assert_num_queries): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + data_fixture.create_database_application(workspace=workspace, name=f"Database 1") + handler = WorkspaceSearchHandler() + + def do_search(q: str): + return handler.search_workspace( + user=user, workspace=workspace, query=q, limit=100, offset=0 + ) + + with django_assert_num_queries(5): + result_data = handler.search_workspace( + user=user, workspace=workspace, query="Database", limit=10, offset=0 + ) + + assert len(result_data["results"]) == 1 + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_search_handler_with_pagination(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + databases = [] + for i in range(13): + databases.append( + data_fixture.create_database_application( + workspace=workspace, name=f"Search Database {i:02d}" + ) + ) + + handler = WorkspaceSearchHandler() + + page_1 = handler.search_workspace( + user=user, workspace=workspace, query="Search Database", limit=5, offset=0 + ) + + page_2 = handler.search_workspace( + user=user, workspace=workspace, query="Search Database", limit=6, offset=5 + ) + + assert len(page_1["results"]) == 5 + assert len(page_2["results"]) == 6 + + result1_ids = {str(r["id"]) for r in page_1["results"]} + result2_ids = {str(r["id"]) for r in page_2["results"]} + assert result1_ids.isdisjoint(result2_ids) + + assert page_1["has_more"] is True + assert page_2["has_more"] is True + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_search_handler_permission_filtering(data_fixture): + admin_user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=admin_user) + + non_member_user = data_fixture.create_user() + + data_fixture.create_database_application( + workspace=workspace, name="Shared Database" + ) + + handler = WorkspaceSearchHandler() + + admin_results = handler.search_workspace( + user=admin_user, + workspace=workspace, + query="Shared Database", + limit=10, + offset=0, + ) + + non_member_results = handler.search_workspace( + user=non_member_user, + workspace=workspace, + query="Shared Database", + limit=10, + offset=0, + ) + + assert len(admin_results["results"]) == 1 + assert len(non_member_results["results"]) == 0 + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_search_handler_priority_ordering(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + database = data_fixture.create_database_application( + workspace=workspace, name="Priority Test" + ) + table = data_fixture.create_database_table( + database=database, name="Priority Test Table" + ) + field = data_fixture.create_text_field(table=table, name="Priority Test Field") + + handler = WorkspaceSearchHandler() + result = handler.search_workspace( + user=user, workspace=workspace, query="Priority Test", limit=10, offset=0 + )["results"] + + assert len(result) == 3 + assert result[0]["type"] == "database" + assert result[1]["type"] == "database_table" + assert result[2]["type"] == "database_field" + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_search_context_creation(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + with patch.object(WorkspaceSearchHandler, "search_all_types") as mock_search: + mock_search.return_value = ([], False) + + WorkspaceSearchHandler().search_workspace( + user=user, workspace=workspace, query="test query", limit=15, offset=5 + ) + + assert mock_search.called + _, _, context = mock_search.call_args[0] + + assert isinstance(context, SearchContext) + assert context.query == "test query" + assert context.limit == 16 # limit+1 for has_more + assert context.offset == 5 + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_search_handler_has_more_logic(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + for i in range(10): + data_fixture.create_database_application( + workspace=workspace, name=f"HasMore Database {i:02d}" + ) + + handler = WorkspaceSearchHandler() + + result1 = handler.search_workspace( + user=user, workspace=workspace, query="HasMore Database", limit=5, offset=0 + ) + + result2 = handler.search_workspace( + user=user, workspace=workspace, query="HasMore Database", limit=5, offset=5 + ) + + result3 = handler.search_workspace( + user=user, workspace=workspace, query="HasMore Database", limit=5, offset=10 + ) + + assert result1["has_more"] is True + assert len(result1["results"]) == 5 + + assert result3["has_more"] is False + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_search_handler_result_serialization(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + database = data_fixture.create_database_application( + workspace=workspace, name="Serialization Test Database" + ) + + handler = WorkspaceSearchHandler() + result_data = handler.search_workspace( + user=user, workspace=workspace, query="Serialization Test", limit=10, offset=0 + )["results"] + + assert len(result_data) == 1 + assert result_data[0]["type"] == "database" + assert result_data[0]["id"] == str(database.id) + assert result_data[0]["title"] == database.name + assert result_data[0]["subtitle"] == "Database" + assert result_data[0]["metadata"] == {} + + +@pytest.mark.workspace_search +@pytest.mark.django_db +def test_search_handler_with_special_characters(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + databases = [ + data_fixture.create_database_application( + workspace=workspace, name="Database with & symbols" + ), + data_fixture.create_database_application( + workspace=workspace, name="Database with (parentheses)" + ), + data_fixture.create_database_application( + workspace=workspace, name="Database with 'quotes'" + ), + data_fixture.create_database_application( + workspace=workspace, name="Database with @#$ symbols" + ), + ] + + handler = WorkspaceSearchHandler() + + test_queries = [ + "&", + "(", + ")", + "'", + "@", + "#", + "$", + "symbols", + "parentheses", + "quotes", + ] + + for query in test_queries: + result_data = handler.search_workspace( + user=user, workspace=workspace, query=query, limit=10, offset=0 + ) + + assert "results" in result_data + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_row_search_handler_with_interesting_database(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + with defer_signals( + [ + "baserow.ws.tasks.broadcast_to_channel_group.delay", + "baserow.contrib.database.search.tasks.schedule_update_search_data.delay", + "baserow.contrib.database.search.tasks.update_search_data.delay", + "baserow.contrib.database.table.tasks.update_table_usage.delay", + ] + ): + database = setup_interesting_test_database( + data_fixture, user=user, workspace=workspace, name="db" + ) + + handler = WorkspaceSearchHandler() + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + for table in database.table_set.all(): + SearchHandler.initialize_missing_search_data(table) + SearchHandler.process_search_data_updates(table) + + def do_search(q: str): + return handler.search_workspace( + user=user, workspace=workspace, query=q, limit=100, offset=0 + ) + + def _row_results(r): + return [x for x in r["results"] if x["type"] == "database_row"] + + def _assert_row_shape(item): + assert "title" in item and item["title"].startswith("Row #") + assert "subtitle" in item and " / " in item["subtitle"] + md = item.get("metadata", {}) + for k in ["workspace_id", "database_id", "table_id", "row_id", "field_id"]: + assert k in md + + # Basic text + res = do_search("text") + import pprint + + pprint.pprint(res) + rows = _row_results(res) + assert len(rows) >= 1 + _assert_row_shape(rows[0]) + + # File visible_name from interesting table + res = do_search("a.txt") + rows = _row_results(res) + assert len(rows) >= 1 + _assert_row_shape(rows[0]) + + # URL/email/phone fragments + res = do_search("google.com") + rows = _row_results(res) + assert len(rows) >= 1 + _assert_row_shape(rows[0]) + + res = do_search("test@example.com") + rows = _row_results(res) + assert len(rows) >= 1 + _assert_row_shape(rows[0]) + + res = do_search("+4412345678") + rows = _row_results(res) + assert len(rows) >= 1 + _assert_row_shape(rows[0]) + + # Select/number/date fragments + res = do_search("Object") + rows = _row_results(res) + assert len(rows) >= 1 + _assert_row_shape(rows[0]) + + res = do_search("1.2") + rows = _row_results(res) + assert len(rows) >= 1 + _assert_row_shape(rows[0]) + + res = do_search("2020") + rows = _row_results(res) + assert len(rows) >= 1 + _assert_row_shape(rows[0]) + + # Linked rows created by helper + res = do_search("linked_row_1") + rows = _row_results(res) + assert len(rows) >= 1 + _assert_row_shape(rows[0]) + + # Negative control should produce no results + empty = do_search("__nohit__") + assert empty["results"] == [] diff --git a/backend/tests/baserow/contrib/integrations/core/test_core_http_request_service_type.py b/backend/tests/baserow/contrib/integrations/core/test_core_http_request_service_type.py index 0bd80f704a..a5f8ea1b58 100644 --- a/backend/tests/baserow/contrib/integrations/core/test_core_http_request_service_type.py +++ b/backend/tests/baserow/contrib/integrations/core/test_core_http_request_service_type.py @@ -240,9 +240,9 @@ def test_core_http_request_with_query_params( service_type = service.get_type() service.query_params.create( - key="test", value="""concat('test__', get('page_parameter.id'))""" + key="test", value="concat('test__', get('page_parameter.id'))" ) - service.query_params.create(key="test2", value="""'value'""") + service.query_params.create(key="test2", value="'value'") formula_context = {"page_parameter": {"id": 2}} dispatch_context = FakeDispatchContext(context=formula_context) @@ -305,7 +305,7 @@ def test_core_http_request_create(data_fixture): form_data=[{"key": "key", "value": "'value'"}], ) - assert service.url == "'http://example.com'" + assert service.url["formula"] == "'http://example.com'" assert service.headers.count() == 1 assert service.headers.first().key == "key" assert service.query_params.count() == 1 @@ -332,7 +332,7 @@ def test_core_http_request_update(data_fixture): service.refresh_from_db() - assert service.url == "'http://another.url'" + assert service.url["formula"] == "'http://another.url'" assert service.headers.count() == 1 assert service.headers.first().key == "key" assert service.query_params.count() == 1 @@ -343,7 +343,7 @@ def test_core_http_request_update(data_fixture): @pytest.mark.django_db def test_core_http_request_formula_generator(): - service = service = ServiceHandler().create_service( + service = ServiceHandler().create_service( CoreHTTPRequestServiceType(), url="'http://example.com'", body_content="'body'", @@ -351,17 +351,15 @@ def test_core_http_request_formula_generator(): query_params=[{"key": "key", "value": "'value2'"}], form_data=[{"key": "key", "value": "'value3'"}], ) - service_type = service.get_type() formulas = list(service_type.formula_generator(service)) - assert formulas == [ - "'body'", - "'http://example.com'", - "'value3'", - "'value1'", - "'value2'", + {"mode": "simple", "version": "0.1", "formula": "'body'"}, + {"mode": "simple", "version": "0.1", "formula": "'http://example.com'"}, + {"mode": "simple", "version": "0.1", "formula": "'value3'"}, + {"mode": "simple", "version": "0.1", "formula": "'value1'"}, + {"mode": "simple", "version": "0.1", "formula": "'value2'"}, ] @@ -376,7 +374,7 @@ def test_core_http_request_extract_properties(data_fixture): @pytest.mark.django_db def test_core_http_request_export_import(): - service = service = ServiceHandler().create_service( + service = ServiceHandler().create_service( CoreHTTPRequestServiceType(), url="'http://example.com'", body_content="'body'", @@ -394,19 +392,34 @@ def test_core_http_request_export_import(): "integration_id": None, "type": "http_request", "http_method": "GET", - "url": "'http://example.com'", - "headers": [{"key": "key", "value": "'value1'"}], - "query_params": [{"key": "key", "value": "'value2'"}], - "form_data": [{"key": "key", "value": "'value3'"}], + "url": {"formula": "'http://example.com'", "version": "0.1", "mode": "simple"}, + "headers": [ + { + "key": "key", + "value": {"formula": "'value1'", "version": "0.1", "mode": "simple"}, + } + ], + "query_params": [ + { + "key": "key", + "value": {"formula": "'value2'", "version": "0.1", "mode": "simple"}, + } + ], + "form_data": [ + { + "key": "key", + "value": {"formula": "'value3'", "version": "0.1", "mode": "simple"}, + } + ], "body_type": "none", - "body_content": "'body'", + "body_content": {"formula": "'body'", "version": "0.1", "mode": "simple"}, "timeout": 30, "sample_data": None, } new_service = service_type.import_serialized(None, serialized, {}, lambda x, d: x) - assert new_service.url == "'http://example.com'" + assert new_service.url["formula"] == "'http://example.com'" assert new_service.headers.count() == 1 assert new_service.query_params.count() == 1 assert new_service.form_data.count() == 1 @@ -414,7 +427,7 @@ def test_core_http_request_export_import(): @pytest.mark.django_db def test_core_http_request_generate_schema(): - service = service = ServiceHandler().create_service( + service = ServiceHandler().create_service( CoreHTTPRequestServiceType(), url="'http://example.com'", body_content="'body'", @@ -491,7 +504,6 @@ def test_core_http_request_generate_schema_with_sample_data(): service = ServiceHandler().create_service( CoreHTTPRequestServiceType(), url="'http://example.com'", - body_content=api_response, headers=[{"key": "key", "value": "'value1'"}], query_params=[{"key": "key", "value": "'value2'"}], form_data=[{"key": "key", "value": "'value3'"}], diff --git a/backend/tests/baserow/contrib/integrations/core/test_core_router_service_type.py b/backend/tests/baserow/contrib/integrations/core/test_core_router_service_type.py index af4fc2489d..388cf4129b 100644 --- a/backend/tests/baserow/contrib/integrations/core/test_core_router_service_type.py +++ b/backend/tests/baserow/contrib/integrations/core/test_core_router_service_type.py @@ -43,7 +43,7 @@ def test_update_core_router_service(data_fixture): assert result.service.edges.count() == 1 edge = result.service.edges.first() assert edge.label == "Branch name" - assert edge.condition == "'true'" + assert edge.condition["formula"] == "'true'" @pytest.mark.django_db diff --git a/backend/tests/baserow/contrib/integrations/core/test_smtp_email_service_type.py b/backend/tests/baserow/contrib/integrations/core/test_smtp_email_service_type.py index f04e211e61..92e55c36cb 100644 --- a/backend/tests/baserow/contrib/integrations/core/test_smtp_email_service_type.py +++ b/backend/tests/baserow/contrib/integrations/core/test_smtp_email_service_type.py @@ -394,14 +394,30 @@ def test_serialized_export_import(data_fixture): "integration_id": smtp_integration.id, "sample_data": None, "type": "smtp_email", - "from_email": "'sender@example.com'", - "from_name": "'Test Sender'", - "to_emails": "'recipient@example.com'", - "cc_emails": "'cc@example.com'", - "bcc_emails": "'bcc@example.com'", - "subject": "'Test Subject'", + "from_email": { + "formula": "'sender@example.com'", + "mode": "simple", + "version": "0.1", + }, + "from_name": {"formula": "'Test Sender'", "mode": "simple", "version": "0.1"}, + "to_emails": { + "formula": "'recipient@example.com'", + "mode": "simple", + "version": "0.1", + }, + "cc_emails": { + "formula": "'cc@example.com'", + "mode": "simple", + "version": "0.1", + }, + "bcc_emails": { + "formula": "'bcc@example.com'", + "mode": "simple", + "version": "0.1", + }, + "subject": {"formula": "'Test Subject'", "mode": "simple", "version": "0.1"}, "body_type": "html", - "body": "'Test body'", + "body": {"formula": "'Test body'", "mode": "simple", "version": "0.1"}, } assert serialized == expected_serialized @@ -410,14 +426,14 @@ def test_serialized_export_import(data_fixture): None, serialized, {smtp_integration.id: smtp_integration}, lambda x, d: x ) - assert new_service.from_email == "'sender@example.com'" - assert new_service.from_name == "'Test Sender'" - assert new_service.to_emails == "'recipient@example.com'" - assert new_service.cc_emails == "'cc@example.com'" - assert new_service.bcc_emails == "'bcc@example.com'" - assert new_service.subject == "'Test Subject'" + assert new_service.from_email["formula"] == "'sender@example.com'" + assert new_service.from_name["formula"] == "'Test Sender'" + assert new_service.to_emails["formula"] == "'recipient@example.com'" + assert new_service.cc_emails["formula"] == "'cc@example.com'" + assert new_service.bcc_emails["formula"] == "'bcc@example.com'" + assert new_service.subject["formula"] == "'Test Subject'" assert new_service.body_type == "html" - assert new_service.body == "'Test body'" + assert new_service.body["formula"] == "'Test body'" @pytest.mark.django_db @@ -435,11 +451,31 @@ def test_smtp_email_service_create_update(data_fixture): body_type="plain", ) - assert service.from_email == "'sender@example.com'" - assert service.from_name == "'Test Sender'" - assert service.to_emails == "'recipient@example.com'" - assert service.subject == "'Test Subject'" - assert service.body == "'Test body'" + assert service.from_email == { + "mode": "simple", + "formula": "'sender@example.com'", + "version": "0.1", + } + assert service.from_name == { + "mode": "simple", + "formula": "'Test Sender'", + "version": "0.1", + } + assert service.to_emails == { + "mode": "simple", + "formula": "'recipient@example.com'", + "version": "0.1", + } + assert service.subject == { + "mode": "simple", + "formula": "'Test Subject'", + "version": "0.1", + } + assert service.body == { + "mode": "simple", + "formula": "'Test body'", + "version": "0.1", + } assert service.body_type == "plain" assert service.integration_id == smtp_integration.id @@ -454,6 +490,7 @@ def test_smtp_email_service_create_update(data_fixture): service.refresh_from_db() - assert service.from_email == "'updated@example.com'" - assert service.subject == "'Updated Subject'" + assert service.from_email["formula"] == "'updated@example.com'" + assert service.subject["formula"] == "'Updated Subject'" + assert service.body["formula"] == "'Test body'" assert service.body_type == "html" diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_delete_row_service_type.py b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_delete_row_service_type.py index 8d63a2be89..77f47ae751 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_delete_row_service_type.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_delete_row_service_type.py @@ -37,7 +37,7 @@ def test_create_local_baserow_delete_row_service(data_fixture): ) assert service.table.id == table.id - assert service.row_id == "" + assert service.row_id["formula"] == "" @pytest.mark.django_db @@ -65,7 +65,7 @@ def test_update_local_baserow_delete_row_service(data_fixture): service.refresh_from_db() - assert service.row_id == "1" + assert service.row_id["formula"] == "1" @pytest.mark.django_db diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_get_row_service_type.py b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_get_row_service_type.py index 158feb147c..89db1a1fd1 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_get_row_service_type.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_get_row_service_type.py @@ -50,7 +50,7 @@ def test_create_local_baserow_get_row_service(data_fixture): assert service.view.id == view.id assert service.table.id == view.table_id - assert service.row_id == "1" + assert service.row_id["formula"] == "1" @pytest.mark.django_db @@ -177,7 +177,7 @@ def test_update_local_baserow_get_row_service(data_fixture): service.refresh_from_db() assert service.specific.table is None - assert service.specific.search_query == "" + assert service.specific.search_query["formula"] == "" assert service.specific.integration is None @@ -510,7 +510,7 @@ def test_import_datasource_provider_formula_using_get_row_service_containing_no_ duplicated_element = duplicated_page.element_set.first() duplicated_data_source = duplicated_page.datasource_set.first() assert ( - duplicated_element.specific.placeholder + duplicated_element.specific.placeholder["formula"] == f"get('data_source.{duplicated_data_source.id}')" ) @@ -588,7 +588,6 @@ def test_import_formula_local_baserow_get_row_user_service_type(data_fixture): duplicated_page = PageService().duplicate_page(user, page) data_source2 = duplicated_page.datasource_set.first() - id_mapping = {} id_mapping = {"builder_data_sources": {data_source.id: data_source2.id}} from baserow.contrib.builder.formula_importer import import_formula @@ -598,14 +597,20 @@ def test_import_formula_local_baserow_get_row_user_service_type(data_fixture): ) # See the docstring to understand why these formulas looks truncated. - assert imported_service.search_query == f"get('data_source.{data_source2.id}')" - assert imported_service.row_id == f"get('data_source.{data_source2.id}')" + assert ( + imported_service.search_query["formula"] + == f"get('data_source.{data_source2.id}')" + ) + assert imported_service.row_id["formula"] == f"get('data_source.{data_source2.id}')" imported_service_filter = imported_service.service_filters.get(order=0) - assert imported_service_filter.value == f"get('data_source.{data_source2.id}')" + assert ( + imported_service_filter.value["formula"] + == f"get('data_source.{data_source2.id}')" + ) imported_service_filter = imported_service.service_filters.get(order=1) - assert imported_service_filter.value == "FooServiceFilter" + assert imported_service_filter.value["formula"] == "FooServiceFilter" @pytest.mark.django_db diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_list_rows_service_type.py b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_list_rows_service_type.py index 2f269569f1..78c9b26dab 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_list_rows_service_type.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_list_rows_service_type.py @@ -201,7 +201,7 @@ def test_update_local_baserow_list_rows_service(data_fixture): service.refresh_from_db() assert service.specific.view is None - assert service.specific.search_query == "" + assert service.specific.search_query["formula"] == "" assert service.specific.integration is None @@ -727,7 +727,7 @@ def test_import_datasource_provider_formula_using_list_rows_service_containing_n duplicated_element = duplicated_page.element_set.first() duplicated_data_source = duplicated_page.datasource_set.first() assert ( - duplicated_element.specific.placeholder + duplicated_element.specific.placeholder["formula"] == f"get('data_source.{duplicated_data_source.id}')" ) @@ -802,19 +802,19 @@ def test_import_formula_local_baserow_list_rows_user_service_type(data_fixture): integration, exported, id_mapping, import_formula=import_formula ) assert ( - imported_service.search_query + imported_service.search_query["formula"] == f"get('data_source.{data_source2.id}.0.{text_field.db_column}')" ) imported_service_filter_0 = imported_service.service_filters.get(order=0) assert ( - imported_service_filter_0.value + imported_service_filter_0.value["formula"] == f"get('data_source.{data_source2.id}.0.{text_field.db_column}')" ) assert imported_service_filter_0.value_is_formula is True imported_service_filter_1 = imported_service.service_filters.get(order=1) - assert imported_service_filter_1.value == "fooValue" + assert imported_service_filter_1.value["formula"] == "fooValue" assert imported_service_filter_1.value_is_formula is False diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_upsert_row_service_type.py b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_upsert_row_service_type.py index 815d3584c4..a348a9dc8d 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_upsert_row_service_type.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_upsert_row_service_type.py @@ -19,6 +19,9 @@ from baserow.contrib.integrations.local_baserow.service_types import ( LocalBaserowUpsertRowServiceType, ) +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE from baserow.core.handler import CoreHandler from baserow.core.registries import ImportExportConfig from baserow.core.services.exceptions import ( @@ -588,11 +591,19 @@ def test_local_baserow_upsert_row_service_resolve_service_formulas( dispatch_context = FakeDispatchContext() # We're creating a row. - assert service.row_id == "" + service.row_id = BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) assert service_type.resolve_service_formulas(service, dispatch_context) == {} # We're updating a row, but the ID isn't an integer - service.row_id = "'horse'" + service.row_id = BaserowFormulaObject( + formula="'horse'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) with pytest.raises(InvalidContextContentDispatchException) as exc: service_type.resolve_service_formulas(service, dispatch_context) @@ -601,13 +612,6 @@ def test_local_baserow_upsert_row_service_resolve_service_formulas( "The value must be an integer or convertible to an integer." ) - # We're updating a row, but the ID formula can't be resolved - service.row_id = "'horse" - with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc: - service_type.resolve_service_formulas(service, dispatch_context) - - assert exc.value.args[0].startswith('Error in formula for "row_id"') - @pytest.mark.django_db def test_local_baserow_upsert_row_service_prepare_values(data_fixture): @@ -679,11 +683,11 @@ def test_export_import_local_baserow_upsert_row_service( assert imported_field_mapping.field == imported_field assert ( - imported_field_mapping.value + imported_field_mapping.value["formula"] == f"get('data_source.{imported_data_source.id}.{imported_field.db_column}')" ) assert ( - imported_upsert_row_service.row_id + imported_upsert_row_service.row_id["formula"] == f"get('data_source.{imported_data_source.id}.{imported_field.db_column}')" ) diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/test_integration_signals.py b/backend/tests/baserow/contrib/integrations/local_baserow/test_integration_signals.py index 6a1756d80b..e5350ce755 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/test_integration_signals.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/test_integration_signals.py @@ -165,10 +165,10 @@ def test_local_baserow_service_filters_delete_when_field_type_changes_to_incompa number_field = data_fixture.create_number_field(table=table) service = data_fixture.create_local_baserow_list_rows_service(table=table) link_row_filter = data_fixture.create_local_baserow_table_service_filter( - service=service, type="link_row_has", field=link_field, value=1, order=0 + service=service, type="link_row_has", field=link_field, value="'1'", order=0 ) number_filter = data_fixture.create_local_baserow_table_service_filter( - service=service, field=number_field, value=25, order=1 + service=service, field=number_field, value="'25'", order=1 ) # Converting a `link_row` to a `text` field type will result in an # incompatible filter, so it'll be destroyed. diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/test_mixins.py b/backend/tests/baserow/contrib/integrations/local_baserow/test_mixins.py index cb0a86f782..60e474aecc 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/test_mixins.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/test_mixins.py @@ -17,6 +17,7 @@ from baserow.contrib.integrations.local_baserow.service_types import ( LocalBaserowTableServiceType, ) +from baserow.core.formula import BaserowFormulaObject from baserow.core.handler import CoreHandler from baserow.core.registries import ImportExportConfig from baserow.core.services.exceptions import ( @@ -131,7 +132,7 @@ def test_local_baserow_table_service_filterable_mixin_import_export(data_fixture page=page, table=table, integration=integration ) data_fixture.create_local_baserow_table_service_filter( - service=data_source.service, field=text_field, value="foobar", order=0 + service=data_source.service, field=text_field, value="'foobar'", order=0 ) data_fixture.create_local_baserow_table_service_filter( service=data_source.service, field=text_field, value="123", order=1 @@ -139,7 +140,7 @@ def test_local_baserow_table_service_filterable_mixin_import_export(data_fixture data_fixture.create_local_baserow_table_service_filter( service=data_source.service, field=single_select_field, - value=single_option.id, + value=str(single_option.id), order=2, ) data_fixture.create_builder_table_element( @@ -185,11 +186,21 @@ def test_local_baserow_table_service_filterable_mixin_import_export(data_fixture ] assert imported_filters == [ - {"field_id": imported_text_field.id, "value": "foobar"}, - {"field_id": imported_text_field.id, "value": "123"}, + { + "field_id": imported_text_field.id, + "value": {"mode": "simple", "version": "0.1", "formula": "'foobar'"}, + }, + { + "field_id": imported_text_field.id, + "value": {"mode": "simple", "version": "0.1", "formula": "123"}, + }, { "field_id": imported_single_select_field.id, - "value": str(imported_select_option.id), + "value": { + "mode": "simple", + "version": "0.1", + "formula": str(imported_select_option.id), + }, }, ] @@ -392,7 +403,9 @@ def test_local_baserow_table_service_searchable_mixin_get_table_queryset( ] == [alessia.id, alex.id, alastair.id, alexandra.id] # Add a service level search query - service.search_query = "'Ale'" + service.search_query = BaserowFormulaObject( + formula="'Ale'", mode="simple", version="0.1" + ) assert [ row.id diff --git a/backend/tests/baserow/core/search/test_workspace_search_registry_pagination.py b/backend/tests/baserow/core/search/test_workspace_search_registry_pagination.py new file mode 100644 index 0000000000..123f88eaeb --- /dev/null +++ b/backend/tests/baserow/core/search/test_workspace_search_registry_pagination.py @@ -0,0 +1,181 @@ +import pytest + +from baserow.core.search.handler import WorkspaceSearchHandler + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_search_all_types_pagination_within_single_type(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + # Create multiple applications to test pagination + apps = [] + for i in range(50): + app = data_fixture.create_database_application( + workspace=workspace, name=f"Database {i:02d}" + ) + apps.append(app) + + handler = WorkspaceSearchHandler() + + # Test pagination using search_workspace method + result_data = handler.search_workspace( + user=user, workspace=workspace, query="Database", limit=10, offset=5 + ) + + # Should return 10 results starting from offset 5 + assert len(result_data["results"]) == 10 + assert all(result["type"] == "database" for result in result_data["results"]) + + result_ids = [result["id"] for result in result_data["results"]] + expected_ids = [str(apps[i].id) for i in range(5, 15)] # offset 5, limit 10 + assert result_ids == expected_ids + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_search_all_types_pagination_skip_entire_type(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + # Create applications with different priorities by creating them in different order + # This will help us test the priority-based ordering + app1 = data_fixture.create_database_application( + workspace=workspace, name="First Database" + ) + app2 = data_fixture.create_database_application( + workspace=workspace, name="Second Database" + ) + app3 = data_fixture.create_database_application( + workspace=workspace, name="Third Database" + ) + + handler = WorkspaceSearchHandler() + + # Test with offset that should skip some results + result_data = handler.search_workspace( + user=user, workspace=workspace, query="Database", limit=2, offset=1 + ) + + # Should return 2 results starting from offset 1 + assert len(result_data["results"]) == 2 + assert all(result["type"] == "database" for result in result_data["results"]) + + # Results should be ordered by object_id (since all have same priority) + result_ids = [result["id"] for result in result_data["results"]] + expected_ids = [str(app.id) for app in [app1, app2, app3][1:3]] # offset 1, limit 2 + assert result_ids == expected_ids + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_search_all_types_pagination_no_results(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + # Create a few applications + data_fixture.create_database_application(workspace=workspace, name="Database 1") + data_fixture.create_database_application(workspace=workspace, name="Database 2") + + handler = WorkspaceSearchHandler() + + # Test with offset beyond available results + result_data = handler.search_workspace( + user=user, workspace=workspace, query="Database", limit=5, offset=10 + ) + + # Should return no results + assert len(result_data["results"]) == 0 + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_search_all_types_pagination_limit_reached(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + # Create multiple applications + apps = [] + for i in range(10): + app = data_fixture.create_database_application( + workspace=workspace, name=f"Database {i:02d}" + ) + apps.append(app) + + handler = WorkspaceSearchHandler() + + # Test with limit that should return exactly the limit + result_data = handler.search_workspace( + user=user, workspace=workspace, query="Database", limit=5, offset=0 + ) + + # Should return exactly 5 results + assert len(result_data["results"]) == 5 + assert all(result["type"] == "database" for result in result_data["results"]) + + # Results should be ordered by object_id + result_ids = [result["id"] for result in result_data["results"]] + expected_ids = [str(apps[i].id) for i in range(5)] # first 5 apps + assert result_ids == expected_ids + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_search_all_types_pagination_with_different_priorities(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + # Create applications - they all have the same priority (10) by default + app1 = data_fixture.create_database_application(workspace=workspace, name="App 1") + app2 = data_fixture.create_database_application(workspace=workspace, name="App 2") + app3 = data_fixture.create_database_application(workspace=workspace, name="App 3") + + handler = WorkspaceSearchHandler() + + # Test pagination + result_data = handler.search_workspace( + user=user, workspace=workspace, query="App", limit=2, offset=1 + ) + + # Should return 2 results starting from offset 1 + assert len(result_data["results"]) == 2 + assert all(result["type"] == "database" for result in result_data["results"]) + + # Results should be ordered by object_id (since all have same priority) + result_ids = [result["id"] for result in result_data["results"]] + expected_ids = [str(app.id) for app in [app1, app2, app3][1:3]] # offset 1, limit 2 + assert result_ids == expected_ids + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_search_all_types_pagination_edge_cases(data_fixture): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + # Create a single application + app = data_fixture.create_database_application( + workspace=workspace, name="Single Database" + ) + + handler = WorkspaceSearchHandler() + + # Test with limit 0 + result_data = handler.search_workspace( + user=user, workspace=workspace, query="Database", limit=0, offset=0 + ) + assert len(result_data["results"]) == 0 + + # Test with offset 0, limit 1 + result_data = handler.search_workspace( + user=user, workspace=workspace, query="Database", limit=1, offset=0 + ) + assert len(result_data["results"]) == 1 + assert result_data["results"][0]["id"] == str(app.id) + + # Test with offset 1, limit 1 (should return no results) + result_data = handler.search_workspace( + user=user, workspace=workspace, query="Database", limit=1, offset=1 + ) + assert len(result_data["results"]) == 0 diff --git a/backend/tests/baserow/core/service/test_service_handler.py b/backend/tests/baserow/core/service/test_service_handler.py index 0dee450596..917a78d372 100644 --- a/backend/tests/baserow/core/service/test_service_handler.py +++ b/backend/tests/baserow/core/service/test_service_handler.py @@ -78,7 +78,7 @@ def test_update_service(data_fixture): ) assert service_updated.service.view.id == view.id - assert service_updated.service.search_query == search_query + assert service_updated.service.search_query["formula"] == search_query @pytest.mark.django_db diff --git a/backend/tests/baserow/core/test_core_baserow_formula_migration.py b/backend/tests/baserow/core/test_core_baserow_formula_migration.py new file mode 100644 index 0000000000..0370d37e3d --- /dev/null +++ b/backend/tests/baserow/core/test_core_baserow_formula_migration.py @@ -0,0 +1,100 @@ +import json +from unittest.mock import patch + +from django.db import connection + +import pytest + +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_SIMPLE, + BaserowFormulaMinified, +) + + +def get_raw_table_value(field_name, table_name, pk) -> str: + with connection.cursor() as cursor: + cursor.execute( + f"SELECT {field_name} FROM {table_name} WHERE service_ptr_id = %s", + [pk], + ) + return cursor.fetchone()[0] + + +@pytest.mark.django_db +@patch("baserow.core.formula.field.FormulaField.db_type", return_value="text") +def test_create_text_formula_field_value(mock_db_type, data_fixture): + # Create a service with a raw formula string. + service = data_fixture.create_core_http_request_service(url="'http://google.com'") + assert service.url == BaserowFormulaObject( + formula="'http://google.com'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + raw_url = get_raw_table_value("url", service._meta.db_table, service.id) + assert json.loads(raw_url) == BaserowFormulaMinified( + f="'http://google.com'", + m=BASEROW_FORMULA_MODE_SIMPLE, + v=BASEROW_FORMULA_VERSION_INITIAL, + ) + + # Create a service with a formula context. + service = data_fixture.create_core_http_request_service( + url=BaserowFormulaObject( + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + formula="'http://google.com'", + ) + ) + assert service.url == BaserowFormulaObject( + formula="'http://google.com'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + raw_url = get_raw_table_value("url", service._meta.db_table, service.id) + assert json.loads(raw_url) == BaserowFormulaMinified( + f="'http://google.com'", + m=BASEROW_FORMULA_MODE_SIMPLE, + v=BASEROW_FORMULA_VERSION_INITIAL, + ) + + +@pytest.mark.django_db +@patch("baserow.core.formula.field.FormulaField.db_type", return_value="text") +def test_update_text_formula_field_value(mock_db_type, data_fixture): + # Update a service with a raw formula string. + service = data_fixture.create_core_http_request_service() + service.url = "'http://google.com'" + service.save() + assert service.url == BaserowFormulaObject( + formula="'http://google.com'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + raw_url = get_raw_table_value("url", service._meta.db_table, service.id) + assert json.loads(raw_url) == BaserowFormulaMinified( + f="'http://google.com'", + m=BASEROW_FORMULA_MODE_SIMPLE, + v=BASEROW_FORMULA_VERSION_INITIAL, + ) + + # Update a service with a formula context. + service = data_fixture.create_core_http_request_service() + service.url = BaserowFormulaObject( + formula="'http://google.com'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + service.save() + assert service.url == BaserowFormulaObject( + formula="'http://google.com'", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ) + raw_url = get_raw_table_value("url", service._meta.db_table, service.id) + assert json.loads(raw_url) == BaserowFormulaMinified( + f="'http://google.com'", + m=BASEROW_FORMULA_MODE_SIMPLE, + v=BASEROW_FORMULA_VERSION_INITIAL, + ) diff --git a/changelog/entries/unreleased/feature/3732_ability_to_find_items_anywhere_in_the_workspace.json b/changelog/entries/unreleased/feature/3732_ability_to_find_items_anywhere_in_the_workspace.json new file mode 100644 index 0000000000..ddd062c57b --- /dev/null +++ b/changelog/entries/unreleased/feature/3732_ability_to_find_items_anywhere_in_the_workspace.json @@ -0,0 +1,8 @@ +{ + "type": "feature", + "message": "Ability to find items anywhere in the workspace", + "domain": "database", + "issue_number": 3732, + "bullet_points": [], + "created_at": "2025-10-09" +} \ No newline at end of file diff --git a/docs/technical/workspace-search.md b/docs/technical/workspace-search.md new file mode 100644 index 0000000000..74bb7cc96b --- /dev/null +++ b/docs/technical/workspace-search.md @@ -0,0 +1,107 @@ +## Workspace Search + +This document explains how workspace-wide search works, how results are combined, and how to add a new searchable type. + +### Overview + +- **Backend**: Each searchable type implements a standardized queryset which is UNION ALL'ed together and globally ordered. See `backend/src/baserow/core/search/registries.py` and `backend/src/baserow/core/search/handler.py`. +- **API**: `GET /api/search/workspace/{workspace_id}/?query=...&limit=...&offset=...` returns a flat, priority-ordered list. See `backend/src/baserow/api/search/views.py` and `backend/src/baserow/api/search/urls.py`. +- **Frontend**: `web-frontend/modules/core/store/workspaceSearch.js` calls the API via `modules/core/services/workspaceSearch.js` and powers `modules/core/components/workspace/WorkspaceSearchModal.vue`. + +### Data model for combined results + +All types contribute rows with the same fields (see `backend/src/baserow/core/search/constants.py`): + +- **search_type**: unique type name (e.g. `table`, `view`, `row`). +- **object_id**: string id of the object. +- **sort_key**: deterministic ordering key within a type (e.g. id). +- **rank**: optional relevance score (higher is better). +- **priority**: type-level priority (lower first) to group more important types earlier. +- **title**: primary display label. +- **subtitle**: optional secondary label. +- **payload**: optional JSON for extra fields (description, timestamps, etc.). + +Response items are returned as `SearchResult` dicts (see `backend/src/baserow/core/search/data_types.py`). + +### Query plan and ordering + +1. Each type builds a queryset filtered by permissions and `query`. +2. Each queryset is annotated to the standard fields and projected to the union schema. +3. All type querysets are combined with `UNION ALL`. +4. Global ordering is applied: `priority ASC`, `rank DESC NULLS LAST`, `sort_key ASC`, `object_id ASC`. +5. Global pagination is applied: `offset`, `limit + 1` is used to detect `has_more`. +6. Per-type postprocessing can enrich results in bulk before they are flattened back into original order. + +### Backend components + +- `WorkspaceSearchRegistry` (registry of types): + - Calls each type's `get_union_values_queryset(user, workspace, context)` to build the union. + - Applies global order and pagination. + - Groups rows by `search_type` and calls `postprocess(rows)` per type. +- `SearchableItemType` (base class for a type): + - Implement `get_search_queryset(user, workspace, context)` to return a base queryset filtered by permissions and query. + - Optionally override `get_union_values_queryset(...)` to customize annotations to the standard fields. + - Optionally override `postprocess(rows)` to batch-enrich results. + - Optionally implement `serialize_result(...)` if using the direct (non-union) path. +- `WorkspaceSearchHandler.search_workspace(...)` orchestrates registry search and returns `{ results, has_more }`. + +### API + +- Endpoint: `GET /api/search/workspace/{workspace_id}/` +- Query params: + - `query` (string, required) + - `limit` (int, default 20) + - `offset` (int, default 0) +- Response: + - `results`: array of `{ type, id, title, subtitle?, description?, metadata?, created_on?, updated_on? }` + - `has_more`: boolean + +### Frontend flow + +- Store: `modules/core/store/workspaceSearch.js` + - Action `search({ workspaceId, searchTerm, limit, offset, append })` calls the API and merges results. + - Getters provide result counts and filtering by `type`. +- Service: `modules/core/services/workspaceSearch.js` exposes `search(workspaceId, params)`. +- UI: `modules/core/components/workspace/WorkspaceSearchModal.vue` handles input, debounced requests, infinite scroll, and navigation. + +### Infinite scroll and pagination + +- Backend: + - The handler requests `limit + 1` rows to detect if there are more results beyond the current page. + - If more than `limit` rows are returned, it sets `has_more = true` and trims the list to `limit` before responding. +- Frontend: + - Reads `has_more` from the response and stores it (e.g. `hasMoreResults`). + - Uses the current total result count as the next `offset` when loading more. + - Calls the same search action with `append: true` and a page-sized `limit` for subsequent loads. + - Triggers load-more when the scroll container approaches the bottom threshold. + +### Adding a new search type + +1. **Create a new type class** + - Subclass `SearchableItemType` in an appropriate module (e.g. `backend/src/baserow//search/types.py`). + - Set `type` (unique string), `name` (human-readable), and optional `priority` (lower shows earlier globally). + - Implement `get_search_queryset(user, workspace, context)`: + - Filter to objects inside `workspace` the `user` can see. + - Apply the `context.query` filter (ILIKE/tsvector/etc.). + - Do not apply limit/offset here. + - Optionally override `get_union_values_queryset(...)` to annotate the standard fields: + - Ensure you provide: `search_type`, `object_id` (cast to text), `sort_key`, `rank` (nullable), `priority`, `title`, `subtitle`, `payload` (JSON). + - Optionally override `postprocess(rows)` to bulk load related data and enhance titles/subtitles/payloads. + +2. **Register the type** + - Import your type and register it with `workspace_search_registry.register(MyType())` at app ready/init time (e.g. in your app `ready()` or registry module). + +3. **Backend tests** + - Add tests covering permission filtering, query matching, ordering, pagination, and `postprocess` behavior. + +4. **Frontend (optional)** + - If needed, update UI rendering to display new type-specific metadata (the store already accepts any `type`). + +### Tips + +- Use deterministic `sort_key` within your type to avoid jitter between pages. +- Provide a sensible `priority` so critical types appear earlier. +- If you compute a relevance `rank`, higher values should mean more relevant. +- Keep per-row work out of query execution; prefer `postprocess(rows)` for batched enrichment. + + diff --git a/e2e-tests/tests/builder/elements/buttonElement.spec.ts b/e2e-tests/tests/builder/elements/buttonElement.spec.ts index b9d0600f04..721f8e0f6d 100644 --- a/e2e-tests/tests/builder/elements/buttonElement.spec.ts +++ b/e2e-tests/tests/builder/elements/buttonElement.spec.ts @@ -20,7 +20,7 @@ test.describe("Builder page button element test suite", () => { await builderElementModal.addElementByName("Button"); await expect( - page.locator(".button-element").getByText("Missing button text...") + page.locator(".button-element").getByText("Empty button text...") ).toBeVisible(); }); diff --git a/e2e-tests/tests/builder/elements/headingElement.spec.ts b/e2e-tests/tests/builder/elements/headingElement.spec.ts index 90fcc75ee1..2057613734 100644 --- a/e2e-tests/tests/builder/elements/headingElement.spec.ts +++ b/e2e-tests/tests/builder/elements/headingElement.spec.ts @@ -22,7 +22,7 @@ test.describe("Builder page heading element test suite", () => { await builderElementModal.addElementByName("Heading"); await expect( - page.locator(".heading-element").getByText("Missing title...") + page.locator(".heading-element").getByText("Empty title...") ).toBeVisible(); }); diff --git a/enterprise/backend/src/baserow_enterprise/builder/elements/element_types.py b/enterprise/backend/src/baserow_enterprise/builder/elements/element_types.py index 277da66cc5..44e5311cdf 100644 --- a/enterprise/backend/src/baserow_enterprise/builder/elements/element_types.py +++ b/enterprise/backend/src/baserow_enterprise/builder/elements/element_types.py @@ -9,7 +9,12 @@ from baserow.contrib.builder.elements.registries import ElementType from baserow.contrib.builder.pages.handler import PageHandler from baserow.contrib.builder.types import ElementDict -from baserow.core.formula.types import BaserowFormula +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import ( + BASEROW_FORMULA_MODE_SIMPLE, + BaserowFormula, + BaserowFormulaObject, +) from baserow.core.services.dispatch_context import DispatchContext from baserow.core.user_sources.handler import UserSourceHandler from baserow_enterprise.builder.elements.models import AuthFormElement, FileInputElement @@ -54,8 +59,6 @@ def serializer_field_overrides(self): "login_button_label" ).help_text, required=False, - allow_blank=True, - default="", ), "styles": DynamicConfigBlockSerializer( required=False, @@ -187,26 +190,18 @@ def serializer_field_overrides(self): "label": FormulaSerializerField( help_text=FileInputElement._meta.get_field("label").help_text, required=False, - allow_blank=True, - default="", ), "default_name": FormulaSerializerField( help_text=FileInputElement._meta.get_field("default_name").help_text, required=False, - allow_blank=True, - default="", ), "default_url": FormulaSerializerField( help_text=FileInputElement._meta.get_field("default_url").help_text, required=False, - allow_blank=True, - default="", ), "help_text": FormulaSerializerField( help_text=FileInputElement._meta.get_field("help_text").help_text, required=False, - allow_blank=True, - default="", ), "max_filesize": serializers.IntegerField( help_text=FileInputElement._meta.get_field("preview").help_text, @@ -234,12 +229,28 @@ def is_deactivated(self, workspace) -> bool: def get_pytest_params(self, pytest_data_fixture): return { - "label": "", + "label": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "required": False, "multiple": False, - "default_name": "", - "default_url": "", - "help_text": "", + "default_name": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "default_url": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), + "help_text": BaserowFormulaObject( + formula="", + mode=BASEROW_FORMULA_MODE_SIMPLE, + version=BASEROW_FORMULA_VERSION_INITIAL, + ), "max_filesize": 5, "allowed_filetypes": [], "preview": False, diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/search/test_workspace_search_enterprise_permissions.py b/enterprise/backend/tests/baserow_enterprise_tests/api/search/test_workspace_search_enterprise_permissions.py new file mode 100644 index 0000000000..fbb4c0782f --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/api/search/test_workspace_search_enterprise_permissions.py @@ -0,0 +1,435 @@ +from django.urls import reverse + +import pytest +from rest_framework.status import HTTP_200_OK + +from baserow_enterprise.field_permissions.handler import FieldPermissionsHandler +from baserow_enterprise.field_permissions.models import FieldPermissionsRoleEnum +from baserow_enterprise.role.handler import RoleAssignmentHandler +from baserow_enterprise.role.models import Role + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_denies_access_without_workspace_permission( + api_client, enterprise_data_fixture, enable_enterprise, synced_roles +): + no_access_user, no_access_token = enterprise_data_fixture.create_user_and_token() + admin_user, _ = enterprise_data_fixture.create_user_and_token() + + workspace = enterprise_data_fixture.create_workspace( + users=[admin_user, no_access_user] + ) + + role_no_access = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role(no_access_user, workspace, role=role_no_access) + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "Table"}, + HTTP_AUTHORIZATION=f"JWT {no_access_token}", + ) + assert response.status_code == 401 # Expected: no permission to access workspace + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_filters_tables_by_permissions( + api_client, enterprise_data_fixture, enable_enterprise, synced_roles +): + admin_user, admin_token = enterprise_data_fixture.create_user_and_token() + viewer_user, viewer_token = enterprise_data_fixture.create_user_and_token() + + workspace = enterprise_data_fixture.create_workspace( + users=[admin_user, viewer_user] + ) + + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table1, _, _ = enterprise_data_fixture.build_table( + user=admin_user, + columns=[("restricted_field", "text")], + rows=[["secret data"]], + database=database, + ) + table2, _, _ = enterprise_data_fixture.build_table( + user=admin_user, + columns=[("public_field", "text")], + rows=[["public data"]], + database=database, + ) + + table1.name = "Restricted Table" + table1.save() + table2.name = "Public Table" + table2.save() + + from baserow.contrib.database.search.handler import SearchHandler + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table1) + SearchHandler.initialize_missing_search_data(table2) + + role_admin = Role.objects.get(uid="ADMIN") + role_viewer = Role.objects.get(uid="VIEWER") + role_no_access = Role.objects.get(uid="NO_ACCESS") + + RoleAssignmentHandler().assign_role(admin_user, workspace, role=role_admin) + + RoleAssignmentHandler().assign_role( + viewer_user, workspace, role=role_no_access, scope=database + ) + RoleAssignmentHandler().assign_role( + viewer_user, workspace, role=role_viewer, scope=table2 + ) + RoleAssignmentHandler().assign_role( + viewer_user, workspace, role=role_no_access, scope=table1 + ) + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "Table"}, + HTTP_AUTHORIZATION=f"JWT {admin_token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + table_results = [r for r in results if r["type"] == "database_table"] + assert len(table_results) == 2 + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "Table"}, + HTTP_AUTHORIZATION=f"JWT {viewer_token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + table_results = [r for r in results if r["type"] == "database_table"] + assert len(table_results) == 1 + assert table_results[0]["title"] == "Public Table" + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_respects_field_permissions( + api_client, enterprise_data_fixture, enable_enterprise, synced_roles +): + user, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(users=[user]) + + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table, fields, _ = enterprise_data_fixture.build_table( + user=user, + columns=[("public_field", "text"), ("restricted_field", "text")], + rows=[["public data", "restricted data"]], + database=database, + ) + public_field, restricted_field = fields + + from baserow.contrib.database.search.handler import SearchHandler + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table) + + FieldPermissionsHandler().update_field_permissions( + user=user, + field=restricted_field, + role=FieldPermissionsRoleEnum.NOBODY.value, + allow_in_forms=False, + ) + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "data"}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + row_results = [r for r in results if r["type"] == "database_row"] + + for result in row_results: + field_name = result["metadata"].get("field_name") + assert field_name == "public_field" + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_team_permission_inheritance( + api_client, enterprise_data_fixture, enable_enterprise, synced_roles +): + user, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(users=[user]) + + team = enterprise_data_fixture.create_team( + workspace=workspace, name="Engineering Team" + ) + enterprise_data_fixture.create_subject(team=team, subject=user) + + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table1 = enterprise_data_fixture.create_database_table( + database=database, name="Team Table" + ) + table2 = enterprise_data_fixture.create_database_table( + database=database, name="Other Table" + ) + + from baserow.contrib.database.search.handler import SearchHandler + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table1) + SearchHandler.initialize_missing_search_data(table2) + + role_viewer = Role.objects.get(uid="VIEWER") + role_no_access = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role(team, workspace, role=role_viewer, scope=table1) + RoleAssignmentHandler().assign_role( + user, workspace, role=role_no_access, scope=table2 + ) + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "Table"}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + table_results = [r for r in results if r["type"] == "database_table"] + assert len(table_results) == 1 + assert table_results[0]["title"] == "Team Table" + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_scope_inheritance( + api_client, enterprise_data_fixture, enable_enterprise, synced_roles +): + user, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(users=[user]) + + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table, _, _ = enterprise_data_fixture.build_table( + user=user, + columns=[("test_field", "text")], + rows=[["test data"]], + database=database, + ) + + table.name = "Test Table" + table.save() + + from baserow.contrib.database.search.handler import SearchHandler + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table) + + role_viewer = Role.objects.get(uid="VIEWER") + RoleAssignmentHandler().assign_role( + user, workspace, role=role_viewer, scope=database + ) + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "Test"}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + table_results = [r for r in results if r["type"] == "database_table"] + assert len(table_results) == 1 + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_no_role_low_priority_behavior( + api_client, enterprise_data_fixture, enable_enterprise, synced_roles +): + admin_user = enterprise_data_fixture.create_user() + user, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(users=[admin_user, user]) + + team = enterprise_data_fixture.create_team(workspace=workspace) + enterprise_data_fixture.create_subject(team=team, subject=user) + + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table, _, _ = enterprise_data_fixture.build_table( + user=admin_user, + columns=[("test_field", "text")], + rows=[["test data"]], + database=database, + ) + + table.name = "Team Table" + table.save() + + from baserow.contrib.database.search.handler import SearchHandler + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table) + + role_admin = Role.objects.get(uid="ADMIN") + role_no_role_low_priority = Role.objects.get(uid="NO_ROLE_LOW_PRIORITY") + role_viewer = Role.objects.get(uid="VIEWER") + + RoleAssignmentHandler().assign_role(admin_user, workspace, role=role_admin) + RoleAssignmentHandler().assign_role(user, workspace, role=role_no_role_low_priority) + RoleAssignmentHandler().assign_role(team, workspace, role=role_viewer, scope=table) + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "Team"}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + table_results = [r for r in results if r["type"] == "database_table"] + assert len(table_results) == 1 + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_actor_role_precedence_over_team( + api_client, enterprise_data_fixture, enable_enterprise, synced_roles +): + user, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(users=[user]) + + team = enterprise_data_fixture.create_team(workspace=workspace) + enterprise_data_fixture.create_subject(team=team, subject=user) + + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table1 = enterprise_data_fixture.create_database_table( + database=database, name="Table 1" + ) + table2 = enterprise_data_fixture.create_database_table( + database=database, name="Table 2" + ) + + from baserow.contrib.database.search.handler import SearchHandler + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table1) + SearchHandler.initialize_missing_search_data(table2) + + role_viewer = Role.objects.get(uid="VIEWER") + role_no_access = Role.objects.get(uid="NO_ACCESS") + + RoleAssignmentHandler().assign_role(team, workspace, role=role_viewer, scope=table1) + RoleAssignmentHandler().assign_role( + user, workspace, role=role_no_access, scope=table1 + ) + RoleAssignmentHandler().assign_role(user, workspace, role=role_viewer, scope=table2) + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "Table"}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + table_results = [r for r in results if r["type"] == "database_table"] + assert len(table_results) == 1 + assert table_results[0]["title"] == "Table 2" + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_respects_table_access_permissions( + api_client, enterprise_data_fixture, enable_enterprise, synced_roles +): + user, token = enterprise_data_fixture.create_user_and_token() + admin_user = enterprise_data_fixture.create_user() + workspace = enterprise_data_fixture.create_workspace(users=[user, admin_user]) + + role_admin = Role.objects.get(uid="ADMIN") + RoleAssignmentHandler().assign_role(admin_user, workspace, role=role_admin) + + database = enterprise_data_fixture.create_database_application(workspace=workspace) + + role_no_access = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user, workspace, role=role_no_access, scope=database + ) + table, _, _ = enterprise_data_fixture.build_table( + user=admin_user, + columns=[("test_field", "text")], + rows=[["test data"]], + database=database, + ) + + table.name = "Restricted Table" + table.save() + + from baserow.contrib.database.search.handler import SearchHandler + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table) + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "test"}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + table_results = [r for r in results if r["type"] == "database_table"] + row_results = [r for r in results if r["type"] == "database_row"] + assert len(table_results) == 0 + assert len(row_results) == 0 + + +@pytest.mark.workspace_search +@pytest.mark.django_db(transaction=True) +def test_workspace_search_team_role_union_behavior( + api_client, enterprise_data_fixture, enable_enterprise, synced_roles +): + user, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(users=[user]) + + team1 = enterprise_data_fixture.create_team(workspace=workspace, name="Team 1") + team2 = enterprise_data_fixture.create_team(workspace=workspace, name="Team 2") + enterprise_data_fixture.create_subject(team=team1, subject=user) + enterprise_data_fixture.create_subject(team=team2, subject=user) + + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table1, _, _ = enterprise_data_fixture.build_table( + user=user, + columns=[("field1", "text")], + rows=[["data1"]], + database=database, + ) + table2, _, _ = enterprise_data_fixture.build_table( + user=user, + columns=[("field2", "text")], + rows=[["data2"]], + database=database, + ) + + table1.name = "Table 1" + table1.save() + table2.name = "Table 2" + table2.save() + + from baserow.contrib.database.search.handler import SearchHandler + + SearchHandler.create_workspace_search_table_if_not_exists(workspace.id) + SearchHandler.initialize_missing_search_data(table1) + SearchHandler.initialize_missing_search_data(table2) + + role_viewer = Role.objects.get(uid="VIEWER") + RoleAssignmentHandler().assign_role( + team1, workspace, role=role_viewer, scope=table1 + ) + RoleAssignmentHandler().assign_role( + team2, workspace, role=role_viewer, scope=table2 + ) + + response = api_client.get( + reverse("api:search:workspace_search", kwargs={"workspace_id": workspace.id}), + {"query": "Table"}, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + table_results = [r for r in results if r["type"] == "database_table"] + assert len(table_results) == 2 + table_names = [r["title"] for r in table_results] + assert "Table 1" in table_names + assert "Table 2" in table_names diff --git a/premium/backend/src/baserow_premium/fields/field_types.py b/premium/backend/src/baserow_premium/fields/field_types.py index 967a17af52..f0d189903c 100644 --- a/premium/backend/src/baserow_premium/fields/field_types.py +++ b/premium/backend/src/baserow_premium/fields/field_types.py @@ -87,8 +87,6 @@ class AIFieldType(CollationSortMixin, SelectOptionBaseFieldType): "ai_prompt": FormulaSerializerField( help_text="The prompt that must run for each row. Must be an formula.", required=False, - allow_blank=True, - default="", ), "ai_file_field_id": serializers.IntegerField( min_value=1, diff --git a/premium/backend/src/baserow_premium/fields/visitors.py b/premium/backend/src/baserow_premium/fields/visitors.py index 5b557793f3..b10e92b75b 100644 --- a/premium/backend/src/baserow_premium/fields/visitors.py +++ b/premium/backend/src/baserow_premium/fields/visitors.py @@ -1,7 +1,11 @@ -from typing import Dict +from typing import Dict, Union from baserow.contrib.database.fields.utils import get_field_id_from_field_key -from baserow.core.formula import BaserowFormula, BaserowFormulaVisitor +from baserow.core.formula import ( + BaserowFormula, + BaserowFormulaObject, + BaserowFormulaVisitor, +) from baserow.core.formula.parser.exceptions import FieldByIdReferencesAreDeprecated from baserow.core.formula.parser.parser import get_parse_tree_for_formula from baserow.core.utils import to_path @@ -84,7 +88,9 @@ def visitRightWhitespaceOrComments( return ctx.expr().accept(self) -def replace_field_id_references(formula: str, id_mapping: Dict[str, str]) -> str: +def replace_field_id_references( + formula: Union[str, BaserowFormulaObject], id_mapping: Dict[str, str] +) -> str: """ Replace the `get("fields.field_1")` field id references with the new value in the provided mapping. @@ -100,8 +106,11 @@ def replace_field_id_references(formula: str, id_mapping: Dict[str, str]) -> str :return: The formula with the updated field references. """ - if not formula: - return formula + # Figure out what our formula string is. + formula_str = formula if isinstance(formula, str) else formula["formula"] - tree = get_parse_tree_for_formula(formula) + if not formula_str: + return formula_str + + tree = get_parse_tree_for_formula(formula_str) return BaserowFormulaReplaceFieldReferences(id_mapping).visit(tree) diff --git a/premium/backend/tests/baserow_premium_tests/api/dashboard/test_grouped_aggregate_rows_data_source_type.py b/premium/backend/tests/baserow_premium_tests/api/dashboard/test_grouped_aggregate_rows_data_source_type.py index d8ab03ba37..cae30f24c0 100644 --- a/premium/backend/tests/baserow_premium_tests/api/dashboard/test_grouped_aggregate_rows_data_source_type.py +++ b/premium/backend/tests/baserow_premium_tests/api/dashboard/test_grouped_aggregate_rows_data_source_type.py @@ -10,6 +10,9 @@ from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.views.models import SORT_ORDER_ASC +from baserow.core.formula import BaserowFormulaObject +from baserow.core.formula.field import BASEROW_FORMULA_VERSION_INITIAL +from baserow.core.formula.types import BASEROW_FORMULA_MODE_SIMPLE from baserow.test_utils.helpers import AnyDict, AnyInt @@ -143,7 +146,11 @@ def test_grouped_aggregate_rows_get_dashboard_data_sources( "name": "Name 2", "order": "2.00000000000000000000", "schema": None, - "search_query": "", + "search_query": BaserowFormulaObject( + formula="", + version=BASEROW_FORMULA_VERSION_INITIAL, + mode=BASEROW_FORMULA_MODE_SIMPLE, + ), "table_id": None, "type": "local_baserow_list_rows", "view_id": None, diff --git a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py index 640ac933c8..6390187f71 100644 --- a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py +++ b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py @@ -35,7 +35,7 @@ def test_create_ai_field_type(premium_data_fixture): assert ai_field.ai_output_type == "text" # default value assert ai_field.ai_generative_ai_type == "test_generative_ai" assert ai_field.ai_generative_ai_model == "test_1" - assert ai_field.ai_prompt == "'Who are you?'" + assert ai_field.ai_prompt["formula"] == "'Who are you?'" assert len(AIField.objects.all()) == 1 @@ -59,7 +59,7 @@ def test_update_ai_field_type(premium_data_fixture): assert ai_field.ai_output_type == "text" # default value assert ai_field.ai_generative_ai_type == "test_generative_ai" assert ai_field.ai_generative_ai_model == "test_1" - assert ai_field.ai_prompt == "'Who are you?'" + assert ai_field.ai_prompt["formula"] == "'Who are you?'" @pytest.mark.django_db @@ -107,7 +107,7 @@ def test_create_ai_field_type_via_api(premium_data_fixture, api_client): assert response_json["ai_output_type"] == "text" assert response_json["ai_generative_ai_type"] == "test_generative_ai" assert response_json["ai_generative_ai_model"] == "test_1" - assert response_json["ai_prompt"] == "'Who are you?'" + assert response_json["ai_prompt"]["formula"] == "'Who are you?'" assert response_json["ai_temperature"] is None @@ -168,7 +168,7 @@ def test_create_ai_field_type_via_api_with_ai_output_type( assert response_json["ai_output_type"] == "text" assert response_json["ai_generative_ai_type"] == "test_generative_ai" assert response_json["ai_generative_ai_model"] == "test_1" - assert response_json["ai_prompt"] == "'Who are you?'" + assert response_json["ai_prompt"]["formula"] == "'Who are you?'" assert response_json["ai_temperature"] is None @@ -199,7 +199,7 @@ def test_create_ai_field_type_with_temperature_via_api( assert response.status_code == HTTP_200_OK assert response_json["ai_generative_ai_type"] == "test_generative_ai" assert response_json["ai_generative_ai_model"] == "test_1" - assert response_json["ai_prompt"] == "'Who are you?'" + assert response_json["ai_prompt"]["formula"] == "'Who are you?'" assert response_json["ai_temperature"] == 0.7 @@ -328,7 +328,7 @@ def test_update_ai_field_via_api_valid_output_type(premium_data_fixture, api_cli assert response_json["ai_output_type"] == "text" assert response_json["ai_generative_ai_type"] == "test_generative_ai" assert response_json["ai_generative_ai_model"] == "test_1" - assert response_json["ai_prompt"] == "'Who are you?'" + assert response_json["ai_prompt"]["formula"] == "'Who are you?'" assert response_json["ai_temperature"] is None @@ -358,7 +358,7 @@ def test_update_to_ai_field_with_all_parameters(premium_data_fixture, api_client assert response_json["ai_output_type"] == "text" assert response_json["ai_generative_ai_type"] == "test_generative_ai" assert response_json["ai_generative_ai_model"] == "test_1" - assert response_json["ai_prompt"] == "'Who are you?'" + assert response_json["ai_prompt"]["formula"] == "'Who are you?'" assert response_json["ai_temperature"] is None @@ -385,7 +385,7 @@ def test_update_to_ai_field_without_parameters(premium_data_fixture, api_client) assert response_json["ai_output_type"] == "text" assert response_json["ai_generative_ai_type"] == "test_generative_ai" assert response_json["ai_generative_ai_model"] == "test_1" - assert response_json["ai_prompt"] == "" + assert response_json["ai_prompt"]["formula"] == "" assert response_json["ai_temperature"] is None @@ -746,7 +746,7 @@ def test_duplicate_table_with_ai_field(premium_data_fixture): assert duplicated_ai_field.ai_generative_ai_model == "test_1" assert duplicated_ai_field.ai_file_field_id == duplicated_file_field.id assert ( - duplicated_ai_field.ai_prompt + duplicated_ai_field.ai_prompt["formula"] == f"concat('test:',get('fields.field_{duplicated_text_field.id}'))" ) @@ -785,7 +785,10 @@ def test_duplicate_table_with_ai_field_broken_references(premium_data_fixture): ) duplicated_ai_field = duplicated_fields[2] - assert duplicated_ai_field.ai_prompt == f"concat('test:',get('fields.field_0'))" + assert ( + duplicated_ai_field.ai_prompt["formula"] + == f"concat('test:',get('fields.field_0'))" + ) @pytest.mark.django_db @@ -822,7 +825,7 @@ def test_can_set_select_options_to_choice_ai_output_type( assert response_json["ai_output_type"] == "choice" assert response_json["ai_generative_ai_type"] == "test_generative_ai" assert response_json["ai_generative_ai_model"] == "test_1" - assert response_json["ai_prompt"] == "'Who are you?'" + assert response_json["ai_prompt"]["formula"] == "'Who are you?'" assert len(response_json["select_options"]) == 3 diff --git a/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue b/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue index d594684ef8..b2c43216ff 100644 --- a/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue +++ b/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue @@ -63,16 +63,16 @@
@@ -111,7 +111,7 @@ export default { return { allowedValues: ['ai_prompt', 'ai_file_field_id', 'ai_output_type'], values: { - ai_prompt: '', + ai_prompt: { formula: '' }, ai_output_type: TextAIFieldOutputType.getType(), ai_file_field_id: null, }, @@ -119,6 +119,14 @@ export default { } }, computed: { + /** + * Extract the formula string from the value object, the FormulaInputField + * component only needs the formula string itself. + * @returns {String} The formula string. + */ + formulaStr() { + return this.values.ai_prompt.formula + }, // Return the reactive object that can be updated in runtime. workspace() { return this.$store.getters['workspace/get'](this.database.workspace.id) @@ -164,6 +172,15 @@ export default { }, }, methods: { + /** + * When `FormulaInputField` emits a new formula string, we need to emit the + * entire value object with the updated formula string. + * @param {String} newFormulaStr The new formula string. + */ + updatedFormulaStr(newFormulaStr) { + this.v$.values.ai_prompt.formula.$model = newFormulaStr + this.$emit('input', { formula: newFormulaStr }) + }, setFileFieldSupported(generativeAIType) { if (generativeAIType) { const modelType = this.$registry.get( @@ -183,7 +200,7 @@ export default { validations() { return { values: { - ai_prompt: { required }, + ai_prompt: { formula: { required } }, ai_file_field_id: {}, ai_output_type: {}, }, diff --git a/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue b/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue index 3f8b3f25b7..ad0837c92c 100644 --- a/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue +++ b/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue @@ -2,9 +2,10 @@ @@ -13,6 +14,7 @@ import { inject, computed, useContext } from '@nuxtjs/composition-api' import FormulaInputField from '@baserow/modules/core/components/formula/FormulaInputField' const props = defineProps({ + value: { type: Object, required: false, default: () => ({}) }, dataProvidersAllowed: { type: Array, required: false, default: () => [] }, }) @@ -24,4 +26,23 @@ const dataProviders = computed(() => { app.$registry.get('automationDataProvider', dataProviderName) ) }) + +/** + * Extract the formula string from the value object, the FormulaInputField + * component only needs the formula string itself. + * @returns {String} The formula string. + */ +const formulaStr = computed(() => { + return props.value.formula +}) + +/** + * When `FormulaInputField` emits a new formula string, we need to emit the + * entire value object with the updated formula string. + * @param {String} newFormulaStr The new formula string. + */ +const emit = defineEmits(['input']) +const updatedFormulaStr = (newFormulaStr) => { + emit('input', { ...props.value, formula: newFormulaStr }) +} diff --git a/web-frontend/modules/automation/plugin.js b/web-frontend/modules/automation/plugin.js index 6b0ae31b00..d0f46d47eb 100644 --- a/web-frontend/modules/automation/plugin.js +++ b/web-frontend/modules/automation/plugin.js @@ -44,6 +44,8 @@ import { } from '@baserow/modules/automation/editorSidePanelTypes' import { PreviousNodeDataProviderType } from '@baserow/modules/automation/dataProviderTypes' import { PeriodicTriggerServiceType } from '@baserow/modules/automation/serviceTypes' +import { AutomationSearchType } from '@baserow/modules/automation/searchTypes' +import { searchTypeRegistry } from '@baserow/modules/core/search/types/registry' import { AutomationGuidedTourType } from '@baserow/modules/automation/guidedTourTypes' export default (context) => { @@ -146,6 +148,9 @@ export default (context) => { 'editorSidePanel', new HistoryEditorSidePanelType(context) ) + + // Register automation search type + searchTypeRegistry.register(new AutomationSearchType()) app.$registry.register('guidedTour', new AutomationGuidedTourType(context)) } } diff --git a/web-frontend/modules/automation/searchTypes.js b/web-frontend/modules/automation/searchTypes.js new file mode 100644 index 0000000000..0654478121 --- /dev/null +++ b/web-frontend/modules/automation/searchTypes.js @@ -0,0 +1,36 @@ +import { BaseSearchType } from '@baserow/modules/core/search/types/base' + +export class AutomationSearchType extends BaseSearchType { + constructor() { + super() + this.type = 'automation' + this.name = 'Automation' + this.icon = 'baserow-icon-automation' + this.priority = 4 + } + + buildUrl(result, context = null) { + const appId = result?.metadata?.application_id || result?.id + if (!appId) { + return null + } + + if (context && context.store) { + const automation = context.store.getters['application/get'](appId) + if ( + automation && + automation.workflows && + automation.workflows.length > 0 + ) { + const workflows = [...automation.workflows].sort( + (a, b) => a.order - b.order + ) + if (workflows.length > 0) { + return `/automation/${appId}/workflow/${workflows[0].id}` + } + } + } + + return null + } +} diff --git a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue index ed990aecec..0cbb5eed74 100644 --- a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue +++ b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue @@ -2,10 +2,11 @@ @@ -30,6 +31,11 @@ export default { }, }, props: { + value: { + type: Object, + required: false, + default: () => ({}), + }, dataProvidersAllowed: { type: Array, required: false, @@ -37,6 +43,14 @@ export default { }, }, computed: { + /** + * Extract the formula string from the value object, the FormulaInputField + * component only needs the formula string itself. + * @returns {String} The formula string. + */ + formulaStr() { + return this.value.formula + }, dataSourceLoading() { return this.$store.getters['dataSource/getLoading'](this.elementPage) }, @@ -68,5 +82,15 @@ export default { ) }, }, + methods: { + /** + * When `FormulaInputField` emits a new formula string, we need to emit the + * entire value object with the updated formula string. + * @param {String} newFormulaStr The new formula string. + */ + updatedFormulaStr(newFormulaStr) { + this.$emit('input', { ...this.value, formula: newFormulaStr }) + }, + }, } diff --git a/web-frontend/modules/builder/components/elements/components/collectionField/form/BooleanFieldForm.vue b/web-frontend/modules/builder/components/elements/components/collectionField/form/BooleanFieldForm.vue index 50d9bc55e0..acc1df2456 100644 --- a/web-frontend/modules/builder/components/elements/components/collectionField/form/BooleanFieldForm.vue +++ b/web-frontend/modules/builder/components/elements/components/collectionField/form/BooleanFieldForm.vue @@ -38,7 +38,7 @@ export default { return { allowedValues: ['value', 'styles'], values: { - value: '', + value: {}, styles: {}, }, } diff --git a/web-frontend/modules/builder/components/elements/components/collectionField/form/ButtonFieldForm.vue b/web-frontend/modules/builder/components/elements/components/collectionField/form/ButtonFieldForm.vue index 5f5c07d7fc..f1faf98bbc 100644 --- a/web-frontend/modules/builder/components/elements/components/collectionField/form/ButtonFieldForm.vue +++ b/web-frontend/modules/builder/components/elements/components/collectionField/form/ButtonFieldForm.vue @@ -41,7 +41,7 @@ export default { return { allowedValues: ['label', 'styles'], values: { - label: '', + label: {}, styles: {}, }, } diff --git a/web-frontend/modules/builder/components/elements/components/collectionField/form/ImageFieldForm.vue b/web-frontend/modules/builder/components/elements/components/collectionField/form/ImageFieldForm.vue index fb44918119..4bbd91db54 100644 --- a/web-frontend/modules/builder/components/elements/components/collectionField/form/ImageFieldForm.vue +++ b/web-frontend/modules/builder/components/elements/components/collectionField/form/ImageFieldForm.vue @@ -40,8 +40,8 @@ export default { return { allowedValues: ['src', 'alt', 'styles'], values: { - src: '', - alt: '', + src: {}, + alt: {}, styles: {}, }, } diff --git a/web-frontend/modules/builder/components/elements/components/collectionField/form/LinkFieldForm.vue b/web-frontend/modules/builder/components/elements/components/collectionField/form/LinkFieldForm.vue index 0ecf82a49b..28b1a93b36 100644 --- a/web-frontend/modules/builder/components/elements/components/collectionField/form/LinkFieldForm.vue +++ b/web-frontend/modules/builder/components/elements/components/collectionField/form/LinkFieldForm.vue @@ -61,7 +61,7 @@ export default { return { allowedValues: ['link_name', 'styles', 'variant'], values: { - link_name: '', + link_name: {}, styles: {}, variant: LINK_VARIANTS.LINK, }, diff --git a/web-frontend/modules/builder/components/elements/components/collectionField/form/RatingFieldForm.vue b/web-frontend/modules/builder/components/elements/components/collectionField/form/RatingFieldForm.vue index df6824310c..36de8f94e0 100644 --- a/web-frontend/modules/builder/components/elements/components/collectionField/form/RatingFieldForm.vue +++ b/web-frontend/modules/builder/components/elements/components/collectionField/form/RatingFieldForm.vue @@ -34,7 +34,7 @@ export default { return { allowedValues: ['value'], values: { - value: '', + value: {}, }, } }, diff --git a/web-frontend/modules/builder/components/elements/components/collectionField/form/TagsFieldForm.vue b/web-frontend/modules/builder/components/elements/components/collectionField/form/TagsFieldForm.vue index 50547572a5..21b2c72427 100644 --- a/web-frontend/modules/builder/components/elements/components/collectionField/form/TagsFieldForm.vue +++ b/web-frontend/modules/builder/components/elements/components/collectionField/form/TagsFieldForm.vue @@ -81,7 +81,7 @@ export default { return { allowedValues: ['values', 'colors', 'colors_is_formula', 'styles'], values: { - values: '', + values: {}, colors: '#acc8f8', colors_is_formula: false, styles: {}, diff --git a/web-frontend/modules/builder/components/elements/components/collectionField/form/TextFieldForm.vue b/web-frontend/modules/builder/components/elements/components/collectionField/form/TextFieldForm.vue index 9c3e9b94bb..c2a4c5ec50 100644 --- a/web-frontend/modules/builder/components/elements/components/collectionField/form/TextFieldForm.vue +++ b/web-frontend/modules/builder/components/elements/components/collectionField/form/TextFieldForm.vue @@ -38,7 +38,7 @@ export default { return { allowedValues: ['value', 'styles'], values: { - value: '', + value: {}, styles: {}, }, } diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/ButtonElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/ButtonElementForm.vue index 1a31b84f0d..e236df4a8c 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/ButtonElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/ButtonElementForm.vue @@ -35,7 +35,7 @@ export default { data() { return { values: { - value: '', + value: {}, styles: {}, }, allowedValues: ['value', 'styles'], diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/CheckboxElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/CheckboxElementForm.vue index 4c0d25aca5..2939adaadf 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/CheckboxElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/CheckboxElementForm.vue @@ -53,8 +53,8 @@ export default { return { allowedValues: ['label', 'default_value', 'required', 'styles'], values: { - label: '', - default_value: '', + label: {}, + default_value: {}, required: false, styles: {}, }, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue index 740fa31d30..6ee939a6f3 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue @@ -210,16 +210,16 @@ export default { 'styles', ], values: { - label: '', - default_value: '', + label: {}, + default_value: {}, required: false, - placeholder: '', + placeholder: {}, options: [], multiple: false, show_as_dropdown: true, option_type: CHOICE_OPTION_TYPES.MANUAL, - formula_name: '', - formula_value: '', + formula_name: {}, + formula_value: {}, styles: {}, }, } diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/DateTimePickerElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/DateTimePickerElementForm.vue index 3e8812c299..a66347323d 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/DateTimePickerElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/DateTimePickerElementForm.vue @@ -104,8 +104,8 @@ export default { 'styles', ], values: { - label: '', - default_value: '', + label: {}, + default_value: {}, required: false, date_format: '', include_time: false, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/FormContainerElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/FormContainerElementForm.vue index 719aa2fe41..c3d4925dee 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/FormContainerElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/FormContainerElementForm.vue @@ -44,7 +44,7 @@ export default { data() { return { values: { - submit_button_label: '', + submit_button_label: {}, reset_initial_values_post_submission: false, styles: {}, }, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/HeadingElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/HeadingElementForm.vue index 6abc460691..6703beeb52 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/HeadingElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/HeadingElementForm.vue @@ -54,7 +54,7 @@ export default { data() { return { values: { - value: '', + value: {}, level: 1, styles: {}, }, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/IFrameElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/IFrameElementForm.vue index 4b2e22789a..069493424b 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/IFrameElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/IFrameElementForm.vue @@ -83,8 +83,8 @@ export default { allowedValues: ['source_type', 'url', 'embed', 'height', 'styles'], values: { source_type: IFRAME_SOURCE_TYPES.URL, - url: '', - embed: '', + url: {}, + embed: {}, height: 300, styles: {}, }, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/ImageElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/ImageElementForm.vue index 05075ad6e1..5be7a7a302 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/ImageElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/ImageElementForm.vue @@ -99,8 +99,8 @@ export default { values: { image_source_type: IMAGE_SOURCE_TYPES.UPLOAD, image_file: null, - image_url: '', - alt_text: '', + image_url: {}, + alt_text: {}, styles: {}, }, } diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/InputTextElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/InputTextElementForm.vue index 52738254f1..32f1c26768 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/InputTextElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/InputTextElementForm.vue @@ -153,11 +153,11 @@ export default { 'input_type', ], values: { - label: '', - default_value: '', + label: {}, + default_value: {}, required: false, validation_type: 'any', - placeholder: '', + placeholder: {}, is_multiline: false, input_type: 'text', rows: 3, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/LinkElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/LinkElementForm.vue index 0248546679..e36cf5b874 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/LinkElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/LinkElementForm.vue @@ -61,7 +61,7 @@ export default { data() { return { values: { - value: '', + value: {}, alignment: HORIZONTAL_ALIGNMENTS.LEFT, variant: LINK_VARIANTS.LINK, width: WIDTHS_NEW.AUTO, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/LinkNavigationSelectionForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/LinkNavigationSelectionForm.vue index b282abaac6..fb029546ef 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/LinkNavigationSelectionForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/LinkNavigationSelectionForm.vue @@ -142,7 +142,7 @@ export default { values: { navigation_type: 'page', navigate_to_page_id: null, - navigate_to_url: '', + navigate_to_url: {}, page_parameters: [], query_parameters: [], target: 'self', @@ -240,7 +240,7 @@ export default { this.values.page_parameters = ( this.destinationPage?.path_params || [] ).map(({ name }, index) => { - const previousValue = this.values.page_parameters[index]?.value || '' + const previousValue = this.values.page_parameters[index]?.value || {} return { name, value: previousValue } }) @@ -248,7 +248,7 @@ export default { this.values.query_parameters = ( this.destinationPage?.query_params || [] ).map(({ name }, index) => { - const previousValue = this.values.query_parameters[index]?.value || '' + const previousValue = this.values.query_parameters[index]?.value || {} return { name, value: previousValue } }) diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RatingElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RatingElementForm.vue index 063b6f6f64..85725161e4 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/RatingElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/RatingElementForm.vue @@ -36,7 +36,7 @@ export default { data() { return { values: { - value: '', + value: {}, }, allowedValues: ['value'], } diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RatingInputElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RatingInputElementForm.vue index 2fcb020260..d4a1eb46c3 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/RatingInputElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/RatingInputElementForm.vue @@ -59,9 +59,9 @@ export default { data() { return { values: { - value: '', + value: {}, required: false, - label: '', + label: {}, }, allowedValues: ['value', 'required', 'label'], } diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue index f338d31dd7..b34ccfccae 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue @@ -175,11 +175,11 @@ export default { required: false, data_source_id: null, items_per_page: null, - label: '', - default_value: '', - placeholder: '', + label: {}, + default_value: {}, + placeholder: {}, multiple: false, - option_name_suffix: '', + option_name_suffix: {}, styles: {}, }, } diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue index 60c8d9327d..f7cf15b256 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/RepeatElementForm.vue @@ -219,7 +219,7 @@ export default { orientation: 'vertical', vertical_gap: 0, horizontal_gap: 0, - button_load_more_label: '', + button_load_more_label: {}, styles: {}, }, } diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue index d380bd1efd..7225fece36 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/TableElementForm.vue @@ -304,7 +304,7 @@ export default { items_per_page: 1, styles: {}, orientation: {}, - button_load_more_label: '', + button_load_more_label: {}, }, } }, @@ -345,7 +345,7 @@ export default { this.$t('tableElementForm.fieldDefaultName'), this.v$.values.fields.$model.map(({ name }) => name) ), - value: '', + value: {}, type: 'text', id: uuid(), // Temporary id uid: uuid(), diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/TextElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/TextElementForm.vue index e04456fee2..da90e38295 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/TextElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/TextElementForm.vue @@ -52,7 +52,7 @@ export default { return { allowedValues: ['value', 'format', 'styles'], values: { - value: '', + value: {}, format: TEXT_FORMAT_TYPES.PLAIN, styles: {}, }, diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js index 1907050bc5..f40acaed75 100644 --- a/web-frontend/modules/builder/elementTypes.js +++ b/web-frontend/modules/builder/elementTypes.js @@ -1167,13 +1167,10 @@ export class FormElementType extends ElementType { * @returns {string} this element's display name. */ getDisplayName(element, applicationContext) { - if (element.label) { - const resolvedName = ensureString( - this.resolveFormula(element.label, applicationContext) - ).trim() - return resolvedName || this.name - } - return this.name + const resolvedName = ensureString( + this.resolveFormula(element.label, applicationContext) + ).trim() + return resolvedName || this.name } afterDelete(element, page) { @@ -1251,14 +1248,10 @@ export class InputTextElementType extends FormElementType { getDisplayName(element, applicationContext) { const displayValue = element.label || element.placeholder - - if (displayValue?.trim()) { - const resolvedName = ensureString( - this.resolveFormula(displayValue, applicationContext) - ).trim() - return resolvedName || this.name - } - return this.name + const resolvedName = ensureString( + this.resolveFormula(displayValue, applicationContext) + ).trim() + return resolvedName || this.name } getInitialFormDataValue(element, applicationContext) { @@ -1311,7 +1304,7 @@ export class HeadingElementType extends ElementType { * is empty to indicate an error, otherwise return false. */ getErrorMessage({ workspace, page, element, builder }) { - if (element.value.length === 0) { + if (element.value.formula.length === 0) { return this.app.i18n.t('elementType.errorValueMissing') } return super.getErrorMessage({ @@ -1323,13 +1316,10 @@ export class HeadingElementType extends ElementType { } getDisplayName(element, applicationContext) { - if (element.value && element.value.length) { - const resolvedName = ensureString( - this.resolveFormula(element.value, applicationContext) - ).trim() - return resolvedName || this.name - } - return this.name + const resolvedName = ensureString( + this.resolveFormula(element.value, applicationContext) + ).trim() + return resolvedName || this.name } } @@ -1367,7 +1357,7 @@ export class TextElementType extends ElementType { * is empty to indicate an error, otherwise return false. */ getErrorMessage({ workspace, page, element, builder }) { - if (element.value.length === 0) { + if (element.value.formula.length === 0) { return this.app.i18n.t('elementType.errorValueMissing') } return super.getErrorMessage({ @@ -1379,13 +1369,10 @@ export class TextElementType extends ElementType { } getDisplayName(element, applicationContext) { - if (element.value) { - const resolvedName = ensureString( - this.resolveFormula(element.value, applicationContext) - ).trim() - return resolvedName || this.name - } - return this.name + const resolvedName = ensureString( + this.resolveFormula(element.value, applicationContext) + ).trim() + return resolvedName || this.name } } @@ -1427,7 +1414,7 @@ export class LinkElementType extends ElementType { */ getErrorMessage({ workspace, page, element, builder }) { // A Link without any text isn't usable - if (element.value.length === 0) { + if (element.value.formula.length === 0) { return this.app.i18n.t('elementType.errorValueMissing') } @@ -1445,7 +1432,7 @@ export class LinkElementType extends ElementType { } } else if ( element.navigation_type === 'custom' && - !element.navigate_to_url + !element.navigate_to_url.formula ) { return this.app.i18n.t('elementType.errorNavigationUrlMissing') } @@ -1458,7 +1445,6 @@ export class LinkElementType extends ElementType { } getDisplayName(element, applicationContext) { - let displayValue = '' let destination = '' if (element.navigation_type === 'page') { const builder = applicationContext.builder @@ -1480,11 +1466,9 @@ export class LinkElementType extends ElementType { destination = ` -> ${destination}` } - if (element.value) { - displayValue = ensureString( - this.resolveFormula(element.value, applicationContext) - ).trim() - } + const displayValue = ensureString( + this.resolveFormula(element.value, applicationContext) + ).trim() return displayValue ? `${displayValue}${destination}` @@ -1547,13 +1531,10 @@ export class ImageElementType extends ElementType { } getDisplayName(element, applicationContext) { - if (element.alt_text) { - const resolvedName = ensureString( - this.resolveFormula(element.alt_text, applicationContext) - ).trim() - return resolvedName || this.name - } - return this.name + const resolvedName = ensureString( + this.resolveFormula(element.alt_text, applicationContext) + ).trim() + return resolvedName || this.name } } @@ -1596,7 +1577,7 @@ export class ButtonElementType extends ElementType { */ getErrorMessage({ workspace, page, element, builder }) { // If Button without any label should be considered invalid - if (element.value.length === 0) { + if (element.value.formula.length === 0) { return this.app.i18n.t('elementType.errorValueMissing') } @@ -1617,13 +1598,10 @@ export class ButtonElementType extends ElementType { } getDisplayName(element, applicationContext) { - if (element.value) { - const resolvedName = ensureString( - this.resolveFormula(element.value, applicationContext) - ).trim() - return resolvedName || this.name - } - return this.name + const resolvedName = ensureString( + this.resolveFormula(element.value, applicationContext) + ).trim() + return resolvedName || this.name } } @@ -1715,14 +1693,10 @@ export class ChoiceElementType extends FormElementType { getDisplayName(element, applicationContext) { const displayValue = element.label || element.placeholder - - if (displayValue) { - const resolvedName = ensureString( - this.resolveFormula(displayValue, applicationContext) - ).trim() - return resolvedName || this.name - } - return this.name + const resolvedName = ensureString( + this.resolveFormula(displayValue, applicationContext) + ).trim() + return resolvedName || this.name } /** @@ -1897,11 +1871,14 @@ export class IFrameElementType extends ElementType { * otherwise return false. */ getErrorMessage({ workspace, page, element, builder }) { - if (element.source_type === IFRAME_SOURCE_TYPES.URL && !element.url) { + if ( + element.source_type === IFRAME_SOURCE_TYPES.URL && + !element.url.formula + ) { return this.app.i18n.t('elementType.errorIframeUrlMissing') } else if ( element.source_type === IFRAME_SOURCE_TYPES.EMBED && - !element.embed + !element.embed.formula ) { return this.app.i18n.t('elementType.errorIframeContentMissing') } @@ -1914,13 +1891,10 @@ export class IFrameElementType extends ElementType { } getDisplayName(element, applicationContext) { - if (element.url && element.url.length) { - const resolvedName = ensureString( - this.resolveFormula(element.url, applicationContext) - ) - return resolvedName || this.name - } - return this.name + const resolvedName = ensureString( + this.resolveFormula(element.url, applicationContext) + ) + return resolvedName || this.name } } @@ -1981,15 +1955,10 @@ export class RecordSelectorElementType extends CollectionElementTypeMixin( getDisplayName(element, applicationContext) { const displayValue = element.label || element.placeholder - - if (displayValue) { - const resolvedName = ensureString( - this.resolveFormula(displayValue, applicationContext) - ).trim() - return resolvedName || this.name - } - - return this.name + const resolvedName = ensureString( + this.resolveFormula(displayValue, applicationContext) + ).trim() + return resolvedName || this.name } isValid(element, value, applicationContext) { @@ -2505,7 +2474,7 @@ export class MenuElementType extends ElementType { if ( menuItem.navigation_type === 'custom' && - !menuItem.navigate_to_url + !menuItem.navigate_to_url.formula ) { return this.app.i18n.t('elementType.errorNavigationUrlMissing') } diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json index 0121fe5ef1..57fdaa1593 100644 --- a/web-frontend/modules/builder/locales/en.json +++ b/web-frontend/modules/builder/locales/en.json @@ -837,7 +837,8 @@ "link": "Link", "tags": "Tags", "image": "Image", - "rating": "Rating" + "rating": "Rating", + "errorValueMissing": "Missing value property" }, "textFieldForm": { "fieldValueLabel": "Value", diff --git a/web-frontend/modules/builder/plugin.js b/web-frontend/modules/builder/plugin.js index b42360e77a..e4ba17dcc0 100644 --- a/web-frontend/modules/builder/plugin.js +++ b/web-frontend/modules/builder/plugin.js @@ -149,6 +149,8 @@ import { NumericQueryParamType, } from '@baserow/modules/builder/queryParamTypes' import { BuilderGuidedTourType } from '@baserow/modules/builder/guidedTourTypes' +import { BuilderSearchType } from '@baserow/modules/builder/searchTypes' +import { searchTypeRegistry } from '@baserow/modules/core/search/types/registry' export default (context) => { const { store, app, isDev } = context @@ -424,4 +426,6 @@ export default (context) => { app.$registry.register('fontFamily', new BrushScriptMTFontFamilyType(context)) app.$registry.register('guidedTour', new BuilderGuidedTourType(context)) + + searchTypeRegistry.register(new BuilderSearchType()) } diff --git a/web-frontend/modules/builder/searchTypes.js b/web-frontend/modules/builder/searchTypes.js new file mode 100644 index 0000000000..bce3965c55 --- /dev/null +++ b/web-frontend/modules/builder/searchTypes.js @@ -0,0 +1,28 @@ +import { BaseSearchType } from '@baserow/modules/core/search/types/base' + +export class BuilderSearchType extends BaseSearchType { + constructor() { + super() + this.type = 'builder' + this.name = 'Builder' + this.icon = 'baserow-icon-application' + this.priority = 2 + } + + buildUrl(result, context = null) { + if (!context || !context.store) { + return null + } + const application = context.store.getters['application/get']( + parseInt(result.id) + ) + if (!application) { + return null + } + const pages = context.store.getters['page/getVisiblePages'](application) + if (pages && pages.length > 0) { + return `/builder/${application.id}/page/${pages[0].id}` + } + return null + } +} diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss index 40b78ff059..693b6a8e1a 100644 --- a/web-frontend/modules/core/assets/scss/components/all.scss +++ b/web-frontend/modules/core/assets/scss/components/all.scss @@ -193,3 +193,4 @@ @import 'mcp_endpoint'; @import 'code_editor'; @import 'field_constraints'; +@import 'workspace_search'; diff --git a/web-frontend/modules/core/assets/scss/components/form_input.scss b/web-frontend/modules/core/assets/scss/components/form_input.scss index b429f6c07b..5888f4676d 100644 --- a/web-frontend/modules/core/assets/scss/components/form_input.scss +++ b/web-frontend/modules/core/assets/scss/components/form_input.scss @@ -23,9 +23,14 @@ height: 52px; } - &:active, - &:focus, - &:focus-within { + &.form-input--no-border { + border: none; + box-shadow: none; + } + + &:active:not(.form-input--no-border), + &:focus:not(.form-input--no-border), + &:focus-within:not(.form-input--no-border) { &:not(.form-input--disabled):not(.form-input--error) { border-color: $palette-blue-500; } @@ -160,9 +165,17 @@ display: none; } - .form-input:active:not(.form-input--disabled):not(.form-input--error) &, - .form-input:focus:not(.form-input--disabled):not(.form-input--error) &, - .form-input:focus-within:not(.form-input--disabled):not(.form-input--error) + .form-input:active:not(.form-input--disabled):not(.form-input--error):not( + .form-input--no-border + ) + &, + .form-input:focus:not(.form-input--disabled):not(.form-input--error):not( + .form-input--no-border + ) + &, + .form-input:focus-within:not(.form-input--disabled):not( + .form-input--error + ):not(.form-input--no-border) & { color: $palette-blue-500; } diff --git a/web-frontend/modules/core/assets/scss/components/formula_input_field.scss b/web-frontend/modules/core/assets/scss/components/formula_input_field.scss index 7850a16b61..9b1a8c72d9 100644 --- a/web-frontend/modules/core/assets/scss/components/formula_input_field.scss +++ b/web-frontend/modules/core/assets/scss/components/formula_input_field.scss @@ -5,6 +5,13 @@ padding: 5px 12px; line-height: 25px; + // If the field is empty, then give it the + // same padding as a normal form input field. + &:has(div.is-editor-empty) { + padding: 12px 16px; + line-height: 100%; + } + &.formula-input-field--large { line-height: 25px; padding: 10px 12px; diff --git a/web-frontend/modules/core/assets/scss/components/modal.scss b/web-frontend/modules/core/assets/scss/components/modal.scss index 1540a00799..a97c6d0b20 100644 --- a/web-frontend/modules/core/assets/scss/components/modal.scss +++ b/web-frontend/modules/core/assets/scss/components/modal.scss @@ -52,6 +52,10 @@ padding: 15px; } + &.modal__box--no-padding { + padding: 0; + } + &.modal__box--right { margin-right: 20px; diff --git a/web-frontend/modules/core/assets/scss/components/sidebar.scss b/web-frontend/modules/core/assets/scss/components/sidebar.scss index 468a4bff91..040b64eeeb 100644 --- a/web-frontend/modules/core/assets/scss/components/sidebar.scss +++ b/web-frontend/modules/core/assets/scss/components/sidebar.scss @@ -310,3 +310,73 @@ margin-left: auto; color: $palette-neutral-700; } + +.sidebar__search { + margin-bottom: 8px; + display: block; + width: 100%; + padding: 0; + background-color: transparent; + + &:hover, + &:not(.tree__action--disabled):hover { + background-color: transparent; + } +} + +.sidebar__search-input { + position: relative; + display: flex; + align-items: center; + gap: 8px; + height: 36px; + line-height: 36px; + width: 100%; + padding: 0 48px 0 8px; + background-color: $white; + border: 1px solid $palette-neutral-400; + cursor: pointer; + transition: all 0.2s ease; + + @include rounded($rounded-md); + @include elevation($elevation-low); +} + +.sidebar__search-icon { + color: $palette-neutral-700; + font-size: 16px; + flex-shrink: 0; +} + +.sidebar__search-placeholder { + flex: 1 1 auto; + min-width: 0; + font-size: 12px; + line-height: 12px; + color: $palette-neutral-700; + font-weight: 400; +} + +.sidebar__search-shortcut { + position: absolute; + top: calc(50% - 8px); + right: 8px; + display: inline-flex; + align-items: center; + gap: 2px; + + kbd { + border: 0; + background-color: $palette-neutral-100; + border-radius: 3px; + font-family: inherit; + font-size: 10px; + font-weight: 700; + color: $palette-neutral-700; + height: 16px; + line-height: 16px; + padding: 0; + min-width: 16px; + text-align: center; + } +} diff --git a/web-frontend/modules/core/assets/scss/components/workspace_search.scss b/web-frontend/modules/core/assets/scss/components/workspace_search.scss new file mode 100644 index 0000000000..74af67e011 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/workspace_search.scss @@ -0,0 +1,294 @@ +.workspace-search__header { + padding: 0; + border-bottom: none; + margin: 0; +} + +.workspace-search { + width: 100%; + max-width: 100%; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + min-height: 0; + max-height: calc(100vh - 80px); + overflow: hidden; + + &--expanded { + .workspace-search__header { + padding: 0; + border-bottom: 1px solid $palette-neutral-300; + } + } +} + +.workspace-search__search { + position: relative; + display: flex; + width: 100%; +} + +.workspace-search__icon { + font-size: 20px; + line-height: 20px; + + @include absolute(calc(50% - 10px), auto, auto, 16px); + + &--active { + color: $palette-blue-500; + } +} + +.workspace-search__input { + width: 100%; + max-width: 100%; + box-sizing: border-box; + margin: 0; + padding: 0 46px; + font-size: 14px; + line-height: 56px; + border: 0; + outline: none; + background: none; + border-top-left-radius: $rounded-md; + border-top-right-radius: $rounded-md; + + &::placeholder { + color: $palette-neutral-700; + } +} + +.workspace-search__close { + font-size: 16px; + line-height: 16px; + color: $palette-neutral-500; + + @include absolute(calc(50% - 9px), 16px, auto, auto); + + &:hover { + text-decoration: none; + color: $palette-neutral-1200; + } +} + +.workspace-search__content { + flex: 1 1 auto; + min-height: 120px; + max-height: 350px; + overflow-y: auto; + padding: 12px; +} + +.workspace-search__results-list { + padding: 0; +} + +.workspace-search__result-item { + display: flex; + align-items: center; + height: 64px; + padding: 12px; + cursor: pointer; + margin-bottom: 4px; + width: 100%; + box-sizing: border-box; + overflow: hidden; + + @include rounded($rounded-md); + + &:hover, + &--active { + background-color: $palette-neutral-100; + } + + &:active { + background-color: $palette-neutral-200; + } +} + +.workspace-search--keyboard-nav { + .workspace-search__result-item { + pointer-events: none; + } +} + +.workspace-search__result-icon { + width: 40px; + height: 40px; + margin-right: 16px; + display: flex; + align-items: center; + justify-content: center; + color: $palette-neutral-800; + flex-shrink: 0; + border: 1px solid $palette-neutral-300; + border-radius: 6px; + background-color: $white; + font-size: 16px; + + @include elevation($elevation-low); +} + +.workspace-search__result-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +} + +.workspace-search__result-enter { + margin-left: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.workspace-search__result-title { + @extend %ellipsis; + + font-weight: 500; + color: $palette-neutral-1200; + font-size: 13px; + line-height: 20px; + margin-top: 2px; +} + +.workspace-search__result-subtitle { + @extend %ellipsis; + + font-size: 11px; + color: $palette-neutral-900; + line-height: 16px; + margin-top: 0; + font-weight: 500; +} + +.workspace-search__loading-more { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + color: $palette-neutral-600; +} + +.workspace-search__loading-more-icon { + font-size: 20px; + margin-bottom: 8px; + color: $palette-neutral-500; +} + +.workspace-search__loading-more-text { + font-size: 12px; + color: $palette-neutral-700; +} + +.workspace-search__empty, +.workspace-search__loading, +.workspace-search__welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: $palette-neutral-800; +} + +.workspace-search__empty-icon, +.workspace-search__loading-icon, +.workspace-search__welcome-icon { + font-size: 32px; + margin-bottom: 16px; + color: $palette-neutral-500; +} + +.workspace-search__loading-spinner { + margin-bottom: 16px; +} + +.workspace-search__empty-title, +.workspace-search__welcome-title { + font-size: 16px; + font-weight: 600; + color: $palette-neutral-1000; + margin-bottom: 8px; +} + +.workspace-search__loading-text { + font-size: 14px; + color: $palette-neutral-800; +} + +.workspace-search__empty-subtitle, +.workspace-search__welcome-subtitle { + font-size: 14px; + color: $palette-neutral-700; + line-height: 1.4; +} + +.workspace-search__footer { + padding: 12px; + border-top: 1px solid $palette-neutral-200; + margin-top: 8px; + background-color: $palette-neutral-25; + border-bottom-left-radius: $rounded-md; + border-bottom-right-radius: $rounded-md; +} + +.workspace-search__shortcuts { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.workspace-search__shortcut { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: $palette-neutral-900; + font-weight: 500; + line-height: 20px; +} + +.workspace-search__keys { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + min-width: 24px; + line-height: 24px; + background-color: $white; + border: 1px solid $palette-neutral-400; + color: $palette-neutral-700; + border-radius: 4px; + padding: 0 4px; + + @include elevation($elevation-low); + + i { + font-size: 12px; + line-height: 12px; + } +} + +.workspace-search__shortcuts-left { + display: flex; + align-items: center; + gap: 16px; +} + +.workspace-search__shortcuts-right { + display: flex; + align-items: center; + gap: 16px; +} + +.workspace-search__result-highlight { + background-color: $palette-yellow-100; +} diff --git a/web-frontend/modules/core/components/Modal.vue b/web-frontend/modules/core/components/Modal.vue index db042d5cf7..dcba5b53ee 100644 --- a/web-frontend/modules/core/components/Modal.vue +++ b/web-frontend/modules/core/components/Modal.vue @@ -17,6 +17,7 @@ 'modal__box--small': small, 'modal__box--tiny': tiny, 'modal__box--right': right, + 'modal__box--no-padding': !boxPadding, }" > diff --git a/web-frontend/modules/integrations/localBaserow/components/services/ServiceRefinementForms.vue b/web-frontend/modules/integrations/localBaserow/components/services/ServiceRefinementForms.vue index ea539b67ad..70488cb46a 100644 --- a/web-frontend/modules/integrations/localBaserow/components/services/ServiceRefinementForms.vue +++ b/web-frontend/modules/integrations/localBaserow/components/services/ServiceRefinementForms.vue @@ -252,7 +252,8 @@ export default { }, hasActiveSearch() { return ( - this.values.search_query && this.values.search_query.trim().length > 0 + this.values.search_query && + this.values.search_query.formula.trim().length > 0 ) }, }, diff --git a/web-frontend/modules/integrations/localBaserow/serviceTypes.js b/web-frontend/modules/integrations/localBaserow/serviceTypes.js index cdeb009391..2aa687fab2 100644 --- a/web-frontend/modules/integrations/localBaserow/serviceTypes.js +++ b/web-frontend/modules/integrations/localBaserow/serviceTypes.js @@ -219,11 +219,11 @@ export class LocalBaserowListRowsServiceType extends DataSourceLocalBaserowTable outputType = 'rating' } else if (originalType === 'url') { return { - link_name: valueFormula, + link_name: { formula: valueFormula }, name: service.schema.items.properties[field].title, id: uuid(), // Temporary id navigate_to_page_id: null, - navigate_to_url: valueFormula, + navigate_to_url: { formula: valueFormula }, navigation_type: 'custom', page_parameters: [], target: 'blank', @@ -234,8 +234,8 @@ export class LocalBaserowListRowsServiceType extends DataSourceLocalBaserowTable id: uuid(), name: service.schema.items.properties[field].title, type: 'image', - src: `get('current_record.${field}.*.url')`, - alt: `get('current_record.${field}.*.visible_name')`, + src: { formula: `get('current_record.${field}.*.url')` }, + alt: { formula: `get('current_record.${field}.*.visible_name')` }, } } else if ( originalType === 'last_modified_by' || @@ -253,7 +253,7 @@ export class LocalBaserowListRowsServiceType extends DataSourceLocalBaserowTable return { name: service.schema.items.properties[field].title, type: outputType, - value: valueFormula, + value: { formula: valueFormula }, id: uuid(), // Temporary id } }) diff --git a/web-frontend/modules/integrations/locales/en.json b/web-frontend/modules/integrations/locales/en.json index ee594cef4c..1bf4ec5761 100644 --- a/web-frontend/modules/integrations/locales/en.json +++ b/web-frontend/modules/integrations/locales/en.json @@ -34,7 +34,7 @@ "coreHTTPTrigger": "Receive an HTTP Request", "coreHTTPTriggerDescription": "Triggered when an HTTP request is received.", "coreHTTPRequest": "Send an HTTP request", - "coreHTTPRequestDescription": "Sends an HTTP request to a specified endpoint.", + "coreHTTPRequestDescription": "Sends an HTTP request to a specified endpoint.", "coreSMTPEmail": "Send Email", "coreRouter": "Router", "coreRouterEdgesRequired": "At least one edge is required", @@ -104,7 +104,8 @@ "filterTypeNotFound": "The filter type is not compatible.", "noCompatibleFilterTypesErrorTitle": "No compatible filter types", "noCompatibleFilterTypesErrorMessage": "None of your fields have any compatible filter types", - "formulaFilterInputPlaceholder": "Enter text...", + "textFilterInputPlaceholder": "Enter text...", + "formulaFilterInputPlaceholder": "Choose a formula...", "useFormulaForValue": "Use a formula for this filter", "useDefaultForValue": "Use the default filter for this field" }, diff --git a/web-frontend/package.json b/web-frontend/package.json index 19a0fb7f7e..4a91cdce5e 100644 --- a/web-frontend/package.json +++ b/web-frontend/package.json @@ -68,9 +68,9 @@ "@tiptap/pm": "2.14.0", "@tiptap/suggestion": "2.14.0", "@tiptap/vue-2": "2.14.0", - "@vue2-flow/background": "^7.0.0", - "@vue2-flow/controls": "^7.0.0", - "@vue2-flow/core": "^0.7.0", + "@vue2-flow/background": "^8.0.0", + "@vue2-flow/controls": "^8.0.0", + "@vue2-flow/core": "^0.8.0", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "@zip.js/zip.js": "^2.7.45", diff --git a/web-frontend/test/unit/builder/components/elements/components/HeadingElement.spec.js b/web-frontend/test/unit/builder/components/elements/components/HeadingElement.spec.js index 9268943c0b..d373d1a112 100644 --- a/web-frontend/test/unit/builder/components/elements/components/HeadingElement.spec.js +++ b/web-frontend/test/unit/builder/components/elements/components/HeadingElement.spec.js @@ -27,7 +27,7 @@ describe('HeadingElement', () => { const builder = { id: 1, theme: { primary_color: '#ccc' } } const page = {} const workspace = {} - const element = { level: 2, value: '', styles: {} } + const element = { level: 2, value: { formula: '' }, styles: {} } const mode = 'public' const wrapper = await mountComponent({ @@ -51,7 +51,7 @@ describe('HeadingElement', () => { const builder = { id: 1, theme: { primary_color: '#ccc' } } const page = {} const workspace = {} - const element = { level: 3, value: '"hello"', styles: {} } + const element = { level: 3, value: { formula: '"hello"' }, styles: {} } const mode = 'public' const wrapper = await mountComponent({ diff --git a/web-frontend/test/unit/builder/components/elements/components/RecordSelectorElement.spec.js b/web-frontend/test/unit/builder/components/elements/components/RecordSelectorElement.spec.js index 6b9e9ffaa3..e8b92e50ff 100644 --- a/web-frontend/test/unit/builder/components/elements/components/RecordSelectorElement.spec.js +++ b/web-frontend/test/unit/builder/components/elements/components/RecordSelectorElement.spec.js @@ -153,7 +153,7 @@ describe('RecordSelectorElement', () => { type: 'record_selector', data_source_id: page.dataSources[0].id, items_per_page: 5, - option_name_suffix: "'Suffix'", + option_name_suffix: { formula: "'Suffix'" }, } store.dispatch('element/forceCreate', { page, element }) @@ -198,7 +198,9 @@ describe('RecordSelectorElement', () => { builder, page, element, - values: { option_name_suffix: "get('current_record.field_2')" }, + values: { + option_name_suffix: { formula: "get('current_record.field_2')" }, + }, }) await flushPromises() await wrapper diff --git a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/HeadingElement.spec.js.snap b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/HeadingElement.spec.js.snap index ad353d5f48..b762ec154c 100644 --- a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/HeadingElement.spec.js.snap +++ b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/HeadingElement.spec.js.snap @@ -8,7 +8,7 @@ exports[`HeadingElement Default HeadingElement component 1`] = ` class="ab-heading ab-heading--h2" > - headingElement.missingValue +   diff --git a/web-frontend/test/unit/builder/elementTypes.spec.js b/web-frontend/test/unit/builder/elementTypes.spec.js index d7dfa22dba..cd0cc945cb 100644 --- a/web-frontend/test/unit/builder/elementTypes.spec.js +++ b/web-frontend/test/unit/builder/elementTypes.spec.js @@ -99,82 +99,101 @@ describe('elementTypes tests', () => { }) test('InputTextElementType label and default_value variations', () => { const elementType = testApp.getRegistry().get('element', 'input_text') - expect(elementType.getDisplayName({ label: "'First name'" }, {})).toBe( - 'First name' - ) + expect( + elementType.getDisplayName({ label: { formula: "'First name'" } }, {}) + ).toBe('First name') expect( elementType.getDisplayName( - { placeholder: "'Choose a first name...'" }, + { placeholder: { formula: "'Choose a first name...'" } }, {} ) ).toBe('Choose a first name...') // If a formula resolves to a blank string, fallback to the name. expect( elementType.getDisplayName( - { label: "get('page_parameter.id')" }, + { label: { formula: "get('page_parameter.id')" } }, contextBlankParam ) ).toBe(elementType.name) - expect(elementType.getDisplayName({}, {})).toBe(elementType.name) + expect( + elementType.getDisplayName( + { label: { formula: '' }, placeholder: { formula: '' } }, + {} + ) + ).toBe(elementType.name) }) test('ChoiceElementType label, default_value & placeholder variations', () => { const elementType = testApp.getRegistry().get('element', 'choice') - expect(elementType.getDisplayName({ label: "'Animals'" }, {})).toBe( - 'Animals' - ) expect( - elementType.getDisplayName({ placeholder: "'Choose an animal'" }, {}) + elementType.getDisplayName({ label: { formula: "'Animals'" } }, {}) + ).toBe('Animals') + expect( + elementType.getDisplayName( + { placeholder: { formula: "'Choose an animal'" } }, + {} + ) ).toBe('Choose an animal') // If a formula resolves to a blank string, fallback to the name. expect( elementType.getDisplayName( - { label: "get('page_parameter.id')" }, + { label: { formula: "get('page_parameter.id')" } }, contextBlankParam ) ).toBe(elementType.name) - expect(elementType.getDisplayName({}, {})).toBe(elementType.name) + expect( + elementType.getDisplayName( + { label: { formula: '' }, placeholder: { formula: '' } }, + {} + ) + ).toBe(elementType.name) }) test('CheckboxElementType with and without a label to use', () => { const elementType = testApp.getRegistry().get('element', 'checkbox') - expect(elementType.getDisplayName({ label: "'Active'" }, {})).toBe( - 'Active' - ) + expect( + elementType.getDisplayName({ label: { formula: "'Active'" } }, {}) + ).toBe('Active') // If a formula resolves to a blank string, fallback to the name. expect( elementType.getDisplayName( - { label: "get('page_parameter.id')" }, + { label: { formula: "get('page_parameter.id')" } }, contextBlankParam ) ).toBe(elementType.name) - expect(elementType.getDisplayName({}, {})).toBe(elementType.name) + expect(elementType.getDisplayName({ label: { formula: '' } }, {})).toBe( + elementType.name + ) }) test('HeadingElementType with and without a value to use', () => { const elementType = testApp.getRegistry().get('element', 'heading') - expect(elementType.getDisplayName({ value: "'A heading'" }, {})).toBe( - 'A heading' - ) + expect( + elementType.getDisplayName({ value: { formula: "'A heading'" } }, {}) + ).toBe('A heading') // If a formula resolves to a blank string, fallback to the name. expect( elementType.getDisplayName( - { value: "get('page_parameter.id')" }, + { value: { formula: "get('page_parameter.id')" } }, contextBlankParam ) ).toBe(elementType.name) - expect(elementType.getDisplayName({}, {})).toBe(elementType.name) + expect(elementType.getDisplayName({ value: { formula: '' } }, {})).toBe( + elementType.name + ) }) test('TextElementType with and without a value to use', () => { const elementType = testApp.getRegistry().get('element', 'text') - expect(elementType.getDisplayName({ value: "'Some text'" }, {})).toBe( - 'Some text' - ) + expect( + elementType.getDisplayName({ value: { formula: "'Some text'" } }, {}) + ).toBe('Some text') // If a formula resolves to a blank string, fallback to the name. expect( elementType.getDisplayName( - { value: "get('page_parameter.id')" }, + { value: { formula: "get('page_parameter.id')" } }, contextBlankParam ) ).toBe(elementType.name) - expect(elementType.getDisplayName({}, {})).toBe(elementType.name) + expect(elementType.getDisplayName({ value: { formula: '' } }, {})).toBe( + elementType.name + ) }) test('LinkElementType page and custom URL variations', () => { const elementType = testApp.getRegistry().get('element', 'link') @@ -188,6 +207,7 @@ describe('elementTypes tests', () => { { navigate_to_page_id: 1, navigation_type: 'page', + value: { formula: '' }, }, applicationContext ) @@ -197,7 +217,7 @@ describe('elementTypes tests', () => { expect( elementType.getDisplayName( { - value: "'Click me'", + value: { formula: "'Click me'" }, navigate_to_page_id: 2, navigation_type: 'page', }, @@ -211,6 +231,7 @@ describe('elementTypes tests', () => { { navigate_to_page_id: 2, navigation_type: 'page', + value: { formula: '' }, }, applicationContext ) @@ -220,8 +241,8 @@ describe('elementTypes tests', () => { elementType.getDisplayName( { navigation_type: 'custom', - navigate_to_url: "'https://baserow.io'", - value: "'Link name'", + navigate_to_url: { formula: "'https://baserow.io'" }, + value: { formula: "'Link name'" }, }, applicationContext ) @@ -230,30 +251,37 @@ describe('elementTypes tests', () => { test('ImageElementType with and without alt text to use', () => { const elementType = testApp.getRegistry().get('element', 'image') expect( - elementType.getDisplayName({ alt_text: "'Baserow logo'" }, {}) + elementType.getDisplayName( + { alt_text: { formula: "'Baserow logo'" } }, + {} + ) ).toBe('Baserow logo') // If a formula resolves to a blank string, fallback to the name. expect( elementType.getDisplayName( - { alt_text: "get('page_parameter.id')" }, + { alt_text: { formula: "get('page_parameter.id')" } }, contextBlankParam ) ).toBe(elementType.name) - expect(elementType.getDisplayName({}, {})).toBe(elementType.name) + expect( + elementType.getDisplayName({ alt_text: { formula: '' } }, {}) + ).toBe(elementType.name) }) test('ButtonElementType with and without value to use', () => { const elementType = testApp.getRegistry().get('element', 'button') - expect(elementType.getDisplayName({ value: "'Click me'" }, {})).toBe( - 'Click me' - ) + expect( + elementType.getDisplayName({ value: { formula: "'Click me'" } }, {}) + ).toBe('Click me') // If a formula resolves to a blank string, fallback to the name. expect( elementType.getDisplayName( - { value: "get('page_parameter.id')" }, + { value: { formula: "get('page_parameter.id')" } }, contextBlankParam ) ).toBe(elementType.name) - expect(elementType.getDisplayName({}, {})).toBe(elementType.name) + expect(elementType.getDisplayName({ value: { formula: '' } }, {})).toBe( + elementType.name + ) }) test('TableElementType with and without data_source_id to use', () => { const elementType = testApp.getRegistry().get('element', 'table') @@ -297,11 +325,13 @@ describe('elementTypes tests', () => { const elementType = testApp.getRegistry().get('element', 'iframe') expect( elementType.getDisplayName( - { url: "'https://www.youtube.com/watch?v=dQw4w9WgXcQ'" }, + { url: { formula: "'https://www.youtube.com/watch?v=dQw4w9WgXcQ'" } }, {} ) ).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ') - expect(elementType.getDisplayName({}, {})).toBe(elementType.name) + expect(elementType.getDisplayName({ url: { formula: '' } }, {})).toBe( + elementType.name + ) }) }) @@ -707,13 +737,16 @@ describe('elementTypes tests', () => { const elementType = testApp.getRegistry().get('element', 'heading') // Heading with missing value is invalid - expect(elementType.isInError({ page: {}, element: { value: '' } })).toBe( - true - ) + expect( + elementType.isInError({ page: {}, element: { value: { formula: '' } } }) + ).toBe(true) // Heading with value is valid expect( - elementType.isInError({ page: {}, element: { value: 'Foo Heading' } }) + elementType.isInError({ + page: {}, + element: { value: { formula: "'Foo Heading'" } }, + }) ).toBe(false) }) }) @@ -723,13 +756,16 @@ describe('elementTypes tests', () => { const elementType = testApp.getRegistry().get('element', 'text') // Text with missing value is invalid - expect(elementType.isInError({ page: {}, element: { value: '' } })).toBe( - true - ) + expect( + elementType.isInError({ page: {}, element: { value: { formula: '' } } }) + ).toBe(true) // Text with value is valid expect( - elementType.isInError({ page: {}, element: { value: 'Foo Text' } }) + elementType.isInError({ + page: {}, + element: { value: { formula: "'Foo Text'" } }, + }) ).toBe(false) }) }) @@ -739,13 +775,15 @@ describe('elementTypes tests', () => { const elementType = testApp.getRegistry().get('element', 'link') // Link with missing text is invalid - expect(elementType.isInError({ element: { value: '' } })).toBe(true) + expect( + elementType.isInError({ element: { value: { formula: '' } } }) + ).toBe(true) // When navigation_type is 'page' the navigate_to_page_id must be set let element = { navigation_type: 'page', navigate_to_page_id: '', - value: 'Foo Link', + value: { formula: "'Foo Link'" }, } expect(elementType.isInError({ page: {}, element })).toBe(true) @@ -758,14 +796,14 @@ describe('elementTypes tests', () => { // When navigation_type is 'custom' the navigate_to_url must be set element = { navigation_type: 'custom', - navigate_to_url: '', - value: 'Test', + navigate_to_url: { formula: '' }, + value: { formula: "'Test'" }, } expect(elementType.isInError({ page, element })).toBe(true) // Otherwise it is valid - element.navigate_to_url = 'http://localhost' - element.value = 'Foo Link' + element.navigate_to_url = { formula: 'http://localhost' } + element.value = { formula: "'Foo Link'" } expect(elementType.isInError({ page, element })).toBe(false) }) }) @@ -788,7 +826,7 @@ describe('elementTypes tests', () => { expect(elementType.isInError({ element })).toBe(true) // Otherwise it is valid - element.image_url = "'http://localhost'" + element.image_url = { formula: "'http://localhost'" } expect(elementType.isInError({ element })).toBe(false) }) }) @@ -805,7 +843,7 @@ describe('elementTypes tests', () => { } const element = { id: 50, - value: 'Click me', + value: { formula: "'Click me'" }, page_id: page.id, } const builder = { @@ -821,18 +859,18 @@ describe('elementTypes tests', () => { const page = { id: 1, shared: false, - name: 'Foo Page', + name: { formula: "'Foo Page'" }, workflowActions: [], } const builder = { id: 1, pages: [page] } - const element = { id: 50, value: '', page_id: page.id } + const element = { id: 50, value: { formula: '' }, page_id: page.id } const elementType = testApp.getRegistry().get('element', 'button') // Button with missing value is invalid expect(elementType.isInError({ page, element, builder })).toBe(true) // Button with value but missing workflowActions is invalid - element.value = 'click me' + element.value = { formula: "'click me'" } expect(elementType.isInError({ page, element, builder })).toBe(true) // Button with value and workflowAction is valid @@ -846,11 +884,15 @@ describe('elementTypes tests', () => { const elementType = testApp.getRegistry().get('element', 'iframe') // IFrame with source_type of 'url' and missing url is invalid - const element = { source_type: IFRAME_SOURCE_TYPES.URL } + const element = { + source_type: IFRAME_SOURCE_TYPES.URL, + url: { formula: '' }, + embed: { formula: '' }, + } expect(elementType.isInError({ element })).toBe(true) // Otherwise it is valid - element.url = 'http://localhost' + element.url = { formula: "'http://localhost'" } expect(elementType.isInError({ element })).toBe(false) // IFrame with source_type of 'embed' and missing embed is invalid @@ -858,7 +900,7 @@ describe('elementTypes tests', () => { expect(elementType.isInError({ element })).toBe(true) // Otherwise it is valid - element.embed = 'http://localhost' + element.embed = { formula: "'http://localhost'" } expect(elementType.isInError({ element })).toBe(false) // Default is to return no errors @@ -879,7 +921,7 @@ describe('elementTypes tests', () => { } const element = { id: 50, - submit_button_label: 'Submit', + submit_button_label: { formula: "'Submit'" }, page_id: page.id, } const childElement = { @@ -908,7 +950,7 @@ describe('elementTypes tests', () => { } const element = { id: 50, - submit_button_label: 'Submit', + submit_button_label: { formula: "'Submit'" }, page_id: page.id, } page.elementMap = { 50: element } @@ -993,7 +1035,7 @@ describe('elementTypes tests', () => { element.menu_items[0].name = 'sub link' element.menu_items[0].navigation_type = 'custom' - element.menu_items[0].navigate_to_url = '' + element.menu_items[0].navigate_to_url = { formula: '' } // Link Menu item - sublink with custom navigation but no URL is invalid. expect(elementType.isInError({ page, element, builder })).toBe(true) @@ -1017,7 +1059,9 @@ describe('elementTypes tests', () => { element.menu_items[0].type = 'link' element.menu_items[0].name = 'foo link' element.menu_items[0].navigation_type = 'custom' - element.menu_items[0].navigate_to_url = 'https://www.baserow.io' + element.menu_items[0].navigate_to_url = { + formula: "'https://www.baserow.io'", + } expect(elementType.isInError({ page, element, builder })).toBe(false) }) diff --git a/web-frontend/test/unit/builder/utils/urlResolution.spec.js b/web-frontend/test/unit/builder/utils/urlResolution.spec.js index 6e11ab7c53..30b92b2129 100644 --- a/web-frontend/test/unit/builder/utils/urlResolution.spec.js +++ b/web-frontend/test/unit/builder/utils/urlResolution.spec.js @@ -42,7 +42,7 @@ describe('resolveElementUrl tests', () => { const element = { navigation_type: 'page', navigate_to_page_id: 1, - page_parameters: [{ name: 'id', value: "'10'" }], + page_parameters: [{ name: 'id', value: { formula: "'10'" } }], } const builder = { id: 123, @@ -67,7 +67,7 @@ describe('resolveElementUrl tests', () => { test('Should return resolvedContext for external custom navigation type.', () => { const element = { navigation_type: 'custom', - navigate_to_url: "'https://baserow.io'", + navigate_to_url: { formula: "'https://baserow.io'" }, } const builder = { pages: [] } @@ -83,7 +83,7 @@ describe('resolveElementUrl tests', () => { test('Should return resolvedContext for internal custom navigation type.', () => { const element = { navigation_type: 'custom', - navigate_to_url: "'/contact/'", + navigate_to_url: { formula: "'/contact/'" }, } const builder = { id: 123, pages: [] } @@ -100,7 +100,7 @@ describe('resolveElementUrl tests', () => { const element = { navigation_type: 'page', navigate_to_page_id: 1, - page_parameters: [{ name: 'id', value: '"10"' }], + page_parameters: [{ name: 'id', value: { formula: '"10"' } }], } const builder = { id: 123, diff --git a/web-frontend/test/unit/core/components/textElementForm.spec.js b/web-frontend/test/unit/core/components/textElementForm.spec.js index e68e13d67b..b6e4d57818 100644 --- a/web-frontend/test/unit/core/components/textElementForm.spec.js +++ b/web-frontend/test/unit/core/components/textElementForm.spec.js @@ -7,7 +7,7 @@ describe('TextElementForm', () => { const defaultProps = { defaultValues: { - value: 'test text', + value: { formula: 'test text' }, format: TEXT_FORMAT_TYPES.PLAIN, styles: {}, // Add some non-allowed properties @@ -78,7 +78,7 @@ describe('TextElementForm', () => { 'styles', ]) expect(lastEmittedValues).toEqual({ - value: 'test text', + value: { formula: 'test text' }, format: TEXT_FORMAT_TYPES.MARKDOWN, styles: { color: 'red' }, }) diff --git a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap index 4137d469cb..44297a331f 100644 --- a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap +++ b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap @@ -604,6 +604,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = `