diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js index 16e159b..0ffe77b 100644 --- a/src/myfasthtml/assets/myfasthtml.js +++ b/src/myfasthtml/assets/myfasthtml.js @@ -1493,6 +1493,11 @@ function updateTabs(controllerId) { }; })(); +function initDataGrid(gridId) { + initDataGridScrollbars(gridId); + makeDatagridColumnsResizable(gridId) +} + /** * Initialize DataGrid with CSS Grid layout + Custom Scrollbars * @@ -1731,7 +1736,7 @@ function initDataGridScrollbars(gridId) { event.preventDefault(); }; - wrapper.addEventListener("wheel", handleWheelScrolling, { passive: false }); + wrapper.addEventListener("wheel", handleWheelScrolling, {passive: false}); // Initialize scrollbars with single batched update 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); + } +} diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 755bab2..0b9c618 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -1,4 +1,5 @@ import html +import logging import re from functools import lru_cache 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 _HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']') +logger = logging.getLogger("Datagrid") + @lru_cache(maxsize=2) def _mk_bool_cached(_value): @@ -38,8 +41,8 @@ def _mk_bool_cached(_value): class DatagridState(DbObject): def __init__(self, owner, save_state): - super().__init__(owner, name=f"{owner.get_full_id()}-state", save_state=save_state) with self.initializing(): + super().__init__(owner, name=f"{owner.get_full_id()}#state", save_state=save_state) self.sidebar_visible: bool = False self.selected_view: str = None self.row_index: bool = True @@ -59,8 +62,9 @@ class DatagridState(DbObject): class DatagridSettings(DbObject): def __init__(self, owner, save_state): - super().__init__(owner, name=f"{owner.get_full_id()}-settings", save_state=save_state) 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.selected_sheet_name: Optional[str] = None self.header_visible: bool = True @@ -82,21 +86,28 @@ class Commands(BaseCommands): trigger=f"intersect root:#tb_{self._id} once", 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): - 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) 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.init_from_dataframe(self._state.ne_df) + self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState @property def _df(self): 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): if pd.api.types.is_integer_dtype(dtype): @@ -146,15 +157,28 @@ class DataGrid(MultipleInstance): 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 + if init_state: + 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 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): + resize_cmd = self.commands.set_column_width() + def _mk_header_name(col_def: DataGridColumnState): return Div( mk.label(col_def.title, name="dt2-header-title"), @@ -164,7 +188,7 @@ class DataGrid(MultipleInstance): def _mk_header(col_def: DataGridColumnState): return Div( _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;", data_col=col_def.col_id, data_tooltip=col_def.title, @@ -403,10 +427,10 @@ class DataGrid(MultipleInstance): def render(self): if self._state.ne_df is None: return Div("No data to display !") - + return Div( self.mk_table(), - Script(f"initDataGridScrollbars('{self._id}');"), + Script(f"initDataGrid('{self._id}');"), id=self._id, style="height: 100%;" ) diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index e5f62d8..33594fd 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -24,7 +24,7 @@ class DocumentDefinition: document_id: str namespace: str name: str - type: str # table, card, + type: str # table, card, tab_id: str datagrid_id: str @@ -92,7 +92,7 @@ class DataGridsManager(MultipleInstance): def open_from_excel(self, tab_id, file_upload: FileUpload): excel_content = file_upload.get_content() 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) document = DocumentDefinition( document_id=str(uuid.uuid4()), @@ -112,12 +112,12 @@ class DataGridsManager(MultipleInstance): document_id = self._tree.get_bag(node_id) try: 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) except StopIteration: # 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. @@ -131,16 +131,16 @@ class DataGridsManager(MultipleInstance): """ # 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) - + dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings + # Wrap in Panel return Panel(self).set_main(dg) - + def clear_tree(self): self._state.elements = [] self._tree.clear() diff --git a/src/myfasthtml/controls/TabsManager.py b/src/myfasthtml/controls/TabsManager.py index 81ce3c2..1d0303c 100644 --- a/src/myfasthtml/controls/TabsManager.py +++ b/src/myfasthtml/controls/TabsManager.py @@ -129,7 +129,7 @@ class TabsManager(MultipleInstance): # 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.") + return Div("Failed to retrieve tab content (no parent).") parent = InstancesManager.get(self._session, tab_config["component_parent"][1], None) if parent is None: @@ -146,11 +146,11 @@ class TabsManager(MultipleInstance): return content except Exception as 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: # 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 (create tab not supported).") def _get_or_create_tab_content(self, tab_id): """ diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py index 7734432..8f883a7 100644 --- a/src/myfasthtml/core/dbmanager.py +++ b/src/myfasthtml/core/dbmanager.py @@ -1,3 +1,4 @@ +import logging from contextlib import contextmanager from types import SimpleNamespace @@ -6,6 +7,8 @@ from dbengine.dbengine import DbEngine from myfasthtml.core.instances import SingleInstance, BaseInstance from myfasthtml.core.utils import retrieve_user_info +logger = logging.getLogger("DbManager") + class DbManager(SingleInstance): def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True): @@ -52,8 +55,8 @@ class DbObject: try: yield finally: - self._finalize_initialization() self._initializing = old_state + self._finalize_initialization() def __setattr__(self, name: str, value: str): if name.startswith("_") or name.startswith("ns_") or getattr(self, "_initializing", False): @@ -69,11 +72,24 @@ class DbObject: self._save_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): props = self._db_manager.load(self._name) self.update(props) - else: - self._save_self() + return True + + return False def _save_self(self): if not self._save_state: @@ -125,3 +141,15 @@ class DbObject: as_dict = self._get_properties().copy() as_dict = {k: v for k, v in as_dict.items() if k not in DbObject._forbidden_attrs} 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