4 Commits

Author SHA1 Message Date
2c5fe004f5 Improving lazing loading. The scrollbars updates itself 2025-08-22 00:09:59 +02:00
9cf0e5e26a Trying things 2025-08-18 06:59:25 +02:00
67abb45804 Working on improving the perf 2025-08-10 17:42:54 +02:00
5820efb7f1 Updating git 2025-08-10 11:29:39 +02:00
41 changed files with 239 additions and 2064 deletions

1
.gitignore vendored
View File

@@ -13,7 +13,6 @@ tools.db
.mytools_db .mytools_db
.idea/MyManagingTools.iml .idea/MyManagingTools.iml
.idea/misc.xml .idea/misc.xml
.idea_bak
**/*.prof **/*.prof
# Created by .ignore support plugin (hsz.mobi) # Created by .ignore support plugin (hsz.mobi)

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -1,4 +1,4 @@
from core.utils import get_user_id, get_unique_id from core.utils import get_user_id
class BaseComponent: class BaseComponent:
@@ -51,12 +51,3 @@ class BaseComponentSingleton(BaseComponent):
@classmethod @classmethod
def create_component_id(cls, session): def create_component_id(cls, session):
return f"{cls.COMPONENT_INSTANCE_ID}{session['user_id']}" return f"{cls.COMPONENT_INSTANCE_ID}{session['user_id']}"
class BaseComponentMultipleInstance(BaseComponent):
COMPONENT_INSTANCE_ID = None
@classmethod
def create_component_id(cls, session):
component_id = cls.COMPONENT_INSTANCE_ID or cls.__name__
return get_unique_id(f"{component_id}{session['user_id']}")

View File

@@ -1,6 +1,9 @@
import asyncio
import json import json
import logging import logging
from fasthtml.components import Div, sse_message
from fasthtml.core import EventStream
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from starlette.datastructures import UploadFile from starlette.datastructures import UploadFile
@@ -138,6 +141,12 @@ def post(session, _id: str, state: str, args: str = None):
return instance.manage_state_changed(state, args) return instance.manage_state_changed(state, args)
@rt(Routes.YieldRow)
async def get(session, _id: str):
logger.debug(f"Entering {Routes.YieldRow} with args {_id=}")
instance = InstanceManager.get(session, _id)
return EventStream(instance.mk_body_content_sse())
@rt(Routes.GetPage) @rt(Routes.GetPage)
def get(session, _id: str, page_index: int): def get(session, _id: str, page_index: int):
logger.debug(f"Entering {Routes.GetPage} with args {_id=}, {page_index=}") logger.debug(f"Entering {Routes.GetPage} with args {_id=}, {page_index=}")

View File

@@ -1,5 +1,5 @@
import asyncio
import copy import copy
import html
import logging import logging
from io import BytesIO from io import BytesIO
from typing import Literal, Any from typing import Literal, Any
@@ -122,21 +122,14 @@ class DataGrid(BaseComponent):
else: else:
return ColumnType.Text # Default to Text if no match return ColumnType.Text # Default to Text if no match
def _init_columns(_df):
columns = [DataGridColumnState(make_safe_id(col_id),
col_index,
col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(_df.columns)]
if self._state.row_index:
columns.insert(0, DataGridColumnState(make_safe_id(ROW_INDEX_ID), -1, " ", ColumnType.RowIndex))
return columns
self._df = df.copy() self._df = df.copy()
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index] self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
self._state.columns = _init_columns(df) # use df not self._df to keep the original title self._state.columns = [DataGridColumnState(make_safe_id(col_id),
col_index,
col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(df.columns)]
self._fast_access = self._init_fast_access(self._df) self._fast_access = self._init_fast_access(self._df)
self._total_rows = len(self._df) if self._df is not None else 0 self._total_rows = len(self._df) if self._df is not None else 0
@@ -218,7 +211,6 @@ class DataGrid(BaseComponent):
self._state.columns = new_columns_states self._state.columns = new_columns_states
self._fast_access = self._init_fast_access(self._df)
self._views.recompute_need_save() self._views.recompute_need_save()
self._db.save_all(self._settings, self._state, self._df if new_column else None) self._db.save_all(self._settings, self._state, self._df if new_column else None)
@@ -400,6 +392,7 @@ class DataGrid(BaseComponent):
id=f"scb_{self._id}", id=f"scb_{self._id}",
) )
@timed
def mk_table(self, oob=False): def mk_table(self, oob=False):
htmx_extra_params = { htmx_extra_params = {
"hx-on::before-settle": f"onAfterSettle('{self._id}', event);", "hx-on::before-settle": f"onAfterSettle('{self._id}', event);",
@@ -453,6 +446,8 @@ class DataGrid(BaseComponent):
_mk_keyboard_management(), _mk_keyboard_management(),
Div( Div(
self.mk_table_header(), self.mk_table_header(),
#self.mk_table_body(),
#self.mk_table_body_sse(),
self.mk_table_body_page(), self.mk_table_body_page(),
self.mk_table_footer(), self.mk_table_footer(),
cls="dt2-inner-table"), cls="dt2-inner-table"),
@@ -488,11 +483,32 @@ class DataGrid(BaseComponent):
header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden" header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
return Div( return Div(
Div(sse_swap="message"),
*[_mk_header(col_def) for col_def in self._state.columns], *[_mk_header(col_def) for col_def in self._state.columns],
cls=header_class, cls=header_class,
id=f"th_{self._id}" id=f"th_{self._id}"
) )
def mk_table_body_sse(self):
"""
This function is used to create a sse update
Unfortunately, the sse does not update the correct element
tb_{self._id} is not updated
Plus UI refreshment issues
"""
max_height = self._compute_body_max_height()
return Div(
hx_ext="sse",
sse_connect=f"{ROUTE_ROOT}{Routes.YieldRow}?_id={self._id}",
sse_close='close',
sse_swap="message",
hx_swap="beforeend",
cls="dt2-body",
style=f"max-height:{max_height}px;",
id=f"tb_{self._id}",
)
def mk_table_body_page(self): def mk_table_body_page(self):
""" """
This function is used to update the table body when the vertical scrollbar reaches the end This function is used to update the table body when the vertical scrollbar reaches the end
@@ -507,6 +523,22 @@ class DataGrid(BaseComponent):
id=f"tb_{self._id}", id=f"tb_{self._id}",
) )
def mk_table_body(self):
df = self._get_filtered_df()
max_height = self._compute_body_max_height()
return Div(
*[Div(
*[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row",
data_row=f"{row_index}",
id=f"tr_{self._id}-{row_index}",
) for row_index in df.index],
cls="dt2-body",
style=f"max-height:{max_height}px;",
id=f"tb_{self._id}"
)
def mk_table_footer(self): def mk_table_footer(self):
return Div( return Div(
*[Div( *[Div(
@@ -536,10 +568,26 @@ class DataGrid(BaseComponent):
**self.commands.get_page(page_index + 1) if row_index == last_row else {} **self.commands.get_page(page_index + 1) if row_index == last_row else {}
) for row_index in df.index[start:end]] ) for row_index in df.index[start:end]]
rows.append(Script(f"manageScrollbars('{self._id}', false);"), ) rows.append(Script(f"manageScrollbars('{self._id}', false);"),)
return rows return rows
async def mk_body_content_sse(self):
df = self._get_filtered_df()
for i, row_index in enumerate(df.index):
yield sse_message(Div(
*[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row",
data_row=f"{row_index}",
id=f"tr_{self._id}-{row_index}",
))
if i % 50 == 0:
await asyncio.sleep(0.01)
logger.debug(f"yielding row {i}")
logger.debug(f"yielding close event")
yield f"event: close\ndata: \n\n"
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState): def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState):
if not col_def.usable: if not col_def.usable:
return None return None
@@ -567,7 +615,7 @@ class DataGrid(BaseComponent):
return mk_my_ellipsis(_value, cls="dt2-cell-content-number") return mk_my_ellipsis(_value, cls="dt2-cell-content-number")
def process_cell_content(_value): def process_cell_content(_value):
value_str = html.escape(str(_value)) value_str = str(_value)
if FILTER_INPUT_CID not in self._state.filtered or ( if FILTER_INPUT_CID not in self._state.filtered or (
keyword := self._state.filtered[FILTER_INPUT_CID]) is None: keyword := self._state.filtered[FILTER_INPUT_CID]) is None:
@@ -584,6 +632,7 @@ class DataGrid(BaseComponent):
return tuple(res) return tuple(res)
column_type = col_def.type column_type = col_def.type
# value = self._df.iloc[row_index, col_def.col_index]
value = self._fast_access[col_def.col_id][row_index] value = self._fast_access[col_def.col_id][row_index]
if column_type == ColumnType.Bool: if column_type == ColumnType.Bool:
@@ -873,12 +922,7 @@ class DataGrid(BaseComponent):
dict: A dictionary where the keys are the column names of the input DataFrame dict: A dictionary where the keys are the column names of the input DataFrame
and the values are the corresponding column values as NumPy arrays. and the values are the corresponding column values as NumPy arrays.
""" """
if df is None: return {col: df[col].to_numpy() for col in df.columns}
return {}
res = {col: df[col].to_numpy() for col in df.columns}
res[ROW_INDEX_ID] = df.index.to_numpy()
return res
@timed @timed
def __ft__(self): def __ft__(self):

View File

@@ -118,6 +118,38 @@ class DataGridCommandManager(BaseCommandManager):
"data_tooltip": tooltip_msg, "data_tooltip": tooltip_msg,
"cls": self.merge_class(cls, "mmt-tooltip") "cls": self.merge_class(cls, "mmt-tooltip")
} }
#
# @staticmethod
# def merge(*items):
# """
# Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
# If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
# If the key's value is not a string, an error is raised.
#
# :param items: dictionaries to be merged. If all items are None, None is returned.
# :return: A single dictionary containing the merged key-value pairs from all input dictionaries.
# :raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
# """
# if all(item is None for item in items):
# return None
#
# res = {}
# for item in [item for item in items if item is not None]:
#
# for key, value in item.items():
# if not key in res:
# res[key] = value
# else:
# if isinstance(res[key], str):
# res[key] += " " + value
# else:
# raise NotImplementedError("")
#
# return res
#
# @staticmethod
# def merge_class(cls1, cls2):
# return (cls1 + " " + cls2) if cls2 else cls1
class FilterAllCommands(BaseCommandManager): class FilterAllCommands(BaseCommandManager):

View File

@@ -19,8 +19,6 @@ DATAGRID_STATE_FOOTER = "footer"
DATAGRID_PAGE_SIZE = 50 DATAGRID_PAGE_SIZE = 50
ROW_INDEX_ID = "__row_index__"
class Routes: class Routes:
Filter = "/filter" # request the filtering in the grid Filter = "/filter" # request the filtering in the grid
ResetFilter = "/reset_filter" # ResetFilter = "/reset_filter" #
@@ -36,6 +34,7 @@ class Routes:
UpdateView = "/update_view" UpdateView = "/update_view"
ShowFooterMenu = "/show_footer_menu" ShowFooterMenu = "/show_footer_menu"
UpdateState = "/update_state" UpdateState = "/update_state"
YieldRow = "/yield-row"
GetPage = "/page" GetPage = "/page"

View File

@@ -69,7 +69,6 @@ class DataGridSettings:
class DataGridState: class DataGridState:
sidebar_visible: bool = False sidebar_visible: bool = False
selected_view: str = None selected_view: str = None
row_index: bool = False
columns: list[DataGridColumnState] = dataclasses.field(default_factory=list) columns: list[DataGridColumnState] = dataclasses.field(default_factory=list)
rows: list[DataGridRowState] = dataclasses.field(default_factory=list) # only the rows that have a specific state rows: list[DataGridRowState] = dataclasses.field(default_factory=list) # only the rows that have a specific state
footers: list[DataGridFooterConf] = dataclasses.field(default_factory=list) footers: list[DataGridFooterConf] = dataclasses.field(default_factory=list)

View File

@@ -62,7 +62,7 @@ class JsonViewerHelper:
class JsonViewer(BaseComponent): class JsonViewer(BaseComponent):
def __init__(self, session, _id, owner, user_id, data, hooks=None, key=None, boundaries=None): def __init__(self, session, _id, owner, user_id, data, hooks=None, key=None, boundaries=None):
super().__init__(session, _id) super().__init__(session, _id)
self._key = key # for comparison between two jsonviewer components self._key = key
self._owner = owner # debugger component self._owner = owner # debugger component
self.user_id = user_id self.user_id = user_id
self.data = data self.data = data
@@ -88,10 +88,6 @@ class JsonViewer(BaseComponent):
self._helper = JsonViewerHelper() self._helper = JsonViewerHelper()
def set_data(self, data):
self.data = data
self.node = self._create_node(None, data)
def set_node_folding(self, node_id, folding): def set_node_folding(self, node_id, folding):
if folding == self._folding_mode: if folding == self._folding_mode:
self._nodes_to_track.remove(node_id) self._nodes_to_track.remove(node_id)
@@ -315,6 +311,8 @@ class JsonViewer(BaseComponent):
def __hash__(self): def __hash__(self):
return hash(self._key) if self._key is not None else super().__hash__() return hash(self._key) if self._key is not None else super().__hash__()
@staticmethod @staticmethod
def add_quotes(value: str): def add_quotes(value: str):
if '"' in value and "'" in value: if '"' in value and "'" in value:

View File

@@ -1,26 +0,0 @@
import logging
from fasthtml.fastapp import fast_app
from components.entryselector.constants import Routes
from core.instance_manager import debug_session, InstanceManager
logger = logging.getLogger("EntrySelectorApp")
repositories_app, rt = fast_app()
@rt(Routes.Select)
def get(session, _id: str, entry: str):
logger.debug(f"Entering {Routes.Select} with args {debug_session(session)}, {_id=}, {entry=}")
instance = InstanceManager.get(session, _id)
to_update = instance.select_entry(entry)
res = [instance]
if res is None:
return instance
if isinstance(to_update, (list, tuple)):
res.extend(to_update)
else:
res.append(to_update)
return tuple(res)

View File

@@ -1,20 +0,0 @@
.es-container {
overflow-x: auto;
white-space: nowrap;
}
.es-entry {
border: 2px solid var(--color-base-300);
padding: 2px;
cursor: pointer;
display: inline-block; /* Ensure entries align horizontally if needed */
}
.es-entry-selected {
border: 2px solid var(--color-primary);
}
.es-entry:hover {
background-color: var(--color-base-300);
}

View File

@@ -1,15 +0,0 @@
from components.BaseCommandManager import BaseCommandManager
from components.entryselector.constants import Routes, ROUTE_ROOT
class EntrySelectorCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def select_entry(self, entry):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.Select}",
"hx-target": f"#{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "entry": "{entry}"}}',
}

View File

@@ -1,56 +0,0 @@
import logging
from fasthtml.components import *
from components.BaseComponent import BaseComponentMultipleInstance
from components.entryselector.commands import EntrySelectorCommandManager
logger = logging.getLogger("EntrySelector")
class EntrySelector(BaseComponentMultipleInstance):
def __init__(self, session, _id, owner, data=None, hooks=None, key=None, boundaries=None):
super().__init__(session, _id)
self._key = key
self._owner = owner # debugger component
self.data = data
self.selected = None
self.hooks = hooks
self._boundaries = boundaries if boundaries else {"width": "300"}
self._commands = EntrySelectorCommandManager(self)
def set_data(self, data):
self.data = data
def set_selected(self, selected):
if selected is None:
self.selected = None
else:
self.selected = int(selected)
def set_boundaries(self, boundaries):
self._boundaries = boundaries
def select_entry(self, entry):
logger.debug(f"Selecting entry {entry}")
self.set_selected(entry)
if self.hooks is not None and (on_entry_selected := self.hooks.get("on_entry_selected", None)) is not None:
return on_entry_selected(entry)
else:
return None
def _mk_content(self):
if not self.data:
return [Div("no entry")]
return [Div(index,
**self._commands.select_entry(index),
cls=f"es-entry {'es-entry-selected' if index == self.selected else ''}")
for index in range(self.data)]
def __ft__(self):
return Div(
*self._mk_content(),
cls="flex es-container",
id=f"{self._id}",
)

View File

@@ -1,5 +0,0 @@
ROUTE_ROOT = "/es" # for EntrySelector
class Routes:
Select = "/select"

View File

@@ -1,18 +0,0 @@
import logging
from fasthtml.fastapp import fast_app
from components.jsonviewer.constants import Routes
from core.instance_manager import debug_session, InstanceManager
jsonviwer_app, rt = fast_app()
logger = logging.getLogger("JsonViewer")
@rt(Routes.Fold)
def post(session, _id: str, node_id: str, folding: str):
logger.debug(f"Entering {Routes.Fold} with args {debug_session(session)}, {_id=}, {node_id=}, {folding=}")
instance = InstanceManager.get(session, _id)
instance.set_node_folding(node_id, folding)
return instance.render_node(node_id)

View File

@@ -1,449 +0,0 @@
# JsonViewer Hooks System - Technical Documentation
## Overview
The JsonViewer Hooks System provides a flexible, event-driven mechanism to customize the behavior and rendering of JSON nodes. Using a fluent builder pattern, developers can define conditions and actions that trigger during specific events in the JsonViewer lifecycle.
## Core Concepts
### Hook Architecture
A **Hook** consists of three components:
- **Event Type**: When the hook should trigger (`on_render`, `on_click`, etc.)
- **Conditions**: What criteria must be met for the hook to execute
- **Executor**: The function that runs when conditions are met
### HookContext
The `HookContext` object provides rich information about the current node being processed:
```python
class HookContext:
key: Any # The key of the current node
node: Any # The node object itself
helper: Any # JsonViewer helper utilities
jsonviewer: Any # Reference to the parent JsonViewer instance
json_path: str # Full JSON path (e.g., "users.0.name")
parent_node: Any # Reference to the parent node
metadata: dict # Additional metadata storage
```
**Utility Methods:**
- `get_node_type()`: Returns the string representation of the node type
- `get_value()`: Gets the actual value from the node
- `is_leaf_node()`: Checks if the node has no children
## HookBuilder API
### Creating a Hook
Use the `HookBuilder` class with method chaining to create hooks:
```python
hook = (HookBuilder()
.on_render()
.when_long_text(100)
.execute(my_custom_renderer))
```
### Event Types
#### `on_render()`
Triggers during node rendering, allowing custom rendering logic.
```python
def custom_text_renderer(context):
value = context.get_value()
return Span(f"Custom: {value}", cls="custom-text")
text_hook = (HookBuilder()
.on_render()
.when_type(str)
.execute(custom_text_renderer))
```
#### `on_click()`
Triggers when a node is clicked.
```python
def handle_click(context):
print(f"Clicked on: {context.json_path}")
return None # No rendering change
click_hook = (HookBuilder()
.on_click()
.when_editable()
.requires_modification()
.execute(handle_click))
```
#### `on_hover()` / `on_focus()`
Triggers on hover or focus events respectively.
```python
def show_tooltip(context):
return Div(f"Path: {context.json_path}", cls="tooltip")
hover_hook = (HookBuilder()
.on_hover()
.when_type(str)
.execute(show_tooltip))
```
## Conditions
Conditions determine when a hook should execute. Multiple conditions can be chained, and all must be satisfied.
### `when_type(target_type)`
Matches nodes with values of a specific type.
```python
# Hook for string values only
string_hook = (HookBuilder()
.on_render()
.when_type(str)
.execute(string_formatter))
# Hook for numeric values
number_hook = (HookBuilder()
.on_render()
.when_type((int, float)) # Accepts tuple of types
.execute(number_formatter))
```
### `when_key(key_pattern)`
Matches nodes based on their key.
```python
# Exact key match
email_hook = (HookBuilder()
.on_render()
.when_key("email")
.execute(email_formatter))
# Function-based key matching
def is_id_key(key):
return str(key).endswith("_id")
id_hook = (HookBuilder()
.on_render()
.when_key(is_id_key)
.execute(id_formatter))
```
### `when_value(target_value=None, predicate=None)`
Matches nodes based on their actual value.
**Exact value matching:**
```python
# Highlight error status
error_hook = (HookBuilder()
.on_render()
.when_value("ERROR")
.execute(lambda ctx: Span(ctx.get_value(), cls="error-status")))
# Special handling for null values
null_hook = (HookBuilder()
.on_render()
.when_value(None)
.execute(lambda ctx: Span("N/A", cls="null-value")))
```
**Predicate-based matching:**
```python
# URLs as clickable links
url_hook = (HookBuilder()
.on_render()
.when_value(predicate=lambda x: isinstance(x, str) and x.startswith("http"))
.execute(lambda ctx: A(ctx.get_value(), href=ctx.get_value(), target="_blank")))
# Large numbers formatting
large_number_hook = (HookBuilder()
.on_render()
.when_value(predicate=lambda x: isinstance(x, (int, float)) and x > 1000)
.execute(lambda ctx: Span(f"{x:,}", cls="large-number")))
```
### `when_path(path_pattern)`
Matches nodes based on their JSON path using regex.
```python
# Match all user names
user_name_hook = (HookBuilder()
.on_render()
.when_path(r"users\.\d+\.name")
.execute(user_name_formatter))
# Match any nested configuration
config_hook = (HookBuilder()
.on_render()
.when_path(r".*\.config\..*")
.execute(config_formatter))
```
### `when_long_text(threshold=100)`
Matches string values longer than the specified threshold.
```python
def text_truncator(context):
value = context.get_value()
truncated = value[:100] + "..."
return Div(
Span(truncated, cls="truncated-text"),
Button("Show more", cls="expand-btn"),
cls="long-text-container"
)
long_text_hook = (HookBuilder()
.on_render()
.when_long_text(100)
.execute(text_truncator))
```
### `when_editable(editable_paths=None, editable_types=None)`
Matches nodes that should be editable.
```python
def inline_editor(context):
value = context.get_value()
return Input(
value=str(value),
type="text" if isinstance(value, str) else "number",
cls="inline-editor",
**{"data-path": context.json_path}
)
editable_hook = (HookBuilder()
.on_click()
.when_editable(
editable_paths=["user.name", "user.email"],
editable_types=[str, int, float]
)
.requires_modification()
.execute(inline_editor))
```
### `when_custom(condition)`
Use custom condition objects or callable predicates for complex logic.
The `when_custom()` method accepts either:
- **Condition instances**: Objects that inherit from the `Condition` base class
- **Callable predicates**: Functions that take a `HookContext` parameter and return a boolean
When a callable is provided, it's automatically wrapped in a `PredicateCondition` class internally.
```python
class BusinessLogicCondition(Condition):
def evaluate(self, context):
# Complex business logic here
return (context.key == "status" and
context.get_value() in ["pending", "processing"])
custom_hook = (HookBuilder()
.on_render()
.when_custom(BusinessLogicCondition())
.execute(status_renderer))
```
## Combining Conditions
### Multiple Conditions (AND Logic)
Chain multiple conditions - all must be satisfied:
```python
complex_hook = (HookBuilder()
.on_render()
.when_type(str)
.when_key("description")
.when_long_text(50)
.execute(description_formatter))
```
### Composite Conditions
Use `when_all()` and `when_any()` for explicit logic:
```python
# AND logic
strict_hook = (HookBuilder()
.on_render()
.when_all([
WhenType(str),
WhenLongText(100),
WhenKey("content")
])
.execute(content_formatter))
# OR logic
flexible_hook = (HookBuilder()
.on_render()
.when_any([
WhenKey("title"),
WhenKey("name"),
WhenKey("label")
])
.execute(title_formatter))
```
## State Modification
Use `requires_modification()` to indicate that the hook will modify the application state:
```python
def save_edit(context):
new_value = get_new_value_from_ui() # Implementation specific
# Update the actual data
context.jsonviewer.update_value(context.json_path, new_value)
return success_indicator()
edit_hook = (HookBuilder()
.on_click()
.when_editable()
.requires_modification()
.execute(save_edit))
```
## Complete Examples
### Example 1: Enhanced Text Display
```python
def enhanced_text_renderer(context):
value = context.get_value()
# Truncate long text
if len(value) > 100:
display_value = value[:100] + "..."
tooltip = value # Full text as tooltip
else:
display_value = value
tooltip = None
return Span(
display_value,
cls="enhanced-text",
title=tooltip,
**{"data-full-text": value}
)
text_hook = (HookBuilder()
.on_render()
.when_type(str)
.when_value(predicate=lambda x: len(x) > 20)
.execute(enhanced_text_renderer))
```
### Example 2: Interactive Email Fields
```python
def email_renderer(context):
email = context.get_value()
return Div(
A(f"mailto:{email}", href=f"mailto:{email}", cls="email-link"),
Button("Copy", cls="copy-btn", **{"data-clipboard": email}),
cls="email-container"
)
email_hook = (HookBuilder()
.on_render()
.when_key("email")
.when_value(predicate=lambda x: "@" in str(x))
.execute(email_renderer))
```
### Example 3: Status Badge System
```python
def status_badge(context):
status = context.get_value().lower()
badge_classes = {
"active": "badge-success",
"pending": "badge-warning",
"error": "badge-danger",
"inactive": "badge-secondary"
}
css_class = badge_classes.get(status, "badge-default")
return Span(
status.title(),
cls=f"status-badge {css_class}"
)
status_hook = (HookBuilder()
.on_render()
.when_key("status")
.when_value(predicate=lambda x: str(x).lower() in ["active", "pending", "error", "inactive"])
.execute(status_badge))
```
## Integration with JsonViewer
### Adding Hooks to JsonViewer
```python
# Create your hooks
hooks = [
text_hook,
email_hook,
status_hook
]
# Initialize JsonViewer with hooks
viewer = JsonViewer(
session=session,
_id="my-viewer",
data=my_json_data,
hooks=hooks
)
```
### Factory Functions
Create reusable hook factories for common patterns:
```python
def create_url_link_hook():
"""Factory for URL link rendering"""
def url_renderer(context):
url = context.get_value()
return A(url, href=url, target="_blank", cls="url-link")
return (HookBuilder()
.on_render()
.when_value(predicate=lambda x: isinstance(x, str) and x.startswith(("http://", "https://")))
.execute(url_renderer))
def create_currency_formatter_hook(currency_symbol="$"):
"""Factory for currency formatting"""
def currency_renderer(context):
amount = context.get_value()
return Span(f"{currency_symbol}{amount:,.2f}", cls="currency-amount")
return (HookBuilder()
.on_render()
.when_type((int, float))
.when_key(lambda k: "price" in str(k).lower() or "amount" in str(k).lower())
.execute(currency_renderer))
# Usage
hooks = [
create_url_link_hook(),
create_currency_formatter_hook("€"),
]
```
## Best Practices
1. **Specific Conditions**: Use the most specific conditions possible to avoid unintended matches
2. **Performance**: Avoid complex predicates in `when_value()` for large datasets
3. **Error Handling**: Include error handling in your executor functions
4. **Reusability**: Create factory functions for common hook patterns
5. **Testing**: Test hooks with various data structures to ensure they work as expected
## Performance Considerations
- Hooks are evaluated in the order they are added to the JsonViewer
- Only the first matching hook for each event type will execute per node
- Use simple conditions when possible to minimize evaluation time
- Consider the size of your JSON data when using regex in `when_path()`

View File

@@ -1,27 +0,0 @@
from fastcore.basics import NotStr
# Fluent CaretRight20Filled
icon_collapsed = NotStr("""<svg name="collapsed" 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="M7 14.204a1 1 0 0 0 1.628.778l4.723-3.815a1.5 1.5 0 0 0 0-2.334L8.628 5.02A1 1 0 0 0 7 5.797v8.407z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent CaretDown20Filled
icon_expanded = NotStr("""<svg name="expanded" 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="M5.797 7a1 1 0 0 0-.778 1.628l3.814 4.723a1.5 1.5 0 0 0 2.334 0l3.815-4.723A1 1 0 0 0 14.204 7H5.797z" fill="currentColor">
</path>
</g>
</svg>""")
icon_class = NotStr("""
<svg name="expanded" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-width="1.5" >
<polygon points="5,2 2,8 8,8" />
<rect x="12" y="2" width="6" height="6"/>
<circle cx="5" cy="15" r="3" />
<polygon points="11.5,15 15,11.5 18.5,15 15,18.5" />
</g>
</svg>""")

View File

@@ -1,23 +0,0 @@
from components.jsonviewer.constants import ROUTE_ROOT, Routes
class JsonViewerCommands:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()
def fold(self, node_id: str, folding: str):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Fold}",
"hx-target": f"#{node_id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "node_id": "{node_id}", "folding": "{folding}"}}',
}
def open_digest(self, user_id, digest):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngineDigest}",
"hx-target": f"#{self._owner.get_owner().tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}", "digest": "{digest}"}}',
}

View File

@@ -1,544 +0,0 @@
from dataclasses import dataclass, field
from typing import Any, Optional
from fasthtml.components import *
from pandas import DataFrame
from components.BaseComponent import BaseComponentMultipleInstance
from components.datagrid_new.components.DataGrid import DataGrid
from components.jsonviewer.assets.icons import icon_expanded, icon_collapsed, icon_class
from components.jsonviewer.commands import JsonViewerCommands
from components.jsonviewer.constants import NODES_KEYS_TO_NOT_EXPAND, NODE_OBJECT, INDENT_SIZE, MAX_TEXT_LENGTH
from components.jsonviewer.hooks import HookManager, HookContext, EventType, Hook
from components_helpers import apply_boundaries
from core.serializer import TAG_OBJECT
class FoldingMode:
COLLAPSE = "collapse"
EXPAND = "expand"
@dataclass
class Node:
value: Any
@dataclass
class ValueNode(Node):
hint: str = None
@dataclass
class ListNode(Node):
node_id: str
level: int
children: list[Node] = field(default_factory=list)
@dataclass
class DictNode(Node):
node_id: str
level: int
children: dict[str, Node] = field(default_factory=dict)
class NodeIdGenerator:
"""Manages unique node ID generation"""
def __init__(self, base_id: str):
self.base_id = base_id
self._counter = -1
def generate(self) -> str:
self._counter += 1
return f"{self.base_id}-{self._counter}"
def reset(self):
self._counter = -1
class FoldingManager:
"""Manages folding/unfolding state of nodes"""
# A little explanation on how the folding / unfolding work
# all the nodes are either fold or unfold... except when there are not !
# self._folding_mode keeps the current value (it's FoldingMode.COLLAPSE or FoldingMode.EXPAND
# self._nodes_to_track keeps track of the exceptions
# The idea is to minimize the memory usage
def __init__(self, default_mode: str = FoldingMode.COLLAPSE):
self._folding_mode = default_mode
self._nodes_to_track = set() # exceptions to the default mode
def set_folding_mode(self, mode: str):
"""Changes the global folding mode and clears exceptions"""
self._folding_mode = mode
self._nodes_to_track.clear()
def set_node_folding(self, node_id: str, folding: str):
"""Sets specific folding state for a node"""
if folding == self._folding_mode:
self._nodes_to_track.discard(node_id)
else:
self._nodes_to_track.add(node_id)
def must_expand(self, node: Node) -> Optional[bool]:
"""Determines if a node should be expanded"""
if not isinstance(node, (ListNode, DictNode)):
return None
if self._folding_mode == FoldingMode.COLLAPSE:
return node.node_id in self._nodes_to_track
else:
return node.node_id not in self._nodes_to_track
def get_folding_mode(self) -> str:
return self._folding_mode
def get_nodes_to_track(self) -> set[str]:
return self._nodes_to_track
class NodeFactory:
"""Factory for creating nodes from data with JSON path tracking"""
def __init__(self, id_generator: NodeIdGenerator, folding_manager: FoldingManager):
self.id_generator = id_generator
self.folding_manager = folding_manager
self._nodes_by_id = {}
self._node_paths = {} # node_id -> json_path mapping
self._node_parents = {} # node_id -> parent_node mapping
def create_node(self, key: Any, data: Any, level: int = 0, json_path: str = "", parent_node: Any = None) -> Node:
"""Creates appropriate node type based on data with path tracking"""
if isinstance(data, list):
return self._create_list_node(key, data, level, json_path, parent_node)
elif isinstance(data, dict):
return self._create_dict_node(key, data, level, json_path, parent_node)
else:
return self._create_value_node(key, data, json_path, parent_node)
def _create_list_node(self, key: Any, data: list, level: int, json_path: str, parent_node: Any) -> ListNode:
node_id = self.id_generator.generate()
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
self.folding_manager._nodes_to_track.add(node_id)
node = ListNode(data, node_id, level)
self._nodes_by_id[node_id] = (key, node)
self._node_paths[node_id] = json_path
self._node_parents[node_id] = parent_node
for index, item in enumerate(data):
child_path = f"{json_path}[{index}]" if json_path else f"[{index}]"
node.children.append(self.create_node(index, item, level + 1, child_path, node))
return node
def _create_dict_node(self, key: Any, data: dict, level: int, json_path: str, parent_node: Any) -> DictNode:
node_id = self.id_generator.generate()
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
self.folding_manager._nodes_to_track.add(node_id)
node = DictNode(data, node_id, level)
self._nodes_by_id[node_id] = (key, node)
self._node_paths[node_id] = json_path
self._node_parents[node_id] = parent_node
for child_key, value in data.items():
child_path = f"{json_path}.{child_key}" if json_path else str(child_key)
node.children[child_key] = self.create_node(child_key, value, level + 1, child_path, node)
return node
def _create_value_node(self, key: Any, data: Any, json_path: str, parent_node: Any) -> ValueNode:
hint = NODE_OBJECT if key == TAG_OBJECT else None
node = ValueNode(data, hint)
# Value nodes don't have node_id, but we can still track their path for hooks
return node
def get_node_by_id(self, node_id: str) -> tuple[Any, Node]:
return self._nodes_by_id[node_id]
def get_node_path(self, node_id: str) -> str:
return self._node_paths.get(node_id, "")
def get_node_parent(self, node_id: str) -> Any:
return self._node_parents.get(node_id, None)
def clear(self):
"""Clears all stored nodes"""
self._nodes_by_id.clear()
self._node_paths.clear()
self._node_parents.clear()
class JsonViewerHelper:
class_string = f"mmt-jsonviewer-string"
class_bool = f"mmt-jsonviewer-bool"
class_number = f"mmt-jsonviewer-number"
class_null = f"mmt-jsonviewer-null"
class_digest = f"mmt-jsonviewer-digest"
class_object = f"mmt-jsonviewer-object"
class_dataframe = f"mmt-jsonviewer-dataframe"
@staticmethod
def is_sha256(_value):
return (isinstance(_value, str) and
len(_value) == 64 and
all(c in '0123456789abcdefABCDEF' for c in _value))
@staticmethod
def add_quotes(value: str) -> str:
if '"' in value and "'" in value:
return f'"{value.replace("\"", "\\\"")}"'
elif '"' in value:
return f"'{value}'"
else:
return f'"{value}"'
class NodeRenderer:
"""Single class handling all node rendering with new hook system"""
def __init__(self, session,
jsonviewer_instance,
folding_manager: FoldingManager,
commands: JsonViewerCommands,
helper: JsonViewerHelper,
hook_manager: HookManager,
node_factory: NodeFactory):
self.session = session
self.jsonviewer = jsonviewer_instance
self.folding_manager = folding_manager
self.commands = commands
self.helper = helper
self.hook_manager = hook_manager
self.node_factory = node_factory
def render(self, key: Any, node: Node, json_path: str = "", parent_node: Any = None) -> Div:
"""Main rendering method for any node"""
must_expand = self.folding_manager.must_expand(node)
return Div(
self._create_folding_icon(node, must_expand),
Span(f'{key} : ') if key is not None else None,
self._render_value(key, node, must_expand, json_path, parent_node),
style=f"margin-left: {INDENT_SIZE}px;",
id=getattr(node, "node_id", None)
)
def _create_folding_icon(self, node: Node, must_expand: Optional[bool]) -> Optional[Span]:
"""Creates folding/unfolding icon"""
if must_expand is None:
return None
return Span(
icon_expanded if must_expand else icon_collapsed,
cls="icon-16-inline mmt-jsonviewer-folding",
style=f"margin-left: -{INDENT_SIZE}px;",
**self.commands.fold(
node.node_id,
FoldingMode.COLLAPSE if must_expand else FoldingMode.EXPAND
)
)
def _render_value(self, key: Any,
node: Node,
must_expand: Optional[bool],
json_path: str = "",
parent_node: Any = None):
"""Renders the value part of a node with new hook system"""
if must_expand is False:
return self._render_collapsed_indicator(node)
# Create hook context
context = HookContext(
key=key,
node=node,
helper=self.helper,
jsonviewer=self.jsonviewer,
json_path=json_path,
parent_node=parent_node
)
# Execute render hooks and check for results
hook_results = self.hook_manager.execute_hooks(EventType.RENDER, context)
# If any hook returned a result, use the first one
if hook_results:
# Filter out None results
valid_results = [result for result in hook_results if result is not None]
if valid_results:
return valid_results[0]
# No hooks matched or returned results, use default rendering
if isinstance(node, DictNode):
return self._render_dict_node(key, node)
elif isinstance(node, ListNode):
return self._render_list_node(key, node)
else:
return self._render_value_node(key, node)
def _render_collapsed_indicator(self, node: Node) -> Span:
"""Renders collapsed indicator"""
indicator = "[...]" if isinstance(node, ListNode) else "{...}"
return Span(
indicator,
id=node.node_id,
**self.commands.fold(node.node_id, FoldingMode.EXPAND)
)
def _render_dict_node(self, key: Any, node: DictNode) -> Span:
"""Renders dictionary node"""
children_elements = []
base_path = self.node_factory.get_node_path(node.node_id)
for child_key, child_node in node.children.items():
child_path = f"{base_path}.{child_key}" if base_path else str(child_key)
children_elements.append(self.render(child_key, child_node, child_path, node))
return Span(
"{",
*children_elements,
Div("}"),
id=node.node_id
)
def _render_list_node(self, key: Any, node: ListNode) -> Span:
"""Renders list node"""
if self._should_render_list_as_grid(key, node):
return self._render_list_as_grid(key, node)
else:
return self._render_list_as_array(key, node)
def _should_render_list_as_grid(self, key: Any, node: ListNode) -> bool:
"""Determines if list should be rendered as grid"""
if len(node.children) == 0:
return False
sample_node = node.children[0]
sample_value = sample_node.value
if sample_value is None:
return False
type_ = type(sample_value)
if type_ in (int, float, str, bool, list, dict, ValueNode):
return False
# Check if hooks handle this type (simplified check)
sample_context = HookContext(
key=key,
node=sample_node,
helper=self.helper,
jsonviewer=self.jsonviewer
)
hook_results = self.hook_manager.execute_hooks(EventType.RENDER, sample_context)
if hook_results and any(result is not None for result in hook_results):
return False
return all(type(item.value) == type_ for item in node.children)
def _render_list_as_grid(self, key: Any, node: ListNode) -> Span:
"""Renders list as grid"""
type_ = type(node.children[0].value)
icon = icon_class
str_value = type_.__name__.split(".")[-1]
data = [child.value.__dict__ for child in node.children]
df = DataFrame(data)
dg = DataGrid(self.session)
dg.init_from_dataframe(df)
return Span(
Span(
Span(icon, cls="icon-16-inline mr-1"),
Span(str_value),
cls="mmt-jsonviewer-object"
),
dg,
id=node.node_id
)
def _render_list_as_array(self, key: Any, node: ListNode) -> Span:
"""Renders list as array"""
children_elements = []
base_path = self.node_factory.get_node_path(node.node_id)
for index, child_node in enumerate(node.children):
child_path = f"{base_path}[{index}]" if base_path else f"[{index}]"
children_elements.append(self.render(index, child_node, child_path, node))
return Span(
"[",
*children_elements,
Div("]"),
)
def _render_value_node(self, key: Any, node: ValueNode) -> Span:
"""Renders value node"""
data_tooltip = None
htmx_params = {}
icon = None
if isinstance(node.value, bool): # order is important bool is an int in Python !
str_value = "true" if node.value else "false"
data_class = "bool"
elif isinstance(node.value, (int, float)):
str_value = str(node.value)
data_class = "number"
elif node.value is None:
str_value = "null"
data_class = "null"
elif self.helper.is_sha256(node.value):
str_value = str(node.value)
data_class = "digest"
htmx_params = self.commands.open_digest(self.jsonviewer.user_id, node.value)
elif node.hint == NODE_OBJECT:
icon = icon_class
str_value = node.value.split(".")[-1]
data_class = "object"
elif isinstance(node.value, DataFrame):
return self._render_dataframe_value(node.value)
else:
str_value, data_tooltip = self._format_string_value(node.value)
data_class = "string"
return self._create_value_span(str_value, data_class, icon, data_tooltip, htmx_params)
def _render_dataframe_value(self, dataframe: DataFrame) -> Any:
"""Renders DataFrame value"""
dg = DataGrid(self.session)
dg.init_from_dataframe(dataframe)
return dg
def _format_string_value(self, value: Any) -> tuple[str, Optional[str]]:
"""Formats string value with tooltip if too long"""
as_str = str(value)
if len(as_str) > MAX_TEXT_LENGTH:
return as_str[:MAX_TEXT_LENGTH] + "...", as_str
else:
return self.helper.add_quotes(as_str), None
def _create_value_span(self, str_value: str, data_class: str, icon: Any,
data_tooltip: Optional[str], htmx_params: dict) -> Span:
"""Creates the final Span element for a value"""
css_class = f"mmt-jsonviewer-{data_class}"
if data_tooltip:
css_class += " mmt-tooltip"
if icon:
return Span(
Span(icon, cls="icon-16-inline mr-1"),
Span(str_value, data_tooltip=data_tooltip, **htmx_params),
cls=css_class
)
return Span(str_value, cls=css_class, data_tooltip=data_tooltip, **htmx_params)
class JsonViewer(BaseComponentMultipleInstance):
"""Main JsonViewer component with new hook system"""
COMPONENT_INSTANCE_ID = "Jsonviewer"
def __init__(self, session, _id, data=None, hooks: list[Hook] = None, key=None, boundaries=None):
super().__init__(session, _id)
self._key = key
self.data = data
self._boundaries = boundaries if boundaries else {"height": "600"}
self._commands = JsonViewerCommands(self)
# Initialize hook system (transparent to user)
self._hook_manager = HookManager()
if hooks:
self._hook_manager.add_hooks(hooks)
# Initialize helper components
self._helper = JsonViewerHelper()
self._id_generator = NodeIdGenerator(_id)
self._folding_manager = FoldingManager()
self._node_factory = NodeFactory(self._id_generator, self._folding_manager)
# Initialize renderer with hook manager
self._node_renderer = NodeRenderer(
session, self, self._folding_manager,
self._commands, self._helper, self._hook_manager, self._node_factory
)
# Create the initial node tree
self.node = self._node_factory.create_node(None, data)
@property
def user_id(self) -> str:
"""Gets user_id from session or returns default"""
return getattr(self, '_user_id', getattr(self._session, 'user_id', 'default_user'))
def set_data(self, data):
"""Updates the data and recreates the node tree"""
self.data = data
self._id_generator.reset()
self._node_factory.clear()
self.node = self._node_factory.create_node(None, data)
def add_hook(self, hook: Hook):
"""Adds a single hook to the viewer"""
self._hook_manager.add_hook(hook)
def add_hooks(self, hooks: list[Hook]):
"""Adds multiple hooks to the viewer"""
self._hook_manager.add_hooks(hooks)
def clear_hooks(self):
"""Removes all hooks from the viewer"""
self._hook_manager.clear_hooks()
def set_node_folding(self, node_id: str, folding: str):
"""Sets folding state for a specific node"""
self._folding_manager.set_node_folding(node_id, folding)
def render_node(self, node_id: str):
"""Renders a specific node by ID"""
key, node = self._node_factory.get_node_by_id(node_id)
json_path = self._node_factory.get_node_path(node_id)
parent_node = self._node_factory.get_node_parent(node_id)
return self._node_renderer.render(key, node, json_path, parent_node)
def set_folding_mode(self, folding_mode: str):
"""Sets global folding mode"""
self._folding_manager.set_folding_mode(folding_mode)
def get_folding_mode(self) -> str:
"""Gets current folding mode"""
return self._folding_manager.get_folding_mode()
def open_digest(self, user_id: str, digest: str):
"""Opens digest - preserves original method"""
return self._owner.db_engine_headers(user_id, digest)
def __ft__(self):
"""FastHTML rendering method"""
if self.node is None:
return Div("No data to display", cls="mmt-jsonviewer", id=f"{self._id}")
return Div(
Div(
self._node_renderer.render(None, self.node, "", None),
id=f"{self._id}-root",
style="margin-left: 0px;"
),
cls="mmt-jsonviewer",
id=f"{self._id}",
**apply_boundaries(self._boundaries)
)
def __eq__(self, other):
"""Equality comparison"""
if type(other) is type(self):
return self._key is not None and self._key == other._key
return False
def __hash__(self):
"""Hash method"""
return hash(self._key) if self._key is not None else super().__hash__()

View File

@@ -1,10 +0,0 @@
ROUTE_ROOT = "/jsonviewer"
INDENT_SIZE = 20
MAX_TEXT_LENGTH = 50
NODE_OBJECT = "Object"
NODES_KEYS_TO_NOT_EXPAND = ["Dataframe", "__parent__"]
class Routes:
Fold = "/fold"

View File

@@ -1,386 +0,0 @@
from abc import ABC, abstractmethod
from enum import Enum
from typing import Any, Callable, Optional
class EventType(Enum):
RENDER = "render"
CLICK = "click"
HOVER = "hover"
FOCUS = "focus"
class DefaultEditableTypes:
pass
class HookContext:
"""Enhanced context object passed to hook executors"""
def __init__(self, key: Any, node: Any, helper: Any, jsonviewer: Any,
json_path: str = None, parent_node: Any = None):
self.key = key
self.node = node
self.helper = helper
self.jsonviewer = jsonviewer
self.json_path = json_path or ""
self.parent_node = parent_node
self.metadata = {}
def get_node_type(self) -> str:
"""Returns string representation of node type"""
if hasattr(self.node, '__class__'):
return self.node.__class__.__name__
return type(self.node.value).__name__ if hasattr(self.node, 'value') else "unknown"
def get_value(self) -> Any:
"""Gets the actual value from the node"""
return getattr(self.node, 'value', self.node)
def is_leaf_node(self) -> bool:
"""Checks if this is a leaf node (no children)"""
return not hasattr(self.node, 'children') or not self.node.children
class Condition(ABC):
"""Base class for all conditions"""
@abstractmethod
def evaluate(self, context: HookContext) -> bool:
pass
class WhenLongText(Condition):
"""Condition: text length > threshold"""
def __init__(self, threshold: int = 100):
self.threshold = threshold
def evaluate(self, context: HookContext) -> bool:
value = context.get_value()
return isinstance(value, str) and len(value) > self.threshold
class WhenEditable(Condition):
"""Condition: node is editable (configurable logic)"""
def __init__(self, editable_paths: list[str] = None, editable_types: list[type] = DefaultEditableTypes):
self.editable_paths = set(editable_paths or [])
if editable_types is None:
self.editable_types = set()
else:
self.editable_types = set([str, int, float, bool] if editable_types is DefaultEditableTypes else editable_types)
def evaluate(self, context: HookContext) -> bool:
# Check if path is in editable paths
if self.editable_paths and context.json_path in self.editable_paths:
return True
# Check if type is editable
value = context.get_value()
return type(value) in self.editable_types and context.is_leaf_node()
class WhenType(Condition):
"""Condition: node value is of specific type"""
def __init__(self, target_type: type):
self.target_type = target_type
def evaluate(self, context: HookContext) -> bool:
value = context.get_value()
return isinstance(value, self.target_type)
class WhenKey(Condition):
"""Condition: node key matches pattern"""
def __init__(self, key_pattern: Any):
self.key_pattern = key_pattern
def evaluate(self, context: HookContext) -> bool:
if callable(self.key_pattern):
return self.key_pattern(context.key)
return context.key == self.key_pattern
class WhenPath(Condition):
"""Condition: JSON path matches pattern"""
def __init__(self, path_pattern: str):
self.path_pattern = path_pattern
def evaluate(self, context: HookContext) -> bool:
import re
return bool(re.match(self.path_pattern, context.json_path))
class WhenValue(Condition):
"""Condition: node value matches specific value or predicate"""
def __init__(self, target_value: Any = None, predicate: Callable[[Any], bool] = None):
if target_value is not None and predicate is not None:
raise ValueError("Cannot specify both target_value and predicate")
if target_value is None and predicate is None:
raise ValueError("Must specify either target_value or predicate")
self.target_value = target_value
self.predicate = predicate
def evaluate(self, context: HookContext) -> bool:
value = context.get_value()
if self.predicate:
return self.predicate(value)
else:
return value == self.target_value
class CompositeCondition(Condition):
"""Allows combining conditions with AND/OR logic"""
def __init__(self, conditions: list[Condition], operator: str = "AND"):
self.conditions = conditions
self.operator = operator.upper()
def evaluate(self, context: HookContext) -> bool:
if not self.conditions:
return True
results = [condition.evaluate(context) for condition in self.conditions]
if self.operator == "AND":
return all(results)
elif self.operator == "OR":
return any(results)
else:
raise ValueError(f"Unknown operator: {self.operator}")
class Hook:
"""Represents a complete hook with event, conditions, and executor"""
def __init__(self, event_type: EventType, conditions: list[Condition],
executor: Callable, requires_modification: bool = False):
self.event_type = event_type
self.conditions = conditions
self.executor = executor
self.requires_modification = requires_modification
def matches(self, event_type: EventType, context: HookContext) -> bool:
"""Checks if this hook should be executed for given event and context"""
if self.event_type != event_type:
return False
return all(condition.evaluate(context) for condition in self.conditions)
def execute(self, context: HookContext) -> Any:
"""Executes the hook with given context"""
return self.executor(context)
class HookBuilder:
"""Builder class for creating hooks with fluent interface"""
def __init__(self):
self._event_type: Optional[EventType] = None
self._conditions: list[Condition] = []
self._executor: Optional[Callable] = None
self._requires_modification: bool = False
# Event specification methods
def on_render(self):
"""Hook will be triggered on render event"""
self._event_type = EventType.RENDER
return self
def on_click(self):
"""Hook will be triggered on click event"""
self._event_type = EventType.CLICK
return self
def on_hover(self):
"""Hook will be triggered on hover event"""
self._event_type = EventType.HOVER
return self
def on_focus(self):
"""Hook will be triggered on focus event"""
self._event_type = EventType.FOCUS
return self
# Condition methods
def when_long_text(self, threshold: int = 100):
"""Add condition: text length > threshold"""
self._conditions.append(WhenLongText(threshold))
return self
def when_editable(self, editable_paths: list[str] = None, editable_types: list[type] = None):
"""Add condition: node is editable"""
self._conditions.append(WhenEditable(editable_paths, editable_types))
return self
def when_type(self, target_type: type):
"""Add condition: node value is of specific type"""
self._conditions.append(WhenType(target_type))
return self
def when_key(self, key_pattern: Any):
"""Add condition: node key matches pattern"""
self._conditions.append(WhenKey(key_pattern))
return self
def when_path(self, path_pattern: str):
"""Add condition: JSON path matches pattern"""
self._conditions.append(WhenPath(path_pattern))
return self
def when_value(self, target_value: Any = None, predicate: Callable[[Any], bool] = None):
"""Add condition: node value matches specific value or predicate"""
self._conditions.append(WhenValue(target_value, predicate))
return self
def when_custom(self, condition):
"""Add custom condition (supports both Condition instances and predicate functions)."""
if callable(condition) and not isinstance(condition, Condition):
# Wrap the predicate function in a Condition class
class PredicateCondition(Condition):
def __init__(self, predicate):
self.predicate = predicate
def evaluate(self, context):
return self.predicate(context)
condition = PredicateCondition(condition) # Pass the function to the wrapper
elif not isinstance(condition, Condition):
raise ValueError("when_custom expects a Condition instance or a callable predicate.")
self._conditions.append(condition)
return self
def when_all(self, conditions: list[Condition]):
"""Add composite condition with AND logic"""
self._conditions.append(CompositeCondition(conditions, "AND"))
return self
def when_any(self, conditions: list[Condition]):
"""Add composite condition with OR logic"""
self._conditions.append(CompositeCondition(conditions, "OR"))
return self
# Modification flag
def requires_modification(self):
"""Indicates this hook will modify the state"""
self._requires_modification = True
return self
# Execution
def execute(self, executor: Callable) -> Hook:
"""Sets the executor function and builds the hook"""
if not self._event_type:
raise ValueError("Event type must be specified (use on_render(), on_click(), etc.)")
if not executor:
raise ValueError("Executor function must be provided")
self._executor = executor
return Hook(
event_type=self._event_type,
conditions=self._conditions,
executor=self._executor,
requires_modification=self._requires_modification
)
class HookManager:
"""Manages and executes hooks for JsonViewer"""
def __init__(self):
self.hooks: list[Hook] = []
def add_hook(self, hook: Hook):
"""Adds a hook to the manager"""
self.hooks.append(hook)
def add_hooks(self, hooks: list[Hook]):
"""Adds multiple hooks to the manager"""
self.hooks.extend(hooks)
def find_matching_hooks(self, event_type: EventType, context: HookContext) -> list[Hook]:
"""Finds all hooks that match the event and context"""
return [hook for hook in self.hooks if hook.matches(event_type, context)]
def execute_hooks(self, event_type: EventType, context: HookContext) -> list[Any]:
"""Executes all matching hooks and returns results"""
matching_hooks = self.find_matching_hooks(event_type, context)
results = []
for hook in matching_hooks:
try:
result = hook.execute(context)
results.append(result)
# If this hook requires modification, we might want to stop here
# or handle the modification differently
if hook.requires_modification:
# Could add callback to parent component here
pass
except Exception as e:
# Log error but continue with other hooks
print(f"Hook execution error: {e}")
continue
return results
def clear_hooks(self):
"""Removes all hooks"""
self.hooks.clear()
# Example usage and factory functions
def create_long_text_viewer_hook(threshold: int = 100) -> Hook:
"""Factory function for common long text viewer hook"""
def text_viewer_component(context: HookContext):
from fasthtml.components import Div, Span
value = context.get_value()
truncated = value[:threshold] + "..."
return Div(
Span(truncated, cls="text-truncated"),
Span("Click to expand", cls="expand-hint"),
cls="long-text-viewer"
)
return (HookBuilder()
.on_render()
.when_long_text(threshold)
.execute(text_viewer_component))
def create_inline_editor_hook(editable_paths: list[str] = None) -> Hook:
"""Factory function for common inline editor hook"""
def inline_editor_component(context: HookContext):
from fasthtml.components import Input, Div
value = context.get_value()
return Div(
Input(
value=str(value),
type="text" if isinstance(value, str) else "number",
cls="inline-editor"
),
cls="editable-field"
)
return (HookBuilder()
.on_click()
.when_editable(editable_paths)
.requires_modification()
.execute(inline_editor_component))

View File

@@ -46,7 +46,7 @@ class WorkflowDesigner(BaseComponent):
self.properties = WorkflowDesignerProperties(self._session, f"{self._id}", self) self.properties = WorkflowDesignerProperties(self._session, f"{self._id}", self)
workflow_name = self._designer_settings.workflow_name workflow_name = self._designer_settings.workflow_name
self.player = InstanceManager.get(self._session, self._player = InstanceManager.get(self._session,
WorkflowPlayer.create_component_id(self._session, workflow_name), WorkflowPlayer.create_component_id(self._session, workflow_name),
WorkflowPlayer, WorkflowPlayer,
settings_manager=self._settings_manager, settings_manager=self._settings_manager,
@@ -222,23 +222,22 @@ class WorkflowDesigner(BaseComponent):
def play_workflow(self, boundaries: dict): def play_workflow(self, boundaries: dict):
self._error_message = None self._error_message = None
self.player.run() self._player.run()
if self.player.global_error: if self._player.global_error:
# Show the error message in the same tab # Show the error message in the same tab
self._error_message = self.player.global_error self._error_message = self._player.global_error
else: else:
self.properties.set_entry_selector_data(self.player.nb_items)
# change the tab and display the results # change the tab and display the results
self.player.set_boundaries(boundaries) self._player.set_boundaries(boundaries)
self.tabs_manager.add_tab(f"Workflow {self._designer_settings.workflow_name}", self.player, self.player.key) self.tabs_manager.add_tab(f"Workflow {self._designer_settings.workflow_name}", self._player, self._player.key)
return self.tabs_manager.refresh() return self.tabs_manager.refresh()
def stop_workflow(self): def stop_workflow(self):
self._error_message = None self._error_message = None
self.player.stop() self._player.stop()
self.properties.set_entry_selector_data(0)
return self.tabs_manager.refresh() return self.tabs_manager.refresh()
def on_processor_details_event(self, component_id: str, event_name: str, details: dict): def on_processor_details_event(self, component_id: str, event_name: str, details: dict):
@@ -315,7 +314,7 @@ class WorkflowDesigner(BaseComponent):
def _mk_component(self, component: WorkflowComponent): def _mk_component(self, component: WorkflowComponent):
runtime_state = self.player.get_component_runtime_state(component.id) runtime_state = self._player.get_component_runtime_state(component.id)
info = COMPONENT_TYPES[component.type] info = COMPONENT_TYPES[component.type]
is_selected = self._state.selected_component_id == component.id is_selected = self._state.selected_component_id == component.id
@@ -510,7 +509,7 @@ class WorkflowDesigner(BaseComponent):
) )
def _mk_properties(self, oob=False): def _mk_properties(self, oob=False):
return self.properties.__ft__(oob) return self.properties
def _mk_jira_processor_details(self, component): def _mk_jira_processor_details(self, component):
def _mk_option(name): def _mk_option(name):

View File

@@ -1,11 +1,9 @@
from fasthtml.common import * from fasthtml.common import *
from dataclasses import dataclass
from components.BaseComponent import BaseComponent from components.BaseComponent import BaseComponent
from components.entryselector.components.EntrySelector import EntrySelector
from components.jsonviewer.components.JsonViewer import JsonViewer
from components.workflows.constants import COMPONENT_TYPES, PROCESSOR_TYPES from components.workflows.constants import COMPONENT_TYPES, PROCESSOR_TYPES
from components_helpers import mk_dialog_buttons from components_helpers import mk_dialog_buttons
from core.instance_manager import InstanceManager
from core.jira import JiraRequestTypes, DEFAULT_SEARCH_FIELDS from core.jira import JiraRequestTypes, DEFAULT_SEARCH_FIELDS
from utils.DbManagementHelper import DbManagementHelper from utils.DbManagementHelper import DbManagementHelper
@@ -27,18 +25,6 @@ class WorkflowDesignerProperties(BaseComponent):
self._component = None self._component = None
self.update_layout() self.update_layout()
self.update_component(self._owner.get_state().selected_component_id) self.update_component(self._owner.get_state().selected_component_id)
self.entry_selector: EntrySelector = InstanceManager.new(self._session,
EntrySelector,
owner=self,
hooks={
"on_entry_selected": self.on_entry_selector_changed})
self._input_jsonviewer: JsonViewer = InstanceManager.new(self._session,
JsonViewer)
self._output_jsonviewer: JsonViewer = InstanceManager.new(self._session,
JsonViewer)
def set_entry_selector_data(self, data):
self.entry_selector.set_data(data)
def update_layout(self): def update_layout(self):
if self._owner.get_state().properties_input_width is None: if self._owner.get_state().properties_input_width is None:
@@ -69,54 +55,29 @@ class WorkflowDesignerProperties(BaseComponent):
return self.__ft__(oob=oob) return self.__ft__(oob=oob)
def on_entry_selector_changed(self, entry):
entry = int(entry)
input_data, output_data = None, None
selected_component_id = self._owner.get_state().selected_component_id
if selected_component_id is not None:
runtime_state = self._owner.player.runtime_states.get(selected_component_id, None)
if runtime_state is not None:
input_content = runtime_state.input[entry] if len(runtime_state.input) > entry else None
output_content = runtime_state.output[entry] if len(runtime_state.output) > entry else None
if input_content is not None:
self._input_jsonviewer.set_data(input_content.item.as_dict())
input_data = self._input_jsonviewer
if output_content is not None:
self._output_jsonviewer.set_data(output_content.item.as_dict())
output_data = self._output_jsonviewer
return (self._mk_input(content=input_data, oob=True),
self._mk_output(content=output_data, oob=True))
def _mk_layout(self): def _mk_layout(self):
return Div( return Div(
self.entry_selector,
Div(
self._mk_input(), self._mk_input(),
self._mk_properties(), self._mk_properties(),
self._mk_output(), self._mk_output(),
cls="flex", cls="flex",
style="height: 100%; width: 100%; flex: 1;" style="height: 100%; width: 100%; flex: 1;"
) )
)
def _mk_input(self, content=None, oob=False): def _mk_input(self):
return Div( return Div(
content, "Input",
id=f"pi_{self._id}", id=f"pi_{self._id}",
style=f"width: {self.layout.input_width}px;", style=f"width: {self.layout.input_width}px;",
cls="wkf-properties-input", cls="wkf-properties-input"
hx_swap_oob=f'true' if oob else None,
) )
def _mk_output(self, content=None, oob=False): def _mk_output(self):
return Div( return Div(
content, "Output",
id=f"po_{self._id}", id=f"po_{self._id}",
style=f"width: {self.layout.output_width}px;", style=f"width: {self.layout.output_width}px;",
cls="wkf-properties-output", cls="wkf-properties-output"
hx_swap_oob=f'true' if oob else None,
) )
def _mk_properties(self): def _mk_properties(self):

View File

@@ -53,7 +53,6 @@ class WorkflowPlayer(BaseComponent):
self.runtime_states = {} self.runtime_states = {}
self.global_error = None self.global_error = None
self.has_error = False self.has_error = False
self.nb_items = 0
def set_boundaries(self, boundaries: dict): def set_boundaries(self, boundaries: dict):
self._datagrid.set_boundaries(boundaries) self._datagrid.set_boundaries(boundaries)
@@ -94,14 +93,11 @@ class WorkflowPlayer(BaseComponent):
self.global_error = engine.global_error self.global_error = engine.global_error
else: # loop through the components and update the runtime states else: # loop through the components and update the runtime states
self.nb_items = engine.nb_items
for component in sorted_components: for component in sorted_components:
runtime_state = self.runtime_states.get(component.id) runtime_state = self.runtime_states.get(component.id)
if component.id not in engine.errors: if component.id not in engine.errors:
runtime_state.state = ComponentState.SUCCESS runtime_state.state = ComponentState.SUCCESS
runtime_state.input = engine.debug[component.id]["input"]
runtime_state.output = engine.debug[component.id]["output"]
continue continue
# the component failed # the component failed
@@ -181,7 +177,7 @@ class WorkflowPlayer(BaseComponent):
# Return sorted components # Return sorted components
return [components_by_id[cid] for cid in sorted_order] return [components_by_id[cid] for cid in sorted_order]
def _get_engine(self, sorted_components) -> WorkflowEngine: def _get_engine(self, sorted_components):
# first reorder the component, according to the connection definitions # first reorder the component, according to the connection definitions
engine = WorkflowEngine() engine = WorkflowEngine()
for component in sorted_components: for component in sorted_components:

View File

@@ -48,8 +48,6 @@ class WorkflowComponentRuntimeState:
id: str id: str
state: ComponentState = ComponentState.SUCCESS state: ComponentState = ComponentState.SUCCESS
error_message: str | None = None error_message: str | None = None
input: list = None
output: list = None
@dataclass @dataclass
@@ -64,7 +62,7 @@ class WorkflowsDesignerState:
component_counter: int = 0 component_counter: int = 0
designer_height: int = 230 designer_height: int = 230
properties_input_width: int = None properties_input_width: int = None
properties_properties_width: int = None properties_properties_width : int = None
properties_output_width: int = None properties_output_width: int = None
selected_component_id: str | None = None selected_component_id: str | None = None

View File

@@ -48,9 +48,6 @@ class Expando:
return self._props.copy() return self._props.copy()
def to_dict(self, mappings: dict) -> dict: def to_dict(self, mappings: dict) -> dict:
"""
Return the information as a dictionary, with the given mappings
"""
return {prop_name: self.get(path) for path, prop_name in mappings.items() if prop_name is not None} return {prop_name: self.get(path) for path, prop_name in mappings.items() if prop_name is not None}
def __hasattr__(self, item): def __hasattr__(self, item):

View File

@@ -7,13 +7,10 @@ attr_map = {
"_id": "id", "_id": "id",
} }
def safe_attr(attr_name): def safe_attr(attr_name):
attr_name = attr_name.replace("hx_", "hx-") attr_name = attr_name.replace("hx_", "hx-")
attr_name = attr_name.replace("data_", "data-")
return attr_map.get(attr_name, attr_name) return attr_map.get(attr_name, attr_name)
def to_html(item): def to_html(item):
if item is None: if item is None:
return "" return ""
@@ -30,14 +27,14 @@ def to_html(item):
class MyFt: class MyFt:
def __init__(self, tag, *args, **kwargs): def __init__(self, name, *args, **kwargs):
self.tag = tag self.name = name
self.children = args self.children = args
self.attrs = {safe_attr(k): v for k, v in kwargs.items()} self.attrs = kwargs
def to_html(self): def to_html(self):
body_items = [to_html(item) for item in self.children] body_items = [to_html(item) for item in self.children]
return f"<{self.tag} {' '.join(f'{k}="{v}"' for k, v in self.attrs.items())}>{' '.join(body_items)}</div>" return f"<{self.name} {' '.join(f'{safe_attr(k)}="{v}"' for k, v in self.attrs.items())}>{' '.join(body_items)}</div>"
def __ft__(self): def __ft__(self):
return NotStr(self.to_html()) return NotStr(self.to_html())

View File

@@ -47,10 +47,6 @@ class InstanceManager:
return InstanceManager._instances[key] return InstanceManager._instances[key]
@staticmethod
def new(session, instance_type, **kwargs):
return InstanceManager.get(session, instance_type.create_component_id(session), instance_type, **kwargs)
@staticmethod @staticmethod
def register(session: dict | None, instance, instance_id: str = None): def register(session: dict | None, instance, instance_id: str = None):
""" """

View File

@@ -1,5 +1,7 @@
# global layout # global layout
import logging.config import logging.config
import random
from asyncio import sleep
import yaml import yaml
from fasthtml.common import * from fasthtml.common import *
@@ -53,6 +55,9 @@ links = [
Link(href="./assets/daisyui-5-themes.css", rel="stylesheet", type="text/css"), Link(href="./assets/daisyui-5-themes.css", rel="stylesheet", type="text/css"),
Script(src="./assets/tailwindcss-browser@4.js"), Script(src="./assets/tailwindcss-browser@4.js"),
# SSE
Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
# Old drawer layout # Old drawer layout
Script(src="./assets/DrawerLayout.js", defer=True), Script(src="./assets/DrawerLayout.js", defer=True),
Link(rel="stylesheet", href="./assets/DrawerLayout.css"), Link(rel="stylesheet", href="./assets/DrawerLayout.css"),
@@ -146,8 +151,6 @@ register_component("theme_controller", "components.themecontroller", "ThemeContr
register_component("main_layout", "components.drawerlayout", "DrawerLayoutApp") register_component("main_layout", "components.drawerlayout", "DrawerLayoutApp")
register_component("undo_redo", "components.undo_redo", "UndoRedoApp") register_component("undo_redo", "components.undo_redo", "UndoRedoApp")
register_component("tabs", "components.tabs", "TabsApp") # before repositories register_component("tabs", "components.tabs", "TabsApp") # before repositories
register_component("entryselector", "components.entryselector", "EntrySelectorApp")
register_component("jsonviewer", "components.jsonviewer", "JsonViewerApp")
register_component("applications", "components.applications", "ApplicationsApp") register_component("applications", "components.applications", "ApplicationsApp")
register_component("repositories", "components.repositories", "RepositoriesApp") register_component("repositories", "components.repositories", "RepositoriesApp")
register_component("workflows", "components.workflows", "WorkflowsApp") register_component("workflows", "components.workflows", "WorkflowsApp")
@@ -216,7 +219,7 @@ app, rt = fast_app(
# ------------------------- # -------------------------
# Profiling middleware # Profiling middleware
# ------------------------- # -------------------------
# @app.middleware("http") @app.middleware("http")
async def timing_middleware(request, call_next): async def timing_middleware(request, call_next):
import time import time
start_total = time.perf_counter() start_total = time.perf_counter()
@@ -273,6 +276,31 @@ def get(session):
DrawerLayoutOld(pages),) DrawerLayoutOld(pages),)
shutdown_event = signal_shutdown()
async def number_generator():
while True: # not shutdown_event.is_set():
data = Article(random.randint(1, 100))
print(data)
yield sse_message(data)
await sleep(1)
@rt("/sse")
def get():
return Titled("SSE Random Number Generator",
P("Generate pairs of random numbers, as the list grows scroll downwards."),
Div(hx_ext="sse",
sse_connect="/number-stream",
hx_swap="beforeend show:bottom",
sse_swap="message"))
@rt("/number-stream")
async def get(): return EventStream(number_generator())
@rt('/toasting') @rt('/toasting')
def get(session): def get(session):
# Normally one toast is enough, this allows us to see # Normally one toast is enough, this allows us to see

View File

@@ -1,7 +1,6 @@
import ast import ast
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Generator from typing import Any, Generator
from components.admin.admin_db_manager import AdminDbManager from components.admin.admin_db_manager import AdminDbManager
@@ -12,14 +11,6 @@ from core.utils import UnreferencedNamesVisitor
from utils.Datahelper import DataHelper from utils.Datahelper import DataHelper
@dataclass
class WorkflowPayload:
processor_name: str
component_id: str
item_linkage_id: int
item: Any
class DataProcessorError(Exception): class DataProcessorError(Exception):
def __init__(self, component_id, error): def __init__(self, component_id, error):
self.component_id = component_id self.component_id = component_id
@@ -155,56 +146,35 @@ class WorkflowEngine:
self.has_error = False self.has_error = False
self.global_error = None self.global_error = None
self.errors = {} self.errors = {}
self.debug = {}
self.nb_items = -1
def add_processor(self, processor: DataProcessor) -> 'WorkflowEngine': def add_processor(self, processor: DataProcessor) -> 'WorkflowEngine':
"""Add a data processor to the pipeline.""" """Add a data processor to the pipeline."""
self.processors.append(processor) self.processors.append(processor)
return self return self
def _process_single_item(self, item_linkage_id, item: Any, processor_index: int = 0) -> Generator[Any, None, None]: def _process_single_item(self, item: Any, processor_index: int = 0) -> Generator[Any, None, None]:
"""Process a single item through the remaining processors.""" """Process a single item through the remaining processors."""
if processor_index >= len(self.processors): if processor_index >= len(self.processors):
yield item yield item
return return
processor = self.processors[processor_index] processor = self.processors[processor_index]
if not processor.component_id in self.debug:
self.debug[processor.component_id] = {"input": [], "output": []}
self.debug[processor.component_id]["input"].append(WorkflowPayload(
processor_name=processor.__class__.__name__,
component_id=processor.component_id,
item_linkage_id=item_linkage_id,
item=item))
# Process the item through the current processor # Process the item through the current processor
for processed_item in processor.process(item): for processed_item in processor.process(item):
self.debug[processor.component_id]["output"].append(WorkflowPayload(
processor_name=processor.__class__.__name__,
component_id=processor.component_id,
item_linkage_id=item_linkage_id,
item=processed_item))
# Recursively process through remaining processors # Recursively process through remaining processors
yield from self._process_single_item(item_linkage_id, processed_item, processor_index + 1) yield from self._process_single_item(processed_item, processor_index + 1)
def run(self) -> Generator[Any, None, None]: def run(self) -> Generator[Any, None, None]:
""" """
Run the workflow pipeline and yield results one by one. Run the workflow pipeline and yield results one by one.
The first processor must be a DataProducer. The first processor must be a DataProducer.
""" """
self.debug.clear()
if not self.processors: if not self.processors:
self.has_error = False self.has_error = False
self.global_error = "No processors in the pipeline" self.global_error = "No processors in the pipeline"
self.nb_items = -1
raise ValueError(self.global_error) raise ValueError(self.global_error)
self.nb_items = 0
first_processor = self.processors[0] first_processor = self.processors[0]
if not isinstance(first_processor, DataProducer): if not isinstance(first_processor, DataProducer):
@@ -212,16 +182,8 @@ class WorkflowEngine:
self.global_error = "First processor must be a DataProducer" self.global_error = "First processor must be a DataProducer"
raise ValueError(self.global_error) raise ValueError(self.global_error)
self.debug[first_processor.component_id] = {"input": [], "output": []} for item in first_processor.process(None):
yield from self._process_single_item(item, 1)
for item_linkage_id, item in enumerate(first_processor.process(None)):
self.nb_items += 1
self.debug[first_processor.component_id]["output"].append(WorkflowPayload(
processor_name=first_processor.__class__.__name__,
component_id=first_processor.component_id,
item_linkage_id=item_linkage_id,
item=item))
yield from self._process_single_item(item_linkage_id, item, 1)
def run_to_list(self) -> list[Any]: def run_to_list(self) -> list[Any]:
""" """

View File

@@ -642,10 +642,10 @@ def extract_table_values_new(ft, header=True):
# first, get the header # first, get the header
if header: if header:
header_element = search_elements_by_name(ft, attrs={"class": "dt2-header"}, comparison_method='contains')[0] header = search_elements_by_name(ft, attrs={"class": "dt2-header"}, comparison_method='contains')[0]
header_map = {} header_map = {}
res = OrderedDict() res = OrderedDict()
for row in header_element.children: for row in header.children:
col_id = row.attrs["data-col"] col_id = row.attrs["data-col"]
title = row.attrs["data-tooltip"] title = row.attrs["data-tooltip"]
header_map[col_id] = title header_map[col_id] = title
@@ -654,7 +654,6 @@ def extract_table_values_new(ft, header=True):
body = search_elements_by_name(ft, attrs={"class": "dt2-body"}, comparison_method='contains')[0] body = search_elements_by_name(ft, attrs={"class": "dt2-body"}, comparison_method='contains')[0]
for row in body.children: for row in body.children:
for col in row.children: for col in row.children:
if hasattr(col, "attrs"):
col_id = col.attrs["data-col"] col_id = col.attrs["data-col"]
cell_value = _get_cell_content_value(col) cell_value = _get_cell_content_value(col)
res[header_map[col_id]].append(cell_value) res[header_map[col_id]].append(cell_value)

View File

@@ -509,18 +509,3 @@ def test_i_can_compute_footer_menu_position_when_not_enough_space(dg):
) )
assert matches(menu, expected) assert matches(menu, expected)
def test_the_content_of_the_cell_is_escaped(empty_dg):
df = pd.DataFrame({
'value': ['<div> My Content </div>'],
'value2': ['{My Content}'],
})
my_dg = empty_dg.init_from_dataframe(df)
actual = my_dg.__ft__()
table_content = extract_table_values_new(actual, header=True)
assert table_content == OrderedDict({
'value': ['&lt;div&gt; My Content &lt;/div&gt;'],
'value2': ['{My Content}']})

View File

@@ -1,228 +0,0 @@
import pytest
from components.jsonviewer.hooks import (
HookContext, EventType, Hook, HookManager, HookBuilder,
WhenLongText, WhenEditable, WhenType, WhenKey, WhenPath, WhenValue,
CompositeCondition
)
# HookContext test helper
def create_mock_context(value=None, key=None, json_path=None, parent_node=None, node_type=None, children=None):
"""Helper to create a mock HookContext for testing."""
class Node:
def __init__(self, value, node_type=None, children=None):
self.value = value
self.__class__.__name__ = node_type or "MockNode"
self.children = children or []
mock_node = Node(value, node_type=node_type, children=children)
return HookContext(key=key, node=mock_node, helper=None, jsonviewer=None, json_path=json_path,
parent_node=parent_node)
# ================
# Test Conditions
# ================
@pytest.mark.parametrize("text, threshold, expected", [
("This is a very long text." * 10, 50, True), # Long text, above threshold
("Short text", 50, False), # Short text, below threshold
])
def test_i_can_detect_long_text(text, threshold, expected):
context = create_mock_context(value=text)
condition = WhenLongText(threshold=threshold)
assert condition.evaluate(context) == expected
@pytest.mark.parametrize("json_path, editable_paths, editable_types, node_value, is_leaf, expected", [
("root.editable.value", ["root.editable.value"], None, "Editable value", True, True), # Editable path matches
("root.not_editable.value", ["root.editable.value"], None, "Editable value", True, False),
# Editable path does not match
("root.editable.numeric", [], [int], 10, True, True), # Type is editable (int)
("root.editable.string", [], [int], "Non-editable value", True, False) # Type is not editable
])
def test_i_can_detect_editable(json_path, editable_paths, editable_types, node_value, is_leaf, expected):
context = create_mock_context(value=node_value, json_path=json_path)
context.is_leaf_node = lambda: is_leaf # Mock is_leaf_node behavior
condition = WhenEditable(editable_paths=editable_paths, editable_types=editable_types)
assert condition.evaluate(context) == expected
@pytest.mark.parametrize("node_value, target_type, expected", [
(123, int, True), # Matches target type
("String value", int, False) # Does not match target type
])
def test_i_can_detect_type_match(node_value, target_type, expected):
context = create_mock_context(value=node_value)
condition = WhenType(target_type=target_type)
assert condition.evaluate(context) == expected
@pytest.mark.parametrize("key, key_pattern, expected", [
("target_key", "target_key", True), # Exact match
("target_key", lambda k: k.startswith("target"), True), # Callable match
("wrong_key", "target_key", False) # Pattern does not match
])
def test_i_can_match_key(key, key_pattern, expected):
context = create_mock_context(key=key)
condition = WhenKey(key_pattern=key_pattern)
assert condition.evaluate(context) == expected
@pytest.mark.parametrize("json_path, path_pattern, expected", [
("root.items[0].name", r"root\.items\[\d+\]\.name", True), # Matches pattern
("root.invalid_path", r"root\.items\[\d+\]\.name", False) # Does not match
])
def test_i_can_match_path(json_path, path_pattern, expected):
context = create_mock_context(json_path=json_path)
condition = WhenPath(path_pattern=path_pattern)
assert condition.evaluate(context) == expected
@pytest.mark.parametrize("value, target_value, predicate, expected", [
(123, 123, None, True), # Direct match
(123, 456, None, False), # Direct mismatch
(150, None, lambda v: v > 100, True), # Satisfies predicate
(50, None, lambda v: v > 100, False), # Does not satisfy predicate
])
def test_i_can_detect_value(value, target_value, predicate, expected):
context = create_mock_context(value=value)
condition = WhenValue(target_value=target_value, predicate=predicate)
assert condition.evaluate(context) == expected
@pytest.mark.parametrize("value, conditions, operator, expected", [
(200, [WhenValue(predicate=lambda v: v > 100), WhenType(target_type=int)], "AND", True),
# Both conditions pass (AND)
(200, [WhenValue(predicate=lambda v: v > 100), WhenType(target_type=str)], "AND", False),
# One condition fails (AND)
(200, [WhenValue(predicate=lambda v: v > 100), WhenType(target_type=str)], "OR", True),
# At least one passes (OR)
(50, [], "AND", True), # No conditions (default True for AND/OR)
])
def test_i_can_combine_conditions(value, conditions, operator, expected):
context = create_mock_context(value=value)
composite = CompositeCondition(conditions=conditions, operator=operator)
assert composite.evaluate(context) == expected
# ================
# Test Hooks
# ================
@pytest.mark.parametrize("event_type, actual_event, threshold, text, expected", [
(EventType.RENDER, EventType.RENDER, 10, "Long text" * 10, True), # Event matches, meets condition
(EventType.RENDER, EventType.CLICK, 10, "Long text" * 10, False), # Event mismatch
])
def test_i_can_match_hook(event_type, actual_event, threshold, text, expected):
context = create_mock_context(value=text)
condition = WhenLongText(threshold=threshold)
hook = Hook(event_type=event_type, conditions=[condition], executor=lambda ctx: "Executed")
assert hook.matches(event_type=actual_event, context=context) == expected
# ================
# Test HookManager
# ================
def test_i_can_execute_hooks_in_manager():
hook_manager = HookManager()
# Add hooks
hook1 = Hook(EventType.RENDER, conditions=[], executor=lambda ctx: "Render Executed")
hook2 = Hook(EventType.CLICK, conditions=[], executor=lambda ctx: "Click Executed")
hook_manager.add_hook(hook1)
hook_manager.add_hook(hook2)
context = create_mock_context()
render_results = hook_manager.execute_hooks(event_type=EventType.RENDER, context=context)
click_results = hook_manager.execute_hooks(event_type=EventType.CLICK, context=context)
assert len(render_results) == 1
assert render_results[0] == "Render Executed"
assert len(click_results) == 1
assert click_results[0] == "Click Executed"
def test_i_can_clear_hooks_in_manager():
hook_manager = HookManager()
hook_manager.add_hook(Hook(EventType.RENDER, conditions=[], executor=lambda ctx: "Render"))
assert len(hook_manager.hooks) == 1
hook_manager.clear_hooks()
assert len(hook_manager.hooks) == 0
# ================
# Test HookBuilder with Callable Conditions
# ================
def test_i_can_use_callable_with_when_custom():
"""Test that when_custom() accepts callable predicates"""
# Define a simple callable condition
def custom_condition(context):
return isinstance(context.get_value(), str) and context.get_value().startswith("CUSTOM_")
# Create hook using callable condition
hook = (HookBuilder()
.on_render()
.when_custom(custom_condition)
.execute(lambda ctx: "Custom hook executed"))
# Test with matching context
matching_context = create_mock_context(value="CUSTOM_test_value")
assert hook.matches(EventType.RENDER, matching_context) == True
assert hook.execute(matching_context) == "Custom hook executed"
# Test with non-matching context
non_matching_context = create_mock_context(value="regular_value")
assert hook.matches(EventType.RENDER, non_matching_context) == False
def test_i_can_use_lambda_with_when_custom():
"""Test that when_custom() accepts lambda expressions"""
# Create hook using lambda condition
hook = (HookBuilder()
.on_render()
.when_custom(lambda ctx: ctx.key == "special" and isinstance(ctx.get_value(), int) and ctx.get_value() > 100)
.execute(lambda ctx: f"Special value: {ctx.get_value()}"))
# Test with matching context
matching_context = create_mock_context(value=150, key="special")
assert hook.matches(EventType.RENDER, matching_context) == True
assert hook.execute(matching_context) == "Special value: 150"
# Test with non-matching contexts
wrong_key_context = create_mock_context(value=150, key="normal")
assert hook.matches(EventType.RENDER, wrong_key_context) == False
wrong_value_context = create_mock_context(value=50, key="special")
assert hook.matches(EventType.RENDER, wrong_value_context) == False
@pytest.mark.parametrize("value, key, json_path, expected", [
("CUSTOM_hook_test", "test_key", "root.test", True), # Matches callable condition
("regular_text", "test_key", "root.test", False), # Doesn't match callable condition
(123, "test_key", "root.test", False), # Wrong type
])
def test_callable_condition_evaluation(value, key, json_path, expected):
"""Test callable condition evaluation with different inputs"""
def custom_callable_condition(context):
return isinstance(context.get_value(), str) and context.get_value().startswith("CUSTOM_")
hook = (HookBuilder()
.on_render()
.when_custom(custom_callable_condition)
.execute(lambda ctx: "Executed"))
context = create_mock_context(value=value, key=key, json_path=json_path)
assert hook.matches(EventType.RENDER, context) == expected

View File

@@ -1,12 +1,12 @@
import pytest import pytest
from components.jsonviewer.components.JsonViewer import * from components.debugger.components.JsonViewer import *
from components.jsonviewer.hooks import HookBuilder
from helpers import matches, span_icon, search_elements_by_name, extract_jsonviewer_node from helpers import matches, span_icon, search_elements_by_name, extract_jsonviewer_node
JSON_VIEWER_INSTANCE_ID = "json_viewer" JSON_VIEWER_INSTANCE_ID = "json_viewer"
ML_20 = "margin-left: 20px;" ML_20 = "margin-left: 20px;"
CLS_PREFIX = "mmt-jsonviewer" CLS_PREFIX = "mmt-jsonviewer"
USER_ID = "user_id"
dn = DictNode dn = DictNode
ln = ListNode ln = ListNode
@@ -15,7 +15,7 @@ n = ValueNode
@pytest.fixture() @pytest.fixture()
def json_viewer(session): def json_viewer(session):
return JsonViewer(session, JSON_VIEWER_INSTANCE_ID, {}) return JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, {})
@pytest.fixture() @pytest.fixture()
@@ -41,7 +41,7 @@ def jv_id(x):
ln([{"a": [1, 2]}], jv_id(0), 0, [dn({"a": [1, 2]}, jv_id(1), 1, {"a": ln([1, 2], jv_id(2), 2, [n(1), n(2)])})])) ln([{"a": [1, 2]}], jv_id(0), 0, [dn({"a": [1, 2]}, jv_id(1), 1, {"a": ln([1, 2], jv_id(2), 2, [n(1), n(2)])})]))
]) ])
def test_i_can_create_node(data, expected_node): def test_i_can_create_node(data, expected_node):
json_viewer_ = JsonViewer(None, JSON_VIEWER_INSTANCE_ID, data) json_viewer_ = JsonViewer(None, JSON_VIEWER_INSTANCE_ID, None, USER_ID, data)
assert json_viewer_.node == expected_node assert json_viewer_.node == expected_node
@@ -63,7 +63,7 @@ def test_i_can_render(json_viewer):
(None, Span("null", cls=f"{CLS_PREFIX}-null")), (None, Span("null", cls=f"{CLS_PREFIX}-null")),
]) ])
def test_i_can_render_simple_value(session, value, expected_inner): def test_i_can_render_simple_value(session, value, expected_inner):
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
actual = jsonv.__ft__() actual = jsonv.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0] to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0]
expected = Div( expected = Div(
@@ -81,7 +81,7 @@ def test_i_can_render_simple_value(session, value, expected_inner):
def test_i_can_render_expanded_list_node(session): def test_i_can_render_expanded_list_node(session):
value = [1, "hello", True] value = [1, "hello", True]
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
# Force expansion of the node # Force expansion of the node
jsonv.set_folding_mode("expand") jsonv.set_folding_mode("expand")
@@ -107,7 +107,7 @@ def test_i_can_render_expanded_list_node(session):
def test_i_can_render_expanded_dict_node(session): def test_i_can_render_expanded_dict_node(session):
value = {"a": 1, "b": "hello", "c": True} value = {"a": 1, "b": "hello", "c": True}
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
# Force expansion of the node # Force expansion of the node
jsonv.set_folding_mode("expand") jsonv.set_folding_mode("expand")
@@ -133,7 +133,7 @@ def test_i_can_render_expanded_dict_node(session):
def test_i_can_render_expanded_list_of_dict_node(session): def test_i_can_render_expanded_list_of_dict_node(session):
value = [{"a": 1, "b": "hello"}] value = [{"a": 1, "b": "hello"}]
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
# Force expansion of all nodes # Force expansion of all nodes
jsonv.set_folding_mode("expand") jsonv.set_folding_mode("expand")
@@ -167,7 +167,7 @@ def test_i_can_render_expanded_list_of_dict_node(session):
def test_render_with_collapse_folding_mode(session): def test_render_with_collapse_folding_mode(session):
# Create a nested structure to test collapse rendering # Create a nested structure to test collapse rendering
value = {"a": [1, 2, 3], "b": {"x": "y", "z": True}} value = {"a": [1, 2, 3], "b": {"x": "y", "z": True}}
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
# Ensure folding mode is set to collapse (should be default) # Ensure folding mode is set to collapse (should be default)
jsonv.set_folding_mode("collapse") jsonv.set_folding_mode("collapse")
@@ -195,7 +195,7 @@ def test_render_with_collapse_folding_mode(session):
def test_render_with_specific_node_expanded_in_collapse_mode(session): def test_render_with_specific_node_expanded_in_collapse_mode(session):
# Create a nested structure to test mixed collapse/expand rendering # Create a nested structure to test mixed collapse/expand rendering
value = {"a": [1, 2, 3], "b": {"x": "y", "z": True}} value = {"a": [1, 2, 3], "b": {"x": "y", "z": True}}
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
# Ensure folding mode is set to collapse # Ensure folding mode is set to collapse
jsonv.set_folding_mode(FoldingMode.COLLAPSE) jsonv.set_folding_mode(FoldingMode.COLLAPSE)
@@ -230,7 +230,7 @@ def test_render_with_specific_node_expanded_in_collapse_mode(session):
def test_multiple_folding_levels_in_collapse_mode(session): def test_multiple_folding_levels_in_collapse_mode(session):
# Create a deeply nested structure # Create a deeply nested structure
value = {"level1": {"level2": {"level3": [1, 2, 3]}}} value = {"level1": {"level2": {"level3": [1, 2, 3]}}}
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
# Set folding mode to collapse # Set folding mode to collapse
jsonv.set_folding_mode(FoldingMode.COLLAPSE) jsonv.set_folding_mode(FoldingMode.COLLAPSE)
@@ -262,7 +262,7 @@ def test_multiple_folding_levels_in_collapse_mode(session):
def test_toggle_between_folding_modes(session): def test_toggle_between_folding_modes(session):
value = {"a": [1, 2, 3], "b": {"x": "y"}} value = {"a": [1, 2, 3], "b": {"x": "y"}}
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
# Start with collapse mode # Start with collapse mode
jsonv.set_folding_mode("collapse") jsonv.set_folding_mode("collapse")
@@ -271,19 +271,19 @@ def test_toggle_between_folding_modes(session):
jsonv.set_node_folding(f"{JSON_VIEWER_INSTANCE_ID}-0", "expand") jsonv.set_node_folding(f"{JSON_VIEWER_INSTANCE_ID}-0", "expand")
# Verify node is in tracked nodes (exceptions to collapse mode) # Verify node is in tracked nodes (exceptions to collapse mode)
assert f"{JSON_VIEWER_INSTANCE_ID}-0" in jsonv._folding_manager.get_nodes_to_track() assert f"{JSON_VIEWER_INSTANCE_ID}-0" in jsonv._nodes_to_track
# Now switch to expand mode # Now switch to expand mode
jsonv.set_folding_mode("expand") jsonv.set_folding_mode("expand")
# Tracked nodes should be cleared # Tracked nodes should be cleared
assert len(jsonv._folding_manager.get_nodes_to_track()) == 0 assert len(jsonv._nodes_to_track) == 0
# Collapse specific node # Collapse specific node
jsonv.set_node_folding(f"{JSON_VIEWER_INSTANCE_ID}-0", "collapse") jsonv.set_node_folding(f"{JSON_VIEWER_INSTANCE_ID}-0", "collapse")
# Verify node is in tracked nodes (exceptions to expand mode) # Verify node is in tracked nodes (exceptions to expand mode)
assert f"{JSON_VIEWER_INSTANCE_ID}-0" in jsonv._folding_manager.get_nodes_to_track() assert f"{JSON_VIEWER_INSTANCE_ID}-0" in jsonv._nodes_to_track
# Render and verify the output # Render and verify the output
actual = jsonv.__ft__() actual = jsonv.__ft__()
@@ -297,43 +297,34 @@ def test_toggle_between_folding_modes(session):
def test_custom_hook_rendering(session, helper): def test_custom_hook_rendering(session, helper):
# Define a custom condition to check if the value is "custom_hook_test" # Define a custom hook for testing
def custom_condition(context): def custom_predicate(key, node, h):
return isinstance(context.node.value, str) and context.node.value == "custom_hook_test" return isinstance(node.value, str) and node.value == "custom_hook_test"
# Define a custom executor to render the desired output def custom_renderer(key, node, h):
def custom_renderer(context):
return Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class") return Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class")
# Build the hook using HookBuilder hooks = [(custom_predicate, custom_renderer)]
hook = (HookBuilder()
.on_render()
.when_custom(custom_condition)
.execute(custom_renderer))
# Create a JsonViewer with the new hook # Create JsonViewer with the custom hook
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, "custom_hook_test", hooks=[hook]) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, "custom_hook_test", hooks=hooks)
# Actual rendered output
actual = jsonv.__ft__() actual = jsonv.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0] to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0]
# Expected rendered output
expected = Div( expected = Div(
Div( Div(
None, None,
None, None,
Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class"), Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class"),
style=ML_20), style=ML_20),
id=f"{jv_id('root')}" id=f"{jv_id('root')}")
)
# Assert that the actual output matches the expected output
assert matches(to_compare, expected) assert matches(to_compare, expected)
def test_folding_mode_operations(session): def test_folding_mode_operations(session):
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, {"a": [1, 2, 3]}) jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, {"a": [1, 2, 3]})
# Check default folding mode # Check default folding mode
assert jsonv.get_folding_mode() == "collapse" assert jsonv.get_folding_mode() == "collapse"
@@ -347,11 +338,11 @@ def test_folding_mode_operations(session):
jsonv.set_node_folding(node_id, "collapse") jsonv.set_node_folding(node_id, "collapse")
# Node should be in tracked nodes since it differs from the default mode # Node should be in tracked nodes since it differs from the default mode
assert node_id in jsonv._folding_manager.get_nodes_to_track() assert node_id in jsonv._nodes_to_track
# Restore to match default mode # Restore to match default mode
jsonv.set_node_folding(node_id, "expand") jsonv.set_node_folding(node_id, "expand")
assert node_id not in jsonv._folding_manager.get_nodes_to_track() assert node_id not in jsonv._nodes_to_track
@pytest.mark.parametrize("input_value, expected_output", [ @pytest.mark.parametrize("input_value, expected_output", [
@@ -362,7 +353,7 @@ def test_folding_mode_operations(session):
('', '""'), # Empty string ('', '""'), # Empty string
]) ])
def test_add_quotes(input_value, expected_output): def test_add_quotes(input_value, expected_output):
result = JsonViewerHelper.add_quotes(input_value) result = JsonViewer.add_quotes(input_value)
assert result == expected_output assert result == expected_output

View File

@@ -73,7 +73,6 @@ def test_run_simple_workflow(engine):
assert result == [1, 2, 3] assert result == [1, 2, 3]
@pytest.mark.skip(reason="Not yet implemented")
def test_process_single_item(engine): def test_process_single_item(engine):
"""Test the internal _process_single_item method.""" """Test the internal _process_single_item method."""
mock_processor = MagicMock(spec=DataProcessor) mock_processor = MagicMock(spec=DataProcessor)