I can save workflow state + uptated css + started Properties Panek

This commit is contained in:
2025-07-02 23:00:32 +02:00
parent d90613119f
commit 797273e603
7 changed files with 130 additions and 28 deletions

View File

@@ -1,5 +1,6 @@
.wkf-toolbox-item { .wkf-toolbox-item {
cursor: grab; cursor: grab;
color: var(--color-neutral);
} }
.wkf-toolbox-item:active { .wkf-toolbox-item:active {
@@ -8,7 +9,7 @@
.wkf-canvas { .wkf-canvas {
position: relative; position: relative;
min-height: 600px; min-height: 230px;
background-image: background-image:
linear-gradient(rgba(0,0,0,.1) 1px, transparent 1px), linear-gradient(rgba(0,0,0,.1) 1px, transparent 1px),
linear-gradient(90deg, 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; cursor: move;
border: 2px solid transparent; border: 2px solid transparent;
transition: all 0.2s; transition: all 0.2s;
height: 64px;
color: var(--color-neutral);
} }
.wkf-workflow-component:hover { .wkf-workflow-component:hover {

View File

@@ -206,6 +206,20 @@ function bindWorkflowDesigner(elementId) {
designer.connectionStart = null; 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) // Canvas drag over event (for dropping new components)
@@ -270,9 +284,9 @@ function bindWorkflowDesigner(elementId) {
if (!fromComp || !toComp) return; if (!fromComp || !toComp) return;
// Get current positions // Get current positions
const fromX = parseInt(fromComp.style.left) + 128; // component width + output point 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 toX = parseInt(toComp.style.left);
const toY = parseInt(toComp.style.top) + 40; const toY = parseInt(toComp.style.top) + 32;
// Create curved path // Create curved path
const midX = (fromX + toX) / 2; const midX = (fromX + toX) / 2;
const path = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`; const path = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`;

View File

@@ -4,8 +4,8 @@ from fasthtml.xtend import Script
from components.BaseComponent import BaseComponent from components.BaseComponent import BaseComponent
from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowsDesignerState, WorkflowComponent, \ from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \
Connection Connection, WorkflowsDesignerDbManager
from components_helpers import apply_boundaries, mk_tooltip from components_helpers import apply_boundaries, mk_tooltip
from core.utils import get_unique_id from core.utils import get_unique_id
@@ -36,12 +36,15 @@ class WorkflowDesigner(BaseComponent):
def __init__(self, session, def __init__(self, session,
_id=None, _id=None,
settings_manager=None, settings_manager=None,
key: str = None,
designer_settings: WorkflowsDesignerSettings = None, designer_settings: WorkflowsDesignerSettings = None,
boundaries: dict = None): boundaries: dict = None):
super().__init__(session, _id) super().__init__(session, _id)
self._settings_manager = settings_manager self._settings_manager = settings_manager
self._key = key
self._designer_settings = designer_settings self._designer_settings = designer_settings
self._state = WorkflowsDesignerState() self._db = WorkflowsDesignerDbManager(session, settings_manager)
self._state = self._db.load_state(key)
self._boundaries = boundaries self._boundaries = boundaries
def set_boundaries(self, boundaries: dict): def set_boundaries(self, boundaries: dict):
@@ -66,12 +69,14 @@ class WorkflowDesigner(BaseComponent):
) )
self._state.components[component_id] = component self._state.components[component_id] = component
self._db.save_state(self._key, self._state) # update db
return self.refresh() return self.refresh()
def move_component(self, component_id, x, y): def move_component(self, component_id, x, y):
if component_id in self._state.components: if component_id in self._state.components:
self._state.components[component_id].x = int(x) self._state.components[component_id].x = int(x)
self._state.components[component_id].y = int(y) self._state.components[component_id].y = int(y)
self._db.save_state(self._key, self._state) # update db
return self.refresh() return self.refresh()
@@ -83,6 +88,8 @@ class WorkflowDesigner(BaseComponent):
# Remove related connections # Remove related connections
self._state.connections = [connection for connection in self._state.connections self._state.connections = [connection for connection in self._state.connections
if connection.from_id != component_id and connection.to_id != component_id] 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() return self.refresh()
@@ -95,6 +102,10 @@ class WorkflowDesigner(BaseComponent):
connection_id = f"conn_{len(self._state.connections) + 1}" connection_id = f"conn_{len(self._state.connections) + 1}"
connection = Connection(id=connection_id, from_id=from_id, to_id=to_id) connection = Connection(id=connection_id, from_id=from_id, to_id=to_id)
self._state.connections.append(connection) self._state.connections.append(connection)
# update db
self._db.save_state(self._key, self._state)
return self.refresh() return self.refresh()
def __ft__(self): def __ft__(self):
@@ -102,6 +113,7 @@ class WorkflowDesigner(BaseComponent):
H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"), 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"), P("Drag components from the toolbox to the canvas to create your workflow.", cls="text-sm mb-6"),
self._mk_designer(), self._mk_designer(),
self._mk_properties(),
Script(f"bindWorkflowDesigner('{self._id}');"), Script(f"bindWorkflowDesigner('{self._id}');"),
**apply_boundaries(self._boundaries), **apply_boundaries(self._boundaries),
id=f"{self._id}", id=f"{self._id}",
@@ -120,9 +132,9 @@ class WorkflowDesigner(BaseComponent):
return Div( return Div(
mk_tooltip( mk_tooltip(
Div( Div(
Span(info["icon"], cls="text-2xl mb-2"), Span(info["icon"], cls="mb-2"),
H4(info["title"], cls="font-semibold text-sm"), H4(info["title"], cls="font-semibold text-xs"),
cls=f"p-3 rounded-lg border-2 {info['color']} text-center" cls=f"p-2 rounded-lg border-2 {info['color']} flex text-center"
), ),
tooltip=info["description"]), tooltip=info["description"]),
cls="wkf-toolbox-item p-2", cls="wkf-toolbox-item p-2",
@@ -142,9 +154,8 @@ class WorkflowDesigner(BaseComponent):
# Component content # Component content
Div( Div(
Span(info["icon"], cls="text-xl mb-1"), Span(info["icon"], cls="text-xl mb-1"),
H4(component.title, cls="font-semibold text-sm"), H4(component.title, cls="font-semibold text-xs"),
P(info["description"], cls="text-xs text-gray-600"), cls=f"p-3 rounded-lg border-2 {info['color']} bg-white shadow-lg flex items-center"
cls=f"p-3 rounded-lg border-2 {info['color']} bg-white shadow-lg"
), ),
# Output connection point # Output connection point
@@ -167,9 +178,9 @@ class WorkflowDesigner(BaseComponent):
# Calculate connection points (approximate) # Calculate connection points (approximate)
x1 = from_comp.x + 128 # component width + output point 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 x2 = to_comp.x
y2 = to_comp.y + 40 y2 = to_comp.y + 32
# Create curved path # Create curved path
mid_x = (x1 + x2) / 2 mid_x = (x1 + x2) / 2
@@ -195,26 +206,25 @@ class WorkflowDesigner(BaseComponent):
# Render components # Render components
*[self._mk_workflow_component(comp) for comp in self._state.components.values()], *[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): def _mk_canvas(self, oob=False):
return Div( return Div(
self._mk_elements(), self._mk_elements(),
cls="flex-1 ", cls="flex-1",
id=f"c_{self._id}", id=f"c_{self._id}",
hx_swap_oob='true' if oob else None, hx_swap_oob='true' if oob else None,
), ),
def _mk_toolbox(self): def _mk_toolbox(self):
return Div( return Div(
H2("Toolbox", cls="text-lg font-semibold mb-4 text-gray-700"),
Div( Div(
*[self._mk_toolbox_item(comp_type, info) *[self._mk_toolbox_item(comp_type, info)
for comp_type, info in COMPONENT_TYPES.items()], 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): def _mk_designer(self):
@@ -224,4 +234,24 @@ class WorkflowDesigner(BaseComponent):
cls="flex gap-4", cls="flex gap-4",
id=f"d_{self._id}", 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

View File

@@ -101,6 +101,7 @@ class Workflows(BaseComponentSingleton):
WorkflowDesigner.create_component_id(self._session, workflow_name), WorkflowDesigner.create_component_id(self._session, workflow_name),
WorkflowDesigner, WorkflowDesigner,
settings_manager=self._settings_manager, settings_manager=self._settings_manager,
key=workflow_name,
designer_settings=WorkflowsDesignerSettings(workflow_name=workflow_name), designer_settings=WorkflowsDesignerSettings(workflow_name=workflow_name),
boundaries=tab_boundaries) boundaries=tab_boundaries)

View File

@@ -1,6 +1,10 @@
WORKFLOWS_INSTANCE_ID = "__Workflows__" WORKFLOWS_INSTANCE_ID = "__Workflows__"
WORKFLOW_DESIGNER_INSTANCE_ID = "__WorkflowDesigner__" 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" ROUTE_ROOT = "/workflows"
@@ -8,6 +12,7 @@ class Routes:
AddWorkflow = "/add-workflow" AddWorkflow = "/add-workflow"
SelectWorkflow = "/select-workflow" SelectWorkflow = "/select-workflow"
ShowWorkflow = "/show-workflow" ShowWorkflow = "/show-workflow"
SelectComponent = "/select-component"
AddComponent = "/add-component" AddComponent = "/add-component"
MoveComponent = "/move-component" MoveComponent = "/move-component"
DeleteComponent = "/delete-component" DeleteComponent = "/delete-component"

View File

@@ -1,11 +1,13 @@
import logging import logging
from dataclasses import dataclass, field 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 from core.settings_management import SettingsManager
logger = logging.getLogger("WorkflowsSettings") logger = logging.getLogger("WorkflowsSettings")
# Data structures # Data structures
@dataclass @dataclass
class WorkflowComponent: class WorkflowComponent:
@@ -23,15 +25,19 @@ class Connection:
from_id: str from_id: str
to_id: str to_id: str
@dataclass @dataclass
class WorkflowsDesignerSettings: class WorkflowsDesignerSettings:
workflow_name: str workflow_name: str = "No Name"
@dataclass @dataclass
class WorkflowsDesignerState: class WorkflowsDesignerState:
components: dict[str, WorkflowComponent] = field(default_factory=dict) components: dict[str, WorkflowComponent] = field(default_factory=dict)
connections: list[Connection] = field(default_factory=list) connections: list[Connection] = field(default_factory=list)
component_counter = 0 component_counter = 0
designer_height = 230
@dataclass @dataclass
class WorkflowsSettings: class WorkflowsSettings:
@@ -54,7 +60,7 @@ class WorkflowsDbManager:
raise ValueError(f"Workflow '{workflow_name}' already exists.") raise ValueError(f"Workflow '{workflow_name}' already exists.")
settings.workflows.append(workflow_name) 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 return True
def get_workflow(self, workflow_name: str): def get_workflow(self, workflow_name: str):
@@ -92,7 +98,7 @@ class WorkflowsDbManager:
raise ValueError(f"workflow '{workflow_name}' does not exist.") raise ValueError(f"workflow '{workflow_name}' does not exist.")
settings.workflows.remove(workflow_name) 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 return True
def exists_workflow(self, workflow_name): def exists_workflow(self, workflow_name):
@@ -115,11 +121,54 @@ class WorkflowsDbManager:
""" """
settings = self._get_settings() settings = self._get_settings()
settings.selected_workflow = workflow_name 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): def get_selected_workflow(self):
settings = self._get_settings() settings = self._get_settings()
return settings.selected_workflow return settings.selected_workflow
def _get_settings(self): 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())

View File

@@ -2,7 +2,7 @@ from unittest.mock import patch
import pytest 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 components.workflows.db_management import WorkflowsDbManager, WorkflowsSettings
from core.settings_management import SettingsManager, MemoryDbEngine 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 # Test _get_settings returns default settings when none exist
with patch.object(settings_manager, 'load', return_value=WorkflowsSettings()) as mock_load: with patch.object(settings_manager, 'load', return_value=WorkflowsSettings()) as mock_load:
settings = workflows_db_manager._get_settings() 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 isinstance(settings, WorkflowsSettings)
assert settings.workflows == [] assert settings.workflows == []
assert settings.selected_workflow is None assert settings.selected_workflow is None