I can persist tabmanager state

This commit is contained in:
2025-11-11 23:03:52 +01:00
parent 7f56b89e66
commit fb57a6a81d
9 changed files with 164 additions and 50 deletions

View File

@@ -5,7 +5,8 @@ from fasthtml.components import *
from myfasthtml.controls.Layout import Layout from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import Ids from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import InstancesManager from myfasthtml.core.instances import InstancesManager
from myfasthtml.myfastapp import create_app from myfasthtml.myfastapp import create_app
@@ -30,11 +31,14 @@ def index(session):
for i in range(1000): for i in range(1000):
layout.left_drawer.append(Div(f"Left Drawer Item {i}")) layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
tabs_manager = InstancesManager.get(session, Ids.TabsManager, TabsManager) tabs_manager = TabsManager(session, _id="main")
tabs_manager.add_tab("Users", Div("Content 1")) btn = mk.button("Add Tab",
tabs_manager.add_tab("Users", Div("Content 2")) command=Command("AddTab",
tabs_manager.add_tab("Users", Div("Content 3")) "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_main(tabs_manager)
layout.set_footer(btn)
return layout return layout

View File

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

View File

@@ -1,11 +1,11 @@
import uuid import uuid
from dataclasses import dataclass
from typing import Any from typing import Any
from fasthtml.common import Div, Button, Span from fasthtml.common import Div, Button, Span
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance, BaseInstance from myfasthtml.core.instances import MultipleInstance, BaseInstance
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular
@@ -35,18 +35,25 @@ class TabsManagerState(DbObject):
class Commands(BaseCommands): class Commands(BaseCommands):
pass 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): class TabsManager(MultipleInstance):
def __init__(self, session): def __init__(self, session, _id=None):
super().__init__(session, Ids.TabsManager) super().__init__(session, Ids.TabsManager, _id=_id)
self._state = TabsManagerState(self) self._state = TabsManagerState(self)
self._commands = Commands(self) self.commands = Commands(self)
def get_state(self): def get_state(self):
return self._state 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: 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. Add a new tab or update an existing one with the same component type, ID and label.
@@ -108,6 +115,13 @@ class TabsManager(MultipleInstance):
return tab_id 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): def _mk_tab_button(self, tab_id: str, tab_data: dict):
""" """
Create a single tab button with its label and close button. Create a single tab button with its label and close button.
@@ -122,7 +136,7 @@ class TabsManager(MultipleInstance):
is_active = tab_id == self._state.active_tab is_active = tab_id == self._state.active_tab
return Button( return Button(
Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"), 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"), Span(dismiss_circle16_regular, cls="mf-tab-close-btn"),
cls=f"mf-tab-button {'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_tab_id=tab_id,

View File

@@ -42,14 +42,7 @@ class DbObject:
self._name = name or self.__class__.__name__ self._name = name or self.__class__.__name__
self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager) self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager)
# init is possible self._finalize_initialization()
if self._db_manager.exists_entry(self._name):
props = self._db_manager.load(self._name)
for k, v in props.items():
if hasattr(self, k):
setattr(self, k, v)
else:
self._save_self()
@contextmanager @contextmanager
def initializing(self): def initializing(self):
@@ -58,8 +51,8 @@ class DbObject:
try: try:
yield yield
finally: finally:
self._finalize_initialization()
self._initializing = old_state self._initializing = old_state
self._save_self()
def __setattr__(self, name: str, value: str): def __setattr__(self, name: str, value: str):
if name.startswith("_") or getattr(self, "_initializing", False): if name.startswith("_") or getattr(self, "_initializing", False):
@@ -73,6 +66,13 @@ class DbObject:
super().__setattr__(name, value) super().__setattr__(name, value)
self._save_self() 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): def _save_self(self):
props = {k: getattr(self, k) for k, v in self._get_properties().items() if not k.startswith("_")} props = {k: getattr(self, k) for k, v in self._get_properties().items() if not k.startswith("_")}
if props: if props:
@@ -103,11 +103,14 @@ class DbObject:
properties |= kwargs properties |= kwargs
with self.initializing(): # save the new state
for k, v in properties.items(): old_state = getattr(self, "_initializing", False)
if hasattr(self, k) and k not in DbObject._forbidden_attrs: # internal variables cannot be updated self._initializing = True
setattr(self, k, v) 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._save_self()
self._initializing = old_state
def copy(self): def copy(self):
as_dict = self._get_properties().copy() as_dict = self._get_properties().copy()

View File

@@ -17,9 +17,10 @@ class BaseInstance:
Base class for all instances (manageable by InstancesManager) 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._session = session
self._id = _id self._id = _id
self._prefix = prefix
if auto_register: if auto_register:
InstancesManager.register(session, self) InstancesManager.register(session, self)
@@ -28,6 +29,9 @@ class BaseInstance:
def get_session(self): def get_session(self):
return self._session return self._session
def get_prefix(self):
return self._prefix
class SingleInstance(BaseInstance): class SingleInstance(BaseInstance):
@@ -36,8 +40,7 @@ class SingleInstance(BaseInstance):
""" """
def __init__(self, session: dict, prefix: str, auto_register: bool = True): def __init__(self, session: dict, prefix: str, auto_register: bool = True):
super().__init__(session, prefix, auto_register) super().__init__(session, prefix, prefix, auto_register)
self._instance = None
class UniqueInstance(BaseInstance): class UniqueInstance(BaseInstance):
@@ -47,8 +50,8 @@ class UniqueInstance(BaseInstance):
""" """
def __init__(self, session: dict, prefix: str, auto_register: bool = True): def __init__(self, session: dict, prefix: str, auto_register: bool = True):
super().__init__(session, prefix, auto_register) super().__init__(session, prefix, prefix, auto_register)
self._instance = None self._prefix = prefix
class MultipleInstance(BaseInstance): class MultipleInstance(BaseInstance):
@@ -56,9 +59,9 @@ class MultipleInstance(BaseInstance):
Base class for instances that can have multiple instances at a time. Base class for instances that can have multiple instances at a time.
""" """
def __init__(self, session: dict, prefix: str, auto_register: bool = True): def __init__(self, session: dict, prefix: str, auto_register: bool = True, _id=None):
super().__init__(session, f"{prefix}-{str(uuid.uuid4())}", auto_register) super().__init__(session, prefix, f"{prefix}-{_id or str(uuid.uuid4())}", auto_register)
self._instance = None self._prefix = prefix
class InstancesManager: class InstancesManager:

View File

@@ -90,6 +90,14 @@ class Empty(ChildrenPredicate):
return len(actual.children) == 0 and len(actual.attrs) == 0 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): class AttributeForbidden(ChildrenPredicate):
""" """
To validate that an attribute is not present in an element. To validate that an attribute is not present in an element.

View File

@@ -3,6 +3,7 @@ from fasthtml.components import *
from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import InstancesManager from myfasthtml.core.instances import InstancesManager
from myfasthtml.test.matcher import matches, NoChildren
from .conftest import session from .conftest import session
@@ -13,18 +14,75 @@ def tabs_manager(session):
InstancesManager.reset() InstancesManager.reset()
def test_tabs_manager_is_registered(session, tabs_manager): class TestTabsManagerBehaviour:
from_instance_manager = InstancesManager.get(session, tabs_manager.get_id()) def test_tabs_manager_is_registered(self, session, tabs_manager):
assert from_instance_manager == 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 def test_i_can_add_tab(self, tabs_manager):
assert tab_id in tabs_manager.get_state().tabs tab_id = tabs_manager.add_tab("Users", Div("Content 1"))
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 tab_id is not None
assert tabs_manager.get_state().tabs[tab_id]["component_id"] is None # Div is not BaseInstance assert tab_id in tabs_manager.get_state().tabs
assert tabs_manager.get_state().tabs_order == [tab_id] assert tabs_manager.get_state().tabs[tab_id]["label"] == "Users"
assert tabs_manager.get_state().active_tab == tab_id 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

@@ -70,7 +70,25 @@ def test_i_can_init_from_dataclass(session, db_manager):
assert len(history) == 1 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 @dataclass
class DummyObject(DbObject): class DummyObject(DbObject):
def __init__(self, sess: dict): def __init__(self, sess: dict):

View File

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