Added Controls testing + documentation
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
--spacing: 0.25rem;
|
||||
--text-xs: 0.6875rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-xl: 1.25rem;
|
||||
@@ -11,6 +12,8 @@
|
||||
--radius-md: 0.375rem;
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
--properties-font-size: var(--text-xs);
|
||||
--mf-tooltip-zindex: 10;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,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;
|
||||
@@ -632,7 +655,6 @@
|
||||
/* *************** Panel Component *************** */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Container principal du panel */
|
||||
.mf-panel {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@@ -641,7 +663,6 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Panel gauche */
|
||||
.mf-panel-left {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
@@ -653,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;
|
||||
@@ -671,4 +690,79 @@
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ************* Properties Component ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Properties container */
|
||||
.mf-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Group card - using DaisyUI card styling */
|
||||
.mf-properties-group-card {
|
||||
background-color: var(--color-base-100);
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Group header - gradient using DaisyUI primary color */
|
||||
.mf-properties-group-header {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);
|
||||
color: var(--color-primary-content);
|
||||
padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);
|
||||
font-weight: 700;
|
||||
font-size: var(--properties-font-size);
|
||||
}
|
||||
|
||||
/* Group content area */
|
||||
.mf-properties-group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Property row */
|
||||
.mf-properties-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
||||
transition: background-color 0.15s ease;
|
||||
gap: calc(var(--properties-font-size) * 0.75);
|
||||
}
|
||||
|
||||
.mf-properties-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-properties-row:hover {
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
|
||||
}
|
||||
|
||||
/* Property key - normal font */
|
||||
.mf-properties-key {
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
||||
flex: 0 0 40%;
|
||||
font-size: var(--properties-font-size);
|
||||
}
|
||||
|
||||
/* Property value - monospace font */
|
||||
.mf-properties-value {
|
||||
font-family: var(--default-mono-font-family);
|
||||
color: var(--color-base-content);
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: var(--properties-font-size);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
})();
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class CommandsDebugger(SingleInstance):
|
||||
"""
|
||||
Represents a debugger designed for visualizing and managing commands in a parent-child
|
||||
hierarchical structure.
|
||||
"""
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
|
||||
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()
|
||||
@@ -22,6 +22,12 @@ class DropdownState:
|
||||
|
||||
|
||||
class Dropdown(MultipleInstance):
|
||||
"""
|
||||
Represents a dropdown component that can be toggled open or closed. This class is used
|
||||
to create interactive dropdown elements, allowing for container and button customization.
|
||||
The dropdown provides functionality to manage its state, including opening, closing, and
|
||||
handling user interactions.
|
||||
"""
|
||||
def __init__(self, parent, content=None, button=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.button = Div(button) if not isinstance(button, FT) else button
|
||||
|
||||
@@ -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):
|
||||
@@ -35,17 +36,25 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class FileUpload(MultipleInstance):
|
||||
"""
|
||||
Represents a file upload component.
|
||||
|
||||
This class provides functionality to handle the uploading process of a file,
|
||||
extract sheet names from an Excel file, and enables users to select a specific
|
||||
sheet for further processing. It integrates commands and state management
|
||||
to ensure smooth operation within a parent application.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
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()
|
||||
@@ -64,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:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
@@ -8,12 +10,26 @@ class InstancesDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
|
||||
return self._panel.set_main(vis_network)
|
||||
|
||||
def on_network_event(self, event_data: dict):
|
||||
session, instance_id = event_data["nodes"][0].split("#")
|
||||
properties_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_def,
|
||||
_id="-properties"))
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
instances = self._get_instances()
|
||||
nodes, edges = from_parent_child_list(
|
||||
|
||||
@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Keyboard(MultipleInstance):
|
||||
"""
|
||||
Represents a keyboard with customizable key combinations support.
|
||||
|
||||
The Keyboard class allows managing key combinations and their corresponding
|
||||
actions for a given parent object.
|
||||
"""
|
||||
def __init__(self, parent, combinations=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
@@ -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
|
||||
@@ -37,12 +39,13 @@ class Commands(BaseCommands):
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
|
||||
|
||||
def update_drawer_width(self, side: Literal["left", "right"]):
|
||||
def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
|
||||
"""
|
||||
Create a command to update drawer width.
|
||||
|
||||
Args:
|
||||
side: Which drawer to update ("left" or "right")
|
||||
width: New width in pixels. Given by the HTMX request
|
||||
|
||||
Returns:
|
||||
Command: Command object for updating drawer width
|
||||
@@ -114,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)
|
||||
@@ -123,16 +126,6 @@ class Layout(SingleInstance):
|
||||
self.header_right = self.Content(self)
|
||||
self.footer_left = self.Content(self)
|
||||
self.footer_right = self.Content(self)
|
||||
self._footer_content = None
|
||||
|
||||
def set_footer(self, content):
|
||||
"""
|
||||
Set the footer content.
|
||||
|
||||
Args:
|
||||
content: FastHTML component(s) or content for the footer
|
||||
"""
|
||||
self._footer_content = content
|
||||
|
||||
def set_main(self, content):
|
||||
"""
|
||||
@@ -145,6 +138,18 @@ class Layout(SingleInstance):
|
||||
return self
|
||||
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
Toggle the state of a drawer (open or close) based on the specified side. This
|
||||
method also generates the corresponding icon and drawer elements for the
|
||||
selected side.
|
||||
|
||||
:param side: The side of the drawer to toggle. Must be either "left" or "right".
|
||||
:type side: Literal["left", "right"]
|
||||
:return: A tuple containing the updated drawer icon and drawer elements for
|
||||
the specified side.
|
||||
:rtype: Tuple[Any, Any]
|
||||
:raises ValueError: If the provided `side` is not "left" or "right".
|
||||
"""
|
||||
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
||||
if side == "left":
|
||||
self._state.left_drawer_open = not self._state.left_drawer_open
|
||||
@@ -190,15 +195,17 @@ class Layout(SingleInstance):
|
||||
return Header(
|
||||
Div( # left
|
||||
self._mk_left_drawer_icon(),
|
||||
*self.header_left.get_content(),
|
||||
cls="flex gap-1"
|
||||
*self._mk_content_wrapper(self.header_left, horizontal=True, show_group_name=False).children,
|
||||
cls="flex gap-1",
|
||||
id=f"{self._id}_hl"
|
||||
),
|
||||
Div( # right
|
||||
*self.header_right.get_content()[None],
|
||||
*self._mk_content_wrapper(self.header_right, horizontal=True, show_group_name=False).children,
|
||||
UserProfile(self),
|
||||
cls="flex gap-1"
|
||||
cls="flex gap-1",
|
||||
id=f"{self._id}_hr"
|
||||
),
|
||||
cls="mf-layout-header"
|
||||
cls="mf-layout-header",
|
||||
)
|
||||
|
||||
def _mk_footer(self):
|
||||
@@ -208,9 +215,17 @@ class Layout(SingleInstance):
|
||||
Returns:
|
||||
Footer: FastHTML Footer component
|
||||
"""
|
||||
footer_content = self._footer_content if self._footer_content else ""
|
||||
return Footer(
|
||||
footer_content,
|
||||
Div( # left
|
||||
*self._mk_content_wrapper(self.footer_left, horizontal=True, show_group_name=False).children,
|
||||
cls="flex gap-1",
|
||||
id=f"{self._id}_fl"
|
||||
),
|
||||
Div( # right
|
||||
*self._mk_content_wrapper(self.footer_right, horizontal=True, show_group_name=False).children,
|
||||
cls="flex gap-1",
|
||||
id=f"{self._id}_fr"
|
||||
),
|
||||
cls="mf-layout-footer footer sm:footer-horizontal"
|
||||
)
|
||||
|
||||
@@ -277,7 +292,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"
|
||||
)
|
||||
|
||||
@@ -290,15 +312,29 @@ 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"))
|
||||
|
||||
@staticmethod
|
||||
def _mk_content_wrapper(content: Content, show_group_name: bool = True, horizontal: bool = False):
|
||||
return Div(
|
||||
*[
|
||||
(
|
||||
Div(cls=f"divider {'divider-horizontal' if horizontal else ''}") if index > 0 else None,
|
||||
group_ft if show_group_name else None,
|
||||
*[item for item in content.get_content()[group_name]]
|
||||
)
|
||||
for index, (group_name, group_ft) in enumerate(content.get_groups())
|
||||
],
|
||||
cls="mf-layout-drawer-content"
|
||||
)
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the complete layout.
|
||||
@@ -309,12 +345,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",
|
||||
)
|
||||
|
||||
@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Mouse(MultipleInstance):
|
||||
"""
|
||||
Represents a mechanism to manage mouse event combinations and their associated commands.
|
||||
|
||||
This class is used to add, manage, and render mouse event sequences with corresponding
|
||||
commands, providing a flexible way to handle mouse interactions programmatically.
|
||||
"""
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
@@ -38,6 +38,15 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class Panel(MultipleInstance):
|
||||
"""
|
||||
Represents a user interface panel that supports customizable left, main, and right components.
|
||||
|
||||
The `Panel` class is used to create and manage a panel layout with optional left, main,
|
||||
and right sections. It provides functionality to set the components of the panel, toggle
|
||||
sides, and adjust the width of the sides dynamically. The class also handles rendering
|
||||
the panel with appropriate HTML elements and JavaScript for interactivity.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
@@ -58,35 +67,35 @@ class Panel(MultipleInstance):
|
||||
|
||||
def set_right(self, right):
|
||||
self._right = right
|
||||
return self
|
||||
return Div(self._right, id=f"{self._id}_r")
|
||||
|
||||
def set_left(self, left):
|
||||
self._left = left
|
||||
return self
|
||||
return Div(self._left, id=f"{self._id}_l")
|
||||
|
||||
def _mk_right(self):
|
||||
if not self.conf.right:
|
||||
return None
|
||||
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
data_command_id=self.commands.update_side_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
|
||||
return Div(resizer, self._right, cls="mf-panel-right")
|
||||
|
||||
|
||||
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
|
||||
|
||||
def _mk_left(self):
|
||||
if not self.conf.left:
|
||||
return None
|
||||
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-left",
|
||||
data_command_id=self.commands.update_side_width("left").id,
|
||||
data_side="left"
|
||||
)
|
||||
|
||||
return Div(self._left, resizer, cls="mf-panel-left")
|
||||
|
||||
return Div(Div(self._left, id=f"{self._id}_l"), resizer, cls="mf-panel-left")
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
|
||||
50
src/myfasthtml/controls/Properties.py
Normal file
50
src/myfasthtml/controls/Properties.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from fasthtml.components import Div
|
||||
from myutils.ProxyObject import ProxyObject
|
||||
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Properties(MultipleInstance):
|
||||
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def set_obj(self, obj, groups: dict = None):
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
Div(
|
||||
*[
|
||||
Div(
|
||||
Div(k, cls="mf-properties-key"),
|
||||
Div(str(v), cls="mf-properties-value", title=str(v)),
|
||||
cls="mf-properties-row"
|
||||
)
|
||||
for k, v in proxy.as_dict().items()
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
),
|
||||
cls="mf-properties-group-card"
|
||||
)
|
||||
for group_name, proxy in self.properties_by_group.items()
|
||||
],
|
||||
id=self._id,
|
||||
cls="mf-properties"
|
||||
)
|
||||
|
||||
def _create_properties_by_group(self):
|
||||
if self.groups is None:
|
||||
return {None: ProxyObject(self.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -21,6 +21,21 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class Search(MultipleInstance):
|
||||
"""
|
||||
Represents a component for managing and filtering a list of items.
|
||||
It uses fuzzy matching and subsequence matching to filter items.
|
||||
|
||||
:ivar items_names: The name of the items used to filter.
|
||||
:type items_names: str
|
||||
:ivar items: The first set of items to filter.
|
||||
:type items: list
|
||||
:ivar filtered: A copy of the `items` list, representing the filtered items after a search operation.
|
||||
:type filtered: list
|
||||
:ivar get_attr: Callable function to extract string values from items for filtering.
|
||||
:type get_attr: Callable[[Any], str]
|
||||
:ivar template: Callable function to define how filtered items are rendered.
|
||||
:type template: Callable[[Any], Any]
|
||||
"""
|
||||
def __init__(self,
|
||||
parent: BaseInstance,
|
||||
_id=None,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -25,19 +25,33 @@ class VisNetworkState(DbObject):
|
||||
},
|
||||
"physics": {"enabled": True}
|
||||
}
|
||||
self.events_handlers: dict = {} # {event_name: command_url}
|
||||
|
||||
|
||||
class VisNetwork(MultipleInstance):
|
||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None, events_handlers=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
logger.debug(f"VisNetwork created with id: {self._id}")
|
||||
|
||||
# possible events (expected in snake_case
|
||||
# - select_node → selectNode
|
||||
# - select → select
|
||||
# - click → click
|
||||
# - double_click → doubleClick
|
||||
|
||||
self._state = VisNetworkState(self)
|
||||
self._update_state(nodes, edges, options)
|
||||
|
||||
# Convert Commands to URLs
|
||||
handlers_htmx_options = {
|
||||
event_name: command.ajax_htmx_options()
|
||||
for event_name, command in events_handlers.items()
|
||||
} if events_handlers else {}
|
||||
|
||||
self._update_state(nodes, edges, options, handlers_htmx_options)
|
||||
|
||||
def _update_state(self, nodes, edges, options):
|
||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
|
||||
if not nodes and not edges and not options:
|
||||
def _update_state(self, nodes, edges, options, events_handlers=None):
|
||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}")
|
||||
if not nodes and not edges and not options and not events_handlers:
|
||||
return
|
||||
|
||||
state = self._state.copy()
|
||||
@@ -47,6 +61,8 @@ class VisNetwork(MultipleInstance):
|
||||
state.edges = edges
|
||||
if options is not None:
|
||||
state.options = options
|
||||
if events_handlers is not None:
|
||||
state.events_handlers = events_handlers
|
||||
|
||||
self._state.update(state)
|
||||
|
||||
@@ -70,6 +86,34 @@ class VisNetwork(MultipleInstance):
|
||||
# Convert Python options to JS
|
||||
js_options = json.dumps(self._state.options, indent=2)
|
||||
|
||||
# Map Python event names to vis-network event names
|
||||
event_name_map = {
|
||||
"select_node": "selectNode",
|
||||
"select": "select",
|
||||
"click": "click",
|
||||
"double_click": "doubleClick"
|
||||
}
|
||||
|
||||
# Generate event handlers JavaScript
|
||||
event_handlers_js = ""
|
||||
for event_name, command_htmx_options in self._state.events_handlers.items():
|
||||
vis_event_name = event_name_map.get(event_name, event_name)
|
||||
event_handlers_js += f"""
|
||||
network.on('{vis_event_name}', function(params) {{
|
||||
const event_data = {{
|
||||
event_name: '{event_name}',
|
||||
nodes: params.nodes,
|
||||
edges: params.edges,
|
||||
pointer: params.pointer
|
||||
}};
|
||||
htmx.ajax('POST', '{command_htmx_options['url']}', {{
|
||||
values: {{event_data: JSON.stringify(event_data)}},
|
||||
target: '{command_htmx_options['target']}',
|
||||
swap: '{command_htmx_options['swap']}'
|
||||
}});
|
||||
}});
|
||||
"""
|
||||
|
||||
return (
|
||||
Div(
|
||||
id=self._id,
|
||||
@@ -92,6 +136,7 @@ class VisNetwork(MultipleInstance):
|
||||
}};
|
||||
const options = {js_options};
|
||||
const network = new vis.Network(container, data, options);
|
||||
{event_handlers_js}
|
||||
}})();
|
||||
""")
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
@@ -97,6 +98,14 @@ class BaseCommand:
|
||||
def url(self):
|
||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
return {
|
||||
"url": self.url,
|
||||
"target": self._htmx_extra.get("hx-target", "this"),
|
||||
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
|
||||
"values": {}
|
||||
}
|
||||
|
||||
def get_ft(self):
|
||||
return self._ft
|
||||
|
||||
@@ -126,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
|
||||
|
||||
@@ -141,8 +150,17 @@ class Command(BaseCommand):
|
||||
return float(value)
|
||||
elif param.annotation == list:
|
||||
return value.split(",")
|
||||
elif param.annotation == dict:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
res = super().ajax_htmx_options()
|
||||
if self.kwargs:
|
||||
res["values"] |= self.kwargs
|
||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||
return res
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
ret_from_bindings = []
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -84,7 +84,7 @@ class BaseInstance:
|
||||
return self._prefix
|
||||
|
||||
def get_full_id(self) -> str:
|
||||
return f"{InstancesManager.get_session_id(self._session)}-{self._id}"
|
||||
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
|
||||
|
||||
def get_full_parent_id(self) -> Optional[str]:
|
||||
parent = self.get_parent()
|
||||
@@ -176,11 +176,22 @@ class InstancesManager:
|
||||
:param instance_id:
|
||||
:return:
|
||||
"""
|
||||
key = (InstancesManager.get_session_id(session), instance_id)
|
||||
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:
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections.abc import Callable
|
||||
ROOT_COLOR = "#ff9999"
|
||||
GHOST_COLOR = "#cccccc"
|
||||
|
||||
|
||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||
"""
|
||||
Convert a list of nested dictionaries to vis.js nodes and edges format.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any
|
||||
|
||||
from fastcore.basics import NotStr
|
||||
from fastcore.xml import FT
|
||||
|
||||
from myfasthtml.core.utils import quoted_str
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.utils import quoted_str, snake_to_pascal
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
MISSING_ATTR = "** MISSING **"
|
||||
@@ -17,7 +20,18 @@ class Predicate:
|
||||
raise NotImplementedError
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
|
||||
if self.value is None:
|
||||
str_value = ''
|
||||
elif isinstance(self.value, str):
|
||||
str_value = self.value
|
||||
elif isinstance(self.value, (list, tuple)):
|
||||
if len(self.value) == 1:
|
||||
str_value = self.value[0]
|
||||
else:
|
||||
str_value = str(self.value)
|
||||
else:
|
||||
str_value = str(self.value)
|
||||
return f"{self.__class__.__name__}({str_value})"
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
|
||||
@@ -47,20 +61,33 @@ class StartsWith(AttrPredicate):
|
||||
return actual.startswith(self.value)
|
||||
|
||||
|
||||
class Contains(AttrPredicate):
|
||||
class EndsWith(AttrPredicate):
|
||||
def __init__(self, value):
|
||||
super().__init__(value)
|
||||
|
||||
def validate(self, actual):
|
||||
return self.value in actual
|
||||
return actual.endswith(self.value)
|
||||
|
||||
|
||||
class Contains(AttrPredicate):
|
||||
def __init__(self, *value, _word=False):
|
||||
super().__init__(value)
|
||||
self._word = _word
|
||||
|
||||
def validate(self, actual):
|
||||
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):
|
||||
def __init__(self, value):
|
||||
def __init__(self, *value):
|
||||
super().__init__(value)
|
||||
|
||||
def validate(self, actual):
|
||||
return self.value not in actual
|
||||
return all(val not in actual for val in self.value)
|
||||
|
||||
|
||||
class AnyValue(AttrPredicate):
|
||||
@@ -75,6 +102,14 @@ class AnyValue(AttrPredicate):
|
||||
return actual is not None
|
||||
|
||||
|
||||
class Regex(AttrPredicate):
|
||||
def __init__(self, pattern):
|
||||
super().__init__(pattern)
|
||||
|
||||
def validate(self, actual):
|
||||
return re.match(self.value, actual) is not None
|
||||
|
||||
|
||||
class ChildrenPredicate(Predicate):
|
||||
"""
|
||||
Predicate given as a child of an element.
|
||||
@@ -116,23 +151,142 @@ 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
|
||||
self.attrs = kwargs
|
||||
|
||||
|
||||
class TestIcon(TestObject):
|
||||
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)
|
||||
self.attrs = {"name": name} | kwargs # name should be first
|
||||
|
||||
|
||||
class TestScript(TestObject):
|
||||
def __init__(self, script):
|
||||
super().__init__("script")
|
||||
self.script = script
|
||||
self.children = [
|
||||
NotStr(self.script),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoNotCheck:
|
||||
desc: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Skip:
|
||||
element: Any
|
||||
desc: str = None
|
||||
|
||||
|
||||
def _get_type(x):
|
||||
if hasattr(x, "tag"):
|
||||
return x.tag
|
||||
if isinstance(x, (TestObject, TestIcon)):
|
||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||
return type(x).__name__
|
||||
|
||||
|
||||
def _get_attr(x, attr):
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs.get(attr, MISSING_ATTR)
|
||||
|
||||
if not hasattr(x, attr):
|
||||
return MISSING_ATTR
|
||||
|
||||
return getattr(x, attr, MISSING_ATTR)
|
||||
|
||||
|
||||
def _get_attributes(x):
|
||||
"""Get the attributes dict from an element."""
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs
|
||||
return {}
|
||||
|
||||
|
||||
def _get_children(x):
|
||||
"""Get the children list from an element."""
|
||||
if hasattr(x, "children"):
|
||||
return x.children
|
||||
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
|
||||
@@ -157,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
|
||||
@@ -187,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
|
||||
@@ -203,11 +357,11 @@ class ErrorOutput:
|
||||
self._add_to_output(")")
|
||||
|
||||
elif isinstance(self.expected, TestObject):
|
||||
cls = _mytype(self.element)
|
||||
attrs = {attr_name: _mygetattr(self.element, attr_name) for attr_name in self.expected.attrs}
|
||||
cls = _get_type(self.element)
|
||||
attrs = {attr_name: _get_attr(self.element, attr_name) for attr_name in self.expected.attrs}
|
||||
self._add_to_output(f"({cls} {_str_attrs(attrs)})")
|
||||
# Try to show where the differences are
|
||||
error_str = self._detect_error_2(self.element, self.expected)
|
||||
error_str = self._detect_error(self.element, self.expected)
|
||||
if error_str:
|
||||
self._add_to_output(error_str)
|
||||
|
||||
@@ -221,79 +375,36 @@ 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):
|
||||
if hasattr(expected, "tag") and hasattr(element, "tag"):
|
||||
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in expected.attrs}
|
||||
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
|
||||
not self._matches(attr_value, expected.attrs[attr_name])]
|
||||
if attrs_in_error:
|
||||
elt_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in elt_attrs.items())
|
||||
error_str = f" {tag_str} {elt_attrs_error}"
|
||||
return error_str
|
||||
else:
|
||||
if not self._matches(element, expected):
|
||||
return len(str(element)) * "^"
|
||||
|
||||
return None
|
||||
|
||||
def _detect_error_2(self, element, expected):
|
||||
"""
|
||||
Too lazy to refactor original _detect_error
|
||||
:param element:
|
||||
:param expected:
|
||||
:return:
|
||||
Detect errors between element and expected, returning a visual marker string.
|
||||
Unified version that handles both FT elements and TestObjects.
|
||||
"""
|
||||
# For elements with structure (FT or TestObject)
|
||||
if hasattr(expected, "tag") or isinstance(expected, TestObject):
|
||||
element_cls = _mytype(element)
|
||||
expected_cls = _mytype(expected)
|
||||
str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls)
|
||||
element_type = _get_type(element)
|
||||
expected_type = _get_type(expected)
|
||||
type_error = (" " if element_type == expected_type else "^") * len(element_type)
|
||||
|
||||
element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs}
|
||||
expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs}
|
||||
element_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in _get_attributes(expected)}
|
||||
expected_attrs = {attr_name: _get_attr(expected, attr_name) for attr_name in _get_attributes(expected)}
|
||||
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
|
||||
not self._matches(attr_value, expected_attrs[attr_name])}
|
||||
str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in element_attrs.items())
|
||||
if str_attrs_error.strip() or str_tag_error.strip():
|
||||
return f" {str_tag_error} {str_attrs_error}"
|
||||
else:
|
||||
return None
|
||||
|
||||
attrs_error = " ".join(
|
||||
len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in element_attrs.items()
|
||||
)
|
||||
|
||||
if type_error.strip() or attrs_error.strip():
|
||||
return f" {type_error} {attrs_error}"
|
||||
return None
|
||||
|
||||
# For simple values
|
||||
else:
|
||||
if not self._matches(element, expected):
|
||||
return len(str(element)) * "^"
|
||||
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _matches(element, expected):
|
||||
@@ -343,39 +454,221 @@ class ErrorComparisonOutput:
|
||||
return "\n".join(output)
|
||||
|
||||
|
||||
def matches(actual, expected, path=""):
|
||||
def print_path(p):
|
||||
return f"Path : '{p}'\n" if p else ""
|
||||
class Matcher:
|
||||
"""
|
||||
Matcher class for comparing actual and expected elements.
|
||||
Provides flexible comparison with support for predicates, nested structures,
|
||||
and detailed error reporting.
|
||||
"""
|
||||
|
||||
def _type(x):
|
||||
return type(x)
|
||||
def __init__(self):
|
||||
self.path = ""
|
||||
|
||||
def _debug(elt):
|
||||
return str(elt) if elt else "None"
|
||||
def matches(self, actual, expected):
|
||||
"""
|
||||
Compare actual and expected elements.
|
||||
|
||||
Args:
|
||||
actual: The actual element to compare
|
||||
expected: The expected element or pattern
|
||||
|
||||
Returns:
|
||||
True if elements match, raises AssertionError otherwise
|
||||
"""
|
||||
if actual is not None and expected is None:
|
||||
self._assert_error("Actual is not None while expected is None", _actual=actual)
|
||||
|
||||
if isinstance(expected, DoNotCheck):
|
||||
return True
|
||||
|
||||
if actual is None and expected is not None:
|
||||
self._assert_error("Actual is None while expected is ", _expected=expected)
|
||||
|
||||
# set the path
|
||||
current_path = self._get_current_path(actual)
|
||||
original_path = self.path
|
||||
self.path = self.path + "." + current_path if self.path else current_path
|
||||
|
||||
try:
|
||||
self._match_elements(actual, expected)
|
||||
finally:
|
||||
# restore the original path for sibling comparisons
|
||||
self.path = original_path
|
||||
|
||||
return True
|
||||
|
||||
def _debug_compare(a, b):
|
||||
actual_out = ErrorOutput(path, a, b)
|
||||
expected_out = ErrorOutput(path, b, b)
|
||||
def _match_elements(self, actual, expected):
|
||||
"""Internal method that performs the actual comparison logic."""
|
||||
if isinstance(expected, TestObject) or hasattr(expected, "tag"):
|
||||
self._match_element(actual, expected)
|
||||
return
|
||||
|
||||
if isinstance(expected, Predicate):
|
||||
assert expected.validate(actual), \
|
||||
self._error_msg(f"The condition '{expected}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
return
|
||||
|
||||
assert _get_type(actual) == _get_type(expected), \
|
||||
self._error_msg("The types are different.", _actual=actual, _expected=expected)
|
||||
|
||||
if isinstance(expected, (list, tuple)):
|
||||
self._match_list(actual, expected)
|
||||
elif isinstance(expected, dict):
|
||||
self._match_dict(actual, expected)
|
||||
elif isinstance(expected, NotStr):
|
||||
self._match_notstr(actual, expected)
|
||||
else:
|
||||
assert actual == expected, self._error_msg("The values are different",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
def _match_element(self, actual, expected):
|
||||
"""Match a TestObject or FT element."""
|
||||
# Validate the type/tag
|
||||
assert _get_type(actual) == _get_type(expected), \
|
||||
self._error_msg("The types are different.", _actual=_get_type(actual), _expected=_get_type(expected))
|
||||
|
||||
# Special conditions (ChildrenPredicate)
|
||||
expected_children = _get_children(expected)
|
||||
for predicate in [c for c in expected_children if isinstance(c, ChildrenPredicate)]:
|
||||
assert predicate.validate(actual), \
|
||||
self._error_msg(f"The condition '{predicate}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=predicate.to_debug(expected))
|
||||
|
||||
# Compare the attributes
|
||||
expected_attrs = _get_attributes(expected)
|
||||
for expected_attr, expected_value in expected_attrs.items():
|
||||
actual_value = _get_attr(actual, expected_attr)
|
||||
|
||||
# Check if attribute exists
|
||||
if actual_value == MISSING_ATTR:
|
||||
self._assert_error(f"'{expected_attr}' is not found in Actual. (attributes: {self._str_attrs(actual)})",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# Handle Predicate values
|
||||
if isinstance(expected_value, Predicate):
|
||||
assert expected_value.validate(actual_value), \
|
||||
self._error_msg(f"The condition '{expected_value}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# Handle TestObject recursive matching
|
||||
elif isinstance(expected, TestObject):
|
||||
try:
|
||||
self.matches(actual_value, expected_value)
|
||||
except AssertionError as e:
|
||||
match = re.search(r"Error : (.+?)\n", str(e))
|
||||
if match:
|
||||
self._assert_error(f"{match.group(1)} for '{expected_attr}'.",
|
||||
_actual=actual_value,
|
||||
_expected=expected_value)
|
||||
else:
|
||||
self._assert_error(f"The values are different for '{expected_attr}'.",
|
||||
_actual=actual_value,
|
||||
_expected=expected_value)
|
||||
|
||||
# Handle regular value comparison
|
||||
else:
|
||||
assert actual_value == expected_value, \
|
||||
self._error_msg(f"The values are different for '{expected_attr}'.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# Compare the children (only if present)
|
||||
if expected_children:
|
||||
# Filter out Predicate children
|
||||
expected_children = [c for c in expected_children if not isinstance(c, Predicate)]
|
||||
actual_children = _get_children(actual)
|
||||
|
||||
if len(actual_children) < len(expected_children):
|
||||
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
|
||||
|
||||
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."""
|
||||
if len(actual) < len(expected):
|
||||
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
|
||||
for actual_child, expected_child in zip(actual, expected):
|
||||
assert self.matches(actual_child, expected_child)
|
||||
|
||||
def _match_dict(self, actual, expected):
|
||||
"""Match dictionary."""
|
||||
if len(actual) < len(expected):
|
||||
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
for k, v in expected.items():
|
||||
assert self.matches(actual[k], v)
|
||||
|
||||
def _match_notstr(self, actual, expected):
|
||||
"""Match NotStr type."""
|
||||
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
|
||||
assert to_compare.startswith(expected.s), self._error_msg("Notstr values are different: ",
|
||||
_actual=to_compare,
|
||||
_expected=expected.s)
|
||||
|
||||
def _print_path(self):
|
||||
"""Format the current path for error messages."""
|
||||
return f"Path : '{self.path}'\n" if self.path else ""
|
||||
|
||||
def _debug_compare(self, a, b):
|
||||
"""Generate a comparison debug output."""
|
||||
actual_out = ErrorOutput(self.path, a, b)
|
||||
expected_out = ErrorOutput(self.path, b, b)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
return comparison_out.render()
|
||||
|
||||
def _error_msg(msg, _actual=None, _expected=None):
|
||||
def _error_msg(self, msg, _actual=None, _expected=None):
|
||||
"""Generate an error message with debug information."""
|
||||
if _actual is None and _expected is None:
|
||||
debug_info = ""
|
||||
elif _actual is None:
|
||||
debug_info = _debug(_expected)
|
||||
debug_info = self._debug(_expected)
|
||||
elif _expected is None:
|
||||
debug_info = _debug(_actual)
|
||||
debug_info = self._debug(_actual)
|
||||
else:
|
||||
debug_info = _debug_compare(_actual, _expected)
|
||||
debug_info = self._debug_compare(_actual, _expected)
|
||||
|
||||
return f"{print_path(path)}Error : {msg}\n{debug_info}"
|
||||
return f"{self._print_path()}Error : {msg}\n{debug_info}"
|
||||
|
||||
def _assert_error(msg, _actual=None, _expected=None):
|
||||
assert False, _error_msg(msg, _actual=_actual, _expected=_expected)
|
||||
def _assert_error(self, msg, _actual=None, _expected=None):
|
||||
"""Raise an assertion error with formatted message."""
|
||||
assert False, self._error_msg(msg, _actual=_actual, _expected=_expected)
|
||||
|
||||
@staticmethod
|
||||
def _get_current_path(elt):
|
||||
"""Get the path representation of an element."""
|
||||
if hasattr(elt, "tag"):
|
||||
res = f"{elt.tag}"
|
||||
if "id" in elt.attrs:
|
||||
@@ -388,192 +681,127 @@ def matches(actual, expected, path=""):
|
||||
else:
|
||||
return elt.__class__.__name__
|
||||
|
||||
if actual is not None and expected is None:
|
||||
_assert_error("Actual is not None while expected is None", _actual=actual)
|
||||
@staticmethod
|
||||
def _str_attrs(element):
|
||||
"""Format attributes as a string."""
|
||||
attrs = _get_attributes(element)
|
||||
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
|
||||
|
||||
if isinstance(expected, DoNotCheck):
|
||||
return True
|
||||
|
||||
if actual is None and expected is not None:
|
||||
_assert_error("Actual is None while expected is ", _expected=expected)
|
||||
|
||||
# set the path
|
||||
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
|
||||
|
||||
if isinstance(expected, TestObject):
|
||||
assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
for attr, value in expected.attrs.items():
|
||||
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
try:
|
||||
matches(getattr(actual, attr), value)
|
||||
except AssertionError as e:
|
||||
match = re.search(r"Error : (.+?)\n", str(e))
|
||||
if match:
|
||||
assert False, _error_msg(f"{match.group(1)} for '{attr}':",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
assert False, _error_msg(f"The values are different for '{attr}': ",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
return True
|
||||
|
||||
if isinstance(expected, Predicate):
|
||||
assert expected.validate(actual), \
|
||||
_error_msg(f"The condition '{expected}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
||||
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
||||
|
||||
if isinstance(expected, (list, tuple)):
|
||||
if len(actual) < len(expected):
|
||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
|
||||
for actual_child, expected_child in zip(actual, expected):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
elif isinstance(expected, dict):
|
||||
if len(actual) < len(expected):
|
||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
for k, v in expected.items():
|
||||
assert matches(actual[k], v, path=f"{path}[{k}={v}]")
|
||||
|
||||
elif isinstance(expected, NotStr):
|
||||
to_compare = actual.s.lstrip('\n').lstrip()
|
||||
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
||||
_actual=to_compare,
|
||||
_expected=expected.s)
|
||||
|
||||
elif hasattr(expected, "tag"):
|
||||
# validate the tags names
|
||||
assert actual.tag == expected.tag, _error_msg("The elements are different.",
|
||||
_actual=actual.tag,
|
||||
_expected=expected.tag)
|
||||
|
||||
# special conditions
|
||||
for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]:
|
||||
assert predicate.validate(actual), \
|
||||
_error_msg(f"The condition '{predicate}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=predicate.to_debug(expected))
|
||||
|
||||
# compare the attributes
|
||||
for expected_attr, expected_value in expected.attrs.items():
|
||||
assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
if isinstance(expected_value, Predicate):
|
||||
assert expected_value.validate(actual.attrs[expected_attr]), \
|
||||
_error_msg(f"The condition '{expected_value}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
else:
|
||||
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
|
||||
_error_msg(f"The values are different for '{expected_attr}': ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# compare the children
|
||||
expected_children = [c for c in expected.children if not isinstance(c, Predicate)]
|
||||
if len(actual.children) < len(expected_children):
|
||||
_assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected)
|
||||
|
||||
for actual_child, expected_child in zip(actual.children, expected_children):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
|
||||
else:
|
||||
assert actual == expected, _error_msg("The values are different",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
return True
|
||||
@staticmethod
|
||||
def _debug(elt):
|
||||
"""Format an element for debug output."""
|
||||
return _str_element(elt, keep_open=False) if elt else "None"
|
||||
|
||||
|
||||
def matches(actual, expected, path=""):
|
||||
"""
|
||||
Compare actual and expected elements.
|
||||
|
||||
This is a convenience wrapper around the Matcher class.
|
||||
|
||||
Args:
|
||||
actual: The actual element to compare
|
||||
expected: The expected element or pattern
|
||||
path: Optional initial path for error reporting
|
||||
|
||||
Returns:
|
||||
True if elements match, raises AssertionError otherwise
|
||||
"""
|
||||
matcher = Matcher()
|
||||
matcher.path = path
|
||||
return matcher.matches(actual, expected)
|
||||
|
||||
|
||||
def find(ft, expected):
|
||||
res = []
|
||||
"""
|
||||
Find all occurrences of an expected element within a FastHTML tree.
|
||||
|
||||
Args:
|
||||
ft: A FastHTML element or list of elements to search in
|
||||
expected: The element pattern to find
|
||||
|
||||
Returns:
|
||||
List of matching elements
|
||||
|
||||
Raises:
|
||||
AssertionError: If no matching elements are found
|
||||
"""
|
||||
|
||||
def _type(x):
|
||||
return type(x)
|
||||
|
||||
def _same(_ft, _expected):
|
||||
if _ft == _expected:
|
||||
def _elements_match(actual, expected):
|
||||
"""Check if two elements are the same based on tag, attributes, and children."""
|
||||
if isinstance(expected, DoNotCheck):
|
||||
return True
|
||||
|
||||
if _ft.tag != _expected.tag:
|
||||
if isinstance(expected, NotStr):
|
||||
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
|
||||
return to_compare.startswith(expected.s)
|
||||
|
||||
if isinstance(actual, NotStr) and _get_type(actual) != _get_type(expected):
|
||||
return False # to manage the unexpected __eq__ behavior of NotStr
|
||||
|
||||
if not isinstance(expected, (TestObject, FT)):
|
||||
return actual == expected
|
||||
|
||||
# Compare tags
|
||||
if _get_type(actual) != _get_type(expected):
|
||||
return False
|
||||
|
||||
for attr in _expected.attrs:
|
||||
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
|
||||
# Compare attributes
|
||||
expected_attrs = _get_attributes(expected)
|
||||
for attr_name, expected_attr_value in expected_attrs.items():
|
||||
actual_attr_value = _get_attr(actual, attr_name)
|
||||
# attribute is missing
|
||||
if actual_attr_value == MISSING_ATTR:
|
||||
return False
|
||||
# manage predicate values
|
||||
if isinstance(expected_attr_value, Predicate):
|
||||
return expected_attr_value.validate(actual_attr_value)
|
||||
# finally compare values
|
||||
return actual_attr_value == expected_attr_value
|
||||
|
||||
for expected_child in _expected.children:
|
||||
for ft_child in _ft.children:
|
||||
if _same(ft_child, expected_child):
|
||||
break
|
||||
else:
|
||||
# Compare children recursively
|
||||
expected_children = _get_children(expected)
|
||||
actual_children = _get_children(actual)
|
||||
|
||||
for expected_child in expected_children:
|
||||
# Check if this expected child exists somewhere in actual children
|
||||
if not any(_elements_match(actual_child, expected_child) for actual_child in actual_children):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _find(current, current_expected):
|
||||
def _search_tree(current, pattern):
|
||||
"""Recursively search for pattern in the tree rooted at current."""
|
||||
# Check if current element matches
|
||||
matches = []
|
||||
if _elements_match(current, pattern):
|
||||
matches.append(current)
|
||||
|
||||
if _type(current) != _type(current_expected):
|
||||
return []
|
||||
# Recursively search in children, in the case that the pattern also appears in children
|
||||
for child in _get_children(current):
|
||||
matches.extend(_search_tree(child, pattern))
|
||||
|
||||
if not hasattr(current, "tag"):
|
||||
return [current] if current == current_expected else []
|
||||
|
||||
_found = []
|
||||
if _same(current, current_expected):
|
||||
_found.append(current)
|
||||
|
||||
# look at the children
|
||||
for child in current.children:
|
||||
_found.extend(_find(child, current_expected))
|
||||
|
||||
return _found
|
||||
return matches
|
||||
|
||||
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
|
||||
# Normalize input to list
|
||||
elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft]
|
||||
|
||||
for current_ft in ft_as_list:
|
||||
found = _find(current_ft, expected)
|
||||
res.extend(found)
|
||||
# Search in all provided elements
|
||||
all_matches = []
|
||||
for element in elements_to_search:
|
||||
all_matches.extend(_search_tree(element, expected))
|
||||
|
||||
if len(res) == 0:
|
||||
# Raise error if nothing found
|
||||
if not all_matches:
|
||||
raise AssertionError(f"No element found for '{expected}'")
|
||||
|
||||
return res
|
||||
return all_matches
|
||||
|
||||
|
||||
def _mytype(x):
|
||||
if hasattr(x, "tag"):
|
||||
return x.tag
|
||||
if isinstance(x, TestObject):
|
||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||
return type(x).__name__
|
||||
|
||||
|
||||
def _mygetattr(x, attr):
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs.get(attr, MISSING_ATTR)
|
||||
|
||||
if not hasattr(x, attr):
|
||||
return MISSING_ATTR
|
||||
|
||||
return getattr(x, attr, MISSING_ATTR)
|
||||
def find_one(ft, expected):
|
||||
found = find(ft, expected)
|
||||
assert len(found) == 1, f"Found {len(found)} elements for '{expected}'"
|
||||
return found[0]
|
||||
|
||||
|
||||
def _str_attrs(attrs: dict):
|
||||
|
||||
Reference in New Issue
Block a user