diff --git a/docs/DataGrid.md b/docs/DataGrid.md index e29876f..b5b9dc6 100644 --- a/docs/DataGrid.md +++ b/docs/DataGrid.md @@ -151,18 +151,6 @@ The DataGrid automatically detects column types from pandas dtypes: | `datetime64` | Datetime | Formatted date | | `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 diff --git a/src/myfasthtml/assets/datagrid/datagrid.css b/src/myfasthtml/assets/datagrid/datagrid.css index 96dbaed..52f867d 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.css +++ b/src/myfasthtml/assets/datagrid/datagrid.css @@ -12,7 +12,6 @@ display: flex; width: 100%; font-size: var(--text-xl); - margin: 4px 0; } /* Body */ @@ -45,6 +44,40 @@ 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 */ .dt2-cell-content-text { text-align: inherit; diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 17c2b4a..f85f617 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -17,14 +17,14 @@ from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEdito from myfasthtml.controls.DslEditor import DslEditorConf from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.Keyboard import Keyboard -from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.Query import Query, QUERY_FILTER from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState from myfasthtml.controls.helpers import mk, column_type_defaults 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.dsls import DslsManager 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.icons.carbon import row, column, grid 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 # 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) self.sidebar_visible: bool = False self.selected_view: str = None - self.row_index: bool = True self.columns: list[DataGridColumnState] = [] self.rows: list[DataGridRowState] = [] # only the rows that have a specific state self.headers: list[DataGridHeaderFooterConf] = [] @@ -99,12 +98,14 @@ class DatagridSettings(DbObject): self.open_settings_visible: bool = True self.text_size: str = "sm" self.enable_formatting: bool = True + self.enable_edition: bool = True class DatagridStore(DbObject): """ Store Dataframes """ + def __init__(self, owner, save_state): with self.initializing(): super().__init__(owner, name=f"{owner.get_id()}#df", save_state=save_state) @@ -140,14 +141,14 @@ class Commands(BaseCommands): self._owner, self._owner.move_column ).htmx(target=None) - + def reset_column_width(self): return Command("ResetColumnWidth", "Auto-size column to fit content", self._owner, self._owner.reset_column_width ).htmx(target=f"#th_{self._id}") - + def filter(self): return Command("Filter", "Filter Grid", @@ -187,16 +188,35 @@ class Commands(BaseCommands): return Command("ToggleColumnsManager", "Hide/Show Columns Manager", 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) def toggle_formatting_editor(self): return Command("ToggleFormattingEditor", "Hide/Show Formatting Editor", self._owner, - self._owner.toggle_formatting_editor + self._owner.handle_toggle_formatting_editor ).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): return Command("OnColumnChanged", "Column definition changed", @@ -213,6 +233,7 @@ class DataGrid(MultipleInstance): self._state = DatagridState(self, save_state=self._settings.save_state) self._df_store = DatagridStore(self, save_state=self._settings.save_state) self._formatting_engine = FormattingEngine() + self._columns = None self.commands = Commands(self) 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.set_side_visible("right", False) # the right Panel always starts closed 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")) # add Query @@ -421,6 +443,14 @@ class DataGrid(MultipleInstance): # Get global tables formatting from manager 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 _get_column_type(dtype): @@ -441,8 +471,6 @@ class DataGrid(MultipleInstance): col_id, _get_column_type(self._df[make_safe_id(col_id)].dtype)) 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 @@ -494,6 +522,8 @@ class DataGrid(MultipleInstance): self._df_store.save() 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._init_columns() 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_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 self._df_store.save() - 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 + def add_new_row(self, row_data: dict = None) -> None: + """Add a new row to the DataGrid with incremental updates. + + 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 - self._state.save() + 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): """Move column to new position. Called via Command from JS.""" @@ -569,9 +659,10 @@ class DataGrid(MultipleInstance): self._state.columns.insert(target_idx, col) else: self._state.columns.insert(target_idx, col) + self._init_columns() self._state.save() - + def calculate_optimal_column_width(self, col_id: str) -> int: """ Calculate optimal width for a column based on content. @@ -589,24 +680,24 @@ class DataGrid(MultipleInstance): if not col_def: logger.warning(f"calculate_optimal_column_width: column not found {col_id=}") return 150 # default width - + # Title length title_length = len(col_def.title) - + # Max data length max_data_length = 0 if col_id in self._df_store.ns_fast_access: col_array = self._df_store.ns_fast_access[col_id] if col_array is not None and len(col_array) > 0: max_data_length = max(len(str(v)) for v in col_array) - + # Calculate width (8px per char + 30px padding) max_length = max(title_length, max_data_length) optimal_width = max_length * 8 + 30 - + # Apply limits (50px min, 500px max) return max(50, min(optimal_width, 500)) - + def reset_column_width(self, col_id: str): """ Auto-size column to fit content. Called via Command from JS double-click. @@ -622,18 +713,18 @@ class DataGrid(MultipleInstance): """ logger.debug(f"reset_column_width: {col_id=}") optimal_width = self.calculate_optimal_column_width(col_id) - + # Update and persist for col in self._state.columns: if col.col_id == col_id: col.width = optimal_width break - + self._state.save() - + # Return updated header with script to update body cells via after-settle return self.render_partial("header", col_id=col_id, optimal_width=optimal_width) - + def filter(self): logger.debug("filter") self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query() @@ -688,16 +779,46 @@ class DataGrid(MultipleInstance): self._state.save() return self.render_partial() - def toggle_columns_manager(self): + def handle_toggle_columns_manager(self): logger.debug(f"toggle_columns_manager") 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) - 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") self._panel.set_title(side="right", title="Formatting") 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): self._state.save() @@ -721,7 +842,7 @@ class DataGrid(MultipleInstance): resize_cmd = self.commands.set_column_width() move_cmd = self.commands.move_column() reset_cmd = self.commands.reset_column_width() - + def _mk_header_name(col_def: DataGridColumnState): return Div( mk.label(col_def.title, icon=IconsHelper.get(col_def.type)), @@ -729,11 +850,14 @@ class DataGrid(MultipleInstance): cls="flex truncate cursor-default", data_tooltip=col_def.title, ) - + def _mk_header(col_def: DataGridColumnState): if not col_def.visible: return None - + + if col_def.type == ColumnType.RowSelection_: + return Div(cls="dt2-row-selection") + return Div( _mk_header_name(col_def), 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_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( - *[_mk_header(col_def) for col_def in self._state.columns], + *header_columns, cls=header_class, id=f"th_{self._id}", data_move_command_id=move_cmd.id @@ -841,7 +970,7 @@ class DataGrid(MultipleInstance): else: 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: Uses OptimizedDiv instead of Div for faster rendering. @@ -849,6 +978,9 @@ class DataGrid(MultipleInstance): if not col_def.visible: 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) 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) @@ -858,7 +990,23 @@ class DataGrid(MultipleInstance): data_tooltip=str(value), style=f"width:{col_def.width}px;", 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): """ @@ -872,22 +1020,16 @@ class DataGrid(MultipleInstance): start = page_index * 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_lower = filter_keyword.lower() if filter_keyword else None + len_columns_1 = len(self._columns) - 1 - rows = [OptimizedDiv( - *[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower) - for col_pos, col_def in enumerate(self._state.columns)], - cls="dt2-row", - data_row=f"{row_index}", - 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]] + rows = [self.mk_row(row_index, filter_keyword_lower, len_columns_1) for row_index in df.index[start:end]] + if self._df_store.ns_total_rows > end: + rows[-1].attrs.extend(self.commands.get_page(page_index + 1).get_htmx_params(escaped=True)) + + self._mk_append_add_row_if_needed(rows) return rows @@ -907,7 +1049,7 @@ class DataGrid(MultipleInstance): def mk_footers(self): return 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}", data_row=f"{row_index}", cls="dt2-row dt2-row-footer", @@ -1059,7 +1201,7 @@ class DataGrid(MultipleInstance): cls="flex items-center justify-between mb-2"), self._panel.set_main(self.mk_table_wrapper()), 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"), id=self._id, cls="grid", @@ -1074,35 +1216,35 @@ class DataGrid(MultipleInstance): :return: """ res = [] - + extra_attr = { "hx-on::after-settle": f"initDataGrid('{self._id}');", } - + if fragment == "body": body_container = self.mk_body_wrapper() body_container.attrs.update(extra_attr) res.append(body_container) - + elif fragment == "table": table = self.mk_table() table.attrs.update(extra_attr) res.append(table) - + elif fragment == "header": col_id = kwargs.get("col_id") optimal_width = kwargs.get("optimal_width") - + header_extra_attr = { "hx-on::after-settle": f"setColumnWidth('{self._id}', '{col_id}', '{optimal_width}');", } - + header = self.mk_headers() header.attrs.update(header_extra_attr) return header - + res.append(self.mk_selection_manager()) - + return tuple(res) def dispose(self): diff --git a/src/myfasthtml/controls/DataGridColumnsManager.py b/src/myfasthtml/controls/DataGridColumnsManager.py index b2797ff..fdf63f8 100644 --- a/src/myfasthtml/controls/DataGridColumnsManager.py +++ b/src/myfasthtml/controls/DataGridColumnsManager.py @@ -10,7 +10,7 @@ from myfasthtml.controls.Search import Search from myfasthtml.controls.datagrid_objects import DataGridColumnState from myfasthtml.controls.helpers import mk 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.formula.dsl.completion.FormulaCompletionEngine import FormulaCompletionEngine from myfasthtml.core.formula.dsl.parser import FormulaParser @@ -39,21 +39,21 @@ class Commands(BaseCommands): return Command(f"ShowAllColumns", f"Show all columns", 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): return Command(f"SaveColumnDetails", f"Save column {col_id}", self._owner, - self._owner.save_column_details, + self._owner.handle_save_column_details, kwargs={"col_id": col_id} ).htmx(target=f"#{self._id}", swap="innerHTML") - def on_new_column(self): - return Command(f"OnNewColumn", - f"New column", + def add_new_column(self): + return Command(f"AddNewColumn", + f"Add a new column", 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): return Command(f"OnColumnTypeChanged", @@ -66,7 +66,8 @@ class DataGridColumnsManager(MultipleInstance): def __init__(self, parent, _id=None): super().__init__(parent, _id=_id) 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( self._parent._parent, @@ -109,6 +110,9 @@ class DataGridColumnsManager(MultipleInstance): return col_def + def set_all_columns(self, all_columns): + self._all_columns = all_columns + def toggle_column(self, col_id): logger.debug(f"toggle_column {col_id=}") 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) - def show_all_columns(self): - return self._mk_inner_content() + def handle_show_all_columns(self): + 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=}") col_def = self._get_updated_col_def_from_col_id(col_id, client_response, copy=False) if col_def.col_id == "__new__": 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._parent.save_state() - return self._mk_inner_content() + return self._mk_inner_content() if self._all_columns else None - def on_new_column(self): - self._new_column = True - col_def = self._get_updated_col_def_from_col_id("__new__") + def handle_add_new_column(self): + self.adding_new_column = True + col_def = DataGridColumnState("__new__", -1) return self.mk_column_details(col_def) 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): size = "sm" + return Div( mk.label("Back", icon=chevron_left20_regular, command=self.commands.show_all_columns()), Form( @@ -210,7 +217,9 @@ class DataGridColumnsManager(MultipleInstance): Label("type"), mk.mk( 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", cls=f"select select-{size}", value=col_def.title, @@ -260,11 +269,15 @@ class DataGridColumnsManager(MultipleInstance): def mk_new_column(self): 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", ) 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(), self.mk_new_column()) diff --git a/src/myfasthtml/controls/IconsHelper.py b/src/myfasthtml/controls/IconsHelper.py index c517ff7..3d5d382 100644 --- a/src/myfasthtml/controls/IconsHelper.py +++ b/src/myfasthtml/controls/IconsHelper.py @@ -1,3 +1,5 @@ +from fastcore.basics import NotStr + from myfasthtml.core.constants import ColumnType from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \ number_row20_regular @@ -13,7 +15,7 @@ default_icons = { False: checkbox_unchecked20_regular, "Brain": brain_circuit20_regular, - "QuestionMark" : question20_regular, + "QuestionMark": question20_regular, # TreeView icons "TreeViewFolder": folder20_regular, @@ -46,6 +48,9 @@ class IconsHelper: :return: The requested icon resource if found; otherwise, returns None. :rtype: object or None """ + if isinstance(name, NotStr): + return name + if name in IconsHelper._icons: return IconsHelper._icons[name] diff --git a/src/myfasthtml/controls/Menu.py b/src/myfasthtml/controls/Menu.py index 7e020ce..9b99108 100644 --- a/src/myfasthtml/controls/Menu.py +++ b/src/myfasthtml/controls/Menu.py @@ -43,6 +43,9 @@ class Menu(MultipleInstance): } def _mk_menu(self, command_name): + if not isinstance(command_name, str): + return command_name + command = self.usable_commands.get(command_name) return mk.icon(command.icon or IconsHelper.get("QuestionMark"), command=command, diff --git a/src/myfasthtml/controls/Panel.py b/src/myfasthtml/controls/Panel.py index 15566df..4207572 100644 --- a/src/myfasthtml/controls/Panel.py +++ b/src/myfasthtml/controls/Panel.py @@ -64,8 +64,8 @@ class PanelState(DbObject): class Commands(BaseCommands): def set_side_visible(self, side: Literal["left", "right"], visible: bool = None): - return Command("TogglePanelSide", - f"Toggle {side} side panel", + return Command("SetVisiblePanelSide", + f"Open / Close {side} side panel", self._owner, self._owner.set_side_visible, args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}") diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 7f6fe40..26e2a9e 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -151,7 +151,7 @@ class Command: before_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "before"] for bound_cmd in before_commands: 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) # 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"] for bound_cmd in after_commands: 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) all_ret = flatten(ret, ret_from_before_commands, ret_from_after_commands, collector.results) diff --git a/src/myfasthtml/core/constants.py b/src/myfasthtml/core/constants.py index 5eede34..cde3cba 100644 --- a/src/myfasthtml/core/constants.py +++ b/src/myfasthtml/core/constants.py @@ -6,6 +6,7 @@ ROUTE_ROOT = "/myfasthtml" # Datagrid ROW_INDEX_ID = "__row_index__" +ROW_SELECTION_ID = "__row_selection__" DATAGRID_DEFAULT_COLUMN_WIDTH = 100 DATAGRID_PAGE_SIZE = 1000 FILTER_INPUT_CID = "__filter_input__" @@ -19,6 +20,7 @@ class Routes: class ColumnType(Enum): + RowSelection_ = "RowSelection_" RowIndex = "RowIndex" Text = "Text" Number = "Number" @@ -29,6 +31,10 @@ class ColumnType(Enum): Formula = "Formula" +def get_columns_types() -> list[ColumnType]: + return [c for c in ColumnType if not c.value.endswith("_")] + + class ViewType(Enum): Table = "Table" Chart = "Chart" diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index eecd92a..30c1d06 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -1,9 +1,9 @@ import logging import uuid -from typing import Optional +from typing import Optional, Literal 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.utils import pascal_to_snake, get_class, snake_to_pascal @@ -113,7 +113,10 @@ class BaseInstance: parent = self.get_parent() 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. @@ -126,18 +129,28 @@ class BaseInstance: Duplicate bindings are automatically prevented using two mechanisms: 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 - existing_bindings = self._bound_commands.get(command_name, []) - for existing in existing_bindings: - if existing.command.name == command_to_bind.name and existing.when == when: - # Binding already exists, don't add it again - return - - # Add new binding - bound = BoundCommand(command=command_to_bind, when=when) - self._bound_commands.setdefault(command_name, []).append(bound) + + def _bind(_command_name, _command_to_bind, _when): + # Check if this binding already exists to prevent duplicates + existing_bindings = self._bound_commands.get(_command_name, []) + for existing in existing_bindings: + if existing.command.name == _command_to_bind.name and existing.when == _when: + return # Binding already exists, don't add it again + + # Add new binding + bound = BoundCommand(command=_command_to_bind, when=_when) + 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): return self._bound_commands.get(command_name, [])