diff --git a/src/app.py b/src/app.py
index dc02e4c..503e8e8 100644
--- a/src/app.py
+++ b/src/app.py
@@ -4,6 +4,7 @@ import yaml
from fasthtml import serve
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
+from myfasthtml.controls.DataGridsManager import DataGridsManager
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
@@ -44,38 +45,38 @@ def create_sample_treeview(parent):
TreeView: Configured TreeView instance with sample data
"""
tree_view = TreeView(parent, _id="-treeview")
-
+
# Create sample file structure
projects = TreeNode(label="Projects", type="folder")
tree_view.add_node(projects)
-
+
myfasthtml = TreeNode(label="MyFastHtml", type="folder")
tree_view.add_node(myfasthtml, parent_id=projects.id)
-
+
app_py = TreeNode(label="app.py", type="file")
tree_view.add_node(app_py, parent_id=myfasthtml.id)
-
+
readme = TreeNode(label="README.md", type="file")
tree_view.add_node(readme, parent_id=myfasthtml.id)
-
+
src_folder = TreeNode(label="src", type="folder")
tree_view.add_node(src_folder, parent_id=myfasthtml.id)
-
+
controls_py = TreeNode(label="controls.py", type="file")
tree_view.add_node(controls_py, parent_id=src_folder.id)
-
+
documents = TreeNode(label="Documents", type="folder")
tree_view.add_node(documents, parent_id=projects.id)
-
+
notes = TreeNode(label="notes.txt", type="file")
tree_view.add_node(notes, parent_id=documents.id)
-
+
todo = TreeNode(label="todo.md", type="file")
tree_view.add_node(todo, parent_id=documents.id)
-
+
# Expand all nodes to show the full structure
- #tree_view.expand_all()
-
+ # tree_view.expand_all()
+
return tree_view
@@ -110,10 +111,10 @@ def index(session):
btn_popup = mk.label("Popup",
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
-
+
# Create TreeView with sample data
tree_view = create_sample_treeview(layout)
-
+
layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
@@ -121,6 +122,7 @@ def index(session):
layout.left_drawer.add(btn_file_upload, "Test")
layout.left_drawer.add(btn_popup, "Test")
layout.left_drawer.add(tree_view, "TreeView")
+ layout.left_drawer.add(DataGridsManager(layout, _id="-datagrids"), "Documents")
layout.set_main(tabs_manager)
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css
index 26b5131..096a4bb 100644
--- a/src/myfasthtml/assets/myfasthtml.css
+++ b/src/myfasthtml/assets/myfasthtml.css
@@ -13,6 +13,7 @@
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
--properties-font-size: var(--text-xs);
+ --mf-tooltip-zindex: 10;
}
@@ -58,6 +59,26 @@
* Compatible with DaisyUI 5
*/
+.mf-tooltip-container {
+ background: var(--color-base-200);
+ padding: 5px 10px;
+ border-radius: 4px;
+ pointer-events: none; /* Prevent interfering with mouse events */
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0; /* Default to invisible */
+ visibility: hidden; /* Prevent interaction when invisible */
+ transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
+ position: fixed; /* Keep it above other content and adjust position */
+ z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */
+}
+
+.mf-tooltip-container[data-visible="true"] {
+ opacity: 1;
+ visibility: visible; /* Show tooltip */
+ transition: opacity 0.3s ease; /* No delay when becoming visible */
+}
+
/* Main layout container using CSS Grid */
.mf-layout {
display: grid;
@@ -634,7 +655,6 @@
/* *************** Panel Component *************** */
/* *********************************************** */
-/* Container principal du panel */
.mf-panel {
display: flex;
width: 100%;
@@ -643,7 +663,6 @@
position: relative;
}
-/* Panel gauche */
.mf-panel-left {
position: relative;
flex-shrink: 0;
@@ -655,15 +674,13 @@
border-right: 1px solid var(--color-border-primary);
}
-/* Panel principal (centre) */
.mf-panel-main {
flex: 1;
height: 100%;
overflow: auto;
- min-width: 0; /* Important pour permettre le shrink du flexbox */
+ min-width: 0; /* Important to allow the shrinking of flexbox */
}
-/* Panel droit */
.mf-panel-right {
position: relative;
flex-shrink: 0;
diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js
index 41715a9..6391b01 100644
--- a/src/myfasthtml/assets/myfasthtml.js
+++ b/src/myfasthtml/assets/myfasthtml.js
@@ -159,6 +159,113 @@ function initResizer(containerId, options = {}) {
});
}
+function bindTooltipsWithDelegation(elementId) {
+ // To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip
+ // Then
+ // the 'truncate' to show only when the text is truncated
+ // the class 'mmt-tooltip' for force the display
+
+ console.info("bindTooltips on element " + elementId);
+
+ const element = document.getElementById(elementId);
+ const tooltipContainer = document.getElementById(`tt_${elementId}`);
+
+
+ if (!element) {
+ console.error(`Invalid element '${elementId}' container`);
+ return;
+ }
+
+ if (!tooltipContainer) {
+ console.error(`Invalid tooltip 'tt_${elementId}' container.`);
+ return;
+ }
+
+ // Add a single mouseenter and mouseleave listener to the parent element
+ element.addEventListener("mouseenter", (event) => {
+ //console.debug("Entering element", event.target)
+
+ const cell = event.target.closest("[data-tooltip]");
+ if (!cell) {
+ // console.debug(" No 'data-tooltip' attribute found. Stopping.");
+ return;
+ }
+
+ const no_tooltip = element.hasAttribute("mf-no-tooltip");
+ if (no_tooltip) {
+ // console.debug(" Attribute 'mmt-no-tooltip' found. Cancelling.");
+ return;
+ }
+
+ const content = cell.querySelector(".truncate") || cell;
+ const isOverflowing = content.scrollWidth > content.clientWidth;
+ const forceShow = cell.classList.contains("mf-tooltip");
+
+ if (isOverflowing || forceShow) {
+ const tooltipText = cell.getAttribute("data-tooltip");
+ if (tooltipText) {
+ const rect = cell.getBoundingClientRect();
+ const tooltipRect = tooltipContainer.getBoundingClientRect();
+
+ let top = rect.top - 30; // Above the cell
+ let left = rect.left;
+
+ // Adjust tooltip position to prevent it from going off-screen
+ if (top < 0) top = rect.bottom + 5; // Move below if no space above
+ if (left + tooltipRect.width > window.innerWidth) {
+ left = window.innerWidth - tooltipRect.width - 5; // Prevent overflow right
+ }
+
+ // Apply styles for tooltip positioning
+ requestAnimationFrame(() => {
+ tooltipContainer.textContent = tooltipText;
+ tooltipContainer.setAttribute("data-visible", "true");
+ tooltipContainer.style.top = `${top}px`;
+ tooltipContainer.style.left = `${left}px`;
+ });
+ }
+ }
+ }, true); // Use capture phase for better delegation if needed
+
+ element.addEventListener("mouseleave", (event) => {
+ const cell = event.target.closest("[data-tooltip]");
+ if (cell) {
+ tooltipContainer.setAttribute("data-visible", "false");
+ }
+ }, true); // Use capture phase for better delegation if needed
+}
+
+function initLayout(elementId) {
+ initResizer(elementId);
+ bindTooltipsWithDelegation(elementId);
+}
+
+function disableTooltip() {
+ const elementId = tooltipElementId
+ // console.debug("disableTooltip on element " + elementId);
+
+ const element = document.getElementById(elementId);
+ if (!element) {
+ console.error(`Invalid element '${elementId}' container`);
+ return;
+ }
+
+ element.setAttribute("mmt-no-tooltip", "");
+}
+
+function enableTooltip() {
+ const elementId = tooltipElementId
+ // console.debug("enableTooltip on element " + elementId);
+
+ const element = document.getElementById(elementId);
+ if (!element) {
+ console.error(`Invalid element '${elementId}' container`);
+ return;
+ }
+
+ element.removeAttribute("mmt-no-tooltip");
+}
+
function initBoundaries(elementId, updateUrl) {
function updateBoundaries() {
const container = document.getElementById(elementId);
@@ -363,7 +470,6 @@ function updateTabs(controllerId) {
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
- console.log("Parsing combination", combinationStr, "=>", sequence);
let currentNode = root;
for (const keySet of sequence) {
@@ -1354,4 +1460,5 @@ function updateTabs(controllerId) {
detachGlobalListener();
}
};
-})();
\ No newline at end of file
+})();
+
diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py
new file mode 100644
index 0000000..909e7ea
--- /dev/null
+++ b/src/myfasthtml/controls/DataGrid.py
@@ -0,0 +1,59 @@
+from typing import Optional
+
+from fasthtml.components import Div
+
+from myfasthtml.controls.BaseCommands import BaseCommands
+from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, DataGridFooterConf, \
+ DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
+from myfasthtml.core.dbmanager import DbObject
+from myfasthtml.core.instances import MultipleInstance
+
+
+class DatagridState(DbObject):
+ def __init__(self, owner):
+ super().__init__(owner)
+ with self.initializing():
+ self.sidebar_visible: bool = False
+ self.selected_view: str = None
+ self.row_index: bool = False
+ self.columns: list[DataGridColumnState] = []
+ self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
+ self.headers: list[DataGridHeaderFooterConf] = []
+ self.footers: list[DataGridHeaderFooterConf] = []
+ self.sorted: list = []
+ self.filtered: dict = {}
+ self.edition: DatagridEditionState = DatagridEditionState()
+ self.selection: DatagridSelectionState = DatagridSelectionState()
+
+
+class DatagridSettings(DbObject):
+ def __init__(self, owner):
+ super().__init__(owner)
+ with self.initializing():
+ self.file_name: Optional[str] = None
+ self.selected_sheet_name: Optional[str] = None
+ self.header_visible: bool = True
+ self.filter_all_visible: bool = True
+ self.views_visible: bool = True
+ self.open_file_visible: bool = True
+ self.open_settings_visible: bool = True
+
+
+class Commands(BaseCommands):
+ pass
+
+
+class DataGrid(MultipleInstance):
+ def __init__(self, parent, settings=None, _id=None):
+ super().__init__(parent, _id=_id)
+ self._settings = DatagridSettings(self).update(settings)
+ self._state = DatagridState(self)
+ self.commands = Commands(self)
+
+ def render(self):
+ return Div(
+ self._id
+ )
+
+ def __ft__(self):
+ return self.render()
diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py
new file mode 100644
index 0000000..6a75171
--- /dev/null
+++ b/src/myfasthtml/controls/DataGridsManager.py
@@ -0,0 +1,58 @@
+import pandas as pd
+from fasthtml.components import Div
+
+from myfasthtml.controls.BaseCommands import BaseCommands
+from myfasthtml.controls.TabsManager import TabsManager
+from myfasthtml.controls.TreeView import TreeView
+from myfasthtml.controls.helpers import mk
+from myfasthtml.core.commands import Command
+from myfasthtml.core.instances import MultipleInstance, InstancesManager
+from myfasthtml.icons.fluent_p1 import table_add20_regular
+from myfasthtml.icons.fluent_p3 import folder_open20_regular
+
+
+class Commands(BaseCommands):
+ def upload_from_source(self):
+ return Command("UploadFromSource", "Upload from source", self._owner.upload_from_source)
+
+ def new_grid(self):
+ return Command("NewGrid", "New grid", self._owner.new_grid)
+
+ def open_from_excel(self, tab_id, get_content_callback):
+ excel_content = get_content_callback()
+ return Command("OpenFromExcel", "Open from Excel", self._owner.open_from_excel, tab_id, excel_content)
+
+
+class DataGridsManager(MultipleInstance):
+ def __init__(self, parent, _id=None):
+ super().__init__(parent, _id=_id)
+ self.tree = TreeView(self, _id="-treeview")
+ self.commands = Commands(self)
+ self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
+
+ def upload_from_source(self):
+ from myfasthtml.controls.FileUpload import FileUpload
+ file_upload = FileUpload(self, _id="-file-upload", auto_register=False)
+ self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
+ tab_id = self._tabs_manager.add_tab("Upload Datagrid", file_upload)
+ file_upload.on_ok = self.commands.open_from_excel(tab_id, file_upload.get_content)
+ return self._tabs_manager.show_tab(tab_id)
+
+ def open_from_excel(self, tab_id, excel_content):
+ df = pd.read_excel(excel_content)
+ content = df.to_html(index=False)
+ self._tabs_manager.switch(tab_id, content)
+
+ def render(self):
+ return Div(
+ Div(
+ mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
+ mk.icon(table_add20_regular, tooltip="New grid"),
+ cls="flex"
+ ),
+ self.tree,
+ id=self._id,
+ )
+
+ def __ft__(self):
+ return self.render()
diff --git a/src/myfasthtml/controls/FileUpload.py b/src/myfasthtml/controls/FileUpload.py
index 7fbf3f0..35c012c 100644
--- a/src/myfasthtml/controls/FileUpload.py
+++ b/src/myfasthtml/controls/FileUpload.py
@@ -6,7 +6,7 @@ from fastapi import UploadFile
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
-from myfasthtml.controls.helpers import Ids, mk
+from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
@@ -24,6 +24,7 @@ class FileUploadState(DbObject):
self.ns_file_name: str | None = None
self.ns_sheets_names: list | None = None
self.ns_selected_sheet_name: str | None = None
+ self.ns_file_content: bytes | None = None
class Commands(BaseCommands):
@@ -44,16 +45,16 @@ class FileUpload(MultipleInstance):
to ensure smooth operation within a parent application.
"""
- def __init__(self, parent, _id=None):
- super().__init__(parent, _id=_id)
+ def __init__(self, parent, _id=None, **kwargs):
+ super().__init__(parent, _id=_id, **kwargs)
self.commands = Commands(self)
self._state = FileUploadState(self)
def upload_file(self, file: UploadFile):
logger.debug(f"upload_file: {file=}")
if file:
- file_content = file.file.read()
- self._state.ns_sheets_names = self.get_sheets_names(file_content)
+ self._state.ns_file_content = file.file.read()
+ self._state.ns_sheets_names = self.get_sheets_names(self._state.ns_file_content)
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
return self.mk_sheet_selector()
@@ -72,6 +73,10 @@ class FileUpload(MultipleInstance):
cls="select select-bordered select-sm w-full ml-2"
)
+ def get_content(self):
+ return self._state.ns_file_content
+
+
@staticmethod
def get_sheets_names(file_content):
try:
diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py
index 4526ea8..55d2cb1 100644
--- a/src/myfasthtml/controls/InstancesDebugger.py
+++ b/src/myfasthtml/controls/InstancesDebugger.py
@@ -21,13 +21,13 @@ class InstancesDebugger(SingleInstance):
def on_network_event(self, event_data: dict):
session, instance_id = event_data["nodes"][0].split("#")
- properties = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
- "State": {"*": "_state"},
- "Commands": {"*": "commands"},
- }
+ properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
+ "State": {"_name": "_state._name", "*": "_state"},
+ "Commands": {"*": "commands"},
+ }
return self._panel.set_right(Properties(self,
InstancesManager.get(session, instance_id),
- properties,
+ properties_def,
_id="-properties"))
def _get_nodes_and_edges(self):
diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py
index fe7f2ed..180fd57 100644
--- a/src/myfasthtml/controls/Layout.py
+++ b/src/myfasthtml/controls/Layout.py
@@ -17,15 +17,17 @@ from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.utils import get_id
-from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
-from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
+from myfasthtml.icons.fluent import panel_left_contract20_regular as left_drawer_contract
+from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_expand
+from myfasthtml.icons.fluent_p1 import panel_right_contract20_regular as right_drawer_contract
+from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_expand
logger = logging.getLogger("LayoutControl")
class LayoutState(DbObject):
- def __init__(self, owner):
- super().__init__(owner)
+ def __init__(self, owner, name=None):
+ super().__init__(owner, name=name)
with self.initializing():
self.left_drawer_open: bool = True
self.right_drawer_open: bool = True
@@ -115,7 +117,7 @@ class Layout(SingleInstance):
# Content storage
self._main_content = None
- self._state = LayoutState(self)
+ self._state = LayoutState(self, "default_layout")
self._boundaries = Boundaries(self)
self.commands = Commands(self)
self.left_drawer = self.Content(self)
@@ -278,7 +280,14 @@ class Layout(SingleInstance):
# Wrap content in scrollable container
content_wrapper = Div(
- *self.right_drawer.get_content(),
+ *[
+ (
+ Div(cls="divider") if index > 0 else None,
+ group_ft,
+ *[item for item in self.right_drawer.get_content()[group_name]]
+ )
+ for index, (group_name, group_ft) in enumerate(self.right_drawer.get_groups())
+ ],
cls="mf-layout-drawer-content"
)
@@ -291,12 +300,12 @@ class Layout(SingleInstance):
)
def _mk_left_drawer_icon(self):
- return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
+ return mk.icon(left_drawer_contract if self._state.left_drawer_open else left_drawer_expand,
id=f"{self._id}_ldi",
command=self.commands.toggle_drawer("left"))
def _mk_right_drawer_icon(self):
- return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
+ return mk.icon(right_drawer_contract if self._state.right_drawer_open else right_drawer_expand,
id=f"{self._id}_rdi",
command=self.commands.toggle_drawer("right"))
@@ -324,12 +333,13 @@ class Layout(SingleInstance):
# Wrap everything in a container div
return Div(
+ Div(id=f"tt_{self._id}", cls="mf-tooltip-container"), # container for the tooltips
self._mk_header(),
self._mk_left_drawer(),
self._mk_main(),
self._mk_right_drawer(),
self._mk_footer(),
- Script(f"initResizer('{self._id}');"),
+ Script(f"initLayout('{self._id}');"),
id=self._id,
cls="mf-layout",
)
diff --git a/src/myfasthtml/controls/TabsManager.py b/src/myfasthtml/controls/TabsManager.py
index 005be8c..707094f 100644
--- a/src/myfasthtml/controls/TabsManager.py
+++ b/src/myfasthtml/controls/TabsManager.py
@@ -102,7 +102,11 @@ class TabsManager(MultipleInstance):
tab_config = self._state.tabs[tab_id]
if tab_config["component_type"] is None:
return None
- return InstancesManager.get(self._session, tab_config["component_id"])
+ try:
+ return InstancesManager.get(self._session, tab_config["component_id"])
+ except Exception as e:
+ logger.error(f"Error while retrieving tab content: {e}")
+ return Div("Tab not found.")
@staticmethod
def _get_tab_count():
@@ -203,6 +207,11 @@ class TabsManager(MultipleInstance):
logger.debug(f" Content already exists. Just switch.")
return self._mk_tabs_controller()
+ def switch_tab(self, tab_id, label, component, activate=True):
+ logger.debug(f"switch_tab {label=}, component={component}, activate={activate}")
+ self._add_or_update_tab(tab_id, label, component, activate)
+ return self.show_tab(tab_id) #
+
def close_tab(self, tab_id: str):
"""
Close a tab and remove it from the tabs manager.
@@ -382,6 +391,34 @@ class TabsManager(MultipleInstance):
def _get_tab_list(self):
return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs]
+ def _add_or_update_tab(self, tab_id, label, component, activate):
+ state = self._state.copy()
+
+ # Extract component ID if the component has a get_id() method
+ component_type, component_id = None, None
+ if isinstance(component, BaseInstance):
+ component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
+ component_id = component.get_id()
+
+ # Add tab metadata to state
+ state.tabs[tab_id] = {
+ 'id': tab_id,
+ 'label': label,
+ 'component_type': component_type,
+ 'component_id': component_id
+ }
+
+ # Add the content
+ state._tabs_content[tab_id] = component
+
+ # Activate tab if requested
+ if activate:
+ state.active_tab = tab_id
+
+ # finally, update the state
+ self._state.update(state)
+ self._search.set_items(self._get_tab_list())
+
def update_boundaries(self):
return Script(f"updateBoundaries('{self._id}');")
diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py
index cd0442b..7b846b9 100644
--- a/src/myfasthtml/controls/TreeView.py
+++ b/src/myfasthtml/controls/TreeView.py
@@ -334,12 +334,11 @@ class TreeView(MultipleInstance):
# Toggle icon
toggle = mk.icon(
- chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
+ chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else None,
command=self.commands.toggle_node(node_id))
# Label or input for editing
if is_editing:
- # TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
label_element = mk.mk(Input(
name="node_label",
value=node.label,
@@ -357,7 +356,6 @@ class TreeView(MultipleInstance):
label_element,
self._render_action_buttons(node_id),
cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
- data_node_id=node_id,
style=f"padding-left: {level * 20}px"
)
@@ -372,7 +370,8 @@ class TreeView(MultipleInstance):
return Div(
node_element,
*children_elements,
- cls="mf-treenode-container"
+ cls="mf-treenode-container",
+ data_node_id=node_id,
)
def render(self):
@@ -390,7 +389,7 @@ class TreeView(MultipleInstance):
return Div(
*[self._render_node(node_id) for node_id in root_nodes],
- Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
+ Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard"),
id=self._id,
cls="mf-treeview"
)
diff --git a/src/myfasthtml/controls/datagrid_objects.py b/src/myfasthtml/controls/datagrid_objects.py
new file mode 100644
index 0000000..277bfa8
--- /dev/null
+++ b/src/myfasthtml/controls/datagrid_objects.py
@@ -0,0 +1,49 @@
+from dataclasses import dataclass, field
+
+from myfasthtml.core.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType
+
+
+@dataclass
+class DataGridRowState:
+ row_id: int
+ visible: bool = True
+ height: int | None = None
+
+@dataclass
+class DataGridColumnState:
+ col_id: str # name of the column: cannot be changed
+ col_index: int # index of the column in the dataframe: cannot be changed
+ title: str = None
+ type: ColumnType = ColumnType.Text
+ visible: bool = True
+ usable: bool = True
+ width: int = DEFAULT_COLUMN_WIDTH
+
+
+@dataclass
+class DatagridEditionState:
+ under_edition: tuple[int, int] | None = None
+ previous_under_edition: tuple[int, int] | None = None
+
+
+@dataclass
+class DatagridSelectionState:
+ selected: tuple[int, int] | None = None
+ last_selected: tuple[int, int] | None = None
+ selection_mode: str = None # valid values are "row", "column" or None for "cell"
+ extra_selected: list[tuple[str, str | int]] = field(default_factory=list) # list(tuple(selection_mode, element_id))
+ last_extra_selected: tuple[int, int] = None
+
+
+@dataclass
+class DataGridHeaderFooterConf:
+ conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id
+
+
+
+
+@dataclass
+class DatagridView:
+ name: str
+ type: ViewType = ViewType.Table
+ columns: list[DataGridColumnState] = None
diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py
index 4c19abb..6e4aaa4 100644
--- a/src/myfasthtml/controls/helpers.py
+++ b/src/myfasthtml/controls/helpers.py
@@ -50,6 +50,7 @@ class mk:
size=20,
can_select=True,
can_hover=False,
+ tooltip=None,
cls='',
command: Command = None,
binding: Binding = None,
@@ -65,6 +66,7 @@ class mk:
:param size: The size of the icon, specified in pixels. Defaults to 20.
:param can_select: Indicates whether the icon can be selected. Defaults to True.
:param can_hover: Indicates whether the icon reacts to hovering. Defaults to False.
+ :param tooltip:
:param cls: A string of custom CSS classes to be added to the icon container.
:param command: The command object defining the function to be executed on icon interaction.
:param binding: The binding object for configuring additional event listeners on the icon.
@@ -79,6 +81,10 @@ class mk:
cls,
kwargs)
+ if tooltip:
+ merged_cls = merge_classes(merged_cls, "mf-tooltip")
+ kwargs["data-tooltip"] = tooltip
+
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod
diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py
index 4871fea..7752442 100644
--- a/src/myfasthtml/core/commands.py
+++ b/src/myfasthtml/core/commands.py
@@ -135,7 +135,7 @@ class Command(BaseCommand):
def __init__(self, name, description, callback, *args, **kwargs):
super().__init__(name, description)
self.callback = callback
- self.callback_parameters = dict(inspect.signature(callback).parameters)
+ self.callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
self.args = args
self.kwargs = kwargs
diff --git a/src/myfasthtml/core/constants.py b/src/myfasthtml/core/constants.py
index 3d3bfdf..c6c2dec 100644
--- a/src/myfasthtml/core/constants.py
+++ b/src/myfasthtml/core/constants.py
@@ -1,5 +1,39 @@
+from enum import Enum
+
+DEFAULT_COLUMN_WIDTH = 100
+
ROUTE_ROOT = "/myfasthtml"
+
class Routes:
Commands = "/commands"
- Bindings = "/bindings"
\ No newline at end of file
+ Bindings = "/bindings"
+
+
+class ColumnType(Enum):
+ RowIndex = "RowIndex"
+ Text = "Text"
+ Number = "Number"
+ Datetime = "DateTime"
+ Bool = "Boolean"
+ Choice = "Choice"
+ List = "List"
+
+
+class ViewType(Enum):
+ Table = "Table"
+ Chart = "Chart"
+ Form = "Form"
+
+
+class FooterAggregation(Enum):
+ Sum = "Sum"
+ Mean = "Mean"
+ Min = "Min"
+ Max = "Max"
+ Count = "Count"
+ FilteredSum = "FilteredSum"
+ FilteredMean = "FilteredMean"
+ FilteredMin = "FilteredMin"
+ FilteredMax = "FilteredMax"
+ FilteredCount = "FilteredCount"
diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py
index 16756fe..97dd8ed 100644
--- a/src/myfasthtml/core/dbmanager.py
+++ b/src/myfasthtml/core/dbmanager.py
@@ -39,7 +39,7 @@ class DbObject:
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
self._owner = owner
- self._name = name or self.__class__.__name__
+ self._name = name or owner.get_full_id()
self._db_manager = db_manager or DbManager(self._owner)
self._finalize_initialization()
@@ -112,6 +112,7 @@ class DbObject:
setattr(self, k, v)
self._save_self()
self._initializing = old_state
+ return self
def copy(self):
as_dict = self._get_properties().copy()
diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py
index a25df37..7325a0d 100644
--- a/src/myfasthtml/core/instances.py
+++ b/src/myfasthtml/core/instances.py
@@ -176,12 +176,22 @@ class InstancesManager:
:param instance_id:
:return:
"""
- session_id = InstancesManager.get_session_id(session) if isinstance(session, dict) else session
+ session_id = InstancesManager.get_session_id(session)
key = (session_id, instance_id)
return InstancesManager.instances[key]
+ @staticmethod
+ def get_by_type(session: dict, cls: type):
+ session_id = InstancesManager.get_session_id(session)
+ res = [i for s, i in InstancesManager.instances.items() if s[0] == session_id and isinstance(i, cls)]
+ assert len(res) <= 1, f"Multiple instances of type {cls.__name__} found"
+ assert len(res) > 0, f"No instance of type {cls.__name__} found"
+ return res[0]
+
@staticmethod
def get_session_id(session):
+ if isinstance(session, str):
+ return session
if session is None:
return "** NOT LOGGED IN **"
if "user_info" not in session:
diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py
index 20a28e9..6f473be 100644
--- a/src/myfasthtml/test/matcher.py
+++ b/src/myfasthtml/test/matcher.py
@@ -1,10 +1,11 @@
import re
from dataclasses import dataclass
-from typing import Optional
+from typing import Optional, Any
from fastcore.basics import NotStr
from fastcore.xml import FT
+from myfasthtml.core.commands import BaseCommand
from myfasthtml.core.utils import quoted_str, snake_to_pascal
from myfasthtml.test.testclient import MyFT
@@ -69,11 +70,16 @@ class EndsWith(AttrPredicate):
class Contains(AttrPredicate):
- def __init__(self, *value):
+ def __init__(self, *value, _word=False):
super().__init__(value)
+ self._word = _word
def validate(self, actual):
- return all(val in actual for val in self.value)
+ if self._word:
+ words = actual.split()
+ return all(val in words for val in self.value)
+ else:
+ return all(val in actual for val in self.value)
class DoesNotContain(AttrPredicate):
@@ -145,6 +151,26 @@ class AttributeForbidden(ChildrenPredicate):
return element
+class HasHtmx(ChildrenPredicate):
+ def __init__(self, command: BaseCommand = None, **htmx_params):
+ super().__init__(None)
+ self.command = command
+ if command:
+ self.htmx_params = command.get_htmx_params() | htmx_params
+ else:
+ self.htmx_params = htmx_params
+
+ self.htmx_params = {k.replace("hx_", "hx-"): v for k, v in self.htmx_params.items()}
+
+ def validate(self, actual):
+ return all(actual.attrs.get(k) == v for k, v in self.htmx_params.items())
+
+ def to_debug(self, element):
+ for k, v in self.htmx_params.items():
+ element.attrs[k] = v
+ return element
+
+
class TestObject:
def __init__(self, cls, **kwargs):
self.cls = cls
@@ -152,17 +178,29 @@ class TestObject:
class TestIcon(TestObject):
- def __init__(self, name: Optional[str] = ''):
+ def __init__(self, name: Optional[str] = '', command=None):
super().__init__("div")
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
self.children = [
TestObject(NotStr, s=Regex(f''
+class TestIconNotStr(TestObject):
+ def __init__(self, name: Optional[str] = ''):
+ super().__init__(NotStr)
+ self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
+ self.attrs["s"] = Regex(f''
+
+
class TestCommand(TestObject):
def __init__(self, name, **kwargs):
super().__init__("Command", **kwargs)
@@ -183,6 +221,12 @@ class DoNotCheck:
desc: str = None
+@dataclass
+class Skip:
+ element: Any
+ desc: str = None
+
+
def _get_type(x):
if hasattr(x, "tag"):
return x.tag
@@ -215,6 +259,34 @@ def _get_children(x):
return []
+def _str_element(element, expected=None, keep_open=None):
+ # compare to itself if no expected element is provided
+ if expected is None:
+ expected = element
+
+ if hasattr(element, "tag"):
+ # the attributes are compared to the expected element
+ elt_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in
+ [attr_name for attr_name in _get_attributes(expected) if attr_name is not None]}
+
+ elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
+ tag_str = f"({element.tag} {elt_attrs_str}"
+
+ # manage the closing tag
+ if keep_open is False:
+ tag_str += " ...)" if len(element.children) > 0 else ")"
+ elif keep_open is True:
+ tag_str += "..." if elt_attrs_str == "" else " ..."
+ else:
+ # close the tag if there are no children
+ not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
+ if len(not_special_children) == 0: tag_str += ")"
+ return tag_str
+
+ else:
+ return quoted_str(element)
+
+
class ErrorOutput:
def __init__(self, path, element, expected):
self.path = path
@@ -239,14 +311,14 @@ class ErrorOutput:
# first render the path hierarchy
for p in self.path.split(".")[:-1]:
elt_name, attr_name, attr_value = self._unconstruct_path_item(p)
- path_str = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
+ path_str = _str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
self._add_to_output(f"{path_str}")
self.indent += " "
# then render the element
if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
# display the tag and its attributes
- tag_str = self._str_element(self.element, self.expected)
+ tag_str = _str_element(self.element, self.expected)
self._add_to_output(tag_str)
# Try to show where the differences are
@@ -269,7 +341,7 @@ class ErrorOutput:
# display the child
element_child = self.element.children[element_index]
- child_str = self._str_element(element_child, expected_child, keep_open=False)
+ child_str = _str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors (only when the expected is a FT element
@@ -303,34 +375,6 @@ class ErrorOutput:
def _add_to_output(self, msg):
self.output.append(f"{self.indent}{msg}")
- @staticmethod
- def _str_element(element, expected=None, keep_open=None):
- # compare to itself if no expected element is provided
- if expected is None:
- expected = element
-
- if hasattr(element, "tag"):
- # the attributes are compared to the expected element
- elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in
- [attr_name for attr_name in expected.attrs if attr_name is not None]}
-
- elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
- tag_str = f"({element.tag} {elt_attrs_str}"
-
- # manage the closing tag
- if keep_open is False:
- tag_str += " ...)" if len(element.children) > 0 else ")"
- elif keep_open is True:
- tag_str += "..." if elt_attrs_str == "" else " ..."
- else:
- # close the tag if there are no children
- not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
- if len(not_special_children) == 0: tag_str += ")"
- return tag_str
-
- else:
- return quoted_str(element)
-
def _detect_error(self, element, expected):
"""
Detect errors between element and expected, returning a visual marker string.
@@ -543,8 +587,29 @@ class Matcher:
if len(actual_children) < len(expected_children):
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
- for actual_child, expected_child in zip(actual_children, expected_children):
+ actual_child_index, expected_child_index = 0, 0
+ while expected_child_index < len(expected_children):
+ if actual_child_index >= len(actual_children):
+ self._assert_error("Nothing more to skip.", _actual=actual, _expected=expected)
+
+ actual_child = actual_children[actual_child_index]
+ expected_child = expected_children[expected_child_index]
+
+ if isinstance(expected_child, Skip):
+ try:
+ # if this is the element to skip, skip it and continue
+ self._match_element(actual_child, expected_child.element)
+ actual_child_index += 1
+ continue
+ except AssertionError:
+ # otherwise try to match with the following element
+ expected_child_index += 1
+ continue
+
assert self.matches(actual_child, expected_child)
+
+ actual_child_index += 1
+ expected_child_index += 1
def _match_list(self, actual, expected):
"""Match list or tuple."""
@@ -625,7 +690,7 @@ class Matcher:
@staticmethod
def _debug(elt):
"""Format an element for debug output."""
- return str(elt) if elt else "None"
+ return _str_element(elt, keep_open=False) if elt else "None"
def matches(actual, expected, path=""):
diff --git a/tests/controls/test_layout.py b/tests/controls/test_layout.py
index 9ac9385..7bf8219 100644
--- a/tests/controls/test_layout.py
+++ b/tests/controls/test_layout.py
@@ -5,7 +5,9 @@ import pytest
from fasthtml.components import *
from myfasthtml.controls.Layout import Layout
-from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript
+from myfasthtml.controls.UserProfile import UserProfile
+from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript, TestObject, AnyValue, Skip, \
+ TestIconNotStr
from .conftest import root_instance
@@ -236,11 +238,12 @@ class TestLayoutRender:
"""Test that Layout renders with all main structural sections.
Why these elements matter:
- - 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
+ - 7 children: Verifies all main sections are rendered (tooltip container, header, drawers, main, footer, script)
- _id: Essential for layout identification and resizer initialization
- cls="mf-layout": Root CSS class for layout styling
"""
expected = Div(
+ Div(), # tooltip container
Header(),
Div(), # left drawer
Main(),
@@ -286,7 +289,7 @@ class TestLayoutRender:
expected = Header(
Div(
- TestIcon("panel_right_expand20_regular"),
+ TestIcon("PanelLeftContract20Regular"),
cls="flex gap-1"
),
cls="mf-layout-header"
@@ -343,7 +346,7 @@ class TestLayoutRender:
expected = Div(
_id=f"{layout._id}_ld",
- cls=Contains("collapsed"),
+ cls=Contains("mf-layout-drawer", "mf-layout-left-drawer", "collapsed"),
style=Contains("width: 0px")
)
@@ -382,7 +385,7 @@ class TestLayoutRender:
expected = Div(
_id=f"{layout._id}_rd",
- cls=Contains("collapsed"),
+ cls=Contains("mf-layout-drawer", "mf-layout-right-drawer", "collapsed"),
style=Contains("width: 0px")
)
@@ -425,34 +428,250 @@ class TestLayoutRender:
resizers = find(drawer, Div(cls=Contains("mf-resizer-right")))
assert len(resizers) == 1, "Right drawer should contain exactly one resizer element"
- def test_drawer_groups_are_separated_by_dividers(self, layout):
- """Test that multiple groups in drawer are separated by divider elements.
-
- Why this test matters:
- - Dividers provide visual separation between content groups
- - At least one divider should exist when multiple groups are present
- """
-
- layout.left_drawer.add(Div("Item 1"), group="group1")
- layout.left_drawer.add(Div("Item 2"), group="group2")
-
- drawer = find(layout.render(), Div(id=f"{layout._id}_ld"))
- content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content"))
- assert len(content_wrappers) == 1
-
- content = content_wrappers[0]
-
- dividers = find(content, Div(cls="divider"))
- assert len(dividers) >= 1, "Groups should be separated by dividers"
-
def test_resizer_script_is_included(self, layout):
"""Test that resizer initialization script is included in render.
Why this test matters:
- Script element: Required to initialize resizer functionality
- - Script contains initResizer call: Ensures resizer is activated for this layout instance
+ - Script contains initLayout call: Ensures layout is activated for this layout instance
"""
script = find_one(layout.render(), Script())
- expected = TestScript(f"initResizer('{layout._id}');")
+ expected = TestScript(f"initLayout('{layout._id}');")
+
+ assert matches(script, expected)
+
+ def test_left_drawer_renders_content_with_groups(self, layout):
+ """Test that left drawer renders content organized by groups with proper wrappers.
+
+ Why these elements matter:
+ - mf-layout-drawer-content wrapper: Required container for drawer scrolling behavior
+ - divider elements: Visual separation between content groups
+ - Group count validation: Ensures all added groups are rendered
+ """
+ layout.left_drawer.add(Div("Item 1", id="item1"), group="group1")
+ layout.left_drawer.add(Div("Item 2", id="item2"), group="group2")
+
+ drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
+
+ content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content"))
+ assert len(content_wrappers) == 1, "Left drawer should contain exactly one content wrapper"
+
+ content = content_wrappers[0]
+ dividers = find(content, Div(cls="divider"))
+ assert len(dividers) == 1, "Two groups should be separated by exactly one divider"
+
+
+ def test_header_left_renders_custom_content(self, layout):
+ """Test that custom content added to header_left is rendered in the left header section.
+
+ Why these elements matter:
+ - id="{layout._id}_hl": Essential for HTMX targeting during updates
+ - cls Contains "flex": Ensures horizontal layout of header items
+ - Icon presence: Toggle drawer icon must always be first element
+ - Custom content: Verifies header_left.add() correctly renders content
+ """
+ custom_content = Div("Custom Header", id="custom_header")
+ layout.header_left.add(custom_content)
+
+ header_left = find_one(layout.render(), Div(id=f"{layout._id}_hl"))
+
+ expected = Div(
+ TestIcon(""),
+ Skip(None),
+ Div("Custom Header", id="custom_header"),
+ id=f"{layout._id}_hl",
+ cls=Contains("flex", "gap-1")
+ )
+
+ assert matches(header_left, expected)
+
+ def test_header_right_renders_custom_content(self, layout):
+ """Test that custom content added to header_right is rendered in the right header section.
+
+ Why these elements matter:
+ - id="{layout._id}_hr": Essential for HTMX targeting during updates
+ - cls Contains "flex": Ensures horizontal layout of header items
+ - Custom content: Verifies header_right.add() correctly renders content
+ - UserProfile component: Must always be last element in right header
+ """
+ custom_content = Div("Custom Header Right", id="custom_header_right")
+ layout.header_right.add(custom_content)
+
+ header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr"))
+
+ expected = Div(
+ Skip(None),
+ Div("Custom Header Right", id="custom_header_right"),
+ TestObject(UserProfile),
+ id=f"{layout._id}_hr",
+ cls=Contains("flex", "gap-1")
+ )
+
+ assert matches(header_right, expected)
+
+ def test_footer_left_renders_custom_content(self, layout):
+ """Test that custom content added to footer_left is rendered in the left footer section.
+
+ Why these elements matter:
+ - id="{layout._id}_fl": Essential for HTMX targeting during updates
+ - cls Contains "flex": Ensures horizontal layout of footer items
+ - Custom content: Verifies footer_left.add() correctly renders content
+ """
+ custom_content = Div("Custom Footer Left", id="custom_footer_left")
+ layout.footer_left.add(custom_content)
+
+ footer_left = find_one(layout.render(), Div(id=f"{layout._id}_fl"))
+
+ expected = Div(
+ Skip(None),
+ Div("Custom Footer Left", id="custom_footer_left"),
+ id=f"{layout._id}_fl",
+ cls=Contains("flex", "gap-1")
+ )
+
+ assert matches(footer_left, expected)
+
+ def test_footer_right_renders_custom_content(self, layout):
+ """Test that custom content added to footer_right is rendered in the right footer section.
+
+ Why these elements matter:
+ - id="{layout._id}_fr": Essential for HTMX targeting during updates
+ - cls Contains "flex": Ensures horizontal layout of footer items
+ - Custom content: Verifies footer_right.add() correctly renders content
+ """
+ custom_content = Div("Custom Footer Right", id="custom_footer_right")
+ layout.footer_right.add(custom_content)
+
+ footer_right = find_one(layout.render(), Div(id=f"{layout._id}_fr"))
+
+ expected = Div(
+ Skip(None),
+ Div("Custom Footer Right", id="custom_footer_right"),
+ id=f"{layout._id}_fr",
+ cls=Contains("flex", "gap-1")
+ )
+
+ assert matches(footer_right, expected)
+
+ def test_left_drawer_resizer_has_command_data(self, layout):
+ """Test that left drawer resizer has correct data attributes for command binding.
+
+ Why these elements matter:
+ - data_command_id: JavaScript uses this to trigger width update command
+ - data_side="left": JavaScript needs this to identify which drawer to resize
+ - cls Contains "mf-resizer-left": CSS uses this for left-specific positioning
+ """
+ drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
+
+ resizer = find_one(drawer, Div(cls=Contains("mf-resizer-left")))
+
+ expected = Div(
+ cls=Contains("mf-resizer", "mf-resizer-left"),
+ data_command_id=AnyValue(),
+ data_side="left"
+ )
+
+ assert matches(resizer, expected)
+
+ def test_right_drawer_resizer_has_command_data(self, layout):
+ """Test that right drawer resizer has correct data attributes for command binding.
+
+ Why these elements matter:
+ - data_command_id: JavaScript uses this to trigger width update command
+ - data_side="right": JavaScript needs this to identify which drawer to resize
+ - cls Contains "mf-resizer-right": CSS uses this for right-specific positioning
+ """
+ drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
+
+ resizer = find_one(drawer, Div(cls=Contains("mf-resizer-right")))
+
+ expected = Div(
+ cls=Contains("mf-resizer", "mf-resizer-right"),
+ data_command_id=AnyValue(),
+ data_side="right"
+ )
+
+ assert matches(resizer, expected)
+
+ def test_left_drawer_icon_changes_when_closed(self, layout):
+ """Test that left drawer toggle icon changes from expand to collapse when drawer is closed.
+
+ Why these elements matter:
+ - id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling
+ - Icon type: Visual feedback to user about drawer state (expand icon when closed)
+ - Icon change: Validates that toggle_drawer returns correct icon
+ """
+ layout._state.left_drawer_open = False
+
+ icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi"))
+
+ expected = Div(
+ TestIconNotStr("panel_left_expand20_regular"),
+ id=f"{layout._id}_ldi"
+ )
+
+ assert matches(icon_div, expected)
+
+ def test_left_drawer_icon_changes_when_opne(self, layout):
+ """Test that left drawer toggle icon changes from collapse to expand when drawer is open..
+
+ Why these elements matter:
+ - id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling
+ - Icon type: Visual feedback to user about drawer state (expand icon when closed)
+ - Icon change: Validates that toggle_drawer returns correct icon
+ """
+ layout._state.left_drawer_open = True
+
+ icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi"))
+
+ expected = Div(
+ TestIconNotStr("panel_left_contract20_regular"),
+ id=f"{layout._id}_ldi"
+ )
+
+ assert matches(icon_div, expected)
+
+ def test_tooltip_container_is_rendered(self, layout):
+ """Test that tooltip container is rendered at the top of the layout.
+
+ Why these elements matter:
+ - id="tt_{layout._id}": JavaScript uses this to append dynamically created tooltips
+ - cls Contains "mf-tooltip-container": CSS positioning for tooltip overlay layer
+ - Presence verification: Tooltips won't work if container is missing
+ """
+ tooltip_container = find_one(layout.render(), Div(id=f"tt_{layout._id}"))
+
+ expected = Div(
+ id=f"tt_{layout._id}",
+ cls=Contains("mf-tooltip-container")
+ )
+
+ assert matches(tooltip_container, expected)
+
+ def test_header_right_contains_user_profile(self, layout):
+ """Test that UserProfile component is rendered in the right header section.
+
+ Why these elements matter:
+ - UserProfile component: Provides authentication and user menu functionality
+ - Position in header right: Conventional placement for user profile controls
+ - Count verification: Ensures component is not duplicated
+ """
+ header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr"))
+
+ user_profiles = find(header_right, TestObject(UserProfile))
+
+ assert len(user_profiles) == 1, "Header right should contain exactly one UserProfile component"
+
+ def test_layout_initialization_script_is_included(self, layout):
+ """Test that layout initialization script is included in render output.
+
+ Why these elements matter:
+ - Script presence: Required to initialize layout behavior (resizers, drawers)
+ - initLayout() call: Activates JavaScript functionality for this layout instance
+ - Layout ID parameter: Ensures initialization targets correct layout
+ """
+ script = find_one(layout.render(), Script())
+
+ expected = TestScript(f"initLayout('{layout._id}');")
assert matches(script, expected)
diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py
index 2e9641d..81742a6 100644
--- a/tests/controls/test_treeview.py
+++ b/tests/controls/test_treeview.py
@@ -6,7 +6,8 @@ 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, TestIcon, find_one, find, Contains, DoesNotContain
+from myfasthtml.test.matcher import matches, TestObject, TestCommand, TestIcon, find_one, find, Contains, \
+ DoesNotContain
from .conftest import root_instance
@@ -376,12 +377,30 @@ class TestTreeviewBehaviour:
# Try to add sibling to node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._add_sibling("nonexistent_id")
+
+ def test_i_can_initialize_with_items_dict(self, root_instance):
+ """Test that TreeView can be initialized with a dictionary of items."""
+ node1 = TreeNode(label="Node 1", type="folder")
+ node2 = TreeNode(label="Node 2", type="file")
+
+ items = {node1.id: node1, node2.id: node2}
+ tree_view = TreeView(root_instance, items=items)
+
+ assert len(tree_view._state.items) == 2
+ assert tree_view._state.items[node1.id].label == "Node 1"
+ assert tree_view._state.items[node1.id].type == "folder"
+ assert tree_view._state.items[node2.id].label == "Node 2"
+ assert tree_view._state.items[node2.id].type == "file"
class TestTreeViewRender:
"""Tests for TreeView HTML rendering."""
- def test_empty_treeview_is_rendered(self, root_instance):
+ @pytest.fixture
+ def tree_view(self, root_instance):
+ return TreeView(root_instance)
+
+ def test_empty_treeview_is_rendered(self, tree_view):
"""Test that empty TreeView generates correct HTML structure.
Why these elements matter:
@@ -389,23 +408,14 @@ class TestTreeViewRender:
- _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)
- @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.
@@ -419,35 +429,40 @@ class TestTreeViewRender:
"""
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")
+ Div(
+ 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"),
+ ),
+ cls="mf-treenode-container",
+ data_node_id=parent.id
),
- cls=Contains("mf-treenode"),
- data_node_id=parent.id
+ id=tree_view.get_id()
)
-
+
# Step 3: Compare
- assert matches(node_container, expected)
-
+ assert matches(rendered, 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"
-
+ child_containers = find(rendered, Div(data_node_id=parent.id))
+ assert len(child_containers) == 1, "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.
@@ -455,40 +470,48 @@ class TestTreeViewRender:
- 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
+
+ Rendered Structure :
+ Div (node_container with data_node_id)
+ ├─ Div (information on current node - icon, label, actions)
+ └─ Div* (children - recursive containers, only if expanded)
"""
parent = TreeNode(label="Parent", type="folder")
- child = TreeNode(label="Child", type="file")
-
+ child1 = TreeNode(label="Child1", type="file")
+ child2 = TreeNode(label="Child2", type="file")
+
tree_view.add_node(parent)
- tree_view.add_node(child, parent_id=parent.id)
+ tree_view.add_node(child1, parent_id=parent.id)
+ tree_view.add_node(child2, 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
+ parent_container = find_one(rendered, Div(data_node_id=parent.id))
+
expected = Div(
- TestIcon("chevron_down20_regular"), # Expanded toggle icon
- cls=Contains("mf-treenode")
+ Div(), # parent info (see test_node_with_children_collapsed_is_rendered)
+ Div(data_node_id=child1.id),
+ Div(data_node_id=child2.id),
)
-
+
# 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(parent_container, expected)
+
+ # now check the child node structure
+ child_container = find_one(rendered, Div(data_node_id=child1.id))
+ expected_child_container = Div(
+ Div(
+ Div(None), # No icon, the div is empty
+ Span("Child1"),
+ Div(), # action buttons
+ cls=Contains("mf-treenode")
+ ),
+ cls="mf-treenode-container",
+ data_node_id=child1.id,
)
- assert matches(child_node, child_expected)
-
+ assert matches(child_container, expected_child_container)
+
def test_leaf_node_is_rendered(self, tree_view):
"""Test that a leaf node (no children) renders without toggle icon.
@@ -499,51 +522,53 @@ class TestTreeViewRender:
"""
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))
-
+ leaf_container = 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")
+ Div(
+ Div(None), # No icon, the div is empty
+ Span("Leaf Node"), # Label
+ Div(), # Action buttons still present
),
cls=Contains("mf-treenode"),
data_node_id=leaf.id
)
-
+
# Step 3: Compare
- assert matches(leaf_node, expected)
-
+ assert matches(leaf_container, 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
+ - Div with mf-treenode: The node information container with selected class
- 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
+ selected_container = find_one(rendered, Div(data_node_id=node.id))
+
expected = Div(
- cls=Contains("mf-treenode", "selected"),
+ Div(
+ Div(None), # No icon, leaf node
+ Span("Selected Node"),
+ Div(), # Action buttons
+ cls=Contains("mf-treenode", "selected")
+ ),
+ cls="mf-treenode-container",
data_node_id=node.id
)
-
- # Step 3: Compare
- assert matches(selected_node, expected)
-
+
+ assert matches(selected_container, 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.
@@ -557,31 +582,34 @@ class TestTreeViewRender:
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
+ editing_container = find_one(rendered, Div(data_node_id=node.id))
+
expected = Div(
- Input(
- name="node_label",
- value="Edit Me",
- cls=Contains("mf-treenode-input")
+ Div(
+ Div(None), # No icon, leaf node
+ Input(
+ name="node_label",
+ value="Edit Me",
+ cls=Contains("mf-treenode-input")
+ ),
+ Div(), # Action buttons
+ cls=Contains("mf-treenode")
),
- cls=Contains("mf-treenode"),
+ cls="mf-treenode-container",
data_node_id=node.id
)
-
- # Step 3: Compare
- assert matches(editing_node, expected)
-
+
+ assert matches(editing_container, expected)
+
# Verify "selected" class is NOT present
+ editing_node_info = find_one(editing_container, Div(cls=Contains("mf-treenode", _word=True)))
no_selected = Div(
cls=DoesNotContain("selected")
)
- assert matches(editing_node, no_selected)
-
+ assert matches(editing_node_info, no_selected)
+
def test_node_indentation_increases_with_level(self, tree_view):
"""Test that node indentation increases correctly with hierarchy level.
@@ -590,38 +618,243 @@ class TestTreeViewRender:
- 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
+ - Padding is applied to the node info Div, not the container
"""
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()
+
+ # Test root node (level 0)
+ root_container = find_one(rendered, Div(data_node_id=root.id))
+ root_expected = Div(
+ Div(
+ TestIcon("chevron_down20_regular"), # Expanded icon
+ Span("Root"),
+ Div(), # Action buttons
+ cls=Contains("mf-treenode"),
+ style=Contains("padding-left: 0px")
+ ),
+ cls="mf-treenode-container",
+ data_node_id=root.id
+ )
+ assert matches(root_container, root_expected)
+
+ # Test child node (level 1)
+ child_container = find_one(rendered, Div(data_node_id=child.id))
+ child_expected = Div(
+ Div(
+ TestIcon("chevron_down20_regular"), # Expanded icon
+ Span("Child"),
+ Div(), # Action buttons
+ cls=Contains("mf-treenode"),
+ style=Contains("padding-left: 20px")
+ ),
+ cls="mf-treenode-container",
+ data_node_id=child.id
+ )
+ assert matches(child_container, child_expected)
+
+ # Test grandchild node (level 2)
+ grandchild_container = find_one(rendered, Div(data_node_id=grandchild.id))
+ grandchild_expected = Div(
+ Div(
+ Div(None), # No icon, leaf node
+ Span("Grandchild"),
+ Div(), # Action buttons
+ cls=Contains("mf-treenode"),
+ style=Contains("padding-left: 40px")
+ ),
+ cls="mf-treenode-container",
+ data_node_id=grandchild.id
+ )
+ assert matches(grandchild_container, grandchild_expected)
+
+ @pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
+ def test_toggle_icon_has_correct_command(self, tree_view):
+ """Test that toggle icon has ToggleNode command.
+
+ Why these elements matter:
+ - Div wrapper with command: mk.icon() wraps SVG in Div with HTMX attributes
+ - TestIcon inside Div: Verifies correct chevron icon is displayed
+ - TestCommand "ToggleNode": Essential for HTMX to route to correct handler
+ - Command targets correct node_id: Ensures the right node is toggled
+ """
+ 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 parent node element
+ rendered = tree_view.render()
+ parent_node = find_one(rendered, Div(data_node_id=parent.id))
+
+ # Step 2: Define expected structure
+ expected = Div(
+ Div(
+ TestIcon("chevron_right20_regular", command=tree_view.commands.toggle_node(parent.id)),
+ ),
+ data_node_id=parent.id
+ )
+
+ # Step 3: Compare
+ assert matches(parent_node, expected)
+
+ @pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
+ def test_action_buttons_have_correct_commands(self, tree_view):
+ """Test that action buttons have correct commands.
+
+ Why these elements matter:
+ - add_circle icon with AddChild: Enables adding child nodes via HTMX
+ - edit icon with StartRename: Triggers inline editing mode
+ - delete icon with DeleteNode: Enables node deletion
+ - cls "mf-treenode-actions": Required CSS class for button container styling
+ """
+ node = TreeNode(label="Node", type="folder")
+ tree_view.add_node(node)
+
+ # Step 1: Extract the action buttons container
+ rendered = tree_view.render()
+ actions = find_one(rendered, Div(cls=Contains("mf-treenode-actions")))
+
+ # Step 2: Define expected structure
+ expected = Div(
+ TestIcon("add_circle20_regular", command=tree_view.commands.add_child(node.id)),
+ TestIcon("edit20_regular", command=tree_view.commands.start_rename(node.id)),
+ TestIcon("delete20_regular", command=tree_view.commands.delete_node(node.id)),
+ cls=Contains("mf-treenode-actions")
+ )
+
+ # Step 3: Compare
+ assert matches(actions, expected)
+
+ @pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
+ def test_label_has_select_command(self, tree_view):
+ """Test that node label has SelectNode command.
+
+ Why these elements matter:
+ - Span with node label: Displays the node text
+ - TestCommand "SelectNode": Clicking label selects the node via HTMX
+ - cls "mf-treenode-label": Required CSS class for label styling
+ """
+ node = TreeNode(label="Clickable Node", type="file")
+ tree_view.add_node(node)
+
+ # Step 1: Extract the label element
+ rendered = tree_view.render()
+ label = find_one(rendered, Span(cls=Contains("mf-treenode-label")))
+
+ # Step 2: Define expected structure
+ expected = Span(
+ "Clickable Node",
+ command=tree_view.commands.select_node(node.id),
+ cls=Contains("mf-treenode-label")
+ )
+
+ # Step 3: Compare
+ assert matches(label, expected)
+
+ @pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
+ def test_input_has_save_rename_command(self, tree_view):
+ """Test that editing input has SaveRename command.
+
+ Why these elements matter:
+ - Input element: Enables inline editing of node label
+ - TestCommand "SaveRename": Submits new label via HTMX on form submission
+ - name "node_label": Required for form data to include the new label value
+ - value with current label: Pre-fills input with existing node text
+ """
+ node = TreeNode(label="Edit Me", type="file")
+ tree_view.add_node(node)
+ tree_view._start_rename(node.id)
+
+ # Step 1: Extract the input element
+ rendered = tree_view.render()
+ input_elem = find_one(rendered, Input(name="node_label"))
+
+ # Step 2: Define expected structure
+ expected = Input(
+ name="node_label",
+ value="Edit Me",
+ command=TestCommand(tree_view.commands.save_rename(node.id)),
+ cls=Contains("mf-treenode-input")
+ )
+
+ # Step 3: Compare
+ assert matches(input_elem, expected)
+
+ def test_keyboard_has_cancel_rename_command(self, tree_view):
+ """Test that Keyboard component has Escape key bound to CancelRename.
+
+ Why these elements matter:
+ - TestObject Keyboard: Verifies keyboard shortcuts component is present
+ - esc combination with CancelRename: Enables canceling rename with Escape key
+ - Essential for UX: Users expect Escape to cancel inline editing
+ """
+ # Step 1: Extract the Keyboard component
+ rendered = tree_view.render()
+ keyboard = find_one(rendered, TestObject(Keyboard))
+
+ # Step 2: Define expected structure
+ expected = TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")})
+
+ # Step 3: Compare
+ assert matches(keyboard, expected)
+
+
+ def test_multiple_root_nodes_are_rendered(self, tree_view):
+ """Test that multiple root nodes are rendered at the same level.
+
+ Why these elements matter:
+ - Multiple root nodes: Verifies TreeView supports forest structure (multiple trees)
+ - All at same level: No artificial parent wrapping root nodes
+ - Each root has its own container: Proper structure for multiple independent trees
+ """
+ root1 = TreeNode(label="Root 1", type="folder")
+ root2 = TreeNode(label="Root 2", type="folder")
+
+ tree_view.add_node(root1)
+ tree_view.add_node(root2)
rendered = tree_view.render()
+ root_containers = find(rendered, Div(cls=Contains("mf-treenode-container")))
- # 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)
+ assert len(root_containers) == 2, "Should have two root-level containers"
- # 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)
+ root1_container = find_one(rendered, Div(data_node_id=root1.id))
+ root2_container = find_one(rendered, Div(data_node_id=root2.id))
- # 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")
+ expected_root1 = Div(
+ Div(
+ Div(None), # No icon, leaf node
+ Span("Root 1"),
+ Div(), # Action buttons
+ cls=Contains("mf-treenode")
+ ),
+ cls="mf-treenode-container",
+ data_node_id=root1.id
)
- assert matches(grandchild_node, grandchild_expected)
+
+ expected_root2 = Div(
+ Div(
+ Div(None), # No icon, leaf node
+ Span("Root 2"),
+ Div(), # Action buttons
+ cls=Contains("mf-treenode")
+ ),
+ cls="mf-treenode-container",
+ data_node_id=root2.id
+ )
+
+ assert matches(root1_container, expected_root1)
+ assert matches(root2_container, expected_root2)
diff --git a/tests/html/keyboard_support.js b/tests/html/keyboard_support.js
index 4b73f63..ff47449 100644
--- a/tests/html/keyboard_support.js
+++ b/tests/html/keyboard_support.js
@@ -91,7 +91,6 @@
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
- console.log("Parsing combination", combinationStr, "=>", sequence);
let currentNode = root;
for (const keySet of sequence) {
diff --git a/tests/testclient/test_matches.py b/tests/testclient/test_matches.py
index 65d207a..6b1006e 100644
--- a/tests/testclient/test_matches.py
+++ b/tests/testclient/test_matches.py
@@ -3,9 +3,10 @@ from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
+from myfasthtml.core.commands import Command
from myfasthtml.icons.fluent_p3 import add20_regular
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, ErrorOutput, \
- ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject, TestIcon, DoNotCheck
+ ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject, Skip, DoNotCheck, TestIcon, HasHtmx
from myfasthtml.test.testclient import MyFT
@@ -55,6 +56,9 @@ class TestMatches:
(mk.icon(add20_regular), TestIcon("Add20Regular")),
(mk.icon(add20_regular), TestIcon("add20_regular")),
(mk.icon(add20_regular), TestIcon()),
+ (Div(None, None, None, Div(id="to_find")), Div(Skip(None), Div(id="to_find"))),
+ (Div(Div(id="to_skip"), Div(id="to_skip"), Div(id="to_find")), Div(Skip(Div(id="to_skip")), Div(id="to_find"))),
+ (Div(hx_post="/url"), Div(HasHtmx(hx_post="/url"))),
])
def test_i_can_match(self, actual, expected):
assert matches(actual, expected)
@@ -100,7 +104,8 @@ class TestMatches:
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different"),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")),
"The condition 'Contains(value2)' is not satisfied"),
-
+ (Div(Div(id="to_skip")), Div(Skip(Div(id="to_skip"))), "Nothing more to skip"),
+ (Div(hx_post="/url"), Div(HasHtmx(hx_post="/url2")), "The condition 'HasHtmx()' is not satisfied"),
])
def test_i_can_detect_errors(self, actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info:
@@ -446,3 +451,20 @@ Error : The condition 'Contains(value2)' is not satisfied.
assert "\n" + res == '''
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
^^^ |'''
+
+
+class TestPredicates:
+ def test_i_can_validate_contains_with_words_only(self):
+ assert Contains("value", _word=True).validate("value value2 value3")
+ assert Contains("value", "value2", _word=True).validate("value value2 value3")
+
+ assert not Contains("value", _word=True).validate("valuevalue2value3")
+ assert not Contains("value value2", _word=True).validate("value value2 value3")
+
+ def test_i_can_validate_has_htmx(self):
+ div = Div(hx_post="/url")
+ assert HasHtmx(hx_post="/url").validate(div)
+
+ c = Command("c", "testing has_htmx", None)
+ c.bind_ft(div)
+ assert HasHtmx(command=c).validate(div)