I can add a new column and a new row

This commit is contained in:
2026-02-26 22:44:35 +01:00
parent b383b1bc8b
commit c07b75ee72
10 changed files with 311 additions and 108 deletions

View File

@@ -151,18 +151,6 @@ The DataGrid automatically detects column types from pandas dtypes:
| `datetime64` | Datetime | Formatted date | | `datetime64` | Datetime | Formatted date |
| `object`, others | Text | Left-aligned, truncated | | `object`, others | Text | Left-aligned, truncated |
### Row Index Column
By default, the DataGrid displays a row index column on the left. This can be useful for identifying rows:
```python
# Row index is enabled by default
grid._state.row_index = True
# To disable the row index column
grid._state.row_index = False
grid.init_from_dataframe(df)
```
## Column Features ## Column Features

View File

@@ -12,7 +12,6 @@
display: flex; display: flex;
width: 100%; width: 100%;
font-size: var(--text-xl); font-size: var(--text-xl);
margin: 4px 0;
} }
/* Body */ /* Body */
@@ -45,6 +44,40 @@
user-select: none; user-select: none;
} }
.dt2-last-cell {
border-right: 1px solid var(--color-border);
}
.dt2-add-column {
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid var(--color-border);
width: 24px;
}
.dt2-add-row {
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
width: 24px;
min-width: 24px;
min-height: 20px;
}
.dt2-row-selection {
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
min-width: 24px;
min-height: 20px;
}
/* Cell content types */ /* Cell content types */
.dt2-cell-content-text { .dt2-cell-content-text {
text-align: inherit; text-align: inherit;

View File

@@ -17,14 +17,14 @@ from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEdito
from myfasthtml.controls.DslEditor import DslEditorConf from myfasthtml.controls.DslEditor import DslEditorConf
from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.Query import Query, QUERY_FILTER from myfasthtml.controls.Query import Query, QUERY_FILTER
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk, column_type_defaults from myfasthtml.controls.helpers import mk, column_type_defaults
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID, \
ROW_SELECTION_ID
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.dsls import DslsManager from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formatting.dsl.completion.FormattingCompletionEngine import FormattingCompletionEngine from myfasthtml.core.formatting.dsl.completion.FormattingCompletionEngine import FormattingCompletionEngine
@@ -36,7 +36,7 @@ from myfasthtml.core.optimized_ft import OptimizedDiv
from myfasthtml.core.utils import make_safe_id, merge_classes, make_unique_safe_id, is_null 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.carbon import row, column, grid
from myfasthtml.icons.fluent import checkbox_unchecked16_regular from myfasthtml.icons.fluent import checkbox_unchecked16_regular
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular, column_edit20_regular from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular, column_edit20_regular, add12_filled
from myfasthtml.icons.fluent_p3 import text_edit_style20_regular from myfasthtml.icons.fluent_p3 import text_edit_style20_regular
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters # OPTIMIZATION: Pre-compiled regex to detect HTML special characters
@@ -70,7 +70,6 @@ class DatagridState(DbObject):
super().__init__(owner, name=f"{owner.get_id()}#state", save_state=save_state) super().__init__(owner, name=f"{owner.get_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.columns: list[DataGridColumnState] = [] self.columns: list[DataGridColumnState] = []
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
self.headers: list[DataGridHeaderFooterConf] = [] self.headers: list[DataGridHeaderFooterConf] = []
@@ -99,12 +98,14 @@ class DatagridSettings(DbObject):
self.open_settings_visible: bool = True self.open_settings_visible: bool = True
self.text_size: str = "sm" self.text_size: str = "sm"
self.enable_formatting: bool = True self.enable_formatting: bool = True
self.enable_edition: bool = True
class DatagridStore(DbObject): class DatagridStore(DbObject):
""" """
Store Dataframes Store Dataframes
""" """
def __init__(self, owner, save_state): def __init__(self, owner, save_state):
with self.initializing(): with self.initializing():
super().__init__(owner, name=f"{owner.get_id()}#df", save_state=save_state) super().__init__(owner, name=f"{owner.get_id()}#df", save_state=save_state)
@@ -187,16 +188,35 @@ class Commands(BaseCommands):
return Command("ToggleColumnsManager", return Command("ToggleColumnsManager",
"Hide/Show Columns Manager", "Hide/Show Columns Manager",
self._owner, self._owner,
self._owner.toggle_columns_manager self._owner.handle_toggle_columns_manager
).htmx(target=None)
def toggle_new_column_editor(self):
return Command("ToggleNewColumnEditor",
"Add New Column",
self._owner,
self._owner.handle_toggle_new_column_editor,
icon=add12_filled,
).htmx(target=None) ).htmx(target=None)
def toggle_formatting_editor(self): def toggle_formatting_editor(self):
return Command("ToggleFormattingEditor", return Command("ToggleFormattingEditor",
"Hide/Show Formatting Editor", "Hide/Show Formatting Editor",
self._owner, self._owner,
self._owner.toggle_formatting_editor self._owner.handle_toggle_formatting_editor
).htmx(target=None) ).htmx(target=None)
def add_row(self):
return Command("AddRow",
"Add row",
self._owner,
self._owner.handle_add_row,
icon=add12_filled,
).htmx(target=f"#tr_{self._id}-last",
swap="outerHTML",
auto_swap_oob=False
)
def on_column_changed(self): def on_column_changed(self):
return Command("OnColumnChanged", return Command("OnColumnChanged",
"Column definition changed", "Column definition changed",
@@ -213,6 +233,7 @@ class DataGrid(MultipleInstance):
self._state = DatagridState(self, save_state=self._settings.save_state) self._state = DatagridState(self, save_state=self._settings.save_state)
self._df_store = DatagridStore(self, save_state=self._settings.save_state) self._df_store = DatagridStore(self, save_state=self._settings.save_state)
self._formatting_engine = FormattingEngine() self._formatting_engine = FormattingEngine()
self._columns = None
self.commands = Commands(self) self.commands = Commands(self)
self.init_from_dataframe(self._df_store.ne_df, init_state=False) # data comes from DatagridStore self.init_from_dataframe(self._df_store.ne_df, init_state=False) # data comes from DatagridStore
@@ -220,6 +241,7 @@ class DataGrid(MultipleInstance):
self._panel = Panel(self, conf=PanelConf(show_display_right=False), _id="-panel") self._panel = Panel(self, conf=PanelConf(show_display_right=False), _id="-panel")
self._panel.set_side_visible("right", False) # the right Panel always starts closed self._panel.set_side_visible("right", False) # the right Panel always starts closed
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right")) self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
self.bind_command("ToggleNewColumnEditor", self._panel.commands.set_side_visible("right", True))
self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right")) self.bind_command("ToggleFormattingEditor", self._panel.commands.toggle_side("right"))
# add Query # add Query
@@ -421,6 +443,14 @@ class DataGrid(MultipleInstance):
# Get global tables formatting from manager # Get global tables formatting from manager
return self._parent.all_tables_formats return self._parent.all_tables_formats
def _init_columns(self):
self._columns = self._state.columns.copy()
if self._settings.enable_edition:
self._columns.insert(0, DataGridColumnState(ROW_SELECTION_ID,
-1,
"",
ColumnType.RowSelection_))
def init_from_dataframe(self, df, init_state=True): def init_from_dataframe(self, df, init_state=True):
def _get_column_type(dtype): def _get_column_type(dtype):
@@ -441,8 +471,6 @@ class DataGrid(MultipleInstance):
col_id, col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype)) _get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(_df.columns)] for col_index, col_id in enumerate(_df.columns)]
if self._state.row_index:
columns.insert(0, DataGridColumnState(make_safe_id(ROW_INDEX_ID), -1, " ", ColumnType.RowIndex))
return columns return columns
@@ -494,6 +522,8 @@ class DataGrid(MultipleInstance):
self._df_store.save() self._df_store.save()
self._state.rows = [] # sparse: only rows with non-default state are stored self._state.rows = [] # sparse: only rows with non-default state are stored
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
self._init_columns()
self._df_store.ns_fast_access = _init_fast_access(self._df) self._df_store.ns_fast_access = _init_fast_access(self._df)
self._df_store.ns_row_data = _init_row_data(self._df) self._df_store.ns_row_data = _init_row_data(self._df)
self._df_store.ns_total_rows = len(self._df) if self._df is not None else 0 self._df_store.ns_total_rows = len(self._df) if self._df is not None else 0
@@ -532,15 +562,75 @@ class DataGrid(MultipleInstance):
row_dict[col_def.col_id] = default_value row_dict[col_def.col_id] = default_value
self._df_store.save() self._df_store.save()
def handle_set_column_width(self, col_id: str, width: str): def add_new_row(self, row_data: dict = None) -> None:
"""Update column width after resize. Called via Command from JS.""" """Add a new row to the DataGrid with incremental updates.
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() Creates default values based on column types and handles formula columns.
Updates ns_fast_access and ns_row_data incrementally for better performance.
Args:
row_data: Optional dict with initial values. If None, uses type defaults.
"""
import numpy as np
def _get_default_value(col_def: DataGridColumnState):
"""Get default value for a column based on its type."""
if col_def.type == ColumnType.Formula:
return None # Will be calculated by FormulaEngine
return column_type_defaults.get(col_def.type, "")
if row_data is None:
# Create default values for all non-formula, non-selection columns
row_data = {}
for col in self._state.columns:
if col.type not in (ColumnType.Formula, ColumnType.RowSelection_):
row_data[col.col_id] = _get_default_value(col)
# 1. Add row to DataFrame (only non-formula columns)
new_index = len(self._df)
self._df.loc[new_index] = row_data
# 2. Incremental update of ns_fast_access
for col_id, value in row_data.items():
if col_id in self._df_store.ns_fast_access:
self._df_store.ns_fast_access[col_id] = np.append(
self._df_store.ns_fast_access[col_id],
value
)
else:
# First value for this column (rare case)
self._df_store.ns_fast_access[col_id] = np.array([value])
# Update row index
if ROW_INDEX_ID in self._df_store.ns_fast_access:
self._df_store.ns_fast_access[ROW_INDEX_ID] = np.append(
self._df_store.ns_fast_access[ROW_INDEX_ID],
new_index
)
else:
self._df_store.ns_fast_access[ROW_INDEX_ID] = np.array([new_index])
# 3. Incremental update of ns_row_data
self._df_store.ns_row_data.append(row_data.copy())
# 4. Handle formula columns
engine = self.get_formula_engine()
if engine is not None:
table_name = self.get_table_name()
# Check if there are any formula columns
has_formulas = any(col.type == ColumnType.Formula for col in self._state.columns)
if has_formulas:
try:
# Recalculate all formulas (engine handles efficiency)
engine.recalculate_if_needed(table_name, self._df_store)
except Exception as e:
logger.warning(f"Failed to calculate formulas for new row: {e}")
# 5. Update total row count
self._df_store.ns_total_rows = len(self._df)
# Save changes
self._df_store.save()
def move_column(self, source_col_id: str, target_col_id: str): def move_column(self, source_col_id: str, target_col_id: str):
"""Move column to new position. Called via Command from JS.""" """Move column to new position. Called via Command from JS."""
@@ -569,6 +659,7 @@ class DataGrid(MultipleInstance):
self._state.columns.insert(target_idx, col) self._state.columns.insert(target_idx, col)
else: else:
self._state.columns.insert(target_idx, col) self._state.columns.insert(target_idx, col)
self._init_columns()
self._state.save() self._state.save()
@@ -688,16 +779,46 @@ class DataGrid(MultipleInstance):
self._state.save() self._state.save()
return self.render_partial() return self.render_partial()
def toggle_columns_manager(self): def handle_toggle_columns_manager(self):
logger.debug(f"toggle_columns_manager") logger.debug(f"toggle_columns_manager")
self._panel.set_title(side="right", title="Columns") self._panel.set_title(side="right", title="Columns")
self._columns_manager.adding_new_column = False
self._columns_manager.set_all_columns(True)
self._columns_manager.unbind_command(("ShowAllColumns", "SaveColumnDetails"))
self._panel.set_right(self._columns_manager) self._panel.set_right(self._columns_manager)
def toggle_formatting_editor(self): def handle_toggle_new_column_editor(self):
logger.debug(f"handle_toggle_new_column_editor")
self._panel.set_title(side="right", title="Columns")
self._columns_manager.adding_new_column = True
self._columns_manager.set_all_columns(False)
self._columns_manager.bind_command(("ShowAllColumns", "SaveColumnDetails"),
self._panel.commands.set_side_visible("right", False))
self._panel.set_right(self._columns_manager)
def handle_toggle_formatting_editor(self):
logger.debug(f"toggle_formatting_editor") logger.debug(f"toggle_formatting_editor")
self._panel.set_title(side="right", title="Formatting") self._panel.set_title(side="right", title="Formatting")
self._panel.set_right(self._formatting_editor) self._panel.set_right(self._formatting_editor)
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:
if col.col_id == col_id:
col.width = int(width)
break
self._state.save()
def handle_add_row(self):
self.add_new_row() # Add row to data
row_index = len(self._df) - 1 # Index of the newly added row
len_columns_1 = len(self._columns) - 1
rows = [self.mk_row(row_index, None, len_columns_1)]
self._mk_append_add_row_if_needed(rows)
return rows
def save_state(self): def save_state(self):
self._state.save() self._state.save()
@@ -734,6 +855,9 @@ class DataGrid(MultipleInstance):
if not col_def.visible: if not col_def.visible:
return None return None
if col_def.type == ColumnType.RowSelection_:
return Div(cls="dt2-row-selection")
return Div( return Div(
_mk_header_name(col_def), _mk_header_name(col_def),
Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id, data_reset_command_id=reset_cmd.id), Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id, data_reset_command_id=reset_cmd.id),
@@ -744,8 +868,13 @@ class DataGrid(MultipleInstance):
) )
header_class = "dt2-header" header_class = "dt2-header"
header_columns = [_mk_header(col_def) for col_def in self._columns]
if self._settings.enable_edition:
header_columns.append(Div(
mk.icon(command=self.commands.toggle_new_column_editor(), size=16),
cls="dt2-add-column"))
return Div( return Div(
*[_mk_header(col_def) for col_def in self._state.columns], *header_columns,
cls=header_class, cls=header_class,
id=f"th_{self._id}", id=f"th_{self._id}",
data_move_command_id=move_cmd.id data_move_command_id=move_cmd.id
@@ -841,7 +970,7 @@ class DataGrid(MultipleInstance):
else: else:
return mk_highlighted_text(value_str, merge_classes("dt2-cell-content-text", cls), css_string) return mk_highlighted_text(value_str, merge_classes("dt2-cell-content-text", cls), css_string)
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None): def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower, is_last):
""" """
OPTIMIZED: Accepts pre-computed filter_keyword_lower to avoid repeated dict lookups. OPTIMIZED: Accepts pre-computed filter_keyword_lower to avoid repeated dict lookups.
OPTIMIZED: Uses OptimizedDiv instead of Div for faster rendering. OPTIMIZED: Uses OptimizedDiv instead of Div for faster rendering.
@@ -849,6 +978,9 @@ class DataGrid(MultipleInstance):
if not col_def.visible: if not col_def.visible:
return None return None
if col_def.type == ColumnType.RowSelection_:
return OptimizedDiv(cls="dt2-row-selection")
col_array = self._df_store.ns_fast_access.get(col_def.col_id) col_array = self._df_store.ns_fast_access.get(col_def.col_id)
value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower) content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
@@ -858,7 +990,23 @@ class DataGrid(MultipleInstance):
data_tooltip=str(value), data_tooltip=str(value),
style=f"width:{col_def.width}px;", style=f"width:{col_def.width}px;",
id=self._get_element_id_from_pos("cell", (col_pos, row_index)), id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
cls="dt2-cell") cls=merge_classes("dt2-cell", "dt2-last-cell" if is_last else None))
def mk_row(self, row_index, filter_keyword_lower, len_columns_1):
return OptimizedDiv(
*[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower, col_pos == len_columns_1)
for col_pos, col_def in enumerate(self._columns)],
cls="dt2-row",
data_row=f"{row_index}",
id=f"tr_{self._id}-{row_index}",
)
def _mk_append_add_row_if_needed(self, rows):
if self._settings.enable_edition:
rows.append(Div(
mk.icon(command=self.commands.add_row(), size=16),
cls="dt2-add-row",
id=f"tr_{self._id}-last"))
def mk_body_content_page(self, page_index: int): def mk_body_content_page(self, page_index: int):
""" """
@@ -872,22 +1020,16 @@ class DataGrid(MultipleInstance):
start = page_index * DATAGRID_PAGE_SIZE start = page_index * DATAGRID_PAGE_SIZE
end = start + DATAGRID_PAGE_SIZE end = start + DATAGRID_PAGE_SIZE
if self._df_store.ns_total_rows > end:
last_row = df.index[end - 1]
else:
last_row = None
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID) filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
len_columns_1 = len(self._columns) - 1
rows = [OptimizedDiv( rows = [self.mk_row(row_index, filter_keyword_lower, len_columns_1) for row_index in df.index[start:end]]
*[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower) if self._df_store.ns_total_rows > end:
for col_pos, col_def in enumerate(self._state.columns)], rows[-1].attrs.extend(self.commands.get_page(page_index + 1).get_htmx_params(escaped=True))
cls="dt2-row",
data_row=f"{row_index}", self._mk_append_add_row_if_needed(rows)
id=f"tr_{self._id}-{row_index}",
**self.commands.get_page(page_index + 1).get_htmx_params(escaped=True) if row_index == last_row else {}
) for row_index in df.index[start:end]]
return rows return rows
@@ -907,7 +1049,7 @@ class DataGrid(MultipleInstance):
def mk_footers(self): def mk_footers(self):
return Div( return Div(
*[Div( *[Div(
*[self.mk_aggregation_cell(col_def, row_index, footer) for col_def in self._state.columns], *[self.mk_aggregation_cell(col_def, row_index, footer) for col_def in self._columns],
id=f"tf_{self._id}", id=f"tf_{self._id}",
data_row=f"{row_index}", data_row=f"{row_index}",
cls="dt2-row dt2-row-footer", cls="dt2-row dt2-row-footer",
@@ -1059,7 +1201,7 @@ class DataGrid(MultipleInstance):
cls="flex items-center justify-between mb-2"), cls="flex items-center justify-between mb-2"),
self._panel.set_main(self.mk_table_wrapper()), self._panel.set_main(self.mk_table_wrapper()),
Script(f"initDataGrid('{self._id}');"), Script(f"initDataGrid('{self._id}');"),
Mouse(self, combinations=self._mouse_support, _id="-mouse"), # Mouse(self, combinations=self._mouse_support, _id="-mouse"),
Keyboard(self, combinations=self._key_support, _id="-keyboard"), Keyboard(self, combinations=self._key_support, _id="-keyboard"),
id=self._id, id=self._id,
cls="grid", cls="grid",

View File

@@ -10,7 +10,7 @@ from myfasthtml.controls.Search import Search
from myfasthtml.controls.datagrid_objects import DataGridColumnState from myfasthtml.controls.datagrid_objects import DataGridColumnState
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType from myfasthtml.core.constants import ColumnType, get_columns_types
from myfasthtml.core.dsls import DslsManager from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formula.dsl.completion.FormulaCompletionEngine import FormulaCompletionEngine from myfasthtml.core.formula.dsl.completion.FormulaCompletionEngine import FormulaCompletionEngine
from myfasthtml.core.formula.dsl.parser import FormulaParser from myfasthtml.core.formula.dsl.parser import FormulaParser
@@ -39,21 +39,21 @@ class Commands(BaseCommands):
return Command(f"ShowAllColumns", return Command(f"ShowAllColumns",
f"Show all columns", f"Show all columns",
self._owner, self._owner,
self._owner.show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML") self._owner.handle_show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML")
def save_column_details(self, col_id): def save_column_details(self, col_id):
return Command(f"SaveColumnDetails", return Command(f"SaveColumnDetails",
f"Save column {col_id}", f"Save column {col_id}",
self._owner, self._owner,
self._owner.save_column_details, self._owner.handle_save_column_details,
kwargs={"col_id": col_id} kwargs={"col_id": col_id}
).htmx(target=f"#{self._id}", swap="innerHTML") ).htmx(target=f"#{self._id}", swap="innerHTML")
def on_new_column(self): def add_new_column(self):
return Command(f"OnNewColumn", return Command(f"AddNewColumn",
f"New column", f"Add a new column",
self._owner, self._owner,
self._owner.on_new_column).htmx(target=f"#{self._id}", swap="innerHTML") self._owner.handle_add_new_column).htmx(target=f"#{self._id}", swap="innerHTML")
def on_column_type_changed(self): def on_column_type_changed(self):
return Command(f"OnColumnTypeChanged", return Command(f"OnColumnTypeChanged",
@@ -66,7 +66,8 @@ class DataGridColumnsManager(MultipleInstance):
def __init__(self, parent, _id=None): def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.commands = Commands(self) self.commands = Commands(self)
self._new_column = False self.adding_new_column = False
self._all_columns = True # Do not return to all_columns after column details
completion_engine = FormulaCompletionEngine( completion_engine = FormulaCompletionEngine(
self._parent._parent, self._parent._parent,
@@ -109,6 +110,9 @@ class DataGridColumnsManager(MultipleInstance):
return col_def return col_def
def set_all_columns(self, all_columns):
self._all_columns = all_columns
def toggle_column(self, col_id): def toggle_column(self, col_id):
logger.debug(f"toggle_column {col_id=}") logger.debug(f"toggle_column {col_id=}")
col_def = self._get_col_def_from_col_id(col_id, copy=False) col_def = self._get_col_def_from_col_id(col_id, copy=False)
@@ -129,22 +133,24 @@ class DataGridColumnsManager(MultipleInstance):
return self.mk_column_details(col_def) return self.mk_column_details(col_def)
def show_all_columns(self): def handle_show_all_columns(self):
return self._mk_inner_content() self.adding_new_column = False
return self._mk_inner_content() if self._all_columns else None
def save_column_details(self, col_id, client_response): def handle_save_column_details(self, col_id, client_response):
logger.debug(f"save_column_details {col_id=}, {client_response=}") logger.debug(f"save_column_details {col_id=}, {client_response=}")
col_def = self._get_updated_col_def_from_col_id(col_id, client_response, copy=False) col_def = self._get_updated_col_def_from_col_id(col_id, client_response, copy=False)
if col_def.col_id == "__new__": if col_def.col_id == "__new__":
self._parent.add_new_column(col_def) # sets the correct col_id before _register_formula self._parent.add_new_column(col_def) # sets the correct col_id before _register_formula
self.adding_new_column = False
self._register_formula(col_def) self._register_formula(col_def)
self._parent.save_state() self._parent.save_state()
return self._mk_inner_content() return self._mk_inner_content() if self._all_columns else None
def on_new_column(self): def handle_add_new_column(self):
self._new_column = True self.adding_new_column = True
col_def = self._get_updated_col_def_from_col_id("__new__") col_def = DataGridColumnState("__new__", -1)
return self.mk_column_details(col_def) return self.mk_column_details(col_def)
def on_column_type_changed(self, col_id, client_response): def on_column_type_changed(self, col_id, client_response):
@@ -192,6 +198,7 @@ class DataGridColumnsManager(MultipleInstance):
def mk_column_details(self, col_def: DataGridColumnState): def mk_column_details(self, col_def: DataGridColumnState):
size = "sm" size = "sm"
return Div( return Div(
mk.label("Back", icon=chevron_left20_regular, command=self.commands.show_all_columns()), mk.label("Back", icon=chevron_left20_regular, command=self.commands.show_all_columns()),
Form( Form(
@@ -210,7 +217,9 @@ class DataGridColumnsManager(MultipleInstance):
Label("type"), Label("type"),
mk.mk( mk.mk(
Select( Select(
*[Option(option.value, value=option.value, selected=option == col_def.type) for option in ColumnType], *[Option(option.value,
value=option.value,
selected=option == col_def.type) for option in get_columns_types()],
name="type", name="type",
cls=f"select select-{size}", cls=f"select select-{size}",
value=col_def.title, value=col_def.title,
@@ -260,11 +269,15 @@ class DataGridColumnsManager(MultipleInstance):
def mk_new_column(self): def mk_new_column(self):
return Div( return Div(
mk.button("New Column", command=self.commands.on_new_column()), mk.button("New Column", command=self.commands.add_new_column()),
cls="mb-1", cls="mb-1",
) )
def _mk_inner_content(self): def _mk_inner_content(self):
if self.adding_new_column:
col_def = DataGridColumnState("__new__", -1)
return self.mk_column_details(col_def)
return (self.mk_all_columns(), return (self.mk_all_columns(),
self.mk_new_column()) self.mk_new_column())

View File

@@ -1,3 +1,5 @@
from fastcore.basics import NotStr
from myfasthtml.core.constants import ColumnType from myfasthtml.core.constants import ColumnType
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \ from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
number_row20_regular number_row20_regular
@@ -13,7 +15,7 @@ default_icons = {
False: checkbox_unchecked20_regular, False: checkbox_unchecked20_regular,
"Brain": brain_circuit20_regular, "Brain": brain_circuit20_regular,
"QuestionMark" : question20_regular, "QuestionMark": question20_regular,
# TreeView icons # TreeView icons
"TreeViewFolder": folder20_regular, "TreeViewFolder": folder20_regular,
@@ -46,6 +48,9 @@ class IconsHelper:
:return: The requested icon resource if found; otherwise, returns None. :return: The requested icon resource if found; otherwise, returns None.
:rtype: object or None :rtype: object or None
""" """
if isinstance(name, NotStr):
return name
if name in IconsHelper._icons: if name in IconsHelper._icons:
return IconsHelper._icons[name] return IconsHelper._icons[name]

View File

@@ -43,6 +43,9 @@ class Menu(MultipleInstance):
} }
def _mk_menu(self, command_name): def _mk_menu(self, command_name):
if not isinstance(command_name, str):
return command_name
command = self.usable_commands.get(command_name) command = self.usable_commands.get(command_name)
return mk.icon(command.icon or IconsHelper.get("QuestionMark"), return mk.icon(command.icon or IconsHelper.get("QuestionMark"),
command=command, command=command,

View File

@@ -64,8 +64,8 @@ class PanelState(DbObject):
class Commands(BaseCommands): class Commands(BaseCommands):
def set_side_visible(self, side: Literal["left", "right"], visible: bool = None): def set_side_visible(self, side: Literal["left", "right"], visible: bool = None):
return Command("TogglePanelSide", return Command("SetVisiblePanelSide",
f"Toggle {side} side panel", f"Open / Close {side} side panel",
self._owner, self._owner,
self._owner.set_side_visible, self._owner.set_side_visible,
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}") args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")

View File

@@ -151,7 +151,7 @@ class Command:
before_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "before"] before_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "before"]
for bound_cmd in before_commands: for bound_cmd in before_commands:
logger.debug(f" will execute bound command {bound_cmd.command.name} BEFORE...") logger.debug(f" will execute bound command {bound_cmd.command.name} BEFORE...")
r = bound_cmd.command.execute() # client_response should not be forwarded as it's not the same command r = bound_cmd.command.execute(client_response)
ret_from_before_commands.append(r) ret_from_before_commands.append(r)
# Execute main callback # Execute main callback
@@ -166,7 +166,7 @@ class Command:
after_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "after"] after_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "after"]
for bound_cmd in after_commands: for bound_cmd in after_commands:
logger.debug(f" will execute bound command {bound_cmd.command.name} AFTER...") logger.debug(f" will execute bound command {bound_cmd.command.name} AFTER...")
r = bound_cmd.command.execute() # client_response should not be forwarded as it's not the same command r = bound_cmd.command.execute(client_response)
ret_from_after_commands.append(r) ret_from_after_commands.append(r)
all_ret = flatten(ret, ret_from_before_commands, ret_from_after_commands, collector.results) all_ret = flatten(ret, ret_from_before_commands, ret_from_after_commands, collector.results)

View File

@@ -6,6 +6,7 @@ ROUTE_ROOT = "/myfasthtml"
# Datagrid # Datagrid
ROW_INDEX_ID = "__row_index__" ROW_INDEX_ID = "__row_index__"
ROW_SELECTION_ID = "__row_selection__"
DATAGRID_DEFAULT_COLUMN_WIDTH = 100 DATAGRID_DEFAULT_COLUMN_WIDTH = 100
DATAGRID_PAGE_SIZE = 1000 DATAGRID_PAGE_SIZE = 1000
FILTER_INPUT_CID = "__filter_input__" FILTER_INPUT_CID = "__filter_input__"
@@ -19,6 +20,7 @@ class Routes:
class ColumnType(Enum): class ColumnType(Enum):
RowSelection_ = "RowSelection_"
RowIndex = "RowIndex" RowIndex = "RowIndex"
Text = "Text" Text = "Text"
Number = "Number" Number = "Number"
@@ -29,6 +31,10 @@ class ColumnType(Enum):
Formula = "Formula" Formula = "Formula"
def get_columns_types() -> list[ColumnType]:
return [c for c in ColumnType if not c.value.endswith("_")]
class ViewType(Enum): class ViewType(Enum):
Table = "Table" Table = "Table"
Chart = "Chart" Chart = "Chart"

View File

@@ -1,9 +1,9 @@
import logging import logging
import uuid import uuid
from typing import Optional from typing import Optional, Literal
from myfasthtml.controls.helpers import Ids from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import BoundCommand from myfasthtml.core.commands import BoundCommand, Command
from myfasthtml.core.constants import NO_DEFAULT_VALUE from myfasthtml.core.constants import NO_DEFAULT_VALUE
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
@@ -113,7 +113,10 @@ class BaseInstance:
parent = self.get_parent() parent = self.get_parent()
return parent.get_full_id() if parent else None return parent.get_full_id() if parent else None
def bind_command(self, command, command_to_bind, when="after"): def bind_command(self,
command: Command | str | tuple | list,
command_to_bind,
when: Literal["before", "after"] = "after"):
""" """
Bind a command to another command. Bind a command to another command.
@@ -126,18 +129,28 @@ class BaseInstance:
Duplicate bindings are automatically prevented using two mechanisms: Duplicate bindings are automatically prevented using two mechanisms:
1. Check if the same binding already exists 1. Check if the same binding already exists
""" """
command_name = command.name if hasattr(command, "name") else command
# Protection 1: Check if this binding already exists to prevent duplicates def _bind(_command_name, _command_to_bind, _when):
existing_bindings = self._bound_commands.get(command_name, []) # Check if this binding already exists to prevent duplicates
for existing in existing_bindings: existing_bindings = self._bound_commands.get(_command_name, [])
if existing.command.name == command_to_bind.name and existing.when == when: for existing in existing_bindings:
# Binding already exists, don't add it again if existing.command.name == _command_to_bind.name and existing.when == _when:
return return # Binding already exists, don't add it again
# Add new binding # Add new binding
bound = BoundCommand(command=command_to_bind, when=when) bound = BoundCommand(command=_command_to_bind, when=_when)
self._bound_commands.setdefault(command_name, []).append(bound) self._bound_commands.setdefault(command_name, []).append(bound)
commands = [command] if isinstance(command, (str, Command)) else command
for c in commands:
command_name = c.name if hasattr(c, "name") else c
_bind(c, command_to_bind, when)
def unbind_command(self, command: Command | str | tuple | list):
commands = [command] if isinstance(command, (str, Command)) else command
for c in commands:
command_name = c.name if hasattr(c, "name") else c
self._bound_commands.pop(command_name, None)
def get_bound_commands(self, command_name): def get_bound_commands(self, command_name):
return self._bound_commands.get(command_name, []) return self._bound_commands.get(command_name, [])