First version of DataGridQuery. Fixed scrollbar issue

This commit is contained in:
2026-01-23 21:26:19 +01:00
parent 872d110f07
commit 191ead1c89
6 changed files with 432 additions and 331 deletions

View File

@@ -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,11 +1759,11 @@ 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);

View File

@@ -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
@@ -102,6 +104,13 @@ class Commands(BaseCommands):
self._owner.move_column
).htmx(target=None)
def filter(self):
return Command("Filter",
"Filter Grid",
self._owner,
self._owner.filter
)
class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, save_state=None, _id=None):
@@ -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):
@@ -214,6 +268,11 @@ class DataGrid(MultipleInstance):
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()
@@ -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):

View File

@@ -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()

View File

@@ -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()

View File

@@ -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):

View File

@@ -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=}")