I can resize Datagrid columns

This commit is contained in:
2026-01-18 19:12:13 +01:00
parent 500340fbd3
commit 509a7b7778
5 changed files with 186 additions and 28 deletions

View File

@@ -1493,6 +1493,11 @@ function updateTabs(controllerId) {
}; };
})(); })();
function initDataGrid(gridId) {
initDataGridScrollbars(gridId);
makeDatagridColumnsResizable(gridId)
}
/** /**
* Initialize DataGrid with CSS Grid layout + Custom Scrollbars * Initialize DataGrid with CSS Grid layout + Custom Scrollbars
* *
@@ -1731,7 +1736,7 @@ function initDataGridScrollbars(gridId) {
event.preventDefault(); event.preventDefault();
}; };
wrapper.addEventListener("wheel", handleWheelScrolling, { passive: false }); wrapper.addEventListener("wheel", handleWheelScrolling, {passive: false});
// Initialize scrollbars with single batched update // Initialize scrollbars with single batched update
updateScrollbars(); updateScrollbars();
@@ -1749,3 +1754,104 @@ function initDataGridScrollbars(gridId) {
}); });
} }
function makeDatagridColumnsResizable(datagridId) {
console.debug("makeResizable on element " + datagridId);
const tableId = 't_' + datagridId;
const table = document.getElementById(tableId);
const resizeHandles = table.querySelectorAll('.dt2-resize-handle');
const MIN_WIDTH = 30; // Prevent columns from becoming too narrow
// Attach event listeners using delegation
resizeHandles.forEach(handle => {
handle.addEventListener('mousedown', onStartResize);
handle.addEventListener('touchstart', onStartResize, {passive: false});
handle.addEventListener('dblclick', onDoubleClick); // Reset column width
});
let resizingState = null; // Maintain resizing state information
function onStartResize(event) {
event.preventDefault(); // Prevent unintended selections
const isTouch = event.type === 'touchstart';
const startX = isTouch ? event.touches[0].pageX : event.pageX;
const handle = event.target;
const cell = handle.parentElement;
const colIndex = cell.getAttribute('data-col');
const commandId = handle.dataset.commandId;
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
// Store initial state
const startWidth = cell.offsetWidth + 8;
resizingState = {startX, startWidth, colIndex, commandId, cells};
// Attach event listeners for resizing
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onResize);
document.addEventListener(isTouch ? 'touchend' : 'mouseup', onStopResize);
}
function onResize(event) {
if (!resizingState) {
return;
}
const isTouch = event.type === 'touchmove';
const currentX = isTouch ? event.touches[0].pageX : event.pageX;
const {startX, startWidth, cells} = resizingState;
// Calculate new width and apply constraints
const newWidth = Math.max(MIN_WIDTH, startWidth + (currentX - startX));
cells.forEach(cell => {
cell.style.width = `${newWidth}px`;
});
}
function onStopResize(event) {
if (!resizingState) {
return;
}
const {colIndex, commandId, cells} = resizingState;
const finalWidth = cells[0].offsetWidth;
// Send width update to server via HTMX
if (commandId) {
htmx.ajax('POST', '/myfasthtml/commands', {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
swap: 'none',
values: {
c_id: commandId,
col_id: colIndex,
width: finalWidth
}
});
}
// Clean up
resizingState = null;
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', onStopResize);
document.removeEventListener('touchmove', onResize);
document.removeEventListener('touchend', onStopResize);
}
function onDoubleClick(event) {
const handle = event.target;
const cell = handle.parentElement;
const colIndex = cell.getAttribute('data-col');
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
// Reset column width
cells.forEach(cell => {
cell.style.width = ''; // Use CSS default width
});
// Emit reset event
const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}});
table.dispatchEvent(resetEvent);
}
}

View File

@@ -1,4 +1,5 @@
import html import html
import logging
import re import re
from functools import lru_cache from functools import lru_cache
from typing import Optional from typing import Optional
@@ -23,6 +24,8 @@ from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters # OPTIMIZATION: Pre-compiled regex to detect HTML special characters
_HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']') _HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']')
logger = logging.getLogger("Datagrid")
@lru_cache(maxsize=2) @lru_cache(maxsize=2)
def _mk_bool_cached(_value): def _mk_bool_cached(_value):
@@ -38,8 +41,8 @@ def _mk_bool_cached(_value):
class DatagridState(DbObject): class DatagridState(DbObject):
def __init__(self, owner, save_state): def __init__(self, owner, save_state):
super().__init__(owner, name=f"{owner.get_full_id()}-state", save_state=save_state)
with self.initializing(): with self.initializing():
super().__init__(owner, name=f"{owner.get_full_id()}#state", save_state=save_state)
self.sidebar_visible: bool = False self.sidebar_visible: bool = False
self.selected_view: str = None self.selected_view: str = None
self.row_index: bool = True self.row_index: bool = True
@@ -59,8 +62,9 @@ class DatagridState(DbObject):
class DatagridSettings(DbObject): class DatagridSettings(DbObject):
def __init__(self, owner, save_state): def __init__(self, owner, save_state):
super().__init__(owner, name=f"{owner.get_full_id()}-settings", save_state=save_state)
with self.initializing(): with self.initializing():
super().__init__(owner, name=f"{owner.get_full_id()}#settings", save_state=save_state)
self.save_state = save_state is True
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
self.header_visible: bool = True self.header_visible: bool = True
@@ -83,20 +87,27 @@ class Commands(BaseCommands):
auto_swap_oob=False auto_swap_oob=False
) )
def set_column_width(self):
return Command("SetColumnWidth",
"Set column width after resize",
self._owner,
self._owner.set_column_width
).htmx(target=None)
class DataGrid(MultipleInstance): class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, save_state=False, _id=None): def __init__(self, parent, settings=None, save_state=None, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self._settings = settings or DatagridSettings(self, save_state=save_state) self._settings = settings or DatagridSettings(self, save_state=save_state)
self._state = DatagridState(self, save_state=save_state) self._state = DatagridState(self, save_state=self._settings.save_state)
self.commands = Commands(self) self.commands = Commands(self)
self.init_from_dataframe(self._state.ne_df) self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
@property @property
def _df(self): def _df(self):
return self._state.ne_df return self._state.ne_df
def init_from_dataframe(self, df): def init_from_dataframe(self, df, init_state=True):
def _get_column_type(dtype): def _get_column_type(dtype):
if pd.api.types.is_integer_dtype(dtype): if pd.api.types.is_integer_dtype(dtype):
@@ -146,15 +157,28 @@ class DataGrid(MultipleInstance):
if df is not None: if df is not None:
self._state.ne_df = df self._state.ne_df = df
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed if init_state:
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index] self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
self._state.columns = _init_columns(df) # use df not self._df to keep the original title 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_fast_access = _init_fast_access(self._df)
self._state.ns_total_rows = len(self._df) if self._df is not None else 0 self._state.ns_total_rows = len(self._df) if self._df is not None else 0
return self return self
def set_column_width(self, col_id: str, width: str):
"""Update column width after resize. Called via Command from JS."""
logger.debug(f"set_column_width: {col_id=} {width=}")
for col in self._state.columns:
if col.col_id == col_id:
col.width = int(width)
break
self._state.save()
def mk_headers(self): def mk_headers(self):
resize_cmd = self.commands.set_column_width()
def _mk_header_name(col_def: DataGridColumnState): def _mk_header_name(col_def: DataGridColumnState):
return Div( return Div(
mk.label(col_def.title, name="dt2-header-title"), mk.label(col_def.title, name="dt2-header-title"),
@@ -164,7 +188,7 @@ class DataGrid(MultipleInstance):
def _mk_header(col_def: DataGridColumnState): def _mk_header(col_def: DataGridColumnState):
return Div( return Div(
_mk_header_name(col_def), _mk_header_name(col_def),
Div(cls="dt2-resize-handle"), Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id),
style=f"width:{col_def.width}px;", style=f"width:{col_def.width}px;",
data_col=col_def.col_id, data_col=col_def.col_id,
data_tooltip=col_def.title, data_tooltip=col_def.title,
@@ -406,7 +430,7 @@ class DataGrid(MultipleInstance):
return Div( return Div(
self.mk_table(), self.mk_table(),
Script(f"initDataGridScrollbars('{self._id}');"), Script(f"initDataGrid('{self._id}');"),
id=self._id, id=self._id,
style="height: 100%;" style="height: 100%;"
) )

View File

@@ -24,7 +24,7 @@ class DocumentDefinition:
document_id: str document_id: str
namespace: str namespace: str
name: str name: str
type: str # table, card, type: str # table, card,
tab_id: str tab_id: str
datagrid_id: str datagrid_id: str
@@ -92,7 +92,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(BytesIO(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) # first time the Datagrid is created
dg.init_from_dataframe(df) dg.init_from_dataframe(df)
document = DocumentDefinition( document = DocumentDefinition(
document_id=str(uuid.uuid4()), document_id=str(uuid.uuid4()),
@@ -112,7 +112,7 @@ class DataGridsManager(MultipleInstance):
document_id = self._tree.get_bag(node_id) document_id = self._tree.get_bag(node_id)
try: try:
document = next(filter(lambda x: x.document_id == document_id, self._state.elements)) document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg) return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
except StopIteration: except StopIteration:
# the selected node is not a document (it's a folder) # the selected node is not a document (it's a folder)
@@ -136,7 +136,7 @@ class DataGridsManager(MultipleInstance):
raise ValueError(f"No document found for tab {tab_id}") raise ValueError(f"No document found for tab {tab_id}")
# Recreate the DataGrid with its saved state # Recreate the DataGrid with its saved state
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
# Wrap in Panel # Wrap in Panel
return Panel(self).set_main(dg) return Panel(self).set_main(dg)

View File

@@ -129,7 +129,7 @@ class TabsManager(MultipleInstance):
# 2. Get or create parent # 2. Get or create parent
if tab_config["component_parent"] is None: if tab_config["component_parent"] is None:
logger.error(f"No parent defined for tab {tab_id}") logger.error(f"No parent defined for tab {tab_id}")
return Div("Failed to retrieve tab content.") return Div("Failed to retrieve tab content (no parent).")
parent = InstancesManager.get(self._session, tab_config["component_parent"][1], None) parent = InstancesManager.get(self._session, tab_config["component_parent"][1], None)
if parent is None: if parent is None:
@@ -146,11 +146,11 @@ class TabsManager(MultipleInstance):
return content return content
except Exception as e: except Exception as e:
logger.error(f"Error while parent creating tab content: {e}") logger.error(f"Error while parent creating tab content: {e}")
return Div("Failed to retrieve tab content.") return Div("Failed to retrieve tab content (cannot create).")
else: else:
# Parent doesn't support create_tab_content, fallback to error # 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") 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 (create tab not supported).")
def _get_or_create_tab_content(self, tab_id): def _get_or_create_tab_content(self, tab_id):
""" """

View File

@@ -1,3 +1,4 @@
import logging
from contextlib import contextmanager from contextlib import contextmanager
from types import SimpleNamespace from types import SimpleNamespace
@@ -6,6 +7,8 @@ from dbengine.dbengine import DbEngine
from myfasthtml.core.instances import SingleInstance, BaseInstance from myfasthtml.core.instances import SingleInstance, BaseInstance
from myfasthtml.core.utils import retrieve_user_info from myfasthtml.core.utils import retrieve_user_info
logger = logging.getLogger("DbManager")
class DbManager(SingleInstance): class DbManager(SingleInstance):
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True): def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
@@ -52,8 +55,8 @@ class DbObject:
try: try:
yield yield
finally: finally:
self._finalize_initialization()
self._initializing = old_state self._initializing = old_state
self._finalize_initialization()
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):
@@ -69,11 +72,24 @@ class DbObject:
self._save_self() self._save_self()
def _finalize_initialization(self): def _finalize_initialization(self):
if getattr(self, "_initializing", False):
return # still under initialization
if self._reload_self():
logger.debug(f"finalize_initialization ({self._name}) : Loaded existing content.")
return
else:
logger.debug(
f"finalize_initialization ({self._name}) : No existing content found, creating new entry {self._save_state=}.")
self._save_self()
def _reload_self(self):
if self._db_manager.exists_entry(self._name): if self._db_manager.exists_entry(self._name):
props = self._db_manager.load(self._name) props = self._db_manager.load(self._name)
self.update(props) self.update(props)
else: return True
self._save_self()
return False
def _save_self(self): def _save_self(self):
if not self._save_state: if not self._save_state:
@@ -125,3 +141,15 @@ class DbObject:
as_dict = self._get_properties().copy() as_dict = self._get_properties().copy()
as_dict = {k: v for k, v in as_dict.items() if k not in DbObject._forbidden_attrs} as_dict = {k: v for k, v in as_dict.items() if k not in DbObject._forbidden_attrs}
return SimpleNamespace(**as_dict) return SimpleNamespace(**as_dict)
def save(self):
self._save_self()
def reload(self):
self._reload_self()
def exists(self):
return self._db_manager.exists_entry(self._name)
def get_id(self):
return self._name