diff --git a/src/components/workflows/Readme.md b/src/components/workflows/Readme.md index d13cbab..66b370c 100644 --- a/src/components/workflows/Readme.md +++ b/src/components/workflows/Readme.md @@ -4,12 +4,20 @@ using `_id={WORKFLOW_DESIGNER_INSTANCE_ID}{session['user_id']}{get_unique_id()}` -| Name | value | -|-----------------|--------------------| -| Canvas | `c_{self._id}` | -| Designer | `d_{self._id}` | -| Error Message | `err_{self._id}` | -| Properties | `p_{self._id}` | -| Spliter | `s_{self._id}` | -| Top element | `t_{self._id}` | +| Name | value | +|----------------------------------|--------------------------------| +| Canvas | `c_{self._id}` | +| Designer | `d_{self._id}` | +| Error Message | `err_{self._id}` | +| Properties | `p_{self._id}` | +| Properties Input Section | `pi_{self._id}` | +| Properties Output Section | `po_{self._id}` | +| Properties Properties Section | `pp_{self._id}` | +| Properties Properties drag top | `ppt_{self._id}` | +| Properties Properties drag left | `ppl_{self._id}` | +| Properties Properties drag right | `ppr_{self._id}` | +| Spliter | `s_{self._id}` | +| Top element | `t_{self._id}` | +| Form for properties | `f_{self._id}_{component_id}` | +| Form for output properties | `fo_{self._id}_{component_id}` | diff --git a/src/components/workflows/WorkflowsApp.py b/src/components/workflows/WorkflowsApp.py index 5fed577..e5176a8 100644 --- a/src/components/workflows/WorkflowsApp.py +++ b/src/components/workflows/WorkflowsApp.py @@ -82,6 +82,14 @@ def post(session, _id: str, designer_height: int): return instance.set_designer_height(designer_height) +@rt(Routes.UpdatePropertiesLayout) +def post(session, _id: str, input_width: int, properties_width: int, output_width: int): + logger.debug( + f"Entering {Routes.UpdatePropertiesLayout} with args {debug_session(session)}, {_id=}, {input_width=}, {properties_width=}, {output_width=}") + instance = InstanceManager.get(session, _id) + return instance.update_properties_layout(input_width, properties_width, output_width) + + @rt(Routes.SelectComponent) def post(session, _id: str, component_id: str): logger.debug( diff --git a/src/components/workflows/assets/Workflows.js b/src/components/workflows/assets/Workflows.js index 9be6702..b6aed49 100644 --- a/src/components/workflows/assets/Workflows.js +++ b/src/components/workflows/assets/Workflows.js @@ -1,6 +1,7 @@ function bindWorkflowDesigner(elementId) { bindWorkflowDesignerToolbox(elementId) bindWorkflowDesignerSplitter(elementId) + bindWorkflowProperties(elementId) } function bindWorkflowDesignerToolbox(elementId) { @@ -612,3 +613,153 @@ function bindWorkflowDesignerSplitter(elementId) { } } +function bindWorkflowProperties(elementId) { + let isDragging = false; + let isResizing = false; + let startX = 0; + let startWidths = {}; + let resizeType = ''; + + console.debug("Binding Properties component for "+ elementId) + properties_component = document.getElementById(`p_${elementId}`); + if (properties_component == null) { + console.error(`'Component ' p_${elementId}' is not found !' `) + return + } + const totalWidth = properties_component.getBoundingClientRect().width + console.debug("totalWidth", totalWidth) + + const minPropertiesWidth = Math.floor(totalWidth * 0.2); // 20% minimum + + const inputSection = document.getElementById(`pi_${elementId}`); + const propertiesSection = document.getElementById(`pp_${elementId}`); + const outputSection = document.getElementById(`po_${elementId}`); + const dragHandle = document.getElementById(`ppt_${elementId}`); + const leftHandle = document.getElementById(`ppl_${elementId}`); + const rightHandle = document.getElementById(`ppr_${elementId}`); + + // Drag and drop for moving properties section + dragHandle.addEventListener('mousedown', (e) => { + isDragging = true; + startX = e.clientX; + startWidths = { + input: parseInt(inputSection.style.width), + properties: parseInt(propertiesSection.style.width), + output: parseInt(outputSection.style.width) + }; + e.preventDefault(); + }); + + // Left resize handle + leftHandle.addEventListener('mousedown', (e) => { + isResizing = true; + resizeType = 'left'; + startX = e.clientX; + startWidths = { + input: parseInt(inputSection.style.width), + properties: parseInt(propertiesSection.style.width), + output: parseInt(outputSection.style.width) + }; + e.preventDefault(); + }); + + // Right resize handle + rightHandle.addEventListener('mousedown', (e) => { + isResizing = true; + resizeType = 'right'; + startX = e.clientX; + startWidths = { + input: parseInt(inputSection.style.width), + properties: parseInt(propertiesSection.style.width), + output: parseInt(outputSection.style.width) + }; + e.preventDefault(); + }); + + // Mouse move + document.addEventListener('mousemove', (e) => { + if (isDragging) { + const deltaX = e.clientX - startX; + let newInputWidth = startWidths.input + deltaX; + let newOutputWidth = startWidths.output - deltaX; + + // Constraints + if (newInputWidth < 0) { + newInputWidth = 0; + newOutputWidth = totalWidth - startWidths.properties; + } + if (newOutputWidth < 0) { + newOutputWidth = 0; + newInputWidth = totalWidth - startWidths.properties; + } + + inputSection.style.width = newInputWidth + 'px'; + outputSection.style.width = newOutputWidth + 'px'; + } + + if (isResizing) { + const deltaX = e.clientX - startX; + let newInputWidth = startWidths.input; + let newPropertiesWidth = startWidths.properties; + let newOutputWidth = startWidths.output; + + if (resizeType === 'left') { + newInputWidth = startWidths.input + deltaX; + newPropertiesWidth = startWidths.properties - deltaX; + + if (newInputWidth < 0) { + newInputWidth = 0; + newPropertiesWidth = startWidths.input + startWidths.properties; + } + if (newPropertiesWidth < minPropertiesWidth) { + newPropertiesWidth = minPropertiesWidth; + newInputWidth = totalWidth - minPropertiesWidth - startWidths.output; + } + } else if (resizeType === 'right') { + newPropertiesWidth = startWidths.properties + deltaX; + newOutputWidth = startWidths.output - deltaX; + + if (newOutputWidth < 0) { + newOutputWidth = 0; + newPropertiesWidth = startWidths.properties + startWidths.output; + } + if (newPropertiesWidth < minPropertiesWidth) { + newPropertiesWidth = minPropertiesWidth; + newOutputWidth = totalWidth - startWidths.input - minPropertiesWidth; + } + } + + inputSection.style.width = newInputWidth + 'px'; + propertiesSection.style.width = newPropertiesWidth + 'px'; + outputSection.style.width = newOutputWidth + 'px'; + } + }); + + // Mouse up + document.addEventListener('mouseup', () => { + if (isDragging || isResizing) { + // Send HTMX request with new dimensions + const currentWidths = { + input_width: parseInt(inputSection.style.width), + properties_width: parseInt(propertiesSection.style.width), + output_width: parseInt(outputSection.style.width) + }; + + try { + htmx.ajax('POST', '/workflows/update-properties-layout', { + target: `#${elementId}`, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + swap: "outerHTML", + values: { _id: elementId, ...currentWidths } + }); + } catch (error) { + console.error('HTMX request failed:', error); + throw error; + } + + isDragging = false; + isResizing = false; + resizeType = ''; + } + }); +} \ No newline at end of file diff --git a/src/components/workflows/components/WorkflowDesigner.py b/src/components/workflows/components/WorkflowDesigner.py index 224d291..a2f78a4 100644 --- a/src/components/workflows/components/WorkflowDesigner.py +++ b/src/components/workflows/components/WorkflowDesigner.py @@ -9,10 +9,11 @@ from components.BaseComponent import BaseComponent from components.undo_redo.constants import UndoRedoAttrs from components.workflows.assets.icons import icon_play, icon_pause, icon_stop, icon_refresh from components.workflows.commands import WorkflowDesignerCommandManager +from components.workflows.components.WorkflowDesignerProperties import WorkflowDesignerProperties from components.workflows.components.WorkflowPlayer import WorkflowPlayer from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, \ - Connection, WorkflowsDesignerDbManager, ComponentState + Connection, WorkflowsDesignerDbManager, ComponentState, WorkflowsDesignerState from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, mk_icon from core.instance_manager import InstanceManager from core.jira import JiraRequestTypes, DEFAULT_SEARCH_FIELDS @@ -65,9 +66,10 @@ class WorkflowDesigner(BaseComponent): self._designer_settings = designer_settings self._db = WorkflowsDesignerDbManager(session, settings_manager) self._undo_redo = ComponentsInstancesHelper.get_undo_redo(session) - self._state = self._db.load_state(key) + self._state: WorkflowsDesignerState = self._db.load_state(key) self._boundaries = boundaries self.commands = WorkflowDesignerCommandManager(self) + self.properties = WorkflowDesignerProperties(self._session, f"{self._id}", self) workflow_name = self._designer_settings.workflow_name self._player = InstanceManager.get(self._session, @@ -83,7 +85,10 @@ class WorkflowDesigner(BaseComponent): def set_boundaries(self, boundaries: dict): self._boundaries = boundaries - def get_state(self): + def get_boundaries(self): + return self._boundaries + + def get_state(self) -> WorkflowsDesignerState: return self._state def get_db(self): @@ -106,6 +111,7 @@ class WorkflowDesigner(BaseComponent): def refresh_state(self): self._state = self._db.load_state(self._key) + self.properties.update_layout() return self.__ft__(oob=True) def add_component(self, component_type, x, y): @@ -140,7 +146,7 @@ class WorkflowDesigner(BaseComponent): undo_redo_attrs = UndoRedoAttrs(f"Move Component '{component.title}'", on_undo=self.refresh_state) self._db.save_state(self._key, self._state, undo_redo_attrs) # update db - return self.refresh_designer(), self.refresh_properties(True), self._undo_redo.refresh() + return self.refresh_designer(), self.properties.refresh(), self._undo_redo.refresh() def delete_component(self, component_id): # Remove component @@ -192,6 +198,16 @@ class WorkflowDesigner(BaseComponent): self._db.save_state(self._key, self._state, undo_redo_attrs) return self.__ft__(), self._undo_redo.refresh() # refresh the whole component + def update_properties_layout(self, input_width, properties_width, output_width): + self._state.properties_input_width = input_width + self._state.properties_properties_width = properties_width + self._state.properties_output_width = output_width + self.properties.update_layout() + + undo_redo_attrs = UndoRedoAttrs(f"Resize Properties", on_undo=lambda: self.refresh_state()) + self._db.save_state(self._key, self._state, undo_redo_attrs) + return self.__ft__(), self._undo_redo.refresh() # refresh the whole component + def select_component(self, component_id): if component_id in self._state.components: self._state.selected_component_id = component_id @@ -273,7 +289,7 @@ class WorkflowDesigner(BaseComponent): def __ft__(self, oob=False): 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"), + # P("Drag components from the toolbox to the canvas to create your workflow.", cls="text-sm"), Div( self._mk_media(), # self._mk_refresh_button(), @@ -365,6 +381,11 @@ class WorkflowDesigner(BaseComponent): ) def _mk_elements(self): + if len(self._state.components) == 0: + return Div("Drag components from the toolbox to the canvas to create your workflow.", + cls="flex items-center justify-center h-full w-full" + ) + return Div( # Render connections *[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections], @@ -436,6 +457,17 @@ class WorkflowDesigner(BaseComponent): return Div('Not defined yet !') + def _mk_properties_output(self, component): + return Div( + "Output name", + Input(type="input", + name="output_name", + placeholder="data", + value=component.properties.get("output_name", None), + cls="input w-xs"), + cls="join" + ) + def _mk_properties_details(self, component_id, allow_component_selection=False): def _mk_header(): return Div( @@ -469,11 +501,32 @@ class WorkflowDesigner(BaseComponent): return Div( Form( _mk_header(), - _mk_select(), - self._mk_processor_properties(component, selected_processor_name), + Div( + Input(type="radio", name=f"pt_{self._id}", cls="tab", aria_label="Properties", checked="checked"), + Div( + _mk_select(), + self._mk_processor_properties(component, selected_processor_name), + cls="tab-content" + ), + + Input(type="radio", name=f"pt_{self._id}", cls="tab", aria_label="Inputs"), + Div( + "Inputs", + cls="tab-content" + ), + + Input(type="radio", name=f"pt_{self._id}", cls="tab", aria_label="Output"), + Div( + self._mk_properties_output(component), + cls="tab-content" + ), + + cls="tabs tabs-border" + ), mk_dialog_buttons(cls="mt-4", on_ok=self.commands.save_properties(component_id), on_cancel=self.commands.cancel_properties(component_id)), + cls="font-mono text-sm", id=f"f_{self._id}_{component_id}", ), @@ -481,13 +534,7 @@ class WorkflowDesigner(BaseComponent): ) def _mk_properties(self, oob=False): - return Div( - self._mk_properties_details(self._state.selected_component_id), - cls="p-2 bg-base-100 rounded-lg border", - style=f"height:{self._get_properties_height()}px;", - hx_swap_oob='true' if oob else None, - id=f"p_{self._id}", - ) + return self.properties def _mk_jira_processor_details(self, component): def _mk_option(name): diff --git a/src/components/workflows/components/WorkflowDesignerProperties.py b/src/components/workflows/components/WorkflowDesignerProperties.py new file mode 100644 index 0000000..8ad3e6f --- /dev/null +++ b/src/components/workflows/components/WorkflowDesignerProperties.py @@ -0,0 +1,113 @@ +from fasthtml.common import * +from dataclasses import dataclass + +from components.BaseComponent import BaseComponent + + +@dataclass +class DesignerLayout: + input_width: int + properties_width: int + output_width: int + + +class WorkflowDesignerProperties(BaseComponent): + def __init__(self, session, instance_id, owner): + super().__init__(session, instance_id) + self._owner = owner + self._boundaries = self._owner.get_boundaries() + self._commands = self._owner.commands + self.layout = self.compute_layout() + + def update_layout(self): + self.layout = self.compute_layout() + + def compute_layout(self) -> DesignerLayout: + if self._owner.get_state().properties_input_width is None: + input_width = self._boundaries["width"] // 3 + properties_width = self._boundaries["width"] // 3 + output_width = self._boundaries["width"] - input_width - properties_width - 10 + else: + input_width = self._owner.get_state().properties_input_width + properties_width = self._owner.get_state().properties_properties_width + output_width = self._owner.get_state().properties_output_width + + return DesignerLayout( + input_width=input_width, + properties_width=properties_width, + output_width=output_width + ) + + def refresh(self, oob=True): + return self.__ft__(oob=oob) + + def _mk_input(self): + return Div( + "Input", + id=f"pi_{self._id}", + style=f"width: {self.layout.input_width}px; height: {self._boundaries['height']}px; background-color: #f0f0f0; border: 1px solid #ccc; display: inline-block; vertical-align: top; padding: 10px; box-sizing: border-box;" + ) + + def _mk_output(self): + return Div( + "Output", + id=f"po_{self._id}", + style=f"width: {self.layout.output_width}px; height: {self._boundaries['height']}px; background-color: #f0f0f0; border: 1px solid #ccc; display: inline-block; vertical-align: top; padding: 10px; box-sizing: border-box;" + ) + + def _mk_properties(self): + return Div( + # Drag handle (20px height) + Div( + "Properties", + id=f"ppt_{self._id}", + style="height: 20px; background-color: #ddd; cursor: move; text-align: center; line-height: 20px; font-weight: bold; font-size: 12px; border-bottom: 1px solid #bbb;" + ), + + # Properties content + Div( + Input(placeholder="output name", style="width: 100%; margin-bottom: 10px;"), + Select( + Option("Repository", value="repository"), + Option("Jira", value="jira"), + style="width: 100%; margin-bottom: 10px;" + ), + Div( + Button("Save", **self._commands.on_save(), style="margin-right: 5px; padding: 5px 10px;"), + Button("Cancel", **self._commands.on_cancel(), style="padding: 5px 10px;"), + style="text-align: center;" + ), + style=f"padding: 10px; height: {self._boundaries['height'] - 20}px; box-sizing: border-box;" + ), + + # Left resize handle + Div( + id=f"ppl_{self._id}", + style="position: absolute; left: 0; top: 0; width: 5px; height: 100%; cursor: ew-resize; background-color: transparent;" + ), + + # Right resize handle + Div( + id=f"ppr_{self._id}", + style="position: absolute; right: 0; top: 0; width: 5px; height: 100%; cursor: ew-resize; background-color: transparent;" + ), + + id=f"pp_{self._id}", + style=f"width: {self.layout.properties_width}px; height: {self._boundaries['height']}px; background-color: #f8f8f8; border: 1px solid #ccc; display: inline-block; vertical-align: top; position: relative; box-sizing: border-box;" + ) + + def _mk_layout(self): + return Div( + self._mk_input(), + self._mk_properties(), + self._mk_output(), + ) + + def __ft__(self, oob=False): + # return self.render() + return Div( + self._mk_layout(), + style=f"height: {self._boundaries['height']}px; border: 2px solid #333; position: relative; font-family: Arial, sans-serif;", + id=f"p_{self._id}", + hx_swap_oob='true' if oob else None, + ) diff --git a/src/components/workflows/constants.py b/src/components/workflows/constants.py index d4473a5..a0bffae 100644 --- a/src/components/workflows/constants.py +++ b/src/components/workflows/constants.py @@ -25,6 +25,7 @@ class Routes: AddConnection = "/add-connection" DeleteConnection = "/delete-connection" ResizeDesigner = "/resize-designer" + UpdatePropertiesLayout = "/update-properties-layout" SaveProperties = "/save-properties" CancelProperties = "/cancel-properties" SelectProcessor = "/select-processor" diff --git a/src/components/workflows/db_management.py b/src/components/workflows/db_management.py index dca6564..e2b00f9 100644 --- a/src/components/workflows/db_management.py +++ b/src/components/workflows/db_management.py @@ -61,6 +61,9 @@ class WorkflowsDesignerState: connections: list[Connection] = field(default_factory=list) component_counter: int = 0 designer_height: int = 230 + properties_input_width: int = None + properties_properties_width : int = None + properties_output_width: int = None selected_component_id: str | None = None diff --git a/tests/test_workflow_designer.py b/tests/test_workflow_designer.py index 88416c3..2961737 100644 --- a/tests/test_workflow_designer.py +++ b/tests/test_workflow_designer.py @@ -97,7 +97,7 @@ def test_i_can_render_no_component(designer): actual = designer.__ft__() expected = Div( H1("Workflow Name"), - P("Drag components from the toolbox to the canvas to create your workflow."), + # P("Drag components from the toolbox to the canvas to create your workflow."), Div(id=f"t_{designer.get_id()}"), # media + error message Div(id=f"d_{designer.get_id()}"), # designer container Div(cls="wkf-splitter"),