26 Commits

Author SHA1 Message Date
a0cf5aff0c Working implementation of DefaultDataPresenter 2025-07-20 19:17:55 +02:00
d064a553dd Adding Jira DataProcessor 2025-07-14 16:57:14 +02:00
6f17f6ee1f Fixed unit tests 2025-07-14 15:22:56 +02:00
ed793995fb Fixed unit tests 2025-07-13 18:11:17 +02:00
f3deeaefd1 Adding unit tests to WorkflowPlayer.py 2025-07-13 12:23:25 +02:00
fdf05edec3 Adding unit tests to WorkflowPlayer.py 2025-07-12 18:40:36 +02:00
bdd954b243 Improving error management 2025-07-12 17:45:30 +02:00
2754312141 Adding visual return when error 2025-07-12 09:52:56 +02:00
d0f7536fa0 Adding error management 2025-07-11 19:03:08 +02:00
2b288348e2 Adding error management 2025-07-11 18:34:04 +02:00
03ed1af7e6 Added user input tracking + Started error management for in the Designer 2025-07-09 20:06:09 +02:00
8135e3d8af First version of DefaultDataFilter 2025-07-08 23:29:47 +02:00
e8fc972f98 Started FilterPresenter 2025-07-08 06:22:09 +02:00
14be07720f I can run simple workflow 2025-07-06 22:53:18 +02:00
e183584f52 I can show WorkflowPlayer tab 2025-07-06 12:17:20 +02:00
60872a0aec Started unit test for Workflows.py and WorkflowDesigner.py 2025-07-06 11:02:57 +02:00
9df32e3b5f I can delete a connection 2025-07-05 22:19:58 +02:00
aed1022be3 Added Default Filter and Presenter Processors 2025-07-05 10:02:10 +02:00
f86f4852c7 I can load and save Jira and Table Processor details 2025-07-04 19:03:32 +02:00
8e718ecb67 Added Simple Workflow Engine 2025-07-04 10:35:25 +02:00
46c14ad3e8 Adding splitter between designer and properties 2025-07-03 22:41:21 +02:00
797273e603 I can save workflow state + uptated css + started Properties Panek 2025-07-02 23:00:32 +02:00
d90613119f I can link items by hovering 2025-07-02 20:55:06 +02:00
f4e8f7a16c I can drag and drop items into the canvas
I
2025-07-02 18:23:49 +02:00
7f6a19813d I can show WorkflowDesigner tab
I
2025-07-02 00:05:49 +02:00
4b06a0fe9b Adding workflow management
I
2025-07-01 22:07:12 +02:00
59 changed files with 4460 additions and 35 deletions

View File

@@ -1,36 +1,58 @@
annotated-types==0.7.0
anyio==4.6.0
apsw==3.50.2.0
apswutils==0.1.0
beautifulsoup4==4.12.3
certifi==2024.8.30
charset-normalizer==3.4.2
click==8.1.7
fastcore==1.7.8
fastlite==0.0.11
et-xmlfile==1.1.0
fastcore==1.8.5
fastlite==0.2.1
h11==0.14.0
httpcore==1.0.5
httptools==0.6.1
httpx==0.27.2
httpx-sse==0.4.0
idna==3.10
iniconfig==2.0.0
itsdangerous==2.2.0
markdown-it-py==3.0.0
mcp==1.9.2
mdurl==0.1.2
numpy==2.1.1
oauthlib==3.2.2
openpyxl==3.1.5
packaging==24.1
pandas==2.2.3
pluggy==1.5.0
pydantic==2.11.5
pydantic-settings==2.9.1
pydantic_core==2.33.2
Pygments==2.19.1
pytest==8.3.3
pytest-mock==3.14.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
python-fasthtml==0.6.4
python-fasthtml==0.12.21
python-multipart==0.0.10
pytz==2024.2
PyYAML==6.0.2
requests==2.32.3
rich==14.0.0
shellingham==1.5.4
six==1.16.0
sniffio==1.3.1
soupsieve==2.6
sqlite-minutils==3.37.0.post3
sse-starlette==2.3.6
starlette==0.38.5
typer==0.16.0
typing-inspection==0.4.1
typing_extensions==4.13.2
tzdata==2024.1
urllib3==2.4.0
uvicorn==0.30.6
uvloop==0.20.0
watchfiles==0.24.0
websockets==13.1
pandas~=2.2.3
numpy~=2.1.1
requests~=2.32.3
mcp~=1.9.2

View File

@@ -10,3 +10,15 @@ icon_dismiss_regular = NotStr(
</g>
</svg>"""
)
# Fluent Add16Regular
icon_add_regular = NotStr("""<svg name="add" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<g fill="none">
<path d="M8 2.5a.5.5 0 0 0-1 0V7H2.5a.5.5 0 0 0 0 1H7v4.5a.5.5 0 0 0 1 0V8h4.5a.5.5 0 0 0 0-1H8V2.5z" fill="currentColor">
</path>
</g>
</svg>
""")
# Fluent ErrorCircle20Regular
icon_error = 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="M10 2a8 8 0 1 1 0 16a8 8 0 0 1 0-16zm0 1a7 7 0 1 0 0 14a7 7 0 0 0 0-14zm0 9.5a.75.75 0 1 1 0 1.5a.75.75 0 0 1 0-1.5zM10 6a.5.5 0 0 1 .492.41l.008.09V11a.5.5 0 0 1-.992.09L9.5 11V6.5A.5.5 0 0 1 10 6z" fill="currentColor"></path></g></svg>""")

View File

@@ -6,6 +6,8 @@
--mmt-tooltip-zindex: 10;
--datagrid-drag-drop-zindex: 5;
--datagrid-resize-zindex: 1;
--color-splitter: color-mix(in oklab, var(--color-base-content) 50%, #0000);
--color-splitter-active: color-mix(in oklab, var(--color-base-content) 50%, #ffff);
}
.mmt-tooltip-container {
@@ -28,6 +30,25 @@
transition: opacity 0.3s ease; /* No delay when becoming visible */
}
.mmt-visible-on-hover {
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
}
/* When parent is hovered, show the child elements with this class */
*:hover > .mmt-visible-on-hover {
opacity: 1;
visibility: visible;
transition: opacity 0.2s ease;
}
.mmt-selected {
background-color: var(--color-base-300);
border-radius: .25rem;
}
.icon-32 {
width: 32px;
height: 32px;
@@ -65,7 +86,6 @@
padding-top: 4px;
}
.icon-16 {
width: 16px;
min-width: 16px;
@@ -82,7 +102,6 @@
padding-top: 5px;
}
.icon-bool {
display: block;
width: 20px;

View File

@@ -1,6 +1,11 @@
const tooltipElementId = "mmt-app"
function bindTooltipsWithDelegation() {
// To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip
// Then
// the 'truncate' to show only when the text is truncated
// the class 'mmt-tooltip' for force the display
const elementId = tooltipElementId
console.debug("bindTooltips on element " + elementId);
@@ -88,4 +93,125 @@ function enableTooltip() {
}
element.removeAttribute("mmt-no-tooltip");
}
}
// Function to save form data to browser storage and track user input in real time
function saveFormData(formId) {
const form = document.getElementById(formId);
if (!form) {
console.error(`Form with ID '${formId}' not found`);
return;
}
const storageKey = `formData_${formId}`;
// Function to save current form state
function saveCurrentState() {
const formData = {};
// Get all input elements
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (input.type === 'checkbox' || input.type === 'radio') {
formData[input.name || input.id] = input.checked;
} else {
formData[input.name || input.id] = input.value;
}
});
// Store in browser storage
const dataToStore = {
timestamp: new Date().toISOString(),
data: formData
};
try {
localStorage.setItem(storageKey, JSON.stringify(dataToStore));
} catch (error) {
console.error('Error saving form data:', error);
}
}
// Add event listeners for real-time tracking
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
// For text inputs, textareas, and selects
if (input.type === 'text' || input.type === 'email' || input.type === 'password' ||
input.type === 'number' || input.type === 'tel' || input.type === 'url' ||
input.tagName === 'TEXTAREA' || input.tagName === 'SELECT') {
// Use 'input' event for real-time tracking
input.addEventListener('input', saveCurrentState);
// Also use 'change' event as fallback
input.addEventListener('change', saveCurrentState);
}
// For checkboxes and radio buttons
if (input.type === 'checkbox' || input.type === 'radio') {
input.addEventListener('change', saveCurrentState);
}
});
// Save initial state
saveCurrentState();
console.debug(`Real-time form tracking enabled for form: ${formId}`);
}
// Function to restore form data from browser storage
function restoreFormData(formId) {
const form = document.getElementById(formId);
if (!form) {
console.error(`Form with ID '${formId}' not found`);
return;
}
const storageKey = `formData_${formId}`;
try {
const storedData = localStorage.getItem(storageKey);
if (storedData) {
const parsedData = JSON.parse(storedData);
const formData = parsedData.data;
// Restore all input values
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const key = input.name || input.id;
if (formData.hasOwnProperty(key)) {
if (input.type === 'checkbox' || input.type === 'radio') {
input.checked = formData[key];
} else {
input.value = formData[key];
}
}
});
}
} catch (error) {
console.error('Error restoring form data:', error);
}
}
function bindFormData(formId) {
console.debug("bindFormData on form " + (formId));
restoreFormData(formId);
saveFormData(formId);
}
// Function to clear saved form data
function clearFormData(formId) {
const storageKey = `formData_${formId}`;
try {
localStorage.removeItem(storageKey);
console.log(`Cleared saved data for form: ${formId}`);
} catch (error) {
console.error('Error clearing form data:', error);
}
}

View File

@@ -34,3 +34,20 @@ class BaseComponent:
@staticmethod
def create_component_id(session):
pass
class BaseComponentSingleton(BaseComponent):
"""
Base class for components that will have a single instance per user
"""
COMPONENT_INSTANCE_ID = None
def __init__(self, session, _id=None, settings_manager=None, tabs_manager=None, **kwargs):
super().__init__(session, _id, **kwargs)
self._settings_manager = settings_manager
self.tabs_manager = tabs_manager
@classmethod
def create_component_id(cls, session):
return f"{cls.COMPONENT_INSTANCE_ID}{session['user_id']}"

View File

@@ -48,4 +48,22 @@ def post(session, _id: str, content: str):
def post(session, _id: str):
logger.debug(f"Entering {Routes.ImportHolidays} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.import_holidays()
return instance.import_holidays()
@rt(Routes.ConfigureJira)
def get(session, _id: str, boundaries: str):
logger.debug(f"Entering {Routes.ConfigureJira} - GET with args {debug_session(session)}, {_id=}, {boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.show_configure_jira(json.loads(boundaries) if boundaries else None)
@rt(Routes.ConfigureJira)
def post(session, _id: str, args: dict):
logger.debug(f"Entering {Routes.ConfigureJira} - POST with args {debug_session(session)}, {_id=}, {args=}")
instance = InstanceManager.get(session, _id)
return instance.update_jira_settings(args)
@rt(Routes.ConfigureJiraCancel)
def post(session, _id: str):
logger.debug(f"Entering {Routes.ConfigureJiraCancel} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.cancel_jira_settings()

View File

@@ -23,9 +23,16 @@ class AiBuddySettingsEntry:
self.ollama_port = port
@dataclass()
class JiraSettingsEntry:
user_name: str = ""
api_token: str = ""
@dataclass
class AdminSettings:
ai_buddy: AiBuddySettingsEntry = field(default_factory=AiBuddySettingsEntry)
jira: JiraSettingsEntry = field(default_factory=JiraSettingsEntry)
class AdminDbManager:
@@ -37,3 +44,8 @@ class AdminDbManager:
ADMIN_SETTINGS_ENTRY,
AdminSettings,
"ai_buddy")
self.jira = NestedSettingsManager(session,
settings_manager,
ADMIN_SETTINGS_ENTRY,
AdminSettings,
"jira")

View File

@@ -0,0 +1,10 @@
from fastcore.basics import NotStr
icon_jira = NotStr("""<svg name="jara" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>.a{fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;}</style>
</defs>
<path class="a" d="M5.5,22.9722h0a8.7361,8.7361,0,0,0,8.7361,8.7361h2.0556v2.0556A8.7361,8.7361,0,0,0,25.0278,42.5h0V22.9722Z"/>
<path class="a" d="M14.2361,14.2361h0a8.7361,8.7361,0,0,0,8.7361,8.7361h2.0556v2.0556a8.7361,8.7361,0,0,0,8.7361,8.7361h0V14.2361Z"/>
<path class="a" d="M22.9722,5.5h0a8.7361,8.7361,0,0,0,8.7361,8.7361h2.0556v2.0556A8.7361,8.7361,0,0,0,42.5,25.0278h0V5.5Z"/>
</svg>""")

View File

@@ -38,7 +38,31 @@ class AdminCommandManager(BaseCommandManager):
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", boundaries: getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def show_configure_jira(self):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.ConfigureJira}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", boundaries: getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def save_configure_jira(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.ConfigureJira}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
# The form adds the rest
}
def cancel_configure_jira(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.ConfigureJiraCancel}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
class ImportHolidaysCommandManager(BaseCommandManager):
def __init__(self, owner):

View File

@@ -4,10 +4,11 @@ from ai.mcp_client import MPC_CLIENTS_IDS
from ai.mcp_tools import MCPServerTools
from components.BaseComponent import BaseComponent
from components.admin.admin_db_manager import AdminDbManager
from components.admin.assets.icons import icon_jira
from components.admin.commands import AdminCommandManager
from components.admin.components.AdminForm import AdminFormItem, AdminFormType, AdminForm
from components.admin.components.ImportHolidays import ImportHolidays
from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID
from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_JIRA_INSTANCE_ID
from components.aibuddy.assets.icons import icon_brain_ok
from components.hoildays.assets.icons import icon_holidays
from components.tabs.components.MyTabs import MyTabs
@@ -59,6 +60,30 @@ class Admin(BaseComponent):
return self._add_tab(ADMIN_AI_BUDDY_INSTANCE_ID, "Admin - Import Holidays", form)
def show_configure_jira(self, boundaries):
fields = [
AdminFormItem('user_name', "Email", "Email used to connect to JIRA.", AdminFormType.TEXT),
AdminFormItem("api_token", "API Key", "API Key to connect to JIRA.", AdminFormType.TEXT),
]
hooks = {
"on_ok": self.commands.save_configure_jira(),
"on_cancel": self.commands.cancel_configure_jira(),
"ok_title": "Apply"
}
form = InstanceManager.get(self._session,
AdminForm.create_component_id(self._session, prefix=self._id),
AdminForm,
owner=self,
title="Jira Configuration Page",
obj=self.db.jira,
form_fields=fields,
hooks=hooks,
key=ADMIN_JIRA_INSTANCE_ID,
boundaries=boundaries
)
return self._add_tab(ADMIN_JIRA_INSTANCE_ID, "Admin - Jira Configuration", form)
def update_ai_buddy_settings(self, values: dict):
values = self.manage_lists(values)
self.db.ai_buddy.update(values, ignore_missing=True)
@@ -69,6 +94,17 @@ class Admin(BaseComponent):
self.tabs_manager.remove_tab(tab_id)
return self.tabs_manager.render()
def update_jira_settings(self, values: dict):
values = self.manage_lists(values)
self.db.jira.update(values, ignore_missing=True)
return self.tabs_manager.render()
def cancel_jira_settings(self):
tab_id = self.tabs_manager.get_tab_id(ADMIN_JIRA_INSTANCE_ID)
self.tabs_manager.remove_tab(tab_id)
return self.tabs_manager.render()
def __ft__(self):
return Div(
Div(cls="divider"),
@@ -84,6 +120,11 @@ class Admin(BaseComponent):
mk_ellipsis("holidays", cls="text-sm", **self.commands.show_import_holidays()),
cls="flex p-0 min-h-0 truncate",
),
Div(
mk_icon(icon_jira, can_select=False),
mk_ellipsis("jira", cls="text-sm", **self.commands.show_configure_jira()),
cls="flex p-0 min-h-0 truncate",
),
#
# cls=""),
# Script(f"bindAdmin('{self._id}')"),

View File

@@ -4,7 +4,7 @@ from typing import Any
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components_helpers import set_boundaries, mk_dialog_buttons, safe_get_dialog_buttons_parameters
from components_helpers import apply_boundaries, mk_dialog_buttons, safe_get_dialog_buttons_parameters
from core.utils import get_unique_id
@@ -108,7 +108,7 @@ class AdminForm(BaseComponent):
for item in self.form_fields
],
mk_dialog_buttons(**safe_get_dialog_buttons_parameters(self._hooks)),
**set_boundaries(self._boundaries),
**apply_boundaries(self._boundaries),
cls="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4"
)
)

View File

@@ -9,7 +9,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
from components.datagrid_new.settings import DataGridSettings
from components.hoildays.helpers.nibelisparser import NibelisParser
from components.repositories.constants import USERS_REPOSITORY_NAME, HOLIDAYS_TABLE_NAME
from components_helpers import mk_dialog_buttons, set_boundaries
from components_helpers import mk_dialog_buttons, apply_boundaries
from core.instance_manager import InstanceManager
@@ -50,7 +50,7 @@ class ImportHolidays(BaseComponent):
mk_dialog_buttons(ok_title="Import", cls="mt-2", on_ok=self.commands.import_holidays()),
id=self._id,
cls="m-2",
**set_boundaries(self._boundaries, other=26),
**apply_boundaries(self._boundaries, other=26),
)
@staticmethod

View File

@@ -1,6 +1,7 @@
ADMIN_INSTANCE_ID = "__Admin__"
ADMIN_AI_BUDDY_INSTANCE_ID = "__AdminAIBuddy__"
ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID = "__AdminImportHolidays__"
ADMIN_JIRA_INSTANCE_ID = "__AdminJira__"
ROUTE_ROOT = "/admin"
ADMIN_SETTINGS_ENTRY = "Admin"
@@ -8,4 +9,6 @@ class Routes:
AiBuddy = "/ai-buddy"
AiBuddyCancel = "/ai-buddy-cancel"
ImportHolidays = "/import-holidays"
PasteHolidays = "/paste-holidays"
PasteHolidays = "/paste-holidays"
ConfigureJira = "/configure-jira"
ConfigureJiraCancel = "/configure-jira-cancel"

View File

@@ -29,7 +29,7 @@ class DataGridDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager, key: tuple):
self._session = session
self._settings_manager = settings_manager
self._key = "#".join(make_safe_id(item) for item in key) if key else ""
self._key = self._key_as_string(key)
# init the db if needed
if self._settings_manager and not self._settings_manager.exists(self._session, self._get_db_entry()):
@@ -38,6 +38,16 @@ class DataGridDbManager:
def _get_db_entry(self):
return f"{DATAGRID_DB_ENTRY}_{self._key}"
@staticmethod
def _key_as_string(key):
if not key:
return ""
if isinstance(key, tuple):
return "#".join(make_safe_id(item) for item in key)
return make_safe_id(key)
def save_settings(self, settings: DataGridSettings):
if self._settings_manager is None:
return

View File

@@ -9,7 +9,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
from components.debugger.assets.icons import icon_expanded, icon_collapsed, icon_class
from components.debugger.commands import JsonViewerCommands
from components.debugger.constants import INDENT_SIZE, MAX_TEXT_LENGTH, NODE_OBJECT, NODES_KEYS_TO_NOT_EXPAND
from components_helpers import set_boundaries
from components_helpers import apply_boundaries
from core.serializer import TAG_OBJECT
from core.utils import get_unique_id
@@ -299,7 +299,7 @@ class JsonViewer(BaseComponent):
style="margin-left: 0px;"),
cls="mmt-jsonviewer",
id=f"{self._id}",
**set_boundaries(self._boundaries),
**apply_boundaries(self._boundaries),
)
def __eq__(self, other):

View File

@@ -11,6 +11,7 @@ from components.drawerlayout.assets.icons import icon_panel_contract_regular, ic
from components.drawerlayout.constants import DRAWER_LAYOUT_INSTANCE_ID
from components.repositories.components.Repositories import Repositories
from components.tabs.components.MyTabs import MyTabs
from components.workflows.components.Workflows import Workflows
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
@@ -24,6 +25,7 @@ class DrawerLayout(BaseComponent):
self._settings_manager = settings_manager
self._tabs = InstanceManager.get(session, MyTabs.create_component_id(session), MyTabs)
self._repositories = self._create_component(Repositories)
self._workflows = self._create_component(Workflows)
self._debugger = self._create_component(Debugger)
self._add_stuff = self._create_component(AddStuffMenu)
self._ai_buddy = self._create_component(AIBuddy)
@@ -41,6 +43,7 @@ class DrawerLayout(BaseComponent):
self._ai_buddy,
self._applications,
self._repositories,
self._workflows,
self._admin,
self._debugger,
),

View File

@@ -1,3 +1,4 @@
from fasthtml.components import Html
from fasthtml.components import *
from fasthtml.xtend import Script

View File

@@ -5,7 +5,7 @@ from core.settings_management import SettingsManager
REPOSITORIES_SETTINGS_ENTRY = "Repositories"
logger = logging.getLogger("AddStuffSettings")
logger = logging.getLogger("RepositoriesSettings")
@dataclasses.dataclass

View File

@@ -1,6 +1,5 @@
function getTabContentBoundaries(tabsId) {
const tabsContainer = document.getElementById(tabsId)
console.debug("tabsContainer", tabsContainer)
const contentDiv = tabsContainer.querySelector('.mmt-tabs-content')
const boundaries = contentDiv.getBoundingClientRect()

View File

@@ -149,6 +149,9 @@ class MyTabs(BaseComponent):
if active is not None:
to_modify.active = active
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 refresh(self):
return self.render(oob=True)
@@ -157,7 +160,7 @@ class MyTabs(BaseComponent):
def render(self, oob=False):
active_content = self.get_active_tab_content()
if hasattr(active_content, "on_htmx_after_settle"):
if hasattr(active_content, "on_htmx_after_settle") and active_content.on_htmx_after_settle is not None:
extra_params = {"hx-on::after-settle": active_content.on_htmx_after_settle()}
else:
extra_params = {}

View File

@@ -0,0 +1,15 @@
# id
**Workflow Designer ids**:
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}` |

View File

@@ -0,0 +1,142 @@
import json
import logging
from fasthtml.fastapp import fast_app
from components.workflows.constants import Routes
from core.instance_manager import InstanceManager, debug_session
logger = logging.getLogger("WorkflowsApp")
repositories_app, rt = fast_app()
@rt(Routes.AddWorkflow)
def get(session, _id: str):
logger.debug(f"Entering {Routes.AddWorkflow} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.request_new_workflow()
@rt(Routes.AddWorkflow)
def post(session, _id: str, tab_id: str, form_id: str, name: str, tab_boundaries: str):
logger.debug(
f"Entering {Routes.AddWorkflow} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {name=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.add_new_workflow(tab_id, form_id, name, json.loads(tab_boundaries))
@rt(Routes.ShowWorkflow)
def post(session, _id: str, name: str, tab_boundaries: str):
logger.debug(
f"Entering {Routes.AddWorkflow} with args {debug_session(session)}, {_id=}, {name=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.show_workflow(name, json.loads(tab_boundaries))
@rt(Routes.AddComponent)
def post(session, _id: str, component_type: str, x: int, y: int):
logger.debug(
f"Entering {Routes.AddComponent} with args {debug_session(session)}, {_id=}, {component_type=}, {x=}, {y=}")
instance = InstanceManager.get(session, _id)
return instance.add_component(component_type, x, y)
@rt(Routes.MoveComponent)
def post(session, _id: str, component_id: str, x: float, y: float):
logger.debug(
f"Entering {Routes.MoveComponent} with args {debug_session(session)}, {_id=}, {component_id=}, {x=}, {y=}")
instance = InstanceManager.get(session, _id)
return instance.move_component(component_id, x, y)
@rt(Routes.DeleteComponent)
def post(session, _id: str, component_id: str):
logger.debug(
f"Entering {Routes.DeleteComponent} with args {debug_session(session)}, {_id=}, {component_id=}")
instance = InstanceManager.get(session, _id)
return instance.delete_component(component_id)
@rt(Routes.AddConnection)
def post(session, _id: str, from_id: str, to_id: str):
logger.debug(
f"Entering {Routes.AddConnection} with args {debug_session(session)}, {_id=}, {from_id=}, {to_id=}")
instance = InstanceManager.get(session, _id)
return instance.add_connection(from_id, to_id)
@rt(Routes.DeleteConnection)
def post(session, _id: str, from_id: str, to_id: str):
logger.debug(
f"Entering {Routes.DeleteConnection} with args {debug_session(session)}, {_id=}, {from_id=}, {to_id=}")
instance = InstanceManager.get(session, _id)
return instance.delete_connection(from_id, to_id)
@rt(Routes.ResizeDesigner)
def post(session, _id: str, designer_height: int):
logger.debug(
f"Entering {Routes.ResizeDesigner} with args {debug_session(session)}, {_id=}, {designer_height=}")
instance = InstanceManager.get(session, _id)
return instance.set_designer_height(designer_height)
@rt(Routes.SelectComponent)
def post(session, _id: str, component_id: str):
logger.debug(
f"Entering {Routes.SelectComponent} with args {debug_session(session)}, {_id=}, {component_id=}")
instance = InstanceManager.get(session, _id)
return instance.select_component(component_id)
@rt(Routes.SaveProperties)
def post(session, _id: str, component_id: str, details: dict):
logger.debug(
f"Entering {Routes.SaveProperties} with args {debug_session(session)}, {_id=}, {component_id=}, {details=}")
instance = InstanceManager.get(session, _id)
details.pop("_id")
details.pop("component_id")
return instance.save_properties(component_id, details)
@rt(Routes.CancelProperties)
def post(session, _id: str, component_id: str):
logger.debug(
f"Entering {Routes.CancelProperties} with args {debug_session(session)}, {_id=}, {component_id=}")
instance = InstanceManager.get(session, _id)
return instance.cancel_properties(component_id)
@rt(Routes.SelectProcessor)
def post(session, _id: str, component_id: str, processor_name: str):
logger.debug(
f"Entering {Routes.SelectProcessor} with args {debug_session(session)}, {_id=}, {component_id=}, {processor_name=}")
instance = InstanceManager.get(session, _id)
return instance.set_selected_processor(component_id, processor_name)
@rt(Routes.OnProcessorDetailsEvent)
def post(session, _id: str, component_id: str, event_name: str, details: dict):
logger.debug(
f"Entering {Routes.OnProcessorDetailsEvent} with args {debug_session(session)}, {_id=}, {component_id=}, {event_name=}, {details=}")
instance = InstanceManager.get(session, _id)
details.pop("_id")
details.pop("component_id")
details.pop("event_name")
return instance.on_processor_details_event(component_id, event_name, details)
@rt(Routes.PlayWorkflow)
def post(session, _id: str, tab_boundaries: str):
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=}")
instance = InstanceManager.get(session, _id)
return instance.stop_workflow()

View File

View File

@@ -0,0 +1,197 @@
.wkf-toolbox-item {
cursor: grab;
}
.wkf-toolbox-item:active {
cursor: grabbing;
}
.wkf-splitter {
cursor: row-resize;
height: 1px;
background-color: var(--color-splitter);
margin: 4px 0;
transition: background-color 0.2s;
position: relative; /* Ensure the parent has position relative */
}
.wkf-splitter::after {
--color-resize: var(--color-splitter);
content: ''; /* This is required */
position: absolute; /* Position as needed */
z-index: 1;
display: block; /* Makes it a block element */
height: 6px;
width: 20px;
background-color: var(--color-splitter);
/* Center horizontally */
left: 50%;
transform: translateX(-50%);
/* Center vertically */
top: 50%;
margin-top: -3px; /* Half of the height */
/* Alternatively: transform: translate(-50%, -50%); */
}
.wkf-splitter:hover, .wkf-splitter-active {
background-color: var(--color-splitter-active);
}
.wkf-designer {
min-height: 230px;
}
.wkf-properties {
box-sizing: border-box;
}
.wkf-canvas {
position: relative;
box-sizing: border-box;
background-image:
linear-gradient(rgba(0,0,0,.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,.1) 1px, transparent 1px);
background-size: 20px 20px;
}
.wkf-canvas-error {
border: 3px solid var(--color-error);
}
.wkf-toolbox {
min-height: 230px;
width: 8rem; /* w-32 (32 * 0.25rem = 8rem) */
padding: 0.5rem; /* p-2 */
background-color: var(--color-base-100); /* bg-base-100 */
border-radius: 0.5rem; /* rounded-lg */
border-width: 1px; /* border */
}
.wkf-workflow-component {
position: absolute;
cursor: move;
border: 2px solid transparent;
transition: all 0.2s;
height: 64px;
}
.wkf-workflow-component:hover {
border-color: #3b82f6;
transform: scale(1.02);
}
.wkf-workflow-component.selected {
border-color: #ef4444;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
}
.wkf-workflow-component.dragging {
transition: none;
}
.wkf-workflow-component.error {
background: var(--color-error);
}
.wkf-component-content {
padding: 0.75rem; /* p-3 in Tailwind */
border-radius: 0.5rem; /* rounded-lg in Tailwind */
border-width: 2px; /* border-2 in Tailwind */
background-color: white; /* bg-white in Tailwind */
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg in Tailwind */
display: flex; /* flex in Tailwind */
align-items: center; /* items-center in Tailwind */
}
.wkf-component-content.error {
background: var(--color-error);
}
.wkf-component-content.not-run {
}
.wkf-connection-line {
position: absolute;
pointer-events: none;
z-index: 1;
}
.wkf-connection-point {
position: absolute;
width: 12px;
height: 12px;
background: #3b82f6;
border-radius: 50%;
cursor: crosshair;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: background-color 0.2s, transform 0.2s;
}
.wkf-connection-point.potential-connection {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
animation: pulse 0.7s infinite;
}
.wkf-connection-point.potential-start {
background: #ef4444;
}
.wkf-output-point {
right: -6px;
top: 50%;
transform: translateY(-50%);
}
.wkf-input-point {
left: -6px;
top: 50%;
transform: translateY(-50%);
}
.wkf-connection-point:hover {
background: #ef4444;
transform: translateY(-50%) scale(1.2);
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7); }
70% { box-shadow: 0 0 0 6px rgba(59, 130, 246, 0); }
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
.wkf-connection-path {
stroke: #3b82f6;
stroke-width: 2;
fill: none;
cursor: pointer;
pointer-events: none;
transition: stroke 0.2s ease, stroke-width 0.2s ease;
}
.wkf-connection-path-thick {
stroke: transparent;
stroke-width: 10;
fill: none;
cursor: pointer;
pointer-events: stroke;
}
.wkf-connection-path-arrowhead {
fill:#3b82f6;
}
.wkf-connection-selected {
stroke: #ef4444 !important;
}
.wkf-connection-path-arrowhead-selected {
fill:#ef4444 !important;;
}

View File

@@ -0,0 +1,614 @@
function bindWorkflowDesigner(elementId) {
bindWorkflowDesignerToolbox(elementId)
bindWorkflowDesignerSplitter(elementId)
}
function bindWorkflowDesignerToolbox(elementId) {
// Constants for configuration
const CONFIG = {
COMPONENT_WIDTH: 128,
COMPONENT_HEIGHT: 64,
DRAG_OFFSET: { x: 64, y: 40 },
CONNECTION_POINT_RADIUS: 6,
INVISIBLE_DRAG_IMAGE: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
};
// Designer state with better organization
const designer = {
// Drag state
draggedType: null,
draggedComponent: null,
// Selection state
selectedComponent: null,
selectedConnection: null,
// Connection state
connectionStart: null,
potentialConnectionStart: null,
// Performance optimization
lastUpdateTime: 0,
animationFrame: null,
// Cleanup tracking
eventListeners: new Map(),
// State methods
reset() {
this.draggedType = null;
this.draggedComponent = null;
this.connectionStart = null;
this.potentialConnectionStart = null;
this.cancelAnimationFrame();
},
cancelAnimationFrame() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
}
};
// Get DOM elements with error handling
const designerContainer = document.getElementById(elementId);
const canvas = document.getElementById(`c_${elementId}`);
if (!designerContainer || !canvas) {
console.error(`Workflow designer elements not found for ID: ${elementId}`);
return null;
}
// Utility functions
const utils = {
// Check if two rectangles overlap
isOverlapping(rect1, circle) {
// Find the closest point on the rectangle to the circle's center
const closestX = Math.max(rect1.x, Math.min(circle.x, rect1.x + rect1.width));
const closestY = Math.max(rect1.y, Math.min(circle.y, rect1.y + rect1.height));
// Calculate the distance between the circle's center and the closest point
const deltaX = circle.x - closestX;
const deltaY = circle.y - closestY;
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
// Check if the distance is less than or equal to the circle's radius
return distanceSquared <= circle.radius * circle.radius;
},
// Get mouse position relative to canvas
getCanvasPosition(event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
},
// Constrain position within canvas bounds
constrainPosition(x, y) {
const canvasRect = canvas.getBoundingClientRect();
return {
x: Math.max(0, Math.min(x, canvasRect.width - CONFIG.COMPONENT_WIDTH)),
y: Math.max(0, Math.min(y, canvasRect.height - CONFIG.COMPONENT_HEIGHT))
};
},
// Create HTMX request with error handling
async makeRequest(url, values, targetId = `#c_${elementId}`, swap="innerHTML") {
try {
return htmx.ajax('POST', url, {
target: targetId,
headers: { "Content-Type": "application/x-www-form-urlencoded" },
swap: swap,
values: { _id: elementId, ...values }
});
} catch (error) {
console.error('HTMX request failed:', error);
throw error;
}
}
};
// Connection management
const connectionManager = {
// Update all connections with performance optimization
updateAll() {
designer.cancelAnimationFrame();
designer.animationFrame = requestAnimationFrame(() => {
const connectionLines = designerContainer.querySelectorAll('.wkf-connection-line');
connectionLines.forEach(svg => {
const { fromId, toId } = svg.dataset;
if (fromId && toId) {
this.updateLine(svg, fromId, toId);
}
});
});
},
// Update a specific connection line
updateLine(svg, fromId, toId) {
const fromComp = designerContainer.querySelector(`[data-component-id="${fromId}"]`);
const toComp = designerContainer.querySelector(`[data-component-id="${toId}"]`);
if (!fromComp || !toComp) return;
// Calculate connection points
const fromX = parseInt(fromComp.style.left) + CONFIG.COMPONENT_WIDTH;
const fromY = parseInt(fromComp.style.top) + CONFIG.COMPONENT_HEIGHT / 2;
const toX = parseInt(toComp.style.left);
const toY = parseInt(toComp.style.top) + CONFIG.COMPONENT_HEIGHT / 2;
// Create smooth curved path
const midX = (fromX + toX) / 2;
const path = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`;
// Update the path element
const pathElement = svg.querySelector('.wkf-connection-path');
if (pathElement) {
pathElement.setAttribute('d', path);
}
},
// Clear all connection highlighting
clearHighlighting() {
designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => {
point.classList.remove('potential-connection', 'potential-start');
point.style.background = '#3b82f6';
});
},
// Select a connection
select(connectionPath) {
// Deselect all other connections
designerContainer.querySelectorAll('.wkf-connection-line path').forEach(path => {
path.classList.remove('wkf-connection-selected');
});
// Select the clicked connection
connectionPath.classList.add('wkf-connection-selected');
// Store connection data
const connectionSvg = connectionPath.closest('.wkf-connection-line');
designer.selectedConnection = {
fromId: connectionSvg.dataset.fromId,
toId: connectionSvg.dataset.toId
};
},
// Deselect all connections
deselectAll() {
designerContainer.querySelectorAll('.wkf-connection-line path').forEach(path => {
path.classList.remove('wkf-connection-selected');
});
designer.selectedConnection = null;
}
};
// Component management
const componentManager = {
// Select a component
select(component) {
// Deselect all other components
designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => {
comp.classList.remove('selected');
});
// Select the clicked component
component.classList.add('selected');
designer.selectedComponent = component.dataset.componentId;
// Also trigger server-side selection
utils.makeRequest('/workflows/select-component', {
component_id: designer.selectedComponent
}, `#p_${elementId}`, "outerHTML");
},
// Deselect all components
deselectAll() {
designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => {
comp.classList.remove('selected');
});
designer.selectedComponent = null;
},
// Update component position with constraints
updatePosition(component, x, y) {
const constrained = utils.constrainPosition(x, y);
component.style.left = constrained.x + 'px';
component.style.top = constrained.y + 'px';
}
};
// Event handlers with improved organization
const eventHandlers = {
// Handle drag start for both toolbox items and components
onDragStart(event) {
const toolboxItem = event.target.closest('.wkf-toolbox-item');
const component = event.target.closest('.wkf-workflow-component');
if (toolboxItem) {
designer.draggedType = toolboxItem.dataset.type;
event.dataTransfer.effectAllowed = 'copy';
return;
}
if (component) {
component.classList.add('dragging');
designer.draggedComponent = component.dataset.componentId;
event.dataTransfer.effectAllowed = 'move';
// Use invisible drag image
const invisibleImg = new Image();
invisibleImg.src = CONFIG.INVISIBLE_DRAG_IMAGE;
event.dataTransfer.setDragImage(invisibleImg, 0, 0);
// Highlight potential connection points
designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => {
if (point.dataset.pointType === 'output' &&
point.dataset.componentId !== designer.draggedComponent) {
point.classList.add('potential-connection');
}
});
}
},
// Handle drag with immediate updates
onDrag(event) {
if (!event.target.closest('.wkf-workflow-component')) return;
if (event.clientX === 0 && event.clientY === 0) return;
const component = event.target.closest('.wkf-workflow-component');
const position = utils.getCanvasPosition(event);
const x = position.x - CONFIG.DRAG_OFFSET.x;
const y = position.y - CONFIG.DRAG_OFFSET.y;
// Update position immediately for responsive feel
componentManager.updatePosition(component, x, y);
// Check for potential connections
eventHandlers.checkPotentialConnections(component);
// Update connections with requestAnimationFrame for smooth rendering
connectionManager.updateAll();
},
// Check for potential connections during drag
checkPotentialConnections(component) {
const componentRect = component.getBoundingClientRect();
const componentId = component.dataset.componentId;
const outputPoints = designerContainer.querySelectorAll('.wkf-connection-point[data-point-type="output"]');
outputPoints.forEach(point => {
if (point.dataset.componentId === componentId) return;
const pointRect = point.getBoundingClientRect();
const pointCircle = {
x: pointRect.left + pointRect.width / 2,
y: pointRect.top + pointRect.height / 2,
radius: CONFIG.CONNECTION_POINT_RADIUS
};
if (point !== designer.potentialConnectionStart &&
utils.isOverlapping(componentRect, pointCircle)) {
// Clear previous potential starts
outputPoints.forEach(otherPoint => {
otherPoint.classList.remove('potential-start');
});
designer.potentialConnectionStart = point.dataset.componentId;
point.classList.add('potential-start');
}
});
},
// Handle drag end with cleanup
async onDragEnd(event) {
if (!event.target.closest('.wkf-workflow-component')) return;
if (designer.draggedComponent) {
const component = event.target.closest('.wkf-workflow-component');
const draggedComponentId = component.dataset.componentId;
component.classList.remove('dragging');
const position = utils.getCanvasPosition(event);
const x = position.x - CONFIG.DRAG_OFFSET.x;
const y = position.y - CONFIG.DRAG_OFFSET.y;
const constrained = utils.constrainPosition(x, y);
try {
// Move component
await utils.makeRequest('/workflows/move-component', {
component_id: designer.draggedComponent,
x: constrained.x,
y: constrained.y
});
// Create connection if applicable
if (designer.potentialConnectionStart) {
await utils.makeRequest('/workflows/add-connection', {
from_id: designer.potentialConnectionStart,
to_id: draggedComponentId
});
}
} catch (error) {
console.error('Failed to update component:', error);
}
// Cleanup
connectionManager.clearHighlighting();
designer.reset();
connectionManager.updateAll();
}
},
// Handle clicks with improved event delegation
onClick(event) {
// Connection point handling
const connectionPoint = event.target.closest('.wkf-connection-point');
if (connectionPoint) {
event.stopPropagation();
eventHandlers.handleConnectionPoint(connectionPoint);
}
// Connection selection
const connectionPath = event.target.closest('.wkf-connection-line path');
if (connectionPath) {
event.stopPropagation();
componentManager.deselectAll();
// get the visible connection path
const visibleConnectionPath = connectionPath.parentElement.querySelector('.wkf-connection-path');
connectionManager.select(visibleConnectionPath);
return;
}
// Canvas click - reset everything
if (event.target === canvas || event.target.classList.contains('wkf-canvas')) {
designer.reset();
connectionManager.clearHighlighting();
connectionManager.deselectAll();
componentManager.deselectAll();
return;
}
// Component selection
const component = event.target.closest('.wkf-workflow-component');
if (component) {
event.stopPropagation();
connectionManager.deselectAll();
componentManager.select(component);
return;
}
},
// Handle connection point interactions
async handleConnectionPoint(connectionPoint) {
const componentId = connectionPoint.dataset.componentId;
const pointType = connectionPoint.dataset.pointType;
if (!designer.connectionStart) {
// Start connection from output point
if (pointType === 'output') {
designer.connectionStart = { componentId, pointType };
connectionPoint.style.background = '#ef4444';
}
} else {
// Complete connection to input point
if (pointType === 'input' && componentId !== designer.connectionStart.componentId) {
try {
await utils.makeRequest('/workflows/add-connection', {
from_id: designer.connectionStart.componentId,
to_id: componentId
});
} catch (error) {
console.error('Failed to create connection:', error);
}
}
// Reset connection mode
connectionManager.clearHighlighting();
designer.connectionStart = null;
}
},
// Handle canvas drop for new components
async onCanvasDrop(event) {
event.preventDefault();
if (designer.draggedType) {
const position = utils.getCanvasPosition(event);
const x = position.x - CONFIG.DRAG_OFFSET.x;
const y = position.y - CONFIG.DRAG_OFFSET.y;
const constrained = utils.constrainPosition(x, y);
try {
await utils.makeRequest('/workflows/add-component', {
component_type: designer.draggedType,
x: constrained.x,
y: constrained.y
});
} catch (error) {
console.error('Failed to add component:', error);
}
designer.draggedType = null;
}
},
// Handle keyboard shortcuts
async onKeyDown(event) {
if (event.key === 'Delete' || event.key === 'Suppr') {
try {
if (designer.selectedComponent) {
await utils.makeRequest('/workflows/delete-component', {
component_id: designer.selectedComponent
});
designer.selectedComponent = null;
} else if (designer.selectedConnection) {
await utils.makeRequest('/workflows/delete-connection', {
from_id: designer.selectedConnection.fromId,
to_id: designer.selectedConnection.toId
});
designer.selectedConnection = null;
}
} catch (error) {
console.error('Failed to delete:', error);
}
}
}
};
// Event registration with cleanup tracking
function registerEventListener(element, event, handler, options = {}) {
const key = `${element.id || 'global'}-${event}`;
element.addEventListener(event, handler, options);
designer.eventListeners.set(key, () => element.removeEventListener(event, handler, options));
}
// Register all event listeners
registerEventListener(designerContainer, 'dragstart', eventHandlers.onDragStart);
registerEventListener(designerContainer, 'drag', eventHandlers.onDrag);
registerEventListener(designerContainer, 'dragend', eventHandlers.onDragEnd);
registerEventListener(designerContainer, 'click', eventHandlers.onClick);
registerEventListener(canvas, 'dragover', (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
});
registerEventListener(canvas, 'drop', eventHandlers.onCanvasDrop);
registerEventListener(document, 'keydown', eventHandlers.onKeyDown);
// Public API
const api = {
// Cleanup function for proper disposal
destroy() {
designer.cancelAnimationFrame();
designer.eventListeners.forEach(cleanup => cleanup());
designer.eventListeners.clear();
},
// Get current designer state
getState() {
return {
selectedComponent: designer.selectedComponent,
selectedConnection: designer.selectedConnection,
connectionStart: designer.connectionStart
};
},
// Force update all connections
updateConnections() {
connectionManager.updateAll();
},
// Select component programmatically
selectComponent(componentId) {
const component = designerContainer.querySelector(`[data-component-id="${componentId}"]`);
if (component) {
componentManager.select(component);
}
}
};
// Initialize connections on load
setTimeout(() => connectionManager.updateAll(), 100);
return api;
}
/**
* Binds drag resize functionality to a workflow designer splitter
* @param {string} elementId - The base ID of the workflow designer element
*/
function bindWorkflowDesignerSplitter(elementId) {
// Get the elements
const designer = document.getElementById(`d_${elementId}`);
const splitter = document.getElementById(`s_${elementId}`);
const properties = document.getElementById(`p_${elementId}`);
const designerMinHeight = parseInt(designer.style.minHeight, 10) || 230;
if (!designer || !splitter) {
console.error("Cannot find all required elements for workflow designer splitter");
return;
}
// Initialize drag state
let isResizing = false;
let startY = 0;
let startDesignerHeight = 0;
// Mouse down event - start dragging
splitter.addEventListener('mousedown', (e) => {
e.preventDefault();
isResizing = true;
startY = e.clientY;
startDesignerHeight = parseInt(designer.style.height, 10) || designer.parentNode.getBoundingClientRect().height;
document.body.style.userSelect = 'none'; // Disable text selection
document.body.style.cursor = "row-resize"; // Change cursor style globally for horizontal splitter
splitter.classList.add('wkf-splitter-active'); // Add class for visual feedback
});
// Mouse move event - update heights while dragging
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
// Calculate new height
const deltaY = e.clientY - startY;
const newDesignerHeight = Math.max(designerMinHeight, startDesignerHeight + deltaY); // Enforce minimum height
designer.style.height = `${newDesignerHeight}px`;
// Update properties panel height if it exists
if (properties) {
const containerHeight = designer.parentNode.getBoundingClientRect().height;
const propertiesHeight = Math.max(50, containerHeight - newDesignerHeight - splitter.offsetHeight);
properties.style.height = `${propertiesHeight}px`;
}
});
// Mouse up event - stop dragging
document.addEventListener('mouseup', () => {
if (!isResizing) return;
isResizing = false;
document.body.style.cursor = ""; // Reset cursor
document.body.style.userSelect = ""; // Re-enable text selection
splitter.classList.remove('wkf-splitter-active');
// Store the current state
const designerHeight = parseInt(designer.style.height, 10);
saveDesignerHeight(elementId, designerHeight);
});
// Handle case when mouse leaves the window
document.addEventListener('mouseleave', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = ""; // Reset cursor
document.body.style.userSelect = ""; // Re-enable text selection
splitter.classList.remove('wkf-splitter-active');
}
});
// Function to save the designer height
function saveDesignerHeight(id, height) {
htmx.ajax('POST', '/workflows/resize-designer', {
target: `#${elementId}`,
headers: {"Content-Type": "application/x-www-form-urlencoded"},
swap: "outerHTML",
values: {
_id: elementId,
designer_height: height,
}
});
}
}

View File

@@ -0,0 +1,25 @@
from fastcore.basics import NotStr
# Fluent Play20Filled
icon_play = NotStr(
"""<svg name="play" 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="M17.222 8.685a1.5 1.5 0 0 1 0 2.628l-10 5.498A1.5 1.5 0 0 1 5 15.496V4.502a1.5 1.5 0 0 1 2.223-1.314l10 5.497z" fill="currentColor"></path></g></svg>""")
# Fluent Pause20Filled
icon_pause = NotStr(
"""<svg name="pause" 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="M12 3.5A1.5 1.5 0 0 1 13.5 2h2A1.5 1.5 0 0 1 17 3.5v13a1.5 1.5 0 0 1-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5v-13zm-9 0A1.5 1.5 0 0 1 4.5 2h2A1.5 1.5 0 0 1 8 3.5v13A1.5 1.5 0 0 1 6.5 18h-2A1.5 1.5 0 0 1 3 16.5v-13z" fill="currentColor"></path></g></svg>""")
# Fluent Stop20Filled
icon_stop = 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="M4.5 3A1.5 1.5 0 0 0 3 4.5v11A1.5 1.5 0 0 0 4.5 17h11a1.5 1.5 0 0 0 1.5-1.5v-11A1.5 1.5 0 0 0 15.5 3h-11z" fill="currentColor"></path></g></svg>""")
# fluent PlayCircle20Regular
icon_play_circle = NotStr(
"""<svg name="play" 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="M9.125 7.184A.75.75 0 0 0 8 7.834v4.333a.75.75 0 0 0 1.125.65l4.125-2.384a.5.5 0 0 0 0-.866L9.125 7.184zM2 10a8 8 0 1 1 16 0a8 8 0 0 1-16 0zm8-7a7 7 0 1 0 0 14a7 7 0 0 0 0-14z" fill="currentColor"></path></g></svg>""")
# fluent PauseCircle20Regular
icon_pause_circle = NotStr(
"""<svg name="pause" 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="M9 7.5a.5.5 0 0 0-1 0v5a.5.5 0 0 0 1 0v-5zm3 0a.5.5 0 0 0-1 0v5a.5.5 0 0 0 1 0v-5zM10 2a8 8 0 1 0 0 16a8 8 0 0 0 0-16zm-7 8a7 7 0 1 1 14 0a7 7 0 0 1-14 0z" fill="currentColor"></path></g></svg>""")
# 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>""")

View File

@@ -0,0 +1,98 @@
from components.BaseCommandManager import BaseCommandManager
from components.workflows.constants import Routes, ROUTE_ROOT
class WorkflowsCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def request_add_workflow(self):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.AddWorkflow}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}
def add_workflow(self, tab_id: str):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.AddWorkflow}",
"hx-target": f"#w_{self._id}",
"hx-vals": f'js:{{"_id": "{self._id}", "tab_id": "{tab_id}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def show_workflow(self, workflow_name):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.ShowWorkflow}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "name": "{workflow_name}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
class WorkflowDesignerCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def select_processor(self, component_id: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.SelectProcessor}",
"hx-target": f"#p_{self._id}",
"hx-swap": "outerHTML",
"hx-trigger": "change",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
}
def save_properties(self, component_id: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.SaveProperties}",
"hx-target": f"#p_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
}
def cancel_properties(self, component_id: str):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.CancelProperties}",
"hx-target": f"#p_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}"}}',
}
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-trigger": "change",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "component_id": "{component_id}", "event_name": "{event_name}"}}',
}
def play_workflow(self):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.PlayWorkflow}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def pause_workflow(self):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.PauseWorkflow}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
def stop_workflow(self):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.StopWorkflow}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}"}}',
}
class WorkflowPlayerCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)

View File

@@ -0,0 +1,565 @@
import logging
from fastcore.basics import NotStr
from fasthtml.components import *
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
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
from components_helpers import apply_boundaries, mk_tooltip, mk_dialog_buttons, mk_icon
from core.instance_manager import InstanceManager
from core.utils import get_unique_id, make_safe_id
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,
_id=None,
settings_manager=None,
tabs_manager=None,
key: str = None,
designer_settings: WorkflowsDesignerSettings = None,
boundaries: dict = None):
super().__init__(session, _id)
self._settings_manager = settings_manager
self.tabs_manager = tabs_manager
self._key = key
self._designer_settings = designer_settings
self._db = WorkflowsDesignerDbManager(session, settings_manager)
self._state = self._db.load_state(key)
self._boundaries = boundaries
self.commands = WorkflowDesignerCommandManager(self)
workflow_name = self._designer_settings.workflow_name
self._player = InstanceManager.get(self._session,
WorkflowPlayer.create_component_id(self._session, workflow_name),
WorkflowPlayer,
settings_manager=self._settings_manager,
tabs_manager=self.tabs_manager,
designer=self,
boundaries=boundaries)
self._error_message = None
def set_boundaries(self, boundaries: dict):
self._boundaries = boundaries
def refresh_designer(self):
return self._mk_elements()
def refresh_properties(self, oob=False):
return self._mk_properties(oob)
def add_component(self, component_type, x, y):
self._state.component_counter += 1
component_id = f"comp_{self._state.component_counter}"
info = COMPONENT_TYPES[component_type]
component = WorkflowComponent(
id=component_id,
type=component_type,
x=int(x),
y=int(y),
title=info["title"],
description=info["description"],
properties={"processor_name": PROCESSOR_TYPES[component_type][0]}
)
self._state.components[component_id] = component
self._db.save_state(self._key, self._state) # update db
return self.refresh_designer()
def move_component(self, component_id, x, y):
if component_id in self._state.components:
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
return self.refresh_designer(), self.refresh_properties(True)
def delete_component(self, component_id):
# Remove component
if component_id in self._state.components:
del self._state.components[component_id]
# Remove related connections
self._state.connections = [connection for connection in self._state.connections
if connection.from_id != component_id and connection.to_id != component_id]
# update db
self._db.save_state(self._key, self._state)
return self.refresh_designer()
def add_connection(self, from_id, to_id):
# Check if connection already exists
for connection in self._state.connections:
if connection.from_id == from_id and connection.to_id == to_id:
return self.refresh_designer() # , self.error_message("Connection already exists")
connection_id = f"conn_{len(self._state.connections) + 1}"
connection = Connection(id=connection_id, from_id=from_id, to_id=to_id)
self._state.connections.append(connection)
# update db
self._db.save_state(self._key, self._state)
return self.refresh_designer()
def delete_connection(self, from_id, to_id):
for connection in self._state.connections:
if connection.from_id == from_id and connection.to_id == to_id:
self._state.connections.remove(connection)
# update db
self._db.save_state(self._key, self._state)
return self.refresh_designer()
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
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)
return self.refresh_properties()
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)
logger.debug(f"Saved properties for component {component_id}: {details}")
return self.refresh_properties()
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()
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()
def play_workflow(self, boundaries: dict):
self._error_message = None
self._player.run()
if self._player.global_error:
# Show the error message in the same tab
self._error_message = self._player.global_error
else:
# change the tab and display the results
self._player.set_boundaries(boundaries)
self.tabs_manager.add_tab(f"Workflow {self._designer_settings.workflow_name}", self._player, self._player.key)
return self.tabs_manager.refresh()
def stop_workflow(self):
self._error_message = None
self._player.stop()
return self.tabs_manager.refresh()
def on_processor_details_event(self, component_id: str, event_name: str, details: dict):
if component_id in self._state.components:
component = self._state.components[component_id]
if event_name == "OnRepositoryChanged":
component.properties["repository"] = details["repository"]
tables = DbManagementHelper.list_tables(self._session, details["repository"])
component.properties["table"] = tables[0] if len(tables) > 0 else None
return self.refresh_properties()
def get_workflow_name(self):
return self._designer_settings.workflow_name
def get_workflow_components(self):
return self._state.components.values()
def get_workflow_connections(self):
return self._state.connections
def __ft__(self):
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"),
Div(
self._mk_media(),
self._mk_error_message(),
cls="flex mb-2",
id=f"t_{self._id}"
),
self._mk_designer(),
Div(cls="wkf-splitter", id=f"s_{self._id}"),
self._mk_properties(),
Script(f"bindWorkflowDesigner('{self._id}');"),
**apply_boundaries(self._boundaries),
id=f"{self._id}",
)
def _mk_connection_svg(self, conn: Connection):
if conn.from_id not in self._state.components or conn.to_id not in self._state.components:
return ""
from_comp = self._state.components[conn.from_id]
to_comp = self._state.components[conn.to_id]
# Calculate connection points (approximate)
x1 = from_comp.x + 128 # component width + output point
y1 = from_comp.y + 32 # component height / 2
x2 = to_comp.x
y2 = to_comp.y + 32
# Create curved path
mid_x = (x1 + x2) / 2
path = f"M {x1} {y1} C {mid_x} {y1}, {mid_x} {y2}, {x2} {y2}"
return f"""
<svg class="wkf-connection-line" style="left: 0; top: 0; width: 100%; height: 100%;"
data-from-id="{conn.from_id}" data-to-id="{conn.to_id}">
<path d="{path}" class="wkf-connection-path-thick"/>
<path d="{path}" class="wkf-connection-path" marker-end="url(#arrowhead)"/>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="wkf-connection-path-arrowhead"/>
</marker>
</defs>
</svg>
"""
def _mk_component(self, component: WorkflowComponent):
runtime_state = self._player.get_component_runtime_state(component.id)
info = COMPONENT_TYPES[component.type]
is_selected = self._state.selected_component_id == component.id
tooltip_content = None
tooltip_class = ""
if runtime_state.state == ComponentState.FAILURE:
state_class = 'error' # To be styled with a red highlight
tooltip_content = runtime_state.error_message
tooltip_class = "mmt-tooltip"
elif runtime_state.state == ComponentState.NOT_RUN:
state_class = 'not-run' # To be styled as greyed-out
else:
state_class = ''
return Div(
# Input connection point
Div(cls="wkf-connection-point wkf-input-point",
data_component_id=component.id,
data_point_type="input"),
# Component content
Div(
Span(info["icon"], cls="text-xl mb-1"),
H4(component.title, cls="font-semibold text-xs"),
cls=f"wkf-component-content {info['color']} {state_class}"
),
# Output connection point
Div(cls="wkf-connection-point wkf-output-point",
data_component_id=component.id,
data_point_type="output"),
cls=f"wkf-workflow-component w-32 {'selected' if is_selected else ''} {tooltip_class}",
style=f"left: {component.x}px; top: {component.y}px;",
data_component_id=component.id,
data_tooltip=tooltip_content,
draggable="true"
)
def _mk_elements(self):
return Div(
# Render connections
*[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections],
# Render components
*[self._mk_component(comp) for comp in self._state.components.values()],
)
def _mk_canvas(self, oob=False):
return Div(
self._mk_elements(),
cls=f"wkf-canvas flex-1 rounded-lg border flex-1 {'wkf-canvas-error' if self._error_message else ''}",
id=f"c_{self._id}",
hx_swap_oob='true' if oob else None,
),
def _mk_toolbox(self):
return Div(
Div(
*[self._mk_toolbox_item(comp_type, info)
for comp_type, info in COMPONENT_TYPES.items()],
# cls="space-y-1"
),
cls="wkf-toolbox"
)
def _mk_designer(self):
return Div(
self._mk_toolbox(), # (Left side)
self._mk_canvas(), # (Right side)
cls="wkf-designer flex gap-1",
id=f"d_{self._id}",
style=f"height:{self._state.designer_height}px;"
)
def _mk_media(self):
return Div(
mk_icon(icon_play, cls="mr-1", **self.commands.play_workflow()),
mk_icon(icon_pause, cls="mr-1", **self.commands.pause_workflow()),
mk_icon(icon_stop, cls="mr-1", **self.commands.stop_workflow()),
cls=f"media-controls flex m-2"
)
def _mk_error_message(self):
if not self._error_message:
return Div()
return Div(
mk_icon(icon_error),
Span(self._error_message, cls="text-sm"),
role="alert",
cls="alert alert-error alert-outline p-1!",
hx_swap_oob='true',
)
def _mk_processor_properties(self, component, processor_name):
if processor_name == "Jira":
return self._mk_jira_processor_details(component)
elif processor_name == "Repository":
return self._mk_repository_processor_details(component)
elif component.type == ProcessorTypes.Filter and processor_name == "Default":
return self._mk_filter_processor_details(component)
elif component.type == ProcessorTypes.Presenter and processor_name == "Default":
return self._mk_presenter_processor_details(component)
return Div('Not defined yet !')
def _mk_properties_details(self, component_id, allow_component_selection=False):
def _mk_header():
return Div(
Div(
Span(icon),
H4(component.title, cls="font-semibold text-xs"),
cls=f"rounded-lg border-2 {color} flex text-center px-2"
),
H1(component_id, cls="ml-4"),
cls="flex mb-2"
)
def _mk_select():
return Select(
*[Option(processor_name, selected="selected" if processor_name == selected_processor_name else None)
for processor_name in PROCESSOR_TYPES[component.type]],
cls="select select-sm w-64 mb-2",
id="processor_name",
name="processor_name",
**self.commands.select_processor(component_id)
)
if component_id is None or component_id not in self._state.components and not allow_component_selection:
return None
else:
component_id = self._state.selected_component_id
component = self._state.components[component_id]
selected_processor_name = component.properties["processor_name"]
icon = COMPONENT_TYPES[component.type]["icon"]
color = COMPONENT_TYPES[component.type]["color"]
return Div(
Form(
_mk_header(),
_mk_select(),
self._mk_processor_properties(component, selected_processor_name),
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}",
),
Script(f"bindFormData('f_{self._id}_{component_id}');")
)
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}",
)
@staticmethod
def _mk_jira_processor_details(component):
return Div(
Fieldset(
Legend("JQL", cls="fieldset-legend"),
Input(type="text",
name="jira_jql",
value=component.properties.get("jira_jql", ""),
placeholder="Enter JQL",
cls="input w-full"),
P("Write your jsl code"),
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
),
)
def _mk_repository_processor_details(self, component):
selected_repo = component.properties.get("repository", None)
selected_table = 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)]
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(component.id, "OnRepositoryChanged"),
),
Select(
*_mk_tables_options(),
cls="select w-64 ml-4",
id=f"table_{self._id}",
name="table",
),
cls="flex",
),
P("Select the source table"),
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
)
)
@staticmethod
def _mk_filter_processor_details(component):
return Div(
Fieldset(
Legend("Filter", cls="fieldset-legend"),
Input(type="text",
name="filter",
value=component.properties.get("filter", ""),
placeholder="Enter filter expression",
cls="input w-full"),
P("Write your filter expression (python syntax)"),
cls="fieldset bg-base-200 border-base-300 rounded-box border p-4"
)
)
@staticmethod
def _mk_presenter_processor_details(component):
return Div(
Fieldset(
Legend("Presenter", cls="fieldset-legend"),
Input(type="text",
name="columns",
value=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 _get_properties_height(self):
print(f"height: {self._boundaries['height']}")
return self._boundaries["height"] - self._state.designer_height - 86
@staticmethod
def create_component_id(session, suffix=None):
prefix = f"{WORKFLOW_DESIGNER_INSTANCE_ID}{session['user_id']}"
if suffix is None:
suffix = get_unique_id()
return make_safe_id(f"{prefix}{suffix}")
@staticmethod
def _mk_toolbox_item(component_type: str, info: dict):
return Div(
mk_tooltip(
Div(
Span(info["icon"], cls="mb-2"),
H4(info["title"], cls="font-semibold text-xs"),
cls=f"p-2 rounded-lg border-2 {info['color']} flex text-center"
),
tooltip=info["description"]),
cls="wkf-toolbox-item p-2",
draggable="true",
data_type=component_type
)

View File

@@ -0,0 +1,231 @@
from collections import deque
from dataclasses import dataclass
import pandas as pd
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.datagrid_new.components.DataGrid import DataGrid
from components.datagrid_new.settings import DataGridSettings
from components.workflows.commands import WorkflowPlayerCommandManager
from components.workflows.constants import WORKFLOW_PLAYER_INSTANCE_ID, ProcessorTypes
from components.workflows.db_management import WorkflowComponentRuntimeState, \
WorkflowComponent, ComponentState
from core.instance_manager import InstanceManager
from core.utils import get_unique_id, make_safe_id
from workflow.DefaultDataPresenter import DefaultDataPresenter
from workflow.engine import WorkflowEngine, TableDataProducer, DefaultDataFilter, JiraDataProducer
grid_settings = DataGridSettings(
header_visible=True,
filter_all_visible=True,
views_visible=False,
open_file_visible=False,
open_settings_visible=False)
@dataclass
class WorkflowsPlayerError(Exception):
component_id: str
error: Exception
class WorkflowPlayer(BaseComponent):
def __init__(self, session,
_id=None,
settings_manager=None,
tabs_manager=None,
designer=None,
boundaries: dict = None):
super().__init__(session, _id)
self._settings_manager = settings_manager
self.tabs_manager = tabs_manager
self._designer = designer
self.key = f"__WorkflowPlayer_{designer.get_workflow_name()}"
self._boundaries = boundaries
self.commands = WorkflowPlayerCommandManager(self)
self._datagrid = InstanceManager.get(self._session,
DataGrid.create_component_id(session),
DataGrid,
key=self.key,
grid_settings=grid_settings,
boundaries=boundaries)
self.runtime_states = {}
self.global_error = None
self.has_error = False
def set_boundaries(self, boundaries: dict):
self._datagrid.set_boundaries(boundaries)
def get_component_runtime_state(self, component_id: str):
# return a default value if the player hasn't been played yet
return self.runtime_states.get(component_id, WorkflowComponentRuntimeState(component_id))
def run(self):
# at least one connection is required to play
if len(self._designer.get_workflow_connections()) == 0:
self.global_error = "No connections defined."
return
self._init_state(ComponentState.NOT_RUN)
try:
sorted_components = self._get_sorted_components()
engine = self._get_engine(sorted_components)
except ValueError as e:
# Handle workflow structure errors (e.g., cycles)
self.has_error = True
self.global_error = f"Workflow configuration error: {e}"
return
except WorkflowsPlayerError as ex:
self.has_error = True
self.global_error = self._get_global_error_as_str(ex, "Failed to init ")
if ex.component_id in self.runtime_states:
self.runtime_states[ex.component_id].state = ComponentState.FAILURE
self.runtime_states[ex.component_id].error_message = str(ex.error)
return
res = engine.run_to_list()
if engine.has_error and not engine.errors:
self.has_error = True
self.global_error = engine.global_error
else: # loop through the components and update the runtime states
for component in sorted_components:
runtime_state = self.runtime_states.get(component.id)
if component.id not in engine.errors:
runtime_state.state = ComponentState.SUCCESS
continue
# the component failed
error = engine.errors[component.id]
runtime_state.state = ComponentState.FAILURE
runtime_state.error_message = str(error)
self.global_error = self._get_global_error_as_str(error, "Error in ") # update global error as well
self.has_error = True
break # the remaining components will remain as NOT_RUN
data = [row.as_dict() for row in res]
df = pd.DataFrame(data)
self._datagrid.init_from_dataframe(df)
def stop(self):
self._init_state()
def get_dataframe(self):
return self._datagrid.get_dataframe()
def __ft__(self):
return Div(
self._datagrid,
id=self._id,
)
def _get_sorted_components(self) -> list[WorkflowComponent]:
"""
Sorts the workflow components based on their connections using topological sort.
- A connection from component A to B means A must come before B.
- Raises a ValueError if a cycle is detected.
- Raises a ValueError if a connection references a non-existent component.
- Ignores components that are not part of any connection.
:return: A list of sorted WorkflowComponent objects.
"""
components_by_id = {c.id: c for c in self._designer.get_workflow_components()}
# Get all component IDs involved in connections
involved_ids = set()
for conn in self._designer.get_workflow_connections():
involved_ids.add(conn.from_id)
involved_ids.add(conn.to_id)
# Check if all involved components exist
for component_id in involved_ids:
if component_id not in components_by_id:
raise ValueError(f"Component with ID '{component_id}' referenced in connections but does not exist.")
# Build the graph (adjacency list and in-degrees) for involved components
adj = {cid: [] for cid in involved_ids}
in_degree = {cid: 0 for cid in involved_ids}
for conn in self._designer.get_workflow_connections():
# from_id -> to_id
adj[conn.from_id].append(conn.to_id)
in_degree[conn.to_id] += 1
# Find all sources (nodes with in-degree 0)
queue = deque([cid for cid in involved_ids if in_degree[cid] == 0])
sorted_order = []
while queue:
u = queue.popleft()
sorted_order.append(u)
for v in adj.get(u, []):
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
# Check for cycles
if len(sorted_order) != len(involved_ids):
raise ValueError("A cycle was detected in the workflow connections.")
# Return sorted components
return [components_by_id[cid] for cid in sorted_order]
def _get_engine(self, sorted_components):
# first reorder the component, according to the connection definitions
engine = WorkflowEngine()
for component in sorted_components:
key = (component.type, component.properties["processor_name"])
try:
if key == (ProcessorTypes.Producer, "Repository"):
engine.add_processor(
TableDataProducer(self._session,
self._settings_manager,
component.id,
component.properties["repository"],
component.properties["table"]))
elif key == (ProcessorTypes.Producer, "Jira"):
engine.add_processor(
JiraDataProducer(self._session,
self._settings_manager,
component.id,
'issues',
component.properties["jira_jql"]))
elif key == (ProcessorTypes.Filter, "Default"):
engine.add_processor(DefaultDataFilter(component.id, component.properties["filter"]))
elif key == (ProcessorTypes.Presenter, "Default"):
engine.add_processor(DefaultDataPresenter(component.id, component.properties["columns"]))
else:
raise ValueError(
f"Unsupported processor : type={component.type}, name={component.properties['processor_name']}")
except Exception as e:
raise WorkflowsPlayerError(component.id, e)
return engine
def _init_state(self, state: ComponentState = ComponentState.SUCCESS):
self.global_error = None
self.has_error = False
self.runtime_states = {component.id: WorkflowComponentRuntimeState(component.id, state)
for component in self._designer.get_workflow_components()}
@staticmethod
def create_component_id(session, suffix=None):
prefix = f"{WORKFLOW_PLAYER_INSTANCE_ID}{session['user_id']}"
if suffix is None:
suffix = get_unique_id()
return make_safe_id(f"{prefix}{suffix}")
@staticmethod
def _get_global_error_as_str(error, prefix=""):
if hasattr(error, "component_id"):
return f"{prefix}component '{error.component_id}': {error.error}"
else:
return str(error)

View File

@@ -0,0 +1,132 @@
import logging
from fasthtml.components import *
from assets.icons import icon_add_regular
from components.BaseComponent import BaseComponentSingleton
from components.form.components.MyForm import MyForm, FormField
from components.workflows.commands import WorkflowsCommandManager
from components.workflows.components.WorkflowDesigner import WorkflowDesigner
from components.workflows.constants import WORKFLOWS_INSTANCE_ID
from components.workflows.db_management import WorkflowsDbManager, WorkflowsDesignerSettings
from components_helpers import mk_ellipsis, mk_icon
from core.instance_manager import InstanceManager
logger = logging.getLogger("Workflows")
class Workflows(BaseComponentSingleton):
COMPONENT_INSTANCE_ID = WORKFLOWS_INSTANCE_ID
def __init__(self, session, _id, settings_manager=None, tabs_manager=None):
super().__init__(session, _id, settings_manager, tabs_manager)
self.commands = WorkflowsCommandManager(self)
self.db = WorkflowsDbManager(session, settings_manager)
def request_new_workflow(self):
# request for a new tab_id
new_tab_id = self.tabs_manager.request_new_tab_id()
# create a new form to ask for the details of the new database
add_workflow_form = self._mk_add_workflow_form(new_tab_id)
# create and display the form in a new tab
self.tabs_manager.add_tab("Add Workflow", add_workflow_form, tab_id=new_tab_id)
return self.tabs_manager
def add_new_workflow(self, tab_id: str, form_id: str, workflow_name: str, tab_boundaries: dict):
"""
:param tab_id: tab id where the table content will be displayed (and where the form was displayed)
:param form_id: form used to give the repository name (to be used in case of error)
:param workflow_name: new workflow name
:param tab_boundaries: tab boundaries
:return:
"""
try:
# Add the new repository and its default table to the list of repositories
self.db.add_workflow(workflow_name)
# update the tab content with table content
self.tabs_manager.set_tab_content(tab_id,
self._get_workflow_designer(workflow_name, tab_boundaries),
title=workflow_name,
key=f"{self._id}_{workflow_name}",
active=True)
return self._mk_workflows(), self.tabs_manager.refresh()
except ValueError as ex:
logger.error(f" Workflow '{workflow_name}' already exists.")
add_repository_form = InstanceManager.get(self._session, form_id)
add_repository_form.set_error(ex)
return self.tabs_manager.refresh()
def show_workflow(self, workflow_name: str, tab_boundaries: dict):
tab_key = f"{self._id}_{workflow_name}"
if tab_key not in self.tabs_manager.tabs:
self.tabs_manager.add_tab(workflow_name,
self._get_workflow_designer(workflow_name, tab_boundaries),
key=tab_key)
else:
workflow_designer = self.tabs_manager.get_tab_content_by_key(tab_key)
workflow_designer.set_boundaries(tab_boundaries)
self.tabs_manager.select_tab_by_key(tab_key)
self.db.select_workflow(workflow_name)
return self.refresh(), self.tabs_manager.refresh()
def refresh(self):
return self._mk_workflows(True)
def __ft__(self):
return Div(
Div(cls="divider"),
Div(
mk_ellipsis("Workflows", cls="text-sm font-medium mb-1"),
mk_icon(icon_add_regular,
size=16,
tooltip="Add Workflow",
cls="ml-2 mmt-visible-on-hover",
**self.commands.request_add_workflow()),
cls="flex"
),
self._mk_workflows(),
id=f"{self._id}"
)
def _get_workflow_designer(self, workflow_name: str, tab_boundaries: dict):
return InstanceManager.get(self._session,
WorkflowDesigner.create_component_id(self._session, workflow_name),
WorkflowDesigner,
settings_manager=self._settings_manager,
tabs_manager=self.tabs_manager,
key=workflow_name,
designer_settings=WorkflowsDesignerSettings(workflow_name=workflow_name),
boundaries=tab_boundaries)
def _mk_add_workflow_form(self, tab_id: str):
return InstanceManager.get(self._session, MyForm.create_component_id(self._session), MyForm,
title="Add Workflow",
fields=[FormField("name", 'Workflow Name', 'input')],
htmx_request=self.commands.add_workflow(tab_id),
)
def _mk_workflow(self, workflow_name: str, selected: bool):
elt = mk_ellipsis(workflow_name, cls="text-sm", **self.commands.show_workflow(workflow_name))
if selected:
return Div(
elt,
cls="items-center mmt-selected"
)
else:
return elt
def _mk_workflows(self, oob=False):
return Div(
*[self._mk_workflow(workflow_name, workflow_name == self.db.get_selected_workflow())
for workflow_name in self.db.get_workflows()],
id=f"w_{self._id}",
hx_swap_oob="true" if oob else None,
)

View File

@@ -0,0 +1,35 @@
WORKFLOWS_INSTANCE_ID = "__Workflows__"
WORKFLOW_DESIGNER_INSTANCE_ID = "__WorkflowDesigner__"
WORKFLOW_PLAYER_INSTANCE_ID = "__WorkflowPlayer__"
WORKFLOWS_DB_ENTRY = "Workflows"
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"
ROUTE_ROOT = "/workflows"
class Routes:
AddWorkflow = "/add-workflow"
SelectWorkflow = "/select-workflow"
ShowWorkflow = "/show-workflow"
SelectComponent = "/select-component"
AddComponent = "/add-component"
MoveComponent = "/move-component"
DeleteComponent = "/delete-component"
AddConnection = "/add-connection"
DeleteConnection = "/delete-connection"
ResizeDesigner = "/resize-designer"
SaveProperties = "/save-properties"
CancelProperties = "/cancel-properties"
SelectProcessor = "/select-processor"
OnProcessorDetailsEvent = "/on-processor-details-event"
PlayWorkflow = "/play-workflow"
PauseWorkflow = "/pause-workflow"
StopWorkflow = "/stop-workflow"

View File

@@ -0,0 +1,196 @@
import enum
import logging
from dataclasses import dataclass, field
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
logger = logging.getLogger("WorkflowsSettings")
class ComponentState(enum.Enum):
"""
Represents the execution state of a workflow component.
"""
SUCCESS = "success"
FAILURE = "failure"
NOT_RUN = "not_run"
# Data structures
@dataclass
class WorkflowComponent:
id: str
type: str
x: int
y: int
title: str
description: str
properties: dict
@dataclass
class Connection:
id: str
from_id: str
to_id: str
@dataclass
class WorkflowComponentRuntimeState:
"""
Represents the runtime state of a single workflow component.
"""
id: str
state: ComponentState = ComponentState.SUCCESS
error_message: str | None = None
@dataclass
class WorkflowsDesignerSettings:
workflow_name: str = "No Name"
@dataclass
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
@dataclass
class WorkflowsSettings:
workflows: list[str] = field(default_factory=list)
selected_workflow: str = None
class WorkflowsDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager):
self.session = session
self.settings_manager = settings_manager
def add_workflow(self, workflow_name: str):
settings = self._get_settings()
if not workflow_name:
raise ValueError("Workflow name cannot be empty.")
if workflow_name in settings.workflows:
raise ValueError(f"Workflow '{workflow_name}' already exists.")
settings.workflows.append(workflow_name)
self.settings_manager.save(self.session, WORKFLOWS_DB_ENTRY, settings)
return True
def get_workflow(self, workflow_name: str):
if not workflow_name:
raise ValueError("Workflow name cannot be empty.")
settings = self._get_settings()
if workflow_name not in settings.workflows:
raise ValueError(f"Workflow '{workflow_name}' does not exist.")
return next(filter(lambda r: r == workflow_name, settings.workflows))
# def modify_workflow(self, old_workflow_name, new_workflow_name: str, tables: list[str]):
# if not old_workflow_name or not new_workflow_name:
# raise ValueError("Workflow name cannot be empty.")
#
# settings = self._get_settings()
# for workflow in settings.workflows:
# if workflow == old_workflow_name:
# workflow.name = new_workflow_name
# workflow.tables = tables
#
# self.settings_manager.save(self.session, workflows_SETTINGS_ENTRY, settings)
# return workflow
#
# else:
# raise ValueError(f"workflow '{old_workflow_name}' not found.")
def remove_workflow(self, workflow_name):
if not workflow_name:
raise ValueError("Workflow name cannot be empty.")
settings = self._get_settings()
if workflow_name not in settings.workflows:
raise ValueError(f"workflow '{workflow_name}' does not exist.")
settings.workflows.remove(workflow_name)
self.settings_manager.save(self.session, WORKFLOWS_DB_ENTRY, settings)
return True
def exists_workflow(self, workflow_name):
if not workflow_name:
raise ValueError("workflow name cannot be empty.")
settings = self._get_settings()
return workflow_name in settings.workflows
def get_workflows(self):
return self._get_settings().workflows
def select_workflow(self, workflow_name: str):
"""
Select and save the specified workflow name in the current session's settings.
:param workflow_name: The name of the workflow to be selected and stored.
:type workflow_name: str
:return: None
"""
settings = self._get_settings()
settings.selected_workflow = workflow_name
self.settings_manager.save(self.session, WORKFLOWS_DB_ENTRY, settings)
def get_selected_workflow(self):
settings = self._get_settings()
return settings.selected_workflow
def _get_settings(self):
return self.settings_manager.load(self.session, WORKFLOWS_DB_ENTRY, default=WorkflowsSettings())
class WorkflowsDesignerDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager):
self._session = session
self._settings_manager = settings_manager
@staticmethod
def _get_db_entry(key):
return f"{WORKFLOW_DESIGNER_DB_ENTRY}_{key}"
def save_settings(self, key: str, settings: WorkflowsDesignerSettings):
self._settings_manager.put(self._session,
self._get_db_entry(key),
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY,
settings)
def save_state(self, key: str, state: WorkflowsDesignerState):
self._settings_manager.put(self._session,
self._get_db_entry(key),
WORKFLOW_DESIGNER_DB_STATE_ENTRY,
state)
def save_all(self, key: str, settings: WorkflowsDesignerSettings = None, state: WorkflowsDesignerState = None):
items = {}
if settings is not None:
items[WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY] = settings
if state is not None:
items[WORKFLOW_DESIGNER_DB_STATE_ENTRY] = state
self._settings_manager.put_many(self._session, self._get_db_entry(key), items)
def load_settings(self, key) -> WorkflowsDesignerSettings:
return self._settings_manager.get(self._session,
self._get_db_entry(key),
WORKFLOW_DESIGNER_DB_SETTINGS_ENTRY,
default=WorkflowsDesignerSettings())
def load_state(self, key) -> WorkflowsDesignerState:
return self._settings_manager.get(self._session,
self._get_db_entry(key),
WORKFLOW_DESIGNER_DB_STATE_ENTRY,
default=WorkflowsDesignerState())

View File

@@ -81,7 +81,7 @@ def mk_accordion_section(component_id, title, icon, content, selected=False):
)
def set_boundaries(boundaries, remove_margin=True, other=0):
def apply_boundaries(boundaries, remove_margin=True, other=0):
if isinstance(boundaries, int):
max_height = boundaries
else:

73
src/core/Expando.py Normal file
View File

@@ -0,0 +1,73 @@
class Expando:
"""
Readonly dynamic class that eases the access to attributes and sub attributes
It is initialized with a dict
You can then access the property using dot '.' (ex. obj.prop1.prop2)
"""
def __init__(self, props):
self._props = props
def __getattr__(self, item):
if item not in self._props:
raise AttributeError(item)
current = self._props[item]
return Expando(current) if isinstance(current, dict) else current
def __setitem__(self, key, value):
self._props[key] = value
def get(self, path):
"""
returns the value, from a string with represents the path
:param path:
:return:
"""
current = self._props
for attr in path.split("."):
if isinstance(current, list):
temp = []
for value in current:
if value and attr in value:
temp.append(value[attr])
current = temp
else:
if current is None or attr not in current:
return None
current = current[attr]
return current
def as_dict(self):
"""
Return the information as a dictionary
:return:
"""
return self._props.copy()
def to_dict(self, mappings: dict) -> dict:
return {prop_name: self.get(path) for path, prop_name in mappings.items() if prop_name is not None}
def __hasattr__(self, item):
return item in self._props
def __repr__(self):
if "key" in self._props:
return f"Expando(key={self._props["key"]})"
props_as_str = str(self._props)
if len(props_as_str) > 50:
props_as_str = props_as_str[:50] + "..."
return f"Expando({props_as_str})"
def __eq__(self, other):
if not isinstance(other, Expando):
return False
return self._props == other._props
def __hash__(self):
return hash(tuple(sorted(self._props.items())))

225
src/core/jira.py Normal file
View File

@@ -0,0 +1,225 @@
import json
import logging
import requests
from requests.auth import HTTPBasicAuth
from core.Expando import Expando
JIRA_ROOT = "https://altares.atlassian.net/rest/api/2"
DEFAULT_HEADERS = {"Accept": "application/json"}
logger = logging.getLogger("jql")
class NotFound(Exception):
pass
class Jira:
"""Manage default operation to JIRA"""
def __init__(self, user_name: str, api_token: str):
"""
Prepare a connection to JIRA
The initialisation do not to anything,
It only stores the user_name and the api_token
Note that user_name and api_token is the recommended way to connect,
therefore, the only supported here
:param user_name:
:param api_token:
"""
self.user_name = user_name
self.api_token = api_token
self.auth = HTTPBasicAuth(self.user_name, self.api_token)
def issue(self, issue_id: str) -> Expando:
"""
Retrieve an issue
:param issue_id:
:return:
"""
url = f"{JIRA_ROOT}/issue/{issue_id}"
response = requests.request(
"GET",
url,
headers=DEFAULT_HEADERS,
auth=self.auth
)
return Expando(json.loads(response.text))
def fields(self) -> list[Expando]:
"""
Retrieve the list of all fields for an issue
:return:
"""
url = f"{JIRA_ROOT}/field"
response = requests.request(
"GET",
url,
headers=DEFAULT_HEADERS,
auth=self.auth
)
as_dict = json.loads(response.text)
return [Expando(field) for field in as_dict]
def jql(self, jql: str, fields="summary,status,assignee") -> 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}'")
url = f"{JIRA_ROOT}/search"
headers = DEFAULT_HEADERS.copy()
headers["Content-Type"] = "application/json"
payload = {
"fields": [f.strip() for f in fields.split(",")],
"fieldsByKeys": False,
"jql": jql,
"maxResults": 500, # Does not seem to be used. It's always 100 !
"startAt": 0
}
result = []
while True:
logger.debug(f"Request startAt '{payload['startAt']}'")
response = requests.request(
"POST",
url,
data=json.dumps(payload),
headers=headers,
auth=self.auth
)
if response.status_code != 200:
raise Exception(self._format_error(response))
as_dict = json.loads(response.text)
result += as_dict["issues"]
if as_dict["startAt"] + as_dict["maxResults"] >= as_dict["total"]:
# We retrieve more than the total nuber of items
break
payload["startAt"] += as_dict["maxResults"]
return [Expando(issue) for issue in result]
def extract(self, jql, mappings, updates=None) -> list[dict]:
"""
Executes a jql and returns list of dict
The <code>issue</code> object, returned by the <ref>jql</ref> methods
contains all the fields for Jira. They are not all necessary
This method selects the required fields
:param jql:
:param mappings:
:param updates: List of updates (lambda on issue) to perform
:return:
"""
logger.debug(f"Processing extract using mapping {mappings}")
def _get_field(mapping):
"""Returns the meaningful jira field, for the mapping description path"""
fields = mapping.split(".")
return fields[1] if len(fields) > 1 and fields[0] == "fields" else fields[0]
# retrieve the list of requested fields from what was asked in the mapping
jira_fields = [_get_field(mapping) for mapping in mappings]
as_string = ", ".join(jira_fields)
issues = self.jql(jql, as_string)
for issue in issues:
# apply updates if needed
if updates:
for update in updates:
update(issue)
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
returns fixVersion number in JIRA
:param project_key:
:param version_name:
:return:
"""
for version in self.get_versions(project_key):
if version.name == version_name:
return version
raise NotFound()
def get_all_fields(self):
"""
Helper function that returns the list of all field that can be requested in an issue
:return:
"""
url = f"{JIRA_ROOT}/field"
response = requests.request(
"GET",
url,
headers=DEFAULT_HEADERS,
auth=self.auth
)
as_dict = json.loads(response.text)
return [Expando(issue) for issue in as_dict]
@staticmethod
def update_customer_refs(issue: Expando, bug_only=True, link_name=None):
issue["ticket_customer_refs"] = []
if bug_only and issue.fields.issuetype.name != "Bug":
return
for issue_link in issue.fields.issuelinks: # [i_link for i_link in issue.fields.issuelinks if i_link["type"]["name"] == "Relates"]:
if link_name and issue_link["type"]["name"] not in link_name:
continue
direction = "inwardIssue" if "inwardIssue" in issue_link else "outwardIssue"
related_issue_key = issue_link[direction]["key"]
if related_issue_key.startswith("ITSUP"):
issue.ticket_customer_refs.append(related_issue_key)
continue
@staticmethod
def _format_error(response):
if "errorMessages" in response.text:
error_messages = json.loads(response.text)["errorMessages"]
else:
error_messages = response.text
return f"Error {response.status_code} : {response.reason} : {error_messages}"

View File

@@ -87,7 +87,7 @@ class MemoryDbEngine:
obj.update(items)
def exists(self, user_id: str, entry: str):
return user_id in entry and entry in self.db[user_id]
return user_id in self.db and entry in self.db[user_id]
class SettingsManager:

View File

@@ -1,3 +1,4 @@
import ast
import base64
import hashlib
import importlib
@@ -417,3 +418,50 @@ def split_host_port(url):
port = None
return host, port
class UnreferencedNamesVisitor(ast.NodeVisitor):
"""
Try to find symbols that will be requested by the ast
It can be variable names, but also function names
"""
def __init__(self):
self.names = set()
def get_names(self, node):
self.visit(node)
return self.names
def visit_Name(self, node):
self.names.add(node.id)
def visit_For(self, node: ast.For):
self.visit_selected(node, ["body", "orelse"])
def visit_selected(self, node, to_visit):
"""Called if no explicit visitor function exists for a node."""
for field in to_visit:
value = getattr(node, field)
if isinstance(value, list):
for item in value:
if isinstance(item, ast.AST):
self.visit(item)
elif isinstance(value, ast.AST):
self.visit(value)
def visit_Call(self, node: ast.Call):
self.visit_selected(node, ["args", "keywords"])
def visit_keyword(self, node: ast.keyword):
"""
Keywords are parameters that are defined with a double star (**) in function / method definition
ex: def fun(positional, *args, **keywords)
:param node:
:type node:
:return:
:rtype:
"""
self.names.add(node.arg)
self.visit_selected(node, ["value"])

View File

@@ -148,6 +148,7 @@ register_component("main_layout", "components.drawerlayout", "DrawerLayoutApp")
register_component("tabs", "components.tabs", "TabsApp") # before repositories
register_component("applications", "components.applications", "ApplicationsApp")
register_component("repositories", "components.repositories", "RepositoriesApp")
register_component("workflows", "components.workflows", "WorkflowsApp")
register_component("add_stuff", "components.addstuff", None)
register_component("form", "components.form", "FormApp")
register_component("datagrid_new", "components.datagrid_new", "DataGridApp")

View File

@@ -6,3 +6,4 @@ class ComponentsInstancesHelper:
@staticmethod
def get_repositories(session):
return InstanceManager.get(session, Repositories.create_component_id(session))

View File

@@ -1,6 +1,7 @@
from dataclasses import is_dataclass
from components.datagrid_new.db_management import DataGridDbManager
from core.Expando import Expando
class DataHelper:
@@ -16,6 +17,8 @@ class DataHelper:
if object_type:
if is_dataclass(object_type):
return [object_type(**row) for row in dataframe.to_dict(orient="records")]
elif object_type is Expando:
return [Expando(row) for row in dataframe.to_dict(orient="records")]
else:
raise ValueError("object_type must be a dataclass type")

View File

@@ -0,0 +1,14 @@
from utils.ComponentsInstancesHelper import ComponentsInstancesHelper
class DbManagementHelper:
@staticmethod
def list_repositories(session):
return ComponentsInstancesHelper.get_repositories(session).db.get_repositories()
@staticmethod
def list_tables(session, repository_name):
if not repository_name:
return []
repository = ComponentsInstancesHelper.get_repositories(session).db.get_repository(repository_name)
return repository.tables

View File

@@ -0,0 +1,103 @@
from typing import Any
from core.Expando import Expando
from workflow.engine import DataPresenter
class DefaultDataPresenter(DataPresenter):
"""Default data presenter that returns the input data unchanged."""
def __init__(self, component_id: str, mappings_definition: str):
super().__init__(component_id)
self._mappings_definition = mappings_definition
self._split_definitions = [definition.strip() for definition in mappings_definition.split(",")]
if "*" not in mappings_definition:
self._static_mappings = self._get_static_mappings()
else:
self._static_mappings = None
def present(self, data: Any) -> Any:
self._validate_mappings_definition()
if self._static_mappings:
return Expando(data.to_dict(self._static_mappings))
dynamic_mappings = self._get_dynamic_mappings(data)
return Expando(data.to_dict(dynamic_mappings))
def _get_dynamic_mappings(self, data):
manage_conflicts = {}
mappings = {}
for mapping in self._split_definitions:
if "=" in mapping:
key, value = [s.strip() for s in mapping.split('=', 1)]
if key == "*":
# all fields
if value != "*":
raise ValueError("Only '*' is accepted when renaming wildcard.")
for key in data.as_dict().keys():
if key in manage_conflicts:
raise ValueError(f"Collision detected for field '{key}'. It is mapped from both '{manage_conflicts[key]}' and '{mapping}'.")
manage_conflicts[key] = mapping
mappings[key] = key
elif key.endswith(".*"):
# all fields in a sub-object
if value != "*":
raise ValueError("Only '*' is accepted when renaming wildcard.")
obj_path = key[:-2]
sub_obj = data.get(obj_path)
if isinstance(sub_obj, dict):
for sub_field in sub_obj:
if sub_field in manage_conflicts:
raise ValueError(
f"Collision detected for field '{sub_field}'. It is mapped from both '{manage_conflicts[sub_field]}' and '{mapping}'.")
manage_conflicts[sub_field] = mapping
mappings[f"{obj_path}.{sub_field}"] = sub_field
else:
raise ValueError(f"Field '{obj_path}' is not an object.")
else:
mappings[key.strip()] = value.strip()
else:
if mapping == "*":
# all fields
for key in data.as_dict().keys():
mappings[key] = key
elif mapping.endswith(".*"):
# all fields in a sub-object
obj_path = mapping[:-2]
sub_obj = data.get(obj_path)
if isinstance(sub_obj, dict):
for sub_field in sub_obj:
mappings[f"{obj_path}.{sub_field}"] = f"{obj_path}.{sub_field}"
else:
raise ValueError(f"Field '{obj_path}' is not an object.")
else:
mappings[mapping] = mapping
return mappings
def _get_static_mappings(self):
mappings = {}
for mapping in self._split_definitions:
if "=" in mapping:
key, value = [s.strip() for s in mapping.split('=', 1)]
mappings[key] = value
else:
mappings[mapping] = mapping
return mappings
def _validate_mappings_definition(self):
last_char_was_comma = False
for i, char in enumerate(self._mappings_definition):
if char == ',':
if last_char_was_comma:
raise ValueError(f"Invalid mappings definition: Error found at index {i}")
last_char_was_comma = True
elif not char.isspace():
last_char_was_comma = False

0
src/workflow/__init__.py Normal file
View File

185
src/workflow/engine.py Normal file
View File

@@ -0,0 +1,185 @@
import ast
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.utils import UnreferencedNamesVisitor
from utils.Datahelper import DataHelper
class DataProcessorError(Exception):
def __init__(self, component_id, error):
self.component_id = component_id
self.error = error
class DataProcessor(ABC):
"""Base class for all data processing components."""
def __init__(self, component_id: str = None):
self.component_id = component_id
@abstractmethod
def process(self, data: Any) -> Generator[Any, None, None]:
pass
class DataProducer(DataProcessor):
"""Base class for data producers that emit data using generators."""
@abstractmethod
def emit(self, data: Any = None) -> Generator[Any, None, None]:
"""Emit data items one by one using yield. Can augment input data."""
pass
def process(self, data: Any) -> Generator[Any, None, None]:
try:
yield from self.emit(data)
except Exception as e:
raise DataProcessorError(self.component_id, e)
class DataFilter(DataProcessor):
"""Base class for data filters that process data items."""
@abstractmethod
def filter(self, data: Any) -> bool:
"""Filter data items. Return True to keep the item, False to discard it."""
pass
def process(self, data: Any) -> Generator[Any, None, None]:
try:
if self.filter(data):
yield data
except Exception as e:
raise DataProcessorError(self.component_id, e)
class DataPresenter(DataProcessor):
"""Base class for data presenters that transform data items."""
@abstractmethod
def present(self, data: Any) -> Any:
"""Present/transform data items."""
pass
def process(self, data: Any) -> Generator[Any, None, None]:
try:
yield self.present(data)
except Exception as e:
raise DataProcessorError(self.component_id, e)
class TableDataProducer(DataProducer):
"""Base class for data producers that emit data from a repository."""
def __init__(self, session, settings_manager, component_id, repository_name, table_name):
super().__init__(component_id)
self._session = session
self.settings_manager = settings_manager
self.repository_name = repository_name
self.table_name = table_name
def emit(self, data: Any = None) -> Generator[Any, None, None]:
yield from DataHelper.get(self._session, self.settings_manager, self.repository_name, self.table_name, Expando)
class JiraDataProducer(DataProducer):
"""Base class for data producers that emit data from Jira."""
def __init__(self, session, settings_manager, component_id, jira_object='issues', jira_query=''):
super().__init__(component_id)
self._session = session
self.settings_manager = settings_manager
self.jira_object = jira_object
self.jira_query = jira_query
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)
yield from jira.jql(self.jira_query)
class DefaultDataFilter(DataFilter):
def __init__(self, component_id: str, filter_expression: str):
super().__init__(component_id)
self.filter_expression = filter_expression
self._ast_tree = ast.parse(filter_expression, "<user input>", 'eval')
self._compiled = compile(self._ast_tree, "<string>", "eval")
visitor = UnreferencedNamesVisitor()
self._unreferenced_names = visitor.get_names(self._ast_tree)
"""Default data filter that returns True for all data items."""
def filter(self, data: Any) -> bool:
my_locals = {name: data.get(name) for name in self._unreferenced_names if hasattr(data, name)}
return eval(self._compiled, globals(), my_locals)
class WorkflowEngine:
"""Orchestrates the data processing pipeline using generators."""
def __init__(self):
self.processors: list[DataProcessor] = []
self.has_error = False
self.global_error = None
self.errors = {}
def add_processor(self, processor: DataProcessor) -> 'WorkflowEngine':
"""Add a data processor to the pipeline."""
self.processors.append(processor)
return self
def _process_single_item(self, item: Any, processor_index: int = 0) -> Generator[Any, None, None]:
"""Process a single item through the remaining processors."""
if processor_index >= len(self.processors):
yield item
return
processor = self.processors[processor_index]
# Process the item through the current processor
for processed_item in processor.process(item):
# Recursively process through remaining processors
yield from self._process_single_item(processed_item, processor_index + 1)
def run(self) -> Generator[Any, None, None]:
"""
Run the workflow pipeline and yield results one by one.
The first processor must be a DataProducer.
"""
if not self.processors:
self.has_error = False
self.global_error = "No processors in the pipeline"
raise ValueError(self.global_error)
first_processor = self.processors[0]
if not isinstance(first_processor, DataProducer):
self.has_error = False
self.global_error = "First processor must be a DataProducer"
raise ValueError(self.global_error)
for item in first_processor.process(None):
yield from self._process_single_item(item, 1)
def run_to_list(self) -> list[Any]:
"""
Run the workflow and return all results as a list.
Use this method when you need all results at once.
"""
try:
return list(self.run())
except DataProcessorError as err:
self.has_error = True
self.errors[err.component_id] = err.error
return []
except Exception as err:
self.has_error = True
self.global_error = str(err)
return []

View File

@@ -420,7 +420,8 @@ def matches(actual, expected, path=""):
assert matches(actual_child, expected_child)
elif isinstance(expected, NotStr):
assert actual.s.lstrip('\n').startswith(expected.s), \
to_compare = actual.s.lstrip('\n').lstrip()
assert to_compare.startswith(expected.s), \
f"{print_path(path)}NotStr are different: '{actual.s.lstrip('\n')}' != '{expected.s}'."
elif hasattr(actual, "tag"):
@@ -741,10 +742,20 @@ def _get_element_value(element):
def icon(name: str):
"""
Test if an element is an icon
:param name:
:return:
"""
return NotStr(f'<svg name="{name}"')
def div_icon(name: str):
"""
Test if an element is an icon wrapped in a div
:param name:
:return:
"""
return Div(NotStr(f'<svg name="{name}"'))

34
tests/my_mocks.py Normal file
View File

@@ -0,0 +1,34 @@
from unittest.mock import MagicMock
import pytest
from fasthtml.components import *
from components.tabs.components.MyTabs import MyTabs
@pytest.fixture
def tabs_manager():
class MockTabsManager(MagicMock):
def __init__(self, *args, **kwargs):
super().__init__(*args, spec=MyTabs, **kwargs)
self.request_new_tab_id = MagicMock(side_effect=["new_tab_id", "new_tab_2", "new_tab_3", StopIteration])
self.tabs = {}
self.tabs_by_key = {}
def add_tab(self, title, content, key: str | tuple = None, tab_id: str = None, icon=None):
self.tabs[tab_id] = (title, content)
self.tabs_by_key[key] = (title, content)
def set_tab_content(self, tab_id, content, title=None, key: str | tuple = None, active=None):
self.tabs[tab_id] = (title, content)
self.tabs_by_key[key] = (title, content)
def refresh(self):
return Div(
Div(
[Div(title) for title in self.tabs.keys()]
),
list(self.tabs.values())[-1]
)
return MockTabsManager()

66
tests/test_data_helper.py Normal file
View File

@@ -0,0 +1,66 @@
from dataclasses import dataclass
import pandas as pd
import pytest
from components.datagrid_new.components.DataGrid import DataGrid
from core.Expando import Expando
from core.settings_management import SettingsManager, MemoryDbEngine
from utils.Datahelper import DataHelper
TEST_GRID_ID = "testing_grid_id"
TEST_GRID_KEY = ("RepoName", "TableName")
@pytest.fixture()
def settings_manager():
return SettingsManager(MemoryDbEngine())
@pytest.fixture()
def datagrid(session, settings_manager):
dg = DataGrid(session,
_id=TEST_GRID_ID,
settings_manager=settings_manager,
key=TEST_GRID_KEY,
boundaries={"height": 500, "width": 800})
df = pd.DataFrame({
'Name': ['Alice', 'Bob'],
'Age': [20, 25],
'Is Student': [True, False],
})
dg.init_from_dataframe(df, save_state=True)
return dg
def test_i_can_get_data_as_dataframe(session, settings_manager, datagrid):
res = DataHelper.get(session, settings_manager, "RepoName", "TableName")
assert isinstance(res, pd.DataFrame)
assert res.equals(datagrid.get_dataframe())
def test_i_can_get_data_as_dataclass(session, settings_manager, datagrid):
@dataclass
class DataclassTestClass:
name: str
age: int
is_student: bool
res = DataHelper.get(session, settings_manager, "RepoName", "TableName", DataclassTestClass)
assert isinstance(res, list)
assert res == [
DataclassTestClass("Alice", 20, True),
DataclassTestClass("Bob", 25, False),
]
def test_i_can_get_data_as_expando(session, settings_manager, datagrid):
res = DataHelper.get(session, settings_manager, "RepoName", "TableName", Expando)
assert isinstance(res, list)
assert res == [
Expando({"name": "Alice", "age": 20, "is_student": True}),
Expando({"name": "Bob", "age": 25, "is_student": False})
]

View File

@@ -0,0 +1,186 @@
import pytest
from core.Expando import Expando
from workflow.DefaultDataPresenter import DefaultDataPresenter
def test_i_can_present_static_mappings():
mappings_def = "field1 = renamed_1 , field2 "
presenter = DefaultDataPresenter("comp_id", mappings_def)
data = Expando({"field1": "value1", "field2": "value2", "field3": "value3"})
actual = presenter.present(data)
assert actual == Expando({"renamed_1": "value1", "field2": "value2"}) # field3 is removed
def test_the_latter_mappings_take_precedence():
mappings_def = "field1 = renamed_1 , field1 "
presenter = DefaultDataPresenter("comp_id", mappings_def)
data = Expando({"field1": "value1", "field2": "value2", "field3": "value3"})
actual = presenter.present(data)
assert actual == Expando({"field1": "value1"}) # field3 is removed
def test_i_can_present_static_mappings_with_sub_fields():
mappings_def = "root.field1 = renamed_1 , root.field2, root.sub_field.field3, root.sub_field.field4=renamed4 "
presenter = DefaultDataPresenter("comp_id", mappings_def)
as_dict = {"root": {"field1": "value1",
"field2": "value2",
"sub_field": {"field3": "value3",
"field4": "value4"
}}}
data = Expando(as_dict)
actual = presenter.present(data)
assert isinstance(actual, Expando)
assert actual.as_dict() == {"renamed_1": "value1",
"root.field2": "value2",
"root.sub_field.field3": "value3",
"renamed4": "value4"}
def test_i_can_present_dynamic_mappings():
mappings_def = "*"
presenter = DefaultDataPresenter("comp_id", mappings_def)
data = Expando({"field1": "value1", "field2": "value2", "field3": "value3"})
actual = presenter.present(data)
assert actual == Expando({"field1": "value1", "field2": "value2", "field3": "value3"})
def test_i_can_present_dynamic_mappings_for_complex_data():
mappings_def = "*"
presenter = DefaultDataPresenter("comp_id", mappings_def)
as_dict = {"root": {"field1": "value1",
"field2": "value2",
"sub_field": {"field3": "value3",
"field4": "value4"
}
},
"field5": "value5"}
data = Expando(as_dict)
actual = presenter.present(data)
assert isinstance(actual, Expando)
assert actual.as_dict() == as_dict
def test_i_can_present_dynamic_mappings_with_sub_fields():
mappings_def = "root.sub_field.*"
presenter = DefaultDataPresenter("comp_id", mappings_def)
as_dict = {"root": {"field1": "value1",
"field2": "value2",
"sub_field": {"field3": "value3",
"field4": "value4"
}}}
data = Expando(as_dict)
actual = presenter.present(data)
assert isinstance(actual, Expando)
assert actual.as_dict() == {"root.sub_field.field3": "value3",
"root.sub_field.field4": "value4"}
def test_i_can_present_dynamic_mappings_with_sub_fields_and_renames():
mappings_def = "root.sub_field.*=*"
presenter = DefaultDataPresenter("comp_id", mappings_def)
as_dict = {"root": {"field1": "value1",
"field2": "value2",
"sub_field": {"field3": "value3",
"field4": "value4"
}}}
data = Expando(as_dict)
actual = presenter.present(data)
assert isinstance(actual, Expando)
assert actual.as_dict() == {"field3": "value3",
"field4": "value4"}
def test_i_can_present_dynamic_mappings_and_rename_them():
mappings_def = "*=*" # does not really have effects as '*' only goes down one level
presenter = DefaultDataPresenter("comp_id", mappings_def)
as_dict = {"root1": {"field1": "value1",
"field2": "value2"},
"root2": {"field3": "value3",
"field4": "value4"}}
data = Expando(as_dict)
actual = presenter.present(data)
assert isinstance(actual, Expando)
assert actual.as_dict() == as_dict
def test_i_can_present_static_and_dynamic_mappings():
mappings_def = "root.field1 = renamed_1, root.sub_field.*"
presenter = DefaultDataPresenter("comp_id", mappings_def)
as_dict = {"root": {"field1": "value1",
"field2": "value2",
"sub_field": {"field3": "value3",
"field4": "value4"
}}}
data = Expando(as_dict)
actual = presenter.present(data)
assert isinstance(actual, Expando)
assert actual.as_dict() == {"renamed_1": "value1",
"root.sub_field.field3": "value3",
"root.sub_field.field4": "value4"}
def test_another_example_of_static_and_dynamic_mappings():
mappings_def = "* , field1 = renamed_1"
presenter = DefaultDataPresenter("comp_id", mappings_def)
data = Expando({"field1": "value1", "field2": "value2", "field3": "value3"})
actual = presenter.present(data)
assert actual == Expando({"renamed_1": "value1", "field2": "value2", "field3": "value3"}) # field3 is removed
def test_i_can_detect_conflict_when_dynamically_renaming_a_field():
mappings_def = "root_1.*=*, root_2.*=*"
presenter = DefaultDataPresenter("comp_id", mappings_def)
as_dict = {"root_1": {"field1": "value1",
"field2": "value2"},
"root_2": {"field1": "value1",
"field2": "value2"}}
data = Expando(as_dict)
with pytest.raises(ValueError) as e:
presenter.present(data)
assert str(e.value) == "Collision detected for field 'field1'. It is mapped from both 'root_1.*=*' and 'root_2.*=*'."
def test_i_can_detect_declaration_error():
mappings_def = "field1 ,, field2"
presenter = DefaultDataPresenter("comp_id", mappings_def)
data = Expando({"field1": "value1", "field2": "value2", "field3": "value3"})
with pytest.raises(ValueError) as e:
presenter.present(data)
def test_i_can_detect_dynamic_error_declaration():
mappings_def = "root.field1.*" # field1 is not an object
presenter = DefaultDataPresenter("comp_id", mappings_def)
as_dict = {"root": {"field1": "value1",
"field2": "value2",
"sub_field": {"field3": "value3",
"field4": "value4"
}}}
data = Expando(as_dict)
with pytest.raises(ValueError) as e:
presenter.present(data)

75
tests/test_expando.py Normal file
View File

@@ -0,0 +1,75 @@
import pytest
from core.Expando import Expando
def test_i_can_get_properties():
props = {"a": 10,
"b": {
"c": "value",
"d": 20
}}
dynamic = Expando(props)
assert dynamic.a == 10
assert dynamic.b.c == "value"
with pytest.raises(AttributeError):
assert dynamic.unknown == "some_value"
def test_i_can_get():
props = {"a": 10,
"b": {
"c": "value",
"d": 20
}}
dynamic = Expando(props)
assert dynamic.get("a") == 10
assert dynamic.get("b.c") == "value"
assert dynamic.get("unknown") is None
def test_i_can_get_from_list():
props = {"a": [{"c": "value1", "d": 1}, {"c": "value2", "d": 2}]}
dynamic = Expando(props)
assert dynamic.get("a.c") == ["value1", "value2"]
def test_none_is_returned_when_get_from_list_and_property_does_not_exist():
props = {"a": [{"c": "value1", "d": 1},
{"a": "value2", "d": 2} # 'c' does not exist in the second row
]}
dynamic = Expando(props)
assert dynamic.get("a.c") == ["value1"]
def test_i_can_manage_none_values():
props = {"a": 10,
"b": None}
dynamic = Expando(props)
assert dynamic.get("b.c") is None
def test_i_can_manage_none_values_in_list():
props = {"a": [{"b": {"c": "value"}},
{"b": None}
]}
dynamic = Expando(props)
assert dynamic.get("a.b.c") == ["value"]
def test_i_can_add_new_properties():
props = {"a": 10,
"b": 20}
dynamic = Expando(props)
dynamic["c"] = 30
assert dynamic.a == 10
assert dynamic.b == 20
assert dynamic.c == 30

View File

@@ -11,7 +11,7 @@ def sample_structure():
"""
A pytest fixture to provide a sample tree structure for testing.
"""
return Html(
return Div(
Header(cls="first-class"),
Body(
"hello world",
@@ -26,13 +26,13 @@ def sample_structure():
@pytest.mark.parametrize("value, expected, expected_error", [
(Div(), "value",
"The types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=div((),{})\nexpected=value."),
"The types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=<div></div>\nexpected=value."),
(Div(), A(),
"The elements are different: 'div' != 'a'."),
(Div(Div()), Div(A()),
"Path 'div':\n\tThe elements are different: 'div' != 'a'."),
(Div(A(Span())), Div(A("element")),
"Path 'div.a':\n\tThe types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=span((),{})\nexpected=element."),
"Path 'div.a':\n\tThe types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=<span></span>\nexpected=element."),
(Div(attr="one"), Div(attr="two"),
"Path 'div':\n\tThe values are different for 'attr' : 'one' != 'two'."),
(Div(A(attr="alpha")), Div(A(attr="beta")),

View File

@@ -25,7 +25,7 @@ def tabs_manager():
self._called_methods: list[tuple] = []
def add_tab(self, *args, **kwargs):
self._called_methods.append(("set_tab_content", args, kwargs))
self._called_methods.append(("add_tab", args, kwargs))
table_name, content, key = args
self.tabs.append({"table_name": table_name, "content": content, "key": key})

View File

@@ -5,10 +5,13 @@
# assert column_to_number("A") == 1
# assert column_to_number("AA") == 27
# assert column_to_number("ZZZ") == 475254
import ast
import pytest
from fasthtml.components import Div
from core.utils import make_html_id, update_elements, snake_case_to_capitalized_words, merge_classes
from core.utils import make_html_id, update_elements, snake_case_to_capitalized_words, merge_classes, \
UnreferencedNamesVisitor
@pytest.mark.parametrize("string, expected", [
@@ -110,7 +113,7 @@ def test_i_can_merge_cls():
kwargs = {}
assert merge_classes("class1", kwargs) == "class1"
assert kwargs == {}
kwargs = {"foo": "bar"}
assert merge_classes("class1", kwargs) == "class1"
assert kwargs == {"foo": "bar"}
@@ -127,4 +130,21 @@ def test_i_can_merge_cls():
assert merge_classes("class1", ("class2", "class3")) == "class1 class2 class3"
# values are unique
assert merge_classes("class2", "class1", ("class1", ), {"cls": "class1"}) == "class2 class1"
assert merge_classes("class2", "class1", ("class1",), {"cls": "class1"}) == "class2 class1"
@pytest.mark.parametrize("source, expected", [
("a,b", {"a", "b"}),
("isinstance(a, int)", {"a", "int"}),
("date.today()", set()),
("test()", set()),
("sheerka.test()", set()),
("for i in range(10): pass", set()),
("func(x=a, y=b)", {"a", "b", "x", "y"}),
])
def test_i_can_get_unreferenced_variables_from_simple_expressions(source, expected):
ast_ = ast.parse(source)
visitor = UnreferencedNamesVisitor()
visitor.visit(ast_)
assert visitor.names == expected

View File

@@ -0,0 +1,154 @@
import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from fasthtml.xtend import Script
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.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, Contains
from my_mocks import tabs_manager
TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
@pytest.fixture
def designer(session, tabs_manager):
return WorkflowDesigner(session=session, _id=TEST_WORKFLOW_DESIGNER_ID,
settings_manager=SettingsManager(engine=MemoryDbEngine()),
tabs_manager=tabs_manager,
key=TEST_WORKFLOW_DESIGNER_ID,
designer_settings=WorkflowsDesignerSettings("Workflow Name"),
boundaries={"height": 500, "width": 800}
)
@pytest.fixture
def producer_component():
return WorkflowComponent(
"comp_producer",
ProcessorTypes.Producer,
10,
100,
COMPONENT_TYPES[ProcessorTypes.Producer]["title"],
COMPONENT_TYPES[ProcessorTypes.Producer]["description"],
{"processor_name": ProcessorTypes.Producer[0]}
)
@pytest.fixture
def filter_component():
return WorkflowComponent(
"comp_filter",
ProcessorTypes.Filter,
40,
100,
COMPONENT_TYPES[ProcessorTypes.Filter]["title"],
COMPONENT_TYPES[ProcessorTypes.Filter]["description"],
{"processor_name": ProcessorTypes.Filter[0]}
)
@pytest.fixture
def presenter_component():
return WorkflowComponent(
"comp_presenter",
ProcessorTypes.Presenter,
70,
100,
COMPONENT_TYPES[ProcessorTypes.Presenter]["title"],
COMPONENT_TYPES[ProcessorTypes.Presenter]["description"],
{"processor_name": ProcessorTypes.Presenter[0]}
)
@pytest.fixture
def components(producer_component, filter_component, presenter_component):
return [producer_component, filter_component, presenter_component]
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."),
Div(id=f"t_{designer.get_id()}"), # media + error message
Div(id=f"d_{designer.get_id()}"), # designer container
Div(cls="wkf-splitter"),
Div(id=f"p_{designer.get_id()}"), # properties panel
Script(f"bindWorkflowDesigner('{designer.get_id()}');"),
id=designer.get_id(),
)
assert matches(actual, expected)
def test_i_can_render_a_producer(designer, producer_component):
component = producer_component
actual = designer._mk_component(component)
expected = Div(
# input connection point
Div(cls="wkf-connection-point wkf-input-point",
data_component_id=component.id,
data_point_type="input"
),
# Component content
Div(
Span(COMPONENT_TYPES[component.type]["icon"]),
H4(component.title),
cls=Contains("wkf-component-content")
),
# Output connection point
Div(cls="wkf-connection-point wkf-output-point",
data_component_id=component.id,
data_point_type="output"
),
cls=Contains("wkf-workflow-component"),
style=f"left: {component.x}px; top: {component.y}px;",
data_component_id=component.id,
draggable="true"
)
assert matches(actual, expected)
def test_i_can_render_a_connection(designer, components):
designer._state.components = {c.id: c for c in components}
connection = Connection("conn_1", "comp_producer", "comp_presenter")
actual = designer._mk_connection_svg(connection)
path = "M 138 132 C 104.0 132, 104.0 132, 70 132"
expected = f"""
<svg class="wkf-connection-line" style="left: 0; top: 0; width: 100%; height: 100%;"
data-from-id="{connection.from_id}" data-to-id="{connection.to_id}">
<path d="{path}" class="wkf-connection-path-thick"/>
<path d="{path}" class="wkf-connection-path" marker-end="url(#arrowhead)"/>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" class="wkf-connection-path-arrowhead"/>
</marker>
</defs>
</svg>
"""
assert actual == expected
def test_i_can_render_elements_with_connections(designer, components):
designer._state.components = {c.id: c for c in components}
designer._state.connections = [Connection("conn_1", components[0].id, components[1].id),
Connection("conn_2", components[1].id, components[2].id)]
actual = designer._mk_elements()
expected = Div(
NotStr('<svg class="wkf-connection-line"'), # connection 1
NotStr('<svg class="wkf-connection-line"'), # connection 2
Div(cls=Contains("wkf-workflow-component")),
Div(cls=Contains("wkf-workflow-component")),
Div(cls=Contains("wkf-workflow-component")),
)
assert matches(actual, expected)

View File

@@ -0,0 +1,164 @@
from unittest.mock import MagicMock
import pytest
from core.Expando import Expando
from workflow.DefaultDataPresenter import DefaultDataPresenter
from workflow.engine import WorkflowEngine, DataProcessor, DataProducer, DataFilter, DataPresenter
@pytest.fixture
def engine():
"""Fixture that provides a fresh WorkflowEngine instance for each test."""
return WorkflowEngine()
@pytest.fixture
def presenter_sample_data():
return Expando({
"id": 123,
"title": "My Awesome Task",
"creator": {
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com"
},
"assignee": {
"id": 2,
"name": "Jane Smith",
"email": "jane.smith@example.com"
}
})
def test_empty_workflow_initialization(engine):
"""Test that a new WorkflowEngine has no processors."""
assert len(engine.processors) == 0
def test_add_processor(engine):
"""Test adding processors to the workflow."""
mock_processor = MagicMock(spec=DataProcessor)
engine.add_processor(mock_processor)
assert len(engine.processors) == 1
assert engine.processors[0] is mock_processor
def test_run_empty_workflow(engine):
"""Test that running an empty workflow raises ValueError."""
with pytest.raises(ValueError, match="No processors in the pipeline"):
list(engine.run())
def test_run_without_producer_first(engine):
"""Test that running a workflow without a DataProducer first raises ValueError."""
mock_filter = MagicMock(spec=DataFilter)
engine.add_processor(mock_filter)
with pytest.raises(ValueError, match="First processor must be a DataProducer"):
list(engine.run())
def test_run_simple_workflow(engine):
"""Test running a workflow with just a producer."""
class SimpleProducer(DataProducer):
def emit(self, data=None):
yield 1
yield 2
yield 3
engine.add_processor(SimpleProducer())
result = list(engine.run())
assert result == [1, 2, 3]
def test_process_single_item(engine):
"""Test the internal _process_single_item method."""
mock_processor = MagicMock(spec=DataProcessor)
mock_processor.process.return_value = iter([42])
engine.add_processor(mock_processor)
result = list(engine._process_single_item(10, 0)) # 10 is a dummy value for the first item
mock_processor.process.assert_called_once_with(10)
assert result == [42]
def test_run_to_list(engine):
"""Test run_to_list returns all results as a list."""
class SimpleProducer(DataProducer):
def emit(self, data=None):
yield 1
yield 2
engine.add_processor(SimpleProducer())
result = engine.run_to_list()
assert result == [1, 2]
assert isinstance(result, list)
def test_complex_workflow():
"""Test a complex workflow with multiple processors."""
# Define test processors
class NumberProducer(DataProducer):
def emit(self, data=None):
for i in range(1, 6): # 1 to 5
yield i
class EvenFilter(DataFilter):
def filter(self, data):
return data % 2 == 0 # Keep even numbers
class Doubler(DataPresenter):
def present(self, data):
return data * 2
# Create and run workflow
workflow = WorkflowEngine()
workflow.add_processor(NumberProducer())
workflow.add_processor(EvenFilter())
workflow.add_processor(Doubler())
result = workflow.run_to_list()
assert result == [4, 8] # Even numbers (2, 4) doubled
def test_branching_workflow(engine):
"""Test a workflow with branching outputs."""
class BranchingProcessor(DataProducer):
def emit(self, data=None):
yield data
yield data * 10
class SimpleProducer(DataProducer):
def emit(self, data=None):
yield 1
yield 2
engine.add_processor(SimpleProducer())
engine.add_processor(BranchingProcessor())
result = engine.run_to_list()
assert result == [1, 10, 2, 20]
def test_presenter_i_can_use_wildcards(presenter_sample_data):
presenter1 = DefaultDataPresenter("component_id", "id, creator.*")
res = presenter1.present(presenter_sample_data).as_dict()
assert res == {"id": 123, "creator.id": 1, "creator.name": "John Doe", "creator.email": "john.doe@example.com"}
def test_presenter_i_can_rename_wildcard_with_specific_override(presenter_sample_data):
presenter1 = DefaultDataPresenter("component_id", "creator.*=*, creator.name=author_name")
res = presenter1.present(presenter_sample_data).as_dict()
assert res == {"id": 1, "email": "john.doe@example.com", "author_name": "John Doe"}
def test_presenter_i_can_manage_collisions(presenter_sample_data):
presenter1 = DefaultDataPresenter("component_id", "creator.*=*, assignee.*=*")
with pytest.raises(ValueError, match="Collision detected for field"):
presenter1.present(presenter_sample_data).as_dict()

View File

@@ -0,0 +1,216 @@
from unittest.mock import MagicMock
import pandas as pd
import pytest
from pandas.testing import assert_frame_equal
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.settings_management import SettingsManager, MemoryDbEngine
from my_mocks import tabs_manager
from workflow.engine import DataProcessorError
TEST_WORKFLOW_DESIGNER_ID = "workflow_designer_id"
TEST_WORKFLOW_PLAYER_ID = "workflow_player_id"
@pytest.fixture
def settings_manager():
return SettingsManager(MemoryDbEngine())
@pytest.fixture
def designer(session, settings_manager, tabs_manager):
components = [
WorkflowComponent(
"comp_producer",
ProcessorTypes.Producer,
10, 100,
COMPONENT_TYPES[ProcessorTypes.Producer]["title"],
COMPONENT_TYPES[ProcessorTypes.Producer]["description"],
{"processor_name": "Repository"}
),
WorkflowComponent(
"comp_filter",
ProcessorTypes.Filter,
40, 100,
COMPONENT_TYPES[ProcessorTypes.Filter]["title"],
COMPONENT_TYPES[ProcessorTypes.Filter]["description"],
{"processor_name": "Default"}
),
WorkflowComponent(
"comp_presenter",
ProcessorTypes.Presenter,
70, 100,
COMPONENT_TYPES[ProcessorTypes.Presenter]["title"],
COMPONENT_TYPES[ProcessorTypes.Presenter]["description"],
{"processor_name": "Default"}
)
]
connections = [
Connection("conn_1", "comp_producer", "comp_filter"),
Connection("conn_2", "comp_filter", "comp_presenter"),
]
designer = WorkflowDesigner(
session,
TEST_WORKFLOW_DESIGNER_ID,
settings_manager,
tabs_manager,
"Workflow Designer",
WorkflowsDesignerSettings(workflow_name="Test Workflow"),
{"height": 500, "width": 800}
)
designer._state.components = {c.id: c for c in components}
designer._state.connections = connections
return designer
@pytest.fixture
def player(session, settings_manager, tabs_manager, designer):
"""
Sets up a standard WorkflowPlayer instance with a 3-component linear workflow.
A helper method 'get_dataframe' is attached for easier testing.
"""
return WorkflowPlayer(session=session,
_id=TEST_WORKFLOW_PLAYER_ID,
settings_manager=settings_manager,
tabs_manager=tabs_manager,
designer=designer,
boundaries={"height": 500, "width": 800}
)
def test_run_successful_workflow(player, mocker):
"""
Tests the "happy path" where the workflow runs successfully from start to finish.
"""
# 1. Arrange: Mock a successful engine run
mock_engine = MagicMock()
mock_engine.has_error = False
mock_result_data = [
MagicMock(as_dict=lambda: {'col_a': 1, 'col_b': 'x'}),
MagicMock(as_dict=lambda: {'col_a': 2, 'col_b': 'y'})
]
mock_engine.run_to_list.return_value = mock_result_data
mocker.patch.object(player, '_get_engine', return_value=mock_engine)
# 2. Act
player.run()
# 3. Assert: Check for success state and correct data
assert not player.has_error
assert player.global_error is None
for component_id, state in player.runtime_states.items():
assert state.state == ComponentState.SUCCESS
player._get_engine.assert_called_once()
mock_engine.run_to_list.assert_called_once()
expected_df = pd.DataFrame([row.as_dict() for row in mock_result_data])
assert_frame_equal(player.get_dataframe(), expected_df)
def test_run_with_cyclical_dependency(player, mocker):
"""
Tests that a workflow with a cycle is detected and handled before execution.
"""
# 1. Arrange: Introduce a cycle and spy on engine creation
player._designer._state.connections.append(Connection("conn_3", "comp_presenter", "comp_producer"))
spy_get_engine = mocker.spy(player, '_get_engine')
# 2. Act
player.run()
# 3. Assert: Check for the specific cycle error
assert player.has_error
assert "Workflow configuration error: A cycle was detected" in player.global_error
spy_get_engine.assert_not_called()
def test_run_with_component_initialization_failure(player, mocker):
"""
Tests that an error during a component's initialization is handled correctly.
"""
# 1. Arrange: Make the engine creation fail for a specific component
failing_component_id = "comp_filter"
error = ValueError("Missing a required property")
mocker.patch.object(player, '_get_engine', side_effect=WorkflowsPlayerError(failing_component_id, error))
# 2. Act
player.run()
# 3. Assert: Check that the specific component is marked as failed
assert player.has_error
assert f"Failed to init component '{failing_component_id}'" in player.global_error
assert player.runtime_states[failing_component_id].state == ComponentState.FAILURE
assert str(error) in player.runtime_states[failing_component_id].error_message
assert player.runtime_states["comp_producer"].state == ComponentState.NOT_RUN
def test_run_with_failure_in_middle_component(player, mocker):
"""
Tests failure in a middle component updates all component states correctly.
"""
# 1. Arrange: Mock an engine that fails at the filter component
mock_engine = MagicMock()
mock_engine.has_error = True
failing_component_id = "comp_filter"
error = RuntimeError("Data processing failed unexpectedly")
mock_engine.errors = {failing_component_id: DataProcessorError(failing_component_id, error)}
mock_engine.run_to_list.return_value = []
mocker.patch.object(player, '_get_engine', return_value=mock_engine)
# 2. Act
player.run()
# 3. Assert: Check the state of each component in the chain
assert player.has_error
assert f"Error in component 'comp_filter':" in player.global_error
assert player.runtime_states["comp_producer"].state == ComponentState.SUCCESS
assert player.runtime_states[failing_component_id].state == ComponentState.FAILURE
assert str(error) in player.runtime_states[failing_component_id].error_message
assert player.runtime_states["comp_presenter"].state == ComponentState.NOT_RUN
def test_run_with_empty_workflow(player, mocker):
"""
Tests that running a workflow with no components completes without errors.
"""
# 1. Arrange: Clear components and connections
player._designer._state.components = {}
player._designer._state.connections = []
spy_get_engine = mocker.spy(player, '_get_engine')
# 2. Act
player.run()
# 3. Assert: Ensure it finishes cleanly with no data
assert not player.has_error
assert player.global_error == 'No connections defined.'
spy_get_engine.assert_not_called()
def test_run_with_global_engine_error(player, mocker):
"""
Tests a scenario where the engine reports a global error not tied to a specific component.
"""
# 1. Arrange: Mock a global engine failure
mock_engine = MagicMock()
mock_engine.has_error = True
mock_engine.errors = {} # No specific component error
mock_engine.global_error = "A simulated global engine failure"
mock_engine.run_to_list.return_value = []
mocker.patch.object(player, '_get_engine', return_value=mock_engine)
# 2. Act
player.run()
# 3. Assert: The player should report the global error from the engine
assert player.has_error
assert player.global_error == mock_engine.global_error

124
tests/test_workflows.py Normal file
View File

@@ -0,0 +1,124 @@
import pytest
from fasthtml.components import *
from components.form.components.MyForm import FormField, MyForm
from components.workflows.components.Workflows import Workflows
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, div_icon, search_elements_by_name, Contains
from my_mocks import tabs_manager
TEST_WORKFLOWS_ID = "testing_repositories_id"
boundaries = {"height": 500, "width": 800}
@pytest.fixture
def workflows(session, tabs_manager):
return Workflows(session=session, _id=TEST_WORKFLOWS_ID,
settings_manager=SettingsManager(engine=MemoryDbEngine()),
tabs_manager=tabs_manager)
def test_render_no_workflow(workflows):
actual = workflows.__ft__()
expected = Div(
Div(cls="divider"),
Div(
Div("Workflows"),
div_icon("add"), # icon to add a new workflow
cls="flex"
),
Div(id=f"w_{workflows.get_id()}", ), # list of workflow
id=workflows.get_id(),
)
assert matches(actual, expected)
def test_render_with_workflows_defined(workflows):
workflows.db.add_workflow("workflow 1")
workflows.db.add_workflow("workflow 2")
actual = workflows.__ft__()
expected = Div(
Div(cls="divider"),
Div(), # title + icon 'Add'
Div(
Div("workflow 1"),
Div("workflow 2"),
id=f"w_{workflows.get_id()}"
), # list of workflows
id=workflows.get_id(),
)
assert matches(actual, expected)
def test_i_can_see_selected_workflow(workflows):
workflows.db.add_workflow("workflow 1")
workflows.db.add_workflow("workflow 2")
workflows.db.select_workflow("workflow 2")
actual = workflows.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"w_{workflows.get_id()}"})[0]
expected = Div(
Div("workflow 1"),
Div(Div("workflow 2"), cls=Contains("mmt-selected")),
id=f"w_{workflows.get_id()}"
)
assert matches(to_compare, expected)
def test_i_can_request_for_a_new_workflow(workflows, tabs_manager):
res = workflows.request_new_workflow()
tabs_manager.request_new_tab_id.assert_called_once()
assert "new_tab_id" in res.tabs
tab_def = res.tabs["new_tab_id"]
assert tab_def[0] == "Add Workflow"
content = tab_def[1]
assert isinstance(content, MyForm)
assert content.title == "Add Workflow"
assert content.fields == [FormField("name", 'Workflow Name', 'input')]
def test_i_can_add_a_new_workflow(workflows, tabs_manager):
res = workflows.request_new_workflow()
tab_id = list(res.tabs.keys())[0]
actual = workflows.add_new_workflow(tab_id, "Not relevant here", "New Workflow", boundaries)
expected = (
Div(
Div("New Workflow"),
id=f"w_{workflows.get_id()}"
), # list of workflows
Div(), # Workflow Designer embedded in the tab
)
assert matches(actual, expected)
# check that the workflow was added
assert workflows.db.exists_workflow("New Workflow")
def test_i_can_select_a_workflow(workflows):
workflows.add_new_workflow("tab_id_1", "Not relevant", "workflow 1", boundaries)
workflows.add_new_workflow("tab_id_2", "Not relevant", "workflow 2", boundaries)
workflows.add_new_workflow("tab_id_3", "Not relevant", "workflow 3", boundaries)
actual = workflows.show_workflow("workflow 2", boundaries)
expected = (
Div(
Div("workflow 1"),
Div(Div("workflow 2"), cls=Contains("mmt-selected")),
Div("workflow 3"),
id=f"w_{workflows.get_id()}"
), # list of workflows
Div(), # Workflow Designer embedded in the tab
)
assert matches(actual, expected)

View File

@@ -0,0 +1,152 @@
from unittest.mock import patch
import pytest
from components.workflows.constants import WORKFLOWS_DB_ENTRY
from components.workflows.db_management import WorkflowsDbManager, WorkflowsSettings
from core.settings_management import SettingsManager, MemoryDbEngine
USER_EMAIL = "test@mail.com"
USER_ID = "test_user"
@pytest.fixture
def session():
return {"user_id": USER_ID, "user_email": USER_EMAIL}
@pytest.fixture
def settings_manager():
return SettingsManager(engine=MemoryDbEngine())
@pytest.fixture
def workflows_db_manager(session, settings_manager):
return WorkflowsDbManager(session=session, settings_manager=settings_manager)
def test_add_workflow(workflows_db_manager):
# Test adding a new workflow
assert workflows_db_manager.add_workflow("workflow1") is True
# Verify workflow was added
workflows = workflows_db_manager.get_workflows()
assert "workflow1" in workflows
assert len(workflows) == 1
def test_add_workflow_empty_name(workflows_db_manager):
# Test adding a workflow with empty name raises ValueError
with pytest.raises(ValueError, match="Workflow name cannot be empty."):
workflows_db_manager.add_workflow("")
def test_add_workflow_duplicate(workflows_db_manager):
# Add a workflow
workflows_db_manager.add_workflow("workflow1")
# Test adding duplicate workflow raises ValueError
with pytest.raises(ValueError, match="Workflow 'workflow1' already exists."):
workflows_db_manager.add_workflow("workflow1")
def test_get_workflow(workflows_db_manager):
# Add a workflow
workflows_db_manager.add_workflow("workflow1")
# Test getting the workflow
workflow = workflows_db_manager.get_workflow("workflow1")
assert workflow == "workflow1"
def test_get_workflow_empty_name(workflows_db_manager):
# Test getting a workflow with empty name raises ValueError
with pytest.raises(ValueError, match="Workflow name cannot be empty."):
workflows_db_manager.get_workflow("")
def test_get_workflow_nonexistent(workflows_db_manager):
# Test getting a non-existent workflow raises ValueError
with pytest.raises(ValueError, match="Workflow 'nonexistent' does not exist."):
workflows_db_manager.get_workflow("nonexistent")
def test_remove_workflow(workflows_db_manager):
# Add a workflow
workflows_db_manager.add_workflow("workflow1")
# Test removing the workflow
assert workflows_db_manager.remove_workflow("workflow1") is True
# Verify workflow was removed
assert len(workflows_db_manager.get_workflows()) == 0
def test_remove_workflow_empty_name(workflows_db_manager):
# Test removing a workflow with empty name raises ValueError
with pytest.raises(ValueError, match="Workflow name cannot be empty."):
workflows_db_manager.remove_workflow("")
def test_remove_workflow_nonexistent(workflows_db_manager):
# Test removing a non-existent workflow raises ValueError
with pytest.raises(ValueError, match="workflow 'nonexistent' does not exist."):
workflows_db_manager.remove_workflow("nonexistent")
def test_exists_workflow(workflows_db_manager):
# Add a workflow
workflows_db_manager.add_workflow("workflow1")
# Test workflow exists
assert workflows_db_manager.exists_workflow("workflow1") is True
# Test non-existent workflow
assert workflows_db_manager.exists_workflow("nonexistent") is False
def test_exists_workflow_empty_name(workflows_db_manager):
# Test checking existence of workflow with empty name raises ValueError
with pytest.raises(ValueError, match="workflow name cannot be empty."):
workflows_db_manager.exists_workflow("")
def test_get_workflows(workflows_db_manager):
# Initially, no workflows
assert len(workflows_db_manager.get_workflows()) == 0
# Add workflows
workflows_db_manager.add_workflow("workflow1")
workflows_db_manager.add_workflow("workflow2")
# Test getting all workflows
workflows = workflows_db_manager.get_workflows()
assert "workflow1" in workflows
assert "workflow2" in workflows
assert len(workflows) == 2
def test_select_workflow(workflows_db_manager):
# Add a workflow
workflows_db_manager.add_workflow("workflow1")
# Select the workflow
workflows_db_manager.select_workflow("workflow1")
# Verify workflow was selected
assert workflows_db_manager.get_selected_workflow() == "workflow1"
def test_get_selected_workflow_none(workflows_db_manager):
# Initially, no selected workflow
assert workflows_db_manager.get_selected_workflow() is None
def test_get_settings_default(workflows_db_manager, session, settings_manager):
# Test _get_settings returns default settings when none exist
with patch.object(settings_manager, 'load', return_value=WorkflowsSettings()) as mock_load:
settings = workflows_db_manager._get_settings()
mock_load.assert_called_once_with(session, WORKFLOWS_DB_ENTRY, default=WorkflowsSettings())
assert isinstance(settings, WorkflowsSettings)
assert settings.workflows == []
assert settings.selected_workflow is None