diff --git a/src/components/workflows/assets/Workflows.css b/src/components/workflows/assets/Workflows.css index e68d2b9..b3fb045 100644 --- a/src/components/workflows/assets/Workflows.css +++ b/src/components/workflows/assets/Workflows.css @@ -1,5 +1,6 @@ .wkf-toolbox-item { cursor: grab; + color: var(--color-neutral); } .wkf-toolbox-item:active { @@ -8,7 +9,7 @@ .wkf-canvas { position: relative; - min-height: 600px; + min-height: 230px; background-image: linear-gradient(rgba(0,0,0,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(0,0,0,.1) 1px, transparent 1px); @@ -20,6 +21,8 @@ cursor: move; border: 2px solid transparent; transition: all 0.2s; + height: 64px; + color: var(--color-neutral); } .wkf-workflow-component:hover { diff --git a/src/components/workflows/assets/Workflows.js b/src/components/workflows/assets/Workflows.js index 6ed9d06..063c1df 100644 --- a/src/components/workflows/assets/Workflows.js +++ b/src/components/workflows/assets/Workflows.js @@ -206,6 +206,20 @@ function bindWorkflowDesigner(elementId) { designer.connectionStart = null; } } + + else { + const component = event.target.closest('.wkf-workflow-component'); + componentId = component ? component.dataset.componentId : null + htmx.ajax('POST', '/workflows/select-connection', { + target: `#c_${elementId}`, + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + swap: "innerHTML", + values: { + _id: elementId, + component_id: componentId + } + }); + } }); // Canvas drag over event (for dropping new components) @@ -270,9 +284,9 @@ function bindWorkflowDesigner(elementId) { if (!fromComp || !toComp) return; // Get current positions const fromX = parseInt(fromComp.style.left) + 128; // component width + output point - const fromY = parseInt(fromComp.style.top) + 40; // component height / 2 + const fromY = parseInt(fromComp.style.top) + 32; // component height / 2 const toX = parseInt(toComp.style.left); - const toY = parseInt(toComp.style.top) + 40; + const toY = parseInt(toComp.style.top) + 32; // Create curved path const midX = (fromX + toX) / 2; const path = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`; diff --git a/src/components/workflows/components/WorkflowDesigner.py b/src/components/workflows/components/WorkflowDesigner.py index 199f96d..b84ca3f 100644 --- a/src/components/workflows/components/WorkflowDesigner.py +++ b/src/components/workflows/components/WorkflowDesigner.py @@ -4,8 +4,8 @@ from fasthtml.xtend import Script from components.BaseComponent import BaseComponent from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID -from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowsDesignerState, WorkflowComponent, \ - Connection +from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \ + Connection, WorkflowsDesignerDbManager from components_helpers import apply_boundaries, mk_tooltip from core.utils import get_unique_id @@ -36,12 +36,15 @@ class WorkflowDesigner(BaseComponent): def __init__(self, session, _id=None, settings_manager=None, + key: str = None, designer_settings: WorkflowsDesignerSettings = None, boundaries: dict = None): super().__init__(session, _id) self._settings_manager = settings_manager + self._key = key self._designer_settings = designer_settings - self._state = WorkflowsDesignerState() + self._db = WorkflowsDesignerDbManager(session, settings_manager) + self._state = self._db.load_state(key) self._boundaries = boundaries def set_boundaries(self, boundaries: dict): @@ -66,12 +69,14 @@ class WorkflowDesigner(BaseComponent): ) self._state.components[component_id] = component + self._db.save_state(self._key, self._state) # update db return self.refresh() def move_component(self, component_id, x, y): if component_id in self._state.components: self._state.components[component_id].x = int(x) self._state.components[component_id].y = int(y) + self._db.save_state(self._key, self._state) # update db return self.refresh() @@ -83,6 +88,8 @@ class WorkflowDesigner(BaseComponent): # Remove related connections self._state.connections = [connection for connection in self._state.connections if connection.from_id != component_id and connection.to_id != component_id] + # update db + self._db.save_state(self._key, self._state) return self.refresh() @@ -95,6 +102,10 @@ class WorkflowDesigner(BaseComponent): connection_id = f"conn_{len(self._state.connections) + 1}" connection = Connection(id=connection_id, from_id=from_id, to_id=to_id) self._state.connections.append(connection) + + # update db + self._db.save_state(self._key, self._state) + return self.refresh() def __ft__(self): @@ -102,6 +113,7 @@ class WorkflowDesigner(BaseComponent): H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"), P("Drag components from the toolbox to the canvas to create your workflow.", cls="text-sm mb-6"), self._mk_designer(), + self._mk_properties(), Script(f"bindWorkflowDesigner('{self._id}');"), **apply_boundaries(self._boundaries), id=f"{self._id}", @@ -120,9 +132,9 @@ class WorkflowDesigner(BaseComponent): return Div( mk_tooltip( Div( - Span(info["icon"], cls="text-2xl mb-2"), - H4(info["title"], cls="font-semibold text-sm"), - cls=f"p-3 rounded-lg border-2 {info['color']} text-center" + Span(info["icon"], cls="mb-2"), + H4(info["title"], cls="font-semibold text-xs"), + cls=f"p-2 rounded-lg border-2 {info['color']} flex text-center" ), tooltip=info["description"]), cls="wkf-toolbox-item p-2", @@ -142,9 +154,8 @@ class WorkflowDesigner(BaseComponent): # Component content Div( Span(info["icon"], cls="text-xl mb-1"), - H4(component.title, cls="font-semibold text-sm"), - P(info["description"], cls="text-xs text-gray-600"), - cls=f"p-3 rounded-lg border-2 {info['color']} bg-white shadow-lg" + H4(component.title, cls="font-semibold text-xs"), + cls=f"p-3 rounded-lg border-2 {info['color']} bg-white shadow-lg flex items-center" ), # Output connection point @@ -167,9 +178,9 @@ class WorkflowDesigner(BaseComponent): # Calculate connection points (approximate) x1 = from_comp.x + 128 # component width + output point - y1 = from_comp.y + 40 # component height / 2 + y1 = from_comp.y + 32 # component height / 2 x2 = to_comp.x - y2 = to_comp.y + 40 + y2 = to_comp.y + 32 # Create curved path mid_x = (x1 + x2) / 2 @@ -195,26 +206,25 @@ class WorkflowDesigner(BaseComponent): # Render components *[self._mk_workflow_component(comp) for comp in self._state.components.values()], - cls="wkf-canvas bg-white rounded-lg border relative", + cls="wkf-canvas bg-base-100 rounded-lg border relative", ), def _mk_canvas(self, oob=False): return Div( self._mk_elements(), - cls="flex-1 ", + cls="flex-1", id=f"c_{self._id}", hx_swap_oob='true' if oob else None, ), def _mk_toolbox(self): return Div( - H2("Toolbox", cls="text-lg font-semibold mb-4 text-gray-700"), Div( *[self._mk_toolbox_item(comp_type, info) for comp_type, info in COMPONENT_TYPES.items()], - cls="space-y-3" + cls="space-y-1" ), - cls="w-32 p-4 bg-gray-50 rounded-lg border" + cls="w-32 p-2 bg-base-100 rounded-lg border" ) def _mk_designer(self): @@ -224,4 +234,24 @@ class WorkflowDesigner(BaseComponent): cls="flex gap-4", id=f"d_{self._id}", + style=f"max-height:{self._state.designer_height}px;" ) + + def _mk_properties(self): + return Div( + Div( + H4("Properties"), + Div( + P("Workflow name:", cls="text-sm"), + Div(self._designer_settings.workflow_name, cls="text-base"), + cls="flex flex-col gap-2" + ), + ), + + cls="p-2 bg-base-100 rounded-lg border mt-4", + style=f"max-height:{self._get_properties_height()}px;", + id=f"p_{self._id}", + ) + + def _get_properties_height(self): + return self._boundaries["height"] - self._state.designer_height diff --git a/src/components/workflows/components/Workflows.py b/src/components/workflows/components/Workflows.py index 806ccd0..57831e9 100644 --- a/src/components/workflows/components/Workflows.py +++ b/src/components/workflows/components/Workflows.py @@ -101,6 +101,7 @@ class Workflows(BaseComponentSingleton): WorkflowDesigner.create_component_id(self._session, workflow_name), WorkflowDesigner, settings_manager=self._settings_manager, + key=workflow_name, designer_settings=WorkflowsDesignerSettings(workflow_name=workflow_name), boundaries=tab_boundaries) diff --git a/src/components/workflows/constants.py b/src/components/workflows/constants.py index 7d900e4..3986a95 100644 --- a/src/components/workflows/constants.py +++ b/src/components/workflows/constants.py @@ -1,6 +1,10 @@ WORKFLOWS_INSTANCE_ID = "__Workflows__" WORKFLOW_DESIGNER_INSTANCE_ID = "__WorkflowDesigner__" -WORKFLOWS_SETTINGS_ENTRY = "Workflows" +WORKFLOWS_DB_ENTRY = "Workflows" +WORKFLOW_DESIGNER_DB_ENTRY = "WorkflowDesigner" +WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY = "Settings" +WORKFLOW_DESIGNER_DB_STATE_ENTRY = "State" + ROUTE_ROOT = "/workflows" @@ -8,6 +12,7 @@ class Routes: AddWorkflow = "/add-workflow" SelectWorkflow = "/select-workflow" ShowWorkflow = "/show-workflow" + SelectComponent = "/select-component" AddComponent = "/add-component" MoveComponent = "/move-component" DeleteComponent = "/delete-component" diff --git a/src/components/workflows/db_management.py b/src/components/workflows/db_management.py index 750104f..fa6d5c7 100644 --- a/src/components/workflows/db_management.py +++ b/src/components/workflows/db_management.py @@ -1,11 +1,13 @@ import logging from dataclasses import dataclass, field -from components.workflows.constants import WORKFLOWS_SETTINGS_ENTRY +from components.workflows.constants import WORKFLOWS_DB_ENTRY, WORKFLOW_DESIGNER_DB_ENTRY, \ + WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY, WORKFLOW_DESIGNER_DB_STATE_ENTRY from core.settings_management import SettingsManager logger = logging.getLogger("WorkflowsSettings") + # Data structures @dataclass class WorkflowComponent: @@ -23,15 +25,19 @@ class Connection: from_id: str to_id: str + @dataclass class WorkflowsDesignerSettings: - workflow_name: str + workflow_name: str = "No Name" + @dataclass class WorkflowsDesignerState: components: dict[str, WorkflowComponent] = field(default_factory=dict) connections: list[Connection] = field(default_factory=list) component_counter = 0 + designer_height = 230 + @dataclass class WorkflowsSettings: @@ -54,7 +60,7 @@ class WorkflowsDbManager: raise ValueError(f"Workflow '{workflow_name}' already exists.") settings.workflows.append(workflow_name) - self.settings_manager.save(self.session, WORKFLOWS_SETTINGS_ENTRY, settings) + self.settings_manager.save(self.session, WORKFLOWS_DB_ENTRY, settings) return True def get_workflow(self, workflow_name: str): @@ -92,7 +98,7 @@ class WorkflowsDbManager: raise ValueError(f"workflow '{workflow_name}' does not exist.") settings.workflows.remove(workflow_name) - self.settings_manager.save(self.session, WORKFLOWS_SETTINGS_ENTRY, settings) + self.settings_manager.save(self.session, WORKFLOWS_DB_ENTRY, settings) return True def exists_workflow(self, workflow_name): @@ -115,11 +121,54 @@ class WorkflowsDbManager: """ settings = self._get_settings() settings.selected_workflow = workflow_name - self.settings_manager.save(self.session, WORKFLOWS_SETTINGS_ENTRY, settings) + self.settings_manager.save(self.session, WORKFLOWS_DB_ENTRY, settings) def get_selected_workflow(self): settings = self._get_settings() return settings.selected_workflow def _get_settings(self): - return self.settings_manager.load(self.session, WORKFLOWS_SETTINGS_ENTRY, default=WorkflowsSettings()) + return self.settings_manager.load(self.session, WORKFLOWS_DB_ENTRY, default=WorkflowsSettings()) + + +class WorkflowsDesignerDbManager: + def __init__(self, session: dict, settings_manager: SettingsManager): + self._session = session + self._settings_manager = settings_manager + + @staticmethod + def _get_db_entry(key): + return f"{WORKFLOW_DESIGNER_DB_ENTRY}_{key}" + + def save_settings(self, key: str, settings: WorkflowsDesignerSettings): + self._settings_manager.put(self._session, + self._get_db_entry(key), + WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY, + settings) + + def save_state(self, key: str, state: WorkflowsDesignerState): + self._settings_manager.put(self._session, + self._get_db_entry(key), + WORKFLOW_DESIGNER_DB_STATE_ENTRY, + state) + + def save_all(self, key: str, settings: WorkflowsDesignerSettings = None, state: WorkflowsDesignerState = None): + items = {} + if settings is not None: + items[WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY] = settings + if state is not None: + items[WORKFLOW_DESIGNER_DB_STATE_ENTRY] = state + + self._settings_manager.put_many(self._session, self._get_db_entry(key), items) + + def load_settings(self, key) -> WorkflowsDesignerSettings: + return self._settings_manager.get(self._session, + self._get_db_entry(key), + WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY, + default=WorkflowsDesignerSettings()) + + def load_state(self, key) -> WorkflowsDesignerState: + return self._settings_manager.get(self._session, + self._get_db_entry(key), + WORKFLOW_DESIGNER_DB_STATE_ENTRY, + default=WorkflowsDesignerState()) diff --git a/tests/tests_workflows_db_manager.py b/tests/tests_workflows_db_manager.py index 965ad19..3dbae36 100644 --- a/tests/tests_workflows_db_manager.py +++ b/tests/tests_workflows_db_manager.py @@ -2,7 +2,7 @@ from unittest.mock import patch import pytest -from components.workflows.constants import WORKFLOWS_SETTINGS_ENTRY +from components.workflows.constants import WORKFLOWS_DB_ENTRY from components.workflows.db_management import WorkflowsDbManager, WorkflowsSettings from core.settings_management import SettingsManager, MemoryDbEngine @@ -146,7 +146,7 @@ def test_get_settings_default(workflows_db_manager, session, settings_manager): # Test _get_settings returns default settings when none exist with patch.object(settings_manager, 'load', return_value=WorkflowsSettings()) as mock_load: settings = workflows_db_manager._get_settings() - mock_load.assert_called_once_with(session, WORKFLOWS_SETTINGS_ENTRY, default=WorkflowsSettings()) + mock_load.assert_called_once_with(session, WORKFLOWS_DB_ENTRY, default=WorkflowsSettings()) assert isinstance(settings, WorkflowsSettings) assert settings.workflows == [] assert settings.selected_workflow is None