From 191ead1c89f7bfe21ab430952000f6190aa1f3b2 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Fri, 23 Jan 2026 21:26:19 +0100 Subject: [PATCH] First version of DataGridQuery. Fixed scrollbar issue --- src/myfasthtml/assets/myfasthtml.js | 469 +++++++++++----------- src/myfasthtml/controls/DataGrid.py | 111 +++-- src/myfasthtml/controls/DataGridFilter.py | 76 ---- src/myfasthtml/controls/DataGridQuery.py | 97 +++++ src/myfasthtml/core/commands.py | 6 +- src/myfasthtml/core/optimized_ft.py | 4 + 6 files changed, 432 insertions(+), 331 deletions(-) delete mode 100644 src/myfasthtml/controls/DataGridFilter.py create mode 100644 src/myfasthtml/controls/DataGridQuery.py diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js index 75cea03..33b88ef 100644 --- a/src/myfasthtml/assets/myfasthtml.js +++ b/src/myfasthtml/assets/myfasthtml.js @@ -636,7 +636,7 @@ function updateTabs(controllerId) { // Add key to current pressed keys KeyboardRegistry.currentKeys.add(key); - console.debug("Received key", key); + // console.debug("Received key", key); // Create a snapshot of current keyboard state const snapshot = new Set(KeyboardRegistry.currentKeys); @@ -671,7 +671,7 @@ function updateTabs(controllerId) { if (!currentNode) { // No match in this tree, continue to next element - console.debug("No match in tree for event", key); + // console.debug("No match in tree for event", key); continue; } @@ -1289,7 +1289,7 @@ function updateTabs(controllerId) { return; } - console.debug("Right-click on registered element", elementId); + //console.debug("Right-click on registered element", elementId); // For right-click, clicked_inside is always true (we only trigger if clicked on element) const clickedInside = true; @@ -1322,7 +1322,7 @@ function updateTabs(controllerId) { if (!currentNode) { // No match in this tree - console.debug("No match in tree for right-click"); + //console.debug("No match in tree for right-click"); // Clear history for invalid sequences MouseRegistry.snapshotHistory = []; return; @@ -1518,6 +1518,14 @@ function initDataGridScrollbars(gridId) { return; } + // Cleanup previous listeners if any + if (wrapper._scrollbarAbortController) { + wrapper._scrollbarAbortController.abort(); + } + wrapper._scrollbarAbortController = new AbortController(); + const signal = wrapper._scrollbarAbortController.signal; + + const verticalScrollbar = wrapper.querySelector(".dt2-scrollbars-vertical"); const verticalWrapper = wrapper.querySelector(".dt2-scrollbars-vertical-wrapper"); const horizontalScrollbar = wrapper.querySelector(".dt2-scrollbars-horizontal"); @@ -1577,7 +1585,6 @@ function initDataGridScrollbars(gridId) { }; // PHASE 2: Calculate all values - const contentWidth = Math.max(metrics.headerScrollWidth, metrics.bodyScrollWidth); // Visibility @@ -1649,7 +1656,7 @@ function initDataGridScrollbars(gridId) { dragStartY = e.clientY; dragStartScrollTop = cachedBodyScrollTop; wrapper.setAttribute("mf-no-tooltip", ""); - }); + }, { signal }); // Horizontal scrollbar mousedown horizontalScrollbar.addEventListener("mousedown", (e) => { @@ -1657,7 +1664,7 @@ function initDataGridScrollbars(gridId) { dragStartX = e.clientX; dragStartScrollLeft = cachedTableScrollLeft; wrapper.setAttribute("mf-no-tooltip", ""); - }); + }, { signal }); // Consolidated mousemove listener document.addEventListener("mousemove", (e) => { @@ -1688,7 +1695,7 @@ function initDataGridScrollbars(gridId) { }); } } - }); + }, { signal }); // Consolidated mouseup listener document.addEventListener("mouseup", () => { @@ -1699,7 +1706,7 @@ function initDataGridScrollbars(gridId) { isDraggingHorizontal = false; wrapper.removeAttribute("mf-no-tooltip"); } - }); + }, { signal }); // Wheel scrolling - OPTIMIZED with RAF throttling let rafScheduledWheel = false; @@ -1737,7 +1744,7 @@ function initDataGridScrollbars(gridId) { event.preventDefault(); }; - wrapper.addEventListener("wheel", handleWheelScrolling, {passive: false}); + wrapper.addEventListener("wheel", handleWheelScrolling, {passive: false, signal}); // Initialize scrollbars with single batched update updateScrollbars(); @@ -1752,109 +1759,109 @@ function initDataGridScrollbars(gridId) { updateScrollbars(); }); } - }); + }, { signal }); } function makeDatagridColumnsResizable(datagridId) { - console.debug("makeResizable on element " + 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 + 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 + // 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 }); - 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); - } + // Emit reset event + const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}}); + table.dispatchEvent(resetEvent); + } } /** @@ -1863,84 +1870,84 @@ function makeDatagridColumnsResizable(datagridId) { * @param {string} gridId - The DataGrid instance ID */ function makeDatagridColumnsMovable(gridId) { - const table = document.getElementById(`t_${gridId}`); - const headerRow = document.getElementById(`th_${gridId}`); + const table = document.getElementById(`t_${gridId}`); + const headerRow = document.getElementById(`th_${gridId}`); - if (!table || !headerRow) { - console.error(`DataGrid elements not found for ${gridId}`); - return; + if (!table || !headerRow) { + console.error(`DataGrid elements not found for ${gridId}`); + return; + } + + const moveCommandId = headerRow.dataset.moveCommandId; + const headerCells = headerRow.querySelectorAll('.dt2-cell:not(.dt2-col-hidden)'); + + let sourceColumn = null; // Column being dragged (original position) + let lastMoveTarget = null; // Last column we moved to (for persistence) + let hoverColumn = null; // Current hover target (for delayed move check) + + headerCells.forEach(cell => { + cell.setAttribute('draggable', 'true'); + + // Prevent drag when clicking resize handle + const resizeHandle = cell.querySelector('.dt2-resize-handle'); + if (resizeHandle) { + resizeHandle.addEventListener('mousedown', () => cell.setAttribute('draggable', 'false')); + resizeHandle.addEventListener('mouseup', () => cell.setAttribute('draggable', 'true')); } - const moveCommandId = headerRow.dataset.moveCommandId; - const headerCells = headerRow.querySelectorAll('.dt2-cell:not(.dt2-col-hidden)'); - - let sourceColumn = null; // Column being dragged (original position) - let lastMoveTarget = null; // Last column we moved to (for persistence) - let hoverColumn = null; // Current hover target (for delayed move check) - - headerCells.forEach(cell => { - cell.setAttribute('draggable', 'true'); - - // Prevent drag when clicking resize handle - const resizeHandle = cell.querySelector('.dt2-resize-handle'); - if (resizeHandle) { - resizeHandle.addEventListener('mousedown', () => cell.setAttribute('draggable', 'false')); - resizeHandle.addEventListener('mouseup', () => cell.setAttribute('draggable', 'true')); - } - - cell.addEventListener('dragstart', (e) => { - sourceColumn = cell.getAttribute('data-col'); - lastMoveTarget = null; - hoverColumn = null; - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', sourceColumn); - cell.classList.add('dt2-dragging'); - }); - - cell.addEventListener('dragenter', (e) => { - e.preventDefault(); - const targetColumn = cell.getAttribute('data-col'); - hoverColumn = targetColumn; - - if (sourceColumn && sourceColumn !== targetColumn) { - // Delay to skip columns when dragging fast - setTimeout(() => { - if (hoverColumn === targetColumn) { - moveColumn(table, sourceColumn, targetColumn); - lastMoveTarget = targetColumn; - } - }, 50); - } - }); - - cell.addEventListener('dragover', (e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - }); - - cell.addEventListener('drop', (e) => { - e.preventDefault(); - // Persist to server - if (moveCommandId && sourceColumn && lastMoveTarget) { - htmx.ajax('POST', '/myfasthtml/commands', { - headers: {"Content-Type": "application/x-www-form-urlencoded"}, - swap: 'none', - values: { - c_id: moveCommandId, - source_col_id: sourceColumn, - target_col_id: lastMoveTarget - } - }); - } - }); - - cell.addEventListener('dragend', () => { - headerCells.forEach(c => c.classList.remove('dt2-dragging')); - sourceColumn = null; - lastMoveTarget = null; - hoverColumn = null; - }); + cell.addEventListener('dragstart', (e) => { + sourceColumn = cell.getAttribute('data-col'); + lastMoveTarget = null; + hoverColumn = null; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', sourceColumn); + cell.classList.add('dt2-dragging'); }); + + cell.addEventListener('dragenter', (e) => { + e.preventDefault(); + const targetColumn = cell.getAttribute('data-col'); + hoverColumn = targetColumn; + + if (sourceColumn && sourceColumn !== targetColumn) { + // Delay to skip columns when dragging fast + setTimeout(() => { + if (hoverColumn === targetColumn) { + moveColumn(table, sourceColumn, targetColumn); + lastMoveTarget = targetColumn; + } + }, 50); + } + }); + + cell.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }); + + cell.addEventListener('drop', (e) => { + e.preventDefault(); + // Persist to server + if (moveCommandId && sourceColumn && lastMoveTarget) { + htmx.ajax('POST', '/myfasthtml/commands', { + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + swap: 'none', + values: { + c_id: moveCommandId, + source_col_id: sourceColumn, + target_col_id: lastMoveTarget + } + }); + } + }); + + cell.addEventListener('dragend', () => { + headerCells.forEach(c => c.classList.remove('dt2-dragging')); + sourceColumn = null; + lastMoveTarget = null; + hoverColumn = null; + }); + }); } /** @@ -1951,67 +1958,67 @@ function makeDatagridColumnsMovable(gridId) { * @param {string} targetColId - Column ID to move next to */ function moveColumn(table, sourceColId, targetColId) { - const ANIMATION_DURATION = 300; // Must match CSS transition duration + const ANIMATION_DURATION = 300; // Must match CSS transition duration - const sourceHeader = table.querySelector(`.dt2-cell[data-col="${sourceColId}"]`); - const targetHeader = table.querySelector(`.dt2-cell[data-col="${targetColId}"]`); + const sourceHeader = table.querySelector(`.dt2-cell[data-col="${sourceColId}"]`); + const targetHeader = table.querySelector(`.dt2-cell[data-col="${targetColId}"]`); - if (!sourceHeader || !targetHeader) return; - if (sourceHeader.classList.contains('dt2-moving')) return; // Animation in progress + if (!sourceHeader || !targetHeader) return; + if (sourceHeader.classList.contains('dt2-moving')) return; // Animation in progress - const headerCells = Array.from(sourceHeader.parentNode.children); - const sourceIdx = headerCells.indexOf(sourceHeader); - const targetIdx = headerCells.indexOf(targetHeader); + const headerCells = Array.from(sourceHeader.parentNode.children); + const sourceIdx = headerCells.indexOf(sourceHeader); + const targetIdx = headerCells.indexOf(targetHeader); - if (sourceIdx === targetIdx) return; + if (sourceIdx === targetIdx) return; - const movingRight = sourceIdx < targetIdx; - const sourceCells = table.querySelectorAll(`.dt2-cell[data-col="${sourceColId}"]`); + const movingRight = sourceIdx < targetIdx; + const sourceCells = table.querySelectorAll(`.dt2-cell[data-col="${sourceColId}"]`); - // Collect cells that need to shift (between source and target) - const cellsToShift = []; - let shiftWidth = 0; - const [startIdx, endIdx] = movingRight - ? [sourceIdx + 1, targetIdx] - : [targetIdx, sourceIdx - 1]; + // Collect cells that need to shift (between source and target) + const cellsToShift = []; + let shiftWidth = 0; + const [startIdx, endIdx] = movingRight + ? [sourceIdx + 1, targetIdx] + : [targetIdx, sourceIdx - 1]; - for (let i = startIdx; i <= endIdx; i++) { - const colId = headerCells[i].getAttribute('data-col'); - cellsToShift.push(...table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`)); - shiftWidth += headerCells[i].offsetWidth; - } + for (let i = startIdx; i <= endIdx; i++) { + const colId = headerCells[i].getAttribute('data-col'); + cellsToShift.push(...table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`)); + shiftWidth += headerCells[i].offsetWidth; + } - // Calculate animation distances - const sourceWidth = sourceHeader.offsetWidth; - const sourceDistance = movingRight ? shiftWidth : -shiftWidth; - const shiftDistance = movingRight ? -sourceWidth : sourceWidth; + // Calculate animation distances + const sourceWidth = sourceHeader.offsetWidth; + const sourceDistance = movingRight ? shiftWidth : -shiftWidth; + const shiftDistance = movingRight ? -sourceWidth : sourceWidth; - // Animate source column - sourceCells.forEach(cell => { - cell.classList.add('dt2-moving'); - cell.style.transform = `translateX(${sourceDistance}px)`; + // Animate source column + sourceCells.forEach(cell => { + cell.classList.add('dt2-moving'); + cell.style.transform = `translateX(${sourceDistance}px)`; + }); + + // Animate shifted columns + cellsToShift.forEach(cell => { + cell.classList.add('dt2-moving'); + cell.style.transform = `translateX(${shiftDistance}px)`; + }); + + // After animation: reset transforms and update DOM + setTimeout(() => { + [...sourceCells, ...cellsToShift].forEach(cell => { + cell.classList.remove('dt2-moving'); + cell.style.transform = ''; }); - // Animate shifted columns - cellsToShift.forEach(cell => { - cell.classList.add('dt2-moving'); - cell.style.transform = `translateX(${shiftDistance}px)`; + // Move source column in DOM + table.querySelectorAll('.dt2-row').forEach(row => { + const sourceCell = row.querySelector(`[data-col="${sourceColId}"]`); + const targetCell = row.querySelector(`[data-col="${targetColId}"]`); + if (sourceCell && targetCell) { + movingRight ? targetCell.after(sourceCell) : targetCell.before(sourceCell); + } }); - - // After animation: reset transforms and update DOM - setTimeout(() => { - [...sourceCells, ...cellsToShift].forEach(cell => { - cell.classList.remove('dt2-moving'); - cell.style.transform = ''; - }); - - // Move source column in DOM - table.querySelectorAll('.dt2-row').forEach(row => { - const sourceCell = row.querySelector(`[data-col="${sourceColId}"]`); - const targetCell = row.querySelector(`[data-col="${targetColId}"]`); - if (sourceCell && targetCell) { - movingRight ? targetCell.after(sourceCell) : targetCell.before(sourceCell); - } - }); - }, ANIMATION_DURATION); + }, ANIMATION_DURATION); } diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 2458269..12dee37 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -7,8 +7,10 @@ from typing import Optional import pandas as pd from fasthtml.common import NotStr from fasthtml.components import * +from pandas import DataFrame from myfasthtml.controls.BaseCommands import BaseCommands +from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState from myfasthtml.controls.helpers import mk @@ -94,13 +96,20 @@ class Commands(BaseCommands): self._owner, self._owner.set_column_width ).htmx(target=None) - + def move_column(self): return Command("MoveColumn", "Move column to new position", self._owner, self._owner.move_column ).htmx(target=None) + + def filter(self): + return Command("Filter", + "Filter Grid", + self._owner, + self._owner.filter + ) class DataGrid(MultipleInstance): @@ -110,11 +119,56 @@ class DataGrid(MultipleInstance): self._state = DatagridState(self, save_state=self._settings.save_state) self.commands = Commands(self) self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState + self._datagrid_filter = DataGridQuery(self) + self._datagrid_filter.bind_command("QueryChanged", self.commands.filter()) @property def _df(self): return self._state.ne_df + def _apply_sort(self, df): + if df is None: + return None + + sorted_columns = [] + sorted_asc = [] + for sort_def in self._state.sorted: + if sort_def.direction != 0: + sorted_columns.append(sort_def.column_id) + asc = sort_def.direction == 1 + sorted_asc.append(asc) + + if sorted_columns: + df = df.sort_values(by=sorted_columns, ascending=sorted_asc) + + return df + + def _apply_filter(self, df): + if df is None: + return None + + for col_id, values in self._state.filtered.items(): + if col_id == FILTER_INPUT_CID and values is not None: + if self._datagrid_filter.get_query_type() == DG_QUERY_FILTER: + visible_columns = [c.col_id for c in self._state.columns if c.visible and c.col_id in df.columns] + df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)] + else: + pass # we return all the row (but we will keep the highlight) + + else: + df = df[df[col_id].astype(str).isin(values)] + return df + + def _get_filtered_df(self): + if self._df is None: + return DataFrame() + + df = self._df.copy() + df = self._apply_sort(df) # need to keep the real type to sort + df = self._apply_filter(df) + + return df + def init_from_dataframe(self, df, init_state=True): def _get_column_type(dtype): @@ -181,13 +235,13 @@ class DataGrid(MultipleInstance): if col.col_id == col_id: col.width = int(width) break - + self._state.save() - + def move_column(self, source_col_id: str, target_col_id: str): """Move column to new position. Called via Command from JS.""" logger.debug(f"move_column: {source_col_id=} {target_col_id=}") - + # Find indices source_idx = None target_idx = None @@ -196,14 +250,14 @@ class DataGrid(MultipleInstance): source_idx = i if col.col_id == target_col_id: target_idx = i - + if source_idx is None or target_idx is None: logger.warning(f"move_column: column not found {source_col_id=} {target_col_id=}") return - + if source_idx == target_idx: return - + # Remove source column and insert at target position col = self._state.columns.pop(source_idx) # Adjust target index if source was before target @@ -211,19 +265,24 @@ class DataGrid(MultipleInstance): self._state.columns.insert(target_idx, col) else: self._state.columns.insert(target_idx, col) - + self._state.save() - + + def filter(self): + logger.debug("filter") + self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query() + return self.mk_body_container(redraw_scrollbars=True) + def mk_headers(self): resize_cmd = self.commands.set_column_width() move_cmd = self.commands.move_column() - + def _mk_header_name(col_def: DataGridColumnState): return Div( mk.label(col_def.title, name="dt2-header-title"), cls="flex truncate cursor-default", ) - + def _mk_header(col_def: DataGridColumnState): return Div( _mk_header_name(col_def), @@ -233,7 +292,7 @@ class DataGrid(MultipleInstance): data_tooltip=col_def.title, cls="dt2-cell dt2-resizable flex", ) - + header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden" return Div( *[_mk_header(col_def) for col_def in self._state.columns], @@ -268,9 +327,10 @@ class DataGrid(MultipleInstance): res = [] if index > 0: res.append(Span(value_str[:index], cls=f"{css_class}")) - res.append(Span(value_str[index:index + len_keyword], cls=f"{css_class} dt2-highlight-1")) + res.append(Span(value_str[index:index + len_keyword], cls=f"dt2-highlight-1")) if index + len_keyword < len(value_str): res.append(Span(value_str[index + len_keyword:], cls=f"{css_class}")) + return Span(*res, cls=f"{css_class} truncate") if len(res) > 1 else res[0] column_type = col_def.type @@ -322,7 +382,7 @@ class DataGrid(MultipleInstance): OPTIMIZED: Extract filter keyword once instead of 10,000 times. OPTIMIZED: Uses OptimizedDiv for rows instead of Div for faster rendering. """ - df = self._df # self._get_filtered_df() + df = self._get_filtered_df() start = page_index * DATAGRID_PAGE_SIZE end = start + DATAGRID_PAGE_SIZE if self._state.ns_total_rows > end: @@ -344,6 +404,14 @@ class DataGrid(MultipleInstance): return rows + def mk_body_container(self, redraw_scrollbars=False): + return Div( + self.mk_body(), + Script(f"initDataGridScrollbars('{self._id}');") if redraw_scrollbars else None, + cls="dt2-body-container", + id=f"tb_{self._id}" + ) + def mk_body(self): return Div( *self.mk_body_content_page(0), @@ -372,12 +440,9 @@ class DataGrid(MultipleInstance): self.mk_headers(), cls="dt2-header-container" ), - # Body container - scroll via JS, scrollbars hidden - Div( - self.mk_body(), - cls="dt2-body-container", - id=f"tb_{self._id}" - ), + + self.mk_body_container(), # Body container - scroll via JS, scrollbars hidden + # Footer container - no scroll Div( self.mk_footers(), @@ -470,13 +535,13 @@ class DataGrid(MultipleInstance): if self._state.ne_df is None: return Div("No data to display !") - from myfasthtml.controls.DataGridFilter import DataGridFilter return Div( - Div(DataGridFilter(self), cls="mb-2"), + Div(self._datagrid_filter, cls="mb-2"), self.mk_table(), Script(f"initDataGrid('{self._id}');"), id=self._id, - style="height: 100%;" + cls="grid", + style="height: 100%; grid-template-rows: auto 1fr;" ) def __ft__(self): diff --git a/src/myfasthtml/controls/DataGridFilter.py b/src/myfasthtml/controls/DataGridFilter.py deleted file mode 100644 index 85312bb..0000000 --- a/src/myfasthtml/controls/DataGridFilter.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging - -from fasthtml.components import * - -from myfasthtml.controls.BaseCommands import BaseCommands -from myfasthtml.controls.helpers import mk -from myfasthtml.core.commands import Command -from myfasthtml.core.dbmanager import DbObject -from myfasthtml.core.instances import MultipleInstance -from myfasthtml.icons.fluent import brain_circuit20_regular -from myfasthtml.icons.fluent_p1 import filter20_regular, search20_regular -from myfasthtml.icons.fluent_p2 import dismiss_circle20_regular - -logger = logging.getLogger("DataGridFilter") - -filter_type = { - "filter": filter20_regular, - "search": search20_regular, - "ai": brain_circuit20_regular -} - - -class DataGridFilterState(DbObject): - def __init__(self, owner): - with self.initializing(): - super().__init__(owner) - self.filter_type: str = "filter" - - -class Commands(BaseCommands): - def change_filter_type(self): - return Command("ChangeFilterType", - "Change filter type", - self._owner, - self._owner.change_filter_type).htmx(target=f"#{self._id}") - - def on_filter_changed(self): - return Command("FilterChanged", - "Filter changed", - self._owner, - self._owner.filter_changed).htmx(target=None) - - -class DataGridFilter(MultipleInstance): - def __init__(self, parent, _id=None): - super().__init__(parent, _id=_id or "-filter") - self.commands = Commands(self) - self._state = DataGridFilterState(self) - - def change_filter_type(self): - keys = list(filter_type.keys()) # ["filter", "search", "ai"] - current_idx = keys.index(self._state.filter_type) - self._state.filter_type = keys[(current_idx + 1) % len(keys)] - return self - - def filter_changed(self, f): - logger.debug(f"filter_changed {f=}") - return self - - def render(self): - return Div( - mk.label( - Input(name="f", - placeholder="Search...", - **self.commands.on_filter_changed().get_htmx_params(escaped=True)), - icon=mk.icon(filter_type[self._state.filter_type], command=self.commands.change_filter_type()), - cls="input input-sm flex gap-2" - ), - mk.icon(dismiss_circle20_regular, size=24), - # Keyboard(self, _id="-keyboard").add("enter", self.commands.on_filter_changed()), - cls="flex", - id=self._id - ) - - def __ft__(self): - return self.render() diff --git a/src/myfasthtml/controls/DataGridQuery.py b/src/myfasthtml/controls/DataGridQuery.py new file mode 100644 index 0000000..d9362a4 --- /dev/null +++ b/src/myfasthtml/controls/DataGridQuery.py @@ -0,0 +1,97 @@ +import logging +from typing import Optional + +from fasthtml.components import * + +from myfasthtml.controls.BaseCommands import BaseCommands +from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command +from myfasthtml.core.dbmanager import DbObject +from myfasthtml.core.instances import MultipleInstance +from myfasthtml.icons.fluent import brain_circuit20_regular +from myfasthtml.icons.fluent_p1 import filter20_regular, search20_regular +from myfasthtml.icons.fluent_p2 import dismiss_circle20_regular + +logger = logging.getLogger("DataGridFilter") + +DG_QUERY_FILTER = "filter" +DG_QUERY_SEARCH = "search" +DG_QUERY_AI = "ai" + +query_type = { + DG_QUERY_FILTER: filter20_regular, + DG_QUERY_SEARCH: search20_regular, + DG_QUERY_AI: brain_circuit20_regular +} + + +class DataGridFilterState(DbObject): + def __init__(self, owner): + with self.initializing(): + super().__init__(owner) + self.filter_type: str = "filter" + self.query: Optional[str] = None + + +class Commands(BaseCommands): + def change_filter_type(self): + return Command("ChangeFilterType", + "Change filter type", + self._owner, + self._owner.change_query_type).htmx(target=f"#{self._id}") + + def on_filter_changed(self): + return Command("QueryChanged", + "Query changed", + self._owner, + self._owner.query_changed).htmx(target=None) + + def on_cancel_query(self): + return Command("CancelQuery", + "Cancel query", + self._owner, + self._owner.query_changed, + kwargs={"query": ""} + ).htmx(target=f"#{self._id}") + + +class DataGridQuery(MultipleInstance): + def __init__(self, parent, _id=None): + super().__init__(parent, _id=_id or "-query") + self.commands = Commands(self) + self._state = DataGridFilterState(self) + + def get_query(self): + return self._state.query + + def get_query_type(self): + return self._state.filter_type + + def change_query_type(self): + keys = list(query_type.keys()) # ["filter", "search", "ai"] + current_idx = keys.index(self._state.filter_type) + self._state.filter_type = keys[(current_idx + 1) % len(keys)] + return self + + def query_changed(self, query): + logger.debug(f"query_changed {query=}") + self._state.query = query + return self + + def render(self): + return Div( + mk.label( + Input(name="query", + value=self._state.query if self._state.query is not None else "", + placeholder="Search...", + **self.commands.on_filter_changed().get_htmx_params(values_encode="json")), + icon=mk.icon(query_type[self._state.filter_type], command=self.commands.change_filter_type()), + cls="input input-xs flex gap-3" + ), + mk.icon(dismiss_circle20_regular, size=24, command=self.commands.on_cancel_query()), + cls="flex", + id=self._id + ) + + def __ft__(self): + return self.render() diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index be0daef..236871d 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -14,6 +14,7 @@ logger = logging.getLogger("Commands") AUTO_SWAP_OOB = "__auto_swap_oob__" + class Command: """ Represents the base command class for defining executable actions. @@ -99,7 +100,7 @@ class Command: def get_key(self): return self._key - def get_htmx_params(self, escaped=False): + def get_htmx_params(self, escaped=False, values_encode=None): res = { "hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-swap": "outerHTML", @@ -120,6 +121,9 @@ class Command: if escaped: res["hx-vals"] = html.escape(json.dumps(res["hx-vals"])) + if values_encode is "json": + res["hx-vals"] = json.dumps(res["hx-vals"]) + return res def execute(self, client_response: dict = None): diff --git a/src/myfasthtml/core/optimized_ft.py b/src/myfasthtml/core/optimized_ft.py index 58142ef..0bd6abe 100644 --- a/src/myfasthtml/core/optimized_ft.py +++ b/src/myfasthtml/core/optimized_ft.py @@ -7,7 +7,9 @@ by generating HTML strings directly instead of creating full FastHTML objects. from functools import lru_cache +from fastcore.xml import FT from fasthtml.common import NotStr +from fasthtml.components import Span from myfasthtml.core.constants import NO_DEFAULT_VALUE @@ -46,6 +48,8 @@ class OptimizedFt: return item.to_html() elif isinstance(item, NotStr): return str(item) + elif isinstance(item, FT): + return str(item) else: raise Exception(f"Unsupported type: {type(item)}, {item=}")