Added Hooks implementation
This commit is contained in:
443
src/components/jsonviewer/Readme.md
Normal file
443
src/components/jsonviewer/Readme.md
Normal file
@@ -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()`
|
||||
@@ -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):
|
||||
# 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;"
|
||||
),
|
||||
|
||||
386
src/components/jsonviewer/hooks.py
Normal file
386
src/components/jsonviewer/hooks.py
Normal file
@@ -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))
|
||||
159
tests/test_hooks.py
Normal file
159
tests/test_hooks.py
Normal file
@@ -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
|
||||
@@ -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,29 +297,38 @@ 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"
|
||||
# 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"
|
||||
|
||||
def custom_renderer(key, node, h):
|
||||
# Define a custom executor to render the desired output
|
||||
def custom_renderer(context):
|
||||
return Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class")
|
||||
|
||||
hooks = [(custom_predicate, custom_renderer)]
|
||||
# Build the hook using HookBuilder
|
||||
hook = (HookBuilder()
|
||||
.on_render()
|
||||
.when_custom(custom_condition)
|
||||
.execute(custom_renderer))
|
||||
|
||||
# Create JsonViewer with the custom hook
|
||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, "custom_hook_test", hooks=hooks)
|
||||
# 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')}")
|
||||
id=f"{jv_id('root')}"
|
||||
)
|
||||
|
||||
# Assert that the actual output matches the expected output
|
||||
assert matches(to_compare, expected)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user