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
|
## High Level Hierarchical Structure
|
||||||
```
|
```
|
||||||
MyFastHtml
|
Div(id="layout")
|
||||||
├── src
|
├── Header
|
||||||
│ ├── myfasthtml/ # Main library code
|
│ ├── Div(id="layout_hl")
|
||||||
│ │ ├── core/commands.py # Command definitions
|
│ │ ├── Icon # Left drawer icon button
|
||||||
│ │ ├── controls/button.py # Control helpers
|
│ │ └── Div # Left content for the header
|
||||||
│ │ └── pages/LoginPage.py # Predefined Login page
|
│ └── Div(id="layout_hr")
|
||||||
│ └── ...
|
│ ├── Div # Right content for the header
|
||||||
├── tests # Unit and integration tests
|
│ └── UserProfile # user profile icon button
|
||||||
├── LICENSE # License file (MIT)
|
├── Div # Left Drawer
|
||||||
├── README.md # Project documentation
|
├── Main # Main content
|
||||||
└── pyproject.toml # Build configuration
|
├── 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):
|
class CommandsDebugger(SingleInstance):
|
||||||
|
"""
|
||||||
|
Represents a debugger designed for visualizing and managing commands in a parent-child
|
||||||
|
hierarchical structure.
|
||||||
|
"""
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ class DropdownState:
|
|||||||
|
|
||||||
|
|
||||||
class Dropdown(MultipleInstance):
|
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):
|
def __init__(self, parent, content=None, button=None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.button = Div(button) if not isinstance(button, FT) else button
|
self.button = Div(button) if not isinstance(button, FT) else button
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class FileUpload(MultipleInstance):
|
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):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from myfasthtml.controls.Panel import Panel
|
from myfasthtml.controls.Panel import Panel
|
||||||
|
from myfasthtml.controls.Properties import Properties
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||||
from myfasthtml.core.network_utils import from_parent_child_list
|
from myfasthtml.core.network_utils import from_parent_child_list
|
||||||
|
|
||||||
@@ -8,12 +10,23 @@ class InstancesDebugger(SingleInstance):
|
|||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self._panel = Panel(self, _id="-panel")
|
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):
|
def render(self):
|
||||||
nodes, edges = self._get_nodes_and_edges()
|
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)
|
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):
|
def _get_nodes_and_edges(self):
|
||||||
instances = self._get_instances()
|
instances = self._get_instances()
|
||||||
nodes, edges = from_parent_child_list(
|
nodes, edges = from_parent_child_list(
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
|
|||||||
|
|
||||||
|
|
||||||
class Keyboard(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):
|
def __init__(self, parent, combinations=None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.combinations = combinations or {}
|
self.combinations = combinations or {}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
|
|||||||
|
|
||||||
|
|
||||||
class Mouse(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):
|
def __init__(self, parent, _id=None, combinations=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.combinations = combinations or {}
|
self.combinations = combinations or {}
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class Panel(MultipleInstance):
|
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):
|
def __init__(self, parent, conf=None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.conf = conf or PanelConf()
|
self.conf = conf or PanelConf()
|
||||||
@@ -58,35 +67,35 @@ class Panel(MultipleInstance):
|
|||||||
|
|
||||||
def set_right(self, right):
|
def set_right(self, right):
|
||||||
self._right = right
|
self._right = right
|
||||||
return self
|
return Div(self._right, id=f"{self._id}_r")
|
||||||
|
|
||||||
def set_left(self, left):
|
def set_left(self, left):
|
||||||
self._left = left
|
self._left = left
|
||||||
return self
|
return Div(self._left, id=f"{self._id}_l")
|
||||||
|
|
||||||
def _mk_right(self):
|
def _mk_right(self):
|
||||||
if not self.conf.right:
|
if not self.conf.right:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
resizer = Div(
|
resizer = Div(
|
||||||
cls="mf-resizer mf-resizer-right",
|
cls="mf-resizer mf-resizer-right",
|
||||||
data_command_id=self.commands.update_side_width("right").id,
|
data_command_id=self.commands.update_side_width("right").id,
|
||||||
data_side="right"
|
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):
|
def _mk_left(self):
|
||||||
if not self.conf.left:
|
if not self.conf.left:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
resizer = Div(
|
resizer = Div(
|
||||||
cls="mf-resizer mf-resizer-left",
|
cls="mf-resizer mf-resizer-left",
|
||||||
data_command_id=self.commands.update_side_width("left").id,
|
data_command_id=self.commands.update_side_width("left").id,
|
||||||
data_side="left"
|
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):
|
def render(self):
|
||||||
return Div(
|
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):
|
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,
|
def __init__(self,
|
||||||
parent: BaseInstance,
|
parent: BaseInstance,
|
||||||
_id=None,
|
_id=None,
|
||||||
|
|||||||
@@ -25,19 +25,33 @@ class VisNetworkState(DbObject):
|
|||||||
},
|
},
|
||||||
"physics": {"enabled": True}
|
"physics": {"enabled": True}
|
||||||
}
|
}
|
||||||
|
self.events_handlers: dict = {} # {event_name: command_url}
|
||||||
|
|
||||||
|
|
||||||
class VisNetwork(MultipleInstance):
|
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)
|
super().__init__(parent, _id=_id)
|
||||||
logger.debug(f"VisNetwork created with id: {self._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._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):
|
def _update_state(self, nodes, edges, options, events_handlers=None):
|
||||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
|
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}")
|
||||||
if not nodes and not edges and not options:
|
if not nodes and not edges and not options and not events_handlers:
|
||||||
return
|
return
|
||||||
|
|
||||||
state = self._state.copy()
|
state = self._state.copy()
|
||||||
@@ -47,6 +61,8 @@ class VisNetwork(MultipleInstance):
|
|||||||
state.edges = edges
|
state.edges = edges
|
||||||
if options is not None:
|
if options is not None:
|
||||||
state.options = options
|
state.options = options
|
||||||
|
if events_handlers is not None:
|
||||||
|
state.events_handlers = events_handlers
|
||||||
|
|
||||||
self._state.update(state)
|
self._state.update(state)
|
||||||
|
|
||||||
@@ -70,6 +86,34 @@ class VisNetwork(MultipleInstance):
|
|||||||
# Convert Python options to JS
|
# Convert Python options to JS
|
||||||
js_options = json.dumps(self._state.options, indent=2)
|
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 (
|
return (
|
||||||
Div(
|
Div(
|
||||||
id=self._id,
|
id=self._id,
|
||||||
@@ -92,6 +136,7 @@ class VisNetwork(MultipleInstance):
|
|||||||
}};
|
}};
|
||||||
const options = {js_options};
|
const options = {js_options};
|
||||||
const network = new vis.Network(container, data, options);
|
const network = new vis.Network(container, data, options);
|
||||||
|
{event_handlers_js}
|
||||||
}})();
|
}})();
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import inspect
|
import inspect
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -97,6 +98,14 @@ class BaseCommand:
|
|||||||
def url(self):
|
def url(self):
|
||||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
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):
|
def get_ft(self):
|
||||||
return self._ft
|
return self._ft
|
||||||
|
|
||||||
@@ -141,8 +150,17 @@ class Command(BaseCommand):
|
|||||||
return float(value)
|
return float(value)
|
||||||
elif param.annotation == list:
|
elif param.annotation == list:
|
||||||
return value.split(",")
|
return value.split(",")
|
||||||
|
elif param.annotation == dict:
|
||||||
|
return json.loads(value)
|
||||||
return 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):
|
def execute(self, client_response: dict = None):
|
||||||
ret_from_bindings = []
|
ret_from_bindings = []
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class BaseInstance:
|
|||||||
return self._prefix
|
return self._prefix
|
||||||
|
|
||||||
def get_full_id(self) -> str:
|
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]:
|
def get_full_parent_id(self) -> Optional[str]:
|
||||||
parent = self.get_parent()
|
parent = self.get_parent()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from collections.abc import Callable
|
|||||||
ROOT_COLOR = "#ff9999"
|
ROOT_COLOR = "#ff9999"
|
||||||
GHOST_COLOR = "#cccccc"
|
GHOST_COLOR = "#cccccc"
|
||||||
|
|
||||||
|
|
||||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||||
"""
|
"""
|
||||||
Convert a list of nested dictionaries to vis.js nodes and edges format.
|
Convert a list of nested dictionaries to vis.js nodes and edges format.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||||
|
|
||||||
|
|
||||||
class RootInstanceForTests(SingleInstance):
|
class RootInstanceForTests(SingleInstance):
|
||||||
@@ -25,4 +25,5 @@ def session():
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def root_instance(session):
|
def root_instance(session):
|
||||||
|
InstancesManager.reset()
|
||||||
return RootInstanceForTests(session=session)
|
return RootInstanceForTests(session=session)
|
||||||
|
|||||||
@@ -35,15 +35,6 @@ class TestLayoutBehaviour:
|
|||||||
assert layout._main_content == content
|
assert layout._main_content == content
|
||||||
assert result == layout # Should return self for chaining
|
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):
|
def test_i_can_add_content_to_left_drawer(self, root_instance):
|
||||||
"""Test adding content to left drawer."""
|
"""Test adding content to left drawer."""
|
||||||
layout = Layout(root_instance, app_name="Test App")
|
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.Keyboard import Keyboard
|
||||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
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
|
from .conftest import root_instance
|
||||||
|
|
||||||
|
|
||||||
@@ -382,17 +382,246 @@ class TestTreeViewRender:
|
|||||||
"""Tests for TreeView HTML rendering."""
|
"""Tests for TreeView HTML rendering."""
|
||||||
|
|
||||||
def test_empty_treeview_is_rendered(self, root_instance):
|
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)
|
tree_view = TreeView(root_instance)
|
||||||
|
|
||||||
|
# Step 2: Define expected structure
|
||||||
expected = Div(
|
expected = Div(
|
||||||
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
|
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
|
||||||
_id=tree_view.get_id(),
|
_id=tree_view.get_id(),
|
||||||
cls="mf-treeview"
|
cls="mf-treeview"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Step 3: Compare
|
||||||
assert matches(tree_view.__ft__(), expected)
|
assert matches(tree_view.__ft__(), expected)
|
||||||
|
|
||||||
def test_node_action_buttons_are_rendered(self):
|
@pytest.fixture
|
||||||
"""Test that action buttons are present in rendered HTML."""
|
def tree_view(self, root_instance):
|
||||||
# Signature only - implementation later
|
return TreeView(root_instance)
|
||||||
pass
|
|
||||||
|
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