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" + />
@@ -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 @@ - - - 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 @@ + + + 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 @@ @@ -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)" >