TabsManager.py Added unit tests + documentation
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user