diff --git a/js/package.json b/js/package.json index 690b9416..582401e8 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "qgrid", - "version": "1.0.6-beta.6", + "version": "1.1.0-beta.0", "description": "An Interactive Grid for Sorting and Filtering DataFrames in Jupyter Notebook", "author": "Quantopian Inc.", "main": "src/index.js", diff --git a/js/src/qgrid.css b/js/src/qgrid.css index 0afc86d0..80ebb0c7 100644 --- a/js/src/qgrid.css +++ b/js/src/qgrid.css @@ -724,3 +724,8 @@ input.bool-filter-radio { padding-left: 5px; margin-left: -4px; } + +.q-grid .slick-sort-indicator-desc, +.q-grid .slick-sort-indicator-asc { + background-image: none; +} diff --git a/js/src/qgrid.editors.js b/js/src/qgrid.editors.js index 502efeff..ff3d0ec1 100644 --- a/js/src/qgrid.editors.js +++ b/js/src/qgrid.editors.js @@ -63,11 +63,17 @@ class SelectEditor { } var option_str = ""; + + this.elem = $("" + option_str + ""); + this.elem.appendTo(args.container); this.elem.focus(); } diff --git a/js/src/qgrid.filterbase.js b/js/src/qgrid.filterbase.js index 47b41402..36b9e149 100644 --- a/js/src/qgrid.filterbase.js +++ b/js/src/qgrid.filterbase.js @@ -89,7 +89,7 @@ class FilterBase { this.filter_btn.addClass('disabled'); var msg = { - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': this.field, 'search_val': null }; @@ -173,7 +173,7 @@ class FilterBase { } var msg = { - 'type': 'filter_changed', + 'type': 'change_filter', 'field': this.field, 'filter_info': this.get_filter_info() }; diff --git a/js/src/qgrid.textfilter.js b/js/src/qgrid.textfilter.js index bc66fb9f..8204829e 100644 --- a/js/src/qgrid.textfilter.js +++ b/js/src/qgrid.textfilter.js @@ -201,7 +201,7 @@ class TextFilter extends filter_base.FilterBase { this.viewport_timeout = setTimeout(() => { var vp = args.grid.getViewport(); var msg = { - 'type': 'viewport_changed_filter', + 'type': 'change_filter_viewport', 'field': this.field, 'top': vp.top, 'bottom': vp.bottom @@ -311,7 +311,7 @@ class TextFilter extends filter_base.FilterBase { this.search_string = this.security_search.val(); if (old_search_string != this.search_string) { var msg = { - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': this.field, 'search_val': this.search_string }; @@ -363,7 +363,7 @@ class TextFilter extends filter_base.FilterBase { this.filter_list = null; this.send_filter_changed(); var msg = { - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': this.field, 'search_val': this.search_string }; diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index a4417522..d820edcd 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -36,8 +36,8 @@ class QgridModel extends widgets.DOMWidgetModel { _view_name : 'QgridView', _model_module : 'qgrid', _view_module : 'qgrid', - _model_module_version : '^1.0.6-beta.6', - _view_module_version : '^1.0.6-beta.6', + _model_module_version : '^1.1.0-beta.0', + _view_module_version : '^1.1.0-beta.0', _df_json: '', _columns: {} }); @@ -215,6 +215,9 @@ class QgridView extends widgets.DOMWidgetView { this.sort_in_progress = false; this.sort_indicator = null; this.resizing_column = false; + this.ignore_selection_changed = false; + this.vp_response_expected = false; + this.next_viewport_msg = null; var number_type_info = { filter: slider_filter.SliderFilter, @@ -318,14 +321,7 @@ class QgridView extends widgets.DOMWidgetView { var type_info = this.type_infos[cur_column.type] || {}; - var slick_column = { - name: cur_column.name, - field: cur_column.name, - id: cur_column.name, - sortable: false, - resizable: true, - cssClass: cur_column.type - }; + var slick_column = cur_column; Object.assign(slick_column, type_info); @@ -345,9 +341,18 @@ class QgridView extends widgets.DOMWidgetView { this.filter_list.push(cur_filter); } + if (cur_column.width == null){ + delete slick_column.width; + } + + if (cur_column.maxWidth == null){ + delete slick_column.maxWidth; + } + // don't allow editing index columns if (cur_column.is_index) { slick_column.editor = editors.IndexEditor; + slick_column.cssClass += ' idx-col'; if (cur_column.first_index){ slick_column.cssClass += ' first-idx-col'; @@ -358,9 +363,19 @@ class QgridView extends widgets.DOMWidgetView { slick_column.name = cur_column.index_display_text; slick_column.level = cur_column.level; + + if (this.grid_options.boldIndex) { + slick_column.cssClass += ' idx-col'; + } + this.index_columns.push(slick_column); continue; } + + if (cur_column.editable == false) { + slick_column.editor = null; + } + this.columns.push(slick_column); } @@ -431,7 +446,6 @@ class QgridView extends widgets.DOMWidgetView { if (this.sort_in_progress){ return; } - this.sort_in_progress = true; var col_header = $(e.target).closest(".slick-header-column"); if (!col_header.length) { @@ -439,11 +453,21 @@ class QgridView extends widgets.DOMWidgetView { } var column = col_header.data("column"); + if (column.sortable == false){ + return; + } + + this.sort_in_progress = true; + if (this.sorted_column == column){ this.sort_ascending = !this.sort_ascending; } else { this.sorted_column = column; - this.sort_ascending = true; + if ('defaultSortAsc' in column) { + this.sort_ascending = column.defaultSortAsc; + } else{ + this.sort_ascending = true; + } } var all_classes = 'fa-sort-asc fa-sort-desc fa fa-spin fa-spinner'; @@ -458,7 +482,7 @@ class QgridView extends widgets.DOMWidgetView { this.grid_elem.find('.slick-sort-indicator').removeClass(all_classes); this.sort_indicator.addClass(`fa fa-spinner fa-spin`); var msg = { - 'type': 'sort_changed', + 'type': 'change_sort', 'sort_field': this.sorted_column.field, 'sort_ascending': this.sort_ascending }; @@ -475,29 +499,48 @@ class QgridView extends widgets.DOMWidgetView { } this.viewport_timeout = setTimeout(() => { this.last_vp = this.slick_grid.getViewport(); - var msg = { - 'type': 'viewport_changed', - 'top': this.last_vp.top, - 'bottom': this.last_vp.bottom - }; - this.send(msg); + var cur_range = this.model.get('_viewport_range'); + + if (this.last_vp.top != cur_range[0] || this.last_vp.bottom != cur_range[1]) { + var msg = { + 'type': 'change_viewport', + 'top': this.last_vp.top, + 'bottom': this.last_vp.bottom + }; + if (this.vp_response_expected){ + this.next_viewport_msg = msg + } else { + this.vp_response_expected = true; + this.send(msg); + } + } this.viewport_timeout = null; - }, 10); + }, 100); }); // set up callbacks + let editable_rows = this.model.get('_editable_rows'); + if (editable_rows && Object.keys(editable_rows).length > 0) { + this.slick_grid.onBeforeEditCell.subscribe((e, args) => { + editable_rows = this.model.get('_editable_rows'); + return editable_rows[args.item[this.index_col_name]] + }); + } + this.slick_grid.onCellChange.subscribe((e, args) => { var column = this.columns[args.cell].name; var data_item = this.slick_grid.getDataItem(args.row); var msg = {'row_index': data_item.row_index, 'column': column, 'unfiltered_index': data_item[this.index_col_name], - 'value': args.item[column], 'type': 'cell_change'}; + 'value': args.item[column], 'type': 'edit_cell'}; this.send(msg); }); this.slick_grid.onSelectedRowsChanged.subscribe((e, args) => { - var msg = {'rows': args.rows, 'type': 'selection_changed'}; - this.send(msg); + if (!this.ignore_selection_changed) { + var msg = {'rows': args.rows, 'type': 'change_selection'}; + this.send(msg); + } }); setTimeout(() => { @@ -648,7 +691,17 @@ class QgridView extends widgets.DOMWidgetView { this.multi_index = this.model.get("_multi_index"); var data_view = this.create_data_view(df_json.data); - if (msg.triggered_by == 'sort_changed' && this.sort_indicator){ + if (msg.triggered_by === 'change_viewport'){ + if (this.next_viewport_msg) { + this.send(this.next_viewport_msg); + this.next_viewport_msg = null; + return; + } else { + this.vp_response_expected = false; + } + } + + if (msg.triggered_by == 'change_sort' && this.sort_indicator){ var asc = this.model.get('_sort_ascending'); this.sort_indicator.removeClass( 'fa-spinner fa-spin fa-sort-asc fa-sort-desc' @@ -694,7 +747,7 @@ class QgridView extends widgets.DOMWidgetView { } else if (msg.triggered_by === 'add_row') { this.slick_grid.scrollRowIntoView(msg.scroll_to_row); this.slick_grid.setSelectedRows([msg.scroll_to_row]); - } else if (msg.triggered_by === 'viewport_changed' && + } else if (msg.triggered_by === 'change_viewport' && this.last_vp.bottom >= this.df_length) { this.slick_grid.scrollRowIntoView(this.last_vp.bottom); } @@ -704,9 +757,22 @@ class QgridView extends widgets.DOMWidgetView { }); this.send({ 'rows': selected_rows, - 'type': 'selection_changed' + 'type': 'change_selection' }); - }, 10); + }, 100); + } else if (msg.type == 'toggle_editable') { + if (this.slick_grid.getOptions().editable == false) { + this.slick_grid.setOptions({'editable': true}); + } else { + this.slick_grid.setOptions({'editable': false}); + } + } else if (msg.type == 'change_selection') { + this.ignore_selection_changed = true; + this.slick_grid.setSelectedRows(msg.rows); + if (msg.rows && msg.rows.length > 0) { + this.slick_grid.scrollRowIntoView(msg.rows[0]); + } + this.ignore_selection_changed = false; } else if (msg.col_info) { var filter = this.filters[msg.col_info.name]; filter.handle_msg(msg); diff --git a/qgrid/_version.py b/qgrid/_version.py index d1a1301c..cfbd75cc 100644 --- a/qgrid/_version.py +++ b/qgrid/_version.py @@ -1,4 +1,4 @@ -version_info = (1, 0, 6, 'beta', 6) +version_info = (1, 1, 0, 'beta', 0) _specifier_ = {'alpha': 'a', 'beta': 'b', 'candidate': 'rc', 'final': ''} diff --git a/qgrid/grid.py b/qgrid/grid.py index e3e8f360..c567311f 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -3,6 +3,7 @@ import numpy as np import json +from types import FunctionType from IPython.display import display from numbers import Integral from traitlets import ( @@ -51,7 +52,19 @@ def __init__(self): 'sortable': True, 'filterable': True, 'highlightSelectedCell': False, - 'highlightSelectedRow': True + 'highlightSelectedRow': True, + 'boldIndex': True + } + self._column_options = { + 'editable': True, + # the following options are supported by SlickGrid + 'defaultSortAsc': True, + 'maxWidth': None, + 'minWidth': 30, + 'resizable': True, + 'sortable': True, + 'toolTip': "", + 'width': None } self._show_toolbar = False self._precision = None # Defer to pandas.get_option @@ -60,13 +73,15 @@ def set_grid_option(self, optname, optvalue): self._grid_options[optname] = optvalue def set_defaults(self, show_toolbar=None, precision=None, - grid_options=None): + grid_options=None, column_options=None): if show_toolbar is not None: self._show_toolbar = show_toolbar if precision is not None: self._precision = precision if grid_options is not None: self._grid_options = grid_options + if column_options is not None: + self._column_options = column_options @property def show_toolbar(self): @@ -80,6 +95,10 @@ def grid_options(self): def precision(self): return self._precision or pd.get_option('display.precision') - 1 + @property + def column_options(self): + return self._column_options + class _EventHandlers(object): @@ -113,7 +132,10 @@ def notify_listeners(self, event, qgrid_widget): handlers = _EventHandlers() -def set_defaults(show_toolbar=None, precision=None, grid_options=None): +def set_defaults(show_toolbar=None, + precision=None, + grid_options=None, + column_options=None): """ Set the default qgrid options. The options that you can set here are the same ones that you can pass into ``QgridWidget`` constructor, with the @@ -137,7 +159,8 @@ def set_defaults(show_toolbar=None, precision=None, grid_options=None): """ defaults.set_defaults(show_toolbar=show_toolbar, precision=precision, - grid_options=grid_options) + grid_options=grid_options, + column_options=column_options) def on(names, handler): @@ -296,8 +319,13 @@ def disable(): enable(dataframe=False, series=False) -def show_grid(data_frame, show_toolbar=None, - precision=None, grid_options=None): +def show_grid(data_frame, + show_toolbar=None, + precision=None, + grid_options=None, + column_options=None, + column_definitions=None, + row_edit_callback=None): """ Renders a DataFrame or Series as an interactive qgrid, represented by an instance of the ``QgridWidget`` class. The ``QgridWidget`` instance @@ -310,15 +338,135 @@ def show_grid(data_frame, show_toolbar=None, DataFrame before being passed in to the QgridWidget constructor as the ``df`` kwarg. - See the ``QgridWidget`` documentation for descriptions of all of - the options that can be set via it's constructor. - :rtype: QgridWidget + Parameters + ---------- + data_frame : DataFrame + The DataFrame that will be displayed by this instance of + QgridWidget. + grid_options : dict + Options to use when creating the SlickGrid control (i.e. the + interactive grid). See the Notes section below for more information + on the available options, as well as the default options that this + widget uses. + precision : integer + The number of digits of precision to display for floating-point + values. If unset, we use the value of + `pandas.get_option('display.precision')`. + show_toolbar : bool + Whether to show a toolbar with options for adding/removing rows. + Adding/removing rows is an experimental feature which only works + with DataFrames that have an integer index. + column_options : dict + Column options that are to be applied to every column. See the + Notes section below for more information on the available options, + as well as the default options that this widget uses. + column_definitions : dict + Column options that are to be applied to individual + columns. The keys of the dict should be the column names, and each + value should be the column options for a particular column, + represented as a dict. The available options for each column are the + same options that are available to be set for all columns via the + ``column_options`` parameter. See the Notes section below for more + information on those options. + row_edit_callback : callable + A callable that is called to determine whether a particular row + should be editable or not. Its signature should be + ``callable(row)``, where ``row`` is a dictionary which contains a + particular row's values, keyed by column name. The callback should + return True if the provided row should be editable, and False + otherwise. + + + Notes + ----- + The following dictionary is used for ``grid_options`` if none are + provided explicitly:: + + { + # SlickGrid options + 'fullWidthRows': True, + 'syncColumnCellResize': True, + 'forceFitColumns': True, + 'defaultColumnWidth': 150, + 'rowHeight': 28, + 'enableColumnReorder': False, + 'enableTextSelectionOnCells': True, + 'editable': True, + 'autoEdit': False, + 'explicitInitialization': True, + + # Qgrid options + 'maxVisibleRows': 15, + 'minVisibleRows': 8, + 'sortable': True, + 'filterable': True, + 'highlightSelectedCell': False, + 'highlightSelectedRow': True + } + + The first group of options are SlickGrid "grid options" which are + described in the `SlickGrid documentation + `_. + + The second group of option are options that were added specifically + for Qgrid and therefore are not documented in the SlickGrid documentation. + The following bullet points describe these options. + + * **maxVisibleRows** The maximum number of rows that Qgrid will show. + * **minVisibleRows** The minimum number of rows that Qgrid will show + * **sortable** Whether the Qgrid instance will allow the user to sort + columns by clicking the column headers. When this is set to ``False``, + nothing will happen when users click the column headers. + * **filterable** Whether the Qgrid instance will allow the user to filter + the grid. When this is set to ``False`` the filter icons won't be shown + for any columns. + * **highlightSelectedCell** If you set this to True, the selected cell + will be given a light blue border. + * **highlightSelectedRow** If you set this to False, the light blue + background that's shown by default for selected rows will be hidden. + + The following dictionary is used for ``column_options`` if none are + provided explicitly:: + + { + # SlickGrid column options + 'defaultSortAsc': True, + 'maxWidth': None, + 'minWidth': 30, + 'resizable': True, + 'sortable': True, + 'toolTip': "", + 'width': None + + # Qgrid column options + 'editable': True, + } + + The first group of options are SlickGrid "column options" which are + described in the `SlickGrid documentation + `_. + + The ``editable`` option was added specifically for Qgrid and therefore is + not documented in the SlickGrid documentation. This option specifies + whether a column should be editable or not. + See Also -------- + set_defaults : Permanently set global defaults for the parameters + of ``show_grid``, with the exception of the ``data_frame`` + and ``column_definitions`` parameters, since those + depend on the particular set of data being shown by an + instance, and therefore aren't parameters we would want + to set for all QgridWidet instances. + set_grid_option : Permanently set global defaults for individual + grid options. Does so by changing the defaults + that the ``show_grid`` method uses for the + ``grid_options`` parameter. QgridWidget : The widget class that is instantiated and returned by this - function. + method. + """ if show_toolbar is None: @@ -327,6 +475,12 @@ def show_grid(data_frame, show_toolbar=None, precision = defaults.precision if not isinstance(precision, Integral): raise TypeError("precision must be int, not %s" % type(precision)) + if column_options is None: + column_options = defaults.column_options + else: + options = defaults.column_options.copy() + options.update(column_options) + column_options = options if grid_options is None: grid_options = defaults.grid_options else: @@ -346,9 +500,14 @@ def show_grid(data_frame, show_toolbar=None, "data_frame must be DataFrame or Series, not %s" % type(data_frame) ) + column_definitions = (column_definitions or {}) + # create a visualization for the dataframe return QgridWidget(df=data_frame, precision=precision, grid_options=grid_options, + column_options=column_options, + column_definitions=column_definitions, + row_edit_callback=row_edit_callback, show_toolbar=show_toolbar) @@ -365,91 +524,21 @@ def stringify(x): @widgets.register() class QgridWidget(widgets.DOMWidget): """ - The widget class which is instantiated by the 'show_grid' method, and - can also be constructed directly. All of the parameters listed below - can be read/updated after instantiation via attributes of the same name - as the parameter (since they're implemented as traitlets). - - When new values are set for any of these options after instantiation - (such as df, grid_options, etc), the change takes effect immediately by - regenerating the SlickGrid control. - - Parameters - ---------- - df : DataFrame - The DataFrame that will be displayed by this instance of - QgridWidget. - grid_options : dict - Options to use when creating the SlickGrid control (i.e. the - interactive grid). See the Notes section below for more information - on the available options, as well as the default options that this - widget uses. - precision : integer - The number of digits of precision to display for floating-point - values. If unset, we use the value of - `pandas.get_option('display.precision')`. - show_toolbar : bool - Whether to show a toolbar with options for adding/removing rows. - Adding/removing rows is an experimental feature which only works - with DataFrames that have an integer index. - - Notes - ----- - The following dictionary is used for ``grid_options`` if none are - provided explicitly:: - - { - 'fullWidthRows': True, - 'syncColumnCellResize': True, - 'forceFitColumns': True, - 'defaultColumnWidth': 150, - 'rowHeight': 28, - 'enableColumnReorder': False, - 'enableTextSelectionOnCells': True, - 'editable': True, - 'autoEdit': False, - 'explicitInitialization': True, - 'maxVisibleRows': 15, - 'minVisibleRows': 8, - 'sortable': True, - 'filterable': True, - 'highlightSelectedCell': False, - 'highlightSelectedRow': True - } + The widget class which is instantiated by the ``show_grid`` method. This + class can be constructed directly but that's not recommended because + then default options have to be specified explicitly (since default + options are normally provided by the ``show_grid`` method). - Most of these options are SlickGrid options which are described - in the `SlickGrid documentation - `_. The - exceptions are the last 6 options listed, which are options that were - added specifically for Qgrid and therefore are not documented in the - SlickGrid documentation. - - The first two, `maxVisibleRows` and `minVisibleRows`, allow you to set - an upper and lower bound on the height of your Qgrid widget in terms of - number of rows that are visible. - - The next two, `sortable` and `filterable`, control whether qgrid will - allow the user to sort and filter, respectively. If you set `sortable` to - False nothing will happen when the column headers are clicked. - If you set `filterable` to False, the filter icons won't be shown for any - columns. - - The last two, `highlightSelectedCell` and `highlightSelectedRow`, control - how the styling of qgrid changes when a cell is selected. If you set - `highlightSelectedCell` to True, the selected cell will be given - a light blue border. If you set `highlightSelectedRow` to False, the - light blue background that's shown by default for selected rows will be - hidden. + The constructor for this class takes all the same parameters as + ``show_grid``, with one exception, which is that the required + ``data_frame`` parameter is replaced by an optional keyword argument + called ``df``. See Also -------- - set_defaults : Permanently set global defaults for the parameters - of the QgridWidget constructor, with the exception of - the ``df`` parameter. - set_grid_option : Permanently set global defaults for individual - SlickGrid options. Does so by changing the default - for the ``grid_options`` parameter of the QgridWidget - constructor. + show_grid : The method that should be used to construct QgridWidget + instances, because it provides reasonable defaults for all + of the qgrid options. Attributes ---------- @@ -460,11 +549,16 @@ class QgridWidget(widgets.DOMWidget): does reflect sorting/filtering/editing changes, use the ``get_changed_df()`` method. grid_options : dict - Get/set the SlickGrid options being used by the current instance. + Get/set the grid options being used by the current instance. precision : integer Get/set the precision options being used by the current instance. show_toolbar : bool Get/set the show_toolbar option being used by the current instance. + column_options : bool + Get/set the column options being used by the current instance. + column_definitions : bool + Get/set the column definitions (column-specific options) + being used by the current instance. """ @@ -472,8 +566,8 @@ class QgridWidget(widgets.DOMWidget): _model_name = Unicode('QgridModel').tag(sync=True) _view_module = Unicode('qgrid').tag(sync=True) _model_module = Unicode('qgrid').tag(sync=True) - _view_module_version = Unicode('1.0.6-beta.6').tag(sync=True) - _model_module_version = Unicode('1.0.6-beta.6').tag(sync=True) + _view_module_version = Unicode('1.1.0-beta.0').tag(sync=True) + _model_module_version = Unicode('1.1.0-beta.0').tag(sync=True) _df = Instance(pd.DataFrame) _df_json = Unicode('', sync=True) @@ -482,6 +576,7 @@ class QgridWidget(widgets.DOMWidget): _row_styles = Dict({}, sync=True) _disable_grouping = Bool(False) _columns = Dict({}, sync=True) + _editable_rows = Dict({}, sync=True) _filter_tables = Dict({}) _sorted_column_cache = Dict({}) _interval_columns = List([], sync=True) @@ -496,7 +591,10 @@ class QgridWidget(widgets.DOMWidget): _multi_index = Bool(False, sync=True) _edited = Bool(False) _selected_rows = List([]) - _viewport_range = Tuple(Integer(), Integer(), default_value=(0, 100)) + _viewport_range = Tuple(Integer(), + Integer(), + default_value=(0, 100), + sync=True) _df_range = Tuple(Integer(), Integer(), default_value=(0, 100), sync=True) _row_count = Integer(0, sync=True) _sort_field = Any(None, sync=True) @@ -506,6 +604,9 @@ class QgridWidget(widgets.DOMWidget): df = Instance(pd.DataFrame) precision = Integer(6, sync=True) grid_options = Dict(sync=True) + column_options = Dict({}) + column_definitions = Dict({}) + row_edit_callback = Instance(FunctionType, sync=False, allow_none=True) show_toolbar = Bool(False, sync=True) id = Unicode(sync=True) @@ -598,10 +699,10 @@ def on(self, names, handler): data (in json format) down to the browser. This happens as a side effect of certain actions such as scrolling, sorting, and filtering. - * **triggered_by** The name of the event that resulted in rows of - data being sent down to the browser. Possible values are - ``viewport_changed``, ``filter_changed``, ``sort_changed``, - ``add_row``, and ``remove_row``. + * **triggered_by** The name of the event that resulted in + rows of data being sent down to the browser. Possible values + are ``change_viewport``, ``change_filter``, ``change_sort``, + ``add_row``, ``remove_row``, and ``edit_cell``. * **range** A tuple specifying the range of rows that have been sent down to the browser. @@ -609,12 +710,16 @@ def on(self, names, handler): in the grid toolbar. * **index** The index of the newly added row. + * **source** The source of this event. Possible values are + ``api`` (an api method call) and ``gui`` (the grid interface). * **row_removed** The user added removed one or more rows using the "Remove Row" button in the grid toolbar. * **indices** The indices of the removed rows, specified as an array of integers. + * **source** The source of this event. Possible values are + ``api`` (an api method call) and ``gui`` (the grid interface). * **selection_changed** The user changed which rows were highlighted in the grid. @@ -623,6 +728,8 @@ def on(self, names, handler): selected rows. * **new** The indices of the rows that are now selected, again specified as an array. + * **source** The source of this event. Possible values are + ``api`` (an api method call) and ``gui`` (the grid interface). * **sort_changed** The user changed the sort setting for the grid. @@ -885,12 +992,21 @@ def should_be_stringified(col_series): level = self._primary_key.index(col_name) if level == 0: cur_column['first_index'] = True - if self._multi_index and level == (len(self._primary_key) - 1): + if self._multi_index and \ + level == (len(self._primary_key) - 1): cur_column['last_index'] = True cur_column['position'] = i + cur_column['field'] = col_name + cur_column['id'] = col_name + cur_column['cssClass'] = cur_column['type'] + columns[col_name] = cur_column + columns[col_name].update(self.column_options) + if col_name in self.column_definitions.keys(): + columns[col_name].update(self.column_definitions[col_name]) + self._columns = columns # special handling for interval columns: convert to a string column @@ -927,6 +1043,14 @@ def should_be_stringified(col_series): double_precision=self.precision) self._df_json = df_json + + if self.row_edit_callback is not None: + editable_rows = {} + for index, row in df.iterrows(): + editable_rows[int(row[self._index_col_name])] = \ + self.row_edit_callback(row) + self._editable_rows = editable_rows + if fire_data_change_event: self._notify_listeners({ 'name': 'json_updated', @@ -1008,7 +1132,7 @@ def _initialize_sort_column(self, col_name, to_timestamp=False): self._sort_helper_columns[col_name] = sort_column_name return sort_column_name - def _handle_get_column_min_max(self, content): + def _handle_show_filter_dropdown(self, content): col_name = content['field'] col_info = self._columns[col_name] if 'filter_info' in col_info and 'selected' in col_info['filter_info']: @@ -1254,7 +1378,7 @@ def get_value_from_filter_table(i): ) conditions.append(col_series.isin(selected_values)) - def _handle_filter_changed(self, content): + def _handle_change_filter(self, content): col_name = content['field'] columns = self._columns.copy() col_info = columns[col_name] @@ -1287,7 +1411,7 @@ def _handle_filter_changed(self, content): self._sorted_column_cache = {} self._update_sort() - self._update_table(triggered_by='filter_changed') + self._update_table(triggered_by='change_filter') self._ignore_df_changed = False def _handle_qgrid_msg(self, widget, content, buffers=None): @@ -1302,7 +1426,7 @@ def _handle_qgrid_msg_helper(self, content): if 'type' not in content: return - if content['type'] == 'cell_change': + if content['type'] == 'edit_cell': col_info = self._columns[content['column']] try: location = (self._df.index[content['row_index']], @@ -1323,7 +1447,8 @@ def _handle_qgrid_msg_helper(self, content): 'index': location[0], 'column': location[1], 'old': old_value, - 'new': val_to_set + 'new': val_to_set, + 'source': 'gui' }) except (ValueError, TypeError): @@ -1337,24 +1462,17 @@ def _handle_qgrid_msg_helper(self, content): 'triggered_by': 'add_row' }) return - elif content['type'] == 'selection_changed': - old_selection = self._selected_rows - self._selected_rows = content['rows'] + elif content['type'] == 'change_selection': + self._change_selection(content['rows'], 'gui') + elif content['type'] == 'change_viewport': + old_viewport_range = self._viewport_range + self._viewport_range = (content['top'], content['bottom']) - # if the selection didn't change, just return without firing - # the event - if old_selection == self._selected_rows: + # if the viewport didn't change, do nothing + if old_viewport_range == self._viewport_range: return - self._notify_listeners({ - 'name': 'selection_changed', - 'old': old_selection, - 'new': self._selected_rows - }) - elif content['type'] == 'viewport_changed': - old_viewport_range = self._viewport_range - self._viewport_range = (content['top'], content['bottom']) - self._update_table(triggered_by='viewport_changed') + self._update_table(triggered_by='change_viewport') self._notify_listeners({ 'name': 'viewport_changed', 'old': old_viewport_range, @@ -1362,18 +1480,20 @@ def _handle_qgrid_msg_helper(self, content): }) elif content['type'] == 'add_row': - row_index = self.add_row() + row_index = self._duplicate_last_row() self._notify_listeners({ 'name': 'row_added', - 'index': row_index + 'index': row_index, + 'source': 'gui' }) elif content['type'] == 'remove_row': - removed_indices = self.remove_row() + removed_indices = self._remove_rows() self._notify_listeners({ 'name': 'row_removed', - 'indices': removed_indices + 'indices': removed_indices, + 'source': 'gui' }) - elif content['type'] == 'viewport_changed_filter': + elif content['type'] == 'change_filter_viewport': col_name = content['field'] col_info = self._columns[col_name] col_filter_table = self._filter_tables[col_name] @@ -1398,14 +1518,14 @@ def _handle_qgrid_msg_helper(self, content): 'old': old_viewport_range, 'new': col_info['viewport_range'] }) - elif content['type'] == 'sort_changed': + elif content['type'] == 'change_sort': old_column = self._sort_field old_ascending = self._sort_ascending self._sort_field = content['sort_field'] self._sort_ascending = content['sort_ascending'] self._sorted_column_cache = {} self._update_sort() - self._update_table(triggered_by='sort_changed') + self._update_table(triggered_by='change_sort') self._notify_listeners({ 'name': 'sort_changed', 'old': { @@ -1417,14 +1537,14 @@ def _handle_qgrid_msg_helper(self, content): 'ascending': self._sort_ascending } }) - elif content['type'] == 'get_column_min_max': - self._handle_get_column_min_max(content) + elif content['type'] == 'show_filter_dropdown': + self._handle_show_filter_dropdown(content) self._notify_listeners({ 'name': 'filter_dropdown_shown', 'column': content['field'] }) - elif content['type'] == 'filter_changed': - self._handle_filter_changed(content) + elif content['type'] == 'change_filter': + self._handle_change_filter(content) self._notify_listeners({ 'name': 'filter_changed', 'column': content['field'] @@ -1470,10 +1590,41 @@ def get_selected_rows(self): """ return self._selected_rows - def add_row(self): + def add_row(self, row=None): + """ + Append a row at the end of the DataFrame. Values for the new row + can be provided via the ``row`` argument, which is optional for + DataFrames that have an integer index, and required otherwise. + If the ``row`` argument is not provided, the last row will be + duplicated and the index of the new row will be the index of + the last row plus one. + + Parameters + ---------- + row : list (default: None) + A list of 2-tuples of (column name, column value) that specifies + the values for the new row. + + See Also + -------- + QgridWidget.remove_rows: + The method for removing a row (or rows). """ - Append a row at the end of the dataframe by duplicating the - last row and incrementing it's index by 1. The feature is only + if row is None: + added_index = self._duplicate_last_row() + else: + added_index = self._add_row(row) + + self._notify_listeners({ + 'name': 'row_added', + 'index': added_index, + 'source': 'api' + }) + + def _duplicate_last_row(self): + """ + Append a row at the end of the DataFrame by duplicating the + last row and incrementing it's index by 1. The method is only available for DataFrames that have an integer index. """ df = self._df @@ -1497,26 +1648,153 @@ def add_row(self): scroll_to_row=df.index.get_loc(last.name)) return last.name - def remove_row(self): + def _add_row(self, row): """ - Remove the currently selected row (or rows) from the table. + Append a new row to the end of the DataFrame given a list of 2-tuples + of (column name, column value). This method will work for DataFrames + with arbitrary index types. """ - if self._multi_index: - msg = "Cannot remove a row from a table with a multi index" + df = self._df + + col_names, col_data = zip(*row) + col_names = list(col_names) + col_data = list(col_data) + index_col_val = dict(row)[df.index.name] + + # check that the given column names match what + # already exists in the dataframe + required_cols = set(df.columns.values).union({df.index.name}) - \ + {self._index_col_name} + if set(col_names) != required_cols: + msg = "Cannot add row -- column names don't match in "\ + "the existing dataframe" self.send({ 'type': 'show_error', 'error_msg': msg, - 'triggered_by': 'remove_row' + 'triggered_by': 'add_row' }) return - selected_names = \ - list(map(lambda x: self._df.iloc[x].name, self._selected_rows)) + + for i, s in enumerate(col_data): + if col_names[i] == df.index.name: + continue + + df.loc[index_col_val, col_names[i]] = s + self._unfiltered_df.loc[index_col_val, col_names[i]] = s + + self._update_table(triggered_by='add_row', + scroll_to_row=df.index.get_loc(index_col_val), + fire_data_change_event=True) + + return index_col_val + + def edit_cell(self, index, column, value): + old_value = self._df.loc[index, column] + self._df.loc[index, column] = value + self._unfiltered_df.loc[index, column] = value + self._update_table(triggered_by='edit_cell', + fire_data_change_event=True) + + self._notify_listeners({ + 'name': 'cell_edited', + 'index': index, + 'column': column, + 'old': old_value, + 'new': value, + 'source': 'api' + }) + + def remove_rows(self, rows=None): + """ + Remove a row (or rows) from the DataFrame. The indices of the + rows to remove can be provided via the optional ``rows`` argument. + If the ``rows`` argument is not provided, the row (or rows) that are + currently selected in the UI will be removed. + + Parameters + ---------- + rows : list (default: None) + A list of indices of the rows to remove from the DataFrame. For + a multi-indexed DataFrame, each index in the list should be a + tuple, with each value in each tuple corresponding to a level of + the MultiIndex. + + See Also + -------- + QgridWidget.add_row: + The method for adding a row. + QgridWidget.remove_row: + Alias for this method. + """ + row_indices = self._remove_rows(rows=rows) + self._notify_listeners({ + 'name': 'row_removed', + 'indices': row_indices, + 'source': 'api' + }) + return row_indices + + def remove_row(self, rows=None): + """ + Alias for ``remove_rows``, which is provided for convenience + because this was the previous name of that method. + """ + return self.remove_rows(rows) + + def _remove_rows(self, rows=None): + if rows is not None: + selected_names = rows + else: + selected_names = \ + list(map(lambda x: self._df.iloc[x].name, self._selected_rows)) + self._df.drop(selected_names, inplace=True) self._unfiltered_df.drop(selected_names, inplace=True) self._selected_rows = [] self._update_table(triggered_by='remove_row') return selected_names + def change_selection(self, rows=[]): + """ + Select a row (or rows) in the UI. The indices of the + rows to select are provided via the optional ``rows`` argument. + + Parameters + ---------- + rows : list (default: []) + A list of indices of the rows to select. For a multi-indexed + DataFrame, each index in the list should be a tuple, with each + value in each tuple corresponding to a level of the MultiIndex. + The default value of ``[]`` results in the no rows being + selected (i.e. it clears the selection). + """ + new_selection = \ + list(map(lambda x: self._df.index.get_loc(x), rows)) + + self._change_selection(new_selection, 'api', send_msg_to_js=True) + + def _change_selection(self, rows, source, send_msg_to_js=False): + old_selection = self._selected_rows + self._selected_rows = rows + + # if the selection didn't change, just return without firing + # the event + if old_selection == self._selected_rows: + return + + if send_msg_to_js: + data_to_send = { + 'type': 'change_selection', + 'rows': rows + } + self.send(data_to_send) + + self._notify_listeners({ + 'name': 'selection_changed', + 'old': old_selection, + 'new': self._selected_rows, + 'source': source + }) # Alias for legacy support, since we changed the capitalization QGridWidget = QgridWidget diff --git a/qgrid/tests/test_grid.py b/qgrid/tests/test_grid.py index ca0f8f3e..d244796e 100644 --- a/qgrid/tests/test_grid.py +++ b/qgrid/tests/test_grid.py @@ -21,8 +21,8 @@ def create_df(): }) -def create_large_df(): - large_df = pd.DataFrame(np.random.randn(10000, 4), columns=list('ABCD')) +def create_large_df(size=10000): + large_df = pd.DataFrame(np.random.randn(size, 4), columns=list('ABCD')) large_df['B (as str)'] = large_df['B'].map(lambda x: str(x)) return large_df @@ -105,7 +105,7 @@ def check_edit_success(widget, widget._handle_qgrid_msg_helper({ 'column': col_name, 'row_index': row_index, - 'type': "cell_change", + 'type': 'edit_cell', 'unfiltered_index': row_index, 'value': new_val_json }) @@ -116,7 +116,8 @@ def check_edit_success(widget, 'index': expected_index_val, 'column': col_name, 'old': old_val_obj, - 'new': new_val_obj + 'new': new_val_obj, + 'source': 'gui' }] assert widget._df[col_name][row_index] == new_val_obj @@ -135,7 +136,7 @@ def test_edit_number(): old_val = idx -def test_add_row(): +def test_add_row_button(): widget = QgridWidget(df=create_df()) event_history = init_event_history('row_added', widget=widget) @@ -145,7 +146,8 @@ def test_add_row(): assert event_history == [{ 'name': 'row_added', - 'index': 4 + 'index': 4, + 'source': 'gui' }] # make sure the added row in the internal dataframe contains the @@ -158,7 +160,7 @@ def test_add_row(): assert (widget._df.loc[added_index].values == expected_values).all() -def test_remove_row(): +def test_remove_row_button(): widget = QgridWidget(df=create_df()) event_history = init_event_history(['row_removed', 'selection_changed'], widget=widget) @@ -166,7 +168,7 @@ def test_remove_row(): selected_rows = [1, 2] widget._handle_qgrid_msg_helper({ 'rows': selected_rows, - 'type': "selection_changed" + 'type': "change_selection" }) widget._handle_qgrid_msg_helper({ @@ -177,11 +179,13 @@ def test_remove_row(): { 'name': 'selection_changed', 'old': [], - 'new': selected_rows + 'new': selected_rows, + 'source': 'gui' }, { 'name': 'row_removed', - 'indices': selected_rows + 'indices': selected_rows, + 'source': 'gui' } ] @@ -191,12 +195,12 @@ def test_mixed_type_column(): df = df.set_index(pd.Index(['yz', 7, 3.2])) view = QgridWidget(df=df) view._handle_qgrid_msg_helper({ - 'type': 'sort_changed', + 'type': 'change_sort', 'sort_field': 'A', 'sort_ascending': True }) view._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 'A', 'search_val': None }) @@ -208,38 +212,62 @@ def test_nans(): ('foo', 'bar')]) view = QgridWidget(df=df) view._handle_qgrid_msg_helper({ - 'type': 'sort_changed', + 'type': 'change_sort', 'sort_field': 1, 'sort_ascending': True }) view._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 1, 'search_val': None }) +def test_row_edit_callback(): + sample_df = create_df() + + def can_edit_row(row): + return row['E'] == 'train' and row['F'] == 'bar' + + view = QgridWidget(df=sample_df, row_edit_callback=can_edit_row) + + view._handle_qgrid_msg_helper({ + 'type': 'change_sort', + 'sort_field': 'index', + 'sort_ascending': True + }) + + expected_dict = { + 0: False, + 1: True, + 2: False, + 3: False + } + + assert expected_dict == view._editable_rows + + def test_period_object_column(): range_index = pd.period_range(start='2000', periods=10, freq='B') df = pd.DataFrame({'a': 5, 'b': range_index}, index=range_index) view = QgridWidget(df=df) view._handle_qgrid_msg_helper({ - 'type': 'sort_changed', + 'type': 'change_sort', 'sort_field': 'index', 'sort_ascending': True }) view._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 'index', 'search_val': None }) view._handle_qgrid_msg_helper({ - 'type': 'sort_changed', + 'type': 'change_sort', 'sort_field': 'b', 'sort_ascending': True }) view._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 'b', 'search_val': None }) @@ -251,7 +279,7 @@ def test_get_selected_df(): view = QgridWidget(df=sample_df) view._handle_qgrid_msg_helper({ 'rows': selected_rows, - 'type': "selection_changed" + 'type': "change_selection" }) selected_df = view.get_selected_df() assert len(selected_df) == 2 @@ -268,7 +296,7 @@ def test_integer_index_filter(): 'min': 2, 'type': "slider" }, - 'type': "filter_changed" + 'type': "change_filter" }) filtered_df = view.get_changed_df() assert len(filtered_df) == 2 @@ -277,7 +305,7 @@ def test_integer_index_filter(): def test_series_of_text_filters(): view = QgridWidget(df=create_df()) view._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 'E', 'search_val': None }) @@ -289,7 +317,7 @@ def test_series_of_text_filters(): 'type': "text", 'excluded': [] }, - 'type': "filter_changed" + 'type': "change_filter" }) filtered_df = view.get_changed_df() assert len(filtered_df) == 2 @@ -303,12 +331,12 @@ def test_series_of_text_filters(): 'type': "text", 'excluded': [] }, - 'type': "filter_changed" + 'type': "change_filter" }) # ...and apply a text filter on a different column view._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 'F', 'search_val': None }) @@ -320,7 +348,7 @@ def test_series_of_text_filters(): 'type': "text", 'excluded': [] }, - 'type': "filter_changed" + 'type': "change_filter" }) filtered_df = view.get_changed_df() assert len(filtered_df) == 2 @@ -331,7 +359,7 @@ def test_date_index(): df.set_index('Date', inplace=True) view = QgridWidget(df=df) view._handle_qgrid_msg_helper({ - 'type': 'filter_changed', + 'type': 'change_filter', 'field': 'A', 'filter_info': { 'field': 'A', @@ -349,19 +377,19 @@ def test_multi_index(): 'sort_changed'], widget=widget) widget._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 'level_0', 'search_val': None }) widget._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 3, 'search_val': None }) widget._handle_qgrid_msg_helper({ - 'type': 'filter_changed', + 'type': 'change_filter', 'field': 3, 'filter_info': { 'field': 3, @@ -372,7 +400,7 @@ def test_multi_index(): }) widget._handle_qgrid_msg_helper({ - 'type': 'filter_changed', + 'type': 'change_filter', 'field': 3, 'filter_info': { 'field': 3, @@ -383,7 +411,7 @@ def test_multi_index(): }) widget._handle_qgrid_msg_helper({ - 'type': 'filter_changed', + 'type': 'change_filter', 'field': 'level_1', 'filter_info': { 'field': 'level_1', @@ -394,13 +422,13 @@ def test_multi_index(): }) widget._handle_qgrid_msg_helper({ - 'type': 'sort_changed', + 'type': 'change_sort', 'sort_field': 3, 'sort_ascending': True }) widget._handle_qgrid_msg_helper({ - 'type': 'sort_changed', + 'type': 'change_sort', 'sort_field': 'level_0', 'sort_ascending': True }) @@ -514,7 +542,7 @@ def test_object_dtype(): grid_data = json.loads(widget._df_json)['data'] widget._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 'a', 'search_val': None }) @@ -526,7 +554,7 @@ def test_object_dtype(): 'type': "text", 'excluded': [] }, - 'type': "filter_changed" + 'type': "change_filter" }) filter_table = widget._filter_tables['a'] @@ -565,7 +593,7 @@ def test_object_dtype_categorical(): assert not isinstance(constraints_enum[1], dict) widget._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 0, 'search_val': None }) @@ -577,18 +605,18 @@ def test_object_dtype_categorical(): 'type': "text", 'excluded': [] }, - 'type': "filter_changed" + 'type': "change_filter" }) assert len(widget._df) == 1 assert widget._df[0][0] == cat_series[0] -def test_viewport_changed(): +def test_change_viewport(): widget = QgridWidget(df=create_large_df()) event_history = init_event_history(All) widget._handle_qgrid_msg_helper({ - 'type': 'viewport_changed', + 'type': 'change_viewport', 'top': 7124, 'bottom': 7136 }) @@ -596,7 +624,7 @@ def test_viewport_changed(): assert event_history == [ { 'name': 'json_updated', - 'triggered_by': 'viewport_changed', + 'triggered_by': 'change_viewport', 'range': (7024, 7224) }, { @@ -607,25 +635,25 @@ def test_viewport_changed(): ] -def test_viewport_changed_filter(): +def test_change_filter_viewport(): widget = QgridWidget(df=create_large_df()) event_history = init_event_history(All) widget._handle_qgrid_msg_helper({ - 'type': 'get_column_min_max', + 'type': 'show_filter_dropdown', 'field': 'B (as str)', 'search_val': None }) widget._handle_qgrid_msg_helper({ - 'type': 'viewport_changed_filter', + 'type': 'change_filter_viewport', 'field': 'B (as str)', 'top': 556, 'bottom': 568 }) widget._handle_qgrid_msg_helper({ - 'type': 'viewport_changed_filter', + 'type': 'change_filter_viewport', 'field': 'B (as str)', 'top': 302, 'bottom': 314 @@ -651,30 +679,52 @@ def test_viewport_changed_filter(): ] -def test_selection_changed(): - widget = QgridWidget(df=create_df()) +def test_change_selection(): + widget = QgridWidget(df=create_large_df(size=10)) event_history = init_event_history('selection_changed', widget=widget) widget._handle_qgrid_msg_helper({ - 'type': 'selection_changed', + 'type': 'change_selection', 'rows': [5] }) + assert widget._selected_rows == [5] widget._handle_qgrid_msg_helper({ - 'type': 'selection_changed', + 'type': 'change_selection', 'rows': [7, 8] }) + assert widget._selected_rows == [7, 8] + + widget.change_selection([3, 5, 6]) + assert widget._selected_rows == [3, 5, 6] + + widget.change_selection() + assert widget._selected_rows == [] assert event_history == [ { 'name': 'selection_changed', 'old': [], - 'new': [5] + 'new': [5], + 'source': 'gui' }, { 'name': 'selection_changed', 'old': [5], - 'new': [7, 8] + 'new': [7, 8], + 'source': 'gui' + }, + { + 'name': 'selection_changed', + 'old': [7, 8], + 'new': [3, 5, 6], + 'source': 'api' + }, + { + 'name': 'selection_changed', + 'old': [3, 5, 6], + 'new': [], + 'source': 'api' }, ] @@ -689,3 +739,90 @@ def test_instance_created(): } ] assert qgrid_widget.id + + +def test_add_row(): + event_history = init_event_history(All) + df = pd.DataFrame({'foo': ['hello'], 'bar': ['world'], 'baz': [42], 'boo': [57]}) + df.set_index('baz', inplace=True, drop=True) + + q = QgridWidget(df=df) + + new_row = [ + ('baz', 43), + ('bar', "new bar"), + ('boo', 58), + ('foo', "new foo") + ] + + q.add_row(new_row) + + assert q._df.loc[43, 'foo'] == 'new foo' + assert q._df.loc[42, 'foo'] == 'hello' + + assert event_history == [ + {'name': 'instance_created'}, + { + 'name': 'json_updated', + 'range': (0, 100), + 'triggered_by': 'add_row' + }, + { + 'name': 'row_added', + 'index': 43, + 'source': 'api' + } + ] + + +def test_remove_row(): + event_history = init_event_history(All) + df = create_df() + + widget = QgridWidget(df=df) + widget.remove_row(rows=[2]) + + assert 2 not in widget._df.index + assert len(widget._df) == 3 + + assert event_history == [ + {'name': 'instance_created'}, + { + 'name': 'json_updated', + 'range': (0, 100), + 'triggered_by': 'remove_row' + }, + { + 'name': 'row_removed', + 'indices': [2], + 'source': 'api' + } + ] + + +def test_edit_cell(): + df = pd.DataFrame({'foo': ['hello'], 'bar': ['world'], 'baz': [42], 'boo': [57]}) + df.set_index('baz', inplace=True, drop=True) + + q = QgridWidget(df=df) + event_history = init_event_history(All) + + q.edit_cell(42, 'foo', 'hola') + + assert q._df.loc[42, 'foo'] == 'hola' + + assert event_history == [ + { + 'name': 'json_updated', + 'range': (0, 100), + 'triggered_by': 'edit_cell' + }, + { + 'name': 'cell_edited', + 'index': 42, + 'column': 'foo', + 'old': 'hello', + 'new': 'hola', + 'source': 'api' + } + ]