({
@input="onInput"
@update:mode="onModeChanged"
/>
-
+
Emitted Formula:
From 978b9b185d4ce6771b925fcd83ff9d90b24132fe Mon Sep 17 00:00:00 2001
From: Bram
Date: Thu, 13 Nov 2025 10:30:13 +0100
Subject: [PATCH 2/6] Hide empty assistant chats (#4232)
---
.../baserow_enterprise/assistant/handler.py | 14 ++++-
.../api/assistant/test_assistant_views.py | 55 ++++++++++++++++++-
2 files changed, 65 insertions(+), 4 deletions(-)
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/handler.py b/enterprise/backend/src/baserow_enterprise/assistant/handler.py
index fa7033fffc..bfe8b2bf72 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/handler.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/handler.py
@@ -3,6 +3,7 @@
from uuid import UUID
from django.contrib.auth.models import AbstractUser
+from django.db.models import Count
from baserow.core.models import Workspace
@@ -63,9 +64,16 @@ def list_chats(self, user: AbstractUser, workspace_id: int) -> list[AssistantCha
List all AI assistant chats for the user in the specified workspace.
"""
- return AssistantChat.objects.filter(
- workspace_id=workspace_id, user=user
- ).order_by("-updated_on", "id")
+ return (
+ AssistantChat.objects.filter(
+ workspace_id=workspace_id,
+ user=user,
+ )
+ .exclude(title="")
+ .annotate(message_count=Count("messages"))
+ .filter(message_count__gt=0)
+ .order_by("-updated_on", "id")
+ )
def get_chat_message_by_id(self, user: AbstractUser, message_id: int) -> AiMessage:
"""
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py
index f5705824c6..4d41473e0f 100644
--- a/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py
+++ b/enterprise/backend/tests/baserow_enterprise_tests/api/assistant/test_assistant_views.py
@@ -75,7 +75,16 @@ def test_list_assistant_chats(api_client, enterprise_data_fixture):
for i in range(chats_count)
]
with freeze_time("2024-01-14 12:00:00"):
- AssistantChat.objects.bulk_create(chats)
+ created_chats = AssistantChat.objects.bulk_create(chats)
+ messages = [
+ AssistantChatMessage(
+ role=AssistantChatMessage.Role.HUMAN,
+ content="What's the weather like?",
+ chat=chat,
+ )
+ for chat in created_chats
+ ]
+ AssistantChatMessage.objects.bulk_create(messages)
rsp = api_client.get(
reverse("assistant:list") + f"?workspace_id={workspace.id}",
@@ -123,6 +132,50 @@ def test_list_assistant_chats(api_client, enterprise_data_fixture):
assert data["next"] is not None
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_not_list_empty_assistant_chats(api_client, enterprise_data_fixture):
+ user, token = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user)
+ enterprise_data_fixture.enable_enterprise()
+
+ chats = [
+ AssistantChat(workspace=workspace, user=user, title=""),
+ AssistantChat(workspace=workspace, user=user, title="test"),
+ AssistantChat(workspace=workspace, user=user, title="test2"),
+ ]
+ chats = AssistantChat.objects.bulk_create(chats)
+
+ messages = [
+ AssistantChatMessage(
+ id=1,
+ role=AssistantChatMessage.Role.HUMAN,
+ content="What's the weather like?",
+ chat=chats[0],
+ ),
+ AssistantChatMessage(
+ id=2,
+ role=AssistantChatMessage.Role.HUMAN,
+ content="What's the weather like?",
+ chat=chats[2],
+ ),
+ ]
+ AssistantChatMessage.objects.bulk_create(messages)
+
+ rsp = api_client.get(
+ reverse("assistant:list") + f"?workspace_id={workspace.id}",
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token}",
+ )
+ assert rsp.status_code == 200
+ data = rsp.json()
+ assert data["count"] == 1
+ assert len(data["results"]) == 1
+ # The first chat does not have a title, and the second one does not have any
+ # messages. They should therefore both be excluded.
+ assert data["results"][0]["uuid"] == str(chats[2].uuid)
+
+
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_cannot_send_message_without_valid_workspace(
From b472bcc533245740e88c59273de0461b10a0251f Mon Sep 17 00:00:00 2001
From: Bram
Date: Thu, 13 Nov 2025 11:08:31 +0100
Subject: [PATCH 3/6] Fix dashboard help component position (#4230)
---
.../fields/test_ai_field_type.py | 16 +++++++++-------
.../core/assets/scss/components/dashboard.scss | 4 ++++
.../components/dashboard/DashboardHelp.vue | 2 +-
web-frontend/modules/core/pages/workspace.vue | 18 ++++++++----------
4 files changed, 22 insertions(+), 18 deletions(-)
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 21cc02785a..287240e291 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
@@ -3,6 +3,7 @@
import pytest
from baserow_premium.fields.field_types import AIFieldType
from baserow_premium.fields.models import AIField
+from pytest_unordered import unordered
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
from baserow.contrib.database.fields.dependencies.models import FieldDependency
@@ -1259,15 +1260,16 @@ def test_create_ai_field_with_references(premium_data_fixture):
ai_prompt=f"concat('test:',get('fields.field_{ai_field.id}'))",
)
- deps = list(FieldDependency.objects.all().order_by("dependant", "dependency"))
+ deps = list(FieldDependency.objects.values("dependant_id", "dependency_id"))
assert len(deps) == 3
- assert deps[0].dependant_id == ai_field.id
- assert deps[0].dependency_id == text_field.id
- assert deps[1].dependant_id == ai_field.id
- assert deps[1].dependency_id == other_text_field.id
- assert deps[2].dependency_id == ai_field.id
- assert deps[2].dependant_id == other_ai_field.id
+ assert deps == unordered(
+ [
+ {"dependant_id": ai_field.id, "dependency_id": text_field.id},
+ {"dependant_id": ai_field.id, "dependency_id": other_text_field.id},
+ {"dependant_id": other_ai_field.id, "dependency_id": ai_field.id},
+ ]
+ )
@pytest.mark.django_db
diff --git a/web-frontend/modules/core/assets/scss/components/dashboard.scss b/web-frontend/modules/core/assets/scss/components/dashboard.scss
index 9df99f9791..c103ade4dd 100644
--- a/web-frontend/modules/core/assets/scss/components/dashboard.scss
+++ b/web-frontend/modules/core/assets/scss/components/dashboard.scss
@@ -283,3 +283,7 @@
margin: 0;
font-size: 12px;
}
+
+.dashboard__help {
+ @include absolute(auto, 32px, 0, auto);
+}
diff --git a/web-frontend/modules/core/components/dashboard/DashboardHelp.vue b/web-frontend/modules/core/components/dashboard/DashboardHelp.vue
index 3eb2fdaeb6..494537921e 100644
--- a/web-frontend/modules/core/components/dashboard/DashboardHelp.vue
+++ b/web-frontend/modules/core/components/dashboard/DashboardHelp.vue
@@ -3,7 +3,7 @@
v-if="displayAlert"
type="blank"
close-button
- position="bottom"
+ class="dashboard__help"
:width="396"
@close="handleAlertClose"
>
diff --git a/web-frontend/modules/core/pages/workspace.vue b/web-frontend/modules/core/pages/workspace.vue
index 1c3eb0b7d8..e2dfc50196 100644
--- a/web-frontend/modules/core/pages/workspace.vue
+++ b/web-frontend/modules/core/pages/workspace.vue
@@ -198,17 +198,15 @@
ref="createApplicationContext"
:workspace="selectedWorkspace"
>
-
-
-
-
+
+
+
+
Date: Thu, 13 Nov 2025 15:00:41 +0400
Subject: [PATCH 4/6] Tests for Advanced Formulas (#4193)
* Initial batch of tests
* Ensure castToInt/castToFloat is taken care of by parser for random int/float
* Fix tests after rebase
* Update Boolean arg type to use ensurer in test()
* Add remaining tests for argument types
* 'and' and 'or' should be operator types
* Add 'and' and 'or' operators to formula visitor
* Add frontend tests
* Add integration tests for runtime formulas
* Disable flakey test
* Replace manual field name construction with db_column
---
.../baserow/core/formula/argument_types.py | 14 +-
.../core/formula/runtime_formula_types.py | 12 +-
backend/src/baserow/test_utils/helpers.py | 14 +
.../builder/test_runtime_formula_results.py | 580 ++++++
.../formula/test_baserow_formula_results.py | 1 +
.../formula/test_runtime_formula_types.py | 1656 ++++++++++++++++-
.../core/formula/tiptap/toTipTapVisitor.js | 4 +
.../core/runtimeFormulaArgumentTypes.js | 22 +-
.../modules/core/runtimeFormulaTypes.js | 14 +-
web-frontend/modules/core/utils/validator.js | 1 +
.../core/formula/runtimeFormulaTypes.spec.js | 1433 +++++++++++++-
11 files changed, 3722 insertions(+), 29 deletions(-)
create mode 100644 backend/tests/baserow/contrib/builder/test_runtime_formula_results.py
diff --git a/backend/src/baserow/core/formula/argument_types.py b/backend/src/baserow/core/formula/argument_types.py
index 49624da264..f1efd6fb72 100644
--- a/backend/src/baserow/core/formula/argument_types.py
+++ b/backend/src/baserow/core/formula/argument_types.py
@@ -27,6 +27,7 @@ def parse(self, value):
class NumberBaserowRuntimeFormulaArgumentType(BaserowRuntimeFormulaArgumentType):
def __init__(self, *args, **kwargs):
self.cast_to_int = kwargs.pop("cast_to_int", False)
+ self.cast_to_float = kwargs.pop("cast_to_float", False)
super().__init__(*args, **kwargs)
def test(self, value):
@@ -38,7 +39,12 @@ def test(self, value):
def parse(self, value):
value = ensure_numeric(value)
- return int(value) if self.cast_to_int else value
+ if self.cast_to_int:
+ return int(value)
+ elif self.cast_to_float:
+ return float(value)
+ else:
+ return value
class TextBaserowRuntimeFormulaArgumentType(BaserowRuntimeFormulaArgumentType):
@@ -79,7 +85,11 @@ def parse(self, value):
class BooleanBaserowRuntimeFormulaArgumentType(BaserowRuntimeFormulaArgumentType):
def test(self, value):
- return isinstance(value, bool)
+ try:
+ ensure_boolean(value)
+ return True
+ except ValidationError:
+ return False
def parse(self, value):
return ensure_boolean(value)
diff --git a/backend/src/baserow/core/formula/runtime_formula_types.py b/backend/src/baserow/core/formula/runtime_formula_types.py
index c80a08e30d..033d7a44c4 100644
--- a/backend/src/baserow/core/formula/runtime_formula_types.py
+++ b/backend/src/baserow/core/formula/runtime_formula_types.py
@@ -345,24 +345,24 @@ class RuntimeRandomInt(RuntimeFormulaFunction):
type = "random_int"
args = [
- NumberBaserowRuntimeFormulaArgumentType(),
- NumberBaserowRuntimeFormulaArgumentType(),
+ NumberBaserowRuntimeFormulaArgumentType(cast_to_int=True),
+ NumberBaserowRuntimeFormulaArgumentType(cast_to_int=True),
]
def execute(self, context: FormulaContext, args: FormulaArgs):
- return random.randint(int(args[0]), int(args[1])) # nosec: B311
+ return random.randint(args[0], args[1]) # nosec: B311
class RuntimeRandomFloat(RuntimeFormulaFunction):
type = "random_float"
args = [
- NumberBaserowRuntimeFormulaArgumentType(),
- NumberBaserowRuntimeFormulaArgumentType(),
+ NumberBaserowRuntimeFormulaArgumentType(cast_to_float=True),
+ NumberBaserowRuntimeFormulaArgumentType(cast_to_float=True),
]
def execute(self, context: FormulaContext, args: FormulaArgs):
- return random.uniform(float(args[0]), float(args[1])) # nosec: B311
+ return random.uniform(args[0], args[1]) # nosec: B311
class RuntimeRandomBool(RuntimeFormulaFunction):
diff --git a/backend/src/baserow/test_utils/helpers.py b/backend/src/baserow/test_utils/helpers.py
index 19ff361ee7..e80ad1d112 100644
--- a/backend/src/baserow/test_utils/helpers.py
+++ b/backend/src/baserow/test_utils/helpers.py
@@ -624,6 +624,20 @@ def __eq__(self, other):
return isinstance(other, int)
+class AnyFloat(float):
+ """A class that can be used to check if a value is a float."""
+
+ def __eq__(self, other):
+ return isinstance(other, float)
+
+
+class AnyBool:
+ """A class that can be used to check if a value is a boolean."""
+
+ def __eq__(self, other):
+ return isinstance(other, bool)
+
+
class AnyStr(str):
"""
A class that can be used to check if a value is an str. Useful in tests when
diff --git a/backend/tests/baserow/contrib/builder/test_runtime_formula_results.py b/backend/tests/baserow/contrib/builder/test_runtime_formula_results.py
new file mode 100644
index 0000000000..81784aa016
--- /dev/null
+++ b/backend/tests/baserow/contrib/builder/test_runtime_formula_results.py
@@ -0,0 +1,580 @@
+from django.http import HttpRequest
+
+import pytest
+
+from baserow.contrib.builder.data_sources.builder_dispatch_context import (
+ BuilderDispatchContext,
+)
+from baserow.contrib.database.fields.handler import FieldHandler
+from baserow.contrib.database.rows.handler import RowHandler
+from baserow.core.formula import BaserowFormulaObject, resolve_formula
+from baserow.core.formula.registries import formula_runtime_function_registry
+
+ROWS = [
+ [
+ "Cherry", # fruit
+ "Cherry", # fruit duplicate
+ "Strawberry", # fruit alternate
+ 9.5, # rating
+ 3.5, # weight
+ 3.5, # weight duplicate
+ 4.98, # weight alternate
+ "2025-11-12T21:22:23Z", # date
+ "2025-11-12T21:22:23Z", # date duplicate
+ "2025-11-13T19:15:56Z", # date alternate
+ True, # tasty
+ True, # tasty duplicate
+ False, # tasty alternate
+ 4, # even number
+ 3, # odd number
+ "DD-MMM-YYYY HH:mm:ss", # Date format
+ ],
+ [
+ "Durian",
+ "Durian",
+ "Banana",
+ 2,
+ 8,
+ 8,
+ 84.597,
+ "2025-11-13T06:11:59Z",
+ "2025-11-13T06:11:59Z",
+ "2025-11-14T14:09:42Z",
+ False,
+ False,
+ True,
+ 6,
+ 7,
+ "HH:mm:ss", # Time format
+ ],
+]
+
+TEST_CASES_STRINGS = [
+ # formula type, data source type, list rows ID, expected
+ ("upper", "list_rows", 0, "CHERRY,DURIAN"),
+ ("upper", "list_rows_item", 0, "CHERRY"),
+ ("upper", "get_row", None, "CHERRY"),
+ ("lower", "list_rows", 0, "cherry,durian"),
+ ("lower", "list_rows_item", 0, "cherry"),
+ ("lower", "get_row", None, "cherry"),
+ ("capitalize", "list_rows", 0, "Cherry,durian"),
+ ("capitalize", "list_rows_item", 0, "Cherry"),
+ ("capitalize", "get_row", None, "Cherry"),
+]
+
+TEST_CASES_ARITHMETIC = [
+ # operator, expected
+ ("+", 13),
+ ("-", 6.0),
+ ("*", 33.25),
+ ("/", 2.7142857142857144),
+]
+
+# date formulas operate on "2025-11-12T21:22:23Z"
+TEST_CASES_DATE = [
+ ("day", 12),
+ ("month", 11),
+ ("year", 2025),
+ ("hour", 21),
+ ("minute", 22),
+ ("second", 23),
+]
+
+TEST_CASES_COMAPRISON = [
+ # Text
+ ("equal", 0, 1, True),
+ ("equal", 0, 2, False),
+ ("not_equal", 0, 1, False),
+ ("not_equal", 0, 2, True),
+ # Number
+ ("equal", 4, 5, True),
+ ("equal", 4, 6, False),
+ ("not_equal", 4, 5, False),
+ ("not_equal", 4, 6, True),
+ # Date
+ ("equal", 7, 8, True),
+ ("equal", 7, 9, False),
+ ("not_equal", 7, 8, False),
+ ("not_equal", 7, 9, True),
+ # Boolean
+ ("equal", 10, 11, True),
+ ("equal", 10, 12, False),
+ ("not_equal", 10, 11, False),
+ ("not_equal", 10, 12, True),
+]
+
+TEST_CASES_BOOLEAN = [
+ # formula_name, column, expected
+ ("is_even", 13, True),
+ ("is_even", 14, False),
+ ("is_odd", 13, False),
+ ("is_odd", 14, True),
+]
+
+TEST_CASES_COMAPRISON_OPERATOR = [
+ # Columns: 10 = True, 11 = True, 12 = False
+ ("&&", 10, 11, True),
+ ("&&", 10, 12, False),
+ ("||", 10, 11, True),
+ ("||", 10, 12, True),
+ ("||", 12, 12, False),
+ ("=", 10, 11, True),
+ ("=", 10, 12, False),
+ # Number columns: 3 = 9.5, 4 = 3.5
+ (">", 3, 4, True),
+ (">", 4, 3, False),
+ (">=", 3, 4, True),
+ (">=", 4, 3, False),
+ ("<", 3, 4, False),
+ ("<", 4, 3, True),
+ ("<=", 3, 4, False),
+ ("<=", 4, 3, True),
+ # Text columns: 1 = Cherry, 2 = Strawberry
+ (">", 1, 2, False),
+ (">", 2, 1, True),
+ (">=", 1, 2, False),
+ (">=", 2, 1, True),
+ ("<", 1, 2, True),
+ ("<", 2, 1, False),
+ ("<=", 1, 2, True),
+ ("<=", 2, 1, False),
+]
+
+
+def create_test_context(data_fixture):
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ database = data_fixture.create_database_application(user=user, workspace=workspace)
+ table = data_fixture.create_database_table(database=database)
+
+ field_handler = FieldHandler()
+ field_fruit = field_handler.create_field(
+ user=user, table=table, type_name="text", name="Fruit"
+ )
+ field_fruit_duplicate = field_handler.create_field(
+ user=user, table=table, type_name="text", name="Fruit (duplicate)"
+ )
+ field_fruit_alternate = field_handler.create_field(
+ user=user, table=table, type_name="text", name="Fruit (alternate)"
+ )
+ field_rating = field_handler.create_field(
+ user=user,
+ table=table,
+ type_name="number",
+ name="Rating",
+ number_decimal_places=2,
+ )
+ field_weight = field_handler.create_field(
+ user=user,
+ table=table,
+ type_name="number",
+ name="Weight KGs",
+ number_decimal_places=2,
+ )
+ field_weight_duplicate = field_handler.create_field(
+ user=user,
+ table=table,
+ type_name="number",
+ name="Weight KGs (duplicate)",
+ number_decimal_places=2,
+ )
+ field_weight_alternate = field_handler.create_field(
+ user=user,
+ table=table,
+ type_name="number",
+ name="Weight KGs (alternate)",
+ number_decimal_places=2,
+ )
+ field_harvested = field_handler.create_field(
+ user=user,
+ table=table,
+ type_name="date",
+ name="Harvested",
+ date_include_time=True,
+ )
+ field_harvested_duplicate = field_handler.create_field(
+ user=user,
+ table=table,
+ type_name="date",
+ name="Harvested (duplicate)",
+ date_include_time=True,
+ )
+ field_harvested_alternate = field_handler.create_field(
+ user=user,
+ table=table,
+ type_name="date",
+ name="Harvested (alternate)",
+ date_include_time=True,
+ )
+ field_tasty = field_handler.create_field(
+ user=user, table=table, type_name="boolean", name="Is Tasty"
+ )
+ field_tasty_duplicate = field_handler.create_field(
+ user=user, table=table, type_name="boolean", name="Is Tasty (duplicate)"
+ )
+ field_tasty_alternate = field_handler.create_field(
+ user=user, table=table, type_name="boolean", name="Is Tasty (alternate)"
+ )
+ field_even = field_handler.create_field(
+ user=user, table=table, type_name="number", name="Even number"
+ )
+ field_odd = field_handler.create_field(
+ user=user, table=table, type_name="number", name="Odd number"
+ )
+ field_datetime_format = field_handler.create_field(
+ user=user, table=table, type_name="text", name="Datetime Format"
+ )
+
+ fields = [
+ field_fruit,
+ field_fruit_duplicate,
+ field_fruit_alternate,
+ field_rating,
+ field_weight,
+ field_weight_duplicate,
+ field_weight_alternate,
+ field_harvested,
+ field_harvested_duplicate,
+ field_harvested_alternate,
+ field_tasty,
+ field_tasty_duplicate,
+ field_tasty_alternate,
+ field_even,
+ field_odd,
+ field_datetime_format,
+ ]
+
+ row_handler = RowHandler()
+ rows = [
+ row_handler.create_row(
+ user=user,
+ table=table,
+ values={
+ fields[0].db_column: row[0],
+ fields[1].db_column: row[1],
+ fields[2].db_column: row[2],
+ fields[3].db_column: row[3],
+ fields[4].db_column: row[4],
+ fields[5].db_column: row[5],
+ fields[6].db_column: row[6],
+ fields[7].db_column: row[7],
+ fields[8].db_column: row[8],
+ fields[9].db_column: row[9],
+ fields[10].db_column: row[10],
+ fields[11].db_column: row[11],
+ fields[12].db_column: row[12],
+ fields[13].db_column: row[13],
+ fields[14].db_column: row[14],
+ fields[15].db_column: row[15],
+ },
+ )
+ for row in ROWS
+ ]
+
+ builder = data_fixture.create_builder_application(user=user, workspace=workspace)
+ integration = data_fixture.create_local_baserow_integration(
+ user=user, application=builder
+ )
+ page = data_fixture.create_builder_page(builder=builder)
+ data_source_list_rows = (
+ data_fixture.create_builder_local_baserow_list_rows_data_source(
+ page=page, integration=integration, table=table
+ )
+ )
+ data_source_get_row = data_fixture.create_builder_local_baserow_get_row_data_source(
+ page=page, integration=integration, table=table, row_id=rows[0].id
+ )
+
+ return {
+ "data_source_list_rows": data_source_list_rows,
+ "data_source_get_row": data_source_get_row,
+ "page": page,
+ "fields": fields,
+ }
+
+
+@pytest.mark.django_db
+def test_runtime_formula_if(data_fixture):
+ data = create_test_context(data_fixture)
+ data_source_get_row = data["data_source_get_row"]
+ page = data["page"]
+ fields = data["fields"]
+
+ # Cherry
+ value_cherry = f"get('data_source.{data_source_get_row.id}.{fields[1].db_column}')"
+ # Strawberry
+ value_strawberry = (
+ f"get('data_source.{data_source_get_row.id}.{fields[2].db_column}')"
+ )
+ # True
+ value_true = f"get('data_source.{data_source_get_row.id}.{fields[11].db_column}')"
+ # False
+ value_false = f"get('data_source.{data_source_get_row.id}.{fields[12].db_column}')"
+
+ fake_request = HttpRequest()
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ test_cases = [
+ (
+ f"if({value_true}, {value_cherry}, {value_strawberry})",
+ "Cherry",
+ ),
+ (
+ f"if({value_false}, {value_cherry}, {value_strawberry})",
+ "Strawberry",
+ ),
+ ]
+
+ for item in test_cases:
+ value, expected = item
+ formula = BaserowFormulaObject.create(value)
+
+ result = resolve_formula(
+ formula, formula_runtime_function_registry, dispatch_context
+ )
+ assert result == expected, (
+ f"{value} expected to resolve to {expected} " f"but got {result}"
+ )
+
+
+@pytest.mark.django_db
+def test_runtime_formula_get_property(data_fixture):
+ data = create_test_context(data_fixture)
+ data_source_get_row = data["data_source_get_row"]
+ page = data["page"]
+ fields = data["fields"]
+
+ # Cherry
+ key = f"get('data_source.{data_source_get_row.id}.{fields[0].db_column}')"
+ object_str = '\'{"Cherry": "Dark Red"}\''
+ value = f"get_property({object_str}, {key})"
+ formula = BaserowFormulaObject.create(value)
+
+ fake_request = HttpRequest()
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ result = resolve_formula(
+ formula, formula_runtime_function_registry, dispatch_context
+ )
+ expected = "Dark Red"
+ assert result == expected, (
+ f"{value} expected to resolve to {expected} " f"but got {result}"
+ )
+
+
+@pytest.mark.django_db
+def test_runtime_formula_datetime_format(data_fixture):
+ data = create_test_context(data_fixture)
+ data_source_get_row = data["data_source_get_row"]
+ page = data["page"]
+ fields = data["fields"]
+
+ date_str = f"get('data_source.{data_source_get_row.id}.{fields[7].db_column}')"
+ date_format = f"get('data_source.{data_source_get_row.id}.{fields[15].db_column}')"
+ value = f"datetime_format({date_str}, {date_format})"
+ formula = BaserowFormulaObject.create(value)
+
+ fake_request = HttpRequest()
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ result = resolve_formula(
+ formula, formula_runtime_function_registry, dispatch_context
+ )
+ expected = "12-Nov-2025 21:22:23"
+ assert result == expected, (
+ f"{value} expected to resolve to {expected} " f"but got {result}"
+ )
+
+
+@pytest.mark.django_db
+def test_runtime_formula_comparison_operator(data_fixture):
+ data = create_test_context(data_fixture)
+ data_source_get_row = data["data_source_get_row"]
+ page = data["page"]
+ fields = data["fields"]
+
+ for test_case in TEST_CASES_COMAPRISON_OPERATOR:
+ operator, field_a, field_b, expected = test_case
+
+ value_a = (
+ f"get('data_source.{data_source_get_row.id}.{fields[field_a].db_column}')"
+ )
+ value_b = (
+ f"get('data_source.{data_source_get_row.id}.{fields[field_b].db_column}')"
+ )
+
+ value = f"{value_a} {operator} {value_b}"
+ formula = BaserowFormulaObject.create(value)
+
+ fake_request = HttpRequest()
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ result = resolve_formula(
+ formula, formula_runtime_function_registry, dispatch_context
+ )
+ assert result == expected, (
+ f"{value} expected to resolve to {expected} " f"but got {result}"
+ )
+
+
+@pytest.mark.django_db
+def test_runtime_formula_comparison(data_fixture):
+ data = create_test_context(data_fixture)
+ data_source_get_row = data["data_source_get_row"]
+ page = data["page"]
+ fields = data["fields"]
+
+ for test_case in TEST_CASES_COMAPRISON:
+ formula_name, field_a, field_b, expected = test_case
+
+ value_a = (
+ f"get('data_source.{data_source_get_row.id}.{fields[field_a].db_column}')"
+ )
+ value_b = (
+ f"get('data_source.{data_source_get_row.id}.{fields[field_b].db_column}')"
+ )
+
+ value = f"{formula_name}({value_a}, {value_b})"
+ formula = BaserowFormulaObject.create(value)
+
+ fake_request = HttpRequest()
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ result = resolve_formula(
+ formula, formula_runtime_function_registry, dispatch_context
+ )
+ assert result == expected, (
+ f"{value} expected to resolve to {expected} " f"but got {result}"
+ )
+
+
+@pytest.mark.django_db
+def test_runtime_formula_boolean(data_fixture):
+ data = create_test_context(data_fixture)
+ data_source_get_row = data["data_source_get_row"]
+ page = data["page"]
+ fields = data["fields"]
+
+ for test_case in TEST_CASES_BOOLEAN:
+ formula_name, field_id, expected = test_case
+
+ value = (
+ f"get('data_source.{data_source_get_row.id}.{fields[field_id].db_column}')"
+ )
+
+ value = f"{formula_name}({value})"
+ formula = BaserowFormulaObject.create(value)
+
+ fake_request = HttpRequest()
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ result = resolve_formula(
+ formula, formula_runtime_function_registry, dispatch_context
+ )
+ assert result == expected, (
+ f"{value} expected to resolve to {expected} " f"but got {result}"
+ )
+
+
+@pytest.mark.django_db
+def test_runtime_formula_date(data_fixture):
+ data = create_test_context(data_fixture)
+ data_source_get_row = data["data_source_get_row"]
+ page = data["page"]
+ fields = data["fields"]
+
+ for test_case in TEST_CASES_DATE:
+ formula_name, expected = test_case
+
+ value = f"get('data_source.{data_source_get_row.id}.{fields[7].db_column}')"
+
+ value = f"{formula_name}({value})"
+ formula = BaserowFormulaObject.create(value)
+
+ fake_request = HttpRequest()
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ result = resolve_formula(
+ formula, formula_runtime_function_registry, dispatch_context
+ )
+ assert result == expected, (
+ f"{value} expected to resolve to {expected} " f"but got {result}"
+ )
+
+
+@pytest.mark.django_db
+def test_runtime_formula_arithmetic(data_fixture):
+ data = create_test_context(data_fixture)
+ data_source_get_row = data["data_source_get_row"]
+ page = data["page"]
+ fields = data["fields"]
+
+ for test_case in TEST_CASES_ARITHMETIC:
+ operator, expected = test_case
+
+ value_1 = f"get('data_source.{data_source_get_row.id}.{fields[3].db_column}')"
+ value_2 = f"get('data_source.{data_source_get_row.id}.{fields[4].db_column}')"
+
+ value = f"{value_1} {operator} {value_2}"
+ formula = BaserowFormulaObject.create(value)
+
+ fake_request = HttpRequest()
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ result = resolve_formula(
+ formula, formula_runtime_function_registry, dispatch_context
+ )
+ assert result == expected, (
+ f"{value} expected to resolve to {expected} " f"but got {result}"
+ )
+
+
+@pytest.mark.django_db
+def test_runtime_formula_strings(data_fixture):
+ data = create_test_context(data_fixture)
+ data_source_list_rows = data["data_source_list_rows"]
+ data_source_get_row = data["data_source_get_row"]
+ page = data["page"]
+ fields = data["fields"]
+
+ for test_case in TEST_CASES_STRINGS:
+ formula_name, data_source_type, field_id, expected = test_case
+
+ if data_source_type == "list_rows":
+ value = f"get('data_source.{data_source_list_rows.id}.*.{fields[field_id].db_column}')"
+ elif data_source_type == "list_rows_item":
+ value = f"get('data_source.{data_source_list_rows.id}.0.{fields[field_id].db_column}')"
+ elif data_source_type == "get_row":
+ value = f"get('data_source.{data_source_get_row.id}.{fields[0].db_column}')"
+
+ value = f"{formula_name}({value})"
+ formula = BaserowFormulaObject.create(value)
+
+ fake_request = HttpRequest()
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ result = resolve_formula(
+ formula, formula_runtime_function_registry, dispatch_context
+ )
+ assert result == expected, (
+ f"{formula_name}() with {data_source_type} expected {expected} "
+ f"but got {result}"
+ )
diff --git a/backend/tests/baserow/contrib/database/formula/test_baserow_formula_results.py b/backend/tests/baserow/contrib/database/formula/test_baserow_formula_results.py
index 0aca20238d..fcce381bfb 100644
--- a/backend/tests/baserow/contrib/database/formula/test_baserow_formula_results.py
+++ b/backend/tests/baserow/contrib/database/formula/test_baserow_formula_results.py
@@ -752,6 +752,7 @@ def test_can_use_has_option_on_multiple_select_fields(data_fixture):
),
],
)
+@pytest.mark.skip # See: https://github.com/baserow/baserow/issues/4217
def test_can_use_formula_on_lookup_of_multiple_select_fields(
formula, expected_value, data_fixture
):
diff --git a/backend/tests/baserow/core/formula/test_runtime_formula_types.py b/backend/tests/baserow/core/formula/test_runtime_formula_types.py
index ce19c64783..020bcd760d 100644
--- a/backend/tests/baserow/core/formula/test_runtime_formula_types.py
+++ b/backend/tests/baserow/core/formula/test_runtime_formula_types.py
@@ -1,12 +1,52 @@
+import uuid
+from datetime import datetime
from unittest.mock import MagicMock
import pytest
+from freezegun import freeze_time
-from baserow.core.formula.runtime_formula_types import RuntimeConcat
+from baserow.core.formula.runtime_formula_types import (
+ RuntimeAdd,
+ RuntimeAnd,
+ RuntimeCapitalize,
+ RuntimeConcat,
+ RuntimeDateTimeFormat,
+ RuntimeDay,
+ RuntimeDivide,
+ RuntimeEqual,
+ RuntimeGenerateUUID,
+ RuntimeGet,
+ RuntimeGetProperty,
+ RuntimeGreaterThan,
+ RuntimeGreaterThanOrEqual,
+ RuntimeHour,
+ RuntimeIf,
+ RuntimeIsEven,
+ RuntimeIsOdd,
+ RuntimeLessThan,
+ RuntimeLessThanOrEqual,
+ RuntimeLower,
+ RuntimeMinus,
+ RuntimeMinute,
+ RuntimeMonth,
+ RuntimeMultiply,
+ RuntimeNotEqual,
+ RuntimeNow,
+ RuntimeOr,
+ RuntimeRandomBool,
+ RuntimeRandomFloat,
+ RuntimeRandomInt,
+ RuntimeRound,
+ RuntimeSecond,
+ RuntimeToday,
+ RuntimeUpper,
+ RuntimeYear,
+)
+from baserow.test_utils.helpers import AnyBool, AnyFloat, AnyInt
@pytest.mark.parametrize(
- "formula_args,expected",
+ "args,expected",
[
(
[[["Apple", "Banana"]], "Cherry"],
@@ -17,16 +57,1612 @@
"Apple,Banana,Cherry",
),
(
- [[["Apple", "Banana"]], ", Cherry"],
- "Apple,Banana, Cherry",
+ [[["a", "b", "c", "d"]], "x"],
+ "a,b,c,dx",
),
],
)
-def test_returns_concatenated_value(formula_args, expected):
- """
- Ensure that formula_args and non-formula strings are concatenated correctly.
- """
+def test_runtime_concat_execute(args, expected):
+ parsed_args = RuntimeConcat().parse_args(args)
+ result = RuntimeConcat().execute({}, parsed_args)
+ assert result == expected
- context = MagicMock()
- result = RuntimeConcat().execute(context, formula_args)
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ ("foo", None),
+ (101, None),
+ (3.14, None),
+ (True, None),
+ (False, None),
+ ({}, None),
+ (None, None),
+ (datetime.now(), None),
+ ],
+)
+def test_runtime_concat_validate_type_of_args(arg, expected):
+ result = RuntimeConcat().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], True),
+ ],
+)
+def test_runtime_concat_validate_number_of_args(args, expected):
+ result = RuntimeConcat().validate_number_of_args(args)
+ assert result is expected
+
+
+def test_runtime_get_execute():
+ context = {
+ "id": 101,
+ "fruit": "Apple",
+ "color": "Red",
+ }
+
+ assert RuntimeGet().execute(context, ["id"]) == 101
+ assert RuntimeGet().execute(context, ["fruit"]) == "Apple"
+ assert RuntimeGet().execute(context, ["color"]) == "Red"
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([1, 2], 3),
+ ([2, 3], 5),
+ ([2, 3.14], 5.140000000000001),
+ ([2.43, 3.14], 5.57),
+ ([-4, 23], 19),
+ ],
+)
+def test_runtime_add_execute(args, expected):
+ parsed_args = RuntimeAdd().parse_args(args)
+ result = RuntimeAdd().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # These are invalid
+ ("foo", "foo"),
+ (True, True),
+ (None, None),
+ ({}, {}),
+ ([], []),
+ (
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ ),
+ # These are valid
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_add_validate_type_of_args(arg, expected):
+ result = RuntimeAdd().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_add_validate_number_of_args(args, expected):
+ result = RuntimeAdd().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([3, 1], 2),
+ ([3.14, 4.56], -1.4199999999999995),
+ ([45.25, -2], 47.25),
+ ],
+)
+def test_runtime_minus_execute(args, expected):
+ parsed_args = RuntimeMinus().parse_args(args)
+ result = RuntimeMinus().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # These are invalid
+ ("foo", "foo"),
+ (True, True),
+ (None, None),
+ ({}, {}),
+ ([], []),
+ (
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ ),
+ # These are valid
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_minus_validate_type_of_args(arg, expected):
+ result = RuntimeMinus().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_minus_validate_number_of_args(args, expected):
+ result = RuntimeMinus().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([3, 1], 3),
+ ([3.14, 4.56], 14.318399999999999),
+ ([52.14, -2], -104.28),
+ ],
+)
+def test_runtime_multiply_execute(args, expected):
+ parsed_args = RuntimeMultiply().parse_args(args)
+ result = RuntimeMultiply().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # These are invalid
+ ("foo", "foo"),
+ (True, True),
+ (None, None),
+ ({}, {}),
+ ([], []),
+ (
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ ),
+ # These are valid
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_multiply_validate_type_of_args(arg, expected):
+ result = RuntimeMultiply().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_multiply_validate_number_of_args(args, expected):
+ result = RuntimeMultiply().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([4, 2], 2),
+ ([3.14, 1.56], 2.0128205128205128),
+ ([23.24, -2], -11.62),
+ ],
+)
+def test_runtime_divide_execute(args, expected):
+ parsed_args = RuntimeDivide().parse_args(args)
+ result = RuntimeDivide().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # These are invalid
+ ("foo", "foo"),
+ (True, True),
+ (None, None),
+ ({}, {}),
+ ([], []),
+ (
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ ),
+ # These are valid
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_divide_validate_type_of_args(arg, expected):
+ result = RuntimeDivide().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_divide_validate_number_of_args(args, expected):
+ result = RuntimeDivide().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([2, 2], True),
+ ([2, 3], False),
+ (["foo", "foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_equal_execute(args, expected):
+ parsed_args = RuntimeEqual().parse_args(args)
+ result = RuntimeEqual().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # All types are allowed
+ ("foo", None),
+ (True, None),
+ (None, None),
+ ({}, None),
+ ([], None),
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_equal_validate_type_of_args(arg, expected):
+ result = RuntimeEqual().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_equal_validate_number_of_args(args, expected):
+ result = RuntimeEqual().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([2, 2], False),
+ ([2, 3], True),
+ (["foo", "foo"], False),
+ (["foo", "bar"], True),
+ ],
+)
+def test_runtime_not_equal_execute(args, expected):
+ parsed_args = RuntimeNotEqual().parse_args(args)
+ result = RuntimeNotEqual().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # All types are allowed
+ ("foo", None),
+ (True, None),
+ (None, None),
+ ({}, None),
+ ([], None),
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_not_equal_validate_type_of_args(arg, expected):
+ result = RuntimeNotEqual().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_not_equal_validate_number_of_args(args, expected):
+ result = RuntimeNotEqual().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([2, 2], False),
+ ([2, 3], False),
+ ([3, 2], True),
+ (["apple", "ball"], False),
+ (["ball", "apple"], True),
+ ],
+)
+def test_runtime_greater_than_execute(args, expected):
+ parsed_args = RuntimeGreaterThan().parse_args(args)
+ result = RuntimeGreaterThan().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # All types are allowed
+ ("foo", None),
+ (True, None),
+ (None, None),
+ ({}, None),
+ ([], None),
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_greater_than_validate_type_of_args(arg, expected):
+ result = RuntimeGreaterThan().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_greater_than_validate_number_of_args(args, expected):
+ result = RuntimeGreaterThan().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([2, 2], False),
+ ([2, 3], True),
+ ([3, 2], False),
+ (["apple", "ball"], True),
+ (["ball", "apple"], False),
+ ],
+)
+def test_runtime_less_than_execute(args, expected):
+ parsed_args = RuntimeLessThan().parse_args(args)
+ result = RuntimeLessThan().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # All types are allowed
+ ("foo", None),
+ (True, None),
+ (None, None),
+ ({}, None),
+ ([], None),
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_less_than_validate_type_of_args(arg, expected):
+ result = RuntimeLessThan().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_less_than_validate_number_of_args(args, expected):
+ result = RuntimeLessThan().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([2, 2], True),
+ ([2, 3], False),
+ ([3, 2], True),
+ (["apple", "ball"], False),
+ (["ball", "apple"], True),
+ ],
+)
+def test_runtime_greater_than_or_equal_execute(args, expected):
+ parsed_args = RuntimeGreaterThanOrEqual().parse_args(args)
+ result = RuntimeGreaterThanOrEqual().execute({}, parsed_args)
assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # All types are allowed
+ ("foo", None),
+ (True, None),
+ (None, None),
+ ({}, None),
+ ([], None),
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_greater_than_or_equal_validate_type_of_args(arg, expected):
+ result = RuntimeGreaterThanOrEqual().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_greater_than_or_equal_validate_number_of_args(args, expected):
+ result = RuntimeGreaterThanOrEqual().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([2, 2], True),
+ ([2, 3], True),
+ ([3, 2], False),
+ (["apple", "ball"], True),
+ (["ball", "apple"], False),
+ ],
+)
+def test_runtime_less_than_or_equal_execute(args, expected):
+ parsed_args = RuntimeLessThanOrEqual().parse_args(args)
+ result = RuntimeLessThanOrEqual().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # All types are allowed
+ ("foo", None),
+ (True, None),
+ (None, None),
+ ({}, None),
+ ([], None),
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ (1, None),
+ (3.14, None),
+ ("23", None),
+ ("23.33", None),
+ ],
+)
+def test_runtime_less_than_or_equal_validate_type_of_args(arg, expected):
+ result = RuntimeLessThanOrEqual().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_less_than_or_equal_validate_number_of_args(args, expected):
+ result = RuntimeLessThanOrEqual().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["apple"], "APPLE"),
+ (["bAll"], "BALL"),
+ (["Foo Bar"], "FOO BAR"),
+ ],
+)
+def test_runtime_upper_execute(args, expected):
+ parsed_args = RuntimeUpper().parse_args(args)
+ result = RuntimeUpper().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Text (or convertable to text) types are allowed
+ ("foo", None),
+ (123, None),
+ (123.45, None),
+ (None, None),
+ ({}, None),
+ ([], None),
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ],
+)
+def test_runtime_upper_validate_type_of_args(arg, expected):
+ result = RuntimeUpper().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_upper_validate_number_of_args(args, expected):
+ result = RuntimeUpper().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["ApPle"], "apple"),
+ (["BALL"], "ball"),
+ (["Foo BAR"], "foo bar"),
+ ],
+)
+def test_runtime_lower_execute(args, expected):
+ parsed_args = RuntimeLower().parse_args(args)
+ result = RuntimeLower().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Text (or convertable to text) types are allowed
+ ("foo", None),
+ (123, None),
+ (123.45, None),
+ (None, None),
+ ({}, None),
+ ([], None),
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ],
+)
+def test_runtime_lower_validate_type_of_args(arg, expected):
+ result = RuntimeLower().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_lower_validate_number_of_args(args, expected):
+ result = RuntimeLower().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["ApPle"], "Apple"),
+ (["BALL"], "Ball"),
+ (["Foo BAR"], "Foo bar"),
+ ],
+)
+def test_runtime_capitalize_execute(args, expected):
+ parsed_args = RuntimeCapitalize().parse_args(args)
+ result = RuntimeCapitalize().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Text (or convertable to text) types are allowed
+ ("foo", None),
+ (123, None),
+ (123.45, None),
+ (None, None),
+ ({}, None),
+ ([], None),
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ],
+)
+def test_runtime_capitalize_validate_type_of_args(arg, expected):
+ result = RuntimeCapitalize().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_capitalize_validate_number_of_args(args, expected):
+ result = RuntimeCapitalize().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["23.45", 2], 23.45),
+ # Defaults to 2 decimal places
+ ([33.4567], 33.46),
+ ([33, 0], 33),
+ ([49.4587, 3], 49.459),
+ ],
+)
+def test_runtime_round_execute(args, expected):
+ parsed_args = RuntimeRound().parse_args(args)
+ result = RuntimeRound().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Number (or convertable to number) types are allowed
+ ("23.34", None),
+ (123, None),
+ (123.45, None),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ (
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ ),
+ ],
+)
+def test_runtime_round_validate_type_of_args(arg, expected):
+ result = RuntimeRound().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_round_validate_number_of_args(args, expected):
+ result = RuntimeRound().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["23.45"], False),
+ (["24"], True),
+ ([33.4567], False),
+ ([33], False),
+ ([50], True),
+ ],
+)
+def test_runtime_is_even_execute(args, expected):
+ parsed_args = RuntimeIsEven().parse_args(args)
+ result = RuntimeIsEven().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Number (or convertable to number) types are allowed
+ ("23.34", None),
+ (123, None),
+ (123.45, None),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ (
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ ),
+ ],
+)
+def test_runtime_is_even_validate_type_of_args(arg, expected):
+ result = RuntimeIsEven().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_is_even_validate_number_of_args(args, expected):
+ result = RuntimeIsEven().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["23.45"], True),
+ (["24"], False),
+ ([33.4567], True),
+ ([33], True),
+ ([50], False),
+ ],
+)
+def test_runtime_is_odd_execute(args, expected):
+ parsed_args = RuntimeIsOdd().parse_args(args)
+ result = RuntimeIsOdd().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Number (or convertable to number) types are allowed
+ ("23.34", None),
+ (123, None),
+ (123.45, None),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ (
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ ),
+ ],
+)
+def test_runtime_is_odd_validate_type_of_args(arg, expected):
+ result = RuntimeIsOdd().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_is_odd_validate_number_of_args(args, expected):
+ result = RuntimeIsOdd().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["2025-11-03", "YY/MM/DD"], "25/11/03"),
+ (["2025-11-03", "DD/MM/YYYY HH:mm:ss"], "03/11/2025 00:00:00"),
+ (
+ ["2025-11-06 11:30:30.861096+00:00", "DD/MM/YYYY HH:mm:ss"],
+ "06/11/2025 11:30:30",
+ ),
+ (["2025-11-06 11:30:30.861096+00:00", "%f"], "861096"),
+ ],
+)
+def test_runtime_datetime_format_execute(args, expected):
+ parsed_args = RuntimeDateTimeFormat().parse_args(args)
+ context = MagicMock()
+ context.get_timezone_name.return_value = "UTC"
+
+ result = RuntimeDateTimeFormat().execute(context, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Date like values are valid
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ("2025-11-06", None),
+ ("2025-11-06 11:30:30.861096+00:00", None),
+ # Otherwise the type is invalid
+ ("23.34", "23.34"),
+ (123, 123),
+ (123.45, 123.45),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ ],
+)
+def test_runtime_datetime_format_validate_type_of_args(arg, expected):
+ result = RuntimeDateTimeFormat().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], True),
+ (["foo", "bar", "baz", "x"], False),
+ ],
+)
+def test_runtime_datetime_format_validate_number_of_args(args, expected):
+ result = RuntimeDateTimeFormat().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["2025-11-03"], 3),
+ (["2025-11-04 11:30:30.861096+00:00"], 4),
+ (["2025-11-05 11:30:30.861096+00:00"], 5),
+ ],
+)
+def test_runtime_day_execute(args, expected):
+ parsed_args = RuntimeDay().parse_args(args)
+ result = RuntimeDay().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Date like values are valid
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ("2025-11-06", None),
+ ("2025-11-06 11:30:30.861096+00:00", None),
+ # Otherwise the type is invalid
+ ("23.34", "23.34"),
+ (123, 123),
+ (123.45, 123.45),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ ],
+)
+def test_runtime_day_validate_type_of_args(arg, expected):
+ result = RuntimeDay().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_day_validate_number_of_args(args, expected):
+ result = RuntimeDay().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["2025-09-03"], 9),
+ (["2025-10-04 11:30:30.861096+00:00"], 10),
+ (["2025-11-05 11:30:30.861096+00:00"], 11),
+ ],
+)
+def test_runtime_month_execute(args, expected):
+ parsed_args = RuntimeMonth().parse_args(args)
+ result = RuntimeMonth().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Date like values are valid
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ("2025-11-06", None),
+ ("2025-11-06 11:30:30.861096+00:00", None),
+ # Otherwise the type is invalid
+ ("23.34", "23.34"),
+ (123, 123),
+ (123.45, 123.45),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ ],
+)
+def test_runtime_month_validate_type_of_args(arg, expected):
+ result = RuntimeMonth().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_month_validate_number_of_args(args, expected):
+ result = RuntimeMonth().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["2023-09-03"], 2023),
+ (["2024-10-04 11:30:30.861096+00:00"], 2024),
+ (["2025-11-05 11:30:30.861096+00:00"], 2025),
+ ],
+)
+def test_runtime_year_execute(args, expected):
+ parsed_args = RuntimeYear().parse_args(args)
+ result = RuntimeYear().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Date like values are valid
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ("2025-11-06", None),
+ ("2025-11-06 11:30:30.861096+00:00", None),
+ # Otherwise the type is invalid
+ ("23.34", "23.34"),
+ (123, 123),
+ (123.45, 123.45),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ ],
+)
+def test_runtime_year_validate_type_of_args(arg, expected):
+ result = RuntimeYear().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_year_validate_number_of_args(args, expected):
+ result = RuntimeYear().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["2023-09-03"], 0),
+ (["2024-10-04 11:30:30.861096+00:00"], 11),
+ (["2025-11-05 12:30:30.861096+00:00"], 12),
+ (["2025-11-05 16:30:30.861096+00:00"], 16),
+ ],
+)
+def test_runtime_hour_execute(args, expected):
+ parsed_args = RuntimeHour().parse_args(args)
+ result = RuntimeHour().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Date like values are valid
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ("2025-11-06", None),
+ ("2025-11-06 11:30:30.861096+00:00", None),
+ # Otherwise the type is invalid
+ ("23.34", "23.34"),
+ (123, 123),
+ (123.45, 123.45),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ ],
+)
+def test_runtime_hour_validate_type_of_args(arg, expected):
+ result = RuntimeHour().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_hour_validate_number_of_args(args, expected):
+ result = RuntimeHour().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["2023-09-03"], 0),
+ (["2024-10-04 11:05:30.861096+00:00"], 5),
+ (["2025-11-05 12:32:30.861096+00:00"], 32),
+ (["2025-11-05 16:33:30.861096+00:00"], 33),
+ ],
+)
+def test_runtime_minute_execute(args, expected):
+ parsed_args = RuntimeMinute().parse_args(args)
+ result = RuntimeMinute().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Date like values are valid
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ("2025-11-06", None),
+ ("2025-11-06 11:30:30.861096+00:00", None),
+ # Otherwise the type is invalid
+ ("23.34", "23.34"),
+ (123, 123),
+ (123.45, 123.45),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ ],
+)
+def test_runtime_minute_validate_type_of_args(arg, expected):
+ result = RuntimeMinute().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_minute_validate_number_of_args(args, expected):
+ result = RuntimeMinute().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (["2023-09-03"], 0),
+ (["2024-10-04 11:05:05.861096+00:00"], 5),
+ (["2025-11-05 12:32:30.861096+00:00"], 30),
+ (["2025-11-05 16:33:49.861096+00:00"], 49),
+ ],
+)
+def test_runtime_second_execute(args, expected):
+ parsed_args = RuntimeSecond().parse_args(args)
+ result = RuntimeSecond().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "arg,expected",
+ [
+ # Date like values are valid
+ (datetime(year=2025, month=11, day=6, hour=12, minute=30), None),
+ ("2025-11-06", None),
+ ("2025-11-06 11:30:30.861096+00:00", None),
+ # Otherwise the type is invalid
+ ("23.34", "23.34"),
+ (123, 123),
+ (123.45, 123.45),
+ (None, None),
+ ("foo", "foo"),
+ ({}, {}),
+ ([], []),
+ ],
+)
+def test_runtime_second_validate_type_of_args(arg, expected):
+ result = RuntimeSecond().validate_type_of_args([arg])
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], True),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_second_validate_number_of_args(args, expected):
+ result = RuntimeSecond().validate_number_of_args(args)
+ assert result is expected
+
+
+def test_runtime_now_execute():
+ with freeze_time("2025-11-06 15:48:51"):
+ parsed_args = RuntimeNow().parse_args([])
+ result = RuntimeNow().execute({}, parsed_args)
+ assert str(result) == "2025-11-06 15:48:51+00:00"
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], True),
+ (["foo"], False),
+ ],
+)
+def test_runtime_now_validate_number_of_args(args, expected):
+ result = RuntimeNow().validate_number_of_args(args)
+ assert result is expected
+
+
+def test_runtime_today_execute():
+ with freeze_time("2025-11-06 15:48:51"):
+ parsed_args = RuntimeToday().parse_args([])
+ result = RuntimeToday().execute({}, parsed_args)
+ assert str(result) == "2025-11-06"
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], True),
+ (["foo"], False),
+ ],
+)
+def test_runtime_today_validate_number_of_args(args, expected):
+ result = RuntimeToday().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ (['{"foo": "bar"}', "foo"], "bar"),
+ (['{"foo": "bar"}', "baz"], None),
+ ],
+)
+def test_runtime_get_property_execute(args, expected):
+ parsed_args = RuntimeGetProperty().parse_args(args)
+ result = RuntimeGetProperty().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ # Dict (or convertable to dict) types are allowed
+ (['{"foo": "bar"}', "foo"], None),
+ ([{"foo": "bar"}, "foo"], None),
+ # Invalid types for 1st arg (2nd arg is cast to string)
+ (["foo", "foo"], "foo"),
+ ([100, "foo"], 100),
+ ([12.34, "foo"], 12.34),
+ (
+ [datetime(year=2025, month=11, day=6, hour=12, minute=30), "foo"],
+ datetime(year=2025, month=11, day=6, hour=12, minute=30),
+ ),
+ ([None, "foo"], None),
+ ([[], "foo"], []),
+ ],
+)
+def test_runtime_get_property_validate_type_of_args(args, expected):
+ result = RuntimeGetProperty().validate_type_of_args(args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_get_property_validate_number_of_args(args, expected):
+ result = RuntimeGetProperty().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([1, 100], AnyInt()),
+ ([10.24, 100.54], AnyInt()),
+ ],
+)
+def test_runtime_random_int_execute(args, expected):
+ parsed_args = RuntimeRandomInt().parse_args(args)
+ result = RuntimeRandomInt().execute({}, parsed_args)
+ assert result == expected
+ assert result >= args[0] and result <= args[1]
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ # numeric types are allowed
+ ([1, 100], None),
+ ([2.5, 56.64], None),
+ (["3", "4.5"], None),
+ # Invalid types for 1st arg
+ ([{}, 5], {}),
+ (["foo", 5], "foo"),
+ # Invalid types for 2nd arg
+ ([5, {}], {}),
+ ([5, "foo"], "foo"),
+ ],
+)
+def test_runtime_random_int_validate_type_of_args(args, expected):
+ result = RuntimeRandomInt().validate_type_of_args(args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_random_int_validate_number_of_args(args, expected):
+ result = RuntimeRandomInt().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([1, 100], AnyFloat()),
+ ([10.24, 100.54], AnyFloat()),
+ ],
+)
+def test_runtime_random_float_execute(args, expected):
+ parsed_args = RuntimeRandomFloat().parse_args(args)
+ result = RuntimeRandomFloat().execute({}, parsed_args)
+ assert result == expected
+ assert result >= args[0] and result <= args[1]
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ # numeric types are allowed
+ ([1, 100], None),
+ ([2.5, 56.64], None),
+ (["3", "4.5"], None),
+ # Invalid types for 1st arg
+ ([{}, 5], {}),
+ (["foo", 5], "foo"),
+ # Invalid types for 2nd arg
+ ([5, {}], {}),
+ ([5, "foo"], "foo"),
+ ],
+)
+def test_runtime_random_float_validate_type_of_args(args, expected):
+ result = RuntimeRandomFloat().validate_type_of_args(args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_random_float_validate_number_of_args(args, expected):
+ result = RuntimeRandomFloat().validate_number_of_args(args)
+ assert result is expected
+
+
+def test_runtime_random_bool_execute():
+ parsed_args = RuntimeRandomBool().parse_args([])
+ result = RuntimeRandomBool().execute({}, parsed_args)
+ assert result == AnyBool()
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], True),
+ (["foo"], False),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_random_bool_validate_number_of_args(args, expected):
+ result = RuntimeRandomBool().validate_number_of_args(args)
+ assert result is expected
+
+
+def test_runtime_generate_uuid_execute():
+ parsed_args = RuntimeGenerateUUID().parse_args([])
+ result = RuntimeGenerateUUID().execute({}, parsed_args)
+ assert isinstance(result, str)
+
+ parsed = uuid.UUID(result)
+ assert str(parsed) == result
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], True),
+ (["foo"], False),
+ (["foo", "bar"], False),
+ ],
+)
+def test_runtime_generate_uuid_validate_number_of_args(args, expected):
+ result = RuntimeGenerateUUID().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([True, "foo", "bar"], "foo"),
+ ([False, "foo", "bar"], "bar"),
+ ],
+)
+def test_runtime_if_execute(args, expected):
+ parsed_args = RuntimeIf().parse_args(args)
+ result = RuntimeIf().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ # Valid types for 1st arg (2nd and 3rd args can be Any)
+ ([True, "foo", "bar"], None),
+ ([False, "foo", "bar"], None),
+ (["true", "foo", "bar"], None),
+ (["false", "foo", "bar"], None),
+ (["True", "foo", "bar"], None),
+ (["False", "foo", "bar"], None),
+ ],
+)
+def test_runtime_if_validate_type_of_args(args, expected):
+ result = RuntimeIf().validate_type_of_args(args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], False),
+ (["foo", "bar", "baz"], True),
+ (["foo", "bar", "baz", "bat"], False),
+ ],
+)
+def test_runtime_if_validate_number_of_args(args, expected):
+ result = RuntimeIf().validate_number_of_args(args)
+ assert result is expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([True, True], True),
+ ([True, False], False),
+ ([False, False], False),
+ ],
+)
+def test_runtime_and_execute(args, expected):
+ parsed_args = RuntimeAnd().parse_args(args)
+ result = RuntimeAnd().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ # Valid types for 1st arg
+ ([True, True], None),
+ (["true", True], None),
+ (["True", True], None),
+ ([False, True], None),
+ (["false", True], None),
+ (["False", True], None),
+ # Valid types for 2nd arg
+ ([True, True], None),
+ ([True, "true"], None),
+ ([True, "True"], None),
+ ([True, False], None),
+ ([True, "false"], None),
+ ([True, "False"], None),
+ # Invalid types for 1st arg
+ (["foo", True], "foo"),
+ ([{}, True], {}),
+ (["", True], ""),
+ ([100, True], 100),
+ # Invalid types for 2nd arg
+ ([True, "foo"], "foo"),
+ ([True, {}], {}),
+ ([True, ""], ""),
+ ([True, 100], 100),
+ ],
+)
+def test_runtime_and_validate_type_of_args(args, expected):
+ result = RuntimeAnd().validate_type_of_args(args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_and_validate_number_of_args(args, expected):
+ result = RuntimeAnd().validate_number_of_args(args)
+ assert result is expected
+
+
+##
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([True, True], True),
+ ([True, False], True),
+ ([False, False], False),
+ ],
+)
+def test_runtime_or_execute(args, expected):
+ parsed_args = RuntimeOr().parse_args(args)
+ result = RuntimeOr().execute({}, parsed_args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ # Valid types for 1st arg
+ ([True, True], None),
+ (["true", True], None),
+ (["True", True], None),
+ ([False, True], None),
+ (["false", True], None),
+ (["False", True], None),
+ # Valid types for 2nd arg
+ ([True, True], None),
+ ([True, "true"], None),
+ ([True, "True"], None),
+ ([True, False], None),
+ ([True, "false"], None),
+ ([True, "False"], None),
+ # Invalid types for 1st or 2nd arg
+ (["foo", True], "foo"),
+ ([{}, True], {}),
+ (["", True], ""),
+ ([100, True], 100),
+ ([True, "foo"], "foo"),
+ ([True, {}], {}),
+ ([True, ""], ""),
+ ([True, 100], 100),
+ ],
+)
+def test_runtime_or_validate_type_of_args(args, expected):
+ result = RuntimeOr().validate_type_of_args(args)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ "args,expected",
+ [
+ ([], False),
+ (["foo"], False),
+ (["foo", "bar"], True),
+ (["foo", "bar", "baz"], False),
+ ],
+)
+def test_runtime_or_validate_number_of_args(args, expected):
+ result = RuntimeOr().validate_number_of_args(args)
+ assert result is expected
diff --git a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js
index c25210bc46..6e851e7720 100644
--- a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js
+++ b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js
@@ -233,6 +233,10 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor {
op = 'greater_than_or_equal'
} else if (ctx.LTE()) {
op = 'less_than_or_equal'
+ } else if (ctx.AMP_AMP()) {
+ op = 'and'
+ } else if (ctx.PIPE_PIPE()) {
+ op = 'or'
} else {
throw new UnknownOperatorError(ctx.getText())
}
diff --git a/web-frontend/modules/core/runtimeFormulaArgumentTypes.js b/web-frontend/modules/core/runtimeFormulaArgumentTypes.js
index 9d003980ac..a8b9039b4d 100644
--- a/web-frontend/modules/core/runtimeFormulaArgumentTypes.js
+++ b/web-frontend/modules/core/runtimeFormulaArgumentTypes.js
@@ -39,6 +39,7 @@ export class NumberBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormu
constructor(options = {}) {
super(options)
this.castToInt = options.castToInt ?? false
+ this.castToFloat = options.castToFloat ?? false
}
test(value) {
@@ -46,12 +47,22 @@ export class NumberBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormu
return false
}
- return !isNaN(value)
+ try {
+ ensureNumeric(value)
+ return true
+ } catch (e) {
+ return false
+ }
}
parse(value) {
const val = ensureNumeric(value, { allowNull: true })
- return this.castToInt ? Math.trunc(val) : val
+ if (this.castToInt) {
+ return Math.trunc(val)
+ } else if (this.castToFloat) {
+ return parseFloat(val)
+ }
+ return val
}
}
@@ -104,7 +115,12 @@ export class ObjectBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormu
export class BooleanBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormulaArgumentType {
test(value) {
- return typeof value === 'boolean'
+ try {
+ ensureBoolean(value, { useStrict: false })
+ return true
+ } catch (e) {
+ return false
+ }
}
parse(value) {
diff --git a/web-frontend/modules/core/runtimeFormulaTypes.js b/web-frontend/modules/core/runtimeFormulaTypes.js
index 6da86934e8..c0f6bce558 100644
--- a/web-frontend/modules/core/runtimeFormulaTypes.js
+++ b/web-frontend/modules/core/runtimeFormulaTypes.js
@@ -978,7 +978,7 @@ export class RuntimeRound extends RuntimeFormulaFunction {
decimalPlaces = Math.max(args[1], 0)
}
- return args[0].toFixed(decimalPlaces)
+ return Number(args[0].toFixed(decimalPlaces))
}
getDescription() {
@@ -1468,8 +1468,8 @@ export class RuntimeRandomInt extends RuntimeFormulaFunction {
get args() {
return [
- new NumberBaserowRuntimeFormulaArgumentType(),
- new NumberBaserowRuntimeFormulaArgumentType(),
+ new NumberBaserowRuntimeFormulaArgumentType({ castToInt: true }),
+ new NumberBaserowRuntimeFormulaArgumentType({ castToInt: true }),
]
}
@@ -1509,8 +1509,8 @@ export class RuntimeRandomFloat extends RuntimeFormulaFunction {
get args() {
return [
- new NumberBaserowRuntimeFormulaArgumentType(),
- new NumberBaserowRuntimeFormulaArgumentType(),
+ new NumberBaserowRuntimeFormulaArgumentType({ castToFloat: true }),
+ new NumberBaserowRuntimeFormulaArgumentType({ castToFloat: true }),
]
}
@@ -1656,7 +1656,7 @@ export class RuntimeAnd extends RuntimeFormulaFunction {
}
static getFormulaType() {
- return FORMULA_TYPE.FUNCTION
+ return FORMULA_TYPE.OPERATOR
}
static getCategoryType() {
@@ -1699,7 +1699,7 @@ export class RuntimeOr extends RuntimeFormulaFunction {
}
static getFormulaType() {
- return FORMULA_TYPE.FUNCTION
+ return FORMULA_TYPE.OPERATOR
}
static getCategoryType() {
diff --git a/web-frontend/modules/core/utils/validator.js b/web-frontend/modules/core/utils/validator.js
index e55e1ca1a3..8315110ae5 100644
--- a/web-frontend/modules/core/utils/validator.js
+++ b/web-frontend/modules/core/utils/validator.js
@@ -25,6 +25,7 @@ export const ensureNumeric = (value, { allowNull = false } = {}) => {
return Number(value)
}
}
+
throw new Error(
`Value '${value}' is not a valid number or convertible to a number.`
)
diff --git a/web-frontend/test/unit/core/formula/runtimeFormulaTypes.spec.js b/web-frontend/test/unit/core/formula/runtimeFormulaTypes.spec.js
index 63fd7d6472..b29f5315da 100644
--- a/web-frontend/test/unit/core/formula/runtimeFormulaTypes.spec.js
+++ b/web-frontend/test/unit/core/formula/runtimeFormulaTypes.spec.js
@@ -1,4 +1,40 @@
-import { RuntimeConcat } from '@baserow/modules/core/runtimeFormulaTypes'
+import {
+ RuntimeAdd,
+ RuntimeAnd,
+ RuntimeCapitalize,
+ RuntimeConcat,
+ RuntimeDateTimeFormat,
+ RuntimeDay,
+ RuntimeDivide,
+ RuntimeEqual,
+ RuntimeGenerateUUID,
+ RuntimeGet,
+ RuntimeGetProperty,
+ RuntimeGreaterThan,
+ RuntimeGreaterThanOrEqual,
+ RuntimeHour,
+ RuntimeIf,
+ RuntimeIsEven,
+ RuntimeIsOdd,
+ RuntimeLessThan,
+ RuntimeLessThanOrEqual,
+ RuntimeLower,
+ RuntimeMinus,
+ RuntimeMinute,
+ RuntimeMonth,
+ RuntimeMultiply,
+ RuntimeNotEqual,
+ RuntimeNow,
+ RuntimeOr,
+ RuntimeRandomBool,
+ RuntimeRandomFloat,
+ RuntimeRandomInt,
+ RuntimeRound,
+ RuntimeSecond,
+ RuntimeToday,
+ RuntimeUpper,
+ RuntimeYear,
+} from '@baserow/modules/core/runtimeFormulaTypes'
import { expect } from '@jest/globals'
/** Tests for the RuntimeConcat class. */
@@ -19,3 +55,1398 @@ describe('RuntimeConcat', () => {
expect(result).toBe(expected)
})
})
+
+describe('RuntimeGet', () => {
+ test.each([
+ { args: [['id']], expected: 101 },
+ { args: [['fruit']], expected: 'Apple' },
+ { args: [['color']], expected: 'Red' },
+ ])('should get the correct object value', ({ args, expected }) => {
+ const formulaType = new RuntimeGet()
+ const context = {
+ id: 101,
+ fruit: 'Apple',
+ color: 'Red',
+ }
+ const result = formulaType.execute(context, args)
+ expect(result).toBe(expected)
+ })
+})
+
+describe('RuntimeAdd', () => {
+ test.each([
+ { args: [1, 2], expected: 3 },
+ { args: [2, 3], expected: 5 },
+ { args: [2, 3.14], expected: 5.140000000000001 },
+ { args: [2.43, 3.14], expected: 5.57 },
+ { args: [-4, 23], expected: 19 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeAdd()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // These are invalid
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [null], expected: null },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ {
+ args: [new Date(2025, 10, 6, 12, 30)],
+ expected: new Date(2025, 10, 6, 12, 30),
+ },
+ // These are valid
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeAdd()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeAdd()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeMinus', () => {
+ test.each([
+ { args: [3, 2], expected: 1 },
+ { args: [3.14, 4.56], expected: -1.4199999999999995 },
+ { args: [45.25, -2], expected: 47.25 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeMinus()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // These are invalid
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [null], expected: null },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ {
+ args: [new Date(2025, 10, 6, 12, 30)],
+ expected: new Date(2025, 10, 6, 12, 30),
+ },
+ // These are valid
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeMinus()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeMinus()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeMultiply', () => {
+ test.each([
+ { args: [3, 1], expected: 3 },
+ { args: [3.14, 4.56], expected: 14.318399999999999 },
+ { args: [52.14, -2], expected: -104.28 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeMultiply()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // These are invalid
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [null], expected: null },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ {
+ args: [new Date(2025, 10, 6, 12, 30)],
+ expected: new Date(2025, 10, 6, 12, 30),
+ },
+ // These are valid
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeMultiply()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeMultiply()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeDivide', () => {
+ test.each([
+ { args: [4, 2], expected: 2 },
+ { args: [3.14, 1.56], expected: 2.0128205128205128 },
+ { args: [23.24, -2], expected: -11.62 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeDivide()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // These are invalid
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [null], expected: null },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ {
+ args: [new Date(2025, 10, 6, 12, 30)],
+ expected: new Date(2025, 10, 6, 12, 30),
+ },
+ // These are valid
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeDivide()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeDivide()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeEqual', () => {
+ test.each([
+ { args: [2, 2], expected: true },
+ { args: [2, 3], expected: false },
+ { args: ['foo', 'foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeEqual()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // All types are allowed
+ { args: ['foo'], expected: undefined },
+ { args: [true], expected: undefined },
+ { args: [null], expected: undefined },
+ { args: [{}], expected: undefined },
+ { args: [[]], expected: undefined },
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeEqual()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeEqual()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeNotEqual', () => {
+ test.each([
+ { args: [2, 2], expected: false },
+ { args: [2, 3], expected: true },
+ { args: ['foo', 'foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeNotEqual()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // All types are allowed
+ { args: ['foo'], expected: undefined },
+ { args: [true], expected: undefined },
+ { args: [null], expected: undefined },
+ { args: [{}], expected: undefined },
+ { args: [[]], expected: undefined },
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeNotEqual()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeNotEqual()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeGreaterThan', () => {
+ test.each([
+ { args: [2, 2], expected: false },
+ { args: [2, 3], expected: false },
+ { args: [3, 2], expected: true },
+ { args: ['apple', 'ball'], expected: false },
+ { args: ['ball', 'apple'], expected: true },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeGreaterThan()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // All types are allowed
+ { args: ['foo'], expected: undefined },
+ { args: [true], expected: undefined },
+ { args: [null], expected: undefined },
+ { args: [{}], expected: undefined },
+ { args: [[]], expected: undefined },
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeGreaterThan()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeGreaterThan()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeLessThan', () => {
+ test.each([
+ { args: [2, 2], expected: false },
+ { args: [2, 3], expected: true },
+ { args: [3, 2], expected: false },
+ { args: ['apple', 'ball'], expected: true },
+ { args: ['ball', 'apple'], expected: false },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeLessThan()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // All types are allowed
+ { args: ['foo'], expected: undefined },
+ { args: [true], expected: undefined },
+ { args: [null], expected: undefined },
+ { args: [{}], expected: undefined },
+ { args: [[]], expected: undefined },
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeLessThan()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeLessThan()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeGreaterThanOrEqual', () => {
+ test.each([
+ { args: [2, 2], expected: true },
+ { args: [2, 3], expected: false },
+ { args: [3, 2], expected: true },
+ { args: ['apple', 'ball'], expected: false },
+ { args: ['ball', 'apple'], expected: true },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeGreaterThanOrEqual()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // All types are allowed
+ { args: ['foo'], expected: undefined },
+ { args: [true], expected: undefined },
+ { args: [null], expected: undefined },
+ { args: [{}], expected: undefined },
+ { args: [[]], expected: undefined },
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeGreaterThanOrEqual()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeGreaterThanOrEqual()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeLessThanOrEqual', () => {
+ test.each([
+ { args: [2, 2], expected: true },
+ { args: [2, 3], expected: true },
+ { args: [3, 2], expected: false },
+ { args: ['apple', 'ball'], expected: true },
+ { args: ['ball', 'apple'], expected: false },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeLessThanOrEqual()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // All types are allowed
+ { args: ['foo'], expected: undefined },
+ { args: [true], expected: undefined },
+ { args: [null], expected: undefined },
+ { args: [{}], expected: undefined },
+ { args: [[]], expected: undefined },
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeLessThanOrEqual()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeLessThanOrEqual()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeUpper', () => {
+ test.each([
+ { args: ['apple'], expected: 'APPLE' },
+ { args: ['bAll'], expected: 'BALL' },
+ { args: ['Foo Bar'], expected: 'FOO BAR' },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeUpper()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // All types are allowed
+ { args: ['foo'], expected: undefined },
+ { args: [true], expected: undefined },
+ { args: [{}], expected: undefined },
+ { args: [[]], expected: undefined },
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeUpper()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeUpper()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeLower', () => {
+ test.each([
+ { args: ['apple'], expected: 'apple' },
+ { args: ['bAll'], expected: 'ball' },
+ { args: ['Foo Bar'], expected: 'foo bar' },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeLower()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // All types are allowed
+ { args: ['foo'], expected: undefined },
+ { args: [true], expected: undefined },
+ { args: [{}], expected: undefined },
+ { args: [[]], expected: undefined },
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeLower()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeLower()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeCapitalize', () => {
+ test.each([
+ { args: ['apple'], expected: 'Apple' },
+ { args: ['bAll'], expected: 'Ball' },
+ { args: ['Foo Bar'], expected: 'Foo bar' },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeCapitalize()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // All types are allowed
+ { args: ['foo'], expected: undefined },
+ { args: [true], expected: undefined },
+ { args: [{}], expected: undefined },
+ { args: [[]], expected: undefined },
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: [1], expected: undefined },
+ { args: [3.14], expected: undefined },
+ { args: ['23'], expected: undefined },
+ { args: ['23.23'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeCapitalize()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeCapitalize()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeRound', () => {
+ test.each([
+ { args: ['23.45', 2], expected: 23.45 },
+ // Defaults to 2 decimal places
+ { args: [33.4567], expected: 33.46 },
+ { args: [33, 0], expected: 33 },
+ { args: [49.4587, 3], expected: 49.459 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeRound()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Number types are allowed
+ { args: ['23.34'], expected: undefined },
+ { args: [123], expected: undefined },
+ { args: [123.45], expected: undefined },
+ // Other types are invalid
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ {
+ args: [new Date(2025, 10, 6, 12, 30)],
+ expected: new Date(2025, 10, 6, 12, 30),
+ },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeRound()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeRound()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeIsEven', () => {
+ test.each([
+ { args: ['23.34'], expected: false },
+ { args: [24], expected: true },
+ { args: [33.4567], expected: false },
+ { args: [33], expected: false },
+ { args: [50], expected: true },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeIsEven()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Number types are allowed
+ { args: ['23.34'], expected: undefined },
+ { args: [123], expected: undefined },
+ { args: [123.45], expected: undefined },
+ // Other types are invalid
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ {
+ args: [new Date(2025, 10, 6, 12, 30)],
+ expected: new Date(2025, 10, 6, 12, 30),
+ },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeIsEven()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeIsEven()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeIsOdd', () => {
+ test.each([
+ { args: ['23.34'], expected: true },
+ { args: [24], expected: false },
+ { args: [33.4567], expected: true },
+ { args: [33], expected: true },
+ { args: [50], expected: false },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeIsOdd()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Number types are allowed
+ { args: ['23.34'], expected: undefined },
+ { args: [123], expected: undefined },
+ { args: [123.45], expected: undefined },
+ // Other types are invalid
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ {
+ args: [new Date(2025, 10, 6, 12, 30)],
+ expected: new Date(2025, 10, 6, 12, 30),
+ },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeIsOdd()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeIsOdd()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeDateTimeFormat', () => {
+ test.each([
+ { args: ['2025-11-03', 'YY/MM/DD'], expected: '25/11/03' },
+ {
+ args: ['2025-11-03', 'DD/MM/YYYY HH:mm:ss'],
+ expected: '03/11/2025 00:00:00',
+ },
+ {
+ args: ['2025-11-06 11:30:30.861096+00:00', 'DD/MM/YYYY HH:mm:ss'],
+ expected: '06/11/2025 11:30:30',
+ },
+ { args: ['2025-11-06 11:30:30.861096+00:00', 'SSS'], expected: '861' },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeDateTimeFormat()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Date values are valid
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: ['2025-11-06'], expected: undefined },
+ { args: ['2025-11-06 11:30:30.861096+00:00'], expected: undefined },
+ // All other types are invalid
+ { args: ['23.34'], expected: '23.34' },
+ { args: [123], expected: 123 },
+ { args: [123.45], expected: 123.45 },
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeDateTimeFormat()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: true },
+ { args: ['foo', 'bar', 'baz', 'x'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeDateTimeFormat()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeDay', () => {
+ test.each([
+ { args: ['2025-11-03'], expected: 3 },
+ { args: ['2025-11-04 11:30:30.861096+00:00'], expected: 4 },
+ { args: ['2025-11-05 11:30:30.861096+00:00'], expected: 5 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeDay()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Date values are valid
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: ['2025-11-06'], expected: undefined },
+ { args: ['2025-11-06 11:30:30.861096+00:00'], expected: undefined },
+ // All other types are invalid
+ { args: ['23.34'], expected: '23.34' },
+ { args: [123], expected: 123 },
+ { args: [123.45], expected: 123.45 },
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeDay()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeDay()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeMonth', () => {
+ test.each([
+ // JS months are 0-indexed
+ { args: ['2025-09-03'], expected: 8 },
+ { args: ['2025-10-04 11:30:30.861096+00:00'], expected: 9 },
+ { args: ['2025-11-05 11:30:30.861096+00:00'], expected: 10 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeMonth()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Date values are valid
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: ['2025-11-06'], expected: undefined },
+ { args: ['2025-11-06 11:30:30.861096+00:00'], expected: undefined },
+ // All other types are invalid
+ { args: ['23.34'], expected: '23.34' },
+ { args: [123], expected: 123 },
+ { args: [123.45], expected: 123.45 },
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeMonth()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeMonth()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeYear', () => {
+ test.each([
+ // JS months are 0-indexed
+ { args: ['2023-09-03'], expected: 2023 },
+ { args: ['2024-10-04 11:30:30.861096+00:00'], expected: 2024 },
+ { args: ['2025-11-05 11:30:30.861096+00:00'], expected: 2025 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeYear()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Date values are valid
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: ['2025-11-06'], expected: undefined },
+ { args: ['2025-11-06 11:30:30.861096+00:00'], expected: undefined },
+ // All other types are invalid
+ { args: ['23.34'], expected: '23.34' },
+ { args: [123], expected: 123 },
+ { args: [123.45], expected: 123.45 },
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeYear()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeYear()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeHour', () => {
+ test.each([
+ // JS months are 0-indexed
+ { args: ['2023-09-03'], expected: 0 },
+ { args: ['2024-10-04 11:30:30.861096+00:00'], expected: 11 },
+ { args: ['2025-11-05 12:30:30.861096+00:00'], expected: 12 },
+ { args: ['2025-11-05 16:30:30.861096+00:00'], expected: 16 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeHour()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Date values are valid
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: ['2025-11-06'], expected: undefined },
+ { args: ['2025-11-06 11:30:30.861096+00:00'], expected: undefined },
+ // All other types are invalid
+ { args: ['23.34'], expected: '23.34' },
+ { args: [123], expected: 123 },
+ { args: [123.45], expected: 123.45 },
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeHour()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeHour()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeMinute', () => {
+ test.each([
+ // JS months are 0-indexed
+ { args: ['2023-09-03'], expected: 0 },
+ { args: ['2024-10-04 11:28:31.861096+00:00'], expected: 28 },
+ { args: ['2025-11-05 12:29:32.861096+00:00'], expected: 29 },
+ { args: ['2025-11-05 16:30:33.861096+00:00'], expected: 30 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeMinute()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Date values are valid
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: ['2025-11-06'], expected: undefined },
+ { args: ['2025-11-06 11:30:30.861096+00:00'], expected: undefined },
+ // All other types are invalid
+ { args: ['23.34'], expected: '23.34' },
+ { args: [123], expected: 123 },
+ { args: [123.45], expected: 123.45 },
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeMinute()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeMinute()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeSecond', () => {
+ test.each([
+ // JS months are 0-indexed
+ { args: ['2023-09-03'], expected: 0 },
+ { args: ['2024-10-04 11:28:31.861096+00:00'], expected: 31 },
+ { args: ['2025-11-05 12:29:32.861096+00:00'], expected: 32 },
+ { args: ['2025-11-05 16:30:33.861096+00:00'], expected: 33 },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeSecond()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Date values are valid
+ { args: [new Date(2025, 10, 6, 12, 30)], expected: undefined },
+ { args: ['2025-11-06'], expected: undefined },
+ { args: ['2025-11-06 11:30:30.861096+00:00'], expected: undefined },
+ // All other types are invalid
+ { args: ['23.34'], expected: '23.34' },
+ { args: [123], expected: 123 },
+ { args: [123.45], expected: 123.45 },
+ { args: ['foo'], expected: 'foo' },
+ { args: [true], expected: true },
+ { args: [{}], expected: {} },
+ { args: [[]], expected: [] },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeSecond()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: true },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeSecond()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeNow', () => {
+ beforeAll(() => {
+ jest.useFakeTimers()
+ jest.setSystemTime(new Date('2025-11-11T10:40:33.638Z'))
+ })
+
+ afterAll(() => {
+ jest.useRealTimers()
+ })
+
+ test('execute returns expected value', () => {
+ const formulaType = new RuntimeNow()
+ const parsedArgs = formulaType.parseArgs([])
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result.toISOString()).toBe('2025-11-11T10:40:33.638Z')
+ })
+
+ test.each([
+ { args: [], expected: true },
+ { args: ['foo'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeNow()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeToday', () => {
+ beforeAll(() => {
+ jest.useFakeTimers()
+ jest.setSystemTime(new Date('2025-11-11T10:40:33.638Z'))
+ })
+
+ afterAll(() => {
+ jest.useRealTimers()
+ })
+
+ test('execute returns expected value', () => {
+ const formulaType = new RuntimeToday()
+ const parsedArgs = formulaType.parseArgs([])
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe('2025-11-11')
+ })
+
+ test.each([
+ { args: [], expected: true },
+ { args: ['foo'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeToday()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeGetProperty', () => {
+ test.each([
+ { args: ['{"foo": "bar"}', 'foo'], expected: 'bar' },
+ { args: [{ foo: 'bar' }, 'baz'], expected: undefined },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeGetProperty()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toBe(expected)
+ })
+
+ test.each([
+ // Object like values are allowed
+ { args: ['{"foo": "bar"}', 'foo'], expected: undefined },
+ { args: [{ foo: 'bar' }, 'baz'], expected: undefined },
+ // Invalid types for 1st arg (2nd arg is cast to string)
+ { args: ['foo', 'foo'], expected: 'foo' },
+ { args: [12.34, 'bar'], expected: 12.34 },
+ { args: [null, 'bar'], expected: null },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeGetProperty()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeGetProperty()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeRandomInt', () => {
+ test.each([{ args: [1, 100] }, { args: [10.24, 100.54] }])(
+ 'execute returns expected value',
+ ({ args }) => {
+ const formulaType = new RuntimeRandomInt()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toEqual(expect.any(Number))
+ }
+ )
+
+ test.each([
+ // Object like values are allowed
+ { args: [1, 100], expected: undefined },
+ { args: [2.5, 56.64], expected: undefined },
+ { args: ['3', '4.5'], expected: undefined },
+ // Invalid types for 1st arg
+ { args: [{}, 5], expected: {} },
+ { args: ['foo', 5], expected: 'foo' },
+ // Invalid types for 2nd arg
+ { args: [5, {}], expected: {} },
+ { args: [5, 'foo'], expected: 'foo' },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeRandomInt()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeRandomInt()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeRandomFloat', () => {
+ test.each([{ args: [1, 100] }, { args: [10.24, 100.54] }])(
+ 'execute returns expected value',
+ ({ args }) => {
+ const formulaType = new RuntimeRandomFloat()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toEqual(expect.any(Number))
+ }
+ )
+
+ test.each([
+ // Object like values are allowed
+ { args: [1, 100], expected: undefined },
+ { args: [2.5, 56.64], expected: undefined },
+ { args: ['3', '4.5'], expected: undefined },
+ // Invalid types for 1st arg
+ { args: [{}, 5], expected: {} },
+ { args: ['foo', 5], expected: 'foo' },
+ // Invalid types for 2nd arg
+ { args: [5, {}], expected: {} },
+ { args: [5, 'foo'], expected: 'foo' },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeRandomFloat()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeRandomFloat()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeRandomBool', () => {
+ test('execute returns expected value', () => {
+ const formulaType = new RuntimeRandomBool()
+ const parsedArgs = formulaType.parseArgs([])
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toEqual(expect.any(Boolean))
+ })
+
+ test.each([
+ { args: [], expected: true },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeRandomBool()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeGenerateUUID', () => {
+ test('execute returns expected value', () => {
+ const formulaType = new RuntimeGenerateUUID()
+ const parsedArgs = formulaType.parseArgs([])
+ const result = formulaType.execute({}, parsedArgs)
+
+ expect(typeof result).toBe('string')
+ const uuidV4Regex =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+
+ expect(uuidV4Regex.test(result)).toBe(true)
+ })
+
+ test.each([
+ { args: [], expected: true },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeGenerateUUID()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeIf', () => {
+ test.each([
+ { args: [true, 'foo', 'bar'], expected: 'foo' },
+ { args: [false, 'foo', 'bar'], expected: 'bar' },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeIf()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toEqual(expected)
+ })
+
+ test.each([
+ // Valid types for 1st arg (2nd and 3rd args can be Any)
+ { args: [true, 'foo', 'bar'], expected: undefined },
+ { args: [false, 'foo', 'bar'], expected: undefined },
+ { args: ['true', 'foo', 'bar'], expected: undefined },
+ { args: ['false', 'foo', 'bar'], expected: undefined },
+ { args: ['True', 'foo', 'bar'], expected: undefined },
+ { args: ['False', 'foo', 'bar'], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeIf()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: false },
+ { args: ['foo', 'bar', 'baz'], expected: true },
+ { args: ['foo', 'bar', 'baz', 'x'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeIf()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+
+describe('RuntimeAnd', () => {
+ test.each([
+ { args: [true, true], expected: true },
+ { args: [true, false], expected: false },
+ { args: [false, false], expected: false },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeAnd()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toEqual(expected)
+ })
+
+ test.each([
+ // Valid types for 1st arg
+ { args: [true, true], expected: undefined },
+ { args: [false, true], expected: undefined },
+ { args: ['true', true], expected: undefined },
+ { args: ['false', true], expected: undefined },
+ { args: ['True', true], expected: undefined },
+ { args: ['False', true], expected: undefined },
+ // Valid types for 2nd arg
+ { args: [true, false], expected: undefined },
+ { args: [true, false], expected: undefined },
+ { args: [true, 'true'], expected: undefined },
+ { args: [true, 'false'], expected: undefined },
+ { args: [true, 'True'], expected: undefined },
+ { args: [true, 'False'], expected: undefined },
+ // Invalid types for 1st arg
+ { args: ['foo', true], expected: undefined },
+ { args: [{}, true], expected: undefined },
+ { args: ['', true], expected: undefined },
+ { args: [100, true], expected: undefined },
+ // Invalid types for 2nd arg
+ { args: [true, 'foo'], expected: undefined },
+ { args: [true, {}], expected: undefined },
+ { args: [true, ''], expected: undefined },
+ { args: [true, 100], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeAnd()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeAnd()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
+//
+
+describe('RuntimeOr', () => {
+ test.each([
+ { args: [true, true], expected: true },
+ { args: [true, false], expected: true },
+ { args: [false, false], expected: false },
+ ])('execute returns expected value', ({ args, expected }) => {
+ const formulaType = new RuntimeOr()
+ const parsedArgs = formulaType.parseArgs(args)
+ const result = formulaType.execute({}, parsedArgs)
+ expect(result).toEqual(expected)
+ })
+
+ test.each([
+ // Valid types for 1st arg
+ { args: [true, true], expected: undefined },
+ { args: [false, true], expected: undefined },
+ { args: ['true', true], expected: undefined },
+ { args: ['false', true], expected: undefined },
+ { args: ['True', true], expected: undefined },
+ { args: ['False', true], expected: undefined },
+ // Valid types for 2nd arg
+ { args: [true, false], expected: undefined },
+ { args: [true, false], expected: undefined },
+ { args: [true, 'true'], expected: undefined },
+ { args: [true, 'false'], expected: undefined },
+ { args: [true, 'True'], expected: undefined },
+ { args: [true, 'False'], expected: undefined },
+ // Invalid types for 1st arg
+ { args: ['foo', true], expected: undefined },
+ { args: [{}, true], expected: undefined },
+ { args: ['', true], expected: undefined },
+ { args: [100, true], expected: undefined },
+ // Invalid types for 2nd arg
+ { args: [true, 'foo'], expected: undefined },
+ { args: [true, {}], expected: undefined },
+ { args: [true, ''], expected: undefined },
+ { args: [true, 100], expected: undefined },
+ ])('validates type of args', ({ args, expected }) => {
+ const formulaType = new RuntimeOr()
+ const result = formulaType.validateTypeOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+
+ test.each([
+ { args: [], expected: false },
+ { args: ['foo'], expected: false },
+ { args: ['foo', 'bar'], expected: true },
+ { args: ['foo', 'bar', 'baz'], expected: false },
+ ])('validates number of args', ({ args, expected }) => {
+ const formulaType = new RuntimeOr()
+ const result = formulaType.validateNumberOfArgs(args)
+ expect(result).toStrictEqual(expected)
+ })
+})
From dbb1bcdd62045bb01e8e1ac89a8fba90da9ed8fe Mon Sep 17 00:00:00 2001
From: Peter Evans
Date: Thu, 13 Nov 2025 11:33:33 +0000
Subject: [PATCH 5/6] Slack send message service (#4187)
* Initial working commit
* Post rebase tweaks.
* Backend tests; more moving files around; post-rebase tweaks.
* Improving the experience setting up the slack bot integration. There are now instructions inside the modal to guide you.
* We don't always start with an integrationType.
* Small bug fixes and improvements.
* Lint fix
* Post-rebase lint
* Forgot a comma
* Address spirit-check feedback
* Address feedback
---
.../src/baserow/contrib/automation/apps.py | 2 +
.../0023_slack_write_message_node.py | 36 ++
.../contrib/automation/nodes/handler.py | 2 +-
.../contrib/automation/nodes/models.py | 4 +
.../contrib/automation/nodes/node_types.py | 10 +
.../src/baserow/contrib/integrations/apps.py | 10 +
.../integrations/core/integration_types.py | 3 +-
.../integrations/core/service_types.py | 21 +-
...slack_integration_write_message_service.py | 79 ++++
.../contrib/integrations/slack/__init__.py | 0
.../integrations/slack/integration_types.py | 47 +++
.../contrib/integrations/slack/models.py | 23 ++
.../integrations/slack/service_types.py | 167 +++++++++
.../src/baserow/contrib/integrations/utils.py | 21 ++
.../slack/test_slack_bot_integration_type.py | 23 ++
.../test_slack_write_message_service_type.py | 351 ++++++++++++++++++
.../workflow/WorkflowNodeContent.vue | 2 +
.../modules/automation/locales/en.json | 4 +-
web-frontend/modules/automation/nodeTypes.js | 40 ++
web-frontend/modules/automation/plugin.js | 2 +
.../workflow/workflow_node_content.scss | 5 +-
.../scss/components/integrations/all.scss | 1 +
.../integrations/slack/slack_bot_form.scss | 11 +
.../integrations/IntegrationDropdown.vue | 1 +
.../integrations/IntegrationEditForm.vue | 3 +-
web-frontend/modules/core/locales/en.json | 2 +-
.../modules/integrations/locales/en.json | 38 +-
web-frontend/modules/integrations/plugin.js | 6 +-
.../slack/assets/images/slack.svg | 1 +
.../components/integrations/SlackBotForm.vue | 117 ++++++
.../services/SlackWriteMessageServiceForm.vue | 111 ++++++
.../integrations/slack/integrationTypes.js | 36 ++
.../integrations/slack/serviceTypes.js | 49 +++
33 files changed, 1200 insertions(+), 28 deletions(-)
create mode 100644 backend/src/baserow/contrib/automation/migrations/0023_slack_write_message_node.py
create mode 100644 backend/src/baserow/contrib/integrations/migrations/0024_slack_integration_write_message_service.py
create mode 100644 backend/src/baserow/contrib/integrations/slack/__init__.py
create mode 100644 backend/src/baserow/contrib/integrations/slack/integration_types.py
create mode 100644 backend/src/baserow/contrib/integrations/slack/models.py
create mode 100644 backend/src/baserow/contrib/integrations/slack/service_types.py
create mode 100644 backend/src/baserow/contrib/integrations/utils.py
create mode 100644 backend/tests/baserow/contrib/integrations/slack/test_slack_bot_integration_type.py
create mode 100644 backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py
create mode 100644 web-frontend/modules/core/assets/scss/components/integrations/slack/slack_bot_form.scss
create mode 100644 web-frontend/modules/integrations/slack/assets/images/slack.svg
create mode 100644 web-frontend/modules/integrations/slack/components/integrations/SlackBotForm.vue
create mode 100644 web-frontend/modules/integrations/slack/components/services/SlackWriteMessageServiceForm.vue
create mode 100644 web-frontend/modules/integrations/slack/integrationTypes.js
create mode 100644 web-frontend/modules/integrations/slack/serviceTypes.js
diff --git a/backend/src/baserow/contrib/automation/apps.py b/backend/src/baserow/contrib/automation/apps.py
index 590e980f7a..58e56543ff 100644
--- a/backend/src/baserow/contrib/automation/apps.py
+++ b/backend/src/baserow/contrib/automation/apps.py
@@ -36,6 +36,7 @@ def ready(self):
LocalBaserowRowsDeletedNodeTriggerType,
LocalBaserowRowsUpdatedNodeTriggerType,
LocalBaserowUpdateRowNodeType,
+ SlackWriteMessageActionNodeType,
)
from baserow.contrib.automation.nodes.object_scopes import (
AutomationNodeObjectScopeType,
@@ -167,6 +168,7 @@ def ready(self):
LocalBaserowRowsDeletedNodeTriggerType()
)
automation_node_type_registry.register(CorePeriodicTriggerNodeType())
+ automation_node_type_registry.register(SlackWriteMessageActionNodeType())
automation_node_type_registry.register(CoreHTTPTriggerNodeType())
automation_node_type_registry.register(AIAgentActionNodeType())
diff --git a/backend/src/baserow/contrib/automation/migrations/0023_slack_write_message_node.py b/backend/src/baserow/contrib/automation/migrations/0023_slack_write_message_node.py
new file mode 100644
index 0000000000..5cc283ecac
--- /dev/null
+++ b/backend/src/baserow/contrib/automation/migrations/0023_slack_write_message_node.py
@@ -0,0 +1,36 @@
+# Generated by Django 5.0.14 on 2025-11-04 11:15
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ (
+ "automation",
+ "0022_aiagentactionnode",
+ ),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="SlackWriteMessageActionNode",
+ fields=[
+ (
+ "automationnode_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="automation.automationnode",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("automation.automationnode",),
+ ),
+ ]
diff --git a/backend/src/baserow/contrib/automation/nodes/handler.py b/backend/src/baserow/contrib/automation/nodes/handler.py
index d5914059dc..ffceb92342 100644
--- a/backend/src/baserow/contrib/automation/nodes/handler.py
+++ b/backend/src/baserow/contrib/automation/nodes/handler.py
@@ -410,5 +410,5 @@ def dispatch_node(
)
except ServiceImproperlyConfiguredDispatchException as e:
raise AutomationNodeMisconfiguredService(
- f"The node {node.id} has a misconfigured service."
+ f"The node {node.id} is misconfigured and cannot be dispatched. {str(e)}"
) from e
diff --git a/backend/src/baserow/contrib/automation/nodes/models.py b/backend/src/baserow/contrib/automation/nodes/models.py
index 70c48df569..1e80bc5d10 100644
--- a/backend/src/baserow/contrib/automation/nodes/models.py
+++ b/backend/src/baserow/contrib/automation/nodes/models.py
@@ -228,3 +228,7 @@ class CoreIteratorActionNode(AutomationActionNode):
class AIAgentActionNode(AutomationActionNode):
...
+
+
+class SlackWriteMessageActionNode(AutomationActionNode):
+ ...
diff --git a/backend/src/baserow/contrib/automation/nodes/node_types.py b/backend/src/baserow/contrib/automation/nodes/node_types.py
index fb9ea88f3e..9dc510a522 100644
--- a/backend/src/baserow/contrib/automation/nodes/node_types.py
+++ b/backend/src/baserow/contrib/automation/nodes/node_types.py
@@ -34,6 +34,7 @@
LocalBaserowRowsDeletedTriggerNode,
LocalBaserowRowsUpdatedTriggerNode,
LocalBaserowUpdateRowActionNode,
+ SlackWriteMessageActionNode,
)
from baserow.contrib.automation.nodes.registries import AutomationNodeType
from baserow.contrib.automation.nodes.types import NodePositionType
@@ -58,6 +59,9 @@
LocalBaserowRowsUpdatedServiceType,
LocalBaserowUpsertRowServiceType,
)
+from baserow.contrib.integrations.slack.service_types import (
+ SlackWriteMessageServiceType,
+)
from baserow.core.registry import Instance
from baserow.core.services.models import Service
from baserow.core.services.registries import service_type_registry
@@ -404,3 +408,9 @@ class CoreHTTPTriggerNodeType(AutomationNodeTriggerType):
type = "http_trigger"
model_class = CoreHTTPTriggerNode
service_type = CoreHTTPTriggerServiceType.type
+
+
+class SlackWriteMessageActionNodeType(AutomationNodeActionNodeType):
+ type = "slack_write_message"
+ model_class = SlackWriteMessageActionNode
+ service_type = SlackWriteMessageServiceType.type
diff --git a/backend/src/baserow/contrib/integrations/apps.py b/backend/src/baserow/contrib/integrations/apps.py
index 0cfec6cc01..9d07b16d77 100644
--- a/backend/src/baserow/contrib/integrations/apps.py
+++ b/backend/src/baserow/contrib/integrations/apps.py
@@ -12,12 +12,16 @@ def ready(self):
from baserow.contrib.integrations.local_baserow.integration_types import (
LocalBaserowIntegrationType,
)
+ from baserow.contrib.integrations.slack.integration_types import (
+ SlackBotIntegrationType,
+ )
from baserow.core.integrations.registries import integration_type_registry
from baserow.core.services.registries import service_type_registry
integration_type_registry.register(LocalBaserowIntegrationType())
integration_type_registry.register(SMTPIntegrationType())
integration_type_registry.register(AIIntegrationType())
+ integration_type_registry.register(SlackBotIntegrationType())
from baserow.contrib.integrations.local_baserow.service_types import (
LocalBaserowAggregateRowsUserServiceType,
@@ -39,6 +43,12 @@ def ready(self):
service_type_registry.register(LocalBaserowRowsUpdatedServiceType())
service_type_registry.register(LocalBaserowRowsDeletedServiceType())
+ from baserow.contrib.integrations.slack.service_types import (
+ SlackWriteMessageServiceType,
+ )
+
+ service_type_registry.register(SlackWriteMessageServiceType())
+
from baserow.contrib.integrations.core.service_types import (
CoreHTTPRequestServiceType,
CoreHTTPTriggerServiceType,
diff --git a/backend/src/baserow/contrib/integrations/core/integration_types.py b/backend/src/baserow/contrib/integrations/core/integration_types.py
index e59ae78b3c..a3c468dd70 100644
--- a/backend/src/baserow/contrib/integrations/core/integration_types.py
+++ b/backend/src/baserow/contrib/integrations/core/integration_types.py
@@ -1,8 +1,7 @@
+from baserow.contrib.integrations.core.models import SMTPIntegration
from baserow.core.integrations.registries import IntegrationType
from baserow.core.integrations.types import IntegrationDict
-from .models import SMTPIntegration
-
class SMTPIntegrationType(IntegrationType):
type = "smtp"
diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py
index 656601d10d..c70af2bd99 100644
--- a/backend/src/baserow/contrib/integrations/core/service_types.py
+++ b/backend/src/baserow/contrib/integrations/core/service_types.py
@@ -54,6 +54,7 @@
HTTPHeader,
HTTPQueryParam,
)
+from baserow.contrib.integrations.utils import get_http_request_function
from baserow.core.formula.types import BaserowFormulaObject
from baserow.core.formula.validator import (
ensure_array,
@@ -535,24 +536,6 @@ def formulas_to_resolve(
return formulas
- def _get_request_function(self) -> callable:
- """
- Return the appropriate request function based on production environment
- or settings.
- In production mode, the advocate library is used so that the internal
- network can't be reached. This can be disabled by changing the Django
- setting INTEGRATIONS_ALLOW_PRIVATE_ADDRESS.
- """
-
- if settings.INTEGRATIONS_ALLOW_PRIVATE_ADDRESS is True:
- from requests import request
-
- return request
- else:
- from advocate import request
-
- return request
-
def dispatch_data(
self,
service: CoreHTTPRequestService,
@@ -589,7 +572,7 @@ def dispatch_data(
}
try:
- response = self._get_request_function()(
+ response = get_http_request_function()(
method=service.http_method,
url=resolved_values["url"],
headers=headers,
diff --git a/backend/src/baserow/contrib/integrations/migrations/0024_slack_integration_write_message_service.py b/backend/src/baserow/contrib/integrations/migrations/0024_slack_integration_write_message_service.py
new file mode 100644
index 0000000000..c0933dfd66
--- /dev/null
+++ b/backend/src/baserow/contrib/integrations/migrations/0024_slack_integration_write_message_service.py
@@ -0,0 +1,79 @@
+# Generated by Django 5.0.14 on 2025-11-04 11:20
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import baserow.core.formula.field
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("core", "0106_schemaoperation"),
+ ("integrations", "0023_aiagentservice_aiintegration"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="SlackBotIntegration",
+ fields=[
+ (
+ "integration_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="core.integration",
+ ),
+ ),
+ (
+ "token",
+ models.CharField(
+ help_text="The Bot User OAuth Token listed in your Slack bot's OAuth & Permissions page.",
+ max_length=255,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("core.integration",),
+ ),
+ migrations.CreateModel(
+ name="SlackWriteMessageService",
+ fields=[
+ (
+ "service_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="core.service",
+ ),
+ ),
+ (
+ "channel",
+ models.CharField(
+ help_text="The Slack channel ID where the message will be sent.",
+ max_length=80,
+ ),
+ ),
+ (
+ "text",
+ baserow.core.formula.field.FormulaField(
+ blank=True,
+ default="",
+ help_text="The text content of the Slack message.",
+ null=True,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("core.service",),
+ ),
+ ]
diff --git a/backend/src/baserow/contrib/integrations/slack/__init__.py b/backend/src/baserow/contrib/integrations/slack/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/src/baserow/contrib/integrations/slack/integration_types.py b/backend/src/baserow/contrib/integrations/slack/integration_types.py
new file mode 100644
index 0000000000..672b843d51
--- /dev/null
+++ b/backend/src/baserow/contrib/integrations/slack/integration_types.py
@@ -0,0 +1,47 @@
+from typing import Any, Dict
+
+from baserow.contrib.integrations.slack.models import SlackBotIntegration
+from baserow.core.integrations.registries import IntegrationType
+from baserow.core.integrations.types import IntegrationDict
+from baserow.core.models import Application
+
+
+class SlackBotIntegrationType(IntegrationType):
+ type = "slack_bot"
+ model_class = SlackBotIntegration
+
+ class SerializedDict(IntegrationDict):
+ token: str
+
+ serializer_field_names = ["token"]
+ allowed_fields = ["token"]
+ sensitive_fields = ["token"]
+
+ request_serializer_field_names = ["token"]
+ request_serializer_field_overrides = {}
+
+ def import_serialized(
+ self,
+ application: Application,
+ serialized_values: Dict[str, Any],
+ id_mapping: Dict,
+ files_zip=None,
+ storage=None,
+ cache=None,
+ ) -> SlackBotIntegration:
+ """
+ Imports a serialized integration. Ensures that if we're importing an exported
+ integration (where `token` will be `None`), we set it to an empty string.
+ """
+
+ if serialized_values["token"] is None:
+ serialized_values["token"] = "" # nosec B105
+
+ return super().import_serialized(
+ application,
+ serialized_values,
+ id_mapping,
+ files_zip=files_zip,
+ storage=storage,
+ cache=cache,
+ )
diff --git a/backend/src/baserow/contrib/integrations/slack/models.py b/backend/src/baserow/contrib/integrations/slack/models.py
new file mode 100644
index 0000000000..6875b1773d
--- /dev/null
+++ b/backend/src/baserow/contrib/integrations/slack/models.py
@@ -0,0 +1,23 @@
+from django.db import models
+
+from baserow.core.formula.field import FormulaField
+from baserow.core.integrations.models import Integration
+from baserow.core.services.models import Service
+
+
+class SlackBotIntegration(Integration):
+ token = models.CharField(
+ max_length=255,
+ help_text="The Bot User OAuth Token listed in "
+ "your Slack bot's OAuth & Permissions page.",
+ )
+
+
+class SlackWriteMessageService(Service):
+ channel = models.CharField(
+ max_length=80,
+ help_text="The Slack channel ID where the message will be sent.",
+ )
+ text = FormulaField(
+ help_text="The text content of the Slack message.",
+ )
diff --git a/backend/src/baserow/contrib/integrations/slack/service_types.py b/backend/src/baserow/contrib/integrations/slack/service_types.py
new file mode 100644
index 0000000000..a2a363661a
--- /dev/null
+++ b/backend/src/baserow/contrib/integrations/slack/service_types.py
@@ -0,0 +1,167 @@
+from typing import Any, Dict, List, Optional
+
+from django.utils.translation import gettext as _
+
+from loguru import logger
+from requests import exceptions as request_exceptions
+from rest_framework import serializers
+
+from baserow.contrib.integrations.slack.integration_types import SlackBotIntegrationType
+from baserow.contrib.integrations.slack.models import SlackWriteMessageService
+from baserow.contrib.integrations.utils import get_http_request_function
+from baserow.core.formula import BaserowFormulaObject
+from baserow.core.formula.validator import ensure_string
+from baserow.core.services.dispatch_context import DispatchContext
+from baserow.core.services.exceptions import (
+ ServiceImproperlyConfiguredDispatchException,
+ UnexpectedDispatchException,
+)
+from baserow.core.services.registries import DispatchTypes, ServiceType
+from baserow.core.services.types import DispatchResult, FormulaToResolve, ServiceDict
+
+
+class SlackWriteMessageServiceType(ServiceType):
+ type = "slack_write_message"
+ model_class = SlackWriteMessageService
+ dispatch_types = [DispatchTypes.ACTION]
+ integration_type = SlackBotIntegrationType.type
+
+ allowed_fields = ["integration_id", "channel", "text"]
+ serializer_field_names = ["integration_id", "channel", "text"]
+ simple_formula_fields = ["text"]
+
+ class SerializedDict(ServiceDict):
+ channel: str
+ text: BaserowFormulaObject
+
+ @property
+ def serializer_field_overrides(self):
+ from baserow.core.formula.serializers import FormulaSerializerField
+
+ return {
+ "integration_id": serializers.IntegerField(
+ required=False,
+ allow_null=True,
+ help_text="The id of the Slack bot integration.",
+ ),
+ "channel": serializers.CharField(
+ help_text=SlackWriteMessageService._meta.get_field("channel").help_text,
+ allow_blank=True,
+ required=False,
+ default="",
+ ),
+ "text": FormulaSerializerField(
+ help_text=SlackWriteMessageService._meta.get_field("text").help_text
+ ),
+ }
+
+ def formulas_to_resolve(
+ self, service: SlackWriteMessageService
+ ) -> list[FormulaToResolve]:
+ return [
+ FormulaToResolve(
+ "text",
+ service.text,
+ ensure_string,
+ 'property "text"',
+ ),
+ ]
+
+ def dispatch_data(
+ self,
+ service: SlackWriteMessageService,
+ resolved_values: Dict[str, Any],
+ dispatch_context: DispatchContext,
+ ) -> Dict[str, Dict[str, Any]]:
+ """
+ Dispatches the Slack write message service by sending a message to the
+ specified Slack channel using the Slack API.
+
+ :param service: The SlackWriteMessageService instance to be dispatched.
+ :param resolved_values: A dictionary containing the resolved values for the
+ service's fields, including the message text.
+ :param dispatch_context: The context in which the dispatch is occurring.
+ :return: A dictionary containing the response data from the Slack API.
+ :raises UnexpectedDispatchException: If there's an error after the HTTP request.
+ :raises ServiceImproperlyConfiguredDispatchException: If the Slack service is
+ improperly configured, indicated by specific error codes from the Slack API.
+ """
+
+ try:
+ token = service.integration.specific.token
+ response = get_http_request_function()(
+ method="POST",
+ url="https://slack.com/api/chat.postMessage",
+ headers={"Authorization": f"Bearer {token}"},
+ params={
+ "channel": f"#{service.channel}",
+ "text": resolved_values["text"],
+ },
+ timeout=10,
+ )
+ response_data = response.json()
+ except request_exceptions.RequestException as e:
+ raise UnexpectedDispatchException(str(e)) from e
+ except Exception as e:
+ logger.exception("Error while dispatching HTTP request")
+ raise UnexpectedDispatchException(f"Unknown error: {str(e)}") from e
+
+ # If we've found that the response indicates an error, we raise a
+ # ServiceImproperlyConfiguredDispatchException with a relevant message.
+ if not response_data.get("ok", False):
+ # Some frequently occurring error codes from Slack API. Full list:
+ # https://docs.slack.dev/reference/methods/chat.postMessage/
+ misconfigured_service_error_codes = {
+ "no_text": "The message text is missing.",
+ "invalid_auth": "Invalid bot user token.",
+ "channel_not_found": "The channel #{channel} was not found.",
+ "not_in_channel": "Your app has not been invited to channel #{channel}.",
+ "rate_limited": "Your app has sent too many requests in a "
+ "short period of time.",
+ "default": "An unknown error occurred while sending the message, "
+ "the error code was: {error_code}",
+ }
+ error_code = response_data["error"]
+ misconfigured_service_message = misconfigured_service_error_codes.get(
+ error_code, misconfigured_service_error_codes["default"]
+ ).format(channel=service.channel, error_code=error_code)
+ raise ServiceImproperlyConfiguredDispatchException(
+ misconfigured_service_message
+ )
+ return {"data": response_data}
+
+ def dispatch_transform(self, data):
+ return DispatchResult(data=data)
+
+ def get_schema_name(self, service: SlackWriteMessageService) -> str:
+ return f"SlackWriteMessage{service.id}Schema"
+
+ def generate_schema(
+ self,
+ service: SlackWriteMessageService,
+ allowed_fields: Optional[List[str]] = None,
+ ) -> Optional[Dict[str, Any]]:
+ """
+ Generates a JSON schema for the Slack write message service.
+
+ :param service: The SlackWriteMessageService instance for which to generate the
+ schema.
+ :param allowed_fields: An optional list of fields to include in the schema.
+ :return: A dictionary representing the JSON schema of the service.
+ """
+
+ properties = {}
+ if allowed_fields is None or "ok" in allowed_fields:
+ properties.update(
+ **{
+ "ok": {
+ "type": "boolean",
+ "title": _("OK"),
+ },
+ }
+ )
+ return {
+ "title": self.get_schema_name(service),
+ "type": "object",
+ "properties": properties,
+ }
diff --git a/backend/src/baserow/contrib/integrations/utils.py b/backend/src/baserow/contrib/integrations/utils.py
new file mode 100644
index 0000000000..6a05f74a64
--- /dev/null
+++ b/backend/src/baserow/contrib/integrations/utils.py
@@ -0,0 +1,21 @@
+from typing import Callable
+
+from django.conf import settings
+
+import advocate
+import requests
+
+
+def get_http_request_function() -> Callable:
+ """
+ Return the appropriate request function based on production environment
+ or settings.
+ In production mode, the advocate library is used so that the internal
+ network can't be reached. This can be disabled by changing the Django
+ setting INTEGRATIONS_ALLOW_PRIVATE_ADDRESS.
+ """
+
+ if settings.INTEGRATIONS_ALLOW_PRIVATE_ADDRESS is True:
+ return requests.request
+ else:
+ return advocate.request
diff --git a/backend/tests/baserow/contrib/integrations/slack/test_slack_bot_integration_type.py b/backend/tests/baserow/contrib/integrations/slack/test_slack_bot_integration_type.py
new file mode 100644
index 0000000000..a8988d713b
--- /dev/null
+++ b/backend/tests/baserow/contrib/integrations/slack/test_slack_bot_integration_type.py
@@ -0,0 +1,23 @@
+import pytest
+
+from baserow.contrib.integrations.slack.models import SlackBotIntegration
+from baserow.core.integrations.registries import integration_type_registry
+from baserow.core.integrations.service import IntegrationService
+
+
+@pytest.mark.django_db
+def test_slack_bot_integration_creation(data_fixture):
+ user = data_fixture.create_user()
+ application = data_fixture.create_automation_application(user=user)
+
+ integration_type = integration_type_registry.get("slack_bot")
+
+ integration = IntegrationService().create_integration(
+ user,
+ integration_type,
+ application=application,
+ )
+
+ assert integration.token == ""
+ assert integration.application_id == application.id
+ assert isinstance(integration, SlackBotIntegration)
diff --git a/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py b/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py
new file mode 100644
index 0000000000..8d2e4fc5ec
--- /dev/null
+++ b/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py
@@ -0,0 +1,351 @@
+import json
+from unittest.mock import Mock, patch
+
+import pytest
+
+from baserow.contrib.automation.automation_dispatch_context import (
+ AutomationDispatchContext,
+)
+from baserow.contrib.automation.formula_importer import import_formula
+from baserow.contrib.integrations.slack.service_types import (
+ SlackWriteMessageServiceType,
+)
+from baserow.core.integrations.registries import integration_type_registry
+from baserow.core.integrations.service import IntegrationService
+from baserow.core.services.exceptions import (
+ ServiceImproperlyConfiguredDispatchException,
+)
+from baserow.core.services.handler import ServiceHandler
+from baserow.core.services.types import DispatchResult
+from baserow.test_utils.helpers import AnyInt
+from baserow.test_utils.pytest_conftest import FakeDispatchContext
+
+
+@pytest.mark.django_db
+def test_dispatch_slack_write_message_basic(data_fixture):
+ user = data_fixture.create_user()
+ application = data_fixture.create_automation_application(user=user)
+
+ integration = IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("slack_bot"),
+ application=application,
+ token="xoxb-test-token-12345",
+ )
+
+ service = ServiceHandler().create_service(
+ SlackWriteMessageServiceType(),
+ integration=integration,
+ channel="general",
+ text="'Hello from Baserow!'",
+ )
+
+ service_type = service.get_type()
+ dispatch_context = FakeDispatchContext()
+
+ # Mock the HTTP request
+ mock_response = Mock()
+ mock_response.json.return_value = {
+ "ok": True,
+ "channel": "C123456",
+ "ts": "1503435956.000247",
+ "message": {"text": "Hello from Baserow!", "username": "baserow_bot"},
+ }
+
+ mock_request = Mock(return_value=mock_response)
+
+ with patch(
+ "baserow.contrib.integrations.slack.service_types.get_http_request_function",
+ return_value=mock_request,
+ ):
+ dispatch_data = service_type.dispatch(service, dispatch_context)
+
+ mock_request.assert_called_once_with(
+ method="POST",
+ url="https://slack.com/api/chat.postMessage",
+ headers={"Authorization": "Bearer xoxb-test-token-12345"},
+ params={
+ "channel": "#general",
+ "text": "Hello from Baserow!",
+ },
+ timeout=10,
+ )
+
+ assert dispatch_data.data["data"]["ok"] is True
+ assert "channel" in dispatch_data.data["data"]
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "error_code,expected_message",
+ [
+ ("no_text", "The message text is missing."),
+ ("invalid_auth", "Invalid bot user token."),
+ ("channel_not_found", "The channel #general was not found."),
+ ("not_in_channel", "Your app has not been invited to channel #general."),
+ (
+ "rate_limited",
+ "Your app has sent too many requests in a short period of time.",
+ ),
+ (
+ "some_unknown_error",
+ "An unknown error occurred while sending the message, the error code was: some_unknown_error",
+ ),
+ ],
+)
+def test_dispatch_slack_write_message_api_errors(
+ data_fixture, error_code, expected_message
+):
+ user = data_fixture.create_user()
+ application = data_fixture.create_automation_application(user=user)
+
+ integration = IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("slack_bot"),
+ application=application,
+ token="xoxb-test-token-12345",
+ )
+
+ service = ServiceHandler().create_service(
+ SlackWriteMessageServiceType(),
+ integration=integration,
+ channel="general",
+ text="'Hello from Baserow!'",
+ )
+
+ service_type = service.get_type()
+ dispatch_context = FakeDispatchContext()
+
+ mock_response = Mock()
+ mock_response.json.return_value = {
+ "ok": False,
+ "error": error_code,
+ }
+
+ mock_request = Mock(return_value=mock_response)
+
+ with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc_info:
+ with patch(
+ "baserow.contrib.integrations.slack.service_types.get_http_request_function",
+ return_value=mock_request,
+ ):
+ service_type.dispatch(service, dispatch_context)
+
+ assert str(exc_info.value) == expected_message
+
+
+@pytest.mark.django_db
+def test_dispatch_slack_write_message_with_formulas(data_fixture):
+ user = data_fixture.create_user()
+ application = data_fixture.create_automation_application(user=user)
+ workflow = data_fixture.create_automation_workflow(automation=application)
+ trigger = workflow.get_trigger()
+
+ integration = IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("slack_bot"),
+ application=application,
+ token="xoxb-test-token-12345",
+ )
+
+ service = ServiceHandler().create_service(
+ SlackWriteMessageServiceType(),
+ integration=integration,
+ channel="general",
+ text=f"concat('User ', get('previous_node.{trigger.id}.0.name'), ' has joined!')",
+ )
+
+ service_type = service.get_type()
+ dispatch_context = AutomationDispatchContext(workflow)
+ dispatch_context.after_dispatch(
+ trigger, DispatchResult(data={"results": [{"name": "John"}]})
+ )
+
+ mock_response = Mock()
+ mock_response.json.return_value = {
+ "ok": True,
+ "channel": "C123456",
+ "ts": "1503435956.000247",
+ }
+ mock_request = Mock(return_value=mock_response)
+
+ with patch(
+ "baserow.contrib.integrations.slack.service_types.get_http_request_function",
+ return_value=mock_request,
+ ):
+ service_type.dispatch(service, dispatch_context)
+
+ mock_request.assert_called_once_with(
+ method="POST",
+ url="https://slack.com/api/chat.postMessage",
+ headers={"Authorization": "Bearer xoxb-test-token-12345"},
+ params={
+ "channel": "#general",
+ "text": "User John has joined!",
+ },
+ timeout=10,
+ )
+
+
+@pytest.mark.django_db
+def test_slack_write_message_create(data_fixture):
+ user = data_fixture.create_user()
+ application = data_fixture.create_automation_application(user=user)
+
+ integration = IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("slack_bot"),
+ application=application,
+ token="xoxb-test-token-12345",
+ )
+
+ service = ServiceHandler().create_service(
+ SlackWriteMessageServiceType(),
+ integration=integration,
+ channel="general",
+ text="'Hello Slack!'",
+ )
+
+ assert service.channel == "general"
+ assert service.text["formula"] == "'Hello Slack!'"
+ assert service.integration.specific.token == "xoxb-test-token-12345"
+
+
+@pytest.mark.django_db
+def test_slack_write_message_update(data_fixture):
+ user = data_fixture.create_user()
+ application = data_fixture.create_automation_application(user=user)
+
+ integration = IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("slack_bot"),
+ application=application,
+ token="xoxb-test-token-12345",
+ )
+
+ service = ServiceHandler().create_service(
+ SlackWriteMessageServiceType(),
+ integration=integration,
+ channel="general",
+ text="'Hello Slack!'",
+ )
+
+ service_type = service.get_type()
+
+ ServiceHandler().update_service(
+ service_type,
+ service,
+ channel="announcements",
+ text="'Updated message!'",
+ )
+
+ service.refresh_from_db()
+
+ assert service.channel == "announcements"
+ assert service.text["formula"] == "'Updated message!'"
+
+
+@pytest.mark.django_db
+def test_slack_write_message_formula_generator(data_fixture):
+ user = data_fixture.create_user()
+ application = data_fixture.create_automation_application(user=user)
+
+ integration = IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("slack_bot"),
+ application=application,
+ token="xoxb-test-token-12345",
+ )
+
+ service = ServiceHandler().create_service(
+ SlackWriteMessageServiceType(),
+ integration=integration,
+ channel="general",
+ text="'Hello Slack!'",
+ )
+
+ service_type = service.get_type()
+
+ formulas = list(service_type.formula_generator(service))
+ assert formulas == [
+ {"mode": "simple", "version": "0.1", "formula": "'Hello Slack!'"},
+ ]
+
+
+@pytest.mark.django_db
+def test_slack_write_message_export_import(data_fixture):
+ user = data_fixture.create_user()
+ application = data_fixture.create_automation_application(user=user)
+ workflow = data_fixture.create_automation_workflow(automation=application)
+ old_trigger = workflow.get_trigger()
+
+ integration = IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("slack_bot"),
+ application=application,
+ token="xoxb-test-token-12345",
+ )
+
+ service = ServiceHandler().create_service(
+ SlackWriteMessageServiceType(),
+ integration=integration,
+ channel="general",
+ text=f"get('previous_node.{old_trigger.id}.0.field_123')",
+ )
+
+ service_type = service.get_type()
+
+ serialized = json.loads(json.dumps(service_type.export_serialized(service)))
+ assert serialized == {
+ "id": AnyInt(),
+ "integration_id": integration.id,
+ "sample_data": None,
+ "type": "slack_write_message",
+ "channel": "general",
+ "text": {
+ "formula": f"get('previous_node.{old_trigger.id}.0.field_123')",
+ "version": "0.1",
+ "mode": "simple",
+ },
+ }
+
+ new_workflow = data_fixture.create_automation_workflow(automation=application)
+ new_trigger = new_workflow.get_trigger()
+ id_mapping = {"automation_workflow_nodes": {old_trigger.id: new_trigger.id}}
+ new_service = service_type.import_serialized(
+ None, serialized, id_mapping, import_formula
+ )
+ assert new_service.channel == "general"
+ assert (
+ new_service.text["formula"]
+ == f"get('previous_node.{new_trigger.id}.0.field_123')"
+ )
+
+
+@pytest.mark.django_db
+def test_slack_write_message_generate_schema(data_fixture):
+ user = data_fixture.create_user()
+ application = data_fixture.create_automation_application(user=user)
+ integration = IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("slack_bot"),
+ application=application,
+ token="xoxb-test-token-12345",
+ )
+ service = ServiceHandler().create_service(
+ SlackWriteMessageServiceType(),
+ integration=integration,
+ channel="general",
+ text="'Hello Slack!'",
+ )
+ schema = service.get_type().generate_schema(service)
+ assert schema == {
+ "title": f"SlackWriteMessage{service.id}Schema",
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean",
+ "title": "OK",
+ },
+ },
+ }
diff --git a/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue b/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue
index dbd9863462..79e1e19be6 100644
--- a/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue
+++ b/web-frontend/modules/automation/components/workflow/WorkflowNodeContent.vue
@@ -17,12 +17,14 @@
+
{{ displayLabel }}
diff --git a/web-frontend/modules/automation/locales/en.json b/web-frontend/modules/automation/locales/en.json
index 4a25e5eefa..b6a554864f 100644
--- a/web-frontend/modules/automation/locales/en.json
+++ b/web-frontend/modules/automation/locales/en.json
@@ -118,7 +118,9 @@
"iteratorWithChildrenNodesDeleteError": "Cannot be deleted until its child nodes are removed.",
"iteratorWithChildrenNodesReplaceError": "Cannot be replaced until its child nodes are removed.",
"periodicTriggerLabel": "Periodic trigger",
- "aiAgent": "AI prompt"
+ "aiAgent": "AI prompt",
+ "slackWriteMessageName": "Send a Slack message",
+ "slackWriteMessageLabel": "Send a message to #{channel}"
},
"workflowNode": {
"actionDelete": "Delete",
diff --git a/web-frontend/modules/automation/nodeTypes.js b/web-frontend/modules/automation/nodeTypes.js
index bafe18e795..cdef9be19b 100644
--- a/web-frontend/modules/automation/nodeTypes.js
+++ b/web-frontend/modules/automation/nodeTypes.js
@@ -16,6 +16,7 @@ import {
LocalBaserowListRowsServiceType,
LocalBaserowAggregateRowsServiceType,
} from '@baserow/modules/integrations/localBaserow/serviceTypes'
+import slackIntegration from '@baserow/modules/integrations/slack/assets/images/slack.svg'
import localBaserowIntegration from '@baserow/modules/integrations/localBaserow/assets/images/localBaserowIntegration.svg'
import {
CoreHTTPRequestServiceType,
@@ -26,6 +27,7 @@ import {
} from '@baserow/modules/integrations/core/serviceTypes'
import { AIAgentServiceType } from '@baserow/modules/integrations/ai/serviceTypes'
import { uuid } from '@baserow/modules/core/utils/string'
+import { SlackWriteMessageServiceType } from '@baserow/modules/integrations/slack/serviceTypes'
export class NodeType extends Registerable {
/**
@@ -845,3 +847,41 @@ export class AIAgentActionNodeType extends ActionNodeTypeMixin(NodeType) {
return 8
}
}
+
+export class SlackWriteMessageNodeType extends ActionNodeTypeMixin(NodeType) {
+ static getType() {
+ return 'slack_write_message'
+ }
+
+ getOrder() {
+ return 8
+ }
+
+ get iconClass() {
+ return ''
+ }
+
+ get image() {
+ return slackIntegration
+ }
+
+ get name() {
+ return this.app.i18n.t('nodeType.slackWriteMessageName')
+ }
+
+ getDefaultLabel({ node }) {
+ if (!node.service) return this.name
+ return node.service.channel.length
+ ? this.app.i18n.t('nodeType.slackWriteMessageLabel', {
+ channel: node.service.channel,
+ })
+ : this.name
+ }
+
+ get serviceType() {
+ return this.app.$registry.get(
+ 'service',
+ SlackWriteMessageServiceType.getType()
+ )
+ }
+}
diff --git a/web-frontend/modules/automation/plugin.js b/web-frontend/modules/automation/plugin.js
index fcfb2d60ca..751546a514 100644
--- a/web-frontend/modules/automation/plugin.js
+++ b/web-frontend/modules/automation/plugin.js
@@ -34,6 +34,7 @@ import {
CoreRouterNodeType,
CorePeriodicTriggerNodeType,
AIAgentActionNodeType,
+ SlackWriteMessageNodeType,
} from '@baserow/modules/automation/nodeTypes'
import {
DuplicateAutomationWorkflowJobType,
@@ -117,6 +118,7 @@ export default (context) => {
app.$registry.register('node', new CoreSMTPEmailNodeType(context))
app.$registry.register('node', new CoreRouterNodeType(context))
app.$registry.register('node', new CoreIteratorNodeType(context))
+ app.$registry.register('node', new SlackWriteMessageNodeType(context))
app.$registry.register(
'node',
new LocalBaserowDeleteRowActionNodeType(context)
diff --git a/web-frontend/modules/core/assets/scss/components/automation/workflow/workflow_node_content.scss b/web-frontend/modules/core/assets/scss/components/automation/workflow/workflow_node_content.scss
index 0bd7149d4f..13725111ae 100644
--- a/web-frontend/modules/core/assets/scss/components/automation/workflow/workflow_node_content.scss
+++ b/web-frontend/modules/core/assets/scss/components/automation/workflow/workflow_node_content.scss
@@ -97,13 +97,16 @@
height: 40px;
padding: 12px;
justify-content: center;
- align-items: center;
gap: 8px;
background: $white;
border: 1px solid $palette-neutral-200;
@include elevation($elevation-low);
@include rounded($rounded-md);
+
+ &:has(img) {
+ padding: 8px;
+ }
}
.workflow-node-content__title {
diff --git a/web-frontend/modules/core/assets/scss/components/integrations/all.scss b/web-frontend/modules/core/assets/scss/components/integrations/all.scss
index 80b3679ff9..39928e9eb5 100644
--- a/web-frontend/modules/core/assets/scss/components/integrations/all.scss
+++ b/web-frontend/modules/core/assets/scss/components/integrations/all.scss
@@ -1,2 +1,3 @@
@import 'local_baserow/local_baserow_form';
@import 'local_baserow/local_baserow_adhoc_header';
+@import 'slack/slack_bot_form';
diff --git a/web-frontend/modules/core/assets/scss/components/integrations/slack/slack_bot_form.scss b/web-frontend/modules/core/assets/scss/components/integrations/slack/slack_bot_form.scss
new file mode 100644
index 0000000000..0bfa021978
--- /dev/null
+++ b/web-frontend/modules/core/assets/scss/components/integrations/slack/slack_bot_form.scss
@@ -0,0 +1,11 @@
+.slack-bot-form__instructions {
+ color: $palette-neutral-900;
+ padding-left: 20px;
+ line-height: 170%;
+
+ & pre {
+ margin: 0;
+ display: inline;
+ background-color: $color-neutral-100;
+ }
+}
diff --git a/web-frontend/modules/core/components/integrations/IntegrationDropdown.vue b/web-frontend/modules/core/components/integrations/IntegrationDropdown.vue
index 23a41c9f1b..e5e9862fc0 100644
--- a/web-frontend/modules/core/components/integrations/IntegrationDropdown.vue
+++ b/web-frontend/modules/core/components/integrations/IntegrationDropdown.vue
@@ -18,6 +18,7 @@
:key="integrationItem.id"
:name="integrationItem.name"
:value="integrationItem.id"
+ :image="integrationType?.image"
/>
{{ $t('integrationDropdown.noIntegrations') }}
diff --git a/web-frontend/modules/core/components/integrations/IntegrationEditForm.vue b/web-frontend/modules/core/components/integrations/IntegrationEditForm.vue
index 1f1b017c2e..1aa126d396 100644
--- a/web-frontend/modules/core/components/integrations/IntegrationEditForm.vue
+++ b/web-frontend/modules/core/components/integrations/IntegrationEditForm.vue
@@ -2,13 +2,14 @@