Added tests for Layout and Treeview
This commit is contained in:
30
src/app.py
30
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")))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
})();
|
||||
|
||||
|
||||
59
src/myfasthtml/controls/DataGrid.py
Normal file
59
src/myfasthtml/controls/DataGrid.py
Normal file
@@ -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()
|
||||
58
src/myfasthtml/controls/DataGridsManager.py
Normal file
58
src/myfasthtml/controls/DataGridsManager.py
Normal file
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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}');")
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
49
src/myfasthtml/controls/datagrid_objects.py
Normal file
49
src/myfasthtml/controls/datagrid_objects.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
from enum import Enum
|
||||
|
||||
DEFAULT_COLUMN_WIDTH = 100
|
||||
|
||||
ROUTE_ROOT = "/myfasthtml"
|
||||
|
||||
|
||||
class Routes:
|
||||
Commands = "/commands"
|
||||
Bindings = "/bindings"
|
||||
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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'<svg name="\\w+-{self.name}'))
|
||||
]
|
||||
if command:
|
||||
self.attrs |= command.get_htmx_params()
|
||||
|
||||
def __str__(self):
|
||||
return f'<div><svg name="{self.name}" .../></div>'
|
||||
|
||||
|
||||
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'<svg name="\\w+-{self.name}')
|
||||
|
||||
def __str__(self):
|
||||
return f'<svg name="{self.name}" .../>'
|
||||
|
||||
|
||||
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=""):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user