4 Commits

12 changed files with 864 additions and 40 deletions

View File

@@ -37,17 +37,20 @@ markdown-it-py==4.0.0
mdurl==0.1.2 mdurl==0.1.2
more-itertools==10.8.0 more-itertools==10.8.0
myauth==0.2.1 myauth==0.2.1
mydbengine==0.1.0 mydbengine==0.2.1
myutils==0.5.0 -e git+ssh://git@sheerka.synology.me:1010/kodjo/MyFastHtml.git@2f808ed226e98738a1cf476e1f1dda8a1d9118b0#egg=myfasthtml
myutils==0.5.1
nh3==0.3.1 nh3==0.3.1
numpy==2.3.5 numpy==2.3.5
oauthlib==3.3.1 oauthlib==3.3.1
openpyxl==3.1.5 openpyxl==3.1.5
packaging==25.0 packaging==25.0
pandas==2.3.3 pandas==2.3.3
pandas-stubs==2.3.3.251201
passlib==1.7.4 passlib==1.7.4
pipdeptree==2.29.0 pipdeptree==2.29.0
pluggy==1.6.0 pluggy==1.6.0
pyarrow==22.0.0
pyasn1==0.6.1 pyasn1==0.6.1
pycparser==2.23 pycparser==2.23
pydantic==2.12.3 pydantic==2.12.3

View File

@@ -1,6 +1,9 @@
import json
import logging.config import logging.config
import pandas as pd
import yaml import yaml
from dbengine.handlers import BaseRefHandler, handlers
from fasthtml import serve from fasthtml import serve
from fasthtml.components import Div from fasthtml.components import Div
@@ -35,6 +38,23 @@ app, rt = create_app(protect_routes=True,
base_url="http://localhost:5003") base_url="http://localhost:5003")
class DataFrameHandler(BaseRefHandler):
def is_eligible_for(self, obj):
return isinstance(obj, pd.DataFrame)
def tag(self):
return "DataFrame"
def serialize_to_bytes(self, df) -> bytes:
from io import BytesIO
import pickle
return pickle.dumps(df)
def deserialize_from_bytes(self, data: bytes):
import pickle
return pickle.loads(data)
def create_sample_treeview(parent): def create_sample_treeview(parent):
""" """
Create a sample TreeView with a file structure for testing. Create a sample TreeView with a file structure for testing.
@@ -83,7 +103,9 @@ def create_sample_treeview(parent):
@rt("/") @rt("/")
def index(session): def index(session):
session_instance = UniqueInstance(session=session, _id=Ids.UserSession) session_instance = UniqueInstance(session=session,
_id=Ids.UserSession,
on_init=lambda: handlers.register_handler(DataFrameHandler()))
layout = Layout(session_instance, "Testing Layout") layout = Layout(session_instance, "Testing Layout")
layout.footer_left.add("Goodbye World") layout.footer_left.add("Goodbye World")

View File

@@ -824,3 +824,342 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* ********************************************* */
/* ************* Datagrid Component ************ */
/* ********************************************* */
input:focus {
outline: none;
}
.dt2-drag-drop {
display: none;
position: absolute;
top: 100%;
z-index: var(--datagrid-drag-drop-zindex);
width: 100px;
border: 1px solid var(--color-base-300);
border-radius: 10px;
padding: 10px;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
background: var(--color-base-100);
box-sizing: border-box;
overflow-x: auto;
pointer-events: none; /* Prevent interfering with mouse events */
}
.dt2-main {
height: 100%;
position: relative;
}
.dt2-sidebar {
opacity: 0; /* Default to invisible */
visibility: hidden; /* Prevent interaction when invisible */
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
position: absolute;
top: 0;
right: 0;
width: 75%;
max-height: 710px;
overflow-y: auto;
background-color: var(--color-base-100);
z-index: var(--datagrid-sidebar-zindex);
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.5); /* Stronger shadow */
border-radius: 10px;
}
.dt2-sidebar.active {
opacity: 1;
visibility: visible;
transition: opacity 0.3s ease;
}
.dt2-container {
position: relative;
}
.dt2-scrollbars {
position: absolute;
top: 24px;
bottom: 0px;
left: 0;
right: 0;
pointer-events: none; /* Ensures parents don't intercept pointer events */
z-index: var(--datagrid-scrollbars-zindex);
}
/* Scrollbar Wrappers common attributes*/
.dt2-scrollbars-vertical-wrapper,
.dt2-scrollbars-horizontal-wrapper {
position: absolute;
background-color: var(--color-base-200);
opacity: 1;
transition: opacity 0.2s ease-in-out; /* Smooth fade in/out */
pointer-events: auto; /* Allow interaction */
}
/* Scrollbar Wrappers */
.dt2-scrollbars-vertical-wrapper {
left: auto;
right: 3px;
top: 3px;
bottom: 3px;
width: 8px;
}
.dt2-scrollbars-horizontal-wrapper {
left: 3px;
right: 3px;
top: auto;
bottom: -12px;
height: 8px;
}
/* Scrollbars */
.dt2-scrollbars-vertical,
.dt2-scrollbars-horizontal {
background-color: var(--color-base-300);
border-radius: 3px;
pointer-events: auto; /* Allow interaction with the scrollbar */
cursor: pointer;
position: absolute;
border-radius: 3px; /* Rounded corners */
pointer-events: auto; /* Enable interaction */
cursor: pointer;
}
/* Vertical Scrollbar */
.dt2-scrollbars-vertical {
left: 0;
right: 0;
top: auto;
bottom: auto;
width: 100%; /* Fits inside its wrapper */
}
/* Horizontal Scrollbar */
.dt2-scrollbars-horizontal {
left: auto;
right: auto;
top: 0;
bottom: 0;
height: 100%; /* Fits inside its wrapper */
}
/* Scrollbar hover effects */
.dt2-scrollbars-vertical:hover,
.dt2-scrollbars-horizontal:hover,
.dt2-scrollbars-vertical.dt2-dragging,
.dt2-scrollbars-horizontal.dt2-dragging {
background-color: var(--color-base-content);
}
.dt2-table {
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.dt2-table:focus {
outline: none;
}
.dt2-header,
.dt2-footer {
background-color: var(--color-base-200);
border-radius: 10px 10px 0 0;
min-width: max-content;
}
.dt2-body {
overflow: hidden; /* You can change this to auto if horizontal scrolling is required */
font-size: 14px;
min-width: max-content;
}
.dt2-row {
display: flex;
width: 100%;
height: 22px;
}
.dt2-cell {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 2px 8px;
position: relative;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 100px;
flex-grow: 0;
flex-shrink: 1;
box-sizing: border-box; /* to include the borders in the computations */
border-bottom: 1px solid var(--color-border);
user-select: none;
}
.dt2-cell-content-text {
text-align: inherit;
width: 100%;
padding-right: 10px;
}
.dt2-cell-content-checkbox {
display: flex;
width: 100%;
justify-content: center; /* Horizontally center the icon */
align-items: center; /* Vertically center the icon */
}
.dt2-cell-content-number {
text-align: right;
width: 100%;
padding-right: 10px;
}
.dt2-footer-cell {
cursor: pointer
}
.dt2-footer-menu {
position: absolute;
display: None;
z-index: var(--datagrid-menu-zindex);
border: 1px solid oklch(var(--b3));
box-sizing: border-box;
width: 80px;
background-color: var(--color-base-100); /* Add background color */
opacity: 1; /* Ensure full opacity */
}
.dt2-footer-menu.show {
display: block;
}
.dt2-footer-menu-item {
padding: 0 8px;
border-radius: 4px;
background-color: var(--color-base-100); /* Add background color */
}
.dt2-footer-menu-item:hover {
background: color-mix(in oklab, var(--color-base-100, var(--color-base-200)), #000 7%);
cursor: pointer
}
.dt2-resize-handle {
position: absolute;
right: 0;
top: 0;
width: 8px;
height: 100%;
cursor: col-resize;
}
.dt2-resize-handle::after {
content: ''; /* This is required */
position: absolute; /* Position as needed */
z-index: var(--datagrid-resize-zindex);
display: block; /* Makes it a block element */
width: 3px;
height: 60%;
top: calc(50% - 60% * 0.5);
background-color: var(--color-resize);
}
.dt2-header-hidden {
width: 5px;
background: var(--color-neutral-content);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.dt2-col-hidden {
width: 5px;
border-bottom: 1px solid var(--color-border);
}
.dt2-highlight-1 {
color: var(--color-accent);
}
.dt2-item-handle {
background-image: radial-gradient(var(--color-primary-content) 40%, transparent 0);
background-repeat: repeat;
background-size: 4px 4px;
cursor: grab;
display: inline-block;
height: 16px;
margin: auto;
position: relative;
top: 1px;
width: 12px;
}
/* **************************************************************************** */
/* COLUMNS SETTINGS */
/* **************************************************************************** */
.dt2-cs-header {
background-color: var(--color-base-200);
min-width: max-content;
}
.dt2-cs-columns {
display: grid;
grid-template-columns: 20px 1fr 0.5fr 0.5fr 0.5fr 0.5fr;
}
.dt2-cs-body input {
outline: none;
border-color: transparent;
box-shadow: none;
}
.dt2-cs-body input[type="checkbox"],
.dt2-cs-body input.checkbox {
outline: initial;
border-color: var(--color-border);
}
.dt2-cs-cell {
padding: 0 6px 0 6px;
margin: auto;
}
.dt2-cs-checkbox-cell {
margin: auto;
}
.dt2-cs-number-cell {
padding: 0 6px 0 6px;
text-align: right;
}
.dt2-cs-select-cell {
padding: 0 6px;
margin: 3px 0;
}
.dt2-cs-body input:hover {
border: 1px solid #ccc; /* Provide a subtle border on focus */
}
.dt2-views-container-select {
width: 170px;
}
.dt2-views-container-create {
width: 300px;
}

View File

@@ -1,18 +1,24 @@
import html
from typing import Optional from typing import Optional
from fastcore.basics import NotStr import pandas as pd
from fasthtml.components import Div from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands 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.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
from myfasthtml.core.utils import make_safe_id
from myfasthtml.icons.fluent import checkbox_unchecked16_regular
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular
class DatagridState(DbObject): class DatagridState(DbObject):
def __init__(self, owner): def __init__(self, owner, save_state):
super().__init__(owner) super().__init__(owner, name=f"{owner.get_full_id()}-state", save_state=save_state)
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
@@ -25,12 +31,15 @@ class DatagridState(DbObject):
self.filtered: dict = {} self.filtered: dict = {}
self.edition: DatagridEditionState = DatagridEditionState() self.edition: DatagridEditionState = DatagridEditionState()
self.selection: DatagridSelectionState = DatagridSelectionState() self.selection: DatagridSelectionState = DatagridSelectionState()
self.html = None self.ne_df = None
self.ns_fast_access = None
self.ns_total_rows = None
class DatagridSettings(DbObject): class DatagridSettings(DbObject):
def __init__(self, owner): def __init__(self, owner, save_state):
super().__init__(owner) super().__init__(owner, name=f"{owner.get_full_id()}-settings", save_state=save_state)
with self.initializing(): with self.initializing():
self.file_name: Optional[str] = None self.file_name: Optional[str] = None
self.selected_sheet_name: Optional[str] = None self.selected_sheet_name: Optional[str] = None
@@ -46,18 +55,271 @@ class Commands(BaseCommands):
class DataGrid(MultipleInstance): class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, _id=None): def __init__(self, parent, settings=None, save_state=False, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self._settings = DatagridSettings(self) self._settings = settings or DatagridSettings(self, save_state=save_state)
self._state = DatagridState(self) self._state = DatagridState(self, save_state=save_state)
self.commands = Commands(self) self.commands = Commands(self)
self.init_from_dataframe(self._state.ne_df)
def set_html(self, html): @property
self._state.html = html def _df(self):
return self._state.ne_df
def init_from_dataframe(self, df):
def _get_column_type(dtype):
if pd.api.types.is_integer_dtype(dtype):
return ColumnType.Number
elif pd.api.types.is_float_dtype(dtype):
return ColumnType.Number
elif pd.api.types.is_bool_dtype(dtype):
return ColumnType.Bool
elif pd.api.types.is_datetime64_any_dtype(dtype):
return ColumnType.Datetime
else:
return ColumnType.Text # Default to Text if no match
def _init_columns(_df):
columns = [DataGridColumnState(make_safe_id(col_id),
col_index,
col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(_df.columns)]
if self._state.row_index:
columns.insert(0, DataGridColumnState(make_safe_id(ROW_INDEX_ID), -1, " ", ColumnType.RowIndex))
return columns
def _init_fast_access(_df):
"""
Generates a fast-access dictionary for a DataFrame.
This method converts the columns of the provided DataFrame into NumPy arrays
and stores them as values in a dictionary, using the column names as keys.
This allows for efficient access to the data stored in the DataFrame.
Args:
_df (DataFrame): The input pandas DataFrame whose columns are to be converted
into a dictionary of NumPy arrays.
Returns:
dict: A dictionary where the keys are the column names of the input DataFrame
and the values are the corresponding column values as NumPy arrays.
"""
if _df is None:
return {}
res = {col: _df[col].to_numpy() for col in _df.columns}
res[ROW_INDEX_ID] = _df.index.to_numpy()
return res
if df is not None:
self._state.ne_df = df
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
self._state.columns = _init_columns(df) # use df not self._df to keep the original title
self._state.ns_fast_access = _init_fast_access(self._df)
self._state.ns_total_rows = len(self._df) if self._df is not None else 0
return self
def mk_headers(self):
def _mk_header_name(col_def: DataGridColumnState):
return Div(
mk.label(col_def.title, name="dt2-header-title"),
cls="flex truncate cursor-default",
)
def _mk_header(col_def: DataGridColumnState):
return Div(
_mk_header_name(col_def),
Div(cls="dt2-resize-handle"),
style=f"width:{col_def.width}px;",
data_col=col_def.col_id,
data_tooltip=col_def.title,
cls="dt2-cell dt2-resizable flex",
)
header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
return Div(
*[_mk_header(col_def) for col_def in self._state.columns],
cls=header_class,
id=f"th_{self._id}"
)
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState):
def mk_bool(_value):
return Div(mk.icon(checkbox_checked16_regular if _value else checkbox_unchecked16_regular, can_select=False),
cls="dt2-cell-content-checkbox")
def mk_text(_value):
return mk.label(_value, cls="dt2-cell-content-text")
def mk_number(_value):
return mk.label(_value, cls="dt2-cell-content-number")
def process_cell_content(_value):
value_str = html.escape(str(_value))
if FILTER_INPUT_CID not in self._state.filtered or (
keyword := self._state.filtered[FILTER_INPUT_CID]) is None:
return value_str
index = value_str.lower().find(keyword.lower())
if index < 0:
return value_str
len_keyword = len(keyword)
res = [Span(value_str[:index])] if index > 0 else []
res += [Span(value_str[index:index + len_keyword], cls="dt2-highlight-1")]
res += [Span(value_str[index + len_keyword:])] if len(value_str) > len_keyword else []
return tuple(res)
column_type = col_def.type
value = self._state.ns_fast_access[col_def.col_id][row_index]
if column_type == ColumnType.Bool:
content = mk_bool(value)
elif column_type == ColumnType.Number:
content = mk_number(process_cell_content(value))
elif column_type == ColumnType.RowIndex:
content = mk_number(row_index)
else:
content = mk_text(process_cell_content(value))
return content
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState):
if not col_def.usable:
return None
if not col_def.visible:
return Div(cls="dt2-col-hidden")
content = self.mk_body_cell_content(col_pos, row_index, col_def)
return Div(content,
data_col=col_def.col_id,
style=f"width:{col_def.width}px;",
cls="dt2-cell")
def mk_body_content_page(self, page_index: int):
df = self._df # self._get_filtered_df()
start = page_index * DATAGRID_PAGE_SIZE
end = start + DATAGRID_PAGE_SIZE
if self._state.ns_total_rows > end:
last_row = df.index[end - 1]
else:
last_row = None
rows = [Div(
*[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row",
data_row=f"{row_index}",
id=f"tr_{self._id}-{row_index}",
) for row_index in df.index[start:end]]
return rows
def mk_body(self):
return Div(
*self.mk_body_content_page(0),
cls="dt2-body",
id=f"tb_{self._id}",
)
def mk_footers(self):
return Div(
*[Div(
*[self.mk_aggregation_cell(col_def, row_index, footer) for col_def in self._state.columns],
id=f"tf_{self._id}",
data_row=f"{row_index}",
cls="dt2-row dt2-row-footer",
) for row_index, footer in enumerate(self._state.footers)],
cls="dt2-footer",
id=f"tf_{self._id}"
)
def mk_table(self):
return Div(
self.mk_headers(),
self.mk_body(),
self.mk_footers()
)
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
"""
Generates a footer cell for a data table based on the provided column definition,
row index, footer configuration, and optional out-of-bound setting. This method
applies appropriate aggregation functions, determines visibility, and structures
the cell's elements accordingly.
:param col_def: Details of the column state, including its usability, visibility,
and column ID, which are necessary to determine how the footer
cell should be created.
:type col_def: DataGridColumnState
:param row_index: The specific index of the footer row where this cell will be
added. This parameter is used to uniquely identify the cell
within the footer.
:type row_index: int
:param footer_conf: Configuration for the footer that contains mapping of column
IDs to their corresponding aggregation functions. This is
critical for calculating aggregated values for the cell content.
:type footer_conf: DataGridFooterConf
:param oob: A boolean flag indicating whether the configuration involves any
out-of-bound parameters that must be handled specifically. This
parameter is optional and defaults to False.
:type oob: bool
:return: Returns an instance of `Div`, containing the visually structured footer
cell content, including the calculated aggregation if applicable. If
the column is not usable, it returns None. For non-visible columns, it
returns a hidden cell `Div`. The aggregation value is displayed for valid
aggregations. If none is applicable or the configuration is invalid,
appropriate default content or styling is applied.
:rtype: Div | None
"""
if not col_def.usable:
return None
if not col_def.visible:
return Div(cls="dt2-col-hidden")
if col_def.col_id in footer_conf.conf:
agg_function = footer_conf.conf[col_def.col_id]
if agg_function == FooterAggregation.Sum.value:
value = self._df[col_def.col_id].sum()
elif agg_function == FooterAggregation.Min.value:
value = self._df[col_def.col_id].min()
elif agg_function == FooterAggregation.Max.value:
value = self._df[col_def.col_id].max()
elif agg_function == FooterAggregation.Mean.value:
value = self._df[col_def.col_id].mean()
elif agg_function == FooterAggregation.Count.value:
value = self._df[col_def.col_id].count()
else:
value = "** Invalid aggregation function **"
else:
value = None
return Div(mk.label(value, cls="dt2-cell-content-number"),
data_col=col_def.col_id,
style=f"width:{col_def.width}px;",
cls="dt2-cell dt2-footer-cell",
id=f"tf_{self._id}-{col_def.col_id}-{row_index}",
hx_swap_oob='true' if oob else None,
)
def render(self): def render(self):
if self._state.ne_df is None:
return Div("No data to display !")
return Div( return Div(
NotStr(self._state.html) if self._state.html else "Content lost !", Div(
self.mk_table(),
# Script(f"bindDatagrid('{self._id}', false);"),
),
id=self._id id=self._id
) )

View File

@@ -23,7 +23,7 @@ class DocumentDefinition:
document_id: str document_id: str
namespace: str namespace: str
name: str name: str
type: str type: str # table, card,
tab_id: str tab_id: str
datagrid_id: str datagrid_id: str
@@ -32,7 +32,7 @@ class DataGridsState(DbObject):
def __init__(self, owner, name=None): def __init__(self, owner, name=None):
super().__init__(owner, name=name) super().__init__(owner, name=name)
with self.initializing(): with self.initializing():
self.elements: list = [DocumentDefinition] self.elements: list[DocumentDefinition] = []
class Commands(BaseCommands): class Commands(BaseCommands):
@@ -91,9 +91,8 @@ 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(excel_content, file_upload.get_sheet_name())
html = df.to_html(index=False) dg = DataGrid(self._tabs_manager, save_state=True)
dg = DataGrid(self._tabs_manager) dg.init_from_dataframe(df)
dg.set_html(html)
document = DocumentDefinition( document = DocumentDefinition(
document_id=str(uuid.uuid4()), document_id=str(uuid.uuid4()),
namespace=file_upload.get_file_basename(), namespace=file_upload.get_file_basename(),
@@ -118,6 +117,29 @@ class DataGridsManager(MultipleInstance):
# the selected node is not a document (it's a folder) # the selected node is not a document (it's a folder)
return None return None
def create_tab_content(self, tab_id):
"""
Recreate the content for a tab managed by this DataGridsManager.
Called by TabsManager when the content is not in cache (e.g., after restart).
Args:
tab_id: ID of the tab to recreate content for
Returns:
The recreated component (Panel with DataGrid)
"""
# Find the document associated with this tab
document = next((d for d in self._state.elements if d.tab_id == tab_id), None)
if document is None:
raise ValueError(f"No document found for tab {tab_id}")
# Recreate the DataGrid with its saved state
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id)
# Wrap in Panel
return Panel(self).set_main(dg)
def clear_tree(self): def clear_tree(self):
self._state.elements = [] self._state.elements = []
self._tree.clear() self._tree.clear()

View File

@@ -115,13 +115,41 @@ class TabsManager(MultipleInstance):
def _dynamic_get_content(self, tab_id): def _dynamic_get_content(self, tab_id):
if tab_id not in self._state.tabs: if tab_id not in self._state.tabs:
return Div("Tab not found.") return Div("Tab not found.")
tab_config = self._state.tabs[tab_id] tab_config = self._state.tabs[tab_id]
if tab_config["component"] is None: if tab_config["component"] is None:
return Div("Tab content does not support serialization.") return Div("Tab content does not support serialization.")
try:
return InstancesManager.get(self._session, tab_config["component"][1]) # 1. Try to get existing component instance
except Exception as e: res = InstancesManager.get(self._session, tab_config["component"][1], None)
logger.error(f"Error while retrieving tab content: {e}") if res is not None:
logger.debug(f"Component {tab_config['component'][1]} already exists")
return res
# 2. Get or create parent
if tab_config["component_parent"] is None:
logger.error(f"No parent defined for tab {tab_id}")
return Div("Failed to retrieve tab content.")
parent = InstancesManager.get(self._session, tab_config["component_parent"][1], None)
if parent is None:
logger.error(f"Parent {tab_config['component_parent'][1]} not found for tab {tab_id}")
return Div("Parent component not available")
# 3. If parent supports create_tab_content, use it
if hasattr(parent, 'create_tab_content'):
try:
logger.debug(f"Asking parent {tab_config['component_parent'][1]} to create tab content for {tab_id}")
content = parent.create_tab_content(tab_id)
# Store in cache
self._state.ns_tabs_content[tab_id] = content
return content
except Exception as e:
logger.error(f"Error while parent creating tab content: {e}")
return Div("Failed to retrieve tab content.")
else:
# Parent doesn't support create_tab_content, fallback to error
logger.error(f"Parent {tab_config['component_parent'][1]} doesn't support create_tab_content")
return Div("Failed to retrieve tab content.") return Div("Failed to retrieve tab content.")
def _get_or_create_tab_content(self, tab_id): def _get_or_create_tab_content(self, tab_id):

View File

@@ -95,7 +95,7 @@ class mk:
command: Command | CommandTemplate = None, command: Command | CommandTemplate = None,
binding: Binding = None, binding: Binding = None,
**kwargs): **kwargs):
merged_cls = merge_classes("flex", cls, kwargs) merged_cls = merge_classes("flex truncate", cls, kwargs)
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
text_part = Span(text, cls=f"text-{size}") text_part = Span(text, cls=f"text-{size}")
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding) return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)

View File

@@ -4,6 +4,10 @@ DEFAULT_COLUMN_WIDTH = 100
ROUTE_ROOT = "/myfasthtml" ROUTE_ROOT = "/myfasthtml"
# Datagrid
ROW_INDEX_ID = "__row_index__"
DATAGRID_PAGE_SIZE = 1000
FILTER_INPUT_CID = "__filter_input__"
class Routes: class Routes:
Commands = "/commands" Commands = "/commands"

View File

@@ -37,10 +37,11 @@ class DbObject:
_initializing = False _initializing = False
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"} _forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
def __init__(self, owner: BaseInstance, name=None, db_manager=None): def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True):
self._owner = owner self._owner = owner
self._name = name or owner.get_full_id() self._name = name or owner.get_full_id()
self._db_manager = db_manager or DbManager(self._owner) self._db_manager = db_manager or DbManager(self._owner)
self._save_state = save_state
self._finalize_initialization() self._finalize_initialization()
@@ -55,13 +56,14 @@ class DbObject:
self._initializing = old_state self._initializing = old_state
def __setattr__(self, name: str, value: str): def __setattr__(self, name: str, value: str):
if name.startswith("_") or name.startswith("ns") or getattr(self, "_initializing", False): if name.startswith("_") or name.startswith("ns_") or getattr(self, "_initializing", False):
super().__setattr__(name, value) super().__setattr__(name, value)
return return
old_value = getattr(self, name, None) if not name.startswith("ne_"):
if old_value == value: old_value = getattr(self, name, None)
return if old_value == value:
return
super().__setattr__(name, value) super().__setattr__(name, value)
self._save_self() self._save_self()
@@ -74,6 +76,9 @@ class DbObject:
self._save_self() self._save_self()
def _save_self(self): def _save_self(self):
if not self._save_state:
return
props = {k: getattr(self, k) for k, v in self._get_properties().items() if props = {k: getattr(self, k) for k, v in self._get_properties().items() if
not k.startswith("_") and not k.startswith("ns")} not k.startswith("_") and not k.startswith("ns")}
if props: if props:

View File

@@ -3,7 +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.utils import pascal_to_snake from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
logger = logging.getLogger("InstancesManager") logger = logging.getLogger("InstancesManager")
@@ -144,8 +144,11 @@ class UniqueInstance(BaseInstance):
parent: Optional[BaseInstance] = None, parent: Optional[BaseInstance] = None,
session: Optional[dict] = None, session: Optional[dict] = None,
_id: Optional[str] = None, _id: Optional[str] = None,
auto_register: bool = True): auto_register: bool = True,
on_init=None):
super().__init__(parent, session, _id, auto_register) super().__init__(parent, session, _id, auto_register)
if on_init is not None:
on_init()
class MultipleInstance(BaseInstance): class MultipleInstance(BaseInstance):
@@ -180,16 +183,22 @@ class InstancesManager:
return instance return instance
@staticmethod @staticmethod
def get(session: dict, instance_id: str): def get(session: dict, instance_id: str, default="**__no_default__**"):
""" """
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:
:param instance_id: :param instance_id:
:param default:
:return: :return:
""" """
session_id = InstancesManager.get_session_id(session) try:
key = (session_id, instance_id) session_id = InstancesManager.get_session_id(session)
return InstancesManager.instances[key] key = (session_id, instance_id)
return InstancesManager.instances[key]
except KeyError:
if default != "**__non__**":
return default
raise
@staticmethod @staticmethod
def get_by_type(session: dict, cls: type): def get_by_type(session: dict, cls: type):
@@ -199,6 +208,28 @@ class InstancesManager:
assert len(res) > 0, f"No instance of type {cls.__name__} found" assert len(res) > 0, f"No instance of type {cls.__name__} found"
return res[0] return res[0]
@staticmethod
def dynamic_get(session, component_parent: tuple, component: tuple):
component_type, component_id = component
# 1. Check if component already exists
existing = InstancesManager.get(session, component_id, None)
if existing is not None:
logger.debug(f"Component {component_id} already exists, returning existing instance")
return existing
# 2. Component doesn't exist, create it
parent_type, parent_id = component_parent
# parent should always exist
parent = InstancesManager.get(session, parent_id)
real_component_type = snake_to_pascal(component_type.removeprefix("mf-"))
component_full_type = f"myfasthtml.controls.{real_component_type}.{real_component_type}"
cls = get_class(component_full_type)
logger.debug(f"Creating new component {component_id} of type {real_component_type}")
return cls(parent, _id=component_id)
@staticmethod @staticmethod
def get_session_id(session): def get_session_id(session):
if isinstance(session, str): if isinstance(session, str):

View File

@@ -1,3 +1,4 @@
import importlib
import logging import logging
import re import re
@@ -284,6 +285,62 @@ def flatten(*args):
res.append(arg) res.append(arg)
return res return res
def make_html_id(s: str | None) -> str | None:
"""
Creates a valid html id
:param s:
:return:
"""
if s is None:
return None
s = str(s).strip()
# Replace spaces and special characters with hyphens or remove them
s = re.sub(r'[^a-zA-Z0-9_-]', '-', s)
# Ensure the ID starts with a letter or underscore
if not re.match(r'^[a-zA-Z_]', s):
s = 'id_' + s # Add a prefix if it doesn't
# Collapse multiple consecutive hyphens into one
s = re.sub(r'-+', '-', s)
# Replace trailing hyphens with underscores
s = re.sub(r'-+$', '_', s)
return s
def make_safe_id(s: str | None):
if s is None:
return None
res = re.sub('-', '_', make_html_id(s)) # replace '-' by '_'
return res.lower() # no uppercase
def get_class(qualified_class_name: str):
"""
Dynamically loads and returns a class type from its fully qualified name.
Note that the class is not instantiated.
:param qualified_class_name: Fully qualified name of the class (e.g., 'some.module.ClassName').
:return: The class object.
:raises ImportError: If the module cannot be imported.
:raises AttributeError: If the class cannot be resolved in the module.
"""
module_name, class_name = qualified_class_name.rsplit(".", 1)
try:
module = importlib.import_module(module_name)
except ModuleNotFoundError as e:
raise ImportError(f"Could not import module '{module_name}' for '{qualified_class_name}': {e}")
if not hasattr(module, class_name):
raise AttributeError(f"Component '{class_name}' not found in '{module.__name__}'.")
return getattr(module, class_name)
@utils_rt(Routes.Commands) @utils_rt(Routes.Commands)
def post(session, c_id: str, client_response: dict = None): def post(session, c_id: str, client_response: dict = None):
""" """

View File

@@ -1,6 +1,6 @@
import pytest import pytest
from myfasthtml.core.utils import flatten from myfasthtml.core.utils import flatten, make_html_id, pascal_to_snake, snake_to_pascal
@pytest.mark.parametrize("input_args,expected,test_description", [ @pytest.mark.parametrize("input_args,expected,test_description", [
@@ -56,3 +56,54 @@ def test_i_can_flatten(input_args, expected, test_description):
"""Test that flatten correctly handles various nested structures and arguments.""" """Test that flatten correctly handles various nested structures and arguments."""
result = flatten(*input_args) result = flatten(*input_args)
assert result == expected, f"Failed for test case: {test_description}" assert result == expected, f"Failed for test case: {test_description}"
@pytest.mark.parametrize("string, expected", [
("My Example String!", "My-Example-String_"),
("123 Bad ID", "id_123-Bad-ID"),
(None, None)
])
def test_i_can_have_valid_html_id(string, expected):
assert make_html_id(string) == expected
@pytest.mark.parametrize("input_str, expected, test_description", [
("MyClass", "my_class", "simple PascalCase"),
("myVariable", "my_variable", "camelCase"),
("HTTPServer", "http_server", "short uppercase sequence"),
("XMLHttpRequest", "xml_http_request", "long uppercase sequence"),
("A", "a", "single letter"),
("already_snake", "already_snake", "already snake_case"),
("MyClass123", "my_class123", "with numbers"),
("MyLongClassName", "my_long_class_name", "long class name"),
(" MyClass ", "my_class", "with spaces to trim"),
("iPhone", "i_phone", "starts lowercase then uppercase"),
(None, None, "None input"),
("", "", "empty string"),
(" ", "", "only spaces"),
])
def test_i_can_convert_pascal_to_snake(input_str, expected, test_description):
"""Test that pascal_to_snake correctly converts PascalCase/camelCase to snake_case."""
result = pascal_to_snake(input_str)
assert result == expected, f"Failed for test case: {test_description}"
@pytest.mark.parametrize("input_str, expected, test_description", [
("my_class", "MyClass", "simple snake_case"),
("my_long_class_name", "MyLongClassName", "long class name"),
("a", "A", "single letter"),
("myclass", "Myclass", "no underscore"),
(" my_class ", "MyClass", "with spaces to trim"),
("my__class", "MyClass", "multiple consecutive underscores"),
("_my_class", "MyClass", "starts with underscore"),
("my_class_", "MyClass", "ends with underscore"),
("_my_class_", "MyClass", "starts and ends with underscore"),
("my_class_123", "MyClass123", "with numbers"),
(None, None, "None input"),
("", "", "empty string"),
(" ", "", "only spaces"),
("___", "", "only underscores"),
])
def test_i_can_convert_snake_to_pascal(input_str, expected, test_description):
"""Test that snake_to_pascal correctly converts snake_case to PascalCase."""
result = snake_to_pascal(input_str)
assert result == expected, f"Failed for test case: {test_description}"