Fixed double-click handling for column width reset
This commit is contained in:
@@ -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** → `<div id="X"><div id="X" hx-on::...>new</div></div>` (duplicate ID)
|
||||
- `outerHTML`: replaces **element** → `<div id="X" hx-on::...>new</div>` (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 `<div id="X"><div id="X">...</div></div>` (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
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
}
|
||||
});
|
||||
|
||||
// Emit reset event
|
||||
const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}});
|
||||
table.dispatchEvent(resetEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -138,6 +141,13 @@ class Commands(BaseCommands):
|
||||
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:
|
||||
@@ -562,6 +572,68 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
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,6 +720,7 @@ 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(
|
||||
@@ -663,7 +736,7 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
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,11 +1066,11 @@ 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 = []
|
||||
@@ -1016,6 +1089,18 @@ class DataGrid(MultipleInstance):
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user