3 Commits

17 changed files with 442 additions and 44 deletions

View File

@@ -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
```

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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,11 +67,11 @@ 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:
@@ -74,7 +83,7 @@ class Panel(MultipleInstance):
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:
@@ -86,7 +95,7 @@ class Panel(MultipleInstance):
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(

View 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()

View File

@@ -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,

View File

@@ -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}")
self._state = VisNetworkState(self)
self._update_state(nodes, edges, options)
# possible events (expected in snake_case
# - select_node → selectNode
# - select → select
# - click → click
# - double_click → doubleClick
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:
self._state = VisNetworkState(self)
# 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, 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}
}})();
""")
)

View File

@@ -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 = []

View File

@@ -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()

View File

@@ -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.

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)