From 9898aa11565927fc1a17574dd37649d9e64d504d Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 22 Oct 2025 17:06:00 +0200 Subject: [PATCH 1/3] Migrate baserow formulas to object (#4080) * Iimplement the migration * Further improvements to FormulaField. Introducing JSONFormulaField so that CollectionField.config works correctly. Updating so.. so many tests. * Further backend tweaks. Found issues in builder and dashboard. * Further backend tweaks to support the 'used properties' checks. * Add support for view filter component placeholders. For now it just used in ViewFilterTypeText. * Lots of frontend changes in AB/WA/Integrations to support the new formula object. * Fix link resolution * Re-create the migrations * Improvements added to JSONFormulaField to support multiple formula properties. Fixing oh-so-many tests in builder, premium and database. * Fixing frontend tests. * Addressing AI field feedback, adding BaserowFormulaObjectSerializer * Fixing e2e tests. --------- Co-authored-by: peter_baserow --- .../contrib/automation/formula_importer.py | 17 +- .../builder/api/elements/serializers.py | 6 +- .../elements/collection_field_types.py | 38 +- .../contrib/builder/elements/element_types.py | 388 ++++++++------ .../contrib/builder/elements/mixins.py | 4 +- .../contrib/builder/elements/models.py | 78 ++- .../contrib/builder/formula_importer.py | 21 +- .../0064_migrate_to_formula_field_objects.py | 21 + backend/src/baserow/contrib/builder/mixins.py | 7 +- .../workflow_actions/workflow_action_types.py | 26 +- .../contrib/dashboard/widgets/service.py | 1 + .../contrib/dashboard/widgets/widget_types.py | 8 +- .../integrations/api/core/serializers.py | 2 +- .../integrations/core/api/serializers.py | 6 +- .../integrations/core/service_types.py | 25 - .../local_baserow/api/serializers.py | 3 +- .../integrations/local_baserow/mixins.py | 18 +- .../local_baserow/service_types.py | 1 + .../0021_migrate_to_formula_field_objects.py | 51 ++ backend/src/baserow/core/formula/__init__.py | 15 +- backend/src/baserow/core/formula/field.py | 487 +++++++++++++++++- .../src/baserow/core/formula/serializers.py | 101 +++- backend/src/baserow/core/formula/types.py | 31 +- backend/src/baserow/core/registry.py | 5 +- backend/src/baserow/core/services/models.py | 6 +- .../src/baserow/core/services/registries.py | 4 +- backend/src/baserow/core/services/types.py | 4 +- .../automation/api/nodes/test_nodes_views.py | 2 +- .../test_automation_application_types.py | 8 +- .../data_sources/test_data_source_views.py | 40 +- .../api/domains/test_domain_public_views.py | 9 +- .../api/elements/test_element_views.py | 45 +- .../api/elements/test_image_element.py | 6 +- .../builder/api/elements/test_menu_element.py | 15 +- .../api/elements/test_table_element.py | 27 +- .../test_workflow_actions_views.py | 46 +- .../test_boolean_collection_field_type.py | 12 +- .../test_button_collection_field_type.py | 12 +- .../builder/elements/test_element_handler.py | 4 +- .../elements/test_element_receivers.py | 16 +- .../builder/elements/test_element_types.py | 67 +-- .../test_image_collection_field_type.py | 14 +- .../test_link_collection_field_type.py | 28 +- .../elements/test_menu_element_type.py | 46 +- .../test_rating_collection_field_type.py | 15 +- .../elements/test_rating_element_types.py | 11 +- .../test_record_selector_element_type.py | 4 +- .../elements/test_repeat_element_type.py | 14 +- .../elements/test_table_element_type.py | 65 ++- .../test_tags_collection_field_type.py | 27 +- .../test_text_collection_field_type.py | 12 +- .../builder/test_builder_application_type.py | 24 +- .../builder/test_element_formula_mixin.py | 30 +- .../test_workflow_action_types.py | 33 +- .../test_dashboard_data_source_views.py | 4 +- .../test_dashboard_application_types.py | 8 +- .../test_core_http_request_service_type.py | 54 +- .../core/test_core_router_service_type.py | 2 +- .../core/test_smtp_email_service_type.py | 79 ++- .../test_delete_row_service_type.py | 4 +- .../test_get_row_service_type.py | 21 +- .../test_list_rows_service_type.py | 10 +- .../test_upsert_row_service_type.py | 26 +- .../local_baserow/test_integration_signals.py | 4 +- .../integrations/local_baserow/test_mixins.py | 25 +- .../core/service/test_service_handler.py | 2 +- .../test_core_baserow_formula_migration.py | 100 ++++ .../builder/elements/buttonElement.spec.ts | 2 +- .../builder/elements/headingElement.spec.ts | 2 +- .../builder/elements/element_types.py | 41 +- .../src/baserow_premium/fields/field_types.py | 2 - .../src/baserow_premium/fields/visitors.py | 21 +- ...grouped_aggregate_rows_data_source_type.py | 9 +- .../fields/test_ai_field_type.py | 25 +- .../components/field/FieldAISubForm.vue | 27 +- .../AutomationBuilderFormulaInput.vue | 23 +- .../ApplicationBuilderFormulaInput.vue | 26 +- .../collectionField/form/BooleanFieldForm.vue | 2 +- .../collectionField/form/ButtonFieldForm.vue | 2 +- .../collectionField/form/ImageFieldForm.vue | 4 +- .../collectionField/form/LinkFieldForm.vue | 2 +- .../collectionField/form/RatingFieldForm.vue | 2 +- .../collectionField/form/TagsFieldForm.vue | 2 +- .../collectionField/form/TextFieldForm.vue | 2 +- .../forms/general/ButtonElementForm.vue | 2 +- .../forms/general/CheckboxElementForm.vue | 4 +- .../forms/general/ChoiceElementForm.vue | 10 +- .../general/DateTimePickerElementForm.vue | 4 +- .../general/FormContainerElementForm.vue | 2 +- .../forms/general/HeadingElementForm.vue | 2 +- .../forms/general/IFrameElementForm.vue | 4 +- .../forms/general/ImageElementForm.vue | 4 +- .../forms/general/InputTextElementForm.vue | 6 +- .../forms/general/LinkElementForm.vue | 2 +- .../general/LinkNavigationSelectionForm.vue | 6 +- .../forms/general/RatingElementForm.vue | 2 +- .../forms/general/RatingInputElementForm.vue | 4 +- .../general/RecordSelectorElementForm.vue | 8 +- .../forms/general/RepeatElementForm.vue | 2 +- .../forms/general/TableElementForm.vue | 4 +- .../forms/general/TextElementForm.vue | 2 +- web-frontend/modules/builder/elementTypes.js | 131 ++--- web-frontend/modules/builder/locales/en.json | 3 +- .../scss/components/formula_input_field.scss | 7 + .../formula/InjectedFormulaInput.vue | 2 +- web-frontend/modules/core/formula/index.js | 37 +- .../modules/core/runtimeFormulaContext.js | 10 +- .../view/ViewFieldConditionItem.vue | 6 + .../view/ViewFieldConditionsForm.vue | 6 + .../components/view/ViewFilterTypeText.vue | 1 + .../modules/database/mixins/viewFilter.js | 5 + .../components/services/FieldMappingForm.vue | 2 +- .../LocalBaserowAggregateRowsForm.vue | 2 +- .../services/LocalBaserowGetRowForm.vue | 4 +- .../services/LocalBaserowListRowsForm.vue | 2 +- .../services/LocalBaserowServiceForm.vue | 4 +- ...ocalBaserowTableServiceConditionalForm.vue | 35 +- .../services/ServiceRefinementForms.vue | 3 +- .../integrations/localBaserow/serviceTypes.js | 10 +- .../modules/integrations/locales/en.json | 5 +- .../components/HeadingElement.spec.js | 4 +- .../components/RecordSelectorElement.spec.js | 6 +- .../__snapshots__/HeadingElement.spec.js.snap | 2 +- .../test/unit/builder/elementTypes.spec.js | 176 ++++--- .../unit/builder/utils/urlResolution.spec.js | 8 +- .../core/components/textElementForm.spec.js | 4 +- .../__snapshots__/viewFilterForm.spec.js.snap | 1 + 127 files changed, 2194 insertions(+), 889 deletions(-) create mode 100644 backend/src/baserow/contrib/builder/migrations/0064_migrate_to_formula_field_objects.py create mode 100644 backend/src/baserow/contrib/integrations/migrations/0021_migrate_to_formula_field_objects.py create mode 100644 backend/tests/baserow/core/test_core_baserow_formula_migration.py 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/components/formula/InjectedFormulaInput.vue b/web-frontend/modules/core/components/formula/InjectedFormulaInput.vue index 87c7ebc59f..cb578e1b1c 100644 --- a/web-frontend/modules/core/components/formula/InjectedFormulaInput.vue +++ b/web-frontend/modules/core/components/formula/InjectedFormulaInput.vue @@ -3,7 +3,7 @@ :is="formulaComponent" :data-providers-allowed="dataProvidersAllowed || []" v-bind="$attrs" - v-on="$listeners" + @input="$emit('input', $event)" >