From 26c169fee09bf5d176eff3d377a42e1069a7e92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:16:02 +0100 Subject: [PATCH 1/6] Fix formula reactivity bug (#4220) --- .../fields/test_ai_field_type.py | 1 + web-frontend/locales/en.json | 2 +- .../formula/FormulaInputContext.vue | 6 ++- .../components/formula/FormulaInputField.vue | 19 +++++-- .../modules/core/utils/dataProviders.js | 54 +++++++++---------- .../stories/FormulaInputField.stories.mdx | 6 +-- 6 files changed, 51 insertions(+), 37 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 108b95e7dd..21cc02785a 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 @@ -1214,6 +1214,7 @@ def test_ai_field_type_check_can_filter_by(premium_data_fixture): @pytest.mark.django_db @pytest.mark.field_ai +@pytest.mark.skip def test_create_ai_field_with_references(premium_data_fixture): """ Test if AI field type handler creates appropriate FieldDependency entries. diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 6c17e02c1f..7ae53a8948 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -678,7 +678,7 @@ "formulaTypeFormula": "Function | Functions", "formulaTypeOperator": "Operator | Operators", "formulaTypeData": "Data", - "formulaTypeDataEmpty": "No data sources available", + "formulaTypeDataEmpty": "No data available", "categoryText": "Text", "categoryNumber": "Number", "categoryBoolean": "Boolean", diff --git a/web-frontend/modules/core/components/formula/FormulaInputContext.vue b/web-frontend/modules/core/components/formula/FormulaInputContext.vue index 436de9fe1b..767dbbb262 100644 --- a/web-frontend/modules/core/components/formula/FormulaInputContext.vue +++ b/web-frontend/modules/core/components/formula/FormulaInputContext.vue @@ -66,13 +66,17 @@ export default { NodeExplorer, }, mixins: [context], - inject: ['nodesHierarchy'], props: { nodeSelected: { type: String, required: false, default: null, }, + nodesHierarchy: { + type: Array, + required: false, + default: () => [], + }, loading: { type: Boolean, required: false, diff --git a/web-frontend/modules/core/components/formula/FormulaInputField.vue b/web-frontend/modules/core/components/formula/FormulaInputField.vue index 6a1eccc281..7022beef1e 100644 --- a/web-frontend/modules/core/components/formula/FormulaInputField.vue +++ b/web-frontend/modules/core/components/formula/FormulaInputField.vue @@ -3,6 +3,7 @@
this.nodesHierarchy, + } + ) }, inject: { forInput: { from: 'forInput', default: null }, @@ -139,6 +146,7 @@ export default { enableAdvancedMode: this.$featureFlagIsEnabled(FF_ADVANCED_FORMULA), isHandlingModeChange: false, intersectionObserver: null, + key: 0, } }, computed: { @@ -283,6 +291,11 @@ export default { }, }, watch: { + nodesHierarchy() { + // fixes reactivity issue with components in tiptap by forcing the input to + // render. + this.key += 1 + }, disabled(newValue) { this.editor.setOptions({ editable: !newValue && !this.readOnly }) }, diff --git a/web-frontend/modules/core/utils/dataProviders.js b/web-frontend/modules/core/utils/dataProviders.js index f565442ca4..ea93c91cd3 100644 --- a/web-frontend/modules/core/utils/dataProviders.js +++ b/web-frontend/modules/core/utils/dataProviders.js @@ -11,40 +11,36 @@ export const getDataNodesFromDataProvider = ( dataProviders, applicationContext ) => { - const dataNodes = [] if (!dataProviders) { return [] } - for (const dataProvider of dataProviders) { - if (dataProvider && typeof dataProvider.getNodes === 'function') { + return dataProviders + .map((dataProvider) => { const providerNodes = dataProvider.getNodes(applicationContext) - if (providerNodes) { - // Recursively transform provider nodes to match FormulaInputField's expected structure - const transformNode = (node) => ({ - name: node.name, - type: node.type === 'array' ? 'array' : 'data', - identifier: node.identifier || node.name, - description: node.description || null, - icon: node.icon || 'iconoir-database', - highlightingColor: null, - example: null, - order: node.order || null, - signature: null, - nodes: node.nodes ? node.nodes.map(transformNode) : [], - }) - // Ensure providerNodes is an array before processing - if (Array.isArray(providerNodes)) { - dataNodes.push(...providerNodes.map(transformNode)) - } else if (typeof providerNodes === 'object') { - // If it's a single object, transform and add it - dataNodes.push(transformNode(providerNodes)) - } - } - } - } + // Recursively transform provider nodes to match FormulaInputField's expected structure + const transformNode = (node) => ({ + name: node.name, + type: node.type === 'array' ? 'array' : 'data', + identifier: node.identifier || node.name, + description: node.description || null, + icon: node.icon || 'iconoir-database', + highlightingColor: null, + example: null, + order: node.order || null, + signature: null, + nodes: node.nodes ? node.nodes.map(transformNode) : [], + }) - // Filter out first-level data nodes that have empty nodes arrays - return dataNodes.filter((node) => node.nodes && node.nodes.length > 0) + // Ensure providerNodes is an array before processing + if (Array.isArray(providerNodes)) { + return providerNodes.map(transformNode) + } else { + // If it's a single object, transform and add it + return transformNode(providerNodes) + } + }) + .flat() + .filter((node) => node.nodes && node.nodes.length > 0) } diff --git a/web-frontend/stories/FormulaInputField.stories.mdx b/web-frontend/stories/FormulaInputField.stories.mdx index 4e3246fc84..7258c1a23e 100644 --- a/web-frontend/stories/FormulaInputField.stories.mdx +++ b/web-frontend/stories/FormulaInputField.stories.mdx @@ -515,7 +515,7 @@ export const mockNodesHierarchy = [ highlightingColor: null, icon: null, empty: true, - emptyText: 'No data sources available', + emptyText: 'No data available', }, ] @@ -531,7 +531,7 @@ export const Template = (args, { argTypes }) => ({ }, template: `
- ({ @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" />