Refactoring Properties component

This commit is contained in:
2025-08-03 11:10:17 +02:00
parent c694f42c07
commit 2bd998fe69
8 changed files with 354 additions and 23 deletions

View File

@@ -5,11 +5,19 @@
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}` |
| 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}` |

View File

@@ -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(

View File

@@ -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 = '';
}
});
}

View File

@@ -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(),
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):

View File

@@ -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,
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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"),