Compare commits
4 Commits
2f808ed226
...
47848bb2fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 47848bb2fd | |||
| 5201858b79 | |||
| 797883dac8 | |||
| 70abf21c14 |
@@ -37,17 +37,20 @@ markdown-it-py==4.0.0
|
||||
mdurl==0.1.2
|
||||
more-itertools==10.8.0
|
||||
myauth==0.2.1
|
||||
mydbengine==0.1.0
|
||||
myutils==0.5.0
|
||||
mydbengine==0.2.1
|
||||
-e git+ssh://git@sheerka.synology.me:1010/kodjo/MyFastHtml.git@2f808ed226e98738a1cf476e1f1dda8a1d9118b0#egg=myfasthtml
|
||||
myutils==0.5.1
|
||||
nh3==0.3.1
|
||||
numpy==2.3.5
|
||||
oauthlib==3.3.1
|
||||
openpyxl==3.1.5
|
||||
packaging==25.0
|
||||
pandas==2.3.3
|
||||
pandas-stubs==2.3.3.251201
|
||||
passlib==1.7.4
|
||||
pipdeptree==2.29.0
|
||||
pluggy==1.6.0
|
||||
pyarrow==22.0.0
|
||||
pyasn1==0.6.1
|
||||
pycparser==2.23
|
||||
pydantic==2.12.3
|
||||
|
||||
24
src/app.py
24
src/app.py
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
import logging.config
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
from dbengine.handlers import BaseRefHandler, handlers
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import Div
|
||||
|
||||
@@ -35,6 +38,23 @@ app, rt = create_app(protect_routes=True,
|
||||
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):
|
||||
"""
|
||||
Create a sample TreeView with a file structure for testing.
|
||||
@@ -83,7 +103,9 @@ def create_sample_treeview(parent):
|
||||
|
||||
@rt("/")
|
||||
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.footer_left.add("Goodbye World")
|
||||
|
||||
|
||||
@@ -824,3 +824,342 @@
|
||||
text-overflow: ellipsis;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import html
|
||||
from typing import Optional
|
||||
|
||||
from fastcore.basics import NotStr
|
||||
from fasthtml.components import Div
|
||||
import pandas as pd
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
||||
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.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):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
def __init__(self, owner, save_state):
|
||||
super().__init__(owner, name=f"{owner.get_full_id()}-state", save_state=save_state)
|
||||
with self.initializing():
|
||||
self.sidebar_visible: bool = False
|
||||
self.selected_view: str = None
|
||||
@@ -25,12 +31,15 @@ class DatagridState(DbObject):
|
||||
self.filtered: dict = {}
|
||||
self.edition: DatagridEditionState = DatagridEditionState()
|
||||
self.selection: DatagridSelectionState = DatagridSelectionState()
|
||||
self.html = None
|
||||
self.ne_df = None
|
||||
|
||||
self.ns_fast_access = None
|
||||
self.ns_total_rows = None
|
||||
|
||||
|
||||
class DatagridSettings(DbObject):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
def __init__(self, owner, save_state):
|
||||
super().__init__(owner, name=f"{owner.get_full_id()}-settings", save_state=save_state)
|
||||
with self.initializing():
|
||||
self.file_name: Optional[str] = None
|
||||
self.selected_sheet_name: Optional[str] = None
|
||||
@@ -46,18 +55,271 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
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)
|
||||
self._settings = DatagridSettings(self)
|
||||
self._state = DatagridState(self)
|
||||
self._settings = settings or DatagridSettings(self, save_state=save_state)
|
||||
self._state = DatagridState(self, save_state=save_state)
|
||||
self.commands = Commands(self)
|
||||
self.init_from_dataframe(self._state.ne_df)
|
||||
|
||||
def set_html(self, html):
|
||||
self._state.html = html
|
||||
@property
|
||||
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):
|
||||
if self._state.ne_df is None:
|
||||
return Div("No data to display !")
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class DocumentDefinition:
|
||||
document_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
type: str
|
||||
type: str # table, card,
|
||||
tab_id: str
|
||||
datagrid_id: str
|
||||
|
||||
@@ -32,7 +32,7 @@ class DataGridsState(DbObject):
|
||||
def __init__(self, owner, name=None):
|
||||
super().__init__(owner, name=name)
|
||||
with self.initializing():
|
||||
self.elements: list = [DocumentDefinition]
|
||||
self.elements: list[DocumentDefinition] = []
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
@@ -91,9 +91,8 @@ class DataGridsManager(MultipleInstance):
|
||||
def open_from_excel(self, tab_id, file_upload: FileUpload):
|
||||
excel_content = file_upload.get_content()
|
||||
df = pd.read_excel(excel_content, file_upload.get_sheet_name())
|
||||
html = df.to_html(index=False)
|
||||
dg = DataGrid(self._tabs_manager)
|
||||
dg.set_html(html)
|
||||
dg = DataGrid(self._tabs_manager, save_state=True)
|
||||
dg.init_from_dataframe(df)
|
||||
document = DocumentDefinition(
|
||||
document_id=str(uuid.uuid4()),
|
||||
namespace=file_upload.get_file_basename(),
|
||||
@@ -118,6 +117,29 @@ class DataGridsManager(MultipleInstance):
|
||||
# the selected node is not a document (it's a folder)
|
||||
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):
|
||||
self._state.elements = []
|
||||
self._tree.clear()
|
||||
|
||||
@@ -115,13 +115,41 @@ class TabsManager(MultipleInstance):
|
||||
def _dynamic_get_content(self, tab_id):
|
||||
if tab_id not in self._state.tabs:
|
||||
return Div("Tab not found.")
|
||||
|
||||
tab_config = self._state.tabs[tab_id]
|
||||
if tab_config["component"] is None:
|
||||
return Div("Tab content does not support serialization.")
|
||||
try:
|
||||
return InstancesManager.get(self._session, tab_config["component"][1])
|
||||
except Exception as e:
|
||||
logger.error(f"Error while retrieving tab content: {e}")
|
||||
|
||||
# 1. Try to get existing component instance
|
||||
res = InstancesManager.get(self._session, tab_config["component"][1], None)
|
||||
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.")
|
||||
|
||||
def _get_or_create_tab_content(self, tab_id):
|
||||
|
||||
@@ -95,7 +95,7 @@ class mk:
|
||||
command: Command | CommandTemplate = None,
|
||||
binding: Binding = None,
|
||||
**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
|
||||
text_part = Span(text, cls=f"text-{size}")
|
||||
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||
|
||||
@@ -4,6 +4,10 @@ DEFAULT_COLUMN_WIDTH = 100
|
||||
|
||||
ROUTE_ROOT = "/myfasthtml"
|
||||
|
||||
# Datagrid
|
||||
ROW_INDEX_ID = "__row_index__"
|
||||
DATAGRID_PAGE_SIZE = 1000
|
||||
FILTER_INPUT_CID = "__filter_input__"
|
||||
|
||||
class Routes:
|
||||
Commands = "/commands"
|
||||
|
||||
@@ -37,10 +37,11 @@ class DbObject:
|
||||
_initializing = False
|
||||
_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._name = name or owner.get_full_id()
|
||||
self._db_manager = db_manager or DbManager(self._owner)
|
||||
self._save_state = save_state
|
||||
|
||||
self._finalize_initialization()
|
||||
|
||||
@@ -55,13 +56,14 @@ class DbObject:
|
||||
self._initializing = old_state
|
||||
|
||||
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)
|
||||
return
|
||||
|
||||
old_value = getattr(self, name, None)
|
||||
if old_value == value:
|
||||
return
|
||||
if not name.startswith("ne_"):
|
||||
old_value = getattr(self, name, None)
|
||||
if old_value == value:
|
||||
return
|
||||
|
||||
super().__setattr__(name, value)
|
||||
self._save_self()
|
||||
@@ -74,6 +76,9 @@ class DbObject:
|
||||
self._save_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
|
||||
not k.startswith("_") and not k.startswith("ns")}
|
||||
if props:
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from typing import Optional
|
||||
|
||||
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")
|
||||
|
||||
@@ -144,8 +144,11 @@ class UniqueInstance(BaseInstance):
|
||||
parent: Optional[BaseInstance] = None,
|
||||
session: Optional[dict] = None,
|
||||
_id: Optional[str] = None,
|
||||
auto_register: bool = True):
|
||||
auto_register: bool = True,
|
||||
on_init=None):
|
||||
super().__init__(parent, session, _id, auto_register)
|
||||
if on_init is not None:
|
||||
on_init()
|
||||
|
||||
|
||||
class MultipleInstance(BaseInstance):
|
||||
@@ -180,16 +183,22 @@ class InstancesManager:
|
||||
return instance
|
||||
|
||||
@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)
|
||||
:param session:
|
||||
:param instance_id:
|
||||
:param default:
|
||||
:return:
|
||||
"""
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
key = (session_id, instance_id)
|
||||
return InstancesManager.instances[key]
|
||||
try:
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
key = (session_id, instance_id)
|
||||
return InstancesManager.instances[key]
|
||||
except KeyError:
|
||||
if default != "**__non__**":
|
||||
return default
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
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"
|
||||
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
|
||||
def get_session_id(session):
|
||||
if isinstance(session, str):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -284,6 +285,62 @@ def flatten(*args):
|
||||
res.append(arg)
|
||||
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)
|
||||
def post(session, c_id: str, client_response: dict = None):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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", [
|
||||
@@ -56,3 +56,54 @@ def test_i_can_flatten(input_args, expected, test_description):
|
||||
"""Test that flatten correctly handles various nested structures and arguments."""
|
||||
result = flatten(*input_args)
|
||||
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}"
|
||||
|
||||
Reference in New Issue
Block a user