First version of DataGridQuery. Fixed scrollbar issue
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
97
src/myfasthtml/controls/DataGridQuery.py
Normal file
97
src/myfasthtml/controls/DataGridQuery.py
Normal 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()
|
||||
Reference in New Issue
Block a user