From 7f56b89e66f7f80d51f3b7f67de3865da5a425bc Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Tue, 11 Nov 2025 21:32:33 +0100 Subject: [PATCH] I can display tabsmanager --- src/app.py | 8 +- src/myfasthtml/assets/myfasthtml.css | 86 +++++++++++- src/myfasthtml/controls/Layout.py | 7 +- src/myfasthtml/controls/TabsManager.py | 185 +++++++++++++++++++++++++ src/myfasthtml/controls/helpers.py | 2 + src/myfasthtml/core/dbmanager.py | 61 +++++++- src/myfasthtml/core/instances.py | 5 + tests/controls/conftest.py | 16 +++ tests/controls/test_tabsmanager.py | 30 ++++ tests/core/test_db_object.py | 100 ++++++++++++- 10 files changed, 488 insertions(+), 12 deletions(-) create mode 100644 src/myfasthtml/controls/TabsManager.py create mode 100644 tests/controls/conftest.py create mode 100644 tests/controls/test_tabsmanager.py diff --git a/src/app.py b/src/app.py index cbb8490..cc2b85e 100644 --- a/src/app.py +++ b/src/app.py @@ -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 diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index 01aab0d..3bd2bbf 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -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; } \ No newline at end of file diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py index 76a4b96..efb1e30 100644 --- a/src/myfasthtml/controls/Layout.py +++ b/src/myfasthtml/controls/Layout.py @@ -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): diff --git a/src/myfasthtml/controls/TabsManager.py b/src/myfasthtml/controls/TabsManager.py new file mode 100644 index 0000000..3635870 --- /dev/null +++ b/src/myfasthtml/controls/TabsManager.py @@ -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() diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index 186cfcd..9b58c17 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -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" diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py index 61772d7..7987161 100644 --- a/src/myfasthtml/core/dbmanager.py +++ b/src/myfasthtml/core/dbmanager.py @@ -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) diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index f3f1155..dee9474 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -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() diff --git a/tests/controls/conftest.py b/tests/controls/conftest.py new file mode 100644 index 0000000..fdc9e33 --- /dev/null +++ b/tests/controls/conftest.py @@ -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' + } + } diff --git a/tests/controls/test_tabsmanager.py b/tests/controls/test_tabsmanager.py new file mode 100644 index 0000000..de054f4 --- /dev/null +++ b/tests/controls/test_tabsmanager.py @@ -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 diff --git a/tests/core/test_db_object.py b/tests/core/test_db_object.py index 1f3d606..4209448 100644 --- a/tests/core/test_db_object.py +++ b/tests/core/test_db_object.py @@ -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