Implemented lazy loading

This commit is contained in:
2026-01-11 15:49:20 +01:00
parent a9eb23ad76
commit 5d6c02001e
8 changed files with 151 additions and 116 deletions

View File

@@ -965,7 +965,7 @@ input:focus {
flex-direction: column; flex-direction: column;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 10px; border-radius: 10px;
overflow: hidden; /* overflow: hidden; */
} }
.dt2-table:focus { .dt2-table:focus {

View File

@@ -11,6 +11,7 @@ from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
@@ -25,14 +26,14 @@ _HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']')
@lru_cache(maxsize=2) @lru_cache(maxsize=2)
def _mk_bool_cached(_value): def _mk_bool_cached(_value):
""" """
OPTIMIZED: Cached boolean checkbox HTML generator. OPTIMIZED: Cached boolean checkbox HTML generator.
Since there are only 2 possible values (True/False), this will only generate HTML twice. Since there are only 2 possible values (True/False), this will only generate HTML twice.
""" """
return NotStr(str( return NotStr(str(
Div(mk.icon(checkbox_checked16_regular if _value else checkbox_unchecked16_regular, can_select=False), Div(mk.icon(checkbox_checked16_regular if _value else checkbox_unchecked16_regular, can_select=False),
cls="dt2-cell-content-checkbox") cls="dt2-cell-content-checkbox")
)) ))
class DatagridState(DbObject): class DatagridState(DbObject):
@@ -41,7 +42,7 @@ class DatagridState(DbObject):
with self.initializing(): with self.initializing():
self.sidebar_visible: bool = False self.sidebar_visible: bool = False
self.selected_view: str = None self.selected_view: str = None
self.row_index: bool = False self.row_index: bool = True
self.columns: list[DataGridColumnState] = [] self.columns: list[DataGridColumnState] = []
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
self.headers: list[DataGridHeaderFooterConf] = [] self.headers: list[DataGridHeaderFooterConf] = []
@@ -70,7 +71,17 @@ class DatagridSettings(DbObject):
class Commands(BaseCommands): class Commands(BaseCommands):
pass def get_page(self, page_index: int):
return Command("GetPage",
"Get a specific page of data",
self._owner,
self._owner.mk_body_content_page,
kwargs={"page_index": page_index}
).htmx(target=f"#tb_{self._id}",
swap="beforeend",
trigger=f"intersect root:#tb_{self._id} once",
auto_swap_oob=False
)
class DataGrid(MultipleInstance): class DataGrid(MultipleInstance):
@@ -253,7 +264,6 @@ class DataGrid(MultipleInstance):
else: else:
last_row = None last_row = None
# OPTIMIZATION: Extract filter keyword once (was being checked 10,000 times)
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID) filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
@@ -262,7 +272,8 @@ class DataGrid(MultipleInstance):
for col_pos, col_def in enumerate(self._state.columns)], for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row", cls="dt2-row",
data_row=f"{row_index}", data_row=f"{row_index}",
_id=f"tr_{self._id}-{row_index}", id=f"tr_{self._id}-{row_index}",
**self.commands.get_page(page_index + 1).get_htmx_params(escaped=True) if row_index == last_row else {}
) for row_index in df.index[start:end]] ) for row_index in df.index[start:end]]
return rows return rows
@@ -290,7 +301,9 @@ class DataGrid(MultipleInstance):
return Div( return Div(
self.mk_headers(), self.mk_headers(),
self.mk_body(), self.mk_body(),
self.mk_footers() self.mk_footers(),
cls="dt2-table",
id=f"t_{self._id}",
) )
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False): def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):

View File

@@ -1,5 +1,6 @@
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from io import BytesIO
import pandas as pd import pandas as pd
from fasthtml.components import Div from fasthtml.components import Div
@@ -90,7 +91,7 @@ class DataGridsManager(MultipleInstance):
def open_from_excel(self, tab_id, file_upload: FileUpload): def open_from_excel(self, tab_id, file_upload: FileUpload):
excel_content = file_upload.get_content() excel_content = file_upload.get_content()
df = pd.read_excel(excel_content, file_upload.get_sheet_name()) df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name())
dg = DataGrid(self._tabs_manager, save_state=True) dg = DataGrid(self._tabs_manager, save_state=True)
dg.init_from_dataframe(df) dg.init_from_dataframe(df)
document = DocumentDefinition( document = DocumentDefinition(

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from myfasthtml.core.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType from myfasthtml.core.constants import ColumnType, DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType
@dataclass @dataclass
@@ -17,7 +17,7 @@ class DataGridColumnState:
type: ColumnType = ColumnType.Text type: ColumnType = ColumnType.Text
visible: bool = True visible: bool = True
usable: bool = True usable: bool = True
width: int = DEFAULT_COLUMN_WIDTH width: int = DATAGRID_DEFAULT_COLUMN_WIDTH
@dataclass @dataclass

View File

@@ -1,3 +1,4 @@
import html
import inspect import inspect
import json import json
import logging import logging
@@ -11,6 +12,7 @@ from myfasthtml.core.utils import flatten
logger = logging.getLogger("Commands") logger = logging.getLogger("Commands")
AUTO_SWAP_OOB = "__auto_swap_oob__"
class Command: class Command:
""" """
@@ -71,7 +73,7 @@ class Command:
self.callback = callback self.callback = callback
self.default_args = args or [] self.default_args = args or []
self.default_kwargs = kwargs or {} self.default_kwargs = kwargs or {}
self._htmx_extra = {} self._htmx_extra = {AUTO_SWAP_OOB: True}
self._bindings = [] self._bindings = []
self._ft = None self._ft = None
self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {} self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
@@ -97,7 +99,7 @@ class Command:
def get_key(self): def get_key(self):
return self._key return self._key
def get_htmx_params(self): def get_htmx_params(self, escaped=False):
res = { res = {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-swap": "outerHTML", "hx-swap": "outerHTML",
@@ -115,10 +117,13 @@ class Command:
# kwarg are given to the callback as values # kwarg are given to the callback as values
res["hx-vals"] |= self.default_kwargs res["hx-vals"] |= self.default_kwargs
if escaped:
res["hx-vals"] = html.escape(json.dumps(res["hx-vals"]))
return res return res
def execute(self, client_response: dict = None): def execute(self, client_response: dict = None):
logger.debug(f"Executing command {self.name}") logger.debug(f"Executing command {self.name} with arguments {client_response=}")
with ObservableResultCollector(self._bindings) as collector: with ObservableResultCollector(self._bindings) as collector:
kwargs = self._create_kwargs(self.default_kwargs, kwargs = self._create_kwargs(self.default_kwargs,
client_response, client_response,
@@ -135,15 +140,18 @@ class Command:
all_ret = flatten(ret, ret_from_bound_commands, collector.results) all_ret = flatten(ret, ret_from_bound_commands, collector.results)
# Set the hx-swap-oob attribute on all elements returned by the callback # Set the hx-swap-oob attribute on all elements returned by the callback
for r in all_ret[1:]: if self._htmx_extra[AUTO_SWAP_OOB]:
if (hasattr(r, 'attrs') for r in all_ret[1:]:
and "hx-swap-oob" not in r.attrs if (hasattr(r, 'attrs')
and r.get("id", None) is not None): and "hx-swap-oob" not in r.attrs
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true") and r.get("id", None) is not None):
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
return all_ret[0] if len(all_ret) == 1 else all_ret return all_ret[0] if len(all_ret) == 1 else all_ret
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None): def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None, auto_swap_oob=True):
self._htmx_extra[AUTO_SWAP_OOB] = auto_swap_oob
# Note that the default value is the same than in get_htmx_params() # Note that the default value is the same than in get_htmx_params()
if target is None: if target is None:
self._htmx_extra["hx-swap"] = "none" self._htmx_extra["hx-swap"] = "none"

View File

@@ -1,14 +1,16 @@
from enum import Enum from enum import Enum
DEFAULT_COLUMN_WIDTH = 100 NO_DEFAULT_VALUE = object()
ROUTE_ROOT = "/myfasthtml" ROUTE_ROOT = "/myfasthtml"
# Datagrid # Datagrid
ROW_INDEX_ID = "__row_index__" ROW_INDEX_ID = "__row_index__"
DATAGRID_DEFAULT_COLUMN_WIDTH = 100
DATAGRID_PAGE_SIZE = 1000 DATAGRID_PAGE_SIZE = 1000
FILTER_INPUT_CID = "__filter_input__" FILTER_INPUT_CID = "__filter_input__"
class Routes: class Routes:
Commands = "/commands" Commands = "/commands"
Bindings = "/bindings" Bindings = "/bindings"

View File

@@ -3,6 +3,7 @@ import uuid
from typing import Optional from typing import Optional
from myfasthtml.controls.helpers import Ids from myfasthtml.controls.helpers import Ids
from myfasthtml.core.constants import NO_DEFAULT_VALUE
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
logger = logging.getLogger("InstancesManager") logger = logging.getLogger("InstancesManager")
@@ -183,7 +184,7 @@ class InstancesManager:
return instance return instance
@staticmethod @staticmethod
def get(session: dict, instance_id: str, default="**__no_default__**"): def get(session: dict, instance_id: str, default=NO_DEFAULT_VALUE):
""" """
Get or create an instance of the given type (from its id) Get or create an instance of the given type (from its id)
:param session: :param session:
@@ -196,9 +197,9 @@ class InstancesManager:
key = (session_id, instance_id) key = (session_id, instance_id)
return InstancesManager.instances[key] return InstancesManager.instances[key]
except KeyError: except KeyError:
if default != "**__non__**": if default is NO_DEFAULT_VALUE:
return default raise
raise return default
@staticmethod @staticmethod
def get_by_type(session: dict, cls: type): def get_by_type(session: dict, cls: type):

View File

@@ -9,74 +9,84 @@ from functools import lru_cache
from fasthtml.common import NotStr from fasthtml.common import NotStr
from myfasthtml.core.constants import NO_DEFAULT_VALUE
class OptimizedFt: class OptimizedFt:
"""Lightweight FastHTML-compatible element that generates HTML directly.""" """Lightweight FastHTML-compatible element that generates HTML directly."""
ATTR_MAP = { ATTR_MAP = {
"cls": "class", "cls": "class",
"_id": "id", "_id": "id",
} }
def __init__(self, tag, *args, **kwargs): def __init__(self, tag, *args, **kwargs):
self.tag = tag self.tag = tag
self.children = args self.children = args
self.attrs = {self.safe_attr(k): v for k, v in kwargs.items() if v is not None} self.attrs = {self.safe_attr(k): v for k, v in kwargs.items() if v is not None}
@staticmethod @staticmethod
@lru_cache(maxsize=128) @lru_cache(maxsize=128)
def safe_attr(attr_name): def safe_attr(attr_name):
"""Convert Python attribute names to HTML attribute names.""" """Convert Python attribute names to HTML attribute names."""
attr_name = attr_name.replace("hx_", "hx-") attr_name = attr_name.replace("hx_", "hx-")
attr_name = attr_name.replace("data_", "data-") attr_name = attr_name.replace("data_", "data-")
return OptimizedFt.ATTR_MAP.get(attr_name, attr_name) return OptimizedFt.ATTR_MAP.get(attr_name, attr_name)
@staticmethod @staticmethod
def to_html_helper(item): def to_html_helper(item):
"""Convert any item to HTML string.""" """Convert any item to HTML string."""
if item is None: if item is None:
return "" return ""
elif isinstance(item, str): elif isinstance(item, str):
return item return item
elif isinstance(item, (int, float, bool)): elif isinstance(item, (int, float, bool)):
return str(item) return str(item)
elif isinstance(item, OptimizedFt): elif isinstance(item, OptimizedFt):
return item.to_html() return item.to_html()
elif isinstance(item, NotStr): elif isinstance(item, NotStr):
return str(item) return str(item)
else: else:
raise Exception(f"Unsupported type: {type(item)}, {item=}") raise Exception(f"Unsupported type: {type(item)}, {item=}")
def to_html(self): def to_html(self):
"""Generate HTML string.""" """Generate HTML string."""
# Build attributes # Build attributes
attrs_list = [] attrs_list = []
for k, v in self.attrs.items(): for k, v in self.attrs.items():
if v is False: if v is False:
continue # Skip False attributes continue # Skip False attributes
if v is True: if v is True:
attrs_list.append(k) # Boolean attribute attrs_list.append(k) # Boolean attribute
else: else:
# No need to escape v since we control the values (width, IDs, etc.) # No need to escape v since we control the values (width, IDs, etc.)
attrs_list.append(f'{k}="{v}"') attrs_list.append(f'{k}="{v}"')
attrs_str = ' ' + ' '.join(attrs_list) if attrs_list else '' attrs_str = ' ' + ' '.join(attrs_list) if attrs_list else ''
# Build children HTML # Build children HTML
children_html = ''.join(self.to_html_helper(child) for child in self.children) children_html = ''.join(self.to_html_helper(child) for child in self.children)
return f'<{self.tag}{attrs_str}>{children_html}</{self.tag}>' return f'<{self.tag}{attrs_str}>{children_html}</{self.tag}>'
def __ft__(self): def __ft__(self):
"""FastHTML compatibility - returns NotStr to avoid double escaping.""" """FastHTML compatibility - returns NotStr to avoid double escaping."""
return NotStr(self.to_html()) return NotStr(self.to_html())
def __str__(self): def __str__(self):
return self.to_html() return self.to_html()
def get(self, attr_name, default=NO_DEFAULT_VALUE):
try:
return self.attrs[self.safe_attr(attr_name)]
except KeyError:
if default is NO_DEFAULT_VALUE:
raise
return default
class OptimizedDiv(OptimizedFt): class OptimizedDiv(OptimizedFt):
"""Optimized Div element.""" """Optimized Div element."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__("div", *args, **kwargs) super().__init__("div", *args, **kwargs)