TabsManager.py Added unit tests + documentation

This commit is contained in:
2025-12-07 15:42:48 +01:00
parent 05067515d6
commit fde2e85c92
11 changed files with 4375 additions and 317 deletions

View File

@@ -2,6 +2,7 @@ import pandas as pd
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView
from myfasthtml.controls.helpers import mk
@@ -13,14 +14,22 @@ 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)
return Command("UploadFromSource",
"Upload from source",
self._owner.upload_from_source).htmx(target=None)
def new_grid(self):
return Command("NewGrid", "New grid", self._owner.new_grid)
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)
return Command("OpenFromExcel",
"Open from Excel",
self._owner.open_from_excel,
tab_id,
excel_content).htmx(target=None)
class DataGridsManager(MultipleInstance):
@@ -31,10 +40,8 @@ class DataGridsManager(MultipleInstance):
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 = FileUpload(self)
tab_id = self._tabs_manager.create_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)

View File

@@ -36,6 +36,7 @@ class Search(MultipleInstance):
:ivar template: Callable function to define how filtered items are rendered.
:type template: Callable[[Any], Any]
"""
def __init__(self,
parent: BaseInstance,
_id=None,
@@ -69,6 +70,12 @@ class Search(MultipleInstance):
self.filtered = self.items.copy()
return self
def get_items(self):
return self.items
def get_filtered(self):
return self.filtered
def on_search(self, query):
logger.debug(f"on_search {query=}")
self.search(query)

View File

@@ -52,32 +52,39 @@ class TabsManagerState(DbObject):
self.active_tab: str | None = None
# must not be persisted in DB
self._tabs_content: dict[str, Any] = {}
self.ns_tabs_content: dict[str, Any] = {} # Cache: always stores raw content (not wrapped)
self.ns_tabs_sent_to_client: set = set() # for tabs created, but not yet displayed
class Commands(BaseCommands):
def show_tab(self, tab_id):
return Command(f"{self._prefix}ShowTab",
"Activate or show a specific tab",
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-controller", swap="outerHTML")
self._owner.show_tab,
tab_id,
True,
False).htmx(target=f"#{self._id}-controller", swap="outerHTML")
def close_tab(self, tab_id):
return Command(f"{self._prefix}CloseTab",
"Close a specific tab",
self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
self._owner.close_tab,
tab_id).htmx(target=f"#{self._id}-controller", swap="outerHTML")
def add_tab(self, label: str, component: Any, auto_increment=False):
return (Command(f"{self._prefix}AddTab",
"Add a new tab",
self._owner.on_new_tab, label, component, auto_increment).
htmx(target=f"#{self._id}-controller"))
return Command(f"{self._prefix}AddTab",
"Add a new tab",
self._owner.on_new_tab,
label,
component,
auto_increment).htmx(target=f"#{self._id}-controller", swap="outerHTML")
class TabsManager(MultipleInstance):
_tab_count = 0
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self._tab_count = 0
self._state = TabsManagerState(self)
self.commands = Commands(self)
self._boundaries = Boundaries()
@@ -86,6 +93,7 @@ class TabsManager(MultipleInstance):
get_attr=lambda x: x["label"],
template=self._mk_tab_button,
_id="-search")
logger.debug(f"TabsManager created with id: {self._id}")
logger.debug(f" tabs : {self._get_ordered_tabs()}")
logger.debug(f" active tab : {self._state.active_tab}")
@@ -96,22 +104,36 @@ class TabsManager(MultipleInstance):
def _get_ordered_tabs(self):
return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
def _get_tab_content(self, tab_id):
def _dynamic_get_content(self, tab_id):
if tab_id not in self._state.tabs:
return None
return Div("Tab not found.")
tab_config = self._state.tabs[tab_id]
if tab_config["component_type"] is None:
return None
return Div("Tab content does not support serialization.")
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.")
return Div("Failed to retrieve tab content.")
@staticmethod
def _get_tab_count():
res = TabsManager._tab_count
TabsManager._tab_count += 1
def _get_or_create_tab_content(self, tab_id):
"""
Get tab content from cache or create it.
This method ensures content is always stored in raw form (not wrapped).
Args:
tab_id: ID of the tab
Returns:
Raw content component (not wrapped in Div)
"""
if tab_id not in self._state.ns_tabs_content:
self._state.ns_tabs_content[tab_id] = self._dynamic_get_content(tab_id)
return self._state.ns_tabs_content[tab_id]
def _get_tab_count(self):
res = self._tab_count
self._tab_count += 1
return res
def on_new_tab(self, label: str, component: Any, auto_increment=False):
@@ -120,20 +142,13 @@ class TabsManager(MultipleInstance):
label = f"{label}_{self._get_tab_count()}"
component = component or VisNetwork(self, nodes=vis_nodes, edges=vis_edges)
tab_id = self._tab_already_exists(label, component)
if tab_id:
return self.show_tab(tab_id)
tab_id = self.add_tab(label, component)
return (
self._mk_tabs_controller(),
self._wrap_tab_content(self._mk_tab_content(tab_id, component)),
self._mk_tabs_header_wrapper(True),
)
tab_id = self.create_tab(label, component)
return self.show_tab(tab_id, oob=False)
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
def create_tab(self, label: str, component: Any, activate: bool = True) -> str:
"""
Add a new tab or update an existing one with the same component type, ID and label.
The tab is not yet sent to the client.
Args:
label: Display label for the tab
@@ -144,73 +159,52 @@ class TabsManager(MultipleInstance):
tab_id: The UUID of the tab (new or existing)
"""
logger.debug(f"add_tab {label=}, component={component}, activate={activate}")
# copy the state to avoid multiple database call
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()
# Check if a tab with the same component_type, component_id AND label already exists
existing_tab_id = self._tab_already_exists(label, component)
if existing_tab_id:
# Update existing tab (only the component instance in memory)
tab_id = existing_tab_id
state._tabs_content[tab_id] = component
else:
# Create new tab
tab_id = str(uuid.uuid4())
# Add tab metadata to state
state.tabs[tab_id] = {
'id': tab_id,
'label': label,
'component_type': component_type,
'component_id': component_id
}
# Add tab to order
state.tabs_order.append(tab_id)
# Store component in memory
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())
tab_id = self._tab_already_exists(label, component) or str(uuid.uuid4())
self._add_or_update_tab(tab_id, label, component, activate)
return tab_id
def show_tab(self, tab_id):
def show_tab(self, tab_id, activate: bool = True, oob=True):
"""
Send the tab to the client if needed.
If the tab was already sent, just update the active tab.
:param tab_id:
:param activate:
:param oob: default=True so other control will not care of the target
:return:
"""
logger.debug(f"show_tab {tab_id=}")
if tab_id not in self._state.tabs:
logger.debug(f" Tab not found.")
return None
logger.debug(f" Tab label is: {self._state.tabs[tab_id]['label']}")
self._state.active_tab = tab_id
if tab_id not in self._state._tabs_content:
logger.debug(f" Content does not exist. Creating it.")
content = self._get_tab_content(tab_id)
if activate:
self._state.active_tab = tab_id
# Get or create content (always stored in raw form)
content = self._get_or_create_tab_content(tab_id)
if tab_id not in self._state.ns_tabs_sent_to_client:
logger.debug(f" Content not in client memory. Sending it.")
self._state.ns_tabs_sent_to_client.add(tab_id)
tab_content = self._mk_tab_content(tab_id, content)
self._state._tabs_content[tab_id] = tab_content
return self._mk_tabs_controller(), self._wrap_tab_content(tab_content)
return self._mk_tabs_controller(oob), self._mk_tabs_header_wrapper(), self._wrap_tab_content(tab_content)
else:
logger.debug(f" Content already exists. Just switch.")
return self._mk_tabs_controller()
logger.debug(f" Content already in client memory. Just switch.")
return self._mk_tabs_controller(oob) # no new tab_id => header is already up to date
def switch_tab(self, tab_id, label, component, activate=True):
def change_tab_content(self, tab_id, label, component, activate=True):
logger.debug(f"switch_tab {label=}, component={component}, activate={activate}")
if tab_id not in self._state.tabs:
logger.error(f" Tab {tab_id} not found. Cannot change its content.")
return None
self._add_or_update_tab(tab_id, label, component, activate)
return self.show_tab(tab_id) #
self._state.ns_tabs_sent_to_client.discard(tab_id) # to make sure that the new content will be sent to the client
return self.show_tab(tab_id, activate=activate, oob=True)
def close_tab(self, tab_id: str):
"""
@@ -220,10 +214,12 @@ class TabsManager(MultipleInstance):
tab_id: ID of the tab to close
Returns:
Self for chaining
tuple: (controller, header_wrapper, content_to_remove) for HTMX swapping,
or self if tab not found
"""
logger.debug(f"close_tab {tab_id=}")
if tab_id not in self._state.tabs:
logger.debug(f" Tab not found.")
return self
# Copy state
@@ -234,8 +230,12 @@ class TabsManager(MultipleInstance):
state.tabs_order.remove(tab_id)
# Remove from content
if tab_id in state._tabs_content:
del state._tabs_content[tab_id]
if tab_id in state.ns_tabs_content:
del state.ns_tabs_content[tab_id]
# Remove from content sent
if tab_id in state.ns_tabs_sent_to_client:
state.ns_tabs_sent_to_client.remove(tab_id)
# If closing active tab, activate another one
if state.active_tab == tab_id:
@@ -249,7 +249,8 @@ class TabsManager(MultipleInstance):
self._state.update(state)
self._search.set_items(self._get_tab_list())
return self
content_to_remove = Div(id=f"{self._id}-{tab_id}-content", hx_swap_oob=f"delete")
return self._mk_tabs_controller(), self._mk_tabs_header_wrapper(), content_to_remove
def add_tab_btn(self):
return mk.icon(tab_add24_regular,
@@ -259,11 +260,12 @@ class TabsManager(MultipleInstance):
None,
True))
def _mk_tabs_controller(self):
return Div(
Div(id=f"{self._id}-controller", data_active_tab=f"{self._state.active_tab}"),
Script(f'updateTabs("{self._id}-controller");'),
)
def _mk_tabs_controller(self, oob=False):
return Div(id=f"{self._id}-controller",
data_active_tab=f"{self._state.active_tab}",
hx_on__after_settle=f'updateTabs("{self._id}-controller");',
hx_swap_oob="true" if oob else None,
)
def _mk_tabs_header_wrapper(self, oob=False):
# Create visible tab buttons
@@ -273,24 +275,20 @@ class TabsManager(MultipleInstance):
if tab_id in self._state.tabs
]
header_content = [*visible_tab_buttons]
return Div(
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
Div(*visible_tab_buttons, cls="mf-tabs-header", id=f"{self._id}-header"),
self._mk_show_tabs_menu(),
id=f"{self._id}-header-wrapper",
cls="mf-tabs-header-wrapper",
hx_swap_oob="true" if oob else None
)
def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False):
def _mk_tab_button(self, tab_data: dict):
"""
Create a single tab button with its label and close button.
Args:
tab_id: Unique identifier for the tab
tab_data: Dictionary containing tab information (label, component_type, etc.)
in_dropdown: Whether this tab is rendered in the dropdown menu
Returns:
Button element representing the tab
@@ -308,12 +306,10 @@ class TabsManager(MultipleInstance):
command=self.commands.show_tab(tab_id)
)
extra_cls = "mf-tab-in-dropdown" if in_dropdown else ""
return Div(
tab_label,
close_btn,
cls=f"mf-tab-button {extra_cls} {'mf-tab-active' if is_active else ''}",
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
data_tab_id=tab_id,
data_manager_id=self._id
)
@@ -325,15 +321,9 @@ class TabsManager(MultipleInstance):
Returns:
Div element containing the active tab content or empty container
"""
if self._state.active_tab:
active_tab = self._state.active_tab
if active_tab in self._state._tabs_content:
tab_content = self._state._tabs_content[active_tab]
else:
content = self._get_tab_content(active_tab)
tab_content = self._mk_tab_content(active_tab, content)
self._state._tabs_content[active_tab] = tab_content
content = self._get_or_create_tab_content(self._state.active_tab)
tab_content = self._mk_tab_content(self._state.active_tab, content)
else:
tab_content = self._mk_tab_content(None, None)
@@ -344,10 +334,13 @@ class TabsManager(MultipleInstance):
)
def _mk_tab_content(self, tab_id: str, content):
if tab_id is None:
return Div("No Content", cls="mf-empty-content mf-tab-content hidden")
is_active = tab_id == self._state.active_tab
return Div(
content if content else Div("No Content", cls="mf-empty-content"),
cls=f"mf-tab-content {'hidden' if not is_active else ''}", # ← ici
cls=f"mf-tab-content {'hidden' if not is_active else ''}",
id=f"{self._id}-{tab_id}-content",
)
@@ -376,7 +369,7 @@ class TabsManager(MultipleInstance):
if not isinstance(component, BaseInstance):
return None
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
component_type = component.get_prefix()
component_id = component.get_id()
if component_id is not None:
@@ -397,7 +390,7 @@ class TabsManager(MultipleInstance):
# 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_type = component.get_prefix()
component_id = component.get_id()
# Add tab metadata to state
@@ -408,8 +401,12 @@ class TabsManager(MultipleInstance):
'component_id': component_id
}
# Add tab to order list
if tab_id not in state.tabs_order:
state.tabs_order.append(tab_id)
# Add the content
state._tabs_content[tab_id] = component
state.ns_tabs_content[tab_id] = component
# Activate tab if requested
if activate:
@@ -433,6 +430,7 @@ class TabsManager(MultipleInstance):
self._mk_tabs_controller(),
self._mk_tabs_header_wrapper(),
self._mk_tab_content_wrapper(),
Script(f'updateTabs("{self._id}-controller");'), # first time, run the script to initialize the tabs
cls="mf-tabs-manager",
id=self._id,
)

View File

@@ -110,6 +110,14 @@ class Regex(AttrPredicate):
return re.match(self.value, actual) is not None
class And(AttrPredicate):
def __init__(self, *predicates):
super().__init__(predicates)
def validate(self, actual):
return all(p.validate(actual) for p in self.value)
class ChildrenPredicate(Predicate):
"""
Predicate given as a child of an element.
@@ -791,10 +799,6 @@ def find(ft, expected):
for element in elements_to_search:
all_matches.extend(_search_tree(element, expected))
# Raise error if nothing found
if not all_matches:
raise AssertionError(f"No element found for '{expected}'")
return all_matches