16 Commits

Author SHA1 Message Date
e73709c859 Adding integration tests for workflow engine 2025-08-06 18:22:16 +02:00
f0d98d23ff I can finally chain Processor calls 2025-08-05 19:45:25 +02:00
64e7c44a7d Added other Jira resources 2025-08-05 10:42:26 +02:00
3a1870a160 Refactored properties component 2025-08-05 00:18:31 +02:00
c2fcfbb2ab I can save form (but user interaction is broken) 2025-08-04 18:36:28 +02:00
e74639c042 Properties Details correctly reacts on user interaction 2025-08-04 16:40:48 +02:00
badc2e28b0 Dialog box at the bottom. Property layout fully operationnel 2025-08-04 10:34:11 +02:00
4ac3eb2dfa The new layout is ok. We can work on the content 2025-08-03 18:11:26 +02:00
2bd998fe69 Refactoring Properties component 2025-08-03 11:10:17 +02:00
c694f42c07 The correct tab is shown on undo - redo 2025-08-02 15:27:11 +02:00
6949bb2814 Fixed WorkflowDesignerState 2025-08-02 01:22:45 +02:00
14f079d5f9 Added undo functionalities for all commands 2025-08-01 19:25:34 +02:00
3ca23449e4 Fixed unit tests 2025-08-01 18:55:40 +02:00
a6f765c624 Updated gitignore to remove .idea files 2025-08-01 09:16:03 +02:00
43e7dd5f00 Remove .idea directory from tracking 2025-08-01 09:15:17 +02:00
37c91d0d5d Another implementation of undo/redo 2025-07-31 22:54:09 +02:00
32 changed files with 2040 additions and 258 deletions

4
.gitignore vendored
View File

@@ -11,6 +11,8 @@ tests/TestDBEngineRoot
.sesskey
tools.db
.mytools_db
.idea/MyManagingTools.iml
.idea/misc.xml
# Created by .ignore support plugin (hsz.mobi)
### Python template
@@ -196,4 +198,4 @@ fabric.properties
.idea/caches/build_file_checksums.ser
# idea folder, uncomment if you don't need it
# .idea
# .idea

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (MyManagingTools)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

7
.idea/misc.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12 (MyManagingTools)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (MyManagingTools)" project-jdk-type="Python SDK" />
</project>

View File

@@ -14,7 +14,7 @@ python main.py
```shell
docker-compose up -d
```
The application will be accessible on port 8000 (or whatever port you configured).
The application will be accessible on port 8001 (if the docker compose file was not changed !).
2. **Initialize the Mistral model** (first run):
```shell

View File

@@ -152,6 +152,14 @@ class MyTabs(BaseComponent):
def get_tab_content_by_key(self, key):
return self.tabs_by_key[key].content if key in self.tabs_by_key else None
def show_tab(self, tab_key, updated_content=None):
if updated_content:
tab_id = self._get_tab_id_from_tab_key(tab_key)
self.set_tab_content(tab_id, updated_content)
self.select_tab_by_key(tab_key)
return self.refresh()
def refresh(self):
return self.render(oob=True)
@@ -188,6 +196,13 @@ class MyTabs(BaseComponent):
active_tab = next(filter(lambda t: t.active, self.tabs), None)
return active_tab.content if active_tab else None
def get_active_tab_key(self):
active_tab = next(filter(lambda t: t.active, self.tabs), None)
return active_tab.key if active_tab else None
def _get_tab_id_from_tab_key(self, tab_key):
return self.tabs_by_key[tab_key].id if tab_key in self.tabs_by_key else None
@staticmethod
def create_component_id(session):
prefix = f"{MY_TABS_INSTANCE_ID}{session['user_id']}"

View File

@@ -1,30 +1,27 @@
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from fastcore.xml import FT
from fasthtml.components import *
from components.BaseComponent import BaseComponentSingleton
from components.undo_redo.assets.icons import icon_redo, icon_undo
from components.undo_redo.commands import UndoRedoCommandManager
from components.undo_redo.constants import UNDO_REDO_INSTANCE_ID
from components.undo_redo.constants import UNDO_REDO_INSTANCE_ID, UndoRedoAttrs
from components_helpers import mk_icon, mk_tooltip
from core.settings_management import NoDefault
logger = logging.getLogger("UndoRedoApp")
class CommandHistory(ABC):
def __init__(self, name, desc, owner):
self.name = name
self.desc = desc
self.owner = owner
@abstractmethod
def undo(self):
pass
@abstractmethod
def redo(self):
pass
@dataclass
class CommandHistory:
attrs: UndoRedoAttrs
tab_key: str | None
digest: str | None # digest to remember
entry: str # digest to remember
key: str # key
path: str # path within the key if only on subitem needs to be updated
class UndoRedo(BaseComponentSingleton):
@@ -35,35 +32,91 @@ class UndoRedo(BaseComponentSingleton):
self.index = -1
self.history = []
self._commands = UndoRedoCommandManager(self)
self._db_engine = settings_manager.get_db_engine()
def push(self, command: CommandHistory):
self.history = self.history[:self.index + 1]
def snapshot(self, undo_redo_attrs: UndoRedoAttrs, entry, key, path=None):
digest = self._settings_manager.get_digest(self._session, entry) # get the current digest (the last one)
active_tab_key = self.tabs_manager.get_active_tab_key()
# init the history if this is the first call
if len(self.history) == 0:
digest_history = self._settings_manager.history(self._session, entry, digest, 2)
command = CommandHistory(undo_redo_attrs,
active_tab_key,
digest_history[1] if len(digest_history) > 1 else None,
entry,
key,
path)
self.history.append(command)
self.index = 0
command = CommandHistory(undo_redo_attrs, active_tab_key, digest, entry, key, path)
self.history = self.history[:self.index + 1] #
self.history.append(command)
self.index += 1
self.index = len(self.history) - 1
def undo(self):
logger.debug(f"Undo command")
if self.index < 0 :
if self.index < 1:
logger.debug(f" No command to undo.")
return self
command = self.history[self.index]
logger.debug(f" Undoing command {command.name} ({command.desc})")
res = command.undo()
current = self.history[self.index]
current_state = self._settings_manager.load(self._session, None, digest=current.digest)
previous = self.history[self.index - 1]
previous_state = self._settings_manager.load(self._session, None, digest=previous.digest)
# reapply the state
if previous_state is not NoDefault:
current_state[current.key] = previous_state[current.key]
else:
del current_state[current.key]
self._settings_manager.save(self._session, current.entry, current_state)
self.index -= 1
return self, res
if current.attrs.on_undo is not None:
ret = current.attrs.on_undo()
if current.attrs.update_tab and current.tab_key is not None and current.tab_key != self.tabs_manager.get_active_tab_key():
ret = self.tabs_manager.show_tab(current.tab_key)
elif isinstance(ret, FT) and 'id' in ret.attrs:
ret.attrs["hx-swap-oob"] = "true"
return self, ret
else:
return self
def redo(self):
logger.debug("Redo command")
if self.index == len(self.history) - 1:
logger.debug(f" No command to redo.")
logger.debug(f"Redo command")
if self.index >= len(self.history) - 1:
logger.debug(f" No command to undo.")
return self
current = self.history[self.index]
current_state = self._settings_manager.load(self._session, None, digest=current.digest)
next_ = self.history[self.index + 1]
next_state = self._settings_manager.load(self._session, None, digest=next_.digest)
# reapply the state
if current_state is not NoDefault:
current_state[current.key] = next_state[current.key]
else:
current_state = {current.key: next_state[current.key]}
self._settings_manager.save(self._session, current.entry, current_state)
self.index += 1
command = self.history[self.index]
logger.debug(f" Redoing command {command.name} ({command.desc})")
res = command.redo()
return self, res
if current.attrs.on_redo is not None:
ret = current.attrs.on_undo()
if current.attrs.update_tab and current.tab_key is not None and current.tab_key != self.tabs_manager.get_active_tab_key():
ret = self.tabs_manager.show_tab(current.tab_key)
elif isinstance(ret, FT) and 'id' in ret.attrs:
ret.attrs["hx-swap-oob"] = "true"
return self, ret
else:
return self
def refresh(self):
return self.__ft__(oob=True)
@@ -83,7 +136,7 @@ class UndoRedo(BaseComponentSingleton):
return mk_tooltip(mk_icon(icon_undo,
size=24,
**self._commands.undo()),
f"Undo '{command.name}'.")
f"Undo '{command.attrs.name}'.")
else:
return mk_tooltip(mk_icon(icon_undo,
size=24,
@@ -93,11 +146,11 @@ class UndoRedo(BaseComponentSingleton):
def _mk_redo(self):
if self._can_redo():
command = self.history[self.index]
command = self.history[self.index + 1]
return mk_tooltip(mk_icon(icon_redo,
size=24,
**self._commands.redo()),
f"Redo '{command.name}'.")
f"Redo '{command.attrs.name}'.")
else:
return mk_tooltip(mk_icon(icon_redo,
size=24,
@@ -106,7 +159,7 @@ class UndoRedo(BaseComponentSingleton):
"Nothing to redo.")
def _can_undo(self):
return self.index >= 0
return self.index >= 1
def _can_redo(self):
return self.index < len(self.history) - 1

View File

@@ -1,3 +1,6 @@
from dataclasses import dataclass
from typing import Callable
UNDO_REDO_INSTANCE_ID = "__UndoRedo__"
ROUTE_ROOT = "/undo"
@@ -5,4 +8,17 @@ ROUTE_ROOT = "/undo"
class Routes:
Undo = "/undo"
Redo = "/redo"
Redo = "/redo"
@dataclass
class UndoRedoAttrs:
name: str
desc: str = None
update_tab: bool = True
on_undo: Callable = None
on_redo: Callable = None
def __post_init__(self):
if self.on_redo is None:
self.on_redo = self.on_undo

View File

@@ -4,12 +4,21 @@
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}` |
| Properties Properties content | `ppc_{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(
@@ -129,14 +137,20 @@ def post(session, _id: str, component_id: str, event_name: str, details: dict):
@rt(Routes.PlayWorkflow)
def post(session, _id: str, tab_boundaries: str):
logger.debug(
f"Entering {Routes.PlayWorkflow} with args {debug_session(session)}, {_id=}")
logger.debug(f"Entering {Routes.PlayWorkflow} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.play_workflow(json.loads(tab_boundaries))
@rt(Routes.StopWorkflow)
def post(session, _id: str):
logger.debug(
f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}")
logger.debug(f"Entering {Routes.StopWorkflow} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.stop_workflow()
return instance.stop_workflow()
@rt(Routes.Refresh)
def post(session, _id: str):
logger.debug(f"Entering {Routes.Refresh} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.refresh()

View File

@@ -47,8 +47,105 @@
.wkf-properties {
box-sizing: border-box;
position: relative;
font-family: Arial, sans-serif;
background-color: var(--color-base-100); /* bg-base-100 */
}
.wkf-properties-input, .wkf-properties-output {
display: inline-block;
vertical-align: top;
padding: 10px;
box-sizing: border-box;
font-family: Arial, sans-serif;
background-color: var(--color-base-100); /* bg-base-100 */
overflow: auto;
}
.wkf-properties-input {
border-width: 1px;
border-top-left-radius: 0.5rem; /* rounded on left side */
border-bottom-left-radius: 0.5rem;
border-top-right-radius: 0; /* not rounded on right side */
border-bottom-right-radius: 0;
}
.wkf-properties-output {
border-width: 1px;
border-top-right-radius: 0.5rem; /* rounded on right side */
border-bottom-right-radius: 0.5rem;
border-top-left-radius: 0; /* not rounded on left side */
border-bottom-left-radius: 0;
}
.wkf-properties-properties {
vertical-align: top;
position: relative;
box-sizing: border-box;
overflow: auto;
}
.wkf-properties-handle-left {
position: absolute;
left: 0;
top: 0;
width: 5px;
height: 100%;
cursor: ew-resize;
background-color: transparent;
}
.wkf-properties-handle-right {
position: absolute;
right: 0;
top: 0;
width: 5px;
height: 100%;
cursor: ew-resize;
background-color: transparent;
}
.wkf-properties-top {
display: flex;
justify-content: center;
align-items: center;
cursor: move;
padding: 4px;
}
.wkf-properties-handle-top {
background-image: radial-gradient(var(--color-splitter) 40%, transparent 0);
background-repeat: repeat;
background-size: 4px 4px;
cursor: move;
display: flex;
justify-content: center;
align-items: center;
height: 8px;
width: 20px;
position: relative;
top: 1px;
}
.wkf-properties-content {
display: flex;
flex-direction: column;
height: 100%; /* or inherit from a fixed-height parent */
}
.wkf-properties-content-header {
flex-shrink: 0; /* optional: prevent it from shrinking */
}
.wkf-properties-content-form {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden; /* prevent double scrollbars if needed */
}
.wkf-canvas {
position: relative;
box-sizing: border-box;
@@ -193,5 +290,3 @@
.wkf-connection-path-arrowhead-selected {
fill:#ef4444 !important;;
}

View File

@@ -1,6 +1,7 @@
function bindWorkflowDesigner(elementId) {
bindWorkflowDesignerToolbox(elementId)
bindWorkflowDesignerSplitter(elementId)
bindWorkflowProperties(elementId)
}
function bindWorkflowDesignerToolbox(elementId) {
@@ -204,7 +205,7 @@ function bindWorkflowDesignerToolbox(elementId) {
// Also trigger server-side selection
utils.makeRequest('/workflows/select-component', {
component_id: designer.selectedComponent
}, `#p_${elementId}`, "outerHTML");
}, `#ppc_${elementId}`, "outerHTML");
},
// Deselect all components
@@ -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 = 352; // this value avoid scroll bars
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

@@ -23,3 +23,6 @@ icon_pause_circle = NotStr(
# fluent RecordStop20Regular
icon_stop_circle = NotStr(
"""<svg name="stop" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20"><g fill="none"><path d="M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14zm-8 7a8 8 0 1 1 16 0a8 8 0 0 1-16 0zm5-2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V8z" fill="currentColor"></path></g></svg>""")
# fluent ArrowClockwise20Regular
icon_refresh = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20"><g fill="none"><path d="M3.066 9.05a7 7 0 0 1 12.557-3.22l.126.17H12.5a.5.5 0 1 0 0 1h4a.5.5 0 0 0 .5-.5V2.502a.5.5 0 0 0-1 0v2.207a8 8 0 1 0 1.986 4.775a.5.5 0 0 0-.998.064A7 7 0 1 1 3.066 9.05z" fill="currentColor"></path></g></svg>""")

View File

@@ -1,23 +1,6 @@
from components.BaseCommandManager import BaseCommandManager
from components.undo_redo.components.UndoRedo import CommandHistory
from components.workflows.constants import Routes, ROUTE_ROOT
class AddConnectorCommand(CommandHistory):
def __init__(self, owner, connector):
super().__init__("Add connector", "Add connector", owner)
self.connector = connector
def undo(self):
del self.owner.get_state().components[self.connector.id]
self.owner.get_db().save_state(self.owner.get_key(), self.owner.get_state()) # update db
return self.owner.refresh_designer(True)
def redo(self, oob=True):
self.owner.get_state().components[self.connector.id] = self.connector
self.owner.get_db().save_state(self.owner.get_key(), self.owner.get_state()) # update db
return self.owner.refresh_designer(oob)
class WorkflowsCommandManager(BaseCommandManager):
def __init__(self, owner):
@@ -54,7 +37,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
def select_processor(self, component_id: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.SelectProcessor}",
"hx-target": f"#p_{self._id}",
"hx-target": f"#ppc_{self._id}",
"hx-swap": "outerHTML",
"hx-trigger": "change",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
@@ -63,7 +46,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
def save_properties(self, component_id: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.SaveProperties}",
"hx-target": f"#p_{self._id}",
"hx-target": f"#ppc_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
}
@@ -71,7 +54,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
def cancel_properties(self, component_id: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.CancelProperties}",
"hx-target": f"#p_{self._id}",
"hx-target": f"#ppc_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
}
@@ -79,7 +62,7 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
def on_processor_details_event(self, component_id: str, event_name: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.OnProcessorDetailsEvent}",
"hx-target": f"#p_{self._id}",
"hx-target": f"#ppc_{self._id}",
"hx-trigger": "change",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}", "event_name": "{event_name}"}}',
@@ -108,6 +91,13 @@ class WorkflowDesignerCommandManager(BaseCommandManager):
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
def refresh(self):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.Refresh}",
"hx-swap": "none",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
class WorkflowPlayerCommandManager(BaseCommandManager):

View File

@@ -6,12 +6,15 @@ from fasthtml.xtend import Script
from assets.icons import icon_error
from components.BaseComponent import BaseComponent
from components.workflows.assets.icons import icon_play, icon_pause, icon_stop
from components.workflows.commands import WorkflowDesignerCommandManager, AddConnectorCommand
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.constants import WORKFLOW_DESIGNER_INSTANCE_ID, ProcessorTypes, COMPONENT_TYPES, \
PROCESSOR_TYPES
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
@@ -21,33 +24,6 @@ from utils.DbManagementHelper import DbManagementHelper
logger = logging.getLogger("WorkflowDesigner")
# Component templates
COMPONENT_TYPES = {
ProcessorTypes.Producer: {
"title": "Data Producer",
"description": "Generates or loads data",
"icon": "📊",
"color": "bg-green-100 border-green-300 text-neutral"
},
ProcessorTypes.Filter: {
"title": "Data Filter",
"description": "Filters and transforms data",
"icon": "🔍",
"color": "bg-blue-100 border-blue-300 text-neutral"
},
ProcessorTypes.Presenter: {
"title": "Data Presenter",
"description": "Displays or exports data",
"icon": "📋",
"color": "bg-purple-100 border-purple-300 text-neutral"
}
}
PROCESSOR_TYPES = {
ProcessorTypes.Producer: ["Repository", "Jira"],
ProcessorTypes.Filter: ["Default"],
ProcessorTypes.Presenter: ["Default"]}
class WorkflowDesigner(BaseComponent):
def __init__(self, session,
@@ -63,9 +39,11 @@ class WorkflowDesigner(BaseComponent):
self._key = key
self._designer_settings = designer_settings
self._db = WorkflowsDesignerDbManager(session, settings_manager)
self._state = self._db.load_state(key)
self._undo_redo = ComponentsInstancesHelper.get_undo_redo(session)
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,
@@ -81,7 +59,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):
@@ -91,11 +72,23 @@ class WorkflowDesigner(BaseComponent):
return self._key
def refresh_designer(self, oob=False):
return self._mk_canvas(oob)
if oob:
return self._mk_canvas(oob)
else:
return self._mk_elements()
def refresh_properties(self, oob=False):
return self._mk_properties(oob)
def refresh(self):
return self.__ft__(oob=True)
def refresh_state(self):
self._state = self._db.load_state(self._key)
self.properties.update_layout()
self.properties.update_component(self._state.selected_component_id)
return self.__ft__(oob=True)
def add_component(self, component_type, x, y):
self._state.component_counter += 1
@@ -111,39 +104,40 @@ class WorkflowDesigner(BaseComponent):
description=info["description"],
properties={"processor_name": PROCESSOR_TYPES[component_type][0]}
)
command = AddConnectorCommand(self, component)
undo_redo = ComponentsInstancesHelper.get_undo_redo(self._session)
#undo_redo.push(command)
self._state.components[component_id] = component
self._db.save_state(self._key, self._state) # update db
undo_redo.snapshot("add_component")
return command.redo(), undo_redo.refresh()
# self._state.components[component_id] = component
# self._db.save_state(self._key, self._state) # update db
# return self.refresh_designer()
undo_redo_attrs = UndoRedoAttrs(f"Add Component '{component_type}'", on_undo=self.refresh_state)
self._db.save_state(self._key, self._state, undo_redo_attrs) # update db
return self.refresh_designer(), self._undo_redo.refresh()
def move_component(self, component_id, x, y):
if component_id in self._state.components:
component = self._state.components[component_id]
self._state.selected_component_id = component_id
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
component.x = int(x)
component.y = int(y)
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)
return self.refresh_designer(), self.properties.refresh(mode="form", oob=True), self._undo_redo.refresh()
def delete_component(self, component_id):
# Remove component
if component_id in self._state.components:
component = self._state.components[component_id]
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]
# update db
undo_redo_attrs = UndoRedoAttrs(f"Remove Component '{component.title}'", on_undo=self.refresh_state)
self._db.save_state(self._key, self._state, undo_redo_attrs)
# 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_designer()
return self.refresh_designer(), self._undo_redo.refresh()
def add_connection(self, from_id, to_id):
# Check if connection already exists
@@ -156,9 +150,10 @@ class WorkflowDesigner(BaseComponent):
self._state.connections.append(connection)
# update db
self._db.save_state(self._key, self._state)
undo_redo_attrs = UndoRedoAttrs(f"Add Connection", on_undo=self.refresh_state)
self._db.save_state(self._key, self._state, undo_redo_attrs)
return self.refresh_designer()
return self.refresh_designer(), self._undo_redo.refresh()
def delete_connection(self, from_id, to_id):
for connection in self._state.connections:
@@ -166,44 +161,63 @@ class WorkflowDesigner(BaseComponent):
self._state.connections.remove(connection)
# update db
self._db.save_state(self._key, self._state)
undo_redo_attrs = UndoRedoAttrs(f"Delete Connection", on_undo=self.refresh_state)
self._db.save_state(self._key, self._state, undo_redo_attrs)
return self.refresh_designer()
return self.refresh_designer(), self._undo_redo.refresh()
def set_designer_height(self, height):
self._state.designer_height = height
self._db.save_state(self._key, self._state)
return self.__ft__() # refresh the whole component
undo_redo_attrs = UndoRedoAttrs(f"Resize Designer", 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 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
self._db.save_state(self._key, self._state)
component = self._state.components[component_id]
undo_redo_attrs = UndoRedoAttrs(f"Select Component {component.title}", on_undo=self.refresh_state)
self._db.save_state(self._key, self._state, undo_redo_attrs)
return self.refresh_properties()
return self.properties.refresh(mode="form"), self._undo_redo.refresh()
def save_properties(self, component_id: str, details: dict):
if component_id in self._state.components:
component = self._state.components[component_id]
component.properties = details
self._db.save_state(self._key, self._state)
component.properties |= details
undo_redo_attrs = UndoRedoAttrs(f"Set properties for {component.title}", on_undo=self.refresh_state)
self._db.save_state(self._key, self._state, undo_redo_attrs)
logger.debug(f"Saved properties for component {component_id}: {details}")
return self.refresh_properties()
return self.properties.refresh(mode="form"), self._undo_redo.refresh()
def cancel_properties(self, component_id: str):
if component_id in self._state.components:
logger.debug(f"Cancel saving properties for component {component_id}")
return self.refresh_properties()
return self.properties.refresh(mode="form")
def set_selected_processor(self, component_id: str, processor_name: str):
if component_id in self._state.components:
component = self._state.components[component_id]
component.properties = {"processor_name": processor_name}
self._db.save_state(self._key, self._state)
return self.refresh_properties()
undo_redo_attrs = UndoRedoAttrs(f"Set Processor for {component.title}", on_undo=self.refresh_state)
self._db.save_state(self._key, self._state, undo_redo_attrs)
return self.properties.refresh(mode="form"), self._undo_redo.refresh()
def play_workflow(self, boundaries: dict):
self._error_message = None
@@ -236,7 +250,7 @@ class WorkflowDesigner(BaseComponent):
elif event_name == "OnJiraRequestTypeChanged":
component.properties["request_type"] = details["request_type"]
return self.refresh_properties()
return self.properties.refresh(mode="form")
def get_workflow_name(self):
return self._designer_settings.workflow_name
@@ -247,12 +261,13 @@ class WorkflowDesigner(BaseComponent):
def get_workflow_connections(self):
return self._state.connections
def __ft__(self):
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(),
self._mk_error_message(),
cls="flex mb-2",
id=f"t_{self._id}"
@@ -263,6 +278,7 @@ class WorkflowDesigner(BaseComponent):
Script(f"bindWorkflowDesigner('{self._id}');"),
**apply_boundaries(self._boundaries),
id=f"{self._id}",
hx_swap_oob='true' if oob else None,
)
def _mk_connection_svg(self, conn: Connection):
@@ -340,6 +356,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],
@@ -384,6 +405,9 @@ class WorkflowDesigner(BaseComponent):
cls=f"media-controls flex m-2"
)
def _mk_refresh_button(self):
return mk_icon(icon_refresh, **self.commands.refresh())
def _mk_error_message(self):
if not self._error_message:
return Div()
@@ -408,6 +432,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(
@@ -441,11 +476,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}",
),
@@ -453,13 +509,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):
@@ -468,7 +518,7 @@ class WorkflowDesigner(BaseComponent):
selected="selected" if name.value == request_type else None)
def _mk_input_group():
if request_type == JiraRequestTypes.Issues.value:
if request_type == JiraRequestTypes.Search.value:
return Div(
Input(type="text",
name="request",
@@ -488,7 +538,7 @@ class WorkflowDesigner(BaseComponent):
)
def _mk_extra_parameters():
if request_type == JiraRequestTypes.Issues.value:
if request_type == JiraRequestTypes.Search.value:
return Input(type="text",
name="fields",
value=component.properties.get("fields", DEFAULT_SEARCH_FIELDS),
@@ -497,7 +547,7 @@ class WorkflowDesigner(BaseComponent):
else:
return None
request_type = component.properties.get("request_type", JiraRequestTypes.Issues.value)
request_type = component.properties.get("request_type", JiraRequestTypes.Search.value)
return Div(
Fieldset(
Legend("JQL", cls="fieldset-legend"),

View File

@@ -0,0 +1,345 @@
from fasthtml.common import *
from dataclasses import dataclass
from components.BaseComponent import BaseComponent
from components.workflows.constants import COMPONENT_TYPES, PROCESSOR_TYPES
from components_helpers import mk_dialog_buttons
from core.jira import JiraRequestTypes, DEFAULT_SEARCH_FIELDS
from utils.DbManagementHelper import DbManagementHelper
@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 = None
self._component = None
self.update_layout()
self.update_component(self._owner.get_state().selected_component_id)
def update_layout(self):
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
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
self.layout = DesignerLayout(
input_width=input_width,
properties_width=properties_width,
output_width=output_width
)
def update_component(self, component_id):
if component_id is None or component_id not in self._owner.get_state().components:
self._component = None
else:
self._component = self._owner.get_state().components[component_id]
def refresh(self, mode="all", oob=False):
self.update_component(self._owner.get_state().selected_component_id)
if mode == "form":
return self._mk_content(oob=oob)
return self.__ft__(oob=oob)
def _mk_layout(self):
return Div(
self._mk_input(),
self._mk_properties(),
self._mk_output(),
cls="flex",
style="height: 100%; width: 100%; flex: 1;"
)
def _mk_input(self):
return Div(
"Input",
id=f"pi_{self._id}",
style=f"width: {self.layout.input_width}px;",
cls="wkf-properties-input"
)
def _mk_output(self):
return Div(
"Output",
id=f"po_{self._id}",
style=f"width: {self.layout.output_width}px;",
cls="wkf-properties-output"
)
def _mk_properties(self):
return Div(
# Drag handle (20px height)
Div(
A(cls="wkf-properties-handle-top"),
cls="wkf-properties-top",
id=f"ppt_{self._id}",
),
# Properties content
self._mk_content(),
# Left resize handle
Div(
id=f"ppl_{self._id}",
cls="wkf-properties-handle-left"
),
# Right resize handle
Div(
id=f"ppr_{self._id}",
cls="wkf-properties-handle-right"
),
id=f"pp_{self._id}",
style=f"width: {self.layout.properties_width}px; height: 100%;",
cls="wkf-properties-properties flex flex-col",
)
def _mk_content(self, oob=False):
return Div(
self._header(),
self._form(),
cls="wkf-properties-content",
id=f"ppc_{self._id}",
hx_swap_oob=f'true' if oob else None,
)
def _header(self):
if self._component is None:
return None
icon = COMPONENT_TYPES[self._component.type]["icon"]
color = COMPONENT_TYPES[self._component.type]["color"]
return Div(
Div(
Span(icon),
H4(self._component.title, cls="font-semibold text-xs"),
cls=f"rounded-lg border-2 {color} flex text-center px-2"
),
Div(self._component.id, cls="ml-2"),
cls="flex wkf-properties-content-header",
)
def _form(self):
if self._component is None:
return None
component_id = self._component.id
return Form(
Div(
self._mk_select_processor(),
self._content_details(),
style="flex-grow: 1; overflow-y: auto;"
),
mk_dialog_buttons(cls="pb-2",
on_ok=self._commands.save_properties(component_id),
on_cancel=self._commands.cancel_properties(component_id)
),
id=f"ppf_{self._id}",
cls="wkf-properties-content-form",
)
def _mk_select_processor(self):
selected_processor_name = self._component.properties.get("processor_name", None)
return Select(
*[Option(processor_name, selected="selected" if processor_name == selected_processor_name else None)
for processor_name in PROCESSOR_TYPES[self._component.type]],
cls="select select-sm m-2",
id="processor_name",
name="processor_name",
**self._commands.select_processor(self._component.id)
)
def _content_details(self):
component_type = self._component.type
processor_name = self._component.properties.get("processor_name", None)
key = f"_mk_details_{component_type}_{processor_name}".lower()
if hasattr(self, key):
return getattr(self, key)()
else:
return Div(f"Component '{key}' not found")
def _mk_details_producer_jira(self):
def _mk_option(name):
"""
Generic helper to create options
:param name:
:return:
"""
return Option(name.name,
value=name.value,
selected="selected" if name.value == request_type else None)
def _mk_input_group():
if request_type == JiraRequestTypes.Search.value or request_type == "issues": # remove issues at some point
return [
Div(
Input(type="text",
name=f"{request_type}_fields",
value=self._component.properties.get(f"{request_type}_fields", DEFAULT_SEARCH_FIELDS),
placeholder="default fields",
cls="input w-full"),
P("Jira fields to retrieve"),
),
Div(
Input(type="text",
name=f"{request_type}_request",
value=self._component.properties.get(f"{request_type}_request", ""),
placeholder="Enter JQL",
cls="input w-full"),
P("Write your jql code"),
)
]
elif request_type in (JiraRequestTypes.Issue.value, JiraRequestTypes.Comments.value):
return [
Div(
Input(type="text",
name=f"{request_type}_request",
value=self._component.properties.get(f"{request_type}_request", ""),
placeholder="Issue id",
cls="input w-full"),
P("Put the issue id here"),
)
]
elif request_type == JiraRequestTypes.Versions.value:
return [
Div(
Input(type="text",
name=f"{request_type}_request",
value=self._component.properties.get(f"{request_type}_request", ""),
placeholder="Project key",
cls="input w-full"),
P("Enter the project key"),
)
]
else:
return [Div(f"** Not Implemented ** ('{request_type}' not supported yet)")]
request_type = self._component.properties.get("request_type", JiraRequestTypes.Search.value)
return Div(
Fieldset(
Legend("Jira", cls="fieldset-legend"),
Div(
Select(
*[_mk_option(enum) for enum in JiraRequestTypes],
cls="select w-xs",
name="request_type",
**self._commands.on_processor_details_event(self._component.id, "OnJiraRequestTypeChanged"),
),
P("Jira ressource type"),
cls="mb-4"
),
*_mk_input_group(),
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
),
)
def _mk_details_producer_repository(self):
selected_repo = self._component.properties.get("repository", None)
selected_table = self._component.properties.get("table", None)
def _mk_repositories_options():
repositories = DbManagementHelper.list_repositories(self._session)
if len(repositories) == 0:
return [Option("No repository available", disabled=True)]
return ([Option("Choose a repository", disabled=True, selected="selected" if selected_repo is None else None)] +
[Option(repo.name, selected="selected" if repo.name == selected_repo else None)
for repo in DbManagementHelper.list_repositories(self._session)])
def _mk_tables_options():
if selected_repo is None:
return [Option("No repository selected", disabled=True, selected="selected")]
tables = DbManagementHelper.list_tables(self._session, selected_repo)
if len(tables) == 0:
return [Option("No table available", disabled=True)]
return ([Option("Choose a table", disabled=True, selected="selected" if selected_table is None else None)] +
[Option(table, selected="selected" if table == selected_table else None)
for table in DbManagementHelper.list_tables(self._session, selected_repo)])
return Div(
Fieldset(
Legend("Repository", cls="fieldset-legend"),
Div(
Select(
*_mk_repositories_options(),
cls="select w-64",
id=f"repository_{self._id}",
name="repository",
**self._commands.on_processor_details_event(self._component.id, "OnRepositoryChanged"),
),
P("Select the repository"),
),
Div(
Select(
*_mk_tables_options(),
cls="select w-64",
id=f"table_{self._id}",
name="table",
),
P("Select the table"),
),
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
)
)
def _mk_details_filter_default(self):
return Div(
Fieldset(
Legend("Filter", cls="fieldset-legend"),
Input(type="text",
name="filter",
value=self._component.properties.get("filter", ""),
placeholder="Filter expression",
cls="input w-full"),
P("Filter expression"),
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
)
)
def _mk_details_presenter_default(self):
return Div(
Fieldset(
Legend("Presenter", cls="fieldset-legend"),
Input(type="text",
name="columns",
value=self._component.properties.get("columns", ""),
placeholder="Columns to display, separated by comma",
cls="input w-full"),
P("Comma separated list of columns to display. Use '*' to display all columns, 'source=dest' to rename columns."),
P("Use 'parent.*=*' to display all columns from object 'parent' and rename them removing the 'parent' prefix."),
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
)
)
def __ft__(self, oob=False):
# return self.render()
return Div(
self._mk_layout(),
style=f"height: {self._get_height()}px;",
id=f"p_{self._id}",
hx_swap_oob=f'innerHTML' if oob else None,
cls="wkf-properties"
)
def _get_height(self):
return self._boundaries["height"] - self._owner.get_state().designer_height - 86

View File

@@ -191,13 +191,14 @@ class WorkflowPlayer(BaseComponent):
component.properties["repository"],
component.properties["table"]))
elif key == (ProcessorTypes.Producer, "Jira"):
request_type = component.properties["request_type"]
engine.add_processor(
JiraDataProducer(self._session,
self._settings_manager,
component.id,
component.properties["request_type"],
component.properties["request"],
component.properties["fields"]))
component.properties[f"{request_type}_request"],
component.properties.get(f"{request_type}_fields", None)))
elif key == (ProcessorTypes.Filter, "Default"):
engine.add_processor(DefaultDataFilter(component.id, component.properties["filter"]))
elif key == (ProcessorTypes.Presenter, "Default"):

View File

@@ -6,11 +6,39 @@ WORKFLOW_DESIGNER_DB_ENTRY = "WorkflowDesigner"
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY = "Settings"
WORKFLOW_DESIGNER_DB_STATE_ENTRY = "State"
class ProcessorTypes:
Producer = "producer"
Filter = "filter"
Presenter = "presenter"
COMPONENT_TYPES = {
ProcessorTypes.Producer: {
"title": "Data Producer",
"description": "Generates or loads data",
"icon": "📊",
"color": "bg-green-100 border-green-300 text-neutral"
},
ProcessorTypes.Filter: {
"title": "Data Filter",
"description": "Filters and transforms data",
"icon": "🔍",
"color": "bg-blue-100 border-blue-300 text-neutral"
},
ProcessorTypes.Presenter: {
"title": "Data Presenter",
"description": "Displays or exports data",
"icon": "📋",
"color": "bg-purple-100 border-purple-300 text-neutral"
}
}
PROCESSOR_TYPES = {
ProcessorTypes.Producer: ["Repository", "Jira"],
ProcessorTypes.Filter: ["Default"],
ProcessorTypes.Presenter: ["Default"]}
ROUTE_ROOT = "/workflows"
@@ -25,6 +53,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"
@@ -32,4 +61,4 @@ class Routes:
PlayWorkflow = "/play-workflow"
PauseWorkflow = "/pause-workflow"
StopWorkflow = "/stop-workflow"
Refresh = "/refresh"

View File

@@ -2,10 +2,12 @@ import enum
import logging
from dataclasses import dataclass, field
from components.undo_redo.constants import UndoRedoAttrs
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.utils import make_safe_id
from utils.ComponentsInstancesHelper import ComponentsInstancesHelper
logger = logging.getLogger("WorkflowsSettings")
@@ -57,9 +59,12 @@ class WorkflowsDesignerSettings:
class WorkflowsDesignerState:
components: dict[str, WorkflowComponent] = field(default_factory=dict)
connections: list[Connection] = field(default_factory=list)
component_counter = 0
designer_height = 230
selected_component_id = None
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
@dataclass
@@ -158,6 +163,7 @@ class WorkflowsDesignerDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager):
self._session = session
self._settings_manager = settings_manager
self._undo_redo = ComponentsInstancesHelper.get_undo_redo(session)
@staticmethod
def _get_db_entry(key):
@@ -169,11 +175,17 @@ class WorkflowsDesignerDbManager:
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY,
settings)
def save_state(self, key: str, state: WorkflowsDesignerState):
def save_state(self, key: str, state: WorkflowsDesignerState, undo_redo_attrs: UndoRedoAttrs = None):
db_entry = self._get_db_entry(key)
self._settings_manager.put(self._session,
self._get_db_entry(key),
db_entry,
WORKFLOW_DESIGNER_DB_STATE_ENTRY,
state)
if undo_redo_attrs is not None:
self._undo_redo.snapshot(undo_redo_attrs,
db_entry,
WORKFLOW_DESIGNER_DB_STATE_ENTRY)
def save_all(self, key: str, settings: WorkflowsDesignerSettings = None, state: WorkflowsDesignerState = None):
items = {}

View File

@@ -271,6 +271,42 @@ class DbEngine:
except KeyError:
raise DbException(f"Key '{key}' not found in entry '{entry}'")
def history(self, user_id, entry, digest=None, max_items=1000):
"""
Gives the current digest and all its ancestors
:param user_id:
:param entry:
:param digest:
:param max_items:
:return:
"""
with self.lock:
logger.info(f"History for {user_id=}, {entry=}, {digest=}")
digest_to_use = digest or self._get_entry_digest(user_id, entry)
logger.debug(f"Using digest {digest_to_use}.")
count = 0
history = []
while True:
if count >= max_items or digest_to_use is None:
break
history.append(digest_to_use)
count += 1
try:
target_file = self._get_obj_path(user_id, digest_to_use)
with open(target_file, 'r', encoding='utf-8') as file:
as_dict = json.load(file)
digest_to_use = as_dict[TAG_PARENT][0]
except FileNotFoundError:
break
return history
def debug_root(self):
"""
Lists all folders in the root directory
@@ -312,7 +348,7 @@ class DbEngine:
return []
return [f for f in os.listdir(self.root) if os.path.isdir(os.path.join(self.root, f)) and f != 'refs']
def debug_get_digest(self, user_id, entry):
def get_digest(self, user_id, entry):
return self._get_entry_digest(user_id, entry)
def _serialize(self, obj):

View File

@@ -10,7 +10,7 @@ from core.Expando import Expando
JIRA_ROOT = "https://altares.atlassian.net/rest/api/3"
DEFAULT_HEADERS = {"Accept": "application/json"}
DEFAULT_SEARCH_FIELDS = "summary,status,assignee"
logger = logging.getLogger("jql")
logger = logging.getLogger("Jira")
class NotFound(Exception):
@@ -18,8 +18,10 @@ class NotFound(Exception):
class JiraRequestTypes(Enum):
Issues = "issues"
Search = "search"
Issue = "issue"
Comments = "comments"
Versions = "versions"
class Jira:
@@ -41,7 +43,10 @@ class Jira:
self.fields = fields
def test(self):
logger.debug(f"test with no parameters")
url = f"{JIRA_ROOT}/myself"
logger.debug(f" url: {url}")
response = requests.request(
"GET",
@@ -49,16 +54,21 @@ class Jira:
headers=DEFAULT_HEADERS,
auth=self.auth
)
logger.debug(f" response: {response}")
logger.debug(f" response.text: {response.text}")
return response
def issue(self, issue_id: str) -> Expando:
def issue(self, issue_id: str) -> list[Expando]:
"""
Retrieve an issue
:param issue_id:
:return:
"""
logger.debug(f"comments with {issue_id=}")
url = f"{JIRA_ROOT}/issue/{issue_id}"
logger.debug(f" url: {url}")
response = requests.request(
"GET",
@@ -66,8 +76,10 @@ class Jira:
headers=DEFAULT_HEADERS,
auth=self.auth
)
logger.debug(f" response: {response}")
logger.debug(f" response.text: {response.text}")
return Expando(json.loads(response.text))
return [Expando(json.loads(response.text))]
def fields(self) -> list[Expando]:
"""
@@ -86,14 +98,14 @@ class Jira:
as_dict = json.loads(response.text)
return [Expando(field) for field in as_dict]
def issues(self, jql: str, fields=None) -> list[Expando]:
def search(self, jql: str, fields=None) -> list[Expando]:
"""
Executes a JQL and returns the list of issues
:param jql:
:param fields: list of fields to retrieve
:return:
"""
logger.debug(f"Processing jql '{jql}'")
logger.debug(f"search with {jql=}, {fields=}")
if not jql:
raise ValueError("Jql cannot be empty.")
@@ -102,6 +114,7 @@ class Jira:
fields = self.fields
url = f"{JIRA_ROOT}/search"
logger.debug(f" url: {url}")
headers = DEFAULT_HEADERS.copy()
headers["Content-Type"] = "application/json"
@@ -113,15 +126,19 @@ class Jira:
"maxResults": 500, # Does not seem to be used. It's always 100 !
"startAt": 0
}
logger.debug(f" payload: {payload}")
result = []
while True:
logger.debug(f"Request startAt '{payload['startAt']}'")
logger.debug(f" Request startAt '{payload['startAt']}'")
response = requests.request("POST",
url,
data=json.dumps(payload),
headers=headers,
auth=self.auth)
logger.debug(f" response: {response}")
logger.debug(f" response.text: {response.text}")
if response.status_code != 200:
raise Exception(self._format_error(response))
@@ -130,6 +147,7 @@ class Jira:
result += as_dict["issues"]
if as_dict["startAt"] + as_dict["maxResults"] >= as_dict["total"]:
logger.debug(f" response: {response}")
# We retrieve more than the total nuber of items
break
@@ -143,12 +161,18 @@ class Jira:
:param issue_id:
:return:
"""
logger.debug(f"comments with {issue_id=}")
url = f"{JIRA_ROOT}/issue/{issue_id}/comment"
logger.debug(f" url: {url}")
response = requests.request("GET",
url,
headers=DEFAULT_HEADERS,
auth=self.auth)
logger.debug(f" response: {response}")
logger.debug(f" response.text: {response.text}")
if response.status_code != 200:
raise Exception(self._format_error(response))
@@ -156,6 +180,34 @@ class Jira:
result = as_dict["comments"]
return [Expando(issue) for issue in result]
def versions(self, project_key):
"""
Given a project name and a version name
returns fixVersion number in JIRA
:param project_key:
:return:
"""
logger.debug(f"versions with {project_key=}")
url = f"{JIRA_ROOT}/project/{project_key}/versions"
logger.debug(f" url: {url}")
response = requests.request(
"GET",
url,
headers=DEFAULT_HEADERS,
auth=self.auth
)
logger.debug(f" response: {response}")
logger.debug(f" response.text: {response.text}")
if response.status_code != 200:
raise NotFound()
as_list = json.loads(response.text)
return [Expando(version) for version in as_list]
def extract(self, jql, mappings, updates=None) -> list[dict]:
"""
Executes a jql and returns list of dict
@@ -188,30 +240,6 @@ class Jira:
row = {cvs_col: issue.get(jira_path) for jira_path, cvs_col in mappings.items() if cvs_col is not None}
yield row
def get_versions(self, project_key):
"""
Given a project name and a version name
returns fixVersion number in JIRA
:param project_key:
:param version_name:
:return:
"""
url = f"{JIRA_ROOT}/project/{project_key}/versions"
response = requests.request(
"GET",
url,
headers=DEFAULT_HEADERS,
auth=self.auth
)
if response.status_code != 200:
raise NotFound()
as_list = json.loads(response.text)
return [Expando(version) for version in as_list]
def get_version(self, project_key, version_name):
"""
Given a project name and a version name
@@ -221,7 +249,7 @@ class Jira:
:return:
"""
for version in self.get_versions(project_key):
for version in self.versions(project_key):
if version.name == version_name:
return version

189
src/core/preprocessor.py Normal file
View File

@@ -0,0 +1,189 @@
from arpeggio import RegExMatch, ZeroOrMore, OneOrMore, ParserPython, EOF, NoMatch
class VariableParsingError(Exception):
"""Custom exception for variable parsing errors"""
def __init__(self, message, position):
self.message = message
self.position = position
super().__init__(f"Variable parsing error at position {position}: {message}")
class VariableProcessingError(Exception):
"""Custom exception for variable parsing errors"""
def __init__(self, message, position):
self.message = message
self.position = position
super().__init__(f"Variable processing error at position {position}: {message}")
def variable_name():
"""Variable name: alphanumeric characters and underscores"""
return RegExMatch(r'[a-zA-Z_][a-zA-Z0-9_]*')
def property_name():
"""Property name: same rules as variable name"""
return RegExMatch(r'[a-zA-Z_][a-zA-Z0-9_]*')
def variable_property():
"""A property access: .property_name"""
return ".", property_name
def variable():
"""A complete variable: $variable_name(.property)*"""
return "$", variable_name, ZeroOrMore(variable_property)
def text_char():
"""Any character that is not the start of a variable"""
return RegExMatch(r'[^$]')
def text_segment():
"""One or more non-variable characters"""
return OneOrMore(text_char)
def element():
"""Either a variable or a text segment"""
return [variable, text_segment]
def expression():
"""Complete expression: sequence of elements"""
return ZeroOrMore(element), EOF
class PlainTextPreprocessor:
def __init__(self):
self.parser = ParserPython(expression, debug=False, skipws=False)
@staticmethod
def _post_validation(elements):
if len(elements) < 2:
return
for element, next_element in [(element, elements[i + 1]) for i, element in enumerate(elements[:-1])]:
if element['type'] == 'variable' and next_element['type'] == 'variable':
raise VariableParsingError("Invalid syntax.", next_element['start'])
@staticmethod
def _extract_elements_from_tree(parse_tree, original_text):
"""Extract elements with positions from the parse tree"""
elements = []
def process_node(node, current_pos=0):
nonlocal elements
if hasattr(node, 'rule_name'):
if node.rule_name == 'variable':
# Extract variable information
var_start = node.position
var_end = node.position_end
var_text = original_text[var_start:var_end]
parts = var_text[1:].split('.') # Remove $ and split by .
var_name = parts[0]
properties = parts[1:] if len(parts) > 1 else []
elements.append({
"type": "variable",
"name": var_name,
"properties": properties,
"start": var_start,
"end": var_end
})
elif node.rule_name == 'text_segment':
# Extract text segment
text_start = node.position
text_end = node.position_end
content = original_text[text_start:text_end]
stripped = content.strip()
if len(stripped) > 0 and stripped[0] == '.':
raise VariableParsingError("Invalid syntax in property name.", text_start)
elements.append({
"type": "text",
"content": content,
"start": text_start,
"end": text_end
})
elif node.rule_name in ('expression', 'element'):
for child in node:
process_node(child, current_pos)
# Process children
if hasattr(node, '_tx_children') and node._tx_children:
for child in node._tx_children:
process_node(child, current_pos)
process_node(parse_tree)
return elements
def parse(self, text):
"""
Parse text and return structure with text segments and variables with positions
Returns:
[
{"type": "text", "content": "...", "start": int, "end": int},
{"type": "variable", "name": "...", "properties": [...], "start": int, "end": int}
]
"""
if not text:
return []
try:
# Parse the text
parse_tree = self.parser.parse(text)
# Extract elements from parse tree
elements = self._extract_elements_from_tree(parse_tree, text)
# Extra validations
self._post_validation(elements)
# Sort elements by start position
elements.sort(key=lambda x: x['start'])
return elements
except NoMatch as e:
# Convert Arpeggio parsing errors to our custom error
raise VariableParsingError(f"Invalid syntax", e.position)
except Exception as e:
if isinstance(e, VariableParsingError):
raise
raise VariableParsingError(f"Parsing failed: {str(e)}", 0)
def preprocess(self, text, namepace):
result = ""
elements = self.parse(text)
for element in elements:
if element['type'] == 'text':
result += element['content']
elif element['type'] == 'variable':
value = namepace.get(element['name'])
if value is None:
raise VariableProcessingError(f"Variable '{element['name']}' is not defined.", element['start'])
try:
pos = element['start'] + len(element['name']) + 1 # +1 for the starting '$'
for property_name in element['properties']:
value = getattr(value, property_name)
pos += len(property_name) + 1 # +1 for the dot '.'
except AttributeError as e:
raise VariableProcessingError(f"Invalid property '{property_name}' for variable '{element['name']}'.",
pos) from e
result += str(value)
return result

View File

@@ -98,10 +98,10 @@ class SettingsManager:
user_id, user_email = self._get_user(session)
return self._db_engine.save(user_id, user_email, entry, obj)
def load(self, session: dict, entry: str, default=NoDefault):
def load(self, session: dict, entry: str, digest=None, default=NoDefault):
user_id, _ = self._get_user(session)
try:
return self._db_engine.load(user_id, entry)
return self._db_engine.load(user_id, entry, digest)
except DbException:
return default
@@ -128,6 +128,14 @@ class SettingsManager:
return self._db_engine.exists(user_id, entry)
def get_digest(self, session: dict, entry: str):
user_id, _ = self._get_user(session)
return self._db_engine.get_digest(user_id, entry)
def history(self, session, entry, digest=None, max_items=1000):
user_id, _ = self._get_user(session)
return self._db_engine.history(user_id, entry, digest, max_items)
def get_db_engine(self):
return self._db_engine
@@ -177,7 +185,7 @@ class GenericDbManager:
if key.startswith("_"):
super().__setattr__(key, value)
settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
settings = self._settings_manager.load(self._session, self._obj_entry, default=self._obj_type())
if not (hasattr(settings, key)):
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{key}'.")
@@ -188,7 +196,7 @@ class GenericDbManager:
if item.startswith("_"):
return super().__getattribute__(item)
settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
settings = self._settings_manager.load(self._session, self._obj_entry, default=self._obj_type())
if not (hasattr(settings, item)):
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{item}'.")
@@ -250,7 +258,7 @@ class NestedSettingsManager:
self._settings_manager.save(self._session, self._obj_entry, settings)
def _get_settings_and_object(self):
settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
settings = self._settings_manager.load(self._session, self._obj_entry, default=self._obj_type())
if not hasattr(settings, self._obj_attribute):
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{self._obj_attribute}'.")

View File

@@ -47,4 +47,9 @@ loggers:
AddStuffApp:
level: INFO
handlers: [ console ]
propagate: False
Jira:
level: DEBUG
handlers: [ console ]
propagate: False

View File

@@ -1,10 +1,12 @@
import ast
import logging
from abc import ABC, abstractmethod
from typing import Any, Generator
from components.admin.admin_db_manager import AdminDbManager
from core.Expando import Expando
from core.jira import Jira
from core.jira import Jira, JiraRequestTypes
from core.preprocessor import PlainTextPreprocessor
from core.utils import UnreferencedNamesVisitor
from utils.Datahelper import DataHelper
@@ -92,21 +94,32 @@ class TableDataProducer(DataProducer):
class JiraDataProducer(DataProducer):
"""Base class for data producers that emit data from Jira."""
def __init__(self, session, settings_manager, component_id, request_type='issues', request='', fields=None):
logger = logging.getLogger("DataProcessor.Producer.Jira")
def __init__(self, session, settings_manager, component_id, request_type='search', request='', fields=None):
super().__init__(component_id)
self._session = session
self.settings_manager = settings_manager
self.request_type = request_type
self.request_type = request_type.value if isinstance(request_type, JiraRequestTypes) else request_type
self.request = request
self.fields = fields
self.db = AdminDbManager(session, settings_manager).jira
def emit(self, data: Any = None) -> Generator[Any, None, None]:
jira = Jira(self.db.user_name, self.db.api_token, fields=self.fields)
self.logger.debug(f"Emitting data from Jira: {self.request_type} {self.request} {self.fields}")
preprocessor = PlainTextPreprocessor()
preprocessed_fields = preprocessor.preprocess(self.fields, {"data": data})
self.logger.debug(f" {preprocessed_fields=}")
jira = Jira(self.db.user_name, self.db.api_token, fields=preprocessed_fields)
if not hasattr(jira, self.request_type):
raise ValueError(f"Invalid request type: {self.request_type}")
yield from getattr(jira, self.request_type)(self.request)
preprocessed_request = preprocessor.preprocess(self.request, {"data": data})
self.logger.debug(f" {preprocessed_request=}")
yield from getattr(jira, self.request_type)(preprocessed_request)
class DefaultDataFilter(DataFilter):

View File

@@ -235,3 +235,40 @@ def test_put_many_save_only_if_necessary(engine):
entry_content = engine.load(FAKE_USER_ID, "MyEntry")
assert entry_content[TAG_PARENT] == [None] # Still None, nothing was save
def test_i_can_retrieve_history_using_put(engine):
engine.put(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", "key1", DummyObj(1, "a", False))
engine.put(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", "key1", DummyObj(2, "a", False))
engine.put(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", "key1", DummyObj(3, "a", False))
history = engine.history(FAKE_USER_ID, "MyEntry")
assert len(history) == 3
v0 = engine.load(FAKE_USER_ID, "MyEntry", history[0])
v1 = engine.load(FAKE_USER_ID, "MyEntry", history[1])
v2 = engine.load(FAKE_USER_ID, "MyEntry", history[2])
assert v0["key1"] == DummyObj(3, "a", False)
assert v1["key1"] == DummyObj(2, "a", False)
assert v2["key1"] == DummyObj(1, "a", False)
assert v2[TAG_PARENT] == [None]
def test_i_can_retrieve_history_using_save(engine):
engine.save(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", {"key1" : DummyObj(1, "a", False)})
engine.save(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", {"key1" : DummyObj(2, "a", False)})
engine.save(FAKE_USER_ID, FAKE_USER_EMAIL, "MyEntry", {"key1" : DummyObj(3, "a", False)})
history = engine.history(FAKE_USER_ID, "MyEntry")
assert len(history) == 3
v0 = engine.load(FAKE_USER_ID, "MyEntry", history[0])
v1 = engine.load(FAKE_USER_ID, "MyEntry", history[1])
v2 = engine.load(FAKE_USER_ID, "MyEntry", history[2])
assert v0["key1"] == DummyObj(3, "a", False)
assert v1["key1"] == DummyObj(2, "a", False)
assert v2["key1"] == DummyObj(1, "a", False)
assert v2[TAG_PARENT] == [None]

491
tests/test_preprocessor.py Normal file
View File

@@ -0,0 +1,491 @@
import pytest
from core.preprocessor import PlainTextPreprocessor, VariableParsingError, VariableProcessingError
def test_i_can_parse_empty_text():
"""Test that I can parse empty text input"""
processor = PlainTextPreprocessor()
result = processor.parse("")
assert result == []
def test_i_can_parse_text_without_variables():
"""Test that I can parse text without any variables"""
processor = PlainTextPreprocessor()
text = "This is just plain text with no variables"
result = processor.parse(text)
expected = [{
"type": "text",
"content": text,
"start": 0,
"end": len(text)
}]
assert result == expected
def test_i_can_parse_simple_variable():
"""Test that I can parse text with only a simple variable"""
processor = PlainTextPreprocessor()
text = "$variable"
result = processor.parse(text)
expected = [{
"type": "variable",
"name": "variable",
"properties": [],
"start": 0,
"end": 9
}]
assert result == expected
def test_i_can_parse_variable_with_underscores():
"""Test that I can parse variable with underscores in name"""
processor = PlainTextPreprocessor()
text = "$my_variable_name"
result = processor.parse(text)
expected = [{
"type": "variable",
"name": "my_variable_name",
"properties": [],
"start": 0,
"end": 17
}]
assert result == expected
def test_i_can_parse_variable_with_numbers():
"""Test that I can parse variable with numbers in name"""
processor = PlainTextPreprocessor()
text = "$var123"
result = processor.parse(text)
expected = [{
"type": "variable",
"name": "var123",
"properties": [],
"start": 0,
"end": 7
}]
assert result == expected
def test_i_can_parse_properties_with_underscores_and_numbers():
"""Test that I can parse property names with underscores and numbers"""
processor = PlainTextPreprocessor()
text = "$var._prop123.sub_prop_456"
result = processor.parse(text)
expected = [{
"type": "variable",
"name": "var",
"properties": ["_prop123", "sub_prop_456"],
"start": 0,
"end": 26
}]
assert result == expected
def test_i_can_parse_variable_starting_with_underscore():
"""Test that I can parse variable name starting with underscore"""
processor = PlainTextPreprocessor()
text = "$_private_var"
result = processor.parse(text)
expected = [
{
"type": "variable",
"name": "_private_var",
"properties": [],
"start": 0,
"end": 13
}
]
assert result == expected
def test_i_can_parse_variable_with_single_property():
"""Test that I can parse variable with one property"""
processor = PlainTextPreprocessor()
text = "$variable.prop"
result = processor.parse(text)
expected = [{
"type": "variable",
"name": "variable",
"properties": ["prop"],
"start": 0,
"end": 14
}]
assert result == expected
def test_i_can_parse_variable_with_multiple_properties():
"""Test that I can parse variable with multiple properties"""
processor = PlainTextPreprocessor()
text = "$variable.prop.subprop.deep"
result = processor.parse(text)
expected = [{
"type": "variable",
"name": "variable",
"properties": ["prop", "subprop", "deep"],
"start": 0,
"end": 27
}]
assert result == expected
def test_i_can_parse_text_with_variable_in_middle():
"""Test that I can parse text with variable in the middle"""
processor = PlainTextPreprocessor()
text = "project > $project_id and more"
result = processor.parse(text)
expected = [
{
"type": "text",
"content": "project > ",
"start": 0,
"end": 10
},
{
"type": "variable",
"name": "project_id",
"properties": [],
"start": 10,
"end": 21
},
{
"type": "text",
"content": " and more",
"start": 21,
"end": 30
}
]
assert result == expected
def test_i_can_parse_multiple_variables():
"""Test that I can parse text with multiple variables"""
processor = PlainTextPreprocessor()
text = "value == $variable.prop and $other_var"
result = processor.parse(text)
expected = [
{
"type": "text",
"content": "value == ",
"start": 0,
"end": 9
},
{
"type": "variable",
"name": "variable",
"properties": ["prop"],
"start": 9,
"end": 23
},
{
"type": "text",
"content": " and ",
"start": 23,
"end": 28
},
{
"type": "variable",
"name": "other_var",
"properties": [],
"start": 28,
"end": 38
}
]
assert result == expected
def test_i_can_preserve_all_whitespace():
"""Test that I can preserve all whitespace including tabs and newlines"""
processor = PlainTextPreprocessor()
text = " $var \t\n $other.prop "
result = processor.parse(text)
expected = [
{
"type": "text",
"content": " ",
"start": 0,
"end": 2
},
{
"type": "variable",
"name": "var",
"properties": [],
"start": 2,
"end": 6
},
{
"type": "text",
"content": " \t\n ",
"start": 6,
"end": 12
},
{
"type": "variable",
"name": "other",
"properties": ["prop"],
"start": 12,
"end": 23
},
{
"type": "text",
"content": " ",
"start": 23,
"end": 25
}
]
assert result == expected
def test_i_can_parse_text_with_special_characters():
"""Test that I can parse text with special characters"""
processor = PlainTextPreprocessor()
text = "Hello $user! @#%^&*()+={}[]|\\:;\"'<>?,./~`"
result = processor.parse(text)
expected = [
{
"type": "text",
"content": "Hello ",
"start": 0,
"end": 6
},
{
"type": "variable",
"name": "user",
"properties": [],
"start": 6,
"end": 11
},
{
"type": "text",
"content": "! @#%^&*()+={}[]|\\:;\"'<>?,./~`",
"start": 11,
"end": 41
}
]
assert result == expected
def test_i_can_parse_complex_expression():
"""Test that I can parse complex but valid expression"""
processor = PlainTextPreprocessor()
text = "if ($user.profile.age > 18 && $user.status == 'active') { $action.execute(); }"
result = processor.parse(text)
# Should parse successfully and find all variables
variables = [elem for elem in result if elem["type"] == "variable"]
assert len(variables) == 3
# Check variable details
assert variables[0]["name"] == "user"
assert variables[0]["properties"] == ["profile", "age"]
assert variables[1]["name"] == "user"
assert variables[1]["properties"] == ["status"]
assert variables[2]["name"] == "action"
assert variables[2]["properties"] == ["execute"]
def test_positions_are_accurate():
"""Test that element positions are accurate"""
processor = PlainTextPreprocessor()
text = "abc$var123*def"
result = processor.parse(text)
assert len(result) == 3
# Text before
assert result[0]["start"] == 0
assert result[0]["end"] == 3
assert result[0]["content"] == "abc"
# Variable
assert result[1]["start"] == 3
assert result[1]["end"] == 10
assert result[1]["name"] == "var123"
# Text after
assert result[2]["start"] == 10
assert result[2]["end"] == 14
assert result[2]["content"] == "*def"
# Error cases
def test_i_cannot_parse_dollar_alone_at_end():
"""Test that I cannot parse $ at the end of text"""
processor = PlainTextPreprocessor()
text = "Hello $"
with pytest.raises(VariableParsingError) as exc_info:
processor.parse(text)
assert exc_info.value.position == 7
assert "Invalid syntax" in str(exc_info.value)
# assert "Variable name missing after '$'" in str(exc_info.value)
def test_i_cannot_parse_dollar_alone_in_middle():
"""Test that I cannot parse $ alone in middle of text"""
processor = PlainTextPreprocessor()
text = "Hello $ world"
with pytest.raises(VariableParsingError) as exc_info:
processor.parse(text)
assert exc_info.value.position == 7
assert "Invalid syntax" in str(exc_info.value)
def test_i_cannot_parse_dot_immediately_after_dollar():
"""Test that I cannot parse $.property (dot immediately after $)"""
processor = PlainTextPreprocessor()
text = "$.property"
with pytest.raises(VariableParsingError) as exc_info:
processor.parse(text)
assert exc_info.value.position == 1
assert "Invalid syntax" in str(exc_info.value)
# assert "Variable name missing before '.'" in str(exc_info.value)
def test_i_cannot_parse_variable_ending_with_dot():
"""Test that I cannot parse $variable. (dot at the end)"""
processor = PlainTextPreprocessor()
text = "$variable."
with pytest.raises(VariableParsingError) as exc_info:
processor.parse(text)
assert exc_info.value.position == 9
assert "Invalid syntax in property name." in str(exc_info.value)
@pytest.mark.parametrize("text", ["$variable. prop", "$variable .prop", "$variable . prop"])
def test_i_cannot_parse_variable_when_space_in_variable_name(text):
"""Test that I cannot parse $variable. (dot at the end)"""
processor = PlainTextPreprocessor()
# text = "$variable. "
with pytest.raises(VariableParsingError) as exc_info:
processor.parse(text)
assert exc_info.value.position == 9
assert "Invalid syntax in property name." in str(exc_info.value)
def test_i_cannot_parse_variable_with_empty_property():
"""Test that I cannot parse $variable..property (empty property between dots)"""
processor = PlainTextPreprocessor()
text = "$variable..property"
with pytest.raises(VariableParsingError) as exc_info:
processor.parse(text)
assert exc_info.value.position == 9
assert "Invalid syntax in property name." in str(exc_info.value)
def test_i_cannot_parse_variable_ending_with_multiple_dots():
"""Test that I cannot parse $variable... (multiple dots at end)"""
processor = PlainTextPreprocessor()
text = "$variable..."
with pytest.raises(VariableParsingError) as exc_info:
processor.parse(text)
assert exc_info.value.position == 9
assert "Invalid syntax in property name." in str(exc_info.value)
def test_i_cannot_parse_when_consecutive_variables():
"""Test that I can parse consecutive variables without text between"""
processor = PlainTextPreprocessor()
text = "$var1$var2"
with pytest.raises(VariableParsingError) as exc_info:
processor.parse(text)
assert exc_info.value.position == 5
assert "Invalid syntax." in str(exc_info.value)
def test_first_error_is_reported_with_multiple_errors():
"""Test that first error is reported when multiple $ errors exist"""
processor = PlainTextPreprocessor()
text = "$ and $. and $var."
with pytest.raises(VariableParsingError) as exc_info:
processor.parse(text)
# Should report the first error ($ alone)
assert exc_info.value.position == 1
def test_i_can_preprocess_simple_variable():
"""Test preprocessing text with a simple variable"""
processor = PlainTextPreprocessor()
namespace = {"name": "John"}
result = processor.preprocess("Hello $name!", namespace)
assert result == "Hello John!"
def test_i_can_preprocess_with_properties():
"""Test preprocessing text with variable properties"""
class User:
def __init__(self):
self.profile = type('Profile', (), {'age': 25})()
processor = PlainTextPreprocessor()
namespace = {"user": User()}
result = processor.preprocess("Age: $user.profile.age", namespace)
assert result == "Age: 25"
def test_i_can_preprocess_multiple_variables():
"""Test preprocessing text with multiple variables"""
processor = PlainTextPreprocessor()
namespace = {"first": "Hello", "second": "World"}
result = processor.preprocess("$first $second!", namespace)
assert result == "Hello World!"
def test_i_can_preprocess_empty_text():
"""Test preprocessing empty text"""
processor = PlainTextPreprocessor()
namespace = {}
result = processor.preprocess("", namespace)
assert result == ""
def test_i_cannot_preprocess_undefined_variable():
"""Test preprocessing with undefined variable raises error"""
processor = PlainTextPreprocessor()
namespace = {}
with pytest.raises(VariableProcessingError) as exc_info:
processor.preprocess("$undefined_var", namespace)
assert "Variable 'undefined_var' is not defined" in str(exc_info.value)
def test_i_cannot_preprocess_invalid_property():
"""Test preprocessing with invalid property access"""
processor = PlainTextPreprocessor()
namespace = {"obj": object()}
with pytest.raises(VariableProcessingError) as exc_info:
processor.preprocess("some text $obj.invalid_prop", namespace)
assert "Invalid property 'invalid_prop' for variable 'obj'" in str(exc_info.value)
assert exc_info.value.position == 14

View File

@@ -1,33 +1,66 @@
import os
import shutil
import pytest
from fasthtml.components import Div
from components.undo_redo.components.UndoRedo import UndoRedo, CommandHistory
from components.undo_redo.components.UndoRedo import UndoRedo
from components.undo_redo.constants import UndoRedoAttrs
from core.dbengine import DbEngine
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, div_icon, Contains, DoesNotContain
from my_mocks import tabs_manager
DB_ENGINE_ROOT = "undo_redo_test_db"
TEST_DB_ENTRY = "TestDbEntry"
TEST_DB_KEY = "TestDbKey"
class UndoableCommand(CommandHistory):
def __init__(self, old_value=0, new_value=0):
super().__init__("Set new value", lambda value: f"Setting new value to {value}", None)
self.old_value = old_value
self.new_value = new_value
class TestCommand:
def __init__(self, value):
self.value = value
def undo(self):
return Div(self.old_value, hx_swap_oob="true")
def __eq__(self, other):
if not isinstance(other, TestCommand):
return False
return self.value == other.value
def redo(self):
return Div(self.new_value, hx_swap_oob="true")
def __hash__(self):
return hash(self.value)
@pytest.fixture()
def engine(session):
if os.path.exists(DB_ENGINE_ROOT):
shutil.rmtree(DB_ENGINE_ROOT)
engine = DbEngine(DB_ENGINE_ROOT)
engine.init(session["user_id"])
yield engine
shutil.rmtree(DB_ENGINE_ROOT)
@pytest.fixture()
def settings_manager(engine):
return SettingsManager(engine=engine)
@pytest.fixture
def undo_redo(session, tabs_manager):
def undo_redo(session, tabs_manager, settings_manager):
return UndoRedo(session,
UndoRedo.create_component_id(session),
settings_manager=SettingsManager(engine=MemoryDbEngine()),
settings_manager=settings_manager,
tabs_manager=tabs_manager)
def init_command(session, settings_manager, undo_redo, value, on_undo=None):
settings_manager.save(session, TEST_DB_ENTRY, {TEST_DB_KEY: TestCommand(value)})
undo_redo.snapshot(UndoRedoAttrs(f"Set value to {value}", on_undo=on_undo), TEST_DB_ENTRY, TEST_DB_KEY)
def test_i_can_render(undo_redo):
actual = undo_redo.__ft__()
expected = Div(
@@ -39,13 +72,13 @@ def test_i_can_render(undo_redo):
assert matches(actual, expected)
def test_i_can_render_when_undoing_and_redoing(undo_redo):
undo_redo.push(UndoableCommand(0, 1))
undo_redo.push(UndoableCommand(1, 2))
def test_i_can_render_when_undoing_and_redoing(session, settings_manager, undo_redo):
init_command(session, settings_manager, undo_redo, "1")
init_command(session, settings_manager, undo_redo, "2")
actual = undo_redo.__ft__()
expected = Div(
Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set new value'."),
Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set value to 2'."),
Div(div_icon("redo", cls=Contains("mmt-btn-disabled")), data_tooltip="Nothing to redo."),
id=undo_redo.get_id(),
)
@@ -54,8 +87,8 @@ def test_i_can_render_when_undoing_and_redoing(undo_redo):
undo_redo.undo() # The command is now undone. We can redo it and undo the first command.
actual = undo_redo.__ft__()
expected = Div(
Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set new value'."),
Div(div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Redo 'Set new value'."),
Div(div_icon("undo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Undo 'Set value to 1'."),
Div(div_icon("redo", cls=DoesNotContain("mmt-btn-disabled")), data_tooltip="Redo 'Set value to 2'."),
id=undo_redo.get_id(),
)
assert matches(actual, expected)
@@ -88,25 +121,48 @@ def test_i_can_render_when_undoing_and_redoing(undo_redo):
assert matches(actual, expected)
def test_i_can_undo_and_redo(undo_redo):
undo_redo.push(UndoableCommand(0, 1))
undo_redo.push(UndoableCommand(1, 2))
def test_values_are_correctly_reset(session, settings_manager, undo_redo):
# checks that the values are correctly returned
# Only checks that hx_swap_oob="true" is automatically put when id is present in the return
def on_undo():
current = settings_manager.get(session, TEST_DB_ENTRY, TEST_DB_KEY)
return Div(current.value, id='an_id')
init_command(session, settings_manager, undo_redo, "1", on_undo=on_undo)
init_command(session, settings_manager, undo_redo, "2", on_undo=on_undo)
self, res = undo_redo.undo()
expected = Div(1, hx_swap_oob="true")
expected = Div("1", id='an_id', hx_swap_oob="true")
assert matches(res, expected)
self, res = undo_redo.redo()
expected = Div(2, hx_swap_oob="true")
expected = Div("2", id='an_id', hx_swap_oob="true")
assert matches(res, expected)
def test_history_is_rewritten_when_pushing_a_command(undo_redo):
undo_redo.push(UndoableCommand(0, 1))
undo_redo.push(UndoableCommand(1, 2))
undo_redo.push(UndoableCommand(2, 3))
def test_i_can_manage_when_the_entry_was_not_present(session, settings_manager, undo_redo):
def on_undo():
snapshot = settings_manager.load(session, TEST_DB_ENTRY)
if TEST_DB_KEY in snapshot:
return Div(snapshot[TEST_DB_KEY].value, id='an_id')
else:
return Div("**Not Found**", id='an_id')
init_command(session, settings_manager, undo_redo, "1", on_undo=on_undo)
self, res = undo_redo.undo()
expected = Div("**Not Found**", id='an_id', hx_swap_oob="true")
assert matches(res, expected)
def test_history_is_rewritten_when_pushing_a_command_after_undo(session, settings_manager, undo_redo):
init_command(session, settings_manager, undo_redo, "1")
init_command(session, settings_manager, undo_redo, "2")
init_command(session, settings_manager, undo_redo, "3")
undo_redo.undo()
undo_redo.undo()
undo_redo.push(UndoableCommand(1, 5))
init_command(session, settings_manager, undo_redo, "5")
assert len(undo_redo.history) == 2
assert len(undo_redo.history) == 3 # do not forget that history always has a default command with digest = None

View File

@@ -1,11 +1,15 @@
from unittest.mock import MagicMock
import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from fasthtml.xtend import Script
from components.undo_redo.components.UndoRedo import UndoRedo
from components.workflows.components.WorkflowDesigner import WorkflowDesigner, COMPONENT_TYPES
from components.workflows.constants import ProcessorTypes
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowComponent, Connection
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, Contains
from my_mocks import tabs_manager
@@ -13,6 +17,27 @@ from my_mocks import tabs_manager
TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
@pytest.fixture(autouse=True)
def mock_undo_redo(session):
# Create a mock UndoRedo instance
undo_redo = MagicMock(spec=UndoRedo)
# Store original get method
original_get = InstanceManager.get
def mock_get(sess, instance_id, *args, **kwargs):
if instance_id == UndoRedo.create_component_id(sess):
return undo_redo
return original_get(sess, instance_id, *args, **kwargs)
# Replace get method with our mock
InstanceManager.get = mock_get
yield undo_redo
# Restore original get method after test
InstanceManager.get = original_get
@pytest.fixture
def designer(session, tabs_manager):
return WorkflowDesigner(session=session, _id=TEST_WORKFLOW_DESIGNER_ID,
@@ -72,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"),

View File

@@ -0,0 +1,78 @@
import pytest
from unittest.mock import Mock, patch
from core.Expando import Expando
from core.jira import JiraRequestTypes
from core.settings_management import SettingsManager, MemoryDbEngine
from workflow.engine import JiraDataProducer, TableDataProducer
JIRA_IMPORT_PATH = "workflow.engine.Jira"
@pytest.fixture
def mock_jira_search_1():
with patch(JIRA_IMPORT_PATH) as mock_jira_class:
mock_jira_instance = Mock()
mock_jira_instance.search.return_value = [
Expando({
"key": "TEST-1",
"fields": {
"summary": "Test Issue",
"status": {"name": "Open"},
"assignee": {"displayName": "Test User"}
}
})
]
mock_jira_class.return_value = mock_jira_instance
yield mock_jira_instance # This allows us to access the mock instance in our tests
@pytest.fixture
def mock_jira_error():
with patch(JIRA_IMPORT_PATH) as mock_jira_class:
mock_jira_instance = Mock()
mock_jira_instance.search.side_effect = Exception("Jira API Error")
mock_jira_class.return_value = mock_jira_instance
yield mock_jira_instance
def get_jira_patch(jp: JiraDataProducer):
# Create and configure the mock instance
mock_jira_instance = Mock()
if jp.request_type == JiraRequestTypes.Search.value:
mock_jira_instance.search.return_value = [
Expando({
"key": "TEST-1",
"fields": {
"summary": "Test Issue",
"status": {"name": "Open"},
"assignee": {"displayName": "Test User"}
}
})
]
else:
raise ValueError("Unsupported request type")
return patch(JIRA_IMPORT_PATH, return_value=mock_jira_instance)
def jira_producer(session, request_type, request, fields=None):
return JiraDataProducer(session,
SettingsManager(MemoryDbEngine()),
"component_id",
request_type=request_type,
request=request,
fields=fields)
def test_i_can_produce_jira_search(session):
data = {}
jp = jira_producer(session, JiraRequestTypes.Search, "project=key1")
with get_jira_patch(jp):
res = list(jp.process(data))
assert len(res) == 1
assert res[0].key == "TEST-1"

View File

@@ -4,10 +4,12 @@ import pandas as pd
import pytest
from pandas.testing import assert_frame_equal
from components.undo_redo.components.UndoRedo import UndoRedo
from components.workflows.components.WorkflowDesigner import COMPONENT_TYPES, WorkflowDesigner
from components.workflows.components.WorkflowPlayer import WorkflowPlayer, WorkflowsPlayerError
from components.workflows.constants import ProcessorTypes
from components.workflows.db_management import WorkflowComponent, Connection, ComponentState, WorkflowsDesignerSettings
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager, MemoryDbEngine
from my_mocks import tabs_manager
from workflow.engine import DataProcessorError
@@ -16,6 +18,27 @@ TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
TEST_WORKFLOW_PLAYER_ID = "workflow_player_id"
@pytest.fixture(autouse=True)
def mock_undo_redo(session):
# Create a mock UndoRedo instance
undo_redo = MagicMock(spec=UndoRedo)
# Store original get method
original_get = InstanceManager.get
def mock_get(sess, instance_id, *args, **kwargs):
if instance_id == UndoRedo.create_component_id(sess):
return undo_redo
return original_get(sess, instance_id, *args, **kwargs)
# Replace get method with our mock
InstanceManager.get = mock_get
yield undo_redo
# Restore original get method after test
InstanceManager.get = original_get
@pytest.fixture
def settings_manager():
return SettingsManager(MemoryDbEngine())

View File

@@ -1,8 +1,12 @@
from unittest.mock import MagicMock
import pytest
from fasthtml.components import *
from components.form.components.MyForm import FormField, MyForm
from components.undo_redo.components.UndoRedo import UndoRedo
from components.workflows.components.Workflows import Workflows
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, div_icon, search_elements_by_name, Contains
from my_mocks import tabs_manager
@@ -18,6 +22,28 @@ def workflows(session, tabs_manager):
tabs_manager=tabs_manager)
@pytest.fixture(autouse=True)
def mock_undo_redo(session):
# Create a mock UndoRedo instance
undo_redo = MagicMock(spec=UndoRedo)
# Store original get method
original_get = InstanceManager.get
def mock_get(sess, instance_id, *args, **kwargs):
if instance_id == UndoRedo.create_component_id(sess):
return undo_redo
return original_get(sess, instance_id, *args, **kwargs)
# Replace get method with our mock
InstanceManager.get = mock_get
yield undo_redo
# Restore original get method after test
InstanceManager.get = original_get
def test_render_no_workflow(workflows):
actual = workflows.__ft__()
expected = Div(