diff --git a/backend/.flake8 b/backend/.flake8
index b847dcc730..4aefc9587b 100644
--- a/backend/.flake8
+++ b/backend/.flake8
@@ -11,6 +11,7 @@ per-file-ignores =
exclude =
.git,
__pycache__,
+ src/baserow/config/settings/local.py,
src/baserow/core/formula/parser/generated
diff --git a/backend/src/baserow/contrib/automation/nodes/node_types.py b/backend/src/baserow/contrib/automation/nodes/node_types.py
index cff59d6448..c3fcc855ed 100644
--- a/backend/src/baserow/contrib/automation/nodes/node_types.py
+++ b/backend/src/baserow/contrib/automation/nodes/node_types.py
@@ -245,7 +245,8 @@ def after_create(self, node: CoreRouterActionNode):
:param node: The router node instance that was just created.
"""
- node.service.edges.create(label=_("Branch"))
+ if not len(node.service.edges.all()):
+ node.service.edges.create(label=_("Branch"))
def prepare_values(
self,
diff --git a/backend/src/baserow/contrib/automation/workflows/service.py b/backend/src/baserow/contrib/automation/workflows/service.py
index f1987694d9..7414283cf1 100644
--- a/backend/src/baserow/contrib/automation/workflows/service.py
+++ b/backend/src/baserow/contrib/automation/workflows/service.py
@@ -54,6 +54,30 @@ def get_workflow(self, user: AbstractUser, workflow_id: int) -> AutomationWorkfl
return workflow
+ def list_workflows(
+ self, user: AbstractUser, automation_id: int
+ ) -> List[AutomationWorkflow]:
+ """
+ Lists all the workflows that belong to the given automation.
+
+ :param user: The user requesting the workflows.
+ :param automation_id: The automation to which the workflows belong.
+ :return: A list of AutomationWorkflow instances.
+ """
+
+ automation = AutomationHandler().get_automation(automation_id)
+
+ all_workflows = self.handler.get_workflows(
+ automation, base_queryset=AutomationWorkflow.objects
+ )
+
+ return CoreHandler().filter_queryset(
+ user,
+ ReadAutomationWorkflowOperationType.type,
+ all_workflows,
+ workspace=automation.workspace,
+ )
+
def create_workflow(
self,
user: AbstractUser,
diff --git a/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py b/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py
index c79f419005..95809c6c64 100644
--- a/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py
+++ b/backend/src/baserow/contrib/builder/data_providers/data_provider_types.py
@@ -170,9 +170,6 @@ def get_data_chunk(self, dispatch_context: BuilderDispatchContext, path: List[st
# The data source has probably been deleted
raise InvalidRuntimeFormula() from exc
- # Declare the call and check for recursion
- dispatch_context.add_call(data_source.id)
-
dispatch_result = DataSourceHandler().dispatch_data_source(
data_source, dispatch_context
)
diff --git a/backend/src/baserow/contrib/builder/data_sources/handler.py b/backend/src/baserow/contrib/builder/data_sources/handler.py
index b46e70a2da..0cd65a13d0 100644
--- a/backend/src/baserow/contrib/builder/data_sources/handler.py
+++ b/backend/src/baserow/contrib/builder/data_sources/handler.py
@@ -508,16 +508,12 @@ def dispatch_data_sources(
data_sources_dispatch[data_source.id] = {}
continue
- # Add the initial call to the call stack
- dispatch_context.add_call(data_source.id)
try:
data_sources_dispatch[data_source.id] = self.dispatch_data_source(
data_source, dispatch_context
)
except Exception as e:
data_sources_dispatch[data_source.id] = e
- # Reset the stack as we are starting a new dispatch
- dispatch_context.reset_call_stack()
return data_sources_dispatch
@@ -538,21 +534,12 @@ def dispatch_data_source(
raise ServiceImproperlyConfiguredDispatchException(
"The service type is missing."
)
-
cache = dispatch_context.cache
- call_stack = dispatch_context.call_stack
-
+ page = dispatch_context.page
current_data_source_dispatched = dispatch_context.data_source or data_source
- dispatch_context = dispatch_context.clone(
- data_source=current_data_source_dispatched,
- )
-
- # keep the call stack
- dispatch_context.call_stack = call_stack
-
if current_data_source_dispatched != data_source:
- data_sources = self.get_data_sources_with_cache(dispatch_context.page)
+ data_sources = self.get_data_sources_with_cache(page)
ordered_ids = [d.id for d in data_sources]
if ordered_ids.index(current_data_source_dispatched.id) < ordered_ids.index(
data_source.id
@@ -561,9 +548,16 @@ def dispatch_data_source(
"You can't reference a data source after the current data source"
)
+ # Clone the dispatch context to keep the call stack as it is
+ cloned_dispatch_context = dispatch_context.clone(
+ data_source=current_data_source_dispatched
+ )
+ # Declare the call and check for recursion
+ cloned_dispatch_context.add_call(data_source.id)
+
if data_source.id not in cache.setdefault("data_source_contents", {}):
service_dispatch = self.service_handler.dispatch_service(
- data_source.service.specific, dispatch_context
+ data_source.service.specific, cloned_dispatch_context
)
# Cache the dispatch in the formula cache if we have formulas that need
diff --git a/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py b/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py
index 8fcde6df92..7695a99f9d 100644
--- a/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py
+++ b/backend/tests/baserow/contrib/builder/data_providers/test_data_provider_types.py
@@ -240,8 +240,6 @@ def test_data_source_data_provider_get_data_chunk_with_list_data_source(data_fix
== "Blue"
)
- dispatch_context.reset_call_stack()
-
assert (
data_source_provider.get_data_chunk(
dispatch_context, [data_source.id, "2", fields[1].db_column]
@@ -249,8 +247,6 @@ def test_data_source_data_provider_get_data_chunk_with_list_data_source(data_fix
== "White"
)
- dispatch_context.reset_call_stack()
-
assert (
data_source_provider.get_data_chunk(
dispatch_context, [data_source.id, "0", "id"]
@@ -258,8 +254,6 @@ def test_data_source_data_provider_get_data_chunk_with_list_data_source(data_fix
== rows[0].id
)
- dispatch_context.reset_call_stack()
-
assert data_source_provider.get_data_chunk(
dispatch_context, [data_source.id, "*", fields[1].db_column]
) == ["Blue", "Orange", "White", "Green"]
diff --git a/backend/tests/baserow/contrib/builder/workflow_actions/test_workflow_actions_handler.py b/backend/tests/baserow/contrib/builder/workflow_actions/test_workflow_actions_handler.py
index 40e524bca2..b8f414c57f 100644
--- a/backend/tests/baserow/contrib/builder/workflow_actions/test_workflow_actions_handler.py
+++ b/backend/tests/baserow/contrib/builder/workflow_actions/test_workflow_actions_handler.py
@@ -1,5 +1,11 @@
+import json
+from unittest.mock import MagicMock
+
import pytest
+from baserow.contrib.builder.data_sources.builder_dispatch_context import (
+ BuilderDispatchContext,
+)
from baserow.contrib.builder.workflow_actions.exceptions import (
WorkflowActionNotInElement,
)
@@ -15,6 +21,7 @@
OpenPageWorkflowActionType,
)
from baserow.core.services.models import Service
+from baserow.test_utils.helpers import AnyInt, AnyStr
@pytest.mark.django_db
@@ -229,3 +236,64 @@ def test_order_workflow_actions_different_scopes(data_fixture):
)
assert page_workflow_action.order == element_workflow_action.order
+
+
+@pytest.mark.django_db
+def test_dispatch_workflow_action_doesnt_trigger_formula_recursion(data_fixture):
+ user, token = data_fixture.create_user_and_token()
+ workspace = data_fixture.create_workspace(user=user)
+ database = data_fixture.create_database_application(workspace=workspace)
+ builder = data_fixture.create_builder_application(workspace=workspace)
+ table, fields, rows = data_fixture.build_table(
+ user=user,
+ columns=[
+ ("Name", "text"),
+ ("My Color", "text"),
+ ],
+ rows=[
+ ["BMW", "Blue"],
+ ["Audi", "Orange"],
+ ["Volkswagen", "White"],
+ ["Volkswagen", "Green"],
+ ],
+ )
+ page = data_fixture.create_builder_page(builder=builder)
+ element = data_fixture.create_builder_button_element(page=page)
+ integration = data_fixture.create_local_baserow_integration(
+ application=builder, user=user, authorized_user=user
+ )
+ data_source = data_fixture.create_builder_local_baserow_list_rows_data_source(
+ integration=integration,
+ page=page,
+ table=table,
+ )
+ service = data_fixture.create_local_baserow_upsert_row_service(
+ table=table,
+ integration=integration,
+ )
+ service.field_mappings.create(
+ field=fields[0],
+ value=f'concat(get("data_source.{data_source.id}.0.{fields[0].db_column}"), '
+ f'get("data_source.{data_source.id}.0.{fields[1].db_column}"))',
+ )
+ workflow_action = data_fixture.create_local_baserow_create_row_workflow_action(
+ page=page, service=service, element=element, event=EventTypes.CLICK
+ )
+
+ fake_request = MagicMock()
+ fake_request.data = {"metadata": json.dumps({})}
+
+ dispatch_context = BuilderDispatchContext(
+ fake_request, page, only_expose_public_allowed_properties=False
+ )
+
+ result = BuilderWorkflowActionHandler().dispatch_workflow_action(
+ workflow_action, dispatch_context
+ )
+
+ assert result.data == {
+ "id": AnyInt(),
+ "order": AnyStr(),
+ "Name": "AudiOrange",
+ "My Color": None,
+ }
diff --git a/changelog/entries/unreleased/bug/4195_fix_formula_recursion_error_when_the_same_data_source_is_use.json b/changelog/entries/unreleased/bug/4195_fix_formula_recursion_error_when_the_same_data_source_is_use.json
new file mode 100644
index 0000000000..ec6d88cd7e
--- /dev/null
+++ b/changelog/entries/unreleased/bug/4195_fix_formula_recursion_error_when_the_same_data_source_is_use.json
@@ -0,0 +1,8 @@
+{
+ "type": "bug",
+ "message": "Fix formula recursion error when the same data source is used twice in one formula of workflow action",
+ "domain": "builder",
+ "issue_number": 4195,
+ "bullet_points": [],
+ "created_at": "2025-11-10"
+}
\ No newline at end of file
diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py
index b58fc27d49..d85972636f 100755
--- a/enterprise/backend/src/baserow_enterprise/apps.py
+++ b/enterprise/backend/src/baserow_enterprise/apps.py
@@ -303,18 +303,20 @@ def ready(self):
notification_type_registry.register(TwoWaySyncDeactivatedNotificationType())
from baserow_enterprise.assistant.tools import (
- CreateDatabaseToolType,
+ CreateBuildersToolType,
CreateFieldsToolType,
CreateTablesToolType,
CreateViewFiltersToolType,
CreateViewsToolType,
+ CreateWorkflowsToolType,
GenerateDatabaseFormulaToolType,
GetRowsToolsToolType,
GetTablesSchemaToolType,
- ListDatabasesToolType,
+ ListBuildersToolType,
ListRowsToolType,
ListTablesToolType,
ListViewsToolType,
+ ListWorkflowsToolType,
NavigationToolType,
SearchDocsToolType,
)
@@ -325,8 +327,8 @@ def ready(self):
assistant_tool_registry.register(SearchDocsToolType())
assistant_tool_registry.register(NavigationToolType())
- assistant_tool_registry.register(ListDatabasesToolType())
- assistant_tool_registry.register(CreateDatabaseToolType())
+ assistant_tool_registry.register(ListBuildersToolType())
+ assistant_tool_registry.register(CreateBuildersToolType())
assistant_tool_registry.register(ListTablesToolType())
assistant_tool_registry.register(CreateTablesToolType())
assistant_tool_registry.register(GetTablesSchemaToolType())
@@ -338,6 +340,9 @@ def ready(self):
assistant_tool_registry.register(CreateViewsToolType())
assistant_tool_registry.register(CreateViewFiltersToolType())
+ assistant_tool_registry.register(ListWorkflowsToolType())
+ assistant_tool_registry.register(CreateWorkflowsToolType())
+
# The signals must always be imported last because they use the registries
# which need to be filled first.
import baserow_enterprise.audit_log.signals # noqa: F
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/assistant.py b/enterprise/backend/src/baserow_enterprise/assistant/assistant.py
index 0ba3756afa..50930f180c 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/assistant.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/assistant.py
@@ -145,6 +145,7 @@ def _init_lm_client(self):
model=lm_model,
cache=not settings.DEBUG,
max_retries=5,
+ max_tokens=32000,
)
def _init_assistant(self):
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py
index 837b551abc..2e2e4b6d65 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py
@@ -38,15 +38,15 @@
AUTOMATION_BUILDER_CONCEPTS = """
### AUTOMATIONS (no-code automation builder)
-**Structure**: Automation → Workflows → Triggers + Actions + Routers (Nodes)
+**Structure**: Automation → Workflows → Trigger + Actions + Routers (Nodes)
**Key concepts**:
-• **Triggers**: Events that start automations (e.g., row created/updated, view accessed)
+• **Trigger**: The single event that starts the workflow (e.g., row created/updated/deleted)
• **Actions**: Tasks performed (e.g., create/update rows, send emails, call webhooks)
• **Routers**: Conditional logic (if/else, switch) to control flow
• **Execution**: Runs in the background; monitor via logs
• **History**: Track runs, successes, failures
-• **Publishing**: Requires domain configuration
+• **Publishing**: Requires at least one configured action
"""
ASSISTANT_SYSTEM_PROMPT = (
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/__init__.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/__init__.py
index b2bb87ffa3..ac6006dbb9 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/tools/__init__.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/__init__.py
@@ -1,3 +1,5 @@
+from .automation.tools import * # noqa: F401, F403
+from .core.tools import * # noqa: F401, F403
from .database.tools import * # noqa: F401, F403
from .navigation.tools import * # noqa: F401, F403
from .search_docs.tools import * # noqa: F401, F403
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/__init__.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/__init__.py
new file mode 100644
index 0000000000..ca44da1dd7
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/__init__.py
@@ -0,0 +1,6 @@
+from .tools import CreateWorkflowsToolType, ListWorkflowsToolType
+
+__all__ = [
+ "ListWorkflowsToolType",
+ "CreateWorkflowsToolType",
+]
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py
new file mode 100644
index 0000000000..ce5e93ffb6
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/tools.py
@@ -0,0 +1,118 @@
+from typing import TYPE_CHECKING, Any, Callable
+
+from django.contrib.auth.models import AbstractUser
+from django.db import transaction
+from django.utils.translation import gettext as _
+
+from baserow.contrib.automation.workflows.service import AutomationWorkflowService
+from baserow.core.models import Workspace
+from baserow_enterprise.assistant.tools.registries import AssistantToolType
+from baserow_enterprise.assistant.types import WorkflowNavigationType
+
+from . import utils
+from .types import WorkflowCreate
+
+if TYPE_CHECKING:
+ from baserow_enterprise.assistant.assistant import ToolHelpers
+
+
+def get_list_workflows_tool(
+ user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
+) -> Callable[[int], dict[str, list[dict]]]:
+ """
+ List all workflows in an automation.
+ """
+
+ def list_workflows(automation_id: int) -> dict[str, Any]:
+ """
+ List all workflows in an automation application.
+
+ :param automation_id: The ID of the automation application
+ :return: Dictionary with workflows list
+ """
+
+ nonlocal user, workspace, tool_helpers
+
+ tool_helpers.update_status(_("Listing workflows..."))
+
+ automation = utils.get_automation(automation_id, user, workspace)
+ workflows = AutomationWorkflowService().list_workflows(user, automation.id)
+
+ return {
+ "workflows": [
+ {"id": w.id, "name": w.name, "state": w.state} for w in workflows
+ ]
+ }
+
+ return list_workflows
+
+
+def get_create_workflows_tool(
+ user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
+) -> Callable[[int, list[WorkflowCreate]], dict[str, list[dict]]]:
+ """
+ Create new workflows.
+ """
+
+ def create_workflows(
+ automation_id: int, workflows: list[WorkflowCreate]
+ ) -> dict[str, Any]:
+ """
+ Create one or more workflows in an automation.
+
+ :param automation_id: The automation application ID
+ :param workflows: List of workflows to create
+ :return: Dictionary with created workflows
+ """
+
+ nonlocal user, workspace, tool_helpers
+
+ tool_helpers.update_status(_("Creating workflows..."))
+
+ created = []
+
+ automation = utils.get_automation(automation_id, user, workspace)
+ for wf in workflows:
+ with transaction.atomic():
+ orm_workflow = utils.create_workflow(user, automation, wf)
+ created.append(
+ {
+ "id": orm_workflow.id,
+ "name": orm_workflow.name,
+ "state": orm_workflow.state,
+ }
+ )
+ # Navigate to the last created workflow
+ tool_helpers.navigate_to(
+ WorkflowNavigationType(
+ type="automation-workflow",
+ automation_id=automation.id,
+ workflow_id=orm_workflow.id,
+ workflow_name=orm_workflow.name,
+ )
+ )
+
+ return {"created_workflows": created}
+
+ return create_workflows
+
+
+# ============================================================================
+# TOOL TYPE REGISTRY
+# ============================================================================
+
+
+class ListWorkflowsToolType(AssistantToolType):
+ type = "list_workflows"
+
+ @classmethod
+ def get_tool(cls, user, workspace, tool_helpers):
+ return get_list_workflows_tool(user, workspace, tool_helpers)
+
+
+class CreateWorkflowsToolType(AssistantToolType):
+ type = "create_workflows"
+
+ @classmethod
+ def get_tool(cls, user, workspace, tool_helpers):
+ return get_create_workflows_tool(user, workspace, tool_helpers)
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/__init__.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/__init__.py
new file mode 100644
index 0000000000..0adf1e93c6
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/__init__.py
@@ -0,0 +1,24 @@
+from .node import (
+ AiAgentNodeCreate,
+ CreateRowActionCreate,
+ DeleteRowActionCreate,
+ NodeBase,
+ RouterNodeCreate,
+ SendEmailActionCreate,
+ TriggerNodeCreate,
+ UpdateRowActionCreate,
+)
+from .workflow import WorkflowCreate, WorkflowItem
+
+__all__ = [
+ "WorkflowCreate",
+ "WorkflowItem",
+ "NodeBase",
+ "RouterNodeCreate",
+ "CreateRowActionCreate",
+ "UpdateRowActionCreate",
+ "DeleteRowActionCreate",
+ "SendEmailActionCreate",
+ "AiAgentNodeCreate",
+ "TriggerNodeCreate",
+]
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/node.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/node.py
new file mode 100644
index 0000000000..ac48fc6eb8
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/node.py
@@ -0,0 +1,327 @@
+from typing import Annotated, Any, Literal, Optional
+from uuid import uuid4
+
+from pydantic import Field, PrivateAttr
+
+from baserow_enterprise.assistant.types import BaseModel
+
+
+class NodeBase(BaseModel):
+ """Base node model."""
+
+ label: str = Field(..., description="The human readable name of the node")
+ type: str
+
+
+class RefCreate(BaseModel):
+ """Base node creation model."""
+
+ ref: str = Field(
+ ..., description="A reference ID for the node, only used during creation"
+ )
+
+
+class Item(BaseModel):
+ id: str
+
+
+class PeriodicTriggerSettings(BaseModel):
+ """Periodic trigger interval model."""
+
+ interval: Literal["MINUTE", "HOUR", "DAY", "WEEK", "MONTH"] = Field(
+ ..., description="The interval for the periodic trigger"
+ )
+ minute: Optional[int] = Field(
+ default=0, description="The number of minutes for the periodic trigger"
+ )
+ hour: Optional[int] = Field(
+ default=0, description="The number of hours for the periodic trigger"
+ )
+ day_of_week: Optional[int] = Field(
+ default=0,
+ description="The day of the week for the periodic trigger (0=Monday, 6=Sunday)",
+ )
+ day_of_month: Optional[int] = Field(
+ default=1, description="The day of the month for the periodic trigger (1-31)"
+ )
+
+
+class RowsTriggersSettings(BaseModel):
+ """Table trigger configuration."""
+
+ table_id: int = Field(..., description="The ID of the table to monitor")
+
+
+class TriggerNodeCreate(NodeBase, RefCreate):
+ """Create a trigger node in a workflow."""
+
+ type: Literal[
+ "periodic",
+ "http_trigger",
+ "rows_updated",
+ "rows_created",
+ "rows_deleted",
+ ]
+
+ # periodic trigger specific
+ periodic_interval: Optional[PeriodicTriggerSettings] = Field(
+ default=None,
+ description="Configuration for periodic trigger",
+ )
+ rows_triggers_settings: Optional[RowsTriggersSettings] = Field(
+ default=None,
+ description="Configuration for rows trigger",
+ )
+
+ def to_orm_service_dict(self) -> dict[str, Any]:
+ """Convert to ORM dict for node creation service."""
+
+ if self.type == "periodic" and self.periodic_interval:
+ return self.periodic_interval.model_dump()
+
+ if (
+ self.type in ["rows_created", "rows_updated", "rows_deleted"]
+ and self.rows_triggers_settings
+ ):
+ return self.rows_triggers_settings.model_dump()
+
+ return {}
+
+
+class TriggerNodeItem(TriggerNodeCreate, Item):
+ """Existing trigger node with ID."""
+
+ http_trigger_url: str | None = Field(
+ default=None, description="The URL to trigger the HTTP request"
+ )
+
+
+class EdgeCreate(BaseModel):
+ previous_node_ref: str = Field(
+ ...,
+ description="The reference ID of the previous node to link from. Every node can have only one previous node.",
+ )
+ router_edge_label: str = Field(
+ default="",
+ description="If the previous node is a router, the edge label to link from if different from default",
+ )
+
+ def to_orm_reference_node(
+ self, node_mapping: dict
+ ) -> tuple[Optional[int], Optional[str]]:
+ """Get the ORM node ID and output label from the previous node reference."""
+
+ if self.previous_node_ref not in node_mapping:
+ raise ValueError(
+ f"Previous node ref '{self.previous_node_ref}' not found in mapping"
+ )
+
+ previous_orm_node, previous_node_create = node_mapping[self.previous_node_ref]
+
+ output = ""
+ if self.router_edge_label and previous_node_create.type == "router":
+ output = next(
+ (
+ edge._uid
+ for edge in previous_node_create.edges
+ if edge.label == self.router_edge_label
+ ),
+ None,
+ )
+ if output is None:
+ raise ValueError(
+ f"Branch label '{self.router_edge_label}' not found in previous router node"
+ )
+
+ return previous_orm_node.id, output
+
+
+class RouterEdgeCreate(BaseModel):
+ """Router branch configuration."""
+
+ label: str = Field(
+ description="The label of the router branch. Order of branches matters: first matching branch is taken.",
+ )
+ condition: str = Field(
+ default="",
+ description="A brief description of the condition for this branch that will be converted to a formula.",
+ )
+
+ _uid: str = PrivateAttr(default_factory=lambda: str(uuid4()))
+
+ def to_orm_service_dict(self) -> dict[str, Any]:
+ return {
+ "uid": self._uid,
+ "label": self.label,
+ }
+
+
+class RouterBranch(RouterEdgeCreate, Item):
+ """Existing router branch with ID."""
+
+
+class RouterNodeBase(NodeBase):
+ """Create a router node with branches."""
+
+ type: Literal["router"]
+ edges: list[RouterEdgeCreate] = Field(
+ ...,
+ description="List of branches for the router node. A default branch is created automatically.",
+ )
+
+
+class RouterNodeCreate(RouterNodeBase, RefCreate, EdgeCreate):
+ """Create a router node with branches and link configuration."""
+
+ def to_orm_service_dict(self) -> dict[str, Any]:
+ return {"edges": [branch.to_orm_service_dict() for branch in self.edges]}
+
+
+class RouterNodeItem(RouterNodeBase, Item):
+ """Existing router node with ID."""
+
+
+class SendEmailActionBase(NodeBase):
+ """Send email action configuration."""
+
+ type: Literal["smtp_email"]
+ to_emails: str
+ cc_emails: Optional[str]
+ bcc_emails: Optional[str]
+ subject: str
+ body: str
+ body_type: Literal["plain", "html"] = Field(default="plain")
+
+
+class SendEmailActionCreate(SendEmailActionBase, RefCreate, EdgeCreate):
+ """Create a send email action with edge configuration."""
+
+ def to_orm_service_dict(self) -> dict[str, Any]:
+ return {
+ "to_email": f"'{self.to_emails}'",
+ "cc_email": f"'{self.cc_emails or ''}'",
+ "bcc_email": f"'{self.bcc_emails or ''}'",
+ "subject": f"'{self.subject}'",
+ "body": f"'{self.body}'",
+ "body_type": f"'{self.body_type}'",
+ }
+
+
+class SendEmailActionItem(SendEmailActionBase, Item):
+ """Existing send email action with ID."""
+
+
+class CreateRowActionBase(NodeBase):
+ """Create row action configuration."""
+
+ type: Literal["create_row"]
+ table_id: int
+ values: dict[str, Any]
+
+
+class RowActionService:
+ def to_orm_service_dict(self) -> dict[str, Any]:
+ return {
+ "table_id": self.table_id,
+ }
+
+
+class CreateRowActionCreate(
+ RowActionService, CreateRowActionBase, RefCreate, EdgeCreate
+):
+ """Create a create row action with edge configuration."""
+
+
+class CreateRowActionItem(CreateRowActionBase, Item):
+ """Existing create row action with ID."""
+
+
+class UpdateRowActionBase(NodeBase):
+ """Update row action configuration."""
+
+ type: Literal["update_row"]
+ table_id: int
+ row: str = Field(..., description="The row ID or a formula to identify the row")
+ values: dict[str, Any]
+
+
+class UpdateRowActionCreate(
+ RowActionService, UpdateRowActionBase, RefCreate, EdgeCreate
+):
+ """Create an update row action with edge configuration."""
+
+
+class UpdateRowActionItem(UpdateRowActionBase, Item):
+ """Existing update row action with ID."""
+
+
+class DeleteRowActionBase(NodeBase):
+ """Delete row action configuration."""
+
+ type: Literal["delete_row"]
+ table_id: int
+ row: str = Field(..., description="The row ID or a formula to identify the row")
+
+
+class DeleteRowActionCreate(
+ RowActionService, DeleteRowActionBase, RefCreate, EdgeCreate
+):
+ """Create a delete row action with edge configuration."""
+
+
+class DeleteRowActionItem(DeleteRowActionBase, Item):
+ """Existing delete row action with ID."""
+
+
+class AiAgentNodeBase(NodeBase):
+ """AI Agent action configuration."""
+
+ type: Literal["ai_agent"] = Field(
+ ...,
+ description="Don't stop at this node. Chain some other action to use the AI output.",
+ )
+ output_type: Literal["text", "choice"] = Field(default="text")
+ choices: Optional[list[str]] = Field(
+ default=None,
+ description="List of choices if output_type is 'choice'",
+ )
+ temperature: float | None = Field(default=None)
+ prompt: str
+
+
+class AiAgentNodeCreate(AiAgentNodeBase, RefCreate, EdgeCreate):
+ """Create an AI Agent action with edge configuration."""
+
+ def to_orm_service_dict(self) -> dict[str, Any]:
+ return {
+ "ai_choices": (self.choices or []) if self.output_type == "choice" else [],
+ "ai_temperature": self.temperature,
+ "ai_prompt": f"'{self.prompt}'",
+ "ai_output_type": self.output_type,
+ }
+
+
+class AiAgentNodeItem(AiAgentNodeBase, Item):
+ """Existing AI Agent action with ID."""
+
+
+AnyNodeCreate = Annotated[
+ RouterNodeCreate
+ # actions
+ | SendEmailActionCreate
+ | CreateRowActionCreate
+ | UpdateRowActionCreate
+ | DeleteRowActionCreate
+ | AiAgentNodeCreate,
+ Field(discriminator="type"),
+]
+
+AnyNodeItem = (
+ RouterNodeItem
+ # actions
+ | SendEmailActionItem
+ | CreateRowActionItem
+ | UpdateRowActionItem
+ | DeleteRowActionItem
+ | AiAgentNodeItem
+)
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/workflow.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/workflow.py
new file mode 100644
index 0000000000..5470d91648
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/types/workflow.py
@@ -0,0 +1,73 @@
+from typing import Annotated, Literal
+
+from pydantic import Field
+
+from baserow_enterprise.assistant.types import BaseModel
+
+from .node import AnyNodeCreate, TriggerNodeCreate
+
+
+class WorkflowEdgeCreate(BaseModel):
+ """Workflow edge connecting two nodes."""
+
+ type: Literal["edge"]
+ from_node_label: str = Field(
+ ...,
+ description="The label of the node where the edge starts",
+ )
+ to_node_label: str = Field(
+ ...,
+ description="The label of the node where the edge ends",
+ )
+
+
+class WorkflowRouterEdgeCreate(WorkflowEdgeCreate):
+ """Workflow edge connecting to a router node with a branch label."""
+
+ type: Literal["router_branch"]
+ router_branch_label: str = Field(
+ default="",
+ description="The branch label for the router node edge",
+ )
+
+
+AnyWorkflowEdgeCreate = Annotated[
+ WorkflowEdgeCreate,
+ WorkflowRouterEdgeCreate,
+ Field(
+ discriminator="type",
+ default="edge",
+ description=(
+ "The type of workflow edge. Use 'edge' in normal linear (a follows b) connections. "
+ "Use 'router_branch' when connecting to a router node with a branch label. "
+ ),
+ ),
+]
+
+
+class WorkflowCreate(BaseModel):
+ """Base workflow model."""
+
+ name: str = Field(..., description="The name of the workflow")
+ trigger: TriggerNodeCreate = Field(
+ ...,
+ description="The trigger node configuration for the workflow",
+ )
+ nodes: list[AnyNodeCreate] = Field(
+ default_factory=list,
+ description=(
+ "The nodes executed or evaluated once the trigger fires. "
+ "Every node must have only one incoming edge. If the previous node is a router, "
+ "the branch label must be specified for non-default branches. "
+ "Only if explicitly requested, this list can be empty."
+ ),
+ )
+
+
+class WorkflowItem(WorkflowCreate):
+ """Existing workflow with ID."""
+
+ id: int
+ state: str = Field(
+ ..., description="Workflow state: draft, live, paused, or disabled"
+ )
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/utils.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/utils.py
new file mode 100644
index 0000000000..05733ad9f0
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/automation/utils.py
@@ -0,0 +1,83 @@
+from django.contrib.auth.models import AbstractUser
+
+from baserow.contrib.automation.models import Automation
+from baserow.contrib.automation.nodes.registries import automation_node_type_registry
+from baserow.contrib.automation.nodes.service import AutomationNodeService
+from baserow.contrib.automation.workflows.models import AutomationWorkflow
+from baserow.contrib.automation.workflows.service import AutomationWorkflowService
+from baserow.core.models import Workspace
+from baserow.core.service import CoreService
+
+from .types import WorkflowCreate
+
+
+def get_automation(
+ automation_id: int, user: AbstractUser, workspace: Workspace
+) -> Automation:
+ """Get automation with permission check."""
+
+ base_queryset = Automation.objects.filter(workspace=workspace)
+ automation = CoreService().get_application(
+ user, automation_id, base_queryset=base_queryset
+ )
+ return automation
+
+
+def get_workflow(
+ workflow_id: int, user: AbstractUser, workspace: Workspace
+) -> AutomationWorkflow:
+ """Get workflow with permission check."""
+
+ workflow = AutomationWorkflowService().get_workflow(user, workflow_id)
+ if workflow.automation.workspace_id != workspace.id:
+ raise ValueError("Workflow not in workspace")
+ return workflow
+
+
+def create_workflow(
+ user: AbstractUser,
+ automation: Automation,
+ workflow: WorkflowCreate,
+) -> AutomationWorkflow:
+ """
+ Creates a new workflow in the given automation based on the provided definition.
+ """
+
+ orm_wf = AutomationWorkflowService().create_workflow(
+ user, automation.id, workflow.name
+ )
+
+ node_mapping = {}
+
+ # First create the trigger node
+ orm_service_data = workflow.trigger.to_orm_service_dict()
+ node_type = automation_node_type_registry.get(workflow.trigger.type)
+ orm_trigger = AutomationNodeService().create_node(
+ user,
+ node_type,
+ orm_wf,
+ label=workflow.trigger.label,
+ service=orm_service_data,
+ )
+
+ node_mapping[workflow.trigger.ref] = node_mapping[orm_trigger.id] = (
+ orm_trigger,
+ workflow.trigger,
+ )
+
+ for node in workflow.nodes:
+ orm_service_data = node.to_orm_service_dict()
+ reference_node_id, output = node.to_orm_reference_node(node_mapping)
+ node_type = automation_node_type_registry.get(node.type)
+ orm_node = AutomationNodeService().create_node(
+ user,
+ node_type,
+ orm_wf,
+ reference_node_id=reference_node_id,
+ output=output,
+ label=node.label,
+ service=orm_service_data,
+ )
+ node_mapping[node.ref] = node_mapping[orm_node.id] = (orm_node, node)
+
+ return orm_wf
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/core/__init__.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/core/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/core/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/core/tools.py
new file mode 100644
index 0000000000..68ea702ad3
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/core/tools.py
@@ -0,0 +1,130 @@
+from typing import TYPE_CHECKING, Any, Callable, Literal
+
+from django.contrib.auth.models import AbstractUser
+from django.db import transaction
+from django.utils.translation import gettext as _
+
+from baserow.core.actions import CreateApplicationActionType
+from baserow.core.models import Workspace
+from baserow.core.registries import application_type_registry
+from baserow.core.service import CoreService
+from baserow_enterprise.assistant.tools.registries import AssistantToolType
+
+from .types import AnyBuilderItem, BuilderItem, BuilderItemCreate
+
+if TYPE_CHECKING:
+ from baserow_enterprise.assistant.assistant import ToolHelpers
+
+
+def get_list_builders_tool(
+ user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
+) -> Callable[[], list[AnyBuilderItem]]:
+ """
+ Returns a function that lists all the builders the user has access to in the
+ current workspace.
+ """
+
+ def list_builders(
+ builder_types: list[
+ Literal["database", "application", "automation", "dashboard"]
+ ]
+ | None = None
+ ) -> list[AnyBuilderItem] | str:
+ """
+ Lists all the builders the user can access (databases, applications,
+ automations, dashboards) in the current workspace.
+
+ If `builder_types` is provided, only builders of that type are returned,
+ otherwise all builders are returned (default).
+ """
+
+ nonlocal user, workspace, tool_helpers
+
+ tool_helpers.update_status(
+ _("Listing %(builder_types)ss...")
+ % {
+ "builder_types": builder_types[0]
+ if builder_types and len(builder_types) == 1
+ else "builder"
+ }
+ )
+
+ applications_qs = CoreService().list_applications_in_workspace(
+ user, workspace, specific=False
+ )
+
+ builders = {}
+ for builder in applications_qs:
+ builder_type = application_type_registry.get_by_model(
+ builder.specific_class
+ ).type
+ if not builder_types or builder_type in builder_types:
+ builders.setdefault(builder_type, []).append(
+ BuilderItem(
+ id=builder.id, name=builder.name, type=builder_type
+ ).model_dump()
+ )
+
+ return builders if builders else "no builders found"
+
+ return list_builders
+
+
+class ListBuildersToolType(AssistantToolType):
+ type = "list_builders"
+
+ @classmethod
+ def get_tool(
+ cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
+ ) -> Callable[[Any], Any]:
+ return get_list_builders_tool(user, workspace, tool_helpers)
+
+
+def get_create_modules_tool(
+ user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
+) -> Callable[[str], dict[str, Any]]:
+ """
+ Returns a function that creates a module in the current workspace.
+ """
+
+ def create_builders(builders: list[BuilderItemCreate]) -> dict[str, Any]:
+ """
+ Create a builder in the current workspace and return its ID and name.
+
+ - name: desired name for the builder (better if unique in the workspace)
+ """
+
+ nonlocal user, workspace, tool_helpers
+
+ created_builders = []
+ with transaction.atomic():
+ for builder in builders:
+ tool_helpers.update_status(
+ _("Creating %(builder_type)s %(builder_name)s...")
+ % {"builder_type": builder.type, "builder_name": builder.name}
+ )
+ builder_orm_instance = CreateApplicationActionType.do(
+ user, workspace, builder.get_orm_type(), name=builder.name
+ )
+ builder.post_creation_hook(user, builder_orm_instance)
+ created_builders.append(
+ BuilderItem(
+ id=builder_orm_instance.id,
+ name=builder_orm_instance.name,
+ type=builder.type,
+ ).model_dump()
+ )
+
+ return {"created_builders": created_builders}
+
+ return create_builders
+
+
+class CreateBuildersToolType(AssistantToolType):
+ type = "create_builders"
+
+ @classmethod
+ def get_tool(
+ cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
+ ) -> Callable[[Any], Any]:
+ return get_create_modules_tool(user, workspace, tool_helpers)
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/core/types.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/core/types.py
new file mode 100644
index 0000000000..87183d68dc
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/core/types.py
@@ -0,0 +1,172 @@
+from typing import Annotated, Literal
+
+from pydantic import Field
+
+from baserow.core.integrations.registries import integration_type_registry
+from baserow.core.integrations.service import IntegrationService
+from baserow.core.models import Application as BaserowApplication
+from baserow.core.registries import application_type_registry
+from baserow_enterprise.assistant.types import BaseModel
+
+
+class BuilderItemCreate(BaseModel):
+ """Base model for creating a new module (no ID)."""
+
+ name: str = Field(...)
+ type: Literal["database", "application", "automation", "dashboard"] = Field(...)
+
+ def get_orm_type(self) -> str:
+ """Returns the corresponding ORM type for the builder."""
+
+ type_mapping = {
+ "database": "database",
+ "application": "builder",
+ "automation": "automation",
+ "dashboard": "dashboard",
+ }
+ return type_mapping[self.type]
+
+ @classmethod
+ def from_django_orm(cls, orm_app: BaserowApplication) -> "BuilderItem":
+ """Creates a BuilderItem instance from a Django ORM Application instance."""
+
+ return cls(
+ id=orm_app.id,
+ name=orm_app.name,
+ type=application_type_registry.get_by_model(orm_app.specific_class).type,
+ )
+
+ def _post_creation_hook(self, user, builder_orm_instance):
+ """Internal hook that can be overridden to perform actions after creation."""
+
+ def post_creation_hook(self, user, builder_orm_instance):
+ """Hook that can be overridden to perform actions after creation."""
+
+ specific_item_create = builder_type_registry.get_by_type_create(self.type)
+
+ return specific_item_create(**self.model_dump())._post_creation_hook(
+ user, builder_orm_instance
+ )
+
+
+class BuilderItem(BuilderItemCreate):
+ """Model for an existing module (with ID)."""
+
+ id: int = Field(...)
+
+
+class DatabaseItemCreate(BuilderItemCreate):
+ """Base model for creating a new database (no ID)."""
+
+ type: Literal["database"] = Field(...)
+
+
+class DatabaseItem(DatabaseItemCreate):
+ """Model for an existing database (with ID)."""
+
+ id: int = Field(...)
+
+
+class ApplicationItemCreate(BuilderItemCreate):
+ """Base model for creating a new application (no ID)."""
+
+ type: Literal["application"] = Field(...)
+
+ def _post_creation_hook(self, user, builder_orm_instance):
+ IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("local_baserow"),
+ builder_orm_instance,
+ name="Local Baserow",
+ )
+
+
+class ApplicationItem(ApplicationItemCreate):
+ """Model for an existing application (with ID)."""
+
+ id: int = Field(...)
+
+
+class AutomationItemCreate(BuilderItemCreate):
+ """Base model for creating a new automation (no ID)."""
+
+ type: Literal["automation"] = Field(...)
+
+ def _post_creation_hook(self, user, builder_orm_instance):
+ IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("local_baserow"),
+ builder_orm_instance,
+ name="Local Baserow",
+ )
+
+
+class AutomationItem(AutomationItemCreate):
+ """Model for an existing automation (with ID)."""
+
+ id: int = Field(...)
+
+
+class DashboardItemCreate(BuilderItemCreate):
+ """Base model for creating a new dashboard (no ID)."""
+
+ type: Literal["dashboard"] = Field(...)
+
+ def _post_creation_hook(self, user, builder_orm_instance):
+ IntegrationService().create_integration(
+ user,
+ integration_type_registry.get("local_baserow"),
+ builder_orm_instance,
+ name="Local Baserow",
+ )
+
+
+class DashboardItem(DashboardItemCreate):
+ """Model for an existing dashboard (with ID)."""
+
+ id: int = Field(...)
+
+
+AnyBuilderItem = Annotated[
+ DatabaseItem | ApplicationItem | AutomationItem | DashboardItem,
+ Field(discriminator="type"),
+]
+
+AnyBuilderItemCreate = Annotated[
+ DatabaseItemCreate
+ | ApplicationItemCreate
+ | AutomationItemCreate
+ | DashboardItemCreate,
+ Field(discriminator="type"),
+]
+
+
+class BuilderItemRegistry:
+ _registry = {
+ "database": DatabaseItem,
+ "application": ApplicationItem,
+ "builder": ApplicationItem, # alias for application
+ "automation": AutomationItem,
+ "dashboard": DashboardItem,
+ }
+ _registry_create = {
+ "database": DatabaseItemCreate,
+ "application": ApplicationItemCreate,
+ "builder": ApplicationItemCreate, # alias for application
+ "automation": AutomationItemCreate,
+ "dashboard": DashboardItemCreate,
+ }
+
+ def get_by_type(self, builder_type: str) -> AnyBuilderItem:
+ return self._registry[builder_type]
+
+ def get_by_type_create(self, builder_type: str) -> AnyBuilderItemCreate:
+ return self._registry_create[builder_type]
+
+ def from_django_orm(self, orm_app: BaserowApplication) -> BuilderItem:
+ app_type = application_type_registry.get_by_model(orm_app.specific_class).type
+ field_class: AnyBuilderItem = self._registry[app_type]
+ return field_class.from_django_orm(orm_app)
+
+
+builder_type_registry = BuilderItemRegistry()
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py
index f6938f2cbd..2e392170a6 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/tools.py
@@ -1,7 +1,6 @@
from typing import TYPE_CHECKING, Any, Callable, Literal, Tuple
from django.contrib.auth.models import AbstractUser
-from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.utils.translation import gettext as _
@@ -25,7 +24,6 @@
UpdateViewFieldOptionsActionType,
)
from baserow.contrib.database.views.handler import ViewHandler
-from baserow.core.actions import CreateApplicationActionType
from baserow.core.models import Workspace
from baserow.core.service import CoreService
from baserow_enterprise.assistant.tools.registries import AssistantToolType
@@ -44,7 +42,6 @@
AnyViewFilterItemCreate,
AnyViewItemCreate,
BaseTableItem,
- DatabaseItem,
ListTablesFilterArg,
TableItemCreate,
view_item_registry,
@@ -54,51 +51,6 @@
from baserow_enterprise.assistant.assistant import ToolHelpers
-def get_list_databases_tool(
- user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
-) -> Callable[[], list[DatabaseItem]]:
- """
- Returns a function that lists all the databases the user has access to in the
- current workspace.
- """
-
- def list_databases() -> list[DatabaseItem]:
- """
- Lists all the databases the user can access.
- """
-
- nonlocal user, workspace, tool_helpers
-
- tool_helpers.update_status(_("Listing databases..."))
-
- applications_qs = CoreService().list_applications_in_workspace(
- user, workspace, specific=False
- )
-
- database_content_type = ContentType.objects.get_for_model(Database)
-
- return {
- "databases": [
- DatabaseItem(id=database.id, name=database.name).model_dump()
- for database in applications_qs.filter(
- content_type=database_content_type
- )
- ]
- }
-
- return list_databases
-
-
-class ListDatabasesToolType(AssistantToolType):
- type = "list_databases"
-
- @classmethod
- def get_tool(
- cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
- ) -> Callable[[Any], Any]:
- return get_list_databases_tool(user, workspace, tool_helpers)
-
-
def get_list_tables_tool(
user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
) -> Callable[[int], list[str]]:
@@ -224,54 +176,6 @@ def get_tool(
return get_tables_schema_tool(user, workspace, tool_helpers)
-def get_create_database_tool(
- user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
-) -> Callable[[str], dict[str, Any]]:
- """
- Returns a function that creates a database in the current workspace.
- """
-
- def create_database(name: str) -> dict[str, Any]:
- """
- Create a database in the current workspace and return its ID and name.
- **ALWAYS** create tables afterwards unless explicitly asked otherwise.
-
- - name: desired database name (must be unique in the workspace)
- - call list_databases first to avoid duplicates
- - call the create_tables tools afterwards unless explicitly asked otherwise
- """
-
- nonlocal user, workspace, tool_helpers
-
- tool_helpers.update_status(
- _("Creating database %(database_name)s...") % {"database_name": name}
- )
-
- with transaction.atomic():
- database = CreateApplicationActionType.do(
- user, workspace, "database", name=name
- )
-
- return {
- "created_database": DatabaseItem(
- id=database.id, name=database.name
- ).model_dump()
- }
-
- return create_database
-
-
-class CreateDatabaseToolType(AssistantToolType):
- type = "create_database"
- thinking_message = "Creating a new database..."
-
- @classmethod
- def get_tool(
- cls, user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
- ) -> Callable[[Any], Any]:
- return get_create_database_tool(user, workspace, tool_helpers)
-
-
def get_create_tables_tool(
user: AbstractUser, workspace: Workspace, tool_helpers: "ToolHelpers"
) -> Callable[[list[TableItemCreate]], list[dict[str, Any]]]:
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/__init__.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/__init__.py
index 484eb7d528..7406ef8204 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/__init__.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/__init__.py
@@ -1,5 +1,4 @@
from .base import * # noqa: F401, F403
-from .database import * # noqa: F401, F403
from .fields import * # noqa: F401, F403
from .table import * # noqa: F401, F403
from .view_filters import * # noqa: F401, F403
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/database.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/database.py
deleted file mode 100644
index 5508ff0200..0000000000
--- a/enterprise/backend/src/baserow_enterprise/assistant/tools/database/types/database.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from pydantic import Field
-
-from baserow_enterprise.assistant.types import BaseModel
-
-
-class DatabaseItemCreate(BaseModel):
- """Base model for creating a new database (no ID)."""
-
- name: str = Field(...)
-
-
-class DatabaseItem(DatabaseItemCreate):
- """Model for an existing database (with ID)."""
-
- id: int = Field(...)
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/types.py b/enterprise/backend/src/baserow_enterprise/assistant/types.py
index 620e30e91c..4a444e4c15 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/types.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/types.py
@@ -209,8 +209,21 @@ def to_localized_string(self):
return _("home")
+class WorkflowNavigationType(BaseModel):
+ type: Literal["automation-workflow"]
+ automation_id: int
+ workflow_id: int
+ workflow_name: str
+
+ def to_localized_string(self):
+ return _("workflow %(workflow_name)s") % {"workflow_name": self.workflow_name}
+
+
AnyNavigationType = Annotated[
- TableNavigationType | WorkspaceNavigationType | ViewNavigationType,
+ TableNavigationType
+ | WorkspaceNavigationType
+ | ViewNavigationType
+ | WorkflowNavigationType,
Field(discriminator="type"),
]
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_automation_workflow_tools.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_automation_workflow_tools.py
new file mode 100644
index 0000000000..763719d1b0
--- /dev/null
+++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_automation_workflow_tools.py
@@ -0,0 +1,248 @@
+import pytest
+
+from baserow.contrib.automation.workflows.handler import AutomationWorkflowHandler
+from baserow_enterprise.assistant.tools.automation.tools import (
+ get_create_workflows_tool,
+ get_list_workflows_tool,
+)
+from baserow_enterprise.assistant.tools.automation.types import (
+ CreateRowActionCreate,
+ DeleteRowActionCreate,
+ TriggerNodeCreate,
+ UpdateRowActionCreate,
+ WorkflowCreate,
+)
+
+from .utils import fake_tool_helpers
+
+
+@pytest.mark.django_db
+def test_list_workflows(data_fixture):
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ automation = data_fixture.create_automation_application(
+ user=user, workspace=workspace
+ )
+ workflow = data_fixture.create_automation_workflow(
+ automation=automation, name="Test Workflow"
+ )
+
+ tool = get_list_workflows_tool(user, workspace, fake_tool_helpers)
+ result = tool(automation_id=automation.id)
+
+ assert result == {
+ "workflows": [{"id": workflow.id, "name": "Test Workflow", "state": "draft"}]
+ }
+
+
+@pytest.mark.django_db
+def test_list_workflows_multiple(data_fixture):
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ automation = data_fixture.create_automation_application(
+ user=user, workspace=workspace
+ )
+ workflow1 = data_fixture.create_automation_workflow(
+ automation=automation, name="Workflow 1"
+ )
+ workflow2 = data_fixture.create_automation_workflow(
+ automation=automation, name="Workflow 2"
+ )
+
+ tool = get_list_workflows_tool(user, workspace, fake_tool_helpers)
+ result = tool(automation_id=automation.id)
+
+ assert result == {
+ "workflows": [
+ {"id": workflow1.id, "name": "Workflow 1", "state": "draft"},
+ {"id": workflow2.id, "name": "Workflow 2", "state": "draft"},
+ ]
+ }
+
+
+@pytest.mark.django_db(transaction=True)
+def test_create_workflows(data_fixture):
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ automation = data_fixture.create_automation_application(
+ user=user, workspace=workspace
+ )
+ database = data_fixture.create_database_application(user=user, workspace=workspace)
+ table = data_fixture.create_database_table(user=user, database=database)
+
+ tool = get_create_workflows_tool(user, workspace, fake_tool_helpers)
+ result = tool(
+ automation_id=automation.id,
+ workflows=[
+ WorkflowCreate(
+ name="Process Orders",
+ trigger=TriggerNodeCreate(
+ ref="trigger1",
+ label="Periodic Trigger",
+ type="periodic",
+ ),
+ nodes=[
+ CreateRowActionCreate(
+ ref="action1",
+ label="Create row",
+ previous_node_ref="trigger1",
+ type="create_row",
+ table_id=table.id,
+ values={},
+ )
+ ],
+ )
+ ],
+ )
+
+ assert len(result["created_workflows"]) == 1
+ assert result["created_workflows"][0]["name"] == "Process Orders"
+ assert result["created_workflows"][0]["state"] == "draft"
+
+ # Verify workflow was created with a trigger
+ from baserow.contrib.automation.workflows.handler import AutomationWorkflowHandler
+
+ workflow_id = result["created_workflows"][0]["id"]
+ workflow = AutomationWorkflowHandler().get_workflow(workflow_id)
+ trigger = workflow.get_trigger()
+ assert trigger is not None
+ assert trigger.get_type().type == "periodic"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_create_multiple_workflows(data_fixture):
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ automation = data_fixture.create_automation_application(
+ user=user, workspace=workspace
+ )
+ database = data_fixture.create_database_application(user=user, workspace=workspace)
+ table = data_fixture.create_database_table(user=user, database=database)
+
+ tool = get_create_workflows_tool(user, workspace, fake_tool_helpers)
+ result = tool(
+ automation_id=automation.id,
+ workflows=[
+ WorkflowCreate(
+ name="Workflow 1",
+ trigger=TriggerNodeCreate(
+ ref="trigger1",
+ label="Trigger",
+ type="periodic",
+ ),
+ nodes=[
+ CreateRowActionCreate(
+ ref="action1",
+ label="Action",
+ previous_node_ref="trigger1",
+ type="create_row",
+ table_id=table.id,
+ values={},
+ )
+ ],
+ ),
+ WorkflowCreate(
+ name="Workflow 2",
+ trigger=TriggerNodeCreate(
+ ref="trigger2",
+ label="Trigger",
+ type="periodic",
+ ),
+ nodes=[
+ CreateRowActionCreate(
+ ref="action2",
+ label="Action",
+ previous_node_ref="trigger2",
+ type="create_row",
+ table_id=table.id,
+ values={},
+ )
+ ],
+ ),
+ ],
+ )
+
+ assert len(result["created_workflows"]) == 2
+ assert result["created_workflows"][0]["name"] == "Workflow 1"
+ assert result["created_workflows"][1]["name"] == "Workflow 2"
+
+
+@pytest.mark.django_db(transaction=True)
+@pytest.mark.parametrize(
+ "trigger,action",
+ [
+ (
+ TriggerNodeCreate(
+ type="rows_created", ref="trigger", label="Rows Created Trigger"
+ ),
+ CreateRowActionCreate(
+ type="create_row",
+ ref="action",
+ previous_node_ref="trigger",
+ label="Create Row Action",
+ table_id=999,
+ values={},
+ ),
+ ),
+ (
+ TriggerNodeCreate(
+ type="rows_updated", ref="trigger", label="Rows Updated Trigger"
+ ),
+ UpdateRowActionCreate(
+ type="update_row",
+ ref="action",
+ previous_node_ref="trigger",
+ label="Update Row Action",
+ table_id=999,
+ row="1",
+ values={},
+ ),
+ ),
+ (
+ TriggerNodeCreate(
+ type="rows_deleted", ref="trigger", label="Rows Deleted Trigger"
+ ),
+ DeleteRowActionCreate(
+ type="delete_row",
+ ref="action",
+ previous_node_ref="trigger",
+ label="Delete Row Action",
+ table_id=999,
+ row="1",
+ ),
+ ),
+ ],
+)
+def test_create_workflow_with_row_triggers_and_actions(data_fixture, trigger, action):
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ automation = data_fixture.create_automation_application(
+ user=user, workspace=workspace
+ )
+ database = data_fixture.create_database_application(user=user, workspace=workspace)
+ table = data_fixture.create_database_table(user=user, database=database)
+ table.pk = 999 # To match the action's table_id
+ table.save()
+
+ tool = get_create_workflows_tool(user, workspace, fake_tool_helpers)
+ result = tool(
+ automation_id=automation.id,
+ workflows=[
+ WorkflowCreate(
+ name="Test Row Trigger Workflow",
+ trigger=trigger,
+ nodes=[action],
+ )
+ ],
+ )
+
+ assert len(result["created_workflows"]) == 1
+ assert result["created_workflows"][0]["name"] == "Test Row Trigger Workflow"
+ assert result["created_workflows"][0]["state"] == "draft"
+
+ # Verify workflow was created with correct trigger type
+ workflow_id = result["created_workflows"][0]["id"]
+ workflow = AutomationWorkflowHandler().get_workflow(workflow_id)
+ orm_trigger = workflow.get_trigger()
+ assert orm_trigger is not None
+ assert orm_trigger.service.get_type().type == f"local_baserow_{trigger.type}"
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_database_tools.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_database_tools.py.skip
similarity index 100%
rename from enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_database_tools.py
rename to enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_database_tools.py.skip
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantPanel.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantPanel.vue
index b65dccb9e9..a7841db620 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantPanel.vue
+++ b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantPanel.vue
@@ -130,12 +130,12 @@ export default {
handler(newLocation) {
if (!newLocation) return
+ const router = this.$router
+ const store = this.$store
if (
newLocation.type === 'database-table' ||
newLocation.type === 'database-view'
) {
- const router = this.$router
- const store = this.$store
waitFor(() => {
const database = store.getters['application/get'](
newLocation.database_id
@@ -167,6 +167,27 @@ export default {
workspaceId: this.workspace.id,
},
})
+ } else if (newLocation.type === 'automation-workflow') {
+ waitFor(() => {
+ const automation = store.getters['application/get'](
+ newLocation.automation_id
+ )
+
+ return (
+ automation &&
+ automation.workflows.find(
+ (workflow) => workflow.id === newLocation.workflow_id
+ )
+ )
+ }).then(() => {
+ this.$router.push({
+ name: 'automation-workflow',
+ params: {
+ automationId: newLocation.automation_id,
+ workflowId: newLocation.workflow_id,
+ },
+ })
+ })
}
},
},
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantUiContext.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantUiContext.vue
index fe7cd64fdd..145c87101c 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantUiContext.vue
+++ b/enterprise/web-frontend/modules/baserow_enterprise/components/assistant/AssistantUiContext.vue
@@ -16,7 +16,7 @@ export default {
},
computed: {
contextIcon() {
- const applicationType = this.uiContext.application?.type
+ const applicationType = this.uiContext.applicationType
if (!applicationType) {
return null
}
@@ -28,6 +28,8 @@ export default {
return this.uiContext.table.name + ' - ' + this.uiContext.view.name
} else if (this.uiContext.table) {
return this.uiContext.table.name
+ } else if (this.uiContext.workflow) {
+ return this.uiContext.workflow.name
} else if (this.uiContext.application) {
return this.uiContext.application.name
} else if (this.uiContext.workspace) {
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/store/assistant.js b/enterprise/web-frontend/modules/baserow_enterprise/store/assistant.js
index 2a9374cc14..3dae3aaf55 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/store/assistant.js
+++ b/enterprise/web-frontend/modules/baserow_enterprise/store/assistant.js
@@ -324,7 +324,7 @@ export const getters = {
: null
const table =
- application && scope.table
+ application?.type === 'database' && scope.table
? application.tables?.find((t) => t.id === scope.table)
: null
@@ -332,6 +332,7 @@ export const getters = {
table && scope.view ? rootGetters['view/get'](scope.view) : null
const uiContext = {
+ applicationType: application?.type || null,
workspace: { id: workspace.id, name: workspace.name },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
@@ -350,6 +351,19 @@ export const getters = {
if (view) {
uiContext.view = { id: view.id, name: view.name, type: view.type }
}
+
+ try {
+ const workflow =
+ application?.type === 'automation' && scope.workflow
+ ? rootGetters['automationWorkflow/getById'](
+ application,
+ scope.workflow
+ )
+ : null
+ if (workflow) {
+ uiContext.workflow = { id: workflow.id, name: workflow.name }
+ }
+ } catch {}
return uiContext
},
diff --git a/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue b/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue
index b2c43216ff..aa27a26869 100644
--- a/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue
+++ b/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue
@@ -69,11 +69,12 @@
+ @update:mode="updateMode"
+ />
{{ $t('error.requiredField') }}
@@ -99,6 +100,8 @@ import fieldSubForm from '@baserow/modules/database/mixins/fieldSubForm'
import FormulaInputField from '@baserow/modules/core/components/formula/FormulaInputField'
import SelectAIModelForm from '@baserow/modules/core/components/ai/SelectAIModelForm'
import { TextAIFieldOutputType } from '@baserow_premium/aiFieldOutputTypes'
+import { buildFormulaFunctionNodes } from '@baserow/modules/core/formula'
+import { getDataNodesFromDataProvider } from '@baserow/modules/core/utils/dataProviders'
export default {
name: 'FieldAISubForm',
@@ -111,11 +114,12 @@ export default {
return {
allowedValues: ['ai_prompt', 'ai_file_field_id', 'ai_output_type'],
values: {
- ai_prompt: { formula: '' },
+ ai_prompt: { formula: '', mode: 'simple' },
ai_output_type: TextAIFieldOutputType.getType(),
ai_file_field_id: null,
},
fileFieldSupported: false,
+ localMode: 'simple',
}
},
computed: {
@@ -146,6 +150,29 @@ export default {
dataProviders() {
return [this.$registry.get('databaseDataProvider', 'fields')]
},
+ nodesHierarchy() {
+ const hierarchy = []
+
+ const filteredDataNodes = getDataNodesFromDataProvider(
+ this.dataProviders,
+ this.applicationContext
+ )
+
+ if (filteredDataNodes.length > 0) {
+ hierarchy.push({
+ name: this.$t('runtimeFormulaTypes.formulaTypeData'),
+ type: 'data',
+ icon: 'iconoir-database',
+ nodes: filteredDataNodes,
+ })
+ }
+
+ // Add functions and operators from the registry
+ const formulaNodes = buildFormulaFunctionNodes(this)
+ hierarchy.push(...formulaNodes)
+
+ return hierarchy
+ },
isDeactivated() {
return this.$registry
.get('field', this.fieldType)
@@ -171,6 +198,16 @@ export default {
)
},
},
+ watch: {
+ 'values.ai_prompt.mode': {
+ handler(newMode) {
+ if (newMode && newMode !== this.localMode) {
+ this.localMode = newMode
+ }
+ },
+ immediate: true,
+ },
+ },
methods: {
/**
* When `FormulaInputField` emits a new formula string, we need to emit the
@@ -181,6 +218,17 @@ export default {
this.v$.values.ai_prompt.formula.$model = newFormulaStr
this.$emit('input', { formula: newFormulaStr })
},
+ /**
+ * When the mode changes, update the local mode value
+ * @param {String} newMode The new mode value
+ */
+ updateMode(newMode) {
+ this.localMode = newMode
+ this.values.ai_prompt = {
+ ...this.values.ai_prompt,
+ mode: newMode,
+ }
+ },
setFileFieldSupported(generativeAIType) {
if (generativeAIType) {
const modelType = this.$registry.get(
diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json
index 31f5586079..6c17e02c1f 100644
--- a/web-frontend/locales/en.json
+++ b/web-frontend/locales/en.json
@@ -675,12 +675,25 @@
"ifDescription": "If the first argument is true, returns the second argument, otherwise returns the third argument.",
"andDescription": "Returns true if all arguments are true, otherwise returns false.",
"orDescription": "Returns true if any argument is true, otherwise returns false.",
- "formulaTypeFormula": "Formula",
- "formulaTypeOperator": "Operator",
+ "formulaTypeFormula": "Function | Functions",
+ "formulaTypeOperator": "Operator | Operators",
+ "formulaTypeData": "Data",
+ "formulaTypeDataEmpty": "No data sources available",
"categoryText": "Text",
"categoryNumber": "Number",
"categoryBoolean": "Boolean",
"categoryDate": "Date",
- "caregoryCondition": "Condition"
+ "categoryCondition": "Condition"
+ },
+ "formulaInputContext": {
+ "useRegularInput": "Use regular input",
+ "useRegularInputModalTitle": "Switch to regular input?",
+ "useAdvancedInputModalTitle": "Switch to advanced input?",
+ "useAdvancedInput": "Use advanced input",
+ "modalMessage": "Switching to a different input mode will clear the current formula. Are you sure you want to continue?"
+ },
+ "nodeExplorer": {
+ "noResults": "No results found",
+ "resetSearch": "Reset search"
}
}
diff --git a/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue b/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue
index 57dac950d4..6d61b2ac9f 100644
--- a/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue
+++ b/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue
@@ -3,19 +3,25 @@
v-bind="$attrs"
required
:value="formulaStr"
- :data-providers="dataProviders"
- :application-context="applicationContext"
- enable-advanced-mode
- :mode="currentMode"
+ :nodes-hierarchy="nodesHierarchy"
+ context-position="left"
+ :mode="localMode"
+ @update:mode="updateMode"
@input="updatedFormulaStr"
- @mode-changed="updateMode"
/>
diff --git a/web-frontend/modules/automation/store/automationWorkflow.js b/web-frontend/modules/automation/store/automationWorkflow.js
index 76f14fe17e..c72f60c10b 100644
--- a/web-frontend/modules/automation/store/automationWorkflow.js
+++ b/web-frontend/modules/automation/store/automationWorkflow.js
@@ -195,7 +195,7 @@ const actions = {
const getters = {
getWorkflows: (state) => (automation) => {
- return [...automation.workflows]
+ return automation?.workflows ? [...automation.workflows] : []
},
getOrderedWorkflows: (state, getters) => (automation) => {
return getters.getWorkflows(automation).sort((a, b) => a.order - b.order)
diff --git a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue
index a46ce174d2..0c0a92cebe 100644
--- a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue
+++ b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue
@@ -4,102 +4,149 @@
required
enable-advanced-mode
:value="formulaStr"
- :mode="formulaMode"
- :data-explorer-loading="dataExplorerLoading"
- :data-providers="dataProviders"
- :application-context="applicationContext"
+ :mode="localMode"
+ :loading="dataExplorerLoading"
+ :nodes-hierarchy="nodesHierarchy"
+ :context-position="isInSidePanel ? 'left' : 'bottom'"
@input="updatedFormulaStr"
- @mode-changed="updateMode"
+ @update:mode="updateMode"
/>
-
diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss
index e0e247187d..557d0e2aef 100644
--- a/web-frontend/modules/core/assets/scss/components/all.scss
+++ b/web-frontend/modules/core/assets/scss/components/all.scss
@@ -167,11 +167,12 @@
@import 'color_picker_context';
@import 'color_input_group';
@import 'formula_input_field';
+@import 'node_help_tooltip';
@import 'get_formula_component';
@import 'color_input';
@import 'group_bys';
-@import 'data_explorer/data_explorer';
-@import 'data_explorer/data_explorer_node';
+@import 'node_explorer/node_explorer';
+@import 'node_explorer/node_explorer_content';
@import 'anchor';
@import 'call_to_action';
@import 'toast_button';
@@ -201,3 +202,4 @@
@import 'code_editor';
@import 'field_constraints';
@import 'workspace_search';
+@import 'formula_input_context';
diff --git a/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer.scss b/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer.scss
deleted file mode 100644
index 2931fb57b4..0000000000
--- a/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer.scss
+++ /dev/null
@@ -1,10 +0,0 @@
-.data-explorer {
- width: 323px;
-
- .context__description {
- padding: 32px;
- text-align: center;
- white-space: initial;
- line-height: 20px;
- }
-}
diff --git a/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss b/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss
deleted file mode 100644
index 9ad48d309b..0000000000
--- a/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss
+++ /dev/null
@@ -1,64 +0,0 @@
-.data-explorer-node__content-icon {
- color: $color-neutral-600;
-}
-
-.data-explorer-node__children {
- margin-left: 5px;
-}
-
-.data-explorer-node__content {
- @extend %ellipsis;
-
- display: flex;
- justify-content: flex-start;
- align-items: center;
- gap: 6px;
- padding: 0 5px;
- margin: 0 5px;
- font-size: 13px;
- border-radius: 3px;
- line-height: 24px;
-
- .data-explorer-node--selected & {
- background-color: $color-primary-100;
-
- .data-explorer-node__content-selected-icon {
- color: $color-success-500;
- }
- }
-
- .data-explorer-node--level-0 > & {
- font-size: 12px;
- color: $color-neutral-500;
- margin-left: 10px;
-
- &:hover {
- background-color: initial;
- }
- }
-
- .data-explorer-node--level-0 .data-explorer-node--level-1 & {
- cursor: pointer;
-
- &:hover {
- background-color: $color-neutral-100;
- }
- }
-}
-
-.data-explorer-node--level-0 {
- margin-bottom: 8px;
-}
-
-.data-explorer-node__content-name {
- flex: 1;
-}
-
-.data-explorer-node__array-node-more {
- border: none;
- background: none;
- margin-left: 10px;
- padding-top: 3px;
- color: $color-neutral-600;
- cursor: pointer;
-}
diff --git a/web-frontend/modules/core/assets/scss/components/formula_input_context.scss b/web-frontend/modules/core/assets/scss/components/formula_input_context.scss
new file mode 100644
index 0000000000..4dd058fe2b
--- /dev/null
+++ b/web-frontend/modules/core/assets/scss/components/formula_input_context.scss
@@ -0,0 +1,68 @@
+.formula-input-context {
+ width: 400px;
+ display: flex;
+ flex-direction: column;
+ max-height: inherit;
+
+ .node-explorer {
+ flex: 1;
+ min-height: 0;
+ }
+
+ &__tab-content {
+ padding: 12px;
+ }
+
+ &__section {
+ &:not(:last-child) {
+ margin-bottom: 20px;
+ }
+ }
+
+ &__section-title {
+ font-size: 11px;
+ color: $palette-neutral-900;
+ font-weight: 500;
+ margin-bottom: 8px;
+ text-transform: capitalize;
+ }
+
+ &__items {
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ margin: 0;
+ }
+
+ &__item {
+ display: flex;
+ align-items: center;
+ padding: 8px 12px 8px 7px;
+ border-radius: 4px;
+ cursor: pointer;
+ gap: 8px;
+
+ &:hover {
+ background-color: $palette-neutral-100;
+ }
+ }
+
+ &__item-icon {
+ font-size: 14px;
+ color: $palette-neutral-900;
+ flex-shrink: 0;
+ }
+}
+
+.formula-input-context__footer {
+ flex-shrink: 0;
+ padding: 12px 16px;
+ border-top: 1px solid $palette-neutral-200;
+ height: 36px;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ background-color: $white;
+
+ @include rounded($rounded);
+}
diff --git a/web-frontend/modules/core/assets/scss/components/formula_input_field.scss b/web-frontend/modules/core/assets/scss/components/formula_input_field.scss
index 794e80ec33..49d58cba89 100644
--- a/web-frontend/modules/core/assets/scss/components/formula_input_field.scss
+++ b/web-frontend/modules/core/assets/scss/components/formula_input_field.scss
@@ -1,14 +1,65 @@
+.function-name-highlight {
+ color: $palette-cyan-800;
+ font-weight: 500;
+ background-color: $palette-cyan-50;
+ padding: 4px 8px;
+ height: 24px;
+ display: inline-block;
+ vertical-align: top;
+
+ @include rounded;
+}
+
+.operator-highlight {
+ color: $palette-green-800;
+ font-weight: 500;
+ background-color: $palette-green-50;
+ padding: 3px 8px;
+ height: 24px;
+ box-sizing: border-box;
+ display: inline-block;
+ vertical-align: top;
+
+ @include rounded;
+}
+
+.text-segment {
+ min-height: 24px;
+ display: inline-block;
+ vertical-align: top;
+ padding: 3px 0;
+ margin-right: 4px;
+ line-height: 18px;
+}
+
+.function-comma-highlight {
+ margin-right: 4px;
+}
+
+.function-comma-highlight,
+.function-paren-highlight {
+ color: $palette-cyan-800;
+ font-weight: 500;
+ background-color: $palette-cyan-50;
+ padding: 4px 8px;
+ height: 24px;
+ box-sizing: border-box;
+ display: inline-block;
+ vertical-align: top;
+
+ @include rounded;
+}
+
.formula-input-field {
height: auto;
font-size: 13px;
- min-height: 38px;
- padding: 5px 12px;
- line-height: 25px;
+ min-height: 36px;
+ padding: 5px 12px 1px;
// If the field is empty, then give it the
// same padding as a normal form input field.
&:has(div.is-editor-empty) {
- padding: 12px 16px;
+ padding: 10px 16px;
line-height: 100%;
}
@@ -17,10 +68,32 @@
padding: 10px 12px;
min-height: 48px;
}
+
+ // Remove margin from the last element to avoid trailing space
+ /* stylelint-disable-next-line selector-class-pattern */
+ .ProseMirror {
+ span:last-child {
+ margin-right: 0;
+ }
+
+ > div {
+ > span:not(.text-segment) {
+ margin: 0 4px 4px 0;
+ }
+ }
+ }
}
.formula-input-field--focused {
- border-color: $color-primary-500;
+ border-color: $palette-blue-500;
+
+ &.formula-input-field--error {
+ border-color: $palette-red-400;
+ }
+}
+
+.formula-input-field--error {
+ border-color: $palette-red-400;
}
.formula-input-field--disabled {
@@ -35,17 +108,7 @@
.ProseMirror div.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
- color: $color-neutral-500;
+ color: $palette-neutral-500;
pointer-events: none;
height: 0;
}
-
-.formula-input-field__advanced-input {
- width: 100%;
- padding: 12px 16px;
- line-height: 100%;
-
- &::placeholder {
- color: $color-neutral-500;
- }
-}
diff --git a/web-frontend/modules/core/assets/scss/components/get_formula_component.scss b/web-frontend/modules/core/assets/scss/components/get_formula_component.scss
index cc463cfe93..b67e72b26c 100644
--- a/web-frontend/modules/core/assets/scss/components/get_formula_component.scss
+++ b/web-frontend/modules/core/assets/scss/components/get_formula_component.scss
@@ -1,39 +1,41 @@
.get-formula-component {
cursor: pointer;
display: inline-block;
- padding: 2px 8px;
- background-color: $color-neutral-100;
+ vertical-align: middle;
+ background-color: $palette-neutral-100;
font-size: 12px;
- margin: 1px 0;
border-radius: 3px;
user-select: none;
+ min-height: 24px;
line-height: 18px;
+ box-sizing: border-box;
+ padding: 2px 8px;
@include rounded($rounded);
}
.get-formula-component--error {
- background-color: $color-error-100;
+ background-color: $palette-red-100;
}
.get-formula-component--selected {
- background-color: $color-primary-100;
+ background-color: $palette-blue-50;
}
.get-formula-component__caret {
- color: $color-neutral-400;
+ color: $palette-neutral-700;
padding-left: 3px;
padding-right: 3px;
font-size: 12px;
}
.get-formula-component__remove {
- color: $color-neutral-500;
+ color: $palette-neutral-700;
padding-left: 4px;
font-size: 10px;
&:hover {
text-decoration: none;
- color: $color-neutral-900;
+ color: $palette-neutral-1100;
}
}
diff --git a/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer.scss b/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer.scss
new file mode 100644
index 0000000000..6d0182a66b
--- /dev/null
+++ b/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer.scss
@@ -0,0 +1,42 @@
+.node-explorer {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ }
+
+ .context__description {
+ text-align: center;
+ white-space: initial;
+ line-height: 20px;
+ }
+}
+
+.node-explorer__content--empty {
+ padding: 24px;
+ color: $palette-neutral-900;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+}
+
+.node-explorer-tab {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.node-explorer-tab__scrollable {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden auto;
+}
diff --git a/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer_content.scss b/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer_content.scss
new file mode 100644
index 0000000000..c9253fea95
--- /dev/null
+++ b/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer_content.scss
@@ -0,0 +1,67 @@
+.node-explorer-content__content-icon {
+ color: $palette-neutral-600;
+}
+
+.node-explorer-content__children {
+ margin-left: 5px;
+}
+
+.node-explorer-content__name {
+ flex: 1;
+}
+
+.node-explorer-content__content {
+ @extend %ellipsis;
+
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ gap: 6px;
+ padding: 0 5px;
+ margin: 0 5px;
+ font-size: 13px;
+ border-radius: 3px;
+ line-height: 24px;
+
+ .node-explorer-content--selected > & {
+ background-color: $palette-blue-50;
+
+ .node-explorer-content__selected-icon {
+ color: $palette-green-500;
+ }
+ }
+
+ .node-explorer-content--level-0 > & {
+ font-size: 11px;
+ margin-left: 10px;
+
+ &:hover {
+ background-color: initial;
+ }
+
+ .node-explorer-content__name {
+ color: $palette-neutral-900;
+ }
+ }
+
+ .node-explorer-content--level-0 .node-explorer-content--level-1 & {
+ cursor: pointer;
+
+ &:hover {
+ background-color: $palette-neutral-100;
+ }
+ }
+}
+
+.node-explorer-content--level-0 {
+ margin-bottom: 8px;
+}
+
+.node-explorer-content__array-node-more {
+ border: none;
+ background: none;
+ margin-left: 10px;
+ padding-top: 3px;
+ color: $palette-neutral-600;
+ cursor: pointer;
+}
diff --git a/web-frontend/modules/core/assets/scss/components/node_help_tooltip.scss b/web-frontend/modules/core/assets/scss/components/node_help_tooltip.scss
new file mode 100644
index 0000000000..c04cc758a4
--- /dev/null
+++ b/web-frontend/modules/core/assets/scss/components/node_help_tooltip.scss
@@ -0,0 +1,45 @@
+.node-help-tooltip {
+ padding: 20px;
+ max-width: 320px;
+ background: $palette-neutral-100;
+
+ @include rounded($rounded-md);
+
+ &__header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+ }
+
+ &__icon {
+ width: 40px;
+ height: 40px;
+ background: $white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 12px;
+ flex-shrink: 0;
+ border: 1px solid $palette-neutral-400;
+
+ @include rounded($rounded-lg);
+ @include elevation($elevation-low);
+ }
+
+ &__icon-symbol {
+ font-size: 18px;
+ color: $palette-neutral-700;
+ }
+
+ &__title {
+ font-size: 16px;
+ font-weight: 500;
+ margin: 0;
+ }
+
+ &__description {
+ font-size: 12px;
+
+ @extend %mb-16;
+ }
+}
diff --git a/web-frontend/modules/core/assets/scss/components/tabs.scss b/web-frontend/modules/core/assets/scss/components/tabs.scss
index af86d5aad5..e96e000e34 100644
--- a/web-frontend/modules/core/assets/scss/components/tabs.scss
+++ b/web-frontend/modules/core/assets/scss/components/tabs.scss
@@ -1,6 +1,13 @@
.tabs {
width: 100%;
background-color: $white;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+
+ &.tabs--rounded {
+ @include rounded($rounded-md);
+ }
}
.tabs--full-height {
@@ -17,14 +24,14 @@
margin: 0;
gap: 24px;
font-weight: 500;
- border-bottom: solid 1px $palette-neutral-100;
+ border-bottom: solid 1px $palette-neutral-200;
padding-left: 15px;
.tabs--large-offset & {
padding-left: 40px;
}
- .tabs--nopadding & {
+ .tabs--header-nopadding & {
padding-left: 0;
}
}
@@ -85,13 +92,21 @@
.tab {
padding: 14px 15px;
position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
.tabs--full-height & {
overflow: auto;
height: 100%;
}
- .tabs--nopadding & {
+ .tabs--content-no-x-padding & {
padding: 14px 0;
}
+
+ .tabs--content-no-padding & {
+ padding: 0;
+ }
}
diff --git a/web-frontend/modules/core/components/Tabs.vue b/web-frontend/modules/core/components/Tabs.vue
index 545e72890f..a1d28e13a5 100644
--- a/web-frontend/modules/core/components/Tabs.vue
+++ b/web-frontend/modules/core/components/Tabs.vue
@@ -5,8 +5,11 @@
'tabs--full-height': fullHeight,
'tabs--large-offset': largeOffset,
'tabs--large': large,
- 'tabs--nopadding': noPadding,
+ 'tabs--header-nopadding': headerNoPadding,
+ 'tabs--content-no-x-padding': contentNoXPadding,
+ 'tabs--content-no-padding': contentNoPadding,
'tabs--grow-items': growItems,
+ 'tabs--rounded': rounded,
}"
>
+
@@ -72,7 +76,7 @@ export default {
default: null,
},
/**
- * Whether the tabs container should add some extra space to the left.
+ * Whether the tabs header container should add some extra space to the left.
*/
largeOffset: {
type: Boolean,
@@ -90,7 +94,23 @@ export default {
/**
* Removes the padding from the tabs container and header.
*/
- noPadding: {
+ headerNoPadding: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ /**
+ * Removes the left and right padding from the tabs container only.
+ */
+ contentNoXPadding: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ /**
+ * Removes padding (x and y) from the tabs container only.
+ */
+ contentNoPadding: {
type: Boolean,
required: false,
default: false,
@@ -108,6 +128,11 @@ export default {
required: false,
default: null,
},
+ rounded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
diff --git a/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue b/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue
deleted file mode 100644
index ea27509d31..0000000000
--- a/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue
+++ /dev/null
@@ -1,203 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ $t('dataExplorer.noMatchingNodesText') }}
-
- {{ $t('dataExplorer.noProvidersText') }}
-
-
-
-
-
-
-
diff --git a/web-frontend/modules/core/components/formula/ContextManagementExtension.js b/web-frontend/modules/core/components/formula/ContextManagementExtension.js
new file mode 100644
index 0000000000..fa3050bc9c
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/ContextManagementExtension.js
@@ -0,0 +1,282 @@
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+
+const contextManagementPluginKey = new PluginKey('contextManagement')
+
+/**
+ * @name ContextManagementExtension
+ * @description Manages the visibility and positioning of the formula input's
+ * context menu (the data explorer and function list). It handles focus and blur
+ * events to automatically show or hide the context menu. It also provides commands
+ * to control the menu programmatically and reposition it based on the surrounding UI.
+ */
+export const ContextManagementExtension = Extension.create({
+ name: 'contextManagement',
+
+ addOptions() {
+ return {
+ vueComponent: null,
+ contextPosition: 'bottom', // 'bottom', 'left', 'right'
+ disabled: false,
+ readOnly: false,
+ }
+ },
+
+ addStorage() {
+ return {
+ ignoreNextBlur: false,
+ clickOutsideEventCancel: null,
+ }
+ },
+
+ addCommands() {
+ return {
+ repositionContext:
+ () =>
+ ({ editor }) => {
+ const { vueComponent } = this.options
+
+ if (!vueComponent || !vueComponent.isFocused) {
+ return false
+ }
+
+ if (vueComponent && vueComponent.$nextTick) {
+ vueComponent.$nextTick(() => {
+ if (!vueComponent.isFocused) return
+
+ // Read directly from Vue component to get reactive value
+ const contextPosition =
+ vueComponent?.contextPosition ?? this.options.contextPosition
+ let config
+
+ switch (contextPosition) {
+ case 'left':
+ config = {
+ vertical: 'top',
+ horizontal: 'left',
+ needsDynamicOffset: true,
+ }
+ break
+ case 'bottom':
+ config = {
+ vertical: 'bottom',
+ horizontal: 'left',
+ verticalOffset: 10,
+ horizontalOffset: 0,
+ }
+ break
+ case 'right':
+ config = {
+ vertical: 'top',
+ horizontal: 'left',
+ needsDynamicOffset: true,
+ }
+ break
+ default:
+ config = {
+ vertical: 'bottom',
+ horizontal: 'left',
+ verticalOffset: 0,
+ horizontalOffset: -400,
+ }
+ }
+
+ const { vertical, horizontal } = config
+ let { verticalOffset = 0, horizontalOffset = 0 } = config
+
+ // Calculate dynamic offsets if necessary
+ if (config.needsDynamicOffset) {
+ const inputRect = vueComponent.$el?.getBoundingClientRect()
+ const contextRect =
+ vueComponent.$refs?.formulaInputContext?.$el?.getBoundingClientRect()
+
+ switch (contextPosition) {
+ case 'left':
+ verticalOffset = -inputRect?.height || 0
+ horizontalOffset = -(contextRect?.width || 0) - 10
+ break
+ case 'right':
+ verticalOffset = -inputRect?.height || 0
+ horizontalOffset = (inputRect?.width || 0) + 10
+ break
+ }
+ }
+
+ if (vueComponent.$refs?.formulaInputContext) {
+ vueComponent.$refs.formulaInputContext.show(
+ vueComponent.$refs.editor.$el,
+ vertical,
+ horizontal,
+ verticalOffset,
+ horizontalOffset
+ )
+ }
+ })
+ }
+
+ return true
+ },
+ showContext:
+ () =>
+ ({ editor }) => {
+ const { vueComponent } = this.options
+
+ // Read directly from Vue component to get reactive values
+ const disabled = vueComponent?.disabled ?? this.options.disabled
+ const readOnly = vueComponent?.readOnly ?? this.options.readOnly
+
+ if (!vueComponent || readOnly || disabled) {
+ return false
+ }
+
+ vueComponent.isFocused = true
+
+ if (vueComponent && vueComponent.$nextTick) {
+ vueComponent.$nextTick(() => {
+ if (!vueComponent.isFocused) return
+
+ editor.commands.unselectNode()
+
+ // Position the context
+ editor.commands.repositionContext()
+
+ if (vueComponent && vueComponent.$el) {
+ const {
+ onClickOutside,
+ isElement,
+ } = require('@baserow/modules/core/utils/dom')
+
+ this.storage.clickOutsideEventCancel = onClickOutside(
+ vueComponent.$el,
+ (target, event) => {
+ if (
+ vueComponent.$refs?.formulaInputContext &&
+ !isElement(
+ vueComponent.$refs.formulaInputContext.$el,
+ target
+ )
+ ) {
+ editor.commands.hideContext()
+ }
+ }
+ )
+ }
+ })
+ }
+
+ return true
+ },
+ hideContext:
+ () =>
+ ({ editor }) => {
+ const { vueComponent } = this.options
+
+ if (vueComponent) {
+ vueComponent.isFocused = false
+ }
+
+ if (vueComponent?.$refs?.formulaInputContext) {
+ vueComponent.$refs.formulaInputContext.hide()
+ }
+
+ editor.commands.unselectNode()
+
+ if (this.storage.clickOutsideEventCancel) {
+ this.storage.clickOutsideEventCancel()
+ this.storage.clickOutsideEventCancel = null
+ }
+
+ return true
+ },
+
+ handleDataExplorerMouseDown: () => () => {
+ this.storage.ignoreNextBlur = true
+ return true
+ },
+ }
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: contextManagementPluginKey,
+ props: {
+ handleDOMEvents: {
+ focus: (view, event) => {
+ if (!this.options.disabled && !this.options.readOnly) {
+ this.editor.commands.showContext()
+ }
+ return false
+ },
+ blur: (view, event) => {
+ if (this.storage.ignoreNextBlur) {
+ this.storage.ignoreNextBlur = false
+ return false
+ }
+ this.editor.commands.hideContext()
+ return false
+ },
+ },
+ },
+ view: () => ({
+ update: (view, prevState) => {
+ // Reposition context when the document changes and context is visible
+ const { vueComponent } = this.options
+ if (vueComponent?.isFocused && view.state.doc !== prevState.doc) {
+ this.editor.commands.repositionContext()
+ }
+ },
+ }),
+ }),
+ ]
+ },
+
+ onCreate() {
+ this.storage.ignoreNextBlur = false
+ this.storage.clickOutsideEventCancel = null
+ },
+
+ onDestroy() {
+ // Clean up listeners
+ if (this.storage.clickOutsideEventCancel) {
+ this.storage.clickOutsideEventCancel()
+ this.storage.clickOutsideEventCancel = null
+ }
+ },
+
+ getContextConfig() {
+ const { vueComponent } = this.options
+ // Read directly from Vue component to get reactive value
+ const contextPosition =
+ vueComponent?.contextPosition ?? this.options.contextPosition
+
+ switch (contextPosition) {
+ case 'left':
+ return {
+ vertical: 'top',
+ horizontal: 'left',
+ needsDynamicOffset: true,
+ }
+ case 'bottom':
+ return {
+ vertical: 'bottom',
+ horizontal: 'left',
+ verticalOffset: 10,
+ horizontalOffset: 0,
+ }
+ case 'right':
+ return {
+ vertical: 'top',
+ horizontal: 'left',
+ needsDynamicOffset: true,
+ }
+ default:
+ return {
+ vertical: 'bottom',
+ horizontal: 'left',
+ verticalOffset: 0,
+ horizontalOffset: -400,
+ }
+ }
+ },
+})
diff --git a/web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js b/web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js
new file mode 100644
index 0000000000..e6e7337cc3
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js
@@ -0,0 +1,412 @@
+/**
+ * @fileoverview Shared utilities for formula editor extensions
+ * This module provides common functionality for detecting strings, functions,
+ * and other syntactic elements in formula text for use across multiple Tiptap extensions.
+ */
+
+// ============================================================================
+// String Detection Utilities
+// ============================================================================
+
+/**
+ * Finds all closed string literal ranges in the document content
+ * @param {Array} content - Array of content items with {char, type, docPos, ...}
+ * @returns {Array} Array of {start, end} ranges for closed strings
+ */
+export const findClosedStringRanges = (content) => {
+ const ranges = []
+ let i = 0
+
+ while (i < content.length) {
+ if (content[i].type !== 'text') {
+ i++
+ continue
+ }
+
+ const ch = content[i].char
+ if (ch === '"' || ch === "'") {
+ const quoteChar = ch
+ const startIdx = i
+ let escaped = false
+ i++
+
+ // Find the closing quote
+ while (i < content.length) {
+ if (content[i].type !== 'text') {
+ i++
+ continue
+ }
+
+ const currentChar = content[i].char
+
+ if (escaped) {
+ escaped = false
+ i++
+ continue
+ }
+
+ if (currentChar === '\\') {
+ escaped = true
+ i++
+ continue
+ }
+
+ if (currentChar === quoteChar) {
+ // Found closing quote
+ ranges.push({ start: startIdx, end: i })
+ i++
+ break
+ }
+
+ i++
+ }
+ } else {
+ i++
+ }
+ }
+
+ return ranges
+}
+
+/**
+ * Checks if a position is inside a closed string literal
+ * @param {Array} content - Array of content items
+ * @param {number} index - Position to check
+ * @param {Array} stringRanges - Pre-computed closed string ranges
+ * @returns {boolean} True if position is inside a closed string
+ */
+export const isInsideClosedString = (content, index, stringRanges) => {
+ return stringRanges.some((range) => index > range.start && index < range.end)
+}
+
+/**
+ * Checks if we're currently after an unclosed quote
+ * @param {Array} content - Array of content items with {char, type, ...}
+ * @param {number} index - Position to check
+ * @returns {boolean} True if there's an unclosed quote before this position
+ */
+export const isAfterUnclosedQuote = (content, index) => {
+ let inSingleQuote = false
+ let inDoubleQuote = false
+ let escaped = false
+
+ for (let idx = 0; idx < index; idx++) {
+ if (content[idx].type !== 'text') continue
+ const ch = content[idx].char
+
+ if (escaped) {
+ escaped = false
+ continue
+ }
+
+ if (ch === '\\') {
+ escaped = true
+ continue
+ }
+
+ if (ch === "'" && !inDoubleQuote) {
+ inSingleQuote = !inSingleQuote
+ } else if (ch === '"' && !inSingleQuote) {
+ inDoubleQuote = !inDoubleQuote
+ }
+ }
+
+ return inSingleQuote || inDoubleQuote
+}
+
+// ============================================================================
+// String Detection for NodeMap (used by FunctionDeletionExtension)
+// ============================================================================
+
+/**
+ * Finds all closed string literal ranges in a nodeMap structure
+ * @param {Array} nodeMap - Array of nodes with {pos, text, isText, ...}
+ * @returns {Array} Array of {start, end} position ranges for closed strings
+ */
+export const findClosedStringRangesInNodeMap = (nodeMap) => {
+ const ranges = []
+
+ for (const item of nodeMap) {
+ if (item.isText && item.text) {
+ let i = 0
+ while (i < item.text.length) {
+ const ch = item.text[i]
+ const charPos = item.pos + i
+
+ if (ch === '"' || ch === "'") {
+ const quoteChar = ch
+ const startPos = charPos
+ let escaped = false
+ i++
+
+ // Find the closing quote in this or subsequent nodes
+ let found = false
+ for (
+ let nodeIdx = nodeMap.indexOf(item);
+ nodeIdx < nodeMap.length;
+ nodeIdx++
+ ) {
+ const searchItem = nodeMap[nodeIdx]
+ if (!searchItem.isText || !searchItem.text) {
+ if (nodeIdx > nodeMap.indexOf(item)) break
+ continue
+ }
+
+ const startIdx = nodeIdx === nodeMap.indexOf(item) ? i : 0
+ for (let k = startIdx; k < searchItem.text.length; k++) {
+ const currentChar = searchItem.text[k]
+ const currentCharPos = searchItem.pos + k
+
+ if (escaped) {
+ escaped = false
+ continue
+ }
+
+ if (currentChar === '\\') {
+ escaped = true
+ continue
+ }
+
+ if (currentChar === quoteChar) {
+ ranges.push({ start: startPos, end: currentCharPos })
+ i = nodeIdx === nodeMap.indexOf(item) ? k + 1 : item.text.length
+ found = true
+ break
+ }
+ }
+
+ if (found) break
+ }
+
+ if (!found) {
+ // No closing quote found, skip to next char
+ break
+ }
+ } else {
+ i++
+ }
+ }
+ }
+ }
+
+ return ranges
+}
+
+/**
+ * Checks if a position is inside a closed string literal (nodeMap version)
+ * @param {Array} nodeMap - Array of nodes
+ * @param {number} targetPos - Document position to check
+ * @param {Array} stringRanges - Pre-computed closed string ranges
+ * @returns {boolean} True if position is inside a closed string
+ */
+export const isInsideClosedStringInNodeMap = (
+ nodeMap,
+ targetPos,
+ stringRanges
+) => {
+ return stringRanges.some(
+ (range) => targetPos > range.start && targetPos < range.end
+ )
+}
+
+/**
+ * Checks if we're after an unclosed quote (nodeMap version)
+ * @param {Array} nodeMap - Array of nodes with {pos, text, isText, ...}
+ * @param {number} targetPos - Document position to check
+ * @returns {boolean} True if there's an unclosed quote before this position
+ */
+export const isAfterUnclosedQuoteInNodeMap = (nodeMap, targetPos) => {
+ let inSingleQuote = false
+ let inDoubleQuote = false
+ let escaped = false
+
+ for (const item of nodeMap) {
+ if (item.isText && item.text) {
+ for (let idx = 0; idx < item.text.length; idx++) {
+ const currentPos = item.pos + idx
+
+ if (currentPos >= targetPos) {
+ return inSingleQuote || inDoubleQuote
+ }
+
+ const ch = item.text[idx]
+
+ if (escaped) {
+ escaped = false
+ continue
+ }
+
+ if (ch === '\\') {
+ escaped = true
+ continue
+ }
+
+ if (ch === "'" && !inDoubleQuote) {
+ inSingleQuote = !inSingleQuote
+ } else if (ch === '"' && !inSingleQuote) {
+ inDoubleQuote = !inDoubleQuote
+ }
+ }
+ }
+ }
+
+ return inSingleQuote || inDoubleQuote
+}
+
+// ============================================================================
+// String Detection for Plain Text (used by FunctionAutoCompleteExtension)
+// ============================================================================
+
+/**
+ * Checks if cursor is inside a string literal (closed or after unclosed quote)
+ * Works on plain text strings (simpler version for autocomplete)
+ * @param {string} text - The text to analyze
+ * @returns {boolean} True if inside or after an unclosed string
+ */
+export const isInsideStringInText = (text) => {
+ const ranges = []
+ let i = 0
+
+ // Find all closed string ranges
+ while (i < text.length) {
+ const ch = text[i]
+
+ if (ch === '"' || ch === "'") {
+ const quoteChar = ch
+ const startIdx = i
+ let escaped = false
+ i++
+
+ // Find the closing quote
+ while (i < text.length) {
+ const currentChar = text[i]
+
+ if (escaped) {
+ escaped = false
+ i++
+ continue
+ }
+
+ if (currentChar === '\\') {
+ escaped = true
+ i++
+ continue
+ }
+
+ if (currentChar === quoteChar) {
+ // Found closing quote
+ ranges.push({ start: startIdx, end: i })
+ i++
+ break
+ }
+
+ i++
+ }
+ } else {
+ i++
+ }
+ }
+
+ // Check if the last position is inside any closed string range
+ const lastPos = text.length - 1
+ if (ranges.some((range) => lastPos > range.start && lastPos < range.end)) {
+ return true
+ }
+
+ // Also check if we're after an unclosed quote
+ let inSingleQuote = false
+ let inDoubleQuote = false
+ let escaped = false
+
+ for (let idx = 0; idx < text.length; idx++) {
+ const ch = text[idx]
+
+ if (escaped) {
+ escaped = false
+ continue
+ }
+
+ if (ch === '\\') {
+ escaped = true
+ continue
+ }
+
+ if (ch === "'" && !inDoubleQuote) {
+ inSingleQuote = !inSingleQuote
+ } else if (ch === '"' && !inSingleQuote) {
+ inDoubleQuote = !inDoubleQuote
+ }
+ }
+
+ return inSingleQuote || inDoubleQuote
+}
+
+// ============================================================================
+// Pattern Matching
+// ============================================================================
+
+/**
+ * Checks if a pattern matches at a given position in the content
+ * @param {Array} content - Array of content items
+ * @param {number} index - Starting position
+ * @param {string} pattern - Pattern to match
+ * @param {boolean} checkWordBoundary - If true, ensures the match is at a word boundary
+ * @returns {boolean} True if pattern matches at this position
+ */
+export const matchesAt = (
+ content,
+ index,
+ pattern,
+ checkWordBoundary = false
+) => {
+ // If checkWordBoundary is true, verify the character before is not alphanumeric
+ if (checkWordBoundary && index > 0) {
+ const prevItem = content[index - 1]
+ if (prevItem && prevItem.type === 'text') {
+ const prevChar = prevItem.char
+ // Check if previous character is alphanumeric or underscore
+ if (/[a-zA-Z0-9_]/.test(prevChar)) {
+ return false
+ }
+ }
+ }
+
+ for (let i = 0; i < pattern.length; i++) {
+ if (
+ index + i >= content.length ||
+ content[index + i].type !== 'text' ||
+ content[index + i].char.toLowerCase() !== pattern[i].toLowerCase()
+ ) {
+ return false
+ }
+ }
+ return true
+}
+
+/**
+ * Finds the closing parenthesis for a function, ignoring parentheses in closed strings
+ * @param {Array} documentContent - Array of content items
+ * @param {number} startIndex - Index after the opening parenthesis
+ * @param {Array} stringRanges - Pre-computed closed string ranges
+ * @returns {number} Index of closing parenthesis, or -1 if not found
+ */
+export const findClosingParen = (documentContent, startIndex, stringRanges) => {
+ let parenCount = 1
+ let k = startIndex
+
+ while (k < documentContent.length && parenCount > 0) {
+ if (documentContent[k].type === 'text') {
+ // Only count parentheses that are not inside CLOSED strings
+ if (!isInsideClosedString(documentContent, k, stringRanges)) {
+ if (documentContent[k].char === '(') {
+ parenCount++
+ } else if (documentContent[k].char === ')') {
+ parenCount--
+ }
+ }
+ }
+ k++
+ }
+
+ return parenCount === 0 ? k - 1 : -1
+}
diff --git a/web-frontend/modules/core/components/formula/FormulaInputContext.vue b/web-frontend/modules/core/components/formula/FormulaInputContext.vue
new file mode 100644
index 0000000000..436de9fe1b
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/FormulaInputContext.vue
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+ {{
+ isAdvancedMode
+ ? $t('formulaInputContext.useRegularInputModalTitle')
+ : $t('formulaInputContext.useAdvancedInputModalTitle')
+ }}
+
+ {{ $t('formulaInputContext.modalMessage') }}
+
+
+
+
+ {{ $t('action.cancel') }}
+
+
+ {{
+ isAdvancedMode
+ ? $t('formulaInputContext.useRegularInput')
+ : $t('formulaInputContext.useAdvancedInput')
+ }}
+
+
+
+
+
+
+
+
diff --git a/web-frontend/modules/core/components/formula/FormulaInputField.vue b/web-frontend/modules/core/components/formula/FormulaInputField.vue
index 020d5bb10d..6a1eccc281 100644
--- a/web-frontend/modules/core/components/formula/FormulaInputField.vue
+++ b/web-frontend/modules/core/components/formula/FormulaInputField.vue
@@ -1,60 +1,36 @@
-
-
- {{ $t('formulaInputField.errorInvalidFormula') }}
-
-
-
- {{ $t('action.reset') }}
-
-
-
-
-
-
-
-
-
-
- {{ $t('formulaInputField.advancedFormulaMode') }}
-
-
+
@@ -63,29 +39,35 @@ import { Editor, EditorContent, generateHTML, Node } from '@tiptap/vue-2'
import { Placeholder } from '@tiptap/extension-placeholder'
import { Document } from '@tiptap/extension-document'
import { Text } from '@tiptap/extension-text'
+import { History } from '@tiptap/extension-history'
+import { FunctionHighlightExtension } from '@baserow/modules/core/components/formula/FunctionHighlightExtension'
+import { FunctionAutoCompleteExtension } from '@baserow/modules/core/components/formula/FunctionAutoCompleteExtension'
+import { FunctionDeletionExtension } from '@baserow/modules/core/components/formula/FunctionDeletionExtension'
+import { FunctionHelpTooltipExtension } from '@baserow/modules/core/components/formula/FunctionHelpTooltipExtension'
+import { FormulaInsertionExtension } from '@baserow/modules/core/components/formula/FormulaInsertionExtension'
+import { NodeSelectionExtension } from '@baserow/modules/core/components/formula/NodeSelectionExtension'
+import { ContextManagementExtension } from '@baserow/modules/core/components/formula/ContextManagementExtension'
import _ from 'lodash'
import parseBaserowFormula from '@baserow/modules/core/formula/parser/parser'
import { ToTipTapVisitor } from '@baserow/modules/core/formula/tiptap/toTipTapVisitor'
import { RuntimeFunctionCollection } from '@baserow/modules/core/functionCollection'
import { FromTipTapVisitor } from '@baserow/modules/core/formula/tiptap/fromTipTapVisitor'
import { mergeAttributes } from '@tiptap/core'
-import DataExplorer from '@baserow/modules/core/components/dataExplorer/DataExplorer'
-import { RuntimeGet } from '@baserow/modules/core/runtimeFormulaTypes'
-import { isElement, onClickOutside } from '@baserow/modules/core/utils/dom'
-import { isFormulaValid } from '@baserow/modules/core/formula'
+import FormulaInputContext from '@baserow/modules/core/components/formula/FormulaInputContext'
import { FF_ADVANCED_FORMULA } from '@baserow/modules/core/plugins/featureFlags'
+import { isFormulaValid } from '@baserow/modules/core/formula'
+import NodeHelpTooltip from '@baserow/modules/core/components/nodeExplorer/NodeHelpTooltip'
export default {
name: 'FormulaInputField',
components: {
- DataExplorer,
+ FormulaInputContext,
EditorContent,
+ NodeHelpTooltip,
},
provide() {
- // Provide the application context to all formula components
return {
- applicationContext: this.applicationContext,
- dataProviders: this.dataProviders,
+ nodesHierarchy: this.nodesHierarchy,
}
},
inject: {
@@ -101,33 +83,29 @@ export default {
required: false,
default: false,
},
+ readOnly: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
placeholder: {
type: String,
default: null,
},
- dataProviders: {
- type: Array,
- required: false,
- default: () => [],
- },
- dataExplorerLoading: {
+ loading: {
type: Boolean,
required: false,
default: false,
},
- applicationContext: {
- type: Object,
- required: true,
- },
small: {
type: Boolean,
required: false,
default: false,
},
- enableAdvancedMode: {
- type: Boolean,
+ nodesHierarchy: {
+ type: Array,
required: false,
- default: false,
+ default: () => [],
},
allowNodeSelection: {
type: Boolean,
@@ -138,6 +116,17 @@ export default {
type: String,
required: false,
default: 'simple',
+ validator: (value) => {
+ return ['advanced', 'simple', 'raw'].includes(value)
+ },
+ },
+ contextPosition: {
+ type: String,
+ required: false,
+ default: 'bottom',
+ validator: (value) => {
+ return ['bottom', 'left', 'right'].includes(value)
+ },
},
},
data() {
@@ -145,23 +134,22 @@ export default {
editor: null,
content: null,
isFormulaInvalid: false,
- dataNodeSelected: null,
isFocused: false,
- ignoreNextBlur: false,
- advancedFormulaValue: this.value,
+ hoveredFunctionNode: null,
+ enableAdvancedMode: this.$featureFlagIsEnabled(FF_ADVANCED_FORMULA),
+ isHandlingModeChange: false,
+ intersectionObserver: null,
}
},
computed: {
- isAdvancedMode() {
- return this.mode === 'advanced'
- },
classes() {
return {
'form-input--disabled': this.disabled,
- 'form-input--error': this.isFormulaInvalid,
'formula-input-field--small': this.small,
- 'formula-input-field--focused': !this.disabled && this.isFocused,
+ 'formula-input-field--focused':
+ !this.disabled && !this.readOnly && this.isFocused,
'formula-input-field--disabled': this.disabled,
+ 'formula-input-field--error': this.isFormulaInvalid,
}
},
placeHolderExt() {
@@ -187,27 +175,99 @@ export default {
},
})
},
+ functionNames() {
+ const extract = (nodes) => {
+ let names = []
+ if (!nodes) {
+ return names
+ }
+ for (const node of nodes) {
+ if (node.type === 'function' && node.signature) {
+ names.push(node.name)
+ }
+ const children = node.nodes
+ if (children) {
+ names = names.concat(extract(children))
+ }
+ }
+
+ return names
+ }
+
+ return extract(this.nodesHierarchy)
+ },
+ operators() {
+ const extract = (nodes) => {
+ let operators = []
+ if (!nodes) {
+ return operators
+ }
+ for (const node of nodes) {
+ if (
+ node.type === 'operator' &&
+ node.signature &&
+ node.signature.operator
+ ) {
+ operators.push(node.signature.operator)
+ }
+ const children = node.nodes
+ if (children) {
+ operators = operators.concat(extract(children))
+ }
+ }
+ return operators
+ }
+ return extract(this.nodesHierarchy)
+ },
extensions() {
const DocumentNode = Document.extend()
const TextNode = Text.extend({ inline: true })
- return [
+ const extensions = [
DocumentNode,
this.wrapperNode,
TextNode,
this.placeHolderExt,
+ History.configure({
+ depth: 100,
+ }),
+ FormulaInsertionExtension.configure({
+ vueComponent: this,
+ }),
+ NodeSelectionExtension.configure({
+ vueComponent: this,
+ }),
+ ContextManagementExtension.configure({
+ vueComponent: this,
+ contextPosition: this.contextPosition,
+ disabled: this.disabled,
+ readOnly: this.readOnly,
+ }),
+ FunctionHelpTooltipExtension.configure({
+ vueComponent: this,
+ }),
+ FunctionHighlightExtension.configure({
+ functionNames: this.mode === 'advanced' ? this.functionNames : [],
+ operators: this.mode === 'advanced' ? this.operators : [],
+ }),
...this.formulaComponents,
]
- },
- htmlContent() {
- if (this.isAdvancedMode) {
- return ''
+
+ if (this.mode === 'advanced') {
+ extensions.push(
+ FunctionAutoCompleteExtension.configure({
+ functionNames: this.functionNames,
+ }),
+ FunctionDeletionExtension.configure({
+ functionNames: this.functionNames,
+ })
+ )
}
+ return extensions
+ },
+ htmlContent() {
try {
- if (!this.content) {
- return generateHTML(this.toContent(''), this.extensions)
- }
return generateHTML(this.content, this.extensions)
} catch (e) {
console.error('Error while parsing formula content', this.value)
@@ -216,82 +276,29 @@ export default {
}
},
wrapperContent() {
- if (this.isAdvancedMode || !this.editor) {
- return null
- }
return this.editor.getJSON()
},
- nodes() {
- return this.dataProviders
- .map((dataProvider) => dataProvider.getNodes(this.applicationContext))
- .filter((dataProviderNodes) => dataProviderNodes.nodes?.length > 0)
- },
nodeSelected() {
- return this.dataNodeSelected?.attrs?.path || null
- },
- showAdvancedCheckbox() {
- return (
- this.enableAdvancedMode &&
- this.$featureFlagIsEnabled(FF_ADVANCED_FORMULA)
- )
+ return this.editor?.commands.getSelectedNodePath() || null
},
},
watch: {
disabled(newValue) {
- this.editor.setOptions({ editable: !newValue })
+ this.editor.setOptions({ editable: !newValue && !this.readOnly })
},
- async isFocused(value) {
- if (!value) {
- this.$refs.dataExplorer?.hide()
- this.unSelectNode()
- } else {
- // Don't show data explorer in Advanced mode
- if (this.isAdvancedMode) {
- return
- }
-
- // Wait for the data explorer to appear in the DOM.
- await this.$nextTick()
-
- this.unSelectNode()
-
- /**
- * The Context.vue calculates where to display the Context menu
- * relative to the input field that triggered it. When the Context
- * decides that the Context menu should be top-adjusted, it will set
- * its bottom coordinate to match the input field's top coordinate,
- * plus a "margin". This "margin" is the verticalOffset and is a
- * negative number; it is negative because the Context menu should not
- * appear below the input field.
- *
- * When the Context menu's bottom coordinate is less than zero, it
- * is hidden.
- *
- * By setting the verticalOffset to the negative value of the input
- * field's height, we ensure that as long as the input field is within
- * the viewport, the bottom coordinate of the Context menu is always
- * >= the bottom coordinate of the input field that triggered it.
- */
- const verticalOffset = -Math.abs(
- this.$el.getBoundingClientRect().height
- )
-
- this.$refs.dataExplorer.show(
- this.$refs.editor.$el,
- 'bottom',
- 'left',
- verticalOffset,
- -330
- )
- }
+ readOnly(newValue) {
+ this.editor.setOptions({ editable: !this.disabled && !newValue })
},
- value(value) {
- // In advanced mode, just update the value directly
- if (this.isAdvancedMode) {
- this.advancedFormulaValue = value
+
+ mode(newMode, oldMode) {
+ // Skip automatic recreation if we're handling it manually in handleModeChange
+ if (this.isHandlingModeChange) {
return
}
+ this.recreateEditor()
+ },
+ value(value) {
if (!_.isEqual(value, this.toFormula(this.wrapperContent))) {
const content = this.toContent(value)
@@ -302,104 +309,108 @@ export default {
},
content: {
handler() {
- if (!_.isEqual(this.content, this.editor.getJSON())) {
- this.editor?.commands.setContent(this.htmlContent, false, {
+ if (this.editor && !_.isEqual(this.content, this.editor.getJSON())) {
+ this.editor.commands.setContent(this.htmlContent, false, {
preserveWhitespace: 'full',
+ addToHistory: false,
})
}
},
deep: true,
},
-
- isAdvancedMode(newValue) {
- if (newValue) {
- // When switching to advanced mode, preserve current value
- this.advancedFormulaValue = this.value
- this.isFormulaInvalid = false
- } else {
- // When switching to simple mode, clear the value to avoid formula parsing errors
- this.advancedFormulaValue = ''
- this.$emit('input', this.advancedFormulaValue)
- }
- },
},
mounted() {
- if (!this.isAdvancedMode) {
- this.content = this.toContent(this.value)
- }
-
- this.editor = new Editor({
- content: this.htmlContent,
- editable: !this.disabled,
- onUpdate: this.onUpdate,
- onFocus: this.onFocus,
- onBlur: this.onBlur,
- extensions: this.extensions,
- parseOptions: {
- preserveWhitespace: 'full',
- },
- editorProps: {
- handleClick: this.unSelectNode,
- },
- })
+ this.createEditor()
+ this.setupIntersectionObserver()
},
beforeDestroy() {
this.editor?.destroy()
+ this.cleanupIntersectionObserver()
},
methods: {
- resetField() {
- this.isFormulaInvalid = false
- this.$emit('input', '')
+ setupIntersectionObserver() {
+ this.intersectionObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting && this.isFocused) {
+ this.isFocused = false
+ if (this.editor) {
+ this.editor.commands.blur()
+ }
+ }
+ })
+ },
+ {
+ root: null,
+ threshold: 0,
+ }
+ )
+
+ if (this.$refs.formulaInputRoot) {
+ this.intersectionObserver.observe(this.$refs.formulaInputRoot)
+ }
},
- emitChange() {
- if (this.isFormulaInvalid) {
- return
+ cleanupIntersectionObserver() {
+ if (this.intersectionObserver) {
+ this.intersectionObserver.disconnect()
+ this.intersectionObserver = null
}
+ },
+ createEditor(formula = null) {
+ // Use provided formula or fall back to the prop value
+ this.content = this.toContent(formula || this.value)
+ this.editor = new Editor({
+ content: this.htmlContent,
+ editable: !this.disabled && !this.readOnly,
+ onUpdate: this.onUpdate,
+ extensions: this.extensions,
+ parseOptions: {
+ preserveWhitespace: 'full',
+ },
+ editorProps: {},
+ })
+ },
+ recreateEditor(formula = null) {
+ // If no formula is provided, save the current formula before destroying the editor
+ const currentFormula =
+ formula ||
+ (this.editor ? this.toFormula(this.wrapperContent) : this.value)
- const formulaValue = this.toFormula(this.wrapperContent)
- this.$emit('input', formulaValue)
+ this.editor?.destroy()
+ this.createEditor(currentFormula)
},
- toggleMode() {
- this.$emit('mode-changed', this.mode === 'simple' ? 'advanced' : 'simple')
+ emitChange() {
+ const functions = new RuntimeFunctionCollection(this.$registry)
+ const formula = this.toFormula(this.wrapperContent)
+ this.isFormulaInvalid = !isFormulaValid(formula, functions)
+
+ if (!this.isFormulaInvalid) {
+ this.$emit('input', this.toFormula(this.wrapperContent))
+ }
},
onUpdate() {
- this.unSelectNode()
this.emitChange()
},
- onFocus(event) {
- // If the input is disabled, we don't want users to be
- // able to open the data explorer and select nodes.
- if (this.disabled) {
- return
+ handleNodeSelected({ path, node }) {
+ switch (node.type) {
+ case 'data':
+ this.editor.commands.insertDataComponent(path)
+ break
+ case 'array':
+ this.editor.commands.insertDataComponent(path)
+ break
+ case 'function':
+ this.editor.commands.insertFunction(node)
+ break
+ case 'operator':
+ this.editor.commands.insertOperator(node)
+ break
+ default:
+ break
}
- this.isFocused = true
-
- this.$el.clickOutsideEventCancel = onClickOutside(
- this.$el,
- (target, event) => {
- if (
- this.$refs.dataExplorer &&
- // We ignore clicks inside data explorer
- !isElement(this.$refs.dataExplorer.$el, target)
- ) {
- this.isFocused = false
- this.editor.commands.blur()
- this.$el.clickOutsideEventCancel()
- }
- }
- )
},
onDataExplorerMouseDown() {
- // If we click in the data explorer we don't want to close it.
- this.ignoreNextBlur = true
- },
- onBlur() {
- if (this.ignoreNextBlur) {
- // Last click was in the data explorer context, we keep the focus.
- this.ignoreNextBlur = false
- } else {
- this.isFocused = false
- }
+ this.editor?.commands.handleDataExplorerMouseDown()
},
toContent(formula) {
if (!formula) {
@@ -409,60 +420,100 @@ export default {
}
}
+ if (this.readOnly) {
+ return {
+ type: 'doc',
+ content: [
+ {
+ type: 'wrapper',
+ content: [
+ {
+ type: 'text',
+ text: formula,
+ },
+ ],
+ },
+ ],
+ }
+ }
+
try {
const tree = parseBaserowFormula(formula)
const functionCollection = new RuntimeFunctionCollection(this.$registry)
- return new ToTipTapVisitor(functionCollection).visit(tree)
+ return new ToTipTapVisitor(functionCollection, this.mode).visit(tree)
} catch (error) {
- this.isFormulaInvalid = true
return null
}
},
- toFormula(content) {
+ toFormula(content, mode = null) {
const functionCollection = new RuntimeFunctionCollection(this.$registry)
try {
- return new FromTipTapVisitor(functionCollection).visit(content)
+ const formula = new FromTipTapVisitor(
+ functionCollection,
+ mode || this.mode
+ ).visit(content)
+
+ return formula
} catch (error) {
- this.isFormulaInvalid = true
return null
}
},
- dataComponentClicked(node) {
- this.selectNode(node)
+ dataNodeClicked(node) {
+ this.editor.commands.selectNode(node)
},
- dataExplorerItemSelected({ path }) {
- const isInEditingMode = this.dataNodeSelected !== null
- if (isInEditingMode) {
- this.dataNodeSelected.attrs.path = path
- this.emitChange()
- } else {
- const getNode = new RuntimeGet().toNode([{ text: path }])
- this.editor.commands.insertContent(getNode)
+ handleEditorClick() {
+ if (this.editor && !this.disabled && !this.readOnly) {
+ this.editor.commands.showContext()
}
- this.editor.commands.focus()
},
- selectNode(node) {
- if (node) {
- this.unSelectNode()
- this.dataNodeSelected = node
- this.dataNodeSelected.attrs.isSelected = true
+ handleModeChange(newMode) {
+ // If switching from advanced to simple, clear the content
+ if (this.mode === 'advanced' && newMode === 'simple') {
+ this.isHandlingModeChange = true
+ this.editor.commands.clearContent()
+ this.$emit('update:mode', newMode)
+ this.$emit('input', '')
+ this.isFormulaInvalid = false
+ this.isHandlingModeChange = false
+ } else {
+ // Otherwise (simple to advanced), keep the current formula
+ // Get the formula BEFORE changing the mode, using the CURRENT mode
+ const currentFormula = this.toFormula(this.wrapperContent, this.mode)
+
+ // Set flag to prevent automatic recreation from watcher
+ this.isHandlingModeChange = true
+
+ // Update the mode
+ this.$emit('update:mode', newMode)
+
+ // Wait for Vue to update the mode prop
+ this.$nextTick(() => {
+ // Recreate the editor with the new mode and preserved formula
+ this.recreateEditor(currentFormula)
+
+ // Emit the formula value
+ if (currentFormula) {
+ this.$emit('input', currentFormula)
+ }
+
+ // Reset the flag
+ this.isHandlingModeChange = false
+ })
}
},
- unSelectNode() {
- if (this.dataNodeSelected) {
- this.dataNodeSelected.attrs.isSelected = false
- this.dataNodeSelected = null
+ undo() {
+ if (this.editor) {
+ this.editor.commands.undo()
}
},
- emitAdvancedChange() {
- const functions = new RuntimeFunctionCollection(this.$registry)
- if (isFormulaValid(this.advancedFormulaValue, functions)) {
- this.isFormulaInvalid = false
- this.$emit('input', this.advancedFormulaValue)
- } else {
- this.isFormulaInvalid = true
+ redo() {
+ if (this.editor) {
+ this.editor.commands.redo()
}
},
+ unSelectNode() {
+ this.editor?.commands.unselectNode()
+ },
},
}
diff --git a/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js b/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js
new file mode 100644
index 0000000000..3580889c0b
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js
@@ -0,0 +1,90 @@
+import { Node, mergeAttributes, Extension } from '@tiptap/core'
+import { VueNodeViewRenderer } from '@tiptap/vue-2'
+import GetFormulaComponent from '@baserow/modules/core/components/formula/GetFormulaComponent'
+
+export const GetFormulaComponentNode = Node.create({
+ name: 'get-formula-component',
+ group: 'inline',
+ inline: true,
+ draggable: true,
+
+ addAttributes() {
+ return {
+ path: {
+ default: null,
+ },
+ isSelected: {
+ default: false,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span[data-formula-component="get-formula-component"]',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'span',
+ mergeAttributes(HTMLAttributes, { 'data-formula-component': this.name }),
+ ]
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(GetFormulaComponent)
+ },
+})
+
+export const FormulaInsertionExtension = Extension.create({
+ name: 'formulaInsertion',
+ addCommands() {
+ return {
+ insertDataComponent:
+ (path) =>
+ ({ editor, commands }) => {
+ commands.insertContent({
+ type: 'get-formula-component',
+ attrs: { path },
+ })
+
+ commands.focus()
+
+ return true
+ },
+ insertFunction:
+ (node) =>
+ ({ editor, commands }) => {
+ const functionName = node.name
+ // Insert zero-width space so cursor can be positioned in the text-segment
+ const functionText = functionName + '(\u200B)'
+
+ const { state } = editor
+ const startPos = state.selection.from
+
+ commands.insertContent(functionText)
+
+ // Position cursor after the zero-width space (in the text-segment)
+ const cursorPos = startPos + functionName.length + 2
+
+ commands.setTextSelection({ from: cursorPos, to: cursorPos })
+
+ commands.focus()
+
+ return true
+ },
+ insertOperator:
+ (node) =>
+ ({ editor, commands }) => {
+ commands.insertContent(node.signature.operator)
+
+ commands.focus()
+
+ return true
+ },
+ }
+ },
+})
diff --git a/web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js b/web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js
new file mode 100644
index 0000000000..27621e2e97
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js
@@ -0,0 +1,100 @@
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from 'prosemirror-state'
+import { isInsideStringInText } from './FormulaExtensionHelpers'
+
+const functionAutoCompletePluginKey = new PluginKey('functionAutoComplete')
+
+/**
+ * @name FunctionAutoCompleteExtension
+ * @description This Tiptap extension enhances the user experience by automatically
+ * closing parentheses for function calls. When a user types a recognized function
+ * name followed by an opening parenthesis `(`, this extension inserts the matching
+ * closing parenthesis `)` and places the cursor in between them, ready for argument
+ * input. It also adds spacing after commas to position the cursor in a text-segment.
+ */
+export const FunctionAutoCompleteExtension = Extension.create({
+ name: 'functionAutoComplete',
+
+ addOptions() {
+ return {
+ functionNames: [],
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const functionNames = this.options.functionNames
+
+ return [
+ new Plugin({
+ key: functionAutoCompletePluginKey,
+ props: {
+ handleTextInput(view, from, to, text) {
+ const { state } = view
+ const { doc } = state
+
+ if (text === '(') {
+ const textBefore =
+ doc.textBetween(Math.max(0, from - 20), to) + text
+
+ // Check if we're inside a string literal
+ if (isInsideStringInText(textBefore)) {
+ return false
+ }
+
+ if (functionNames.length === 0) {
+ return false
+ }
+
+ const functionPattern = new RegExp(
+ `\\b(${functionNames.join('|')})\\s*\\($`,
+ 'i'
+ )
+ const match = textBefore.match(functionPattern)
+
+ if (match) {
+ const tr = state.tr
+
+ tr.insertText(text, from, to)
+
+ // Insert zero-width space and closing parenthesis
+ tr.insertText('\u200B)', from + 1)
+
+ // Position cursor after the zero-width space (in the text-segment)
+ tr.setSelection(
+ state.selection.constructor.near(tr.doc.resolve(from + 2))
+ )
+
+ view.dispatch(tr)
+ return true
+ }
+ }
+
+ // Handle comma input to add space and position cursor in text-segment
+ if (text === ',') {
+ // Check if we're inside a string literal
+ const textBefore = doc.textBetween(Math.max(0, from - 20), to)
+ if (isInsideStringInText(textBefore + text)) {
+ return false
+ }
+
+ const tr = state.tr
+
+ // Insert comma followed by zero-width space
+ tr.insertText(',\u200B', from, to)
+
+ // Position cursor after the zero-width space (in the text-segment)
+ tr.setSelection(
+ state.selection.constructor.near(tr.doc.resolve(from + 2))
+ )
+
+ view.dispatch(tr)
+ return true
+ }
+
+ return false
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/web-frontend/modules/core/components/formula/FunctionDeletionExtension.js b/web-frontend/modules/core/components/formula/FunctionDeletionExtension.js
new file mode 100644
index 0000000000..afb34974c4
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/FunctionDeletionExtension.js
@@ -0,0 +1,210 @@
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+import {
+ findClosedStringRangesInNodeMap,
+ isInsideClosedStringInNodeMap,
+ isAfterUnclosedQuoteInNodeMap,
+} from './FormulaExtensionHelpers'
+
+const functionDeletionPluginKey = new PluginKey('functionDeletion')
+
+/**
+ * @name FunctionDeletionExtension
+ * @description A Tiptap extension that provides "smart" deletion for function
+ * calls. When the user presses `Backspace` on a character that is part of a
+ * function's syntax (like the parenthesis or a comma), this extension deletes the
+ * entire function call, including its arguments, instead of just a single character.
+ * This prevents leaving syntactically invalid remnants of a function.
+ */
+export const FunctionDeletionExtension = Extension.create({
+ name: 'functionDeletion',
+
+ addOptions() {
+ return {
+ functionNames: [],
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const functionNames = this.options.functionNames
+
+ const deleteFunctionRange = (state, view, startPos, endPos) => {
+ if (
+ startPos < endPos &&
+ startPos >= 0 &&
+ endPos <= state.doc.content.size
+ ) {
+ const tr = state.tr.delete(startPos, endPos)
+ view.dispatch(tr)
+ return true
+ }
+ return false
+ }
+
+ const findFunctionBoundaries = (state, cursorPos, functionNames) => {
+ const nodeMap = []
+ state.doc.descendants((node, pos) => {
+ nodeMap.push({
+ node,
+ pos,
+ end: pos + node.nodeSize,
+ isText: node.isText,
+ isDataComponent: node.type.name === 'get-formula-component',
+ text: node.isText ? node.text : '',
+ })
+ })
+
+ const stringRanges = findClosedStringRangesInNodeMap(nodeMap)
+
+ const candidates = []
+
+ for (let i = 0; i < nodeMap.length; i++) {
+ const item = nodeMap[i]
+
+ if (item.isText && item.text) {
+ const functionMatches = [...item.text.matchAll(/(\w+)\(/g)]
+
+ for (const match of functionMatches) {
+ const funcName = match[1]
+ const matchStart = item.pos + match.index
+ const matchEnd = matchStart + match[0].length
+
+ if (!functionNames.includes(funcName)) continue
+
+ // Skip if this function name is inside a string literal (closed or unclosed)
+ if (
+ isInsideClosedStringInNodeMap(
+ nodeMap,
+ matchStart,
+ stringRanges
+ ) ||
+ isAfterUnclosedQuoteInNodeMap(nodeMap, matchStart)
+ )
+ continue
+
+ let openParens = 1
+ let closingParenPos = -1
+
+ for (let j = i; j < nodeMap.length && openParens > 0; j++) {
+ const searchItem = nodeMap[j]
+
+ if (searchItem.isText && searchItem.text) {
+ let textToSearch = searchItem.text
+ let textStartPos = searchItem.pos
+
+ if (j === i) {
+ const skipIndex = match.index + match[0].length
+ textToSearch = searchItem.text.substring(skipIndex)
+ textStartPos = searchItem.pos + skipIndex
+ }
+
+ for (let k = 0; k < textToSearch.length; k++) {
+ const currentPos = textStartPos + k
+ const char = textToSearch[k]
+
+ // Only ignore parentheses that are inside CLOSED strings
+ if (
+ !isInsideClosedStringInNodeMap(
+ nodeMap,
+ currentPos,
+ stringRanges
+ )
+ ) {
+ if (char === '(') {
+ openParens++
+ } else if (char === ')') {
+ openParens--
+ if (openParens === 0) {
+ closingParenPos = textStartPos + k + 1
+ break
+ }
+ }
+ }
+ }
+
+ if (closingParenPos !== -1) break
+ }
+ }
+
+ if (closingParenPos !== -1) {
+ const isInFunctionRange =
+ cursorPos >= matchStart && cursorPos <= closingParenPos
+
+ if (isInFunctionRange) {
+ const shouldDelete =
+ (cursorPos >= matchStart + funcName.length &&
+ cursorPos <= matchEnd) ||
+ cursorPos === matchEnd ||
+ cursorPos === closingParenPos
+
+ if (shouldDelete) {
+ candidates.push({
+ start: matchStart,
+ end: closingParenPos,
+ functionName: funcName,
+ size: closingParenPos - matchStart,
+ })
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (candidates.length > 0) {
+ candidates.sort((a, b) => a.size - b.size)
+ return candidates[0]
+ }
+
+ return null
+ }
+
+ const handleFunctionDeletion = (state, view, functionNames) => {
+ const { from } = state.selection
+
+ const boundaries = findFunctionBoundaries(state, from, functionNames)
+
+ if (boundaries) {
+ return deleteFunctionRange(
+ state,
+ view,
+ boundaries.start,
+ boundaries.end
+ )
+ }
+
+ return false
+ }
+
+ return [
+ new Plugin({
+ key: functionDeletionPluginKey,
+ props: {
+ handleKeyDown: (view, event) => {
+ if (event.key !== 'Backspace') {
+ return false
+ }
+
+ const { state } = view
+ const { selection } = state
+ const { from, to } = selection
+
+ if (from !== to) {
+ return false
+ }
+
+ const nodeAtCursor = state.doc.nodeAt(from - 1)
+ if (
+ nodeAtCursor &&
+ nodeAtCursor.type.name === 'get-formula-component'
+ ) {
+ return false
+ }
+
+ return handleFunctionDeletion(state, view, functionNames)
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/web-frontend/modules/core/components/formula/FunctionHelpTooltipExtension.js b/web-frontend/modules/core/components/formula/FunctionHelpTooltipExtension.js
new file mode 100644
index 0000000000..dd87a4a532
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/FunctionHelpTooltipExtension.js
@@ -0,0 +1,97 @@
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from 'prosemirror-state'
+
+const functionHelpTooltipKey = new PluginKey('functionHelpTooltip')
+
+export const FunctionHelpTooltipExtension = Extension.create({
+ name: 'functionHelpTooltip',
+
+ addOptions() {
+ return {
+ vueComponent: null,
+ selector: '.function-name-highlight',
+ showDelay: 120,
+ hideDelay: 60,
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const { vueComponent, selector, showDelay, hideDelay } = this.options
+ let lastEl = null
+ let lastName = null
+ let showTimer = null
+ let hideTimer = null
+
+ const findFunctionNodeByName = (name) => {
+ const needle = (name || '').toLowerCase()
+ const walk = (nodes) => {
+ for (const n of nodes || []) {
+ if (
+ n.type === 'function' &&
+ typeof n.name === 'string' &&
+ n.signature
+ ) {
+ if (n.name.toLowerCase() === needle) return n
+ }
+ const hit = walk(n.nodes)
+ if (hit) return hit
+ }
+ return null
+ }
+ return walk(vueComponent?.nodesHierarchy || [])
+ }
+
+ const showTooltip = (el, fname) => {
+ clearTimeout(hideTimer)
+ clearTimeout(showTimer)
+ showTimer = setTimeout(() => {
+ const node = findFunctionNodeByName(fname)
+ if (!node) return
+ vueComponent.hoveredFunctionNode = node
+ vueComponent.$refs.nodeHelpTooltip?.show(el, 'bottom', 'right', 6, 10)
+ lastEl = el
+ lastName = fname
+ }, showDelay)
+ }
+
+ const hideTooltip = () => {
+ clearTimeout(showTimer)
+ clearTimeout(hideTimer)
+ hideTimer = setTimeout(() => {
+ vueComponent.$refs.nodeHelpTooltip?.hide()
+ vueComponent.hoveredFunctionNode = null
+ lastEl = null
+ lastName = null
+ }, hideDelay)
+ }
+
+ return [
+ new Plugin({
+ key: functionHelpTooltipKey,
+ props: {
+ handleDOMEvents: {
+ mousemove(view, event) {
+ const root = view.dom
+ const el = event.target?.closest?.(selector)
+ if (el && root.contains(el)) {
+ const text = (el.textContent || '').trim()
+ const m = text.match(/^([A-Za-z_][A-Za-z0-9_]*)/)
+ const fname = m ? m[1] : null
+ if (!fname) return false
+ if (lastEl === el && lastName === fname) return false
+ showTooltip(el, fname)
+ } else if (lastEl) {
+ hideTooltip()
+ }
+ return false
+ },
+ mouseleave() {
+ if (lastEl) hideTooltip()
+ return false
+ },
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/web-frontend/modules/core/components/formula/FunctionHighlightExtension.js b/web-frontend/modules/core/components/formula/FunctionHighlightExtension.js
new file mode 100644
index 0000000000..4c244e29db
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/FunctionHighlightExtension.js
@@ -0,0 +1,388 @@
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from 'prosemirror-state'
+import { Decoration, DecorationSet } from 'prosemirror-view'
+import {
+ findClosedStringRanges,
+ isInsideClosedString,
+ isAfterUnclosedQuote,
+ matchesAt,
+ findClosingParen,
+} from './FormulaExtensionHelpers'
+
+const functionHighlightPluginKey = new PluginKey('functionHighlight')
+
+// ============================================================================
+// Function Detection
+// ============================================================================
+
+/**
+ * Finds all complete function ranges in the document
+ */
+const findFunctionRanges = (documentContent, functionNames, stringRanges) => {
+ const functionRanges = []
+
+ for (let i = 0; i < documentContent.length; i++) {
+ const content = documentContent[i]
+ if (content.type !== 'text') continue
+
+ // Skip if we're inside a string literal (closed or unclosed)
+ if (
+ isInsideClosedString(documentContent, i, stringRanges) ||
+ isAfterUnclosedQuote(documentContent, i)
+ ) {
+ continue
+ }
+
+ for (const functionName of functionNames) {
+ if (matchesAt(documentContent, i, functionName, true)) {
+ const functionStart = i
+ let j = i + functionName.length
+
+ // Skip whitespace after function name
+ while (
+ j < documentContent.length &&
+ documentContent[j].type === 'text' &&
+ /\s/.test(documentContent[j].char)
+ ) {
+ j++
+ }
+
+ // Check for opening parenthesis
+ if (
+ j < documentContent.length &&
+ documentContent[j].type === 'text' &&
+ documentContent[j].char === '('
+ ) {
+ const openParenPos = j
+ const closeParen = findClosingParen(
+ documentContent,
+ j + 1,
+ stringRanges
+ )
+
+ // Only add function range if it's complete
+ if (closeParen !== -1) {
+ functionRanges.push({
+ name: functionName,
+ start: functionStart,
+ openParen: openParenPos,
+ closeParen,
+ end: closeParen + 1,
+ })
+ }
+ }
+ }
+ }
+ }
+
+ return functionRanges
+}
+
+// ============================================================================
+// Segment Building
+// ============================================================================
+
+/**
+ * Finds the content index for a document position
+ */
+const findContentIndex = (documentContent, docPos) => {
+ return documentContent.findIndex(
+ (c) => c.docPos === docPos && c.type === 'text'
+ )
+}
+
+/**
+ * Builds highlighting segments for a text node
+ */
+const buildSegments = (
+ text,
+ pos,
+ documentContent,
+ functionRanges,
+ operators,
+ stringRanges
+) => {
+ const segments = []
+
+ // Build function name segments
+ for (const funcRange of functionRanges) {
+ let funcStartInText = -1
+ let funcEndInText = -1
+
+ for (let i = 0; i < text.length; i++) {
+ const contentIndex = findContentIndex(documentContent, pos + i)
+ if (contentIndex === -1) continue
+
+ if (
+ contentIndex >= funcRange.start &&
+ contentIndex <= funcRange.openParen
+ ) {
+ if (funcStartInText === -1) funcStartInText = i
+ funcEndInText = i + 1
+ }
+ }
+
+ if (funcStartInText !== -1 && funcEndInText !== -1) {
+ segments.push({
+ start: funcStartInText,
+ end: funcEndInText,
+ type: 'function',
+ functionId: funcRange.start,
+ })
+ }
+ }
+
+ // Build segments for closing parentheses and commas
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i]
+ const contentIndex = findContentIndex(documentContent, pos + i)
+
+ for (const funcRange of functionRanges) {
+ if (contentIndex === -1) continue
+
+ // Highlight closing paren
+ if (contentIndex === funcRange.closeParen) {
+ segments.push({
+ start: i,
+ end: i + 1,
+ type: 'function-paren',
+ })
+ } else if (
+ char === ',' &&
+ contentIndex > funcRange.openParen &&
+ contentIndex < funcRange.closeParen &&
+ !isInsideClosedString(documentContent, contentIndex, stringRanges) &&
+ !isAfterUnclosedQuote(documentContent, contentIndex)
+ ) {
+ segments.push({
+ start: i,
+ end: i + 1,
+ type: 'function-comma',
+ })
+ }
+ }
+ }
+
+ // Build operator segments
+ if (operators.length > 0) {
+ const operatorValues = operators
+ .map((op) => (typeof op === 'string' ? op : op?.operator))
+ .filter((op) => op && typeof op === 'string' && op.trim())
+
+ if (operatorValues.length > 0) {
+ const escapedOperators = operatorValues
+ .map((op) => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .sort((a, b) => b.length - a.length)
+
+ const operatorPattern = new RegExp(`(${escapedOperators.join('|')})`, 'g')
+
+ let operatorMatch
+ while ((operatorMatch = operatorPattern.exec(text)) !== null) {
+ const contentIndex = findContentIndex(
+ documentContent,
+ pos + operatorMatch.index
+ )
+
+ if (
+ contentIndex !== -1 &&
+ !isInsideClosedString(documentContent, contentIndex, stringRanges) &&
+ !isAfterUnclosedQuote(documentContent, contentIndex)
+ ) {
+ addToSegments(
+ segments,
+ operatorMatch.index,
+ operatorMatch.index + operatorMatch[0].length,
+ 'operator'
+ )
+ }
+ }
+ }
+ }
+
+ return segments
+}
+
+/**
+ * Merges overlapping segments
+ */
+const addToSegments = (segments, start, end, type, metadata = {}) => {
+ // Don't merge function segments - each function should have its own span
+ if (type === 'function') {
+ segments.push({ start, end, type, ...metadata })
+ return
+ }
+
+ const existing = segments.find(
+ (s) =>
+ s.type === type &&
+ ((s.start <= start && s.end >= start) ||
+ (s.start <= end && s.end >= end) ||
+ (start <= s.start && end >= s.start))
+ )
+
+ if (existing) {
+ existing.start = Math.min(existing.start, start)
+ existing.end = Math.max(existing.end, end)
+ } else {
+ segments.push({ start, end, type, ...metadata })
+ }
+}
+
+/**
+ * Gets the CSS class for a segment type
+ */
+const getSegmentClassName = (segmentType) => {
+ switch (segmentType) {
+ case 'function':
+ return 'function-name-highlight'
+ case 'function-paren':
+ return 'function-paren-highlight'
+ case 'function-comma':
+ return 'function-comma-highlight'
+ case 'operator':
+ return 'operator-highlight'
+ default:
+ return 'text-segment'
+ }
+}
+
+/**
+ * Applies decorations for segments
+ */
+const applySegmentDecorations = (segments, text, pos, decorations) => {
+ let lastIndex = 0
+
+ segments.forEach((segment) => {
+ // Add decoration for text before this segment
+ if (lastIndex < segment.start) {
+ const beforeText = text.slice(lastIndex, segment.start)
+ // Create text-segment even for whitespace-only text to provide visual cursor placement
+ if (beforeText) {
+ decorations.push(
+ Decoration.inline(pos + lastIndex, pos + segment.start, {
+ class: 'text-segment',
+ })
+ )
+ }
+ }
+
+ // Add decoration for the segment itself
+ decorations.push(
+ Decoration.inline(pos + segment.start, pos + segment.end, {
+ class: getSegmentClassName(segment.type),
+ })
+ )
+
+ lastIndex = segment.end
+ })
+
+ // Add decoration for remaining text
+ if (lastIndex < text.length) {
+ const remainingText = text.slice(lastIndex)
+ // Create text-segment even for whitespace-only text to provide visual cursor placement
+ if (remainingText) {
+ decorations.push(
+ Decoration.inline(pos + lastIndex, pos + text.length, {
+ class: 'text-segment',
+ })
+ )
+ }
+ }
+
+ // If no segments, decorate entire text
+ if (segments.length === 0 && text) {
+ decorations.push(
+ Decoration.inline(pos, pos + text.length, {
+ class: 'text-segment',
+ })
+ )
+ }
+}
+
+// ============================================================================
+// Main Extension
+// ============================================================================
+/**
+ * @name FunctionHighlightExtension
+ * @description Provides syntax highlighting for the formula editor. This Tiptap
+ * extension scans the editor's content and applies custom CSS classes to function
+ * names and operators. It uses ProseMirror's `DecorationSet` to apply inline
+ * decorations without modifying the actual document content, ensuring that the
+ * highlighting is purely a visual enhancement.
+ */
+export const FunctionHighlightExtension = Extension.create({
+ name: 'functionHighlight',
+
+ addOptions() {
+ return {
+ functionNames: [],
+ operators: [],
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const functionNames = this.options.functionNames
+ const operators = this.options.operators
+
+ return [
+ new Plugin({
+ key: functionHighlightPluginKey,
+ props: {
+ decorations(state) {
+ const decorations = []
+ const doc = state.doc
+
+ const documentContent = []
+ doc.descendants((node, pos) => {
+ if (node.isText && node.text) {
+ for (let i = 0; i < node.text.length; i++) {
+ documentContent.push({
+ char: node.text[i],
+ docPos: pos + i,
+ nodePos: pos,
+ charIndex: i,
+ type: 'text',
+ })
+ }
+ } else if (node.isLeaf && node.type.name !== 'wrapper') {
+ documentContent.push({
+ char: '',
+ docPos: pos,
+ nodePos: pos,
+ charIndex: 0,
+ type: 'component',
+ componentType: node.type.name,
+ })
+ }
+ })
+
+ const stringRanges = findClosedStringRanges(documentContent)
+ const functionRanges = findFunctionRanges(
+ documentContent,
+ functionNames,
+ stringRanges
+ )
+
+ doc.descendants((node, pos) => {
+ if (node.isText && node.text) {
+ const segments = buildSegments(
+ node.text,
+ pos,
+ documentContent,
+ functionRanges,
+ operators,
+ stringRanges
+ )
+
+ segments.sort((a, b) => a.start - b.start)
+ applySegmentDecorations(segments, node.text, pos, decorations)
+ }
+ })
+
+ return DecorationSet.create(doc, decorations)
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/web-frontend/modules/core/components/formula/GetFormulaComponent.vue b/web-frontend/modules/core/components/formula/GetFormulaComponent.vue
index 4a8e9da2fa..14de66f76d 100644
--- a/web-frontend/modules/core/components/formula/GetFormulaComponent.vue
+++ b/web-frontend/modules/core/components/formula/GetFormulaComponent.vue
@@ -11,7 +11,7 @@
v-tooltip="$t('getFormulaComponent.errorTooltip')"
tooltip-position="top"
:hide-tooltip="!isInvalid"
- @click="emitToEditor('data-component-clicked', node)"
+ @click.stop="emitToEditor('data-node-clicked', node)"
>
category.nodes || []
+ )
+
+ let currentLevelNodes = allRootNodes
+ let lastNode = null
+
+ for (const identifier of this.rawPathParts) {
+ let nodeFound = null
+ if (currentLevelNodes) {
+ nodeFound = currentLevelNodes.find(
+ (node) => (node.identifier || node.name) === identifier
+ )
+ }
+
+ if (nodeFound) {
+ currentLevelNodes = nodeFound.nodes
+ lastNode = nodeFound
+ } else if (
+ lastNode &&
+ lastNode.type === 'array' &&
+ (/^\d+$/.test(identifier) || identifier === '*')
+ ) {
+ currentLevelNodes = lastNode.nodes
+ } else {
+ return true
+ }
+ }
+ return false
},
path() {
return this.node.attrs.path
},
+ nodesHierarchy() {
+ return this.node.attrs.nodesHierarchy || []
+ },
isSelected() {
return this.node.attrs.isSelected
},
rawPathParts() {
return _.toPath(this.path)
},
- dataProviderType() {
- const pathParts = this.rawPathParts
- return this.dataProviders.find(
- (dataProvider) => dataProvider.type === pathParts[0]
- )
- },
- nodes() {
- return [this.dataProviderType.getNodes(this.applicationContext)]
- },
pathParts() {
- const translatedPathPart = this.rawPathParts.map((_, index) =>
- this.dataProviderType.getPathTitle(
- this.applicationContext,
- this.rawPathParts.slice(0, index + 1)
- )
+ const allRootNodes = this.nodesHierarchy.flatMap(
+ (category) => category.nodes || []
)
- translatedPathPart[0] = this.dataProviderType.name
- return translatedPathPart
- },
- },
- methods: {
- findNode(nodes, path) {
- const [identifier, ...rest] = path
-
- if (!nodes) {
- return null
- }
-
- const nodeFound = nodes.find((node) => node.identifier === identifier)
+ let currentLevelNodes = allRootNodes
+ let lastNode = null
+ const translatedParts = []
- if (!nodeFound) {
- return null
- }
+ for (const identifier of this.rawPathParts) {
+ let nodeFound = null
+ if (currentLevelNodes) {
+ nodeFound = currentLevelNodes.find(
+ (node) => (node.identifier || node.name) === identifier
+ )
+ }
- if (rest.length > 0) {
- if (nodeFound.type === 'array') {
- const [index, ...afterIndex] = rest
- // Check that the index is what is expected
- if (!(index === '*' || /^\d+$/.test(index))) {
- return null
- }
- return this.findNode(nodeFound.nodes, afterIndex)
+ if (nodeFound) {
+ translatedParts.push(nodeFound.name)
+ currentLevelNodes = nodeFound.nodes
+ lastNode = nodeFound
+ } else if (
+ lastNode &&
+ lastNode.type === 'array' &&
+ (/^\d+$/.test(identifier) || identifier === '*')
+ ) {
+ translatedParts.push(identifier)
} else {
- return this.findNode(nodeFound.nodes, rest)
+ translatedParts.push(identifier)
+ currentLevelNodes = null
}
}
-
- return nodeFound
+ return translatedParts
},
},
}
diff --git a/web-frontend/modules/core/components/formula/NodeSelectionExtension.js b/web-frontend/modules/core/components/formula/NodeSelectionExtension.js
new file mode 100644
index 0000000000..736b8cc06d
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/NodeSelectionExtension.js
@@ -0,0 +1,112 @@
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+
+const nodeSelectionPluginKey = new PluginKey('nodeSelection')
+
+/**
+ * @name NodeSelectionExtension
+ * @description A Tiptap extension for managing the selection of special "data
+ * component" nodes within the formula editor. These nodes represent non-textual
+ * elements like field references (e.g., `get('field', 'name')`).
+ */
+export const NodeSelectionExtension = Extension.create({
+ name: 'nodeSelection',
+
+ addOptions() {
+ return {
+ vueComponent: null,
+ }
+ },
+
+ addStorage() {
+ return {
+ selectedNode: null,
+ }
+ },
+
+ addCommands() {
+ return {
+ selectNode:
+ (node) =>
+ ({ editor }) => {
+ if (node) {
+ editor.commands.unselectNode()
+
+ this.storage.selectedNode = node
+ this.storage.selectedNode.attrs.isSelected = true
+
+ const { vueComponent } = this.options
+ if (vueComponent) {
+ vueComponent.$emit('node-selected', {
+ node: this.storage.selectedNode,
+ path: this.storage.selectedNode.attrs?.path || null,
+ })
+ }
+ }
+
+ return true
+ },
+ unselectNode:
+ () =>
+ ({ editor }) => {
+ if (this.storage.selectedNode) {
+ this.storage.selectedNode.attrs.isSelected = false
+
+ const { vueComponent } = this.options
+ if (vueComponent) {
+ vueComponent.$emit('node-unselected', {
+ node: this.storage.selectedNode,
+ })
+ }
+
+ this.storage.selectedNode = null
+ }
+
+ return true
+ },
+ getSelectedNode: () => () => {
+ return this.storage.selectedNode
+ },
+ getSelectedNodePath: () => () => {
+ return this.storage.selectedNode?.attrs?.path || null
+ },
+ }
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: nodeSelectionPluginKey,
+ props: {
+ handleClick: (view, pos, event) => {
+ const { target } = event
+
+ const isDataComponent =
+ target.closest('[data-type="get-formula-component"]') ||
+ target.hasAttribute('data-node-clicked')
+
+ if (!isDataComponent) {
+ this.editor.commands.unselectNode()
+ }
+
+ // Return false to allow default click handling
+ return false
+ },
+ },
+ }),
+ ]
+ },
+
+ onCreate() {
+ this.editor.on('update', () => {
+ this.editor.commands.unselectNode()
+ })
+ },
+
+ onDestroy() {
+ if (this.storage.selectedNode) {
+ this.storage.selectedNode.attrs.isSelected = false
+ this.storage.selectedNode = null
+ }
+ },
+})
diff --git a/web-frontend/modules/core/components/nodeExplorer/NodeExplorer.vue b/web-frontend/modules/core/components/nodeExplorer/NodeExplorer.vue
new file mode 100644
index 0000000000..efdce3aa6d
--- /dev/null
+++ b/web-frontend/modules/core/components/nodeExplorer/NodeExplorer.vue
@@ -0,0 +1,256 @@
+
+
+
+
+
diff --git a/web-frontend/modules/core/components/dataExplorer/DataExplorerNode.vue b/web-frontend/modules/core/components/nodeExplorer/NodeExplorerContent.vue
similarity index 59%
rename from web-frontend/modules/core/components/dataExplorer/DataExplorerNode.vue
rename to web-frontend/modules/core/components/nodeExplorer/NodeExplorerContent.vue
index aafb5dc52c..a56f4e7a28 100644
--- a/web-frontend/modules/core/components/dataExplorer/DataExplorerNode.vue
+++ b/web-frontend/modules/core/components/nodeExplorer/NodeExplorerContent.vue
@@ -1,23 +1,25 @@
-
-
-
{{ node.name }}
-
+
+
+
+
+ {{ node.name }}
+
+
+
{{ $t('dataExplorerNode.selectNode') }}
+
-
+
-
-
{{ `[ ${count}...${nextCount - 1} ]` }}
@@ -75,27 +80,25 @@
diff --git a/web-frontend/modules/core/components/nodeExplorer/NodeExplorerTab.vue b/web-frontend/modules/core/components/nodeExplorer/NodeExplorerTab.vue
new file mode 100644
index 0000000000..87e63d204b
--- /dev/null
+++ b/web-frontend/modules/core/components/nodeExplorer/NodeExplorerTab.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
diff --git a/web-frontend/modules/core/components/nodeExplorer/NodeHelpTooltip.vue b/web-frontend/modules/core/components/nodeExplorer/NodeHelpTooltip.vue
new file mode 100644
index 0000000000..0f7b748cc2
--- /dev/null
+++ b/web-frontend/modules/core/components/nodeExplorer/NodeHelpTooltip.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
diff --git a/web-frontend/modules/core/components/settings/McpEndpoint.vue b/web-frontend/modules/core/components/settings/McpEndpoint.vue
index e6546b1b70..723259d67a 100644
--- a/web-frontend/modules/core/components/settings/McpEndpoint.vue
+++ b/web-frontend/modules/core/components/settings/McpEndpoint.vue
@@ -87,7 +87,7 @@
{{ $t('mcpEndpoint.warning') }}
-
+
{
return false
}
}
+
+/**
+ * Get all formula functions from the registry
+ * @param {Object} app The app instance with registry
+ * @returns {Object} All formula functions
+ */
+export const getFormulaFunctions = (app) => {
+ return app.$registry.getAll('runtimeFormulaFunction')
+}
+
+/**
+ * Get formula functions organized by category
+ * @param {Object} app The app instance with registry
+ * @param {Object} i18n The i18n instance (optional, will be extracted from app if not provided)
+ * @returns {Array} Array of category nodes with their functions
+ */
+export const getFormulaFunctionsByCategory = (app, i18n = null) => {
+ const functions = getFormulaFunctions(app)
+ // Support both Option API (this.$t) and Composition API (app.i18n)
+ const i18nInstance = i18n || app.i18n || app
+ const categorizedFunctions = {}
+ const categorizedOperators = {}
+
+ // Group functions by category
+ for (const [functionName, registryItem] of Object.entries(functions)) {
+ try {
+ // The registry might return instances instead of classes
+ let instance = null
+ let FunctionType = null
+
+ // Check if registryItem is already an instance
+ if (
+ registryItem &&
+ typeof registryItem === 'object' &&
+ registryItem.constructor
+ ) {
+ // It's an instance, get its constructor
+ FunctionType = registryItem.constructor
+ instance = registryItem
+ } else if (typeof registryItem === 'function') {
+ // It's a constructor function
+ FunctionType = registryItem
+ instance = new FunctionType({ app })
+ } else {
+ // Skip items that are not valid constructors or instances
+ continue
+ }
+
+ // Skip if getFormulaType is not defined (not a formula function)
+ if (!FunctionType.getFormulaType) {
+ continue
+ }
+
+ // Skip if getCategoryType is not defined
+ if (!FunctionType.getCategoryType) {
+ continue
+ }
+
+ // Get formula type
+ const formulaType = FunctionType.getFormulaType()
+
+ // Get category - use static method and format it
+ const categoryType = FunctionType.getCategoryType()
+
+ // Get translated category name
+ let category = 'Other'
+ const icon = categoryType.iconClass || 'iconoir-function'
+
+ if (categoryType.category) {
+ // Get translated category name using i18n
+ // Support both i18n.t and $t methods
+ const translateMethod = i18nInstance.t || i18nInstance.$t
+ if (translateMethod) {
+ category = translateMethod.call(
+ i18nInstance,
+ `runtimeFormulaTypes.${categoryType.category}`
+ )
+ }
+ }
+
+ const item = {
+ name: functionName,
+ functionType: FunctionType,
+ icon,
+ instance,
+ }
+
+ if (formulaType === FORMULA_TYPE.OPERATOR) {
+ // Store operators by their category
+ if (!categorizedOperators[category]) {
+ categorizedOperators[category] = []
+ }
+ categorizedOperators[category].push(item)
+ } else if (formulaType === FORMULA_TYPE.FUNCTION) {
+ // Store regular functions
+ if (!categorizedFunctions[category]) {
+ categorizedFunctions[category] = []
+ }
+ categorizedFunctions[category].push(item)
+ }
+ } catch (e) {
+ // Skip functions that throw errors during processing
+ console.warn(`Skipping ${functionName} due to error:`, e.message)
+ }
+ }
+
+ // Build the hierarchy
+ const functionNodes = []
+ const categories = Object.keys(categorizedFunctions).sort()
+
+ for (const category of categories) {
+ if (categorizedFunctions[category].length > 0) {
+ functionNodes.push({
+ name: category,
+ functions: categorizedFunctions[category].sort((a, b) =>
+ a.name.localeCompare(b.name)
+ ),
+ })
+ }
+ }
+
+ // Add operators as a separate structure
+ const operatorNodes = []
+ const operatorCategories = Object.keys(categorizedOperators).sort()
+
+ for (const category of operatorCategories) {
+ if (categorizedOperators[category].length > 0) {
+ operatorNodes.push({
+ name: category,
+ operators: categorizedOperators[category].sort((a, b) =>
+ a.name.localeCompare(b.name)
+ ),
+ })
+ }
+ }
+
+ return { functionNodes, operatorNodes }
+}
+
+/**
+ * Build function nodes for FormulaInputField
+ * @param {Object} app The app instance with registry or Vue component instance
+ * @param {Object} i18n The i18n instance (optional, will be extracted from app if not provided)
+ * @returns {Array} Array of function nodes ready for FormulaInputField
+ */
+export const buildFormulaFunctionNodes = (app, i18n = null) => {
+ // Support both Option API (this.$t) and Composition API (app.i18n)
+ const i18nInstance = i18n || app.i18n || app
+ const { functionNodes, operatorNodes } = getFormulaFunctionsByCategory(
+ app,
+ i18nInstance
+ )
+ const nodes = []
+
+ // Get translation methods once at the beginning
+ const tcMethod = i18nInstance.tc || i18nInstance.$tc
+ const tMethod = i18nInstance.t || i18nInstance.$t
+
+ // Process regular functions
+ if (functionNodes.length > 0) {
+ const functionCategories = []
+
+ for (const category of functionNodes) {
+ const categoryNodes = []
+
+ for (const func of category.functions) {
+ const instance = func.instance
+
+ // Get function signature information
+ let signature = null
+
+ // Check if function is variadic by looking at its validateNumberOfArgs implementation
+ const isVariadic =
+ instance.validateNumberOfArgs &&
+ instance.validateNumberOfArgs !==
+ instance.constructor.prototype.validateNumberOfArgs
+
+ if (instance.args && instance.args.length > 0) {
+ signature = {
+ parameters: instance.args.map((arg, index) => {
+ // Map argument types to their string representation
+ let type = 'any'
+ const argClassName = arg.constructor.name
+
+ if (argClassName === 'NumberBaserowRuntimeFormulaArgumentType') {
+ type = 'number'
+ } else if (
+ argClassName === 'TextBaserowRuntimeFormulaArgumentType'
+ ) {
+ type = 'text'
+ } else if (
+ argClassName === 'DateTimeBaserowRuntimeFormulaArgumentType'
+ ) {
+ type = 'date'
+ } else if (
+ argClassName === 'ObjectBaserowRuntimeFormulaArgumentType'
+ ) {
+ type = 'object'
+ }
+
+ return {
+ type,
+ required: true,
+ }
+ }),
+ variadic: isVariadic,
+ minArgs: isVariadic ? 1 : instance.numArgs ?? instance.args.length,
+ maxArgs: isVariadic
+ ? null
+ : instance.numArgs ?? instance.args.length,
+ }
+ } else {
+ signature = {
+ parameters: [
+ {
+ type: 'any',
+ required: true,
+ },
+ ],
+ variadic: isVariadic,
+ minArgs: 1,
+ maxArgs: null,
+ }
+ }
+
+ // Get description and examples
+ let description = null
+ let example = null
+ try {
+ description = instance.getDescription()
+ } catch (e) {
+ // Method not implemented
+ }
+ try {
+ const examples = instance.getExamples()
+ example = examples && examples.length > 0 ? examples[0] : null
+ } catch (e) {
+ // Method not implemented
+ }
+
+ categoryNodes.push({
+ name: func.name,
+ type: 'function',
+ description,
+ example,
+ highlightingColor: 'blue',
+ icon: func.icon,
+ identifier: null,
+ order: null,
+ signature,
+ })
+ }
+
+ functionCategories.push({
+ name: category.name,
+ identifier: null,
+ order: null,
+ signature: null,
+ description: null,
+ example: null,
+ highlightingColor: null,
+ icon: null,
+ nodes: categoryNodes,
+ })
+ }
+
+ // Add functions as a top-level section
+ nodes.push({
+ name: tcMethod
+ ? tcMethod.call(
+ i18nInstance,
+ 'runtimeFormulaTypes.formulaTypeFormula',
+ {
+ count: functionNodes.length,
+ }
+ )
+ : tMethod.call(i18nInstance, 'runtimeFormulaTypes.formulaTypeFormula'),
+ type: 'function',
+ identifier: null,
+ order: null,
+ signature: null,
+ description: null,
+ example: null,
+ highlightingColor: 'blue',
+ icon: null,
+ nodes: functionCategories,
+ })
+ }
+
+ // Process operators
+ if (operatorNodes.length > 0) {
+ const operatorCategories = []
+
+ for (const category of operatorNodes) {
+ const categoryNodes = []
+
+ for (const op of category.operators) {
+ const instance = op.instance
+ const operatorSymbol = instance.getOperatorSymbol
+
+ // Build operator signature
+ const signature = {
+ operator: operatorSymbol,
+ parameters: instance.args
+ ? instance.args.map((arg, index) => {
+ // Map argument types to their string representation
+ let type = 'any'
+ const argClassName = arg.constructor.name
+
+ if (
+ argClassName === 'NumberBaserowRuntimeFormulaArgumentType'
+ ) {
+ type = 'number'
+ } else if (
+ argClassName === 'TextBaserowRuntimeFormulaArgumentType'
+ ) {
+ type = 'text'
+ } else if (
+ argClassName === 'DateTimeBaserowRuntimeFormulaArgumentType'
+ ) {
+ type = 'date'
+ } else if (
+ argClassName === 'ObjectBaserowRuntimeFormulaArgumentType'
+ ) {
+ type = 'object'
+ }
+
+ return {
+ name: index === 0 ? 'left' : 'right',
+ type,
+ required: true,
+ }
+ })
+ : [
+ {
+ type: 'any',
+ required: true,
+ },
+ {
+ type: 'any',
+ required: true,
+ },
+ ],
+ variadic: false,
+ minArgs: 2,
+ maxArgs: 2,
+ }
+
+ // Get description and examples
+ let description = null
+ let example = null
+
+ description = instance.getDescription()
+ const examples = instance.getExamples()
+ example = examples && examples.length > 0 ? examples[0] : null
+
+ categoryNodes.push({
+ name: op.name,
+ type: 'operator',
+ description,
+ example,
+ highlightingColor: 'green',
+ icon: op.icon,
+ identifier: null,
+ order: null,
+ signature,
+ })
+ }
+
+ operatorCategories.push({
+ name: category.name,
+ example: null,
+ signature: null,
+ highlightingColor: null,
+ icon: null,
+ identifier: null,
+ order: null,
+ description: null,
+ nodes: categoryNodes,
+ })
+ }
+
+ // Add operators as a top-level section
+ nodes.push({
+ name: tcMethod
+ ? tcMethod.call(
+ i18nInstance,
+ 'runtimeFormulaTypes.formulaTypeOperator',
+ {
+ count: operatorNodes.length,
+ }
+ )
+ : tMethod.call(i18nInstance, 'runtimeFormulaTypes.formulaTypeOperator'),
+ type: 'operator',
+ nodes: operatorCategories,
+ })
+ }
+ return nodes
+}
diff --git a/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js b/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js
index fd55383587..27e0e6894b 100644
--- a/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js
+++ b/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js
@@ -1,6 +1,7 @@
export class FromTipTapVisitor {
- constructor(functions) {
+ constructor(functions, mode = 'simple') {
this.functions = functions
+ this.mode = mode
}
visit(node) {
@@ -31,25 +32,118 @@ export class FromTipTapVisitor {
}
}
- // Add the newlines between root wrappers. They are paragraphs.
+ // Try to reconstruct a single function call spread across multiple wrappers
+ const flatContent = node.content.flatMap((w) =>
+ Array.isArray(w?.content) ? w.content : []
+ )
+ if (flatContent.length > 0 && this.isFunctionCallPattern(flatContent)) {
+ const result = this.assembleFunctionCall(flatContent)
+ if (result) return result
+ }
+
+ // Fallback: join multiple paragraphs with a visible newline
return `concat(${nodeContents.join(", '\n', ")})`
}
visitWrapper(node) {
if (!node.content || node.content.length === 0) {
- // An empty wrapper is an empty string
return "''"
}
+ // Handle nested empty wrapper
+ if (node.content.length === 1 && node.content[0].type === 'wrapper') {
+ return this.visit(node.content[0])
+ }
+
if (node.content.length === 1) {
return this.visit(node.content[0])
}
- return `concat(${node.content.map(this.visit.bind(this)).join(', ')})`
+ if (this.isFunctionCallPattern(node.content)) {
+ const result = this.assembleFunctionCall(node.content)
+ if (result) return result
+ }
+
+ if (node.content.length >= 3) {
+ const firstNode = node.content[0]
+ const lastNode = node.content[node.content.length - 1]
+
+ if (firstNode.type === 'text' && lastNode.type === 'text') {
+ const firstText = firstNode.text
+ const lastText = lastNode.text
+
+ if (
+ /^[a-zA-Z_][a-zA-Z0-9_]*\s*\(/.test(firstText) &&
+ lastText.includes(')')
+ ) {
+ const result = this.assembleFunctionCall(node.content)
+ if (result) return result
+ }
+ }
+ }
+
+ if (this.mode === 'simple') {
+ return `concat(${node.content.map(this.visit.bind(this)).join(', ')})`
+ } else {
+ return node.content.map(this.visit.bind(this)).join('\n')
+ }
+ }
+
+ isFunctionCallPattern(content) {
+ if (content.length < 2) return false
+
+ const firstNode = content[0]
+ const lastNode = content[content.length - 1]
+
+ if (firstNode.type !== 'text') return false
+ const firstText = firstNode.text
+ const functionStartPattern = /^[a-zA-Z_][a-zA-Z0-9_]*\s*\(/
+ if (!functionStartPattern.test(firstText)) return false
+
+ if (lastNode.type !== 'text') return false
+ const lastText = lastNode.text
+ if (!lastText.includes(')')) return false
+
+ return true
+ }
+
+ assembleFunctionCall(content) {
+ const firstNode = content[0]
+
+ const firstText = firstNode.text
+ const functionMatch = firstText.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/)
+ if (!functionMatch) return null
+
+ const functionName = functionMatch[1]
+
+ let fullContent = ''
+ for (let i = 0; i < content.length; i++) {
+ const node = content[i]
+ if (node.type === 'text') {
+ // Remove zero-width spaces used for cursor positioning
+ fullContent += node.text.replace(/\u200B/g, '')
+ } else {
+ fullContent += this.visit(node)
+ }
+ }
+
+ const argsStartIndex = fullContent.indexOf('(')
+ const argsEndIndex = fullContent.lastIndexOf(')')
+
+ if (argsStartIndex === -1 || argsEndIndex === -1) {
+ return null
+ }
+
+ const argsString = fullContent.substring(argsStartIndex + 1, argsEndIndex)
+ const suffix = fullContent.slice(argsEndIndex + 1)
+ return `${functionName}(${argsString})${suffix}`
}
visitText(node) {
- return `'${node.text.replace(/'/g, "\\'")}'`
+ // Remove zero-width spaces used for cursor positioning
+ const cleanText = node.text.replace(/\u200B/g, '')
+ if (this.mode === 'simple') return `'${cleanText.replace(/'/g, "\\'")}'`
+ return cleanText
}
visitFunction(node) {
diff --git a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js
index 11b6e4f6fa..c25210bc46 100644
--- a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js
+++ b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js
@@ -3,13 +3,45 @@ import { UnknownOperatorError } from '@baserow/modules/core/formula/parser/error
import _ from 'lodash'
export class ToTipTapVisitor extends BaserowFormulaVisitor {
- constructor(functions) {
+ constructor(functions, mode = 'simple') {
super()
this.functions = functions
+ this.mode = mode
}
visitRoot(ctx) {
const result = ctx.expr().accept(this)
+
+ // In advanced mode, ensure all content is wrapped in a single wrapper
+ if (this.mode === 'advanced') {
+ const content = _.isArray(result) ? result : [result]
+ return {
+ type: 'doc',
+ content: [
+ {
+ type: 'wrapper',
+ content: content.flatMap((item) => {
+ // Filter out null or undefined items
+ if (!item) return []
+
+ // If the item is an array (from functions without wrapper in advanced mode)
+ if (Array.isArray(item)) {
+ return item
+ }
+
+ // If the item is a wrapper, extract its content
+ if (item.type === 'wrapper' && item.content) {
+ return item.content
+ }
+
+ // Return the item if it has a type
+ return item.type ? [item] : []
+ }),
+ },
+ ],
+ }
+ }
+
return { type: 'doc', content: _.isArray(result) ? result : [result] }
}
@@ -18,24 +50,25 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor {
case "'\n'":
// Specific element that helps to recognize root concat
return { type: 'newLine' }
- default:
- if (this.processString(ctx)) {
- return { type: 'text', text: this.processString(ctx) }
+ default: {
+ const processedString = this.processString(ctx)
+ if (processedString) {
+ return { type: 'text', text: processedString }
} else {
// An empty string is an empty wrapper
return { type: 'wrapper' }
}
+ }
}
}
visitDecimalLiteral(ctx) {
- // TODO
- return parseFloat(ctx.getText())
+ return { type: 'text', text: ctx.getText() }
}
visitBooleanLiteral(ctx) {
- // TODO
- return ctx.TRUE() !== null
+ const value = ctx.TRUE() !== null ? 'true' : 'false'
+ return { type: 'text', text: value }
}
visitBrackets(ctx) {
@@ -69,8 +102,111 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor {
)
const formulaFunctionType = this.functions.get(functionName)
+ const node = formulaFunctionType.toNode(args, this.mode)
+
+ // If the function returns an array (like concat with newlines in simple mode),
+ // return it directly
+ if (Array.isArray(node)) {
+ return node
+ }
+
+ // If the function doesn't have a proper TipTap component (node is null or type is null),
+ // wrap it as text but preserve the arguments
+ if (!node || !node.type) {
+ const content = []
+
+ // Check if this is an operator and should use symbol instead of function name
+ const isOperator = formulaFunctionType.getOperatorSymbol
+
+ if (isOperator && args.length === 2) {
+ // For binary operators, display as: arg1 symbol arg2
+ const [leftArg, rightArg] = args
+
+ // Add left argument
+ if (leftArg.type === 'text' && typeof leftArg.text === 'string') {
+ const isBoolean = leftArg.text === 'true' || leftArg.text === 'false'
+ const isNumber = !isNaN(Number(leftArg.text))
+ if (isBoolean || isNumber) {
+ content.push(leftArg)
+ } else {
+ content.push({ type: 'text', text: `"${leftArg.text}"` })
+ }
+ } else if (Array.isArray(leftArg)) {
+ // If arg is an array (from nested function calls in advanced mode),
+ // spread its elements
+ content.push(...leftArg)
+ } else if (leftArg) {
+ content.push(leftArg)
+ }
+
+ // Add operator symbol
+ content.push({
+ type: 'text',
+ text: ` ${formulaFunctionType.getOperatorSymbol} `,
+ })
+
+ // Add right argument
+ if (rightArg.type === 'text' && typeof rightArg.text === 'string') {
+ const isBoolean =
+ rightArg.text === 'true' || rightArg.text === 'false'
+ const isNumber = !isNaN(Number(rightArg.text))
+ if (isBoolean || isNumber) {
+ content.push(rightArg)
+ } else {
+ content.push({ type: 'text', text: `"${rightArg.text}"` })
+ }
+ } else if (Array.isArray(rightArg)) {
+ // If arg is an array (from nested function calls in advanced mode),
+ // spread its elements
+ content.push(...rightArg)
+ } else if (rightArg) {
+ content.push(rightArg)
+ }
+ } else {
+ // For functions, display as: functionName(arg1, arg2, ...)
+ content.push({ type: 'text', text: `${functionName}(` })
+
+ args.forEach((arg, index) => {
+ if (index > 0) {
+ content.push({ type: 'text', text: ', ' })
+ }
+
+ // Check if the argument is a complex node or a simple value
+ if (arg.type === 'text' && typeof arg.text === 'string') {
+ // Don't add quotes for boolean or numeric values
+ const isBoolean = arg.text === 'true' || arg.text === 'false'
+ const isNumber = !isNaN(Number(arg.text))
+
+ if (isBoolean || isNumber) {
+ content.push(arg)
+ } else {
+ // For actual string literals, add quotes
+ content.push({ type: 'text', text: `"${arg.text}"` })
+ }
+ } else if (Array.isArray(arg)) {
+ // If arg is an array (from nested function calls in advanced mode),
+ // spread its elements
+ content.push(...arg)
+ } else if (arg) {
+ content.push(arg)
+ }
+ })
+
+ content.push({ type: 'text', text: ')' })
+ }
+
+ // In advanced mode, return inline content without wrapper
+ if (this.mode === 'advanced') {
+ return content
+ }
+
+ return {
+ type: 'wrapper',
+ content,
+ }
+ }
- return formulaFunctionType.toNode(args)
+ return node
}
visitBinaryOp(ctx) {
diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json
index 311db2f006..b1379119c4 100644
--- a/web-frontend/modules/core/locales/en.json
+++ b/web-frontend/modules/core/locales/en.json
@@ -806,6 +806,17 @@
"errorInvalidFormula": "The formula is invalid.",
"advancedFormulaMode": "Advanced formula mode"
},
+ "formulaInputContext": {
+ "variables": "Variables",
+ "functions": "Functions",
+ "operators": "Operators",
+ "search": "Search",
+ "useRegularInputModalTitle": "Use regular input for this field?",
+ "useRegularInput": "Use regular input",
+ "useAdvancedInput": "Use advanced input",
+ "useAdvancedInputModalTitle": "Use advanced input for this field?",
+ "modalMessage": "Your field’s content will be cleared and it will not be possible to recover it."
+ },
"dataExplorer": {
"noMatchingNodesText": "No matching results were found.",
"noProvidersText": "No data providers were found. To get started you can, for example, add a data source or page parameter."
diff --git a/web-frontend/modules/core/runtimeFormulaTypes.js b/web-frontend/modules/core/runtimeFormulaTypes.js
index 211fa6d293..39831b24f4 100644
--- a/web-frontend/modules/core/runtimeFormulaTypes.js
+++ b/web-frontend/modules/core/runtimeFormulaTypes.js
@@ -151,9 +151,10 @@ export class RuntimeFormulaFunction extends Registerable {
* in the editor.
*
* @param args - The args that are being parsed
+ * @param mode - The mode of the formula editor ('simple', 'advanced', or 'raw')
* @returns {object || Array} - The component configuration or a list of components
*/
- toNode(args) {
+ toNode(args, mode = 'simple') {
return {
type: this.formulaComponentType,
}
@@ -212,8 +213,15 @@ export class RuntimeConcat extends RuntimeFormulaFunction {
return args.length > 1
}
- toNode(args) {
- // Recognize root concat that adds the new lines between paragraphs
+ toNode(args, mode = 'simple') {
+ // In advanced mode, we want to show the formula as-is with quotes
+ if (mode === 'advanced') {
+ return {
+ type: this.formulaComponentType,
+ }
+ }
+
+ // In simple mode, recognize root concat that adds the new lines between paragraphs
if (args.every((arg, index) => index % 2 === 0 || arg.type === 'newLine')) {
return args
.filter((arg, index) => index % 2 === 0) // Remove the new lines elements
diff --git a/web-frontend/modules/core/utils/dataProviders.js b/web-frontend/modules/core/utils/dataProviders.js
new file mode 100644
index 0000000000..f565442ca4
--- /dev/null
+++ b/web-frontend/modules/core/utils/dataProviders.js
@@ -0,0 +1,50 @@
+/**
+ * Processes a list of data providers to extract and transform their nodes
+ * into a structure compatible with the FormulaInputField component. It also
+ * filters out any top-level nodes that do not have any nested nodes.
+ *
+ * @param {Array} dataProviders - An array of data provider instances.
+ * @param {Object} applicationContext - The context required by the data providers' getNodes method.
+ * @returns {Array} An array of filtered and transformed data nodes.
+ */
+export const getDataNodesFromDataProvider = (
+ dataProviders,
+ applicationContext
+) => {
+ const dataNodes = []
+ if (!dataProviders) {
+ return []
+ }
+
+ for (const dataProvider of dataProviders) {
+ if (dataProvider && typeof dataProvider.getNodes === 'function') {
+ 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))
+ }
+ }
+ }
+ }
+
+ // Filter out first-level data nodes that have empty nodes arrays
+ return dataNodes.filter((node) => node.nodes && node.nodes.length > 0)
+}
diff --git a/web-frontend/modules/database/components/row/RowEditModalSidebar.vue b/web-frontend/modules/database/components/row/RowEditModalSidebar.vue
index 40fec1dcfe..e922fcb9c3 100644
--- a/web-frontend/modules/database/components/row/RowEditModalSidebar.vue
+++ b/web-frontend/modules/database/components/row/RowEditModalSidebar.vue
@@ -3,7 +3,8 @@
:selected-index="selectedTabIndex"
full-height
grow-items
- no-padding
+ header-no-padding
+ content-no-x-padding
class="row-edit-modal-sidebar"
>
-
+
+
+# FormulaInputField
+
+The FormulaInputField component is used to input and edit formulas with syntax highlighting, validation, and auto-completion. It supports functions, operators, and data references.
+
+export const mockApplicationContext = {
+ page: {
+ id: 1,
+ name: 'Test Page',
+ },
+}
+
+export const mockNodesHierarchy = [
+ {
+ name: 'Data',
+ type: 'data',
+ identifier: null,
+ icon: null,
+ order: null,
+ description: null,
+ example: null,
+ highlightingColor: null,
+ nodes: [
+ {
+ name: 'User',
+ type: 'data',
+ identifier: 'user',
+ icon: 'iconoir-question-mark',
+ order: null,
+ description: null,
+ example: null,
+ highlightingColor: null,
+ signature: null,
+ nodes: [
+ {
+ name: 'Is authenticated',
+ type: 'data',
+ identifier: 'is_authenticated',
+ description: null,
+ icon: 'baserow-icon-circle-checked',
+ highlightingColor: null,
+ description: null,
+ example: null,
+ order: null,
+ signature: null,
+ },
+ {
+ name: 'Id',
+ type: 'data',
+ identifier: 'id',
+ description: null,
+ icon: 'baserow-icon-hashtag',
+ highlightingColor: null,
+ description: null,
+ example: null,
+ order: null,
+ signature: null,
+ },
+ {
+ name: 'Email',
+ type: 'data',
+ identifier: 'email',
+ description: null,
+ icon: 'iconoir-text',
+ highlightingColor: null,
+ description: null,
+ example: null,
+ order: null,
+ signature: null,
+ },
+ {
+ name: 'Username',
+ type: 'data',
+ identifier: 'username',
+ description: null,
+ icon: 'iconoir-text',
+ highlightingColor: null,
+ description: null,
+ example: null,
+ order: null,
+ signature: null,
+ },
+ {
+ name: 'Role',
+ type: 'data',
+ identifier: 'role',
+ description: null,
+ icon: 'iconoir-text',
+ highlightingColor: null,
+ description: null,
+ example: null,
+ order: null,
+ signature: null,
+ },
+ ],
+ },
+ {
+ name: 'Data records',
+ type: 'data',
+ identifier: 'data_source',
+ highlightingColor: null,
+ description: null,
+ example: null,
+ order: null,
+ icon: 'iconoir-question-mark',
+ signature: null,
+ nodes: [
+ {
+ name: 'List rows',
+ type: 'data',
+ identifier: '543',
+ description: null,
+ highlightingColor: null,
+ example: null,
+ icon: 'iconoir-list',
+ type: 'array',
+ order: null,
+ nodes: [
+ {
+ name: 'Id',
+ type: 'data',
+ order: null,
+ description: null,
+ highlightingColor: null,
+ example: null,
+ icon: 'baserow-icon-hashtag',
+ identifier: 'id',
+ signature: null,
+ },
+ {
+ name: 'Name',
+ type: 'data',
+ description: null,
+ example: null,
+ highlightingColor: null,
+ order: null,
+ icon: 'iconoir-text',
+ identifier: 'field_7094',
+ signature: null,
+ },
+ {
+ name: 'Last name',
+ type: 'data',
+ order: null,
+ description: null,
+ example: null,
+ highlightingColor: null,
+ icon: 'iconoir-text',
+ identifier: 'field_7095',
+ signature: null,
+ },
+ {
+ name: 'Notes',
+ type: 'data',
+ description: null,
+ example: null,
+ highlightingColor: null,
+ order: null,
+ icon: 'iconoir-text',
+ identifier: 'field_7096',
+ signature: null,
+ },
+ {
+ name: 'Active',
+ type: 'data',
+ description: null,
+ example: null,
+ highlightingColor: null,
+ order: null,
+ icon: 'baserow-icon-circle-checked',
+ identifier: 'field_7097',
+ signature: null,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'Functions',
+ type: 'function',
+ identifier: null,
+ order: null,
+ signature: null,
+ description: null,
+ example: null,
+ highlightingColor: null,
+ icon: null,
+ nodes: [
+ {
+ name: 'Text',
+ identifier: null,
+ order: null,
+ signature: null,
+ description: null,
+ example: null,
+ highlightingColor: null,
+ icon: null,
+ nodes: [
+ {
+ name: 'concat',
+ type: 'function',
+ description:
+ 'Concatenates multiple text values together',
+ example: "concat('Hello', ' ', 'World')",
+ highlightingColor: 'blue',
+ icon: 'iconoir-text',
+ identifier: null,
+ order: null,
+ signature: {
+ parameters: [
+ {
+ type: 'string',
+ required: true,
+ },
+ ],
+ variadic: true,
+ minArgs: 1,
+ maxArgs: null,
+ },
+ },
+ {
+ name: 'upper',
+ type: 'function',
+ description: 'Converts text to uppercase',
+ example: "upper('hello world')",
+ highlightingColor: 'blue',
+ icon: 'iconoir-text',
+ identifier: null,
+ order: null,
+ signature: {
+ parameters: [
+ {
+ type: 'string',
+ required: true,
+ },
+ ],
+ variadic: false,
+ minArgs: 1,
+ maxArgs: 1,
+ },
+ },
+ {
+ name: 'lower',
+ type: 'function',
+ description: 'Converts text to lowercase',
+ example: "lower('HELLO WORLD')",
+ highlightingColor: 'blue',
+ icon: 'iconoir-text',
+ identifier: null,
+ order: null,
+ signature: {
+ parameters: [
+ {
+ type: 'string',
+ required: true,
+ },
+ ],
+ variadic: false,
+ minArgs: 1,
+ maxArgs: 1,
+ },
+ },
+ {
+ name: 'substring',
+ type: 'function',
+ description: 'Extracts a portion of text',
+ example: "substring('Hello World', 0, 5)",
+ highlightingColor: 'blue',
+ icon: 'iconoir-text',
+ identifier: null,
+ order: null,
+ signature: {
+ parameters: [
+ {
+ type: 'text',
+ required: true,
+ },
+ {
+ type: 'number',
+ required: true,
+ },
+ {
+ type: 'number',
+ required: false,
+ },
+ ],
+ variadic: false,
+ minArgs: 2,
+ maxArgs: 3,
+ },
+ },
+ ],
+ },
+ {
+ name: 'Math',
+ type: 'function',
+ identifier: null,
+ order: null,
+ signature: null,
+ description: null,
+ example: null,
+ highlightingColor: null,
+ icon: null,
+ nodes: [
+ {
+ name: 'round',
+ type: 'function',
+ description:
+ 'Rounds a number to specified decimal places',
+ highlightingColor: 'green',
+ example: 'round(3.14159, 2)',
+ icon: 'iconoir-calculator',
+ identifier: null,
+ order: null,
+ signature: {
+ parameters: [
+ {
+ type: 'number',
+ required: true,
+ },
+ {
+ type: 'number',
+ required: false,
+ },
+ ],
+ variadic: false,
+ minArgs: 1,
+ maxArgs: 2,
+ },
+ },
+ {
+ name: 'sum',
+ type: 'function',
+ description: 'Calculates the sum of multiple numbers',
+ example: 'sum(1, 2, 3, 4)',
+ highlightingColor: 'green',
+ icon: 'iconoir-calculator',
+ identifier: null,
+ order: null,
+ signature: {
+ parameters: [
+ {
+ type: 'number',
+ required: true,
+ },
+ {
+ type: 'number',
+ required: false,
+ },
+ ],
+ variadic: true,
+ minArgs: 1,
+ maxArgs: null,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'Operators',
+ type: 'operator',
+ nodes: [
+ {
+ name: 'Arithmetic',
+ example: null,
+ signature: null,
+ highlightingColor: null,
+ icon: null,
+ identifier: null,
+ order: null,
+ description: null,
+ nodes: [
+ {
+ name: 'add',
+ type: 'operator',
+ description: 'Adds two numbers',
+ example: '5 + 3',
+ highlightingColor: 'green',
+ icon: 'iconoir-calculator',
+ identifier: null,
+ order: null,
+ signature: {
+ operator: '+',
+ parameters: [
+ {
+ type: 'number',
+ required: true,
+ },
+ {
+ type: 'number',
+ required: true,
+ },
+ ],
+ variadic: false,
+ minArgs: 2,
+ maxArgs: 2,
+ },
+ },
+ {
+ name: 'multiply',
+ type: 'operator',
+ description: 'Multiplies two numbers',
+ example: '5 * 3',
+ highlightingColor: 'green',
+ icon: 'iconoir-calculator',
+ signature: {
+ operator: '*',
+ parameters: [
+ {
+ type: 'number',
+ required: true,
+ },
+ {
+ type: 'number',
+ required: true,
+ },
+ ],
+ variadic: false,
+ minArgs: 2,
+ maxArgs: 2,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'Other',
+ type: 'function',
+ identifier: null,
+ order: null,
+ signature: null,
+ description: null,
+ example: null,
+ highlightingColor: null,
+ icon: null,
+ empty: true,
+ emptyText: 'No data sources available',
+ },
+]
+
+export const Template = (args, { argTypes }) => ({
+ components: { FormulaInputField },
+ props: Object.keys(argTypes),
+ data() {
+ return {
+ formulaValue: 'concat("pwet")',
+ currentMode: args.mode,
+ mockNodesHierarchy,
+ }
+ },
+ template: `
+
+
+
+
+
+
Emitted Formula:
+
+ {{ formulaValue || '(empty)' }}
+
+
+
+ `,
+ methods: {
+ onInput(value) {
+ this.formulaValue = value
+ action('input')(value)
+ },
+ onModeChanged(value) {
+ this.currentMode = value
+ action('update:mode')(value)
+ },
+ },
+})
+
+export const designConfig = {
+ type: 'figma',
+ url: 'https://www.figma.com/design/pARSkP8ldSqMVxV1t2gYYT/Application-builder?node-id=1314-35740&m=dev',
+}
+
+
+
+ {Template.bind({})}
+
+
+
+## Example
+
+```javascript
+
+```
+
+## Features
+
+- **Syntax Highlighting**: Functions and operators are highlighted with colors
+- **Real-time Validation**: Shows errors for invalid formulas or incorrect function arguments
+- **Auto-completion**: Suggests functions and operators as you type
+- **Data Explorer**: Browse available data sources and fields
+- **Advanced/Simple Mode**: Toggle between advanced formula editor and simple input
+- **Function Documentation**: Shows function signatures and examples
+- **Flexible Context Position**: Position the context menu in different locations relative to the input field
+
+## Context Positioning
+
+The `context-position` prop allows you to control where the formula context menu appears, ensuring it never covers the input field:
+
+- **`bottom`** (default): Below the input field
+- **`left`**: To the left side of the input field
+- **`right`**: To the right side of the input field
+
+The context menu is automatically positioned to avoid covering the input field, maintaining optimal usability.
+
+## Props
+
+ {' '}
diff --git a/web-frontend/stories/Tabs.stories.mdx b/web-frontend/stories/Tabs.stories.mdx
index 244b77364e..1ee8e8a0b8 100644
--- a/web-frontend/stories/Tabs.stories.mdx
+++ b/web-frontend/stories/Tabs.stories.mdx
@@ -41,7 +41,28 @@ import Tab from '@baserow/modules/core/components/Tab'
},
defaultValue: false,
},
- noPadding: {
+ contentNoXPadding: {
+ control: {
+ type: 'boolean',
+ options: [true, false],
+ },
+ defaultValue: false,
+ },
+ contentNoPadding: {
+ control: {
+ type: 'boolean',
+ options: [true, false],
+ },
+ defaultValue: false,
+ },
+ headerNoPadding: {
+ control: {
+ type: 'boolean',
+ options: [true, false],
+ },
+ defaultValue: false,
+ },
+ large: {
control: {
type: 'boolean',
options: [true, false],