diff --git a/src/components/admin/components/AdminForm.py b/src/components/admin/components/AdminForm.py index b7a96d8..eda596f 100644 --- a/src/components/admin/components/AdminForm.py +++ b/src/components/admin/components/AdminForm.py @@ -4,7 +4,7 @@ from typing import Any from fasthtml.components import * from components.BaseComponent import BaseComponent -from components_helpers import set_boundaries, mk_dialog_buttons, safe_get_dialog_buttons_parameters +from components_helpers import apply_boundaries, mk_dialog_buttons, safe_get_dialog_buttons_parameters from core.utils import get_unique_id @@ -108,7 +108,7 @@ class AdminForm(BaseComponent): for item in self.form_fields ], mk_dialog_buttons(**safe_get_dialog_buttons_parameters(self._hooks)), - **set_boundaries(self._boundaries), + **apply_boundaries(self._boundaries), cls="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4" ) ) diff --git a/src/components/admin/components/ImportHolidays.py b/src/components/admin/components/ImportHolidays.py index 3b99151..956e3fa 100644 --- a/src/components/admin/components/ImportHolidays.py +++ b/src/components/admin/components/ImportHolidays.py @@ -9,7 +9,7 @@ from components.datagrid_new.components.DataGrid import DataGrid from components.datagrid_new.settings import DataGridSettings from components.hoildays.helpers.nibelisparser import NibelisParser from components.repositories.constants import USERS_REPOSITORY_NAME, HOLIDAYS_TABLE_NAME -from components_helpers import mk_dialog_buttons, set_boundaries +from components_helpers import mk_dialog_buttons, apply_boundaries from core.instance_manager import InstanceManager @@ -50,7 +50,7 @@ class ImportHolidays(BaseComponent): mk_dialog_buttons(ok_title="Import", cls="mt-2", on_ok=self.commands.import_holidays()), id=self._id, cls="m-2", - **set_boundaries(self._boundaries, other=26), + **apply_boundaries(self._boundaries, other=26), ) @staticmethod diff --git a/src/components/debugger/components/JsonViewer.py b/src/components/debugger/components/JsonViewer.py index 4ef8611..f59beb2 100644 --- a/src/components/debugger/components/JsonViewer.py +++ b/src/components/debugger/components/JsonViewer.py @@ -9,7 +9,7 @@ from components.datagrid_new.components.DataGrid import DataGrid from components.debugger.assets.icons import icon_expanded, icon_collapsed, icon_class from components.debugger.commands import JsonViewerCommands from components.debugger.constants import INDENT_SIZE, MAX_TEXT_LENGTH, NODE_OBJECT, NODES_KEYS_TO_NOT_EXPAND -from components_helpers import set_boundaries +from components_helpers import apply_boundaries from core.serializer import TAG_OBJECT from core.utils import get_unique_id @@ -299,7 +299,7 @@ class JsonViewer(BaseComponent): style="margin-left: 0px;"), cls="mmt-jsonviewer", id=f"{self._id}", - **set_boundaries(self._boundaries), + **apply_boundaries(self._boundaries), ) def __eq__(self, other): diff --git a/src/components/workflows/WorkflowsApp.py b/src/components/workflows/WorkflowsApp.py index 8260354..1a63b6e 100644 --- a/src/components/workflows/WorkflowsApp.py +++ b/src/components/workflows/WorkflowsApp.py @@ -32,3 +32,33 @@ def post(session, _id: str, name: str, tab_boundaries: str): f"Entering {Routes.AddWorkflow} with args {debug_session(session)}, {_id=}, {name=}, {tab_boundaries=}") instance = InstanceManager.get(session, _id) return instance.show_workflow(name, json.loads(tab_boundaries)) + + +@rt(Routes.AddComponent) +def post(session, _id: str, component_type: str, x: int, y: int): + logger.debug( + f"Entering {Routes.AddComponent} with args {debug_session(session)}, {_id=}, {component_type=}, {x=}, {y=}") + instance = InstanceManager.get(session, _id) + return instance.add_component(component_type, x, y) + +@rt(Routes.MoveComponent) +def post(session, _id: str, component_id: str, x: int, y: int): + logger.debug( + f"Entering {Routes.MoveComponent} with args {debug_session(session)}, {_id=}, {component_id=}, {x=}, {y=}") + instance = InstanceManager.get(session, _id) + return instance.move_component(component_id, x, y) + + +@rt(Routes.DeleteComponent) +def post(session, _id: str, component_id: str): + logger.debug( + f"Entering {Routes.DeleteComponent} with args {debug_session(session)}, {_id=}, {component_id=}") + instance = InstanceManager.get(session, _id) + return instance.delete_component(component_id) + +@rt(Routes.AddConnection) +def post(session, _id: str, from_id: str, to_id: str): + logger.debug( + f"Entering {Routes.AddConnection} with args {debug_session(session)}, {_id=}, {from_id=}, {to_id=}") + instance = InstanceManager.get(session, _id) + return instance.add_connection(from_id, to_id) \ No newline at end of file diff --git a/src/components/workflows/assets/Workflows.css b/src/components/workflows/assets/Workflows.css new file mode 100644 index 0000000..718e2fb --- /dev/null +++ b/src/components/workflows/assets/Workflows.css @@ -0,0 +1,67 @@ +.wkf-toolbox-item { + cursor: grab; +} + +.wkf-toolbox-item:active { + cursor: grabbing; +} + +.wkf-canvas { + position: relative; + min-height: 600px; + background-image: + linear-gradient(rgba(0,0,0,.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(0,0,0,.1) 1px, transparent 1px); + background-size: 20px 20px; +} + +.wkf-workflow-component { + position: absolute; + cursor: move; + border: 2px solid transparent; + transition: all 0.2s; +} + +.wkf-workflow-component:hover { + border-color: #3b82f6; + transform: scale(1.02); +} + +.wkf-workflow-component.selected { + border-color: #ef4444; + box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); +} + +.wkf-connection-line { + position: absolute; + pointer-events: none; + z-index: 1; +} + +.wkf-connection-point { + position: absolute; + width: 12px; + height: 12px; + background: #3b82f6; + border-radius: 50%; + cursor: crosshair; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +.wkf-output-point { + right: -6px; + top: 50%; + transform: translateY(-50%); +} + +.wkf-input-point { + left: -6px; + top: 50%; + transform: translateY(-50%); +} + +.wkf-connection-point:hover { + background: #ef4444; + transform: translateY(-50%) scale(1.2); +} \ No newline at end of file diff --git a/src/components/workflows/assets/Workflows.js b/src/components/workflows/assets/Workflows.js new file mode 100644 index 0000000..38310a8 --- /dev/null +++ b/src/components/workflows/assets/Workflows.js @@ -0,0 +1,226 @@ +function bindWorkflowDesigner(elementId) { + // Store state for this specific designer instance + const designer = { + draggedType: null, + draggedComponent: null, + selectedComponent: null, + connectionMode: false, + connectionStart: null + }; + + // Get the designer container and canvas + const designerContainer = document.getElementById(elementId); + //const canvas = designerContainer.querySelector('.wkf-canvas'); + const canvas = document.getElementById("c_" + elementId); + + // === Event delegation for components === + + // Use event delegation for all component-related events + designerContainer.addEventListener('dragstart', (event) => { + // Handle toolbox items + if (event.target.closest('.wkf-toolbox-item')) { + designer.draggedType = event.target.closest('.wkf-toolbox-item').dataset.type; + event.dataTransfer.effectAllowed = 'copy'; + } + + // Handle components + if (event.target.closest('.wkf-workflow-component')) { + designer.draggedComponent = event.target.closest('.wkf-workflow-component').dataset.componentId; + event.dataTransfer.effectAllowed = 'move'; + } + }); + + designerContainer.addEventListener('drag', (event) => { + if (!event.target.closest('.wkf-workflow-component')) return; + if (event.clientX === 0 && event.clientY === 0) return; // Ignore invalid drag events + + const component = event.target.closest('.wkf-workflow-component'); + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left - 64; + const y = event.clientY - rect.top - 40; + component.style.left = Math.max(0, x) + 'px'; + component.style.top = Math.max(0, y) + 'px'; + // Update connections in real-time + updateConnections(designerContainer); + }); + + designerContainer.addEventListener('dragend', (event) => { + if (!event.target.closest('.wkf-workflow-component')) return; + if (designer.draggedComponent) { + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left - 64; + const y = event.clientY - rect.top - 40; + htmx.ajax('POST', '/workflows/move-component', { + target: `#c_${elementId}`, + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + swap: "innerHTML", + values: { + _id: elementId, + component_id: designer.draggedComponent, + x: Math.max(0, x), + y: Math.max(0, y), + } + }); + designer.draggedComponent = null; + // Update connections after drag ends + updateConnections(designerContainer); + } + }); + + // Handle component clicks using event delegation + designerContainer.addEventListener('click', (event) => { + // Handle canvas clicks to reset connection mode and deselect components + if (event.target === canvas || event.target.classList.contains('wkf-canvas')) { + if (designer.connectionStart) { + designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => { + point.style.background = '#3b82f6'; + }); + designer.connectionStart = null; + } + // Deselect components + designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => { + comp.classList.remove('selected'); + }); + designer.selectedComponent = null; + return; + } + + // Handle component selection + const component = event.target.closest('.wkf-workflow-component'); + if (component) { + event.stopPropagation(); + // Remove previous selection + designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => { + comp.classList.remove('selected'); + }); + // Select current component + component.classList.add('selected'); + designer.selectedComponent = component.dataset.componentId; + } + + // Handle connection points + const connectionPoint = event.target.closest('.wkf-connection-point'); + if (connectionPoint) { + event.stopPropagation(); + const componentId = connectionPoint.dataset.componentId; + const pointType = connectionPoint.dataset.pointType; + + if (!designer.connectionStart) { + // Start connection from output point + if (pointType === 'output') { + designer.connectionStart = { + componentId: componentId, + pointType: pointType + }; + connectionPoint.style.background = '#ef4444'; + console.log('Connection started from:', componentId); + } + } else { + // Complete connection to input point + if (pointType === 'input' && componentId !== designer.connectionStart.componentId) { + htmx.ajax('POST', '/workflows/add-connection', { + target: `#c_${elementId}`, + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + swap: "innerHTML", + values: { + _id: elementId, + from_id: designer.connectionStart.componentId, + to_id: componentId, + } + }); + } + // Reset connection mode + designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => { + point.style.background = '#3b82f6'; + }); + designer.connectionStart = null; + } + } + }); + + // Canvas drag over event (for dropping new components) + canvas.addEventListener('dragover', (event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }); + + // Canvas drop event (for creating new components) + canvas.addEventListener('drop', (event) => { + event.preventDefault(); + if (designer.draggedType) { + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - rect.left - 64; // Center the component + const y = event.clientY - rect.top - 40; + htmx.ajax('POST', '/workflows/add-component', { + target: `#c_${elementId}`, + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + swap: "innerHTML", + values: { + _id: elementId, + component_type: designer.draggedType, + x: Math.max(0, x), + y: Math.max(0, y), + } + }); + + designer.draggedType = null; + } + }); + + // Delete selected component with Delete key + document.addEventListener('keydown', (event) => { + if (event.key === 'Delete' && designer.selectedComponent) { + htmx.ajax('POST', '/workflows/delete-component', { + target: `#c_${elementId}`, + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + swap: "innerHTML", + values: { + _id: elementId, + component_id: designer.selectedComponent, + } + }); + } + }); + + // Function to update all connection lines for this designer + function updateConnections(container) { + const connectionLines = container.querySelectorAll('.wkf-connection-line'); + connectionLines.forEach(svg => { + const connectionData = svg.dataset; + if (connectionData.fromId && connectionData.toId) { + updateConnectionLine(svg, connectionData.fromId, connectionData.toId, container); + } + }); + } + + // Function to update a single connection line + function updateConnectionLine(svg, fromId, toId, container) { + const fromComp = container.querySelector(`[data-component-id="${fromId}"]`); + const toComp = container.querySelector(`[data-component-id="${toId}"]`); + 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 toX = parseInt(toComp.style.left); + const toY = parseInt(toComp.style.top) + 40; + // Create curved path + const midX = (fromX + toX) / 2; + const path = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`; + // Update the path + const pathElement = svg.querySelector('path'); + if (pathElement) { + pathElement.setAttribute('d', path); + } + } + + // Return the designer object for possible external use + return designer; +} + +// Initialize all workflow designers on page load +document.addEventListener('DOMContentLoaded', () => { + // Find all workflow designer containers and bind them + document.querySelectorAll('[id^="wkf-designer-"]').forEach(designer => { + bindDesigner(designer.id); + }); +}); \ No newline at end of file diff --git a/src/components/workflows/components/WorkflowDesigner.py b/src/components/workflows/components/WorkflowDesigner.py index 54c4b49..199f96d 100644 --- a/src/components/workflows/components/WorkflowDesigner.py +++ b/src/components/workflows/components/WorkflowDesigner.py @@ -1,10 +1,36 @@ +from fastcore.basics import NotStr from fasthtml.components import * +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 +from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowsDesignerState, WorkflowComponent, \ + Connection +from components_helpers import apply_boundaries, mk_tooltip from core.utils import get_unique_id +# Component templates +COMPONENT_TYPES = { + "producer": { + "title": "Data Producer", + "description": "Generates or loads data", + "icon": "📊", + "color": "bg-green-100 border-green-300" + }, + "filter": { + "title": "Data Filter", + "description": "Filters and transforms data", + "icon": "🔍", + "color": "bg-blue-100 border-blue-300" + }, + "presenter": { + "title": "Data Presenter", + "description": "Displays or exports data", + "icon": "📋", + "color": "bg-purple-100 border-purple-300" + } +} + class WorkflowDesigner(BaseComponent): def __init__(self, session, @@ -15,13 +41,71 @@ class WorkflowDesigner(BaseComponent): super().__init__(session, _id) self._settings_manager = settings_manager self._designer_settings = designer_settings - self.boundaries = boundaries + self._state = WorkflowsDesignerState() + self._boundaries = boundaries def set_boundaries(self, boundaries: dict): - self.boundaries = boundaries + self._boundaries = boundaries + + def refresh(self, oob=False): + return self._mk_elements() + + def add_component(self, component_type, x, y): + self._state.component_counter += 1 + + component_id = f"comp_{self._state.component_counter}" + info = COMPONENT_TYPES[component_type] + + component = WorkflowComponent( + id=component_id, + type=component_type, + x=int(x), + y=int(y), + title=info["title"], + description=info["description"] + ) + + self._state.components[component_id] = component + 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) + + return self.refresh() + + def delete_component(self, component_id): + # Remove component + if component_id in self._state.components: + del self._state.components[component_id] + + # 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] + + return self.refresh() + + def add_connection(self, from_id, to_id): + # Check if connection already exists + for connection in self._state.connections: + if connection.from_id == from_id and connection.to_id == to_id: + return self.refresh() # , self.error_message("Connection already exists") + + 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) + return self.refresh() def __ft__(self): - return Div(f"Workflow Designer - {self._designer_settings.workflow_name}") + return Div( + 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(), + Script(f"bindWorkflowDesigner('{self._id}');"), + **apply_boundaries(self._boundaries), + id=f"{self._id}", + ) @staticmethod def create_component_id(session, suffix=None): @@ -30,3 +114,114 @@ class WorkflowDesigner(BaseComponent): suffix = get_unique_id() return f"{prefix}{suffix}" + + @staticmethod + def _mk_toolbox_item(component_type: str, info: dict): + 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" + ), + tooltip=info["description"]), + cls="wkf-toolbox-item p-2", + draggable="true", + data_type=component_type + ) + + @staticmethod + def _mk_workflow_component(component: WorkflowComponent): + info = COMPONENT_TYPES[component.type] + return Div( + # Input connection point + Div(cls="wkf-connection-point wkf-input-point", + data_component_id=component.id, + data_point_type="input"), + + # 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" + ), + + # Output connection point + Div(cls="wkf-connection-point wkf-output-point", + data_component_id=component.id, + data_point_type="output"), + + cls="wkf-workflow-component w-32", + style=f"left: {component.x}px; top: {component.y}px;", + data_component_id=component.id, + draggable="true" + ) + + def _mk_connection_svg(self, conn: Connection): + if conn.from_id not in self._state.components or conn.to_id not in self._state.components: + return "" + + from_comp = self._state.components[conn.from_id] + to_comp = self._state.components[conn.to_id] + + # Calculate connection points (approximate) + x1 = from_comp.x + 128 # component width + output point + y1 = from_comp.y + 40 # component height / 2 + x2 = to_comp.x + y2 = to_comp.y + 40 + + # Create curved path + mid_x = (x1 + x2) / 2 + path = f"M {x1} {y1} C {mid_x} {y1}, {mid_x} {y2}, {x2} {y2}" + + return f""" + + + + + + + + + """ + + def _mk_elements(self): + return Div( + # Render connections + *[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections], + + # Render components + *[self._mk_workflow_component(comp) for comp in self._state.components.values()], + + cls="wkf-canvas bg-white rounded-lg border relative", + ), + + def _mk_canvas(self, oob=False): + return Div( + self._mk_elements(), + 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="w-32 p-4 bg-gray-50 rounded-lg border" + ) + + def _mk_designer(self): + return Div( + self._mk_toolbox(), # (Left side) + self._mk_canvas(), # (Right side) + + cls="flex gap-4", + id=f"d_{self._id}", + ) diff --git a/src/components/workflows/constants.py b/src/components/workflows/constants.py index 143af84..7d900e4 100644 --- a/src/components/workflows/constants.py +++ b/src/components/workflows/constants.py @@ -8,3 +8,7 @@ class Routes: AddWorkflow = "/add-workflow" SelectWorkflow = "/select-workflow" ShowWorkflow = "/show-workflow" + AddComponent = "/add-component" + MoveComponent = "/move-component" + DeleteComponent = "/delete-component" + AddConnection = "/add-connection" diff --git a/src/components/workflows/db_management.py b/src/components/workflows/db_management.py index f0ab673..750104f 100644 --- a/src/components/workflows/db_management.py +++ b/src/components/workflows/db_management.py @@ -6,11 +6,32 @@ from core.settings_management import SettingsManager logger = logging.getLogger("WorkflowsSettings") +# Data structures +@dataclass +class WorkflowComponent: + id: str + type: str + x: int + y: int + title: str + description: str + + +@dataclass +class Connection: + id: str + from_id: str + to_id: str + @dataclass class WorkflowsDesignerSettings: workflow_name: str - +@dataclass +class WorkflowsDesignerState: + components: dict[str, WorkflowComponent] = field(default_factory=dict) + connections: list[Connection] = field(default_factory=list) + component_counter = 0 @dataclass class WorkflowsSettings: diff --git a/src/components_helpers.py b/src/components_helpers.py index cb6b45f..38bcdfd 100644 --- a/src/components_helpers.py +++ b/src/components_helpers.py @@ -81,7 +81,7 @@ def mk_accordion_section(component_id, title, icon, content, selected=False): ) -def set_boundaries(boundaries, remove_margin=True, other=0): +def apply_boundaries(boundaries, remove_margin=True, other=0): if isinstance(boundaries, int): max_height = boundaries else: