2 Commits

Author SHA1 Message Date
fb57a6a81d I can persist tabmanager state 2025-11-11 23:03:52 +01:00
7f56b89e66 I can display tabsmanager 2025-11-11 21:32:33 +01:00
13 changed files with 620 additions and 30 deletions

View File

@@ -4,7 +4,9 @@ from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.helpers import Ids
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import InstancesManager
from myfasthtml.myfastapp import create_app
@@ -29,8 +31,14 @@ 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 = TabsManager(session, _id="main")
btn = mk.button("Add Tab",
command=Command("AddTab",
"Add a new tab",
tabs_manager.on_new_tab, "Tabs", Div("Content")).
htmx(target=f"#{tabs_manager.get_id()}"))
layout.set_main(tabs_manager)
layout.set_footer(btn)
return layout

View File

@@ -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;
}

View File

@@ -1,4 +1,5 @@
class BaseCommands:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()
self._id = owner.get_id()
self._prefix = owner.get_prefix()

View File

@@ -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):

View File

@@ -0,0 +1,199 @@
import uuid
from typing import Any
from fasthtml.common import Div, Button, Span
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command
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):
def show_tab(self, tab_id):
return Command(f"{self._prefix}SowTab",
"Activate or show a specific tab",
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
class TabsManager(MultipleInstance):
def __init__(self, session, _id=None):
super().__init__(session, Ids.TabsManager, _id=_id)
self._state = TabsManagerState(self)
self.commands = Commands(self)
def get_state(self):
return self._state
def on_new_tab(self, label: str, component: Any):
self.add_tab(label, component)
return self
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 show_tab(self, tab_id):
if tab_id not in self._state.tabs:
return None
self._state.active_tab = tab_id
return self
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(
mk.mk(Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"), command=self.commands.show_tab(tab_id)),
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()

View File

@@ -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"

View File

@@ -1,3 +1,6 @@
from contextlib import contextmanager
from types import SimpleNamespace
from dbengine.dbengine import DbEngine
from myfasthtml.controls.helpers import Ids
@@ -31,22 +34,28 @@ 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
self._name = name or self.__class__.__name__
self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager)
# init is possible
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)
else:
self._save_self()
self._finalize_initialization()
@contextmanager
def initializing(self):
old_state = getattr(self, "_initializing", False)
self._initializing = True
try:
yield
finally:
self._finalize_initialization()
self._initializing = old_state
def __setattr__(self, name: str, value: str):
if name.startswith("_"):
if name.startswith("_") or getattr(self, "_initializing", False):
super().__setattr__(name, value)
return
@@ -57,6 +66,53 @@ class DbObject:
super().__setattr__(name, value)
self._save_self()
def _finalize_initialization(self):
if self._db_manager.exists_entry(self._name):
props = self._db_manager.load(self._name)
self.update(props)
else:
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
# save the new state
old_state = getattr(self, "_initializing", False)
self._initializing = True
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()
self._initializing = old_state
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)

View File

@@ -17,9 +17,10 @@ class BaseInstance:
Base class for all instances (manageable by InstancesManager)
"""
def __init__(self, session: dict, _id: str, auto_register: bool = True):
def __init__(self, session: dict, prefix: str, _id: str, auto_register: bool = True):
self._session = session
self._id = _id
self._prefix = prefix
if auto_register:
InstancesManager.register(session, self)
@@ -28,6 +29,9 @@ class BaseInstance:
def get_session(self):
return self._session
def get_prefix(self):
return self._prefix
class SingleInstance(BaseInstance):
@@ -36,8 +40,7 @@ class SingleInstance(BaseInstance):
"""
def __init__(self, session: dict, prefix: str, auto_register: bool = True):
super().__init__(session, prefix, auto_register)
self._instance = None
super().__init__(session, prefix, prefix, auto_register)
class UniqueInstance(BaseInstance):
@@ -47,17 +50,18 @@ class UniqueInstance(BaseInstance):
"""
def __init__(self, session: dict, prefix: str, auto_register: bool = True):
super().__init__(session, prefix, auto_register)
self._instance = None
super().__init__(session, prefix, prefix, auto_register)
self._prefix = prefix
class MultipleInstance(BaseInstance):
"""
Base class for instances that can have multiple instances at a time.
"""
def __init__(self, session: dict, prefix: str, auto_register: bool = True):
super().__init__(session, f"{prefix}-{str(uuid.uuid4())}", auto_register)
self._instance = None
def __init__(self, session: dict, prefix: str, auto_register: bool = True, _id=None):
super().__init__(session, prefix, f"{prefix}-{_id or str(uuid.uuid4())}", auto_register)
self._prefix = prefix
class InstancesManager:
@@ -111,3 +115,7 @@ class InstancesManager:
@staticmethod
def get_auth_proxy():
return InstancesManager.get(special_session, Ids.AuthProxy)
@staticmethod
def reset():
return InstancesManager.instances.clear()

View File

@@ -90,6 +90,14 @@ class Empty(ChildrenPredicate):
return len(actual.children) == 0 and len(actual.attrs) == 0
class NoChildren(ChildrenPredicate):
def __init__(self):
super().__init__(None)
def validate(self, actual):
return len(actual.children) == 0
class AttributeForbidden(ChildrenPredicate):
"""
To validate that an attribute is not present in an element.

View 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'
}
}

View File

@@ -0,0 +1,88 @@
import pytest
from fasthtml.components import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import InstancesManager
from myfasthtml.test.matcher import matches, NoChildren
from .conftest import session
@pytest.fixture()
def tabs_manager(session):
yield TabsManager(session)
InstancesManager.reset()
class TestTabsManagerBehaviour:
def test_tabs_manager_is_registered(self, 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(self, 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
def test_i_can_add_multiple_tabs(self, tabs_manager):
tab_id1 = tabs_manager.add_tab("Users", Div("Content 1"))
tab_id2 = tabs_manager.add_tab("User2", Div("Content 2"))
assert len(tabs_manager.get_state().tabs) == 2
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id2]
assert tabs_manager.get_state().active_tab == tab_id2
class TestTabsManagerRender:
def test_i_can_render_when_no_tabs(self, tabs_manager):
res = tabs_manager.render()
expected = Div(
Div(NoChildren(), id=f"{tabs_manager.get_id()}-header"),
Div(id=f"{tabs_manager.get_id()}-content"),
id=tabs_manager.get_id(),
)
assert matches(res, expected)
def test_i_can_render_when_one_tab(self, tabs_manager):
tabs_manager.add_tab("Users", Div("Content 1"))
res = tabs_manager.render()
expected = Div(
Div(
Button(),
id=f"{tabs_manager.get_id()}-header"
),
Div(
Div("Content 1")
),
id=tabs_manager.get_id(),
)
assert matches(res, expected)
def test_i_can_render_when_multiple_tabs(self, tabs_manager):
tabs_manager.add_tab("Users1", Div("Content 1"))
tabs_manager.add_tab("Users2", Div("Content 2"))
tabs_manager.add_tab("Users3", Div("Content 3"))
res = tabs_manager.render()
expected = Div(
Div(
Button(),
Button(),
Button(),
id=f"{tabs_manager.get_id()}-header"
),
Div(
Div("Content 3")
),
id=tabs_manager.get_id(),
)
assert matches(res, expected)

View File

@@ -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,13 +60,35 @@ 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):
def test_i_can_init_from_db_with(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
# insert other values in db
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
dummy = DummyObject(session)
assert dummy.value == "other_value"
assert dummy.number == 34
def test_i_can_init_from_db_with_dataclass(session, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, sess: dict):
@@ -96,3 +138,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

View File

@@ -3,7 +3,7 @@ from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren
from myfasthtml.test.testclient import MyFT
@@ -24,6 +24,8 @@ from myfasthtml.test.testclient import MyFT
([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())),
(Div(), Div(NoChildren())),
(Div(attr1="value"), Div(NoChildren())),
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
(Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))),
@@ -54,6 +56,7 @@ def test_i_can_match(actual, expected):
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"),
(Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(), Div(Span()), "Actual is lesser than expected"),
@@ -203,6 +206,7 @@ def test_i_can_output_error_child_element():
')',
]
def test_i_can_output_error_child_element_text():
"""I can display error when the children is not a FT"""
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
@@ -217,6 +221,7 @@ def test_i_can_output_error_child_element_text():
')',
]
def test_i_can_output_error_child_element_indicating_sub_children():
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
expected = elt