Compare commits
2 Commits
cba4f2aab4
...
fb57a6a81d
| Author | SHA1 | Date | |
|---|---|---|---|
| fb57a6a81d | |||
| 7f56b89e66 |
14
src/app.py
14
src/app.py
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
199
src/myfasthtml/controls/TabsManager.py
Normal file
199
src/myfasthtml/controls/TabsManager.py
Normal 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()
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
88
tests/controls/test_tabsmanager.py
Normal file
88
tests/controls/test_tabsmanager.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user