From 292a4772985dbad1838d8b735611465c29cfd279 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Thu, 28 Aug 2025 23:24:28 +0200 Subject: [PATCH] Added Hooks implementation --- src/components/jsonviewer/Readme.md | 443 ++++++++++++++++++ .../jsonviewer/components/JsonViewer.py | 178 +++++-- src/components/jsonviewer/hooks.py | 386 +++++++++++++++ tests/test_hooks.py | 159 +++++++ tests/test_jsonviewer.py | 60 ++- 5 files changed, 1152 insertions(+), 74 deletions(-) create mode 100644 src/components/jsonviewer/Readme.md create mode 100644 src/components/jsonviewer/hooks.py create mode 100644 tests/test_hooks.py diff --git a/src/components/jsonviewer/Readme.md b/src/components/jsonviewer/Readme.md new file mode 100644 index 0000000..720fcc6 --- /dev/null +++ b/src/components/jsonviewer/Readme.md @@ -0,0 +1,443 @@ +# 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 for complex logic. + +```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()` \ No newline at end of file diff --git a/src/components/jsonviewer/components/JsonViewer.py b/src/components/jsonviewer/components/JsonViewer.py index 3708881..934bdba 100644 --- a/src/components/jsonviewer/components/JsonViewer.py +++ b/src/components/jsonviewer/components/JsonViewer.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Any, Optional, Callable +from typing import Any, Optional from fasthtml.components import * from pandas import DataFrame @@ -9,6 +9,7 @@ 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 @@ -100,58 +101,76 @@ class FoldingManager: class NodeFactory: - """Factory for creating nodes from data""" + """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) -> Node: - """Creates appropriate node type based on data""" + 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) + return self._create_list_node(key, data, level, json_path, parent_node) elif isinstance(data, dict): - return self._create_dict_node(key, data, level) + return self._create_dict_node(key, data, level, json_path, parent_node) else: - return self._create_value_node(key, data) + return self._create_value_node(key, data, json_path, parent_node) - def _create_list_node(self, key: Any, data: list, level: int) -> ListNode: + 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): - node.children.append(self.create_node(index, item, level + 1)) + 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) -> DictNode: + 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(): - node.children[child_key] = self.create_node(child_key, value, level + 1) + 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) -> ValueNode: + 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 - return ValueNode(data, hint) + 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: @@ -180,26 +199,31 @@ class JsonViewerHelper: class NodeRenderer: - """Single class handling all node rendering with helper methods""" + """Single class handling all node rendering with new hook system""" - def __init__(self, session, jsonviewer_instance, folding_manager: FoldingManager, - commands: JsonViewerCommands, helper: JsonViewerHelper, - hooks: list[tuple[Callable, Callable]] = None): + 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 # reference to main component + self.jsonviewer = jsonviewer_instance self.folding_manager = folding_manager self.commands = commands self.helper = helper - self.hooks = hooks or [] + self.hook_manager = hook_manager + self.node_factory = node_factory - def render(self, key: Any, node: Node) -> Div: + 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), + self._render_value(key, node, must_expand, json_path, parent_node), style=f"margin-left: {INDENT_SIZE}px;", id=getattr(node, "node_id", None) ) @@ -219,17 +243,36 @@ class NodeRenderer: ) ) - def _render_value(self, key: Any, node: Node, must_expand: Optional[bool]) -> Any: - """Renders the value part of a node - extracted from original _render_value""" + 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) - # Check hooks first - for predicate, renderer in self.hooks: - if predicate(key, node, self.helper): - return renderer(key, node, self.helper) + # Create hook context + context = HookContext( + key=key, + node=node, + helper=self.helper, + jsonviewer=self.jsonviewer, + json_path=json_path, + parent_node=parent_node + ) - # Route to appropriate helper method + # 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): @@ -238,7 +281,7 @@ class NodeRenderer: return self._render_value_node(key, node) def _render_collapsed_indicator(self, node: Node) -> Span: - """Renders collapsed indicator - extracted from original""" + """Renders collapsed indicator""" indicator = "[...]" if isinstance(node, ListNode) else "{...}" return Span( indicator, @@ -247,23 +290,30 @@ class NodeRenderer: ) def _render_dict_node(self, key: Any, node: DictNode) -> Span: - """Renders dictionary node - extracted from original _render_dict""" + """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( "{", - *[self.render(child_key, value) for child_key, value in node.children.items()], + *children_elements, Div("}"), id=node.node_id ) def _render_list_node(self, key: Any, node: ListNode) -> Span: - """Renders list node - extracted from original _render_list""" + """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 - extracted from original _all_the_same""" + """Determines if list should be rendered as grid""" if len(node.children) == 0: return False @@ -277,15 +327,21 @@ class NodeRenderer: if type_ in (int, float, str, bool, list, dict, ValueNode): return False - # Check if a specific rendering is specified via hooks - for predicate, renderer in self.hooks: - if predicate(key, sample_node, self.helper): - 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 - extracted from original _render_as_grid""" + """Renders list as grid""" type_ = type(node.children[0].value) icon = icon_class str_value = type_.__name__.split(".")[-1] @@ -306,15 +362,22 @@ class NodeRenderer: ) def _render_list_as_array(self, key: Any, node: ListNode) -> Span: - """Renders list as array - extracted from original _render_as_list""" + """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( "[", - *[self.render(index, item) for index, item in enumerate(node.children)], + *children_elements, Div("]"), ) def _render_value_node(self, key: Any, node: ValueNode) -> Span: - """Renders value node - extracted and simplified from original _render_value""" + """Renders value node""" data_tooltip = None htmx_params = {} icon = None @@ -376,28 +439,32 @@ class NodeRenderer: class JsonViewer(BaseComponentMultipleInstance): - """Main JsonViewer component with separated concerns""" + """Main JsonViewer component with new hook system""" COMPONENT_INSTANCE_ID = "Jsonviewer" - def __init__(self, session, _id, data=None, hooks=None, key=None, boundaries=None): + 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.hooks = hooks or [] 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 single renderer + # Initialize renderer with hook manager self._node_renderer = NodeRenderer( session, self, self._folding_manager, - self._commands, self._helper, self.hooks + self._commands, self._helper, self._hook_manager, self._node_factory ) # Create the initial node tree @@ -406,7 +473,6 @@ class JsonViewer(BaseComponentMultipleInstance): @property def user_id(self) -> str: """Gets user_id from session or returns default""" - # Preserve original behavior - this should be implemented based on your session management return getattr(self, '_user_id', getattr(self._session, 'user_id', 'default_user')) def set_data(self, data): @@ -416,6 +482,18 @@ class JsonViewer(BaseComponentMultipleInstance): 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) @@ -423,7 +501,9 @@ class JsonViewer(BaseComponentMultipleInstance): def render_node(self, node_id: str): """Renders a specific node by ID""" key, node = self._node_factory.get_node_by_id(node_id) - return self._node_renderer.render(key, node) + 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""" @@ -444,7 +524,7 @@ class JsonViewer(BaseComponentMultipleInstance): return Div( Div( - self._node_renderer.render(None, self.node), + self._node_renderer.render(None, self.node, "", None), id=f"{self._id}-root", style="margin-left: 0px;" ), diff --git a/src/components/jsonviewer/hooks.py b/src/components/jsonviewer/hooks.py new file mode 100644 index 0000000..6942c19 --- /dev/null +++ b/src/components/jsonviewer/hooks.py @@ -0,0 +1,386 @@ +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)) \ No newline at end of file diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..3936085 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,159 @@ +import pytest + +from components.jsonviewer.hooks import ( + HookContext, EventType, Hook, HookManager, + 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 diff --git a/tests/test_jsonviewer.py b/tests/test_jsonviewer.py index 9b63307..feec9b3 100644 --- a/tests/test_jsonviewer.py +++ b/tests/test_jsonviewer.py @@ -1,6 +1,7 @@ import pytest from components.jsonviewer.components.JsonViewer import * +from components.jsonviewer.hooks import HookBuilder from helpers import matches, span_icon, search_elements_by_name, extract_jsonviewer_node JSON_VIEWER_INSTANCE_ID = "json_viewer" @@ -296,30 +297,39 @@ def test_toggle_between_folding_modes(session): def test_custom_hook_rendering(session, helper): - # Define a custom hook for testing - def custom_predicate(key, node, h): - return isinstance(node.value, str) and node.value == "custom_hook_test" - - def custom_renderer(key, node, h): - return Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class") - - hooks = [(custom_predicate, custom_renderer)] - - # Create JsonViewer with the custom hook - jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, "custom_hook_test", hooks=hooks) - - actual = jsonv.__ft__() - to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0] - - expected = Div( - Div( - None, - None, - Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class"), - style=ML_20), - id=f"{jv_id('root')}") - - assert matches(to_compare, expected) + # Define a custom condition to check if the value is "custom_hook_test" + def custom_condition(context): + return isinstance(context.node.value, str) and context.node.value == "custom_hook_test" + + # Define a custom executor to render the desired output + def custom_renderer(context): + return Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class") + + # Build the hook using HookBuilder + hook = (HookBuilder() + .on_render() + .when_custom(custom_condition) + .execute(custom_renderer)) + + # Create a JsonViewer with the new hook + jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, "custom_hook_test", hooks=[hook]) + + # Actual rendered output + actual = jsonv.__ft__() + to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0] + + # Expected rendered output + expected = Div( + Div( + None, + None, + Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class"), + style=ML_20), + id=f"{jv_id('root')}" + ) + + # Assert that the actual output matches the expected output + assert matches(to_compare, expected) def test_folding_mode_operations(session): @@ -366,4 +376,4 @@ def test_helper_is_sha256(helper): assert not helper.is_sha256("a" * 63) # Too short assert not helper.is_sha256("a" * 65) # Too long assert not helper.is_sha256("g" * 64) # Invalid character - assert not helper.is_sha256("test") # Not a hash + assert not helper.is_sha256("test") # Not a hash \ No newline at end of file