I can resize Datagrid columns
This commit is contained in:
@@ -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
|
||||||
*
|
*
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +157,7 @@ class DataGrid(MultipleInstance):
|
|||||||
|
|
||||||
if df is not None:
|
if df is not None:
|
||||||
self._state.ne_df = df
|
self._state.ne_df = df
|
||||||
|
if init_state:
|
||||||
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
|
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.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.columns = _init_columns(df) # use df not self._df to keep the original title
|
||||||
@@ -154,7 +166,19 @@ class DataGrid(MultipleInstance):
|
|||||||
|
|
||||||
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%;"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user