Compare commits
3 Commits
93cb477c21
...
e96ac5ddfd
| Author | SHA1 | Date | |
|---|---|---|---|
| e96ac5ddfd | |||
| 378775cdf9 | |||
| e34d675e38 |
@@ -37,15 +37,17 @@ This is only one instance per session.
|
||||
|
||||
## High Level Hierarchical Structure
|
||||
```
|
||||
MyFastHtml
|
||||
├── src
|
||||
│ ├── myfasthtml/ # Main library code
|
||||
│ │ ├── core/commands.py # Command definitions
|
||||
│ │ ├── controls/button.py # Control helpers
|
||||
│ │ └── pages/LoginPage.py # Predefined Login page
|
||||
│ └── ...
|
||||
├── tests # Unit and integration tests
|
||||
├── LICENSE # License file (MIT)
|
||||
├── README.md # Project documentation
|
||||
└── pyproject.toml # Build configuration
|
||||
Div(id="layout")
|
||||
├── Header
|
||||
│ ├── Div(id="layout_hl")
|
||||
│ │ ├── Icon # Left drawer icon button
|
||||
│ │ └── Div # Left content for the header
|
||||
│ └── Div(id="layout_hr")
|
||||
│ ├── Div # Right content for the header
|
||||
│ └── UserProfile # user profile icon button
|
||||
├── Div # Left Drawer
|
||||
├── Main # Main content
|
||||
├── Div # Right Drawer
|
||||
├── Footer # Footer
|
||||
└── Script # To initialize the resizing
|
||||
```
|
||||
@@ -5,6 +5,10 @@ from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class CommandsDebugger(SingleInstance):
|
||||
"""
|
||||
Represents a debugger designed for visualizing and managing commands in a parent-child
|
||||
hierarchical structure.
|
||||
"""
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ class DropdownState:
|
||||
|
||||
|
||||
class Dropdown(MultipleInstance):
|
||||
"""
|
||||
Represents a dropdown component that can be toggled open or closed. This class is used
|
||||
to create interactive dropdown elements, allowing for container and button customization.
|
||||
The dropdown provides functionality to manage its state, including opening, closing, and
|
||||
handling user interactions.
|
||||
"""
|
||||
def __init__(self, parent, content=None, button=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.button = Div(button) if not isinstance(button, FT) else button
|
||||
|
||||
@@ -35,6 +35,14 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class FileUpload(MultipleInstance):
|
||||
"""
|
||||
Represents a file upload component.
|
||||
|
||||
This class provides functionality to handle the uploading process of a file,
|
||||
extract sheet names from an Excel file, and enables users to select a specific
|
||||
sheet for further processing. It integrates commands and state management
|
||||
to ensure smooth operation within a parent application.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
@@ -8,12 +10,23 @@ class InstancesDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
|
||||
return self._panel.set_main(vis_network)
|
||||
|
||||
def on_network_event(self, event_data: dict):
|
||||
session, instance_id = event_data["nodes"][0].split("#")
|
||||
properties = {"Id": "_id", "Parent Id": "_parent._id"}
|
||||
return self._panel.set_right(Properties(self,
|
||||
InstancesManager.get(session, instance_id),
|
||||
properties,
|
||||
_id="-properties"))
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
instances = self._get_instances()
|
||||
nodes, edges = from_parent_child_list(
|
||||
|
||||
@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Keyboard(MultipleInstance):
|
||||
"""
|
||||
Represents a keyboard with customizable key combinations support.
|
||||
|
||||
The Keyboard class allows managing key combinations and their corresponding
|
||||
actions for a given parent object.
|
||||
"""
|
||||
def __init__(self, parent, combinations=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Mouse(MultipleInstance):
|
||||
"""
|
||||
Represents a mechanism to manage mouse event combinations and their associated commands.
|
||||
|
||||
This class is used to add, manage, and render mouse event sequences with corresponding
|
||||
commands, providing a flexible way to handle mouse interactions programmatically.
|
||||
"""
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
@@ -38,6 +38,15 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class Panel(MultipleInstance):
|
||||
"""
|
||||
Represents a user interface panel that supports customizable left, main, and right components.
|
||||
|
||||
The `Panel` class is used to create and manage a panel layout with optional left, main,
|
||||
and right sections. It provides functionality to set the components of the panel, toggle
|
||||
sides, and adjust the width of the sides dynamically. The class also handles rendering
|
||||
the panel with appropriate HTML elements and JavaScript for interactivity.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
@@ -58,35 +67,35 @@ class Panel(MultipleInstance):
|
||||
|
||||
def set_right(self, right):
|
||||
self._right = right
|
||||
return self
|
||||
return Div(self._right, id=f"{self._id}_r")
|
||||
|
||||
def set_left(self, left):
|
||||
self._left = left
|
||||
return self
|
||||
return Div(self._left, id=f"{self._id}_l")
|
||||
|
||||
def _mk_right(self):
|
||||
if not self.conf.right:
|
||||
return None
|
||||
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
data_command_id=self.commands.update_side_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
|
||||
return Div(resizer, self._right, cls="mf-panel-right")
|
||||
|
||||
|
||||
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
|
||||
|
||||
def _mk_left(self):
|
||||
if not self.conf.left:
|
||||
return None
|
||||
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-left",
|
||||
data_command_id=self.commands.update_side_width("left").id,
|
||||
data_side="left"
|
||||
)
|
||||
|
||||
return Div(self._left, resizer, cls="mf-panel-left")
|
||||
|
||||
return Div(Div(self._left, id=f"{self._id}_l"), resizer, cls="mf-panel-left")
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
|
||||
44
src/myfasthtml/controls/Properties.py
Normal file
44
src/myfasthtml/controls/Properties.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fasthtml.components import Div
|
||||
from myutils.Expando import Expando
|
||||
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Properties(MultipleInstance):
|
||||
def __init__(self, parent, obj=None, properties: dict = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.obj = obj
|
||||
self.properties = properties or self._get_default_properties(obj)
|
||||
self.expando = self._create_expando()
|
||||
|
||||
def set_obj(self, obj, properties: list[str] = None):
|
||||
self.obj = obj
|
||||
self.properties = properties or self._get_default_properties(obj)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[Div(k, ":", v) for k, v in self.expando.as_dict().items()],
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def _create_expando(self):
|
||||
res = {}
|
||||
for attr_name, mapping in self.properties.items():
|
||||
attrs_path = mapping.split(".")
|
||||
current = self.obj
|
||||
for attr in attrs_path:
|
||||
if hasattr(current, attr):
|
||||
current = getattr(current, attr)
|
||||
else:
|
||||
res[attr_name] = None
|
||||
break
|
||||
res[attr_name] = current
|
||||
|
||||
return Expando(res)
|
||||
|
||||
@staticmethod
|
||||
def _get_default_properties(obj):
|
||||
return {k: k for k, v in dir(obj) if not k.startswith("_")} if obj else {}
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -21,6 +21,21 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class Search(MultipleInstance):
|
||||
"""
|
||||
Represents a component for managing and filtering a list of items.
|
||||
It uses fuzzy matching and subsequence matching to filter items.
|
||||
|
||||
:ivar items_names: The name of the items used to filter.
|
||||
:type items_names: str
|
||||
:ivar items: The first set of items to filter.
|
||||
:type items: list
|
||||
:ivar filtered: A copy of the `items` list, representing the filtered items after a search operation.
|
||||
:type filtered: list
|
||||
:ivar get_attr: Callable function to extract string values from items for filtering.
|
||||
:type get_attr: Callable[[Any], str]
|
||||
:ivar template: Callable function to define how filtered items are rendered.
|
||||
:type template: Callable[[Any], Any]
|
||||
"""
|
||||
def __init__(self,
|
||||
parent: BaseInstance,
|
||||
_id=None,
|
||||
|
||||
@@ -25,19 +25,33 @@ class VisNetworkState(DbObject):
|
||||
},
|
||||
"physics": {"enabled": True}
|
||||
}
|
||||
self.events_handlers: dict = {} # {event_name: command_url}
|
||||
|
||||
|
||||
class VisNetwork(MultipleInstance):
|
||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None, events_handlers=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
logger.debug(f"VisNetwork created with id: {self._id}")
|
||||
|
||||
# possible events (expected in snake_case
|
||||
# - select_node → selectNode
|
||||
# - select → select
|
||||
# - click → click
|
||||
# - double_click → doubleClick
|
||||
|
||||
self._state = VisNetworkState(self)
|
||||
self._update_state(nodes, edges, options)
|
||||
|
||||
# Convert Commands to URLs
|
||||
handlers_htmx_options = {
|
||||
event_name: command.ajax_htmx_options()
|
||||
for event_name, command in events_handlers.items()
|
||||
} if events_handlers else {}
|
||||
|
||||
self._update_state(nodes, edges, options, handlers_htmx_options)
|
||||
|
||||
def _update_state(self, nodes, edges, options):
|
||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
|
||||
if not nodes and not edges and not options:
|
||||
def _update_state(self, nodes, edges, options, events_handlers=None):
|
||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}")
|
||||
if not nodes and not edges and not options and not events_handlers:
|
||||
return
|
||||
|
||||
state = self._state.copy()
|
||||
@@ -47,6 +61,8 @@ class VisNetwork(MultipleInstance):
|
||||
state.edges = edges
|
||||
if options is not None:
|
||||
state.options = options
|
||||
if events_handlers is not None:
|
||||
state.events_handlers = events_handlers
|
||||
|
||||
self._state.update(state)
|
||||
|
||||
@@ -70,6 +86,34 @@ class VisNetwork(MultipleInstance):
|
||||
# Convert Python options to JS
|
||||
js_options = json.dumps(self._state.options, indent=2)
|
||||
|
||||
# Map Python event names to vis-network event names
|
||||
event_name_map = {
|
||||
"select_node": "selectNode",
|
||||
"select": "select",
|
||||
"click": "click",
|
||||
"double_click": "doubleClick"
|
||||
}
|
||||
|
||||
# Generate event handlers JavaScript
|
||||
event_handlers_js = ""
|
||||
for event_name, command_htmx_options in self._state.events_handlers.items():
|
||||
vis_event_name = event_name_map.get(event_name, event_name)
|
||||
event_handlers_js += f"""
|
||||
network.on('{vis_event_name}', function(params) {{
|
||||
const event_data = {{
|
||||
event_name: '{event_name}',
|
||||
nodes: params.nodes,
|
||||
edges: params.edges,
|
||||
pointer: params.pointer
|
||||
}};
|
||||
htmx.ajax('POST', '{command_htmx_options['url']}', {{
|
||||
values: {{event_data: JSON.stringify(event_data)}},
|
||||
target: '{command_htmx_options['target']}',
|
||||
swap: '{command_htmx_options['swap']}'
|
||||
}});
|
||||
}});
|
||||
"""
|
||||
|
||||
return (
|
||||
Div(
|
||||
id=self._id,
|
||||
@@ -92,6 +136,7 @@ class VisNetwork(MultipleInstance):
|
||||
}};
|
||||
const options = {js_options};
|
||||
const network = new vis.Network(container, data, options);
|
||||
{event_handlers_js}
|
||||
}})();
|
||||
""")
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
@@ -97,6 +98,14 @@ class BaseCommand:
|
||||
def url(self):
|
||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
return {
|
||||
"url": self.url,
|
||||
"target": self._htmx_extra.get("hx-target", "this"),
|
||||
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
|
||||
"values": {}
|
||||
}
|
||||
|
||||
def get_ft(self):
|
||||
return self._ft
|
||||
|
||||
@@ -141,8 +150,17 @@ class Command(BaseCommand):
|
||||
return float(value)
|
||||
elif param.annotation == list:
|
||||
return value.split(",")
|
||||
elif param.annotation == dict:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
res = super().ajax_htmx_options()
|
||||
if self.kwargs:
|
||||
res["values"] |= self.kwargs
|
||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||
return res
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
ret_from_bindings = []
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class BaseInstance:
|
||||
return self._prefix
|
||||
|
||||
def get_full_id(self) -> str:
|
||||
return f"{InstancesManager.get_session_id(self._session)}-{self._id}"
|
||||
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
|
||||
|
||||
def get_full_parent_id(self) -> Optional[str]:
|
||||
parent = self.get_parent()
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections.abc import Callable
|
||||
ROOT_COLOR = "#ff9999"
|
||||
GHOST_COLOR = "#cccccc"
|
||||
|
||||
|
||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||
"""
|
||||
Convert a list of nested dictionaries to vis.js nodes and edges format.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
|
||||
|
||||
class RootInstanceForTests(SingleInstance):
|
||||
@@ -25,4 +25,5 @@ def session():
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def root_instance(session):
|
||||
InstancesManager.reset()
|
||||
return RootInstanceForTests(session=session)
|
||||
|
||||
@@ -35,15 +35,6 @@ class TestLayoutBehaviour:
|
||||
assert layout._main_content == content
|
||||
assert result == layout # Should return self for chaining
|
||||
|
||||
def test_i_can_set_footer_content(self, root_instance):
|
||||
"""Test setting footer content."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
content = Div("Footer content")
|
||||
|
||||
layout.set_footer(content)
|
||||
|
||||
assert layout._footer_content == content
|
||||
|
||||
def test_i_can_add_content_to_left_drawer(self, root_instance):
|
||||
"""Test adding content to left drawer."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
|
||||
@@ -6,7 +6,7 @@ from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.test.matcher import matches, TestObject, TestCommand
|
||||
from myfasthtml.test.matcher import matches, TestObject, TestCommand, TestIcon, find_one, find, Contains, DoesNotContain
|
||||
from .conftest import root_instance
|
||||
|
||||
|
||||
@@ -382,17 +382,246 @@ class TestTreeViewRender:
|
||||
"""Tests for TreeView HTML rendering."""
|
||||
|
||||
def test_empty_treeview_is_rendered(self, root_instance):
|
||||
"""Test that TreeView generates correct HTML structure."""
|
||||
"""Test that empty TreeView generates correct HTML structure.
|
||||
|
||||
Why these elements matter:
|
||||
- TestObject Keyboard: Essential for keyboard shortcuts (Escape to cancel rename)
|
||||
- _id: Required for HTMX targeting and component identification
|
||||
- cls "mf-treeview": Root CSS class for TreeView styling
|
||||
"""
|
||||
# Step 1: Create empty TreeView
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
|
||||
_id=tree_view.get_id(),
|
||||
cls="mf-treeview"
|
||||
)
|
||||
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(tree_view.__ft__(), expected)
|
||||
|
||||
def test_node_action_buttons_are_rendered(self):
|
||||
"""Test that action buttons are present in rendered HTML."""
|
||||
# Signature only - implementation later
|
||||
pass
|
||||
@pytest.fixture
|
||||
def tree_view(self, root_instance):
|
||||
return TreeView(root_instance)
|
||||
|
||||
def test_node_with_children_collapsed_is_rendered(self, tree_view):
|
||||
"""Test that a collapsed node with children renders correctly.
|
||||
|
||||
Why these elements matter:
|
||||
- TestIcon chevron_right: Indicates visually that the node is collapsed
|
||||
- Span with label: Displays the node's text content
|
||||
- Action buttons (add_child, edit, delete): Enable user interactions
|
||||
- cls "mf-treenode": Required CSS class for node styling
|
||||
- data_node_id: Essential for identifying the node in DOM operations
|
||||
- No children in container: Verifies children are hidden when collapsed
|
||||
"""
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
# Step 1: Extract the node element to test
|
||||
rendered = tree_view.render()
|
||||
node_container = find_one(rendered, Div(data_node_id=parent.id))
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
TestIcon("chevron_right20_regular"), # Collapsed toggle icon
|
||||
Span("Parent"), # Label
|
||||
Div( # Action buttons
|
||||
TestIcon("add_circle20_regular"),
|
||||
TestIcon("edit20_regular"),
|
||||
TestIcon("delete20_regular"),
|
||||
cls=Contains("mf-treenode-actions")
|
||||
),
|
||||
cls=Contains("mf-treenode"),
|
||||
data_node_id=parent.id
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(node_container, expected)
|
||||
|
||||
# Verify no children are rendered (collapsed)
|
||||
child_containers = find(node_container, Div(data_node_id=child.id))
|
||||
assert len(child_containers) == 0, "Children should not be rendered when node is collapsed"
|
||||
|
||||
def test_node_with_children_expanded_is_rendered(self, tree_view):
|
||||
"""Test that an expanded node with children renders correctly.
|
||||
|
||||
Why these elements matter:
|
||||
- TestIcon chevron_down: Indicates visually that the node is expanded
|
||||
- Children rendered: Verifies that child nodes are visible when parent is expanded
|
||||
- Child has its own node structure: Ensures recursive rendering works correctly
|
||||
"""
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
tree_view._toggle_node(parent.id) # Expand the parent
|
||||
|
||||
# Step 1: Extract the parent node element to test
|
||||
rendered = tree_view.render()
|
||||
parent_node = find_one(rendered, Div(data_node_id=parent.id))
|
||||
|
||||
# Step 2: Define expected structure for toggle icon
|
||||
expected = Div(
|
||||
TestIcon("chevron_down20_regular"), # Expanded toggle icon
|
||||
cls=Contains("mf-treenode")
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(parent_node, expected)
|
||||
|
||||
# Verify children ARE rendered (expanded)
|
||||
child_containers = find(rendered, Div(data_node_id=child.id))
|
||||
assert len(child_containers) == 1, "Child should be rendered when parent is expanded"
|
||||
|
||||
# Verify child has proper node structure
|
||||
child_node = child_containers[0]
|
||||
child_expected = Div(
|
||||
Span("Child"),
|
||||
cls=Contains("mf-treenode"),
|
||||
data_node_id=child.id
|
||||
)
|
||||
assert matches(child_node, child_expected)
|
||||
|
||||
def test_leaf_node_is_rendered(self, tree_view):
|
||||
"""Test that a leaf node (no children) renders without toggle icon.
|
||||
|
||||
Why these elements matter:
|
||||
- No toggle icon (or empty space): Leaf nodes don't need expand/collapse functionality
|
||||
- Span with label: Displays the node's text content
|
||||
- Action buttons present: Even leaf nodes can be edited, deleted, or receive children
|
||||
"""
|
||||
leaf = TreeNode(label="Leaf Node", type="file")
|
||||
tree_view.add_node(leaf)
|
||||
|
||||
# Step 1: Extract the leaf node element to test
|
||||
rendered = tree_view.render()
|
||||
leaf_node = find_one(rendered, Div(data_node_id=leaf.id))
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
Span("Leaf Node"), # Label
|
||||
Div( # Action buttons still present
|
||||
TestIcon("add_circle20_regular"),
|
||||
TestIcon("edit20_regular"),
|
||||
TestIcon("delete20_regular"),
|
||||
cls=Contains("mf-treenode-actions")
|
||||
),
|
||||
cls=Contains("mf-treenode"),
|
||||
data_node_id=leaf.id
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(leaf_node, expected)
|
||||
|
||||
def test_selected_node_has_selected_class(self, tree_view):
|
||||
"""Test that a selected node has the 'selected' CSS class.
|
||||
|
||||
Why these elements matter:
|
||||
- cls Contains "selected": Enables visual highlighting of the selected node
|
||||
- data_node_id: Required for identifying which node is selected
|
||||
"""
|
||||
node = TreeNode(label="Selected Node", type="file")
|
||||
tree_view.add_node(node)
|
||||
tree_view._select_node(node.id)
|
||||
|
||||
# Step 1: Extract the selected node element to test
|
||||
rendered = tree_view.render()
|
||||
selected_node = find_one(rendered, Div(data_node_id=node.id))
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
cls=Contains("mf-treenode", "selected"),
|
||||
data_node_id=node.id
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(selected_node, expected)
|
||||
|
||||
def test_node_in_editing_mode_shows_input(self, tree_view):
|
||||
"""Test that a node in editing mode renders an Input instead of Span.
|
||||
|
||||
Why these elements matter:
|
||||
- Input element: Enables user to modify the node label inline
|
||||
- cls "mf-treenode-input": Required CSS class for input field styling
|
||||
- name "node_label": Essential for form data submission
|
||||
- value with current label: Pre-fills the input with existing text
|
||||
- cls does NOT contain "selected": Avoids double highlighting during editing
|
||||
"""
|
||||
node = TreeNode(label="Edit Me", type="file")
|
||||
tree_view.add_node(node)
|
||||
tree_view._start_rename(node.id)
|
||||
|
||||
# Step 1: Extract the editing node element to test
|
||||
rendered = tree_view.render()
|
||||
editing_node = find_one(rendered, Div(data_node_id=node.id))
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
Input(
|
||||
name="node_label",
|
||||
value="Edit Me",
|
||||
cls=Contains("mf-treenode-input")
|
||||
),
|
||||
cls=Contains("mf-treenode"),
|
||||
data_node_id=node.id
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(editing_node, expected)
|
||||
|
||||
# Verify "selected" class is NOT present
|
||||
no_selected = Div(
|
||||
cls=DoesNotContain("selected")
|
||||
)
|
||||
assert matches(editing_node, no_selected)
|
||||
|
||||
def test_node_indentation_increases_with_level(self, tree_view):
|
||||
"""Test that node indentation increases correctly with hierarchy level.
|
||||
|
||||
Why these elements matter:
|
||||
- style Contains "padding-left: 0px": Root node has no indentation
|
||||
- style Contains "padding-left: 20px": Child is indented by 20px
|
||||
- style Contains "padding-left: 40px": Grandchild is indented by 40px
|
||||
- Progressive padding: Creates the visual hierarchy of the tree structure
|
||||
"""
|
||||
root = TreeNode(label="Root", type="folder")
|
||||
child = TreeNode(label="Child", type="folder")
|
||||
grandchild = TreeNode(label="Grandchild", type="file")
|
||||
|
||||
tree_view.add_node(root)
|
||||
tree_view.add_node(child, parent_id=root.id)
|
||||
tree_view.add_node(grandchild, parent_id=child.id)
|
||||
|
||||
# Expand all to make hierarchy visible
|
||||
tree_view._toggle_node(root.id)
|
||||
tree_view._toggle_node(child.id)
|
||||
|
||||
rendered = tree_view.render()
|
||||
|
||||
# Step 1 & 2 & 3: Test root node (level 0)
|
||||
root_node = find_one(rendered, Div(data_node_id=root.id))
|
||||
root_expected = Div(
|
||||
style=Contains("padding-left: 0px")
|
||||
)
|
||||
assert matches(root_node, root_expected)
|
||||
|
||||
# Step 1 & 2 & 3: Test child node (level 1)
|
||||
child_node = find_one(rendered, Div(data_node_id=child.id))
|
||||
child_expected = Div(
|
||||
style=Contains("padding-left: 20px")
|
||||
)
|
||||
assert matches(child_node, child_expected)
|
||||
|
||||
# Step 1 & 2 & 3: Test grandchild node (level 2)
|
||||
grandchild_node = find_one(rendered, Div(data_node_id=grandchild.id))
|
||||
grandchild_expected = Div(
|
||||
style=Contains("padding-left: 40px")
|
||||
)
|
||||
assert matches(grandchild_node, grandchild_expected)
|
||||
|
||||
Reference in New Issue
Block a user