From 8f2528787a606cc9776bcdf99aa16d60e439019b Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Fri, 5 Dec 2025 17:46:15 +0100 Subject: [PATCH] Added tests for Layout and Treeview --- src/app.py | 30 +- src/myfasthtml/assets/myfasthtml.css | 27 +- src/myfasthtml/assets/myfasthtml.js | 111 ++++- src/myfasthtml/controls/DataGrid.py | 59 +++ src/myfasthtml/controls/DataGridsManager.py | 58 +++ src/myfasthtml/controls/FileUpload.py | 15 +- src/myfasthtml/controls/InstancesDebugger.py | 10 +- src/myfasthtml/controls/Layout.py | 28 +- src/myfasthtml/controls/TabsManager.py | 39 +- src/myfasthtml/controls/TreeView.py | 9 +- src/myfasthtml/controls/datagrid_objects.py | 49 ++ src/myfasthtml/controls/helpers.py | 6 + src/myfasthtml/core/commands.py | 2 +- src/myfasthtml/core/constants.py | 36 +- src/myfasthtml/core/dbmanager.py | 3 +- src/myfasthtml/core/instances.py | 12 +- src/myfasthtml/test/matcher.py | 139 ++++-- tests/controls/test_layout.py | 273 +++++++++-- tests/controls/test_treeview.py | 457 ++++++++++++++----- tests/html/keyboard_support.js | 1 - tests/testclient/test_matches.py | 26 +- 21 files changed, 1161 insertions(+), 229 deletions(-) create mode 100644 src/myfasthtml/controls/DataGrid.py create mode 100644 src/myfasthtml/controls/DataGridsManager.py create mode 100644 src/myfasthtml/controls/datagrid_objects.py 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)