I can display tabsmanager
This commit is contained in:
@@ -4,6 +4,7 @@ from fasthtml import serve
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
from myfasthtml.myfastapp import create_app
|
||||
@@ -29,8 +30,11 @@ def index(session):
|
||||
for i in range(1000):
|
||||
layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
|
||||
|
||||
content = tuple([Div(f"Content {i}") for i in range(1000)])
|
||||
layout.set_main(content)
|
||||
tabs_manager = InstancesManager.get(session, Ids.TabsManager, TabsManager)
|
||||
tabs_manager.add_tab("Users", Div("Content 1"))
|
||||
tabs_manager.add_tab("Users", Div("Content 2"))
|
||||
tabs_manager.add_tab("Users", Div("Content 3"))
|
||||
layout.set_main(tabs_manager)
|
||||
return layout
|
||||
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 1rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
@@ -169,4 +169,88 @@
|
||||
"main"
|
||||
"footer";
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Tabs Manager Container */
|
||||
.mf-tabs-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--color-base-200);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
/* Tabs Header using DaisyUI tabs component */
|
||||
.mf-tabs-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
/* Individual Tab Button using DaisyUI tab classes */
|
||||
.mf-tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem 0 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-tab-button:hover {
|
||||
color: var(--color-base-content); /* Change text color on hover */
|
||||
}
|
||||
|
||||
.mf-tab-button.mf-tab-active {
|
||||
--depth: 1;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
border-radius: .25rem;
|
||||
box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
|
||||
}
|
||||
|
||||
/* Tab Label */
|
||||
.mf-tab-label {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
/* Tab Close Button */
|
||||
.mf-tab-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
@apply text-base-content/50;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-tab-close-btn:hover {
|
||||
@apply bg-base-300 text-error;
|
||||
}
|
||||
|
||||
/* Tab Content Area */
|
||||
.mf-tab-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background-color: var(--color-base-100);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Empty Content State */
|
||||
.mf-empty-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
@apply text-base-content/50;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -21,13 +21,12 @@ from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_dra
|
||||
logger = logging.getLogger("LayoutControl")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayoutState(DbObject):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner.get_session(), owner.get_id())
|
||||
|
||||
left_drawer_open: bool = True
|
||||
right_drawer_open: bool = False
|
||||
with self.initializing():
|
||||
self.left_drawer_open: bool = True
|
||||
self.right_drawer_open: bool = False
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
|
||||
185
src/myfasthtml/controls/TabsManager.py
Normal file
185
src/myfasthtml/controls/TabsManager.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import Div, Button, Span
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
||||
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular
|
||||
|
||||
|
||||
# Structure de tabs:
|
||||
# {
|
||||
# "tab-uuid-1": {
|
||||
# "label": "Users",
|
||||
# "component_type": "UsersPanel",
|
||||
# "component_id": "UsersPanel-abc123",
|
||||
# },
|
||||
# "tab-uuid-2": { ... }
|
||||
# }
|
||||
|
||||
class TabsManagerState(DbObject):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner.get_session(), owner.get_id())
|
||||
with self.initializing():
|
||||
# persisted in DB
|
||||
self.tabs: dict[str, Any] = {}
|
||||
self.tabs_order: list[str] = []
|
||||
self.active_tab: str | None = None
|
||||
|
||||
# must not be persisted in DB
|
||||
self._tabs_content: dict[str, Any] = {}
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
pass
|
||||
|
||||
|
||||
class TabsManager(MultipleInstance):
|
||||
def __init__(self, session):
|
||||
super().__init__(session, Ids.TabsManager)
|
||||
self._state = TabsManagerState(self)
|
||||
self._commands = Commands(self)
|
||||
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def add_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.
|
||||
|
||||
Args:
|
||||
label: Display label for the tab
|
||||
component: Component instance to display in the tab
|
||||
activate: Whether to activate the new/updated tab immediately (default: True)
|
||||
|
||||
Returns:
|
||||
tab_id: The UUID of the tab (new or existing)
|
||||
"""
|
||||
# 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 = 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 = None
|
||||
if component_id is not None:
|
||||
for tab_id, tab_data in state.tabs.items():
|
||||
if (tab_data.get('component_type') == component_type and
|
||||
tab_data.get('component_id') == component_id and
|
||||
tab_data.get('label') == label):
|
||||
existing_tab_id = tab_id
|
||||
break
|
||||
|
||||
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] = {
|
||||
'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)
|
||||
|
||||
return tab_id
|
||||
|
||||
def _mk_tab_button(self, tab_id: str, 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.)
|
||||
|
||||
Returns:
|
||||
Button element representing the tab
|
||||
"""
|
||||
is_active = tab_id == self._state.active_tab
|
||||
|
||||
return Button(
|
||||
Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"),
|
||||
Span(dismiss_circle16_regular, cls="mf-tab-close-btn"),
|
||||
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
|
||||
data_tab_id=tab_id,
|
||||
data_manager_id=self._id
|
||||
)
|
||||
|
||||
def _mk_tabs_header(self):
|
||||
"""
|
||||
Create the tabs header containing all tab buttons.
|
||||
|
||||
Returns:
|
||||
Div element containing all tab buttons
|
||||
"""
|
||||
tab_buttons = [
|
||||
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
|
||||
for tab_id in self._state.tabs_order
|
||||
if tab_id in self._state.tabs
|
||||
]
|
||||
|
||||
return Div(
|
||||
*tab_buttons,
|
||||
cls="mf-tabs-header",
|
||||
id=f"{self._id}-header"
|
||||
)
|
||||
|
||||
def _mk_tab_content(self):
|
||||
"""
|
||||
Create the active tab content area.
|
||||
|
||||
Returns:
|
||||
Div element containing the active tab content or empty container
|
||||
"""
|
||||
content = None
|
||||
|
||||
if self._state.active_tab and self._state.active_tab in self._state._tabs_content:
|
||||
component = self._state._tabs_content[self._state.active_tab]
|
||||
content = component
|
||||
|
||||
return Div(
|
||||
content if content else Div("No active tab", cls="mf-empty-content"),
|
||||
cls="mf-tab-content",
|
||||
id=f"{self._id}-content"
|
||||
)
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the complete TabsManager component.
|
||||
|
||||
Returns:
|
||||
Div element containing tabs header and content area
|
||||
"""
|
||||
return Div(
|
||||
self._mk_tabs_header(),
|
||||
self._mk_tab_content(),
|
||||
cls="mf-tabs-manager",
|
||||
id=self._id
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -6,9 +6,11 @@ from myfasthtml.core.utils import merge_classes
|
||||
|
||||
|
||||
class Ids:
|
||||
# Please keep the alphabetical order
|
||||
AuthProxy = "mf-auth-proxy"
|
||||
DbManager = "mf-dbmanager"
|
||||
Layout = "mf-layout"
|
||||
TabsManager = "mf-tabs-manager"
|
||||
UserProfile = "mf-user-profile"
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
from dbengine.dbengine import DbEngine
|
||||
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
@@ -31,6 +34,8 @@ class DbObject:
|
||||
When you set the attribute, it persists in DB
|
||||
It loads from DB at startup
|
||||
"""
|
||||
_initializing = False
|
||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_session", "_forbidden_attrs"}
|
||||
|
||||
def __init__(self, session, name=None, db_manager=None):
|
||||
self._session = session
|
||||
@@ -41,12 +46,23 @@ class DbObject:
|
||||
if self._db_manager.exists_entry(self._name):
|
||||
props = self._db_manager.load(self._name)
|
||||
for k, v in props.items():
|
||||
setattr(self, k, v)
|
||||
if hasattr(self, k):
|
||||
setattr(self, k, v)
|
||||
else:
|
||||
self._save_self()
|
||||
|
||||
@contextmanager
|
||||
def initializing(self):
|
||||
old_state = getattr(self, "_initializing", False)
|
||||
self._initializing = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._initializing = old_state
|
||||
self._save_self()
|
||||
|
||||
def __setattr__(self, name: str, value: str):
|
||||
if name.startswith("_"):
|
||||
if name.startswith("_") or getattr(self, "_initializing", False):
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
@@ -58,5 +74,42 @@ class DbObject:
|
||||
self._save_self()
|
||||
|
||||
def _save_self(self):
|
||||
props = {k: getattr(self, k) for k, v in self.__class__.__dict__.items() if not k.startswith("_")}
|
||||
self._db_manager.save(self._name, props)
|
||||
props = {k: getattr(self, k) for k, v in self._get_properties().items() if not k.startswith("_")}
|
||||
if props:
|
||||
self._db_manager.save(self._name, props)
|
||||
|
||||
def _get_properties(self):
|
||||
"""
|
||||
Retrieves all the properties of the current object, combining both the properties defined in
|
||||
the class and the instance attributes.
|
||||
:return: A dictionary containing the properties of the object, where keys are property names
|
||||
and values are their corresponding values.
|
||||
"""
|
||||
props = {k: getattr(self, k) for k, v in self.__class__.__dict__.items()} # for dataclass
|
||||
props |= {k: getattr(self, k) for k, v in self.__dict__.items()} # for dataclass
|
||||
props = {k: v for k, v in props.items() if not k.startswith("__")}
|
||||
return props
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
if len(args) > 1:
|
||||
raise ValueError("Only one argument is allowed")
|
||||
|
||||
properties = {}
|
||||
if args:
|
||||
arg = args[0]
|
||||
if not isinstance(arg, (dict, SimpleNamespace)):
|
||||
raise ValueError("Only dict or Expando are allowed as argument")
|
||||
properties |= vars(arg) if isinstance(arg, SimpleNamespace) else arg
|
||||
|
||||
properties |= kwargs
|
||||
|
||||
with self.initializing():
|
||||
for k, v in properties.items():
|
||||
if hasattr(self, k) and k not in DbObject._forbidden_attrs: # internal variables cannot be updated
|
||||
setattr(self, k, v)
|
||||
self._save_self()
|
||||
|
||||
def copy(self):
|
||||
as_dict = self._get_properties().copy()
|
||||
as_dict = {k: v for k, v in as_dict.items() if k not in DbObject._forbidden_attrs}
|
||||
return SimpleNamespace(**as_dict)
|
||||
|
||||
@@ -50,6 +50,7 @@ class UniqueInstance(BaseInstance):
|
||||
super().__init__(session, prefix, auto_register)
|
||||
self._instance = None
|
||||
|
||||
|
||||
class MultipleInstance(BaseInstance):
|
||||
"""
|
||||
Base class for instances that can have multiple instances at a time.
|
||||
@@ -111,3 +112,7 @@ class InstancesManager:
|
||||
@staticmethod
|
||||
def get_auth_proxy():
|
||||
return InstancesManager.get(special_session, Ids.AuthProxy)
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
return InstancesManager.instances.clear()
|
||||
|
||||
16
tests/controls/conftest.py
Normal file
16
tests/controls/conftest.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session():
|
||||
return {
|
||||
"user_info": {
|
||||
'email': 'test@myfasthtml.com',
|
||||
'username': 'test_user',
|
||||
'roles': ['admin'],
|
||||
'user_settings': {},
|
||||
'id': 'test_user_id',
|
||||
'created_at': '2025-11-10T15:52:59.006213',
|
||||
'updated_at': '2025-11-10T15:52:59.006213'
|
||||
}
|
||||
}
|
||||
30
tests/controls/test_tabsmanager.py
Normal file
30
tests/controls/test_tabsmanager.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
from .conftest import session
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tabs_manager(session):
|
||||
yield TabsManager(session)
|
||||
|
||||
InstancesManager.reset()
|
||||
|
||||
|
||||
def test_tabs_manager_is_registered(session, tabs_manager):
|
||||
from_instance_manager = InstancesManager.get(session, tabs_manager.get_id())
|
||||
assert from_instance_manager == tabs_manager
|
||||
|
||||
|
||||
def test_i_can_add_tab(tabs_manager):
|
||||
tab_id = tabs_manager.add_tab("Users", Div("Content 1"))
|
||||
|
||||
assert tab_id is not None
|
||||
assert tab_id in tabs_manager.get_state().tabs
|
||||
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Users"
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component_type"] is None # Div is not BaseInstance
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component_id"] is None # Div is not BaseInstance
|
||||
assert tabs_manager.get_state().tabs_order == [tab_id]
|
||||
assert tabs_manager.get_state().active_tab == tab_id
|
||||
@@ -33,6 +33,26 @@ def simplify(res: dict) -> dict:
|
||||
|
||||
|
||||
def test_i_can_init(session, db_manager):
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
with self.initializing():
|
||||
self.value: str = "hello"
|
||||
self.number: int = 42
|
||||
self.none_value: None = None
|
||||
|
||||
dummy = DummyObject(session)
|
||||
|
||||
props = dummy._get_properties()
|
||||
|
||||
in_db = db_manager.load("DummyObject")
|
||||
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||
assert simplify(in_db) == {"value": "hello", "number": 42, "none_value": None}
|
||||
assert len(history) == 1
|
||||
|
||||
|
||||
def test_i_can_init_from_dataclass(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, sess: dict):
|
||||
@@ -40,10 +60,14 @@ def test_i_can_init(session, db_manager):
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
none_value: None = None
|
||||
|
||||
DummyObject(session)
|
||||
|
||||
assert simplify(db_manager.load("DummyObject")) == {"value": "hello", "number": 42}
|
||||
in_db = db_manager.load("DummyObject")
|
||||
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||
assert simplify(in_db) == {"value": "hello", "number": 42, "none_value": None}
|
||||
assert len(history) == 1
|
||||
|
||||
|
||||
def test_i_can_init_from_db(session, db_manager):
|
||||
@@ -96,3 +120,77 @@ def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
|
||||
in_db_2 = db_manager.load("DummyObject")
|
||||
|
||||
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
||||
|
||||
|
||||
def test_i_can_update(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
|
||||
dummy = DummyObject(session)
|
||||
clone = dummy.copy()
|
||||
|
||||
clone.number = 34
|
||||
clone.value = "other_value"
|
||||
clone.other_attr = "some_value"
|
||||
|
||||
dummy.update(clone)
|
||||
|
||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
||||
|
||||
|
||||
def test_forbidden_attributes_are_not_the_copy(session, db_manager):
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
with self.initializing():
|
||||
self.value: str = "hello"
|
||||
self.number: int = 42
|
||||
self.none_value: None = None
|
||||
|
||||
dummy = DummyObject(session)
|
||||
|
||||
clone = dummy.copy()
|
||||
|
||||
for k in DbObject._forbidden_attrs:
|
||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||
|
||||
|
||||
def test_forbidden_attributes_are_not_the_copy_for_dataclass(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
none_value: None = None
|
||||
|
||||
dummy = DummyObject(session)
|
||||
|
||||
clone = dummy.copy()
|
||||
|
||||
for k in DbObject._forbidden_attrs:
|
||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||
|
||||
|
||||
def test_i_cannot_update_a_forbidden_attribute(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
none_value: None = None
|
||||
|
||||
dummy = DummyObject(session)
|
||||
|
||||
dummy.update(_session="other_value")
|
||||
|
||||
assert dummy._session == session
|
||||
|
||||
Reference in New Issue
Block a user