diff --git a/.claude/commands/developer-control.md b/.claude/commands/developer-control.md index 9035fda..f40b114 100644 --- a/.claude/commands/developer-control.md +++ b/.claude/commands/developer-control.md @@ -340,6 +340,124 @@ def mk_content(self): --- +### DEV-CONTROL-20: HTMX Ajax Requests + +**Always specify a `target` in HTMX ajax requests.** + +```javascript +// ❌ INCORRECT: Without target, HTMX doesn't know where to swap the response +htmx.ajax('POST', '/url', { + values: {param: value} +}); + +// ✅ CORRECT: Explicitly specify the target +htmx.ajax('POST', '/url', { + target: '#element-id', + values: {param: value} +}); +``` + +**Exception:** Response contains elements with `hx-swap-oob="true"`. + +--- + +### DEV-CONTROL-21: HTMX Swap Modes and Event Listeners + +**`hx-on::after-settle` only works when the swapped element replaces the target (`outerHTML`).** + +```javascript +// ❌ INCORRECT: innerHTML (default) nests the returned element +// The hx-on::after-settle attribute on the returned element is never processed +htmx.ajax('POST', '/url', { + target: '#my-element' + // swap: 'innerHTML' is the default +}); + +// ✅ CORRECT: outerHTML replaces the entire element +// The hx-on::after-settle attribute on the returned element works +htmx.ajax('POST', '/url', { + target: '#my-element', + swap: 'outerHTML' +}); +``` + +**Why:** +- `innerHTML`: replaces **content** → `
new
` (duplicate ID) +- `outerHTML`: replaces **element** → `
new
` (correct) + +--- + +### DEV-CONTROL-22: Reinitializing Event Listeners + +**After an HTMX swap, event listeners attached via JavaScript are lost and must be reinitialized.** + +**Recommended pattern:** + +1. Create a reusable initialization function: +```javascript +function initMyControl(controlId) { + const element = document.getElementById(controlId); + // Attach event listeners + element.addEventListener('click', handleClick); +} +``` + +2. Call this function after swap via `hx-on::after-settle`: +```python +extra_attr = { + "hx-on::after-settle": f"initMyControl('{self._id}');" +} +element.attrs.update(extra_attr) +``` + +**Alternative:** Use event delegation on a stable parent element. + +--- + +### DEV-CONTROL-23: Avoiding Duplicate IDs with HTMX + +**If the element returned by the server has the same ID as the HTMX target, use `swap: 'outerHTML'`.** + +```python +# Server returns an element with id="my-element" +def render_partial(self): + return Div(id="my-element", ...) # Same ID as target + +# JavaScript must use outerHTML +htmx.ajax('POST', '/url', { + target: '#my-element', + swap: 'outerHTML' # ✅ Replaces the entire element +}); +``` + +**Why:** `innerHTML` would create `
...
` (invalid duplicate ID). + +--- + +### DEV-CONTROL-24: Pattern extra_attr for HTMX + +**Use the `extra_attr` pattern to add post-swap behaviors.** + +```python +def render_partial(self, fragment="default"): + extra_attr = { + "hx-on::after-settle": f"initControl('{self._id}');", + # Other HTMX attributes if needed + } + + element = self.mk_element() + element.attrs.update(extra_attr) + return element +``` + +**Use cases:** +- Reinitialize event listeners +- Execute animations +- Update other DOM elements +- Logging or tracking events + +--- + ## Complete Control Template ```python @@ -378,7 +496,7 @@ class Commands(BaseCommands): return Command("Toggle", "Toggle visibility", self._owner, - self._owner.toggle + self._owner.handle_toggle ).htmx(target=f"#{self._id}") @@ -391,7 +509,7 @@ class MyControl(MultipleInstance): logger.debug(f"MyControl created with id={self._id}") - def toggle(self): + def handle_toggle(self): self._state.visible = not self._state.visible return self diff --git a/src/myfasthtml/assets/datagrid/datagrid.js b/src/myfasthtml/assets/datagrid/datagrid.js index de25794..9e9a891 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.js +++ b/src/myfasthtml/assets/datagrid/datagrid.js @@ -6,6 +6,32 @@ function initDataGrid(gridId) { updateDatagridSelection(gridId) } +/** + * Set width for a column and reinitialize handlers. + * Updates all cells (header + body + footer) and reattaches event listeners. + * + * @param {string} gridId - The DataGrid instance ID + * @param {string} colId - The column ID + * @param {number} width - The new width in pixels + */ +function setColumnWidth(gridId, colId, width) { + const table = document.getElementById(`t_${gridId}`); + if (!table) { + console.error(`Table with id "t_${gridId}" not found.`); + return; + } + + // Update all cells in the column (header + body + footer) + const cells = table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`); + cells.forEach(cell => { + cell.style.width = `${width}px`; + }); + + // Reinitialize resize and move handlers (lost after header re-render) + makeDatagridColumnsResizable(gridId); + makeDatagridColumnsMovable(gridId); +} + /** * Initialize DataGrid hover effects using event delegation. @@ -457,17 +483,21 @@ function makeDatagridColumnsResizable(datagridId) { 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}"]`); + const colId = cell.getAttribute('data-col'); + const resetCommandId = handle.dataset.resetCommandId; - // 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); + // Call server command to calculate and apply optimal width + if (resetCommandId) { + htmx.ajax('POST', '/myfasthtml/commands', { + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + target: '#th_' + datagridId, + swap: 'outerHTML', + values: { + c_id: resetCommandId, + col_id: colId + } + }); + } } } diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 83fb0f9..17c2b4a 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -33,7 +33,7 @@ from myfasthtml.core.formatting.dsl.parser import DSLParser from myfasthtml.core.formatting.engine import FormattingEngine from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.optimized_ft import OptimizedDiv -from myfasthtml.core.utils import make_safe_id, merge_classes, make_unique_safe_id +from myfasthtml.core.utils import make_safe_id, merge_classes, make_unique_safe_id, is_null from myfasthtml.icons.carbon import row, column, grid from myfasthtml.icons.fluent import checkbox_unchecked16_regular from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular, column_edit20_regular @@ -102,6 +102,9 @@ class DatagridSettings(DbObject): class DatagridStore(DbObject): + """ + Store Dataframes + """ def __init__(self, owner, save_state): with self.initializing(): super().__init__(owner, name=f"{owner.get_id()}#df", save_state=save_state) @@ -128,7 +131,7 @@ class Commands(BaseCommands): return Command("SetColumnWidth", "Set column width after resize", self._owner, - self._owner.set_column_width + self._owner.handle_set_column_width ).htmx(target=None) def move_column(self): @@ -137,7 +140,14 @@ class Commands(BaseCommands): self._owner, self._owner.move_column ).htmx(target=None) - + + def reset_column_width(self): + return Command("ResetColumnWidth", + "Auto-size column to fit content", + self._owner, + self._owner.reset_column_width + ).htmx(target=f"#th_{self._id}") + def filter(self): return Command("Filter", "Filter Grid", @@ -522,7 +532,7 @@ class DataGrid(MultipleInstance): row_dict[col_def.col_id] = default_value self._df_store.save() - def set_column_width(self, col_id: str, width: str): + def handle_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: @@ -561,7 +571,69 @@ class DataGrid(MultipleInstance): self._state.columns.insert(target_idx, col) self._state.save() - + + def calculate_optimal_column_width(self, col_id: str) -> int: + """ + Calculate optimal width for a column based on content. + + Considers both the title length and the maximum data length in the column, + then applies a formula to estimate pixel width. + + Args: + col_id: Column identifier + + Returns: + Optimal width in pixels (between 50 and 500) + """ + col_def = next((c for c in self._state.columns if c.col_id == col_id), None) + if not col_def: + logger.warning(f"calculate_optimal_column_width: column not found {col_id=}") + return 150 # default width + + # Title length + title_length = len(col_def.title) + + # Max data length + max_data_length = 0 + if col_id in self._df_store.ns_fast_access: + col_array = self._df_store.ns_fast_access[col_id] + if col_array is not None and len(col_array) > 0: + max_data_length = max(len(str(v)) for v in col_array) + + # Calculate width (8px per char + 30px padding) + max_length = max(title_length, max_data_length) + optimal_width = max_length * 8 + 30 + + # Apply limits (50px min, 500px max) + return max(50, min(optimal_width, 500)) + + def reset_column_width(self, col_id: str): + """ + Auto-size column to fit content. Called via Command from JS double-click. + + Calculates the optimal width based on the longest content in the column + and applies it to all cells. Updates both the state and the visual display. + + Args: + col_id: Column identifier to reset + + Returns: + Updated header with script to update body cells + """ + logger.debug(f"reset_column_width: {col_id=}") + optimal_width = self.calculate_optimal_column_width(col_id) + + # Update and persist + for col in self._state.columns: + if col.col_id == col_id: + col.width = optimal_width + break + + self._state.save() + + # Return updated header with script to update body cells via after-settle + return self.render_partial("header", col_id=col_id, optimal_width=optimal_width) + def filter(self): logger.debug("filter") self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query() @@ -648,7 +720,8 @@ class DataGrid(MultipleInstance): def mk_headers(self): resize_cmd = self.commands.set_column_width() move_cmd = self.commands.move_column() - + reset_cmd = self.commands.reset_column_width() + def _mk_header_name(col_def: DataGridColumnState): return Div( mk.label(col_def.title, icon=IconsHelper.get(col_def.type)), @@ -656,14 +729,14 @@ class DataGrid(MultipleInstance): cls="flex truncate cursor-default", data_tooltip=col_def.title, ) - + def _mk_header(col_def: DataGridColumnState): if not col_def.visible: return None - + return Div( _mk_header_name(col_def), - Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id), + Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id, data_reset_command_id=reset_cmd.id), style=f"width:{col_def.width}px;", data_col=col_def.col_id, data_tooltip=col_def.title, @@ -749,7 +822,7 @@ class DataGrid(MultipleInstance): style, formatted_value = self._formatting_engine.apply_format(rules, value, row_data) # Use formatted value or convert to string - value_str = formatted_value if formatted_value is not None else str(value) + value_str = formatted_value if formatted_value is not None else str(value) if not is_null(value) else "" # OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex) if _HTML_SPECIAL_CHARS_REGEX.search(value_str): @@ -993,31 +1066,43 @@ class DataGrid(MultipleInstance): style="height: 100%; grid-template-rows: auto 1fr;" ) - def render_partial(self, fragment="cell"): + def render_partial(self, fragment="cell", **kwargs): """ - - :param fragment: cell | body - :param redraw_scrollbars: + + :param fragment: cell | body | table | header + :param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header) :return: """ res = [] - + extra_attr = { "hx-on::after-settle": f"initDataGrid('{self._id}');", } - + if fragment == "body": body_container = self.mk_body_wrapper() body_container.attrs.update(extra_attr) res.append(body_container) - + elif fragment == "table": table = self.mk_table() table.attrs.update(extra_attr) res.append(table) - + + elif fragment == "header": + col_id = kwargs.get("col_id") + optimal_width = kwargs.get("optimal_width") + + header_extra_attr = { + "hx-on::after-settle": f"setColumnWidth('{self._id}', '{col_id}', '{optimal_width}');", + } + + header = self.mk_headers() + header.attrs.update(header_extra_attr) + return header + res.append(self.mk_selection_manager()) - + return tuple(res) def dispose(self): diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index b194124..65cd21a 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -2,6 +2,7 @@ import importlib import logging import re +import pandas as pd from bs4 import Tag from fastcore.xml import FT from fasthtml.fastapp import fast_app @@ -10,9 +11,9 @@ from rich.table import Table from starlette.routing import Mount from myfasthtml.core.constants import Routes, ROUTE_ROOT +from myfasthtml.core.dsl.exceptions import DSLSyntaxError from myfasthtml.core.dsl.types import Position from myfasthtml.core.dsls import DslsManager -from myfasthtml.core.dsl.exceptions import DSLSyntaxError from myfasthtml.test.MyFT import MyFT utils_app, utils_rt = fast_app() @@ -181,6 +182,10 @@ def is_datalist(elt): return False +def is_null(v): + return v is None or pd.isna(v) or pd.isnull(v) + + def quoted_str(s): if s is None: return "None" @@ -326,7 +331,7 @@ def make_safe_id(s: str | None): def make_unique_safe_id(s: str | None, existing_ids: list): if s is None: return None - + base = make_safe_id(s) res = base i = 1 @@ -335,6 +340,7 @@ def make_unique_safe_id(s: str | None, existing_ids: list): i += 1 return res + def get_class(qualified_class_name: str): """ Dynamically loads and returns a class type from its fully qualified name.