3 Commits

Author SHA1 Message Date
7dc7687b25 Fixed unit tests 2025-08-23 21:34:09 +02:00
f08ae4a90b Added RowIndex in GridState.
Fixed content escaping
2025-08-23 00:29:52 +02:00
b48aaf4621 Fixed unit tests 2025-08-22 23:17:01 +02:00
6 changed files with 54 additions and 75 deletions

View File

@@ -1,5 +1,5 @@
import asyncio
import copy import copy
import html
import logging import logging
from io import BytesIO from io import BytesIO
from typing import Literal, Any from typing import Literal, Any
@@ -122,14 +122,21 @@ class DataGrid(BaseComponent):
else: else:
return ColumnType.Text # Default to Text if no match return ColumnType.Text # Default to Text if no match
self._df = df.copy() def _init_columns(_df):
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed columns = [DataGridColumnState(make_safe_id(col_id),
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
self._state.columns = [DataGridColumnState(make_safe_id(col_id),
col_index, col_index,
col_id, col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype)) _get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(df.columns)] 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
self._df = df.copy()
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._fast_access = self._init_fast_access(self._df) self._fast_access = self._init_fast_access(self._df)
self._total_rows = len(self._df) if self._df is not None else 0 self._total_rows = len(self._df) if self._df is not None else 0
@@ -211,6 +218,7 @@ class DataGrid(BaseComponent):
self._state.columns = new_columns_states self._state.columns = new_columns_states
self._fast_access = self._init_fast_access(self._df)
self._views.recompute_need_save() self._views.recompute_need_save()
self._db.save_all(self._settings, self._state, self._df if new_column else None) self._db.save_all(self._settings, self._state, self._df if new_column else None)
@@ -446,8 +454,6 @@ class DataGrid(BaseComponent):
_mk_keyboard_management(), _mk_keyboard_management(),
Div( Div(
self.mk_table_header(), self.mk_table_header(),
#self.mk_table_body(),
#self.mk_table_body_sse(),
self.mk_table_body_page(), self.mk_table_body_page(),
self.mk_table_footer(), self.mk_table_footer(),
cls="dt2-inner-table"), cls="dt2-inner-table"),
@@ -483,32 +489,11 @@ class DataGrid(BaseComponent):
header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden" header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
return Div( return Div(
Div(sse_swap="message"),
*[_mk_header(col_def) for col_def in self._state.columns], *[_mk_header(col_def) for col_def in self._state.columns],
cls=header_class, cls=header_class,
id=f"th_{self._id}" id=f"th_{self._id}"
) )
def mk_table_body_sse(self):
"""
This function is used to create a sse update
Unfortunately, the sse does not update the correct element
tb_{self._id} is not updated
Plus UI refreshment issues
"""
max_height = self._compute_body_max_height()
return Div(
hx_ext="sse",
sse_connect=f"{ROUTE_ROOT}{Routes.YieldRow}?_id={self._id}",
sse_close='close',
sse_swap="message",
hx_swap="beforeend",
cls="dt2-body",
style=f"max-height:{max_height}px;",
id=f"tb_{self._id}",
)
def mk_table_body_page(self): def mk_table_body_page(self):
""" """
This function is used to update the table body when the vertical scrollbar reaches the end This function is used to update the table body when the vertical scrollbar reaches the end
@@ -523,22 +508,6 @@ class DataGrid(BaseComponent):
id=f"tb_{self._id}", id=f"tb_{self._id}",
) )
def mk_table_body(self):
df = self._get_filtered_df()
max_height = self._compute_body_max_height()
return Div(
*[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],
cls="dt2-body",
style=f"max-height:{max_height}px;",
id=f"tb_{self._id}"
)
def mk_table_footer(self): def mk_table_footer(self):
return Div( return Div(
*[Div( *[Div(
@@ -568,26 +537,10 @@ class DataGrid(BaseComponent):
**self.commands.get_page(page_index + 1) if row_index == last_row else {} **self.commands.get_page(page_index + 1) if row_index == last_row else {}
) for row_index in df.index[start:end]] ) for row_index in df.index[start:end]]
rows.append(Script(f"manageScrollbars('{self._id}', false);"),) rows.append(Script(f"manageScrollbars('{self._id}', false);"), )
return rows return rows
async def mk_body_content_sse(self):
df = self._get_filtered_df()
for i, row_index in enumerate(df.index):
yield sse_message(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}",
))
if i % 50 == 0:
await asyncio.sleep(0.01)
logger.debug(f"yielding row {i}")
logger.debug(f"yielding close event")
yield f"event: close\ndata: \n\n"
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState): def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState):
if not col_def.usable: if not col_def.usable:
return None return None
@@ -615,7 +568,7 @@ class DataGrid(BaseComponent):
return mk_my_ellipsis(_value, cls="dt2-cell-content-number") return mk_my_ellipsis(_value, cls="dt2-cell-content-number")
def process_cell_content(_value): def process_cell_content(_value):
value_str = str(_value) value_str = html.escape(str(_value))
if FILTER_INPUT_CID not in self._state.filtered or ( if FILTER_INPUT_CID not in self._state.filtered or (
keyword := self._state.filtered[FILTER_INPUT_CID]) is None: keyword := self._state.filtered[FILTER_INPUT_CID]) is None:
@@ -632,7 +585,6 @@ class DataGrid(BaseComponent):
return tuple(res) return tuple(res)
column_type = col_def.type column_type = col_def.type
# value = self._df.iloc[row_index, col_def.col_index]
value = self._fast_access[col_def.col_id][row_index] value = self._fast_access[col_def.col_id][row_index]
if column_type == ColumnType.Bool: if column_type == ColumnType.Bool:
@@ -922,7 +874,12 @@ class DataGrid(BaseComponent):
dict: A dictionary where the keys are the column names of the input DataFrame 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. and the values are the corresponding column values as NumPy arrays.
""" """
return {col: df[col].to_numpy() for col in df.columns} 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
@timed @timed
def __ft__(self): def __ft__(self):

View File

@@ -19,6 +19,8 @@ DATAGRID_STATE_FOOTER = "footer"
DATAGRID_PAGE_SIZE = 50 DATAGRID_PAGE_SIZE = 50
ROW_INDEX_ID = "__row_index__"
class Routes: class Routes:
Filter = "/filter" # request the filtering in the grid Filter = "/filter" # request the filtering in the grid
ResetFilter = "/reset_filter" # ResetFilter = "/reset_filter" #

View File

@@ -69,6 +69,7 @@ class DataGridSettings:
class DataGridState: class DataGridState:
sidebar_visible: bool = False sidebar_visible: bool = False
selected_view: str = None selected_view: str = None
row_index: bool = False
columns: list[DataGridColumnState] = dataclasses.field(default_factory=list) columns: list[DataGridColumnState] = dataclasses.field(default_factory=list)
rows: list[DataGridRowState] = dataclasses.field(default_factory=list) # only the rows that have a specific state rows: list[DataGridRowState] = dataclasses.field(default_factory=list) # only the rows that have a specific state
footers: list[DataGridFooterConf] = dataclasses.field(default_factory=list) footers: list[DataGridFooterConf] = dataclasses.field(default_factory=list)

View File

@@ -7,10 +7,13 @@ attr_map = {
"_id": "id", "_id": "id",
} }
def safe_attr(attr_name): def safe_attr(attr_name):
attr_name = attr_name.replace("hx_", "hx-") attr_name = attr_name.replace("hx_", "hx-")
attr_name = attr_name.replace("data_", "data-")
return attr_map.get(attr_name, attr_name) return attr_map.get(attr_name, attr_name)
def to_html(item): def to_html(item):
if item is None: if item is None:
return "" return ""
@@ -27,14 +30,14 @@ def to_html(item):
class MyFt: class MyFt:
def __init__(self, name, *args, **kwargs): def __init__(self, tag, *args, **kwargs):
self.name = name self.tag = tag
self.children = args self.children = args
self.attrs = kwargs self.attrs = {safe_attr(k): v for k, v in kwargs.items()}
def to_html(self): def to_html(self):
body_items = [to_html(item) for item in self.children] body_items = [to_html(item) for item in self.children]
return f"<{self.name} {' '.join(f'{safe_attr(k)}="{v}"' for k, v in self.attrs.items())}>{' '.join(body_items)}</div>" return f"<{self.tag} {' '.join(f'{k}="{v}"' for k, v in self.attrs.items())}>{' '.join(body_items)}</div>"
def __ft__(self): def __ft__(self):
return NotStr(self.to_html()) return NotStr(self.to_html())

View File

@@ -642,10 +642,10 @@ def extract_table_values_new(ft, header=True):
# first, get the header # first, get the header
if header: if header:
header = search_elements_by_name(ft, attrs={"class": "dt2-header"}, comparison_method='contains')[0] header_element = search_elements_by_name(ft, attrs={"class": "dt2-header"}, comparison_method='contains')[0]
header_map = {} header_map = {}
res = OrderedDict() res = OrderedDict()
for row in header.children: for row in header_element.children:
col_id = row.attrs["data-col"] col_id = row.attrs["data-col"]
title = row.attrs["data-tooltip"] title = row.attrs["data-tooltip"]
header_map[col_id] = title header_map[col_id] = title
@@ -654,6 +654,7 @@ def extract_table_values_new(ft, header=True):
body = search_elements_by_name(ft, attrs={"class": "dt2-body"}, comparison_method='contains')[0] body = search_elements_by_name(ft, attrs={"class": "dt2-body"}, comparison_method='contains')[0]
for row in body.children: for row in body.children:
for col in row.children: for col in row.children:
if hasattr(col, "attrs"):
col_id = col.attrs["data-col"] col_id = col.attrs["data-col"]
cell_value = _get_cell_content_value(col) cell_value = _get_cell_content_value(col)
res[header_map[col_id]].append(cell_value) res[header_map[col_id]].append(cell_value)

View File

@@ -509,3 +509,18 @@ def test_i_can_compute_footer_menu_position_when_not_enough_space(dg):
) )
assert matches(menu, expected) assert matches(menu, expected)
def test_the_content_of_the_cell_is_escaped(empty_dg):
df = pd.DataFrame({
'value': ['<div> My Content </div>'],
'value2': ['{My Content}'],
})
my_dg = empty_dg.init_from_dataframe(df)
actual = my_dg.__ft__()
table_content = extract_table_values_new(actual, header=True)
assert table_content == OrderedDict({
'value': ['&lt;div&gt; My Content &lt;/div&gt;'],
'value2': ['{My Content}']})