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):
@@ -175,18 +186,18 @@ class DataGrid(MultipleInstance):
- Avoids html.escape when not necessary - Avoids html.escape when not necessary
- Uses cached boolean HTML (_mk_bool_cached) - Uses cached boolean HTML (_mk_bool_cached)
""" """
def mk_highlighted_text(value_str, css_class): def mk_highlighted_text(value_str, css_class):
"""Return highlighted text as raw HTML string or tuple of Spans.""" """Return highlighted text as raw HTML string or tuple of Spans."""
if not filter_keyword_lower: if not filter_keyword_lower:
# OPTIMIZATION: Return plain HTML string instead of Label object # OPTIMIZATION: Return plain HTML string instead of Label object
# Include "truncate text-sm" to match mk.label() behavior (ellipsis + font size) # Include "truncate text-sm" to match mk.label() behavior (ellipsis + font size)
return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>') return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>')
index = value_str.lower().find(filter_keyword_lower) index = value_str.lower().find(filter_keyword_lower)
if index < 0: if index < 0:
return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>') return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>')
# Has highlighting - need to use Span objects # Has highlighting - need to use Span objects
# Add "truncate text-sm" to match mk.label() behavior # Add "truncate text-sm" to match mk.label() behavior
len_keyword = len(filter_keyword_lower) len_keyword = len(filter_keyword_lower)
@@ -197,25 +208,25 @@ class DataGrid(MultipleInstance):
if index + len_keyword < len(value_str): if index + len_keyword < len(value_str):
res.append(Span(value_str[index + len_keyword:], cls=f"{css_class} text-sm")) res.append(Span(value_str[index + len_keyword:], cls=f"{css_class} text-sm"))
return Span(*res, cls=f"{css_class} truncate") if len(res) > 1 else res[0] return Span(*res, cls=f"{css_class} truncate") if len(res) > 1 else res[0]
column_type = col_def.type column_type = col_def.type
value = self._state.ns_fast_access[col_def.col_id][row_index] value = self._state.ns_fast_access[col_def.col_id][row_index]
# Boolean type - uses cached HTML (only 2 possible values) # Boolean type - uses cached HTML (only 2 possible values)
if column_type == ColumnType.Bool: if column_type == ColumnType.Bool:
return _mk_bool_cached(value) return _mk_bool_cached(value)
# RowIndex - simplest case, just return the number as plain HTML # RowIndex - simplest case, just return the number as plain HTML
if column_type == ColumnType.RowIndex: if column_type == ColumnType.RowIndex:
return NotStr(f'<span class="dt2-cell-content-number truncate text-sm">{row_index}</span>') return NotStr(f'<span class="dt2-cell-content-number truncate text-sm">{row_index}</span>')
# Convert value to string # Convert value to string
value_str = str(value) value_str = str(value)
# OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex) # OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex)
if _HTML_SPECIAL_CHARS_REGEX.search(value_str): if _HTML_SPECIAL_CHARS_REGEX.search(value_str):
value_str = html.escape(value_str) value_str = html.escape(value_str)
# Number or Text type # Number or Text type
if column_type == ColumnType.Number: if column_type == ColumnType.Number:
return mk_highlighted_text(value_str, "dt2-cell-content-number") return mk_highlighted_text(value_str, "dt2-cell-content-number")
@@ -229,12 +240,12 @@ class DataGrid(MultipleInstance):
""" """
if not col_def.usable: if not col_def.usable:
return None return None
if not col_def.visible: if not col_def.visible:
return OptimizedDiv(cls="dt2-col-hidden") return OptimizedDiv(cls="dt2-col-hidden")
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower) content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
return OptimizedDiv(content, return OptimizedDiv(content,
data_col=col_def.col_id, data_col=col_def.col_id,
style=f"width:{col_def.width}px;", style=f"width:{col_def.width}px;",
@@ -252,19 +263,19 @@ class DataGrid(MultipleInstance):
last_row = df.index[end - 1] last_row = df.index[end - 1]
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
rows = [OptimizedDiv( rows = [OptimizedDiv(
*[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower) *[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower)
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
def mk_body(self): def mk_body(self):
@@ -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):
@@ -211,19 +212,19 @@ class InstancesManager:
@staticmethod @staticmethod
def dynamic_get(session, component_parent: tuple, component: tuple): def dynamic_get(session, component_parent: tuple, component: tuple):
component_type, component_id = component component_type, component_id = component
# 1. Check if component already exists # 1. Check if component already exists
existing = InstancesManager.get(session, component_id, None) existing = InstancesManager.get(session, component_id, None)
if existing is not None: if existing is not None:
logger.debug(f"Component {component_id} already exists, returning existing instance") logger.debug(f"Component {component_id} already exists, returning existing instance")
return existing return existing
# 2. Component doesn't exist, create it # 2. Component doesn't exist, create it
parent_type, parent_id = component_parent parent_type, parent_id = component_parent
# parent should always exist # parent should always exist
parent = InstancesManager.get(session, parent_id) parent = InstancesManager.get(session, parent_id)
real_component_type = snake_to_pascal(component_type.removeprefix("mf-")) real_component_type = snake_to_pascal(component_type.removeprefix("mf-"))
component_full_type = f"myfasthtml.controls.{real_component_type}.{real_component_type}" component_full_type = f"myfasthtml.controls.{real_component_type}.{real_component_type}"
cls = get_class(component_full_type) cls = get_class(component_full_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)