From ce04ade74fa4df824ac96b4e21299b145cc80404 Mon Sep 17 00:00:00 2001 From: "Abigail Hahn (Harvey Nash)" Date: Wed, 25 Apr 2018 17:28:05 -0700 Subject: [PATCH 01/21] Implemented the ability to configure conditions under which columns and rows are editable --- js/src/qgrid.widget.js | 69 +++++++++++++++++++++++++++++++++++++++++- qgrid/grid.py | 40 +++++++++++++++++++++--- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index a4417522..2a21169a 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -203,6 +203,8 @@ class QgridView extends widgets.DOMWidgetView { var columns = this.model.get('_columns'); this.data_view = this.create_data_view(df_json.data); this.grid_options = this.model.get('grid_options'); + this.column_definitions = this.model.get('column_definitions'); + this.row_edit_conditions = this.model.get('row_edit_conditions'); this.index_col_name = this.model.get("_index_col_name"); this.row_styles = this.model.get("_row_styles"); @@ -324,7 +326,8 @@ class QgridView extends widgets.DOMWidgetView { id: cur_column.name, sortable: false, resizable: true, - cssClass: cur_column.type + cssClass: cur_column.type, + toolTip: cur_column.toolTip }; Object.assign(slick_column, type_info); @@ -348,6 +351,7 @@ class QgridView extends widgets.DOMWidgetView { // 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 +362,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) ) { + slick_column.editor = null; + } + this.columns.push(slick_column); } @@ -486,6 +500,59 @@ class QgridView extends widgets.DOMWidgetView { }); // set up callbacks + + // evaluate conditions under which cells should be disabled -- this occurs on a per-row basis, i.e., + var evaluateRowEditConditions = function(current_row, obj) { + var result; + + for (var op in obj) { + if (op == 'AND') { + if (result == null) { + result = true; + } + var and_result = true; + for (var cond in obj[op]) { + if (cond == 'AND' || cond == 'OR' || cond == 'NOT') { + and_result = and_result && evaluateRowEditConditions(current_row, {[cond]: obj[op][cond]}); + } else { + and_result = and_result && (current_row[cond] == obj[op][cond]); + } + } + result = result && and_result; + } else if (op == 'OR') { + if (result == null) { + result = false; + } + var or_result = false; + for (var cond in obj[op]) { + if (cond == 'AND' || cond == 'OR' || cond == 'NOT') { + or_result = or_result || evaluateRowEditConditions(current_row, {[cond]: obj[op][cond]}); + } else { + or_result = or_result || (current_row[cond] == obj[op][cond]); + } + } + result = result || or_result; + + } else if (op == 'NOT') { + if (result == null) { + result = true; + } + result = result && !evaluateRowEditConditions(current_row, {'AND': obj[op]}); + } else { + alert("Unsupported operation '" + op + "' found in cell edit conditions!") + } + } + return result; + } + + if ( ! (this.row_edit_conditions == null)) { + var conditions = this.row_edit_conditions; + var grid = this.slick_grid; + this.slick_grid.onBeforeEditCell.subscribe(function(e, args) { + return evaluateRowEditConditions(grid.getDataItem(args.row), conditions); + }); + } + this.slick_grid.onCellChange.subscribe((e, args) => { var column = this.columns[args.cell].name; var data_item = this.slick_grid.getDataItem(args.row); diff --git a/qgrid/grid.py b/qgrid/grid.py index e3e8f360..73f11ccc 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -51,7 +51,12 @@ def __init__(self): 'sortable': True, 'filterable': True, 'highlightSelectedCell': False, - 'highlightSelectedRow': True + 'highlightSelectedRow': True, + 'boldIndex': True + } + self._column_options = { + 'editable': True, + 'toolTip': "", } self._show_toolbar = False self._precision = None # Defer to pandas.get_option @@ -60,13 +65,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 +87,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 +124,7 @@ 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 +148,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): @@ -297,7 +309,9 @@ def disable(): def show_grid(data_frame, show_toolbar=None, - precision=None, grid_options=None): + precision=None, grid_options=None, + column_options=None, column_definitions=None, + row_edit_conditions=None): """ Renders a DataFrame or Series as an interactive qgrid, represented by an instance of the ``QgridWidget`` class. The ``QgridWidget`` instance @@ -327,6 +341,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: @@ -349,6 +369,9 @@ def show_grid(data_frame, show_toolbar=None, # 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_conditions=row_edit_conditions, show_toolbar=show_toolbar) @@ -506,6 +529,9 @@ class QgridWidget(widgets.DOMWidget): df = Instance(pd.DataFrame) precision = Integer(6, sync=True) grid_options = Dict(sync=True) + column_options = Dict(sync=True) + column_definitions = Dict({}) + row_edit_conditions = Dict(sync=True) show_toolbar = Bool(False, sync=True) id = Unicode(sync=True) @@ -891,6 +917,10 @@ def should_be_stringified(col_series): cur_column['position'] = i 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 From 1954299deb41797ec3dbbeafe433f327ba2af060 Mon Sep 17 00:00:00 2001 From: "Abigail Hahn (Harvey Nash)" Date: Wed, 25 Apr 2018 18:33:06 -0700 Subject: [PATCH 02/21] Implemented the ability to add a row programmatically, with support for non-integer indexes; improved the row edit condition evaluation --- js/src/qgrid.widget.js | 27 +++++++++++++++------------ qgrid/grid.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index 2a21169a..b3c6610f 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -501,7 +501,7 @@ class QgridView extends widgets.DOMWidgetView { // set up callbacks - // evaluate conditions under which cells should be disabled -- this occurs on a per-row basis, i.e., + // evaluate conditions under which cells in a row should be disabled (contingent on values of other cells in the same row) var evaluateRowEditConditions = function(current_row, obj) { var result; @@ -510,36 +510,39 @@ class QgridView extends widgets.DOMWidgetView { if (result == null) { result = true; } - var and_result = true; + //var and_result = true; for (var cond in obj[op]) { if (cond == 'AND' || cond == 'OR' || cond == 'NOT') { - and_result = and_result && evaluateRowEditConditions(current_row, {[cond]: obj[op][cond]}); + result = result && evaluateRowEditConditions(current_row, {[cond]: obj[op][cond]}); } else { - and_result = and_result && (current_row[cond] == obj[op][cond]); + result = result && (current_row[cond] == obj[op][cond]); } } - result = result && and_result; } else if (op == 'OR') { if (result == null) { result = false; } var or_result = false; for (var cond in obj[op]) { - if (cond == 'AND' || cond == 'OR' || cond == 'NOT') { - or_result = or_result || evaluateRowEditConditions(current_row, {[cond]: obj[op][cond]}); + if (cond == 'AND' || cond == 'OR' || cond == 'NAND' || cond == 'NOR') { + result = result || evaluateRowEditConditions(current_row, {[cond]: obj[op][cond]}); } else { - or_result = or_result || (current_row[cond] == obj[op][cond]); + result = result || (current_row[cond] == obj[op][cond]); } } - result = result || or_result; - - } else if (op == 'NOT') { + } else if (op == 'NAND') { if (result == null) { result = true; } result = result && !evaluateRowEditConditions(current_row, {'AND': obj[op]}); + } else if (op == 'NOR') { + if (result == null) { + result = true; + } + result = result && !evaluateRowEditConditions(current_row, {'OR': obj[op]}); + } else { - alert("Unsupported operation '" + op + "' found in cell edit conditions!") + alert("Unsupported operation '" + op + "' found in row edit conditions!") } } return result; diff --git a/qgrid/grid.py b/qgrid/grid.py index 73f11ccc..104ad3f2 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -1527,6 +1527,39 @@ def add_row(self): scroll_to_row=df.index.get_loc(last.name)) return last.name + def add_row_internally(self, row): + """ + Append a new row to the end of the dataframe given a list of 2-tuples of (column name, column value). + This feature will work for dataframes with arbitrary index types. + """ + 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': 'add_row' + }) + return + + 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)) + self._trigger_df_change_event() + def remove_row(self): """ Remove the currently selected row (or rows) from the table. From 5522d6cc9c28de5a6ec77f12c5606f361ac86ed7 Mon Sep 17 00:00:00 2001 From: "Abigail Hahn (Harvey Nash)" Date: Tue, 1 May 2018 13:54:06 -0700 Subject: [PATCH 03/21] Implemented a mechanism to toggle the 'editable' option for the entire grid --- js/src/qgrid.widget.js | 15 +++++++-------- qgrid/grid.py | 5 ++++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index b3c6610f..144a13c5 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -510,7 +510,6 @@ class QgridView extends widgets.DOMWidgetView { if (result == null) { result = true; } - //var and_result = true; for (var cond in obj[op]) { if (cond == 'AND' || cond == 'OR' || cond == 'NOT') { result = result && evaluateRowEditConditions(current_row, {[cond]: obj[op][cond]}); @@ -535,12 +534,6 @@ class QgridView extends widgets.DOMWidgetView { result = true; } result = result && !evaluateRowEditConditions(current_row, {'AND': obj[op]}); - } else if (op == 'NOR') { - if (result == null) { - result = true; - } - result = result && !evaluateRowEditConditions(current_row, {'OR': obj[op]}); - } else { alert("Unsupported operation '" + op + "' found in row edit conditions!") } @@ -776,7 +769,13 @@ class QgridView extends widgets.DOMWidgetView { 'rows': selected_rows, 'type': 'selection_changed' }); - }, 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.col_info) { var filter = this.filters[msg.col_info.name]; filter.handle_msg(msg); diff --git a/qgrid/grid.py b/qgrid/grid.py index 104ad3f2..12b2ff84 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -366,6 +366,9 @@ def show_grid(data_frame, show_toolbar=None, "data_frame must be DataFrame or Series, not %s" % type(data_frame) ) + row_edit_conditions = (row_edit_conditions or {}) + column_definitions = (column_definitions or {}) + # create a visualization for the dataframe return QgridWidget(df=data_frame, precision=precision, grid_options=grid_options, @@ -1557,7 +1560,7 @@ def add_row_internally(self, row): 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)) + self._update_table(triggered_by='add_row', scroll_to_row=df.index.get_loc(index_col_val), fire_data_change_event=True) self._trigger_df_change_event() def remove_row(self): From 1baabbf2de8d99c23e81e9d061c698c54e12fbe7 Mon Sep 17 00:00:00 2001 From: "Abigail Hahn (Harvey Nash)" Date: Tue, 1 May 2018 14:06:38 -0700 Subject: [PATCH 04/21] Added a method for updating a value in the grid given an index value, column name value and data value --- qgrid/grid.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qgrid/grid.py b/qgrid/grid.py index 12b2ff84..afb13cdf 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -1563,6 +1563,12 @@ def add_row_internally(self, row): self._update_table(triggered_by='add_row', scroll_to_row=df.index.get_loc(index_col_val), fire_data_change_event=True) self._trigger_df_change_event() + def set_value_internally(self, index, column, value): + self._df.loc[index, column] = value + self._unfiltered_df.loc[index, column] = value + self._update_table(triggered_by='cell_change', fire_data_change_event=True) + self._trigger_df_change_event() + def remove_row(self): """ Remove the currently selected row (or rows) from the table. From 9f0c063d579757839799095a0adff8f7fb93ef6a Mon Sep 17 00:00:00 2001 From: "Abigail Hahn (Harvey Nash)" Date: Mon, 7 May 2018 13:29:24 -0700 Subject: [PATCH 05/21] Fixed a minor bug in the row edit conditions evaluation code --- js/src/qgrid.widget.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index 144a13c5..c6815c04 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -534,6 +534,11 @@ class QgridView extends widgets.DOMWidgetView { result = true; } result = result && !evaluateRowEditConditions(current_row, {'AND': obj[op]}); + } else if (op == 'NOR') { + if (result == null) { + result = false; + } + result = result || !evaluateRowEditConditions(current_row, {'OR': obj[op]}); } else { alert("Unsupported operation '" + op + "' found in row edit conditions!") } From 981269bf7fb86eafb6378e0104f2ef30e4b72abb Mon Sep 17 00:00:00 2001 From: "Abigail Hahn (Harvey Nash)" Date: Mon, 7 May 2018 13:55:13 -0700 Subject: [PATCH 06/21] Added tests --- qgrid/tests/test_grid.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/qgrid/tests/test_grid.py b/qgrid/tests/test_grid.py index ca0f8f3e..181708b9 100644 --- a/qgrid/tests/test_grid.py +++ b/qgrid/tests/test_grid.py @@ -689,3 +689,33 @@ def test_instance_created(): } ] assert qgrid_widget.id + + +def test_add_row_internally(): + 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_internally(new_row) + + assert q._df.loc[43, 'foo'] == 'new foo' + assert q._df.loc[42, 'foo'] == 'hello' + + +def test_set_value_internally(): + df = pd.DataFrame({'foo': ['hello'], 'bar': ['world'], 'baz': [42], 'boo': [57]}) + df.set_index('baz', inplace=True, drop=True) + + q = QgridWidget(df=df) + + q.set_value_internally(42, 'foo', 'hola') + + assert q._df.loc[42, 'foo'] == 'hola' From 749c10b33b3a3fec8a34ead36bb168324b33d57d Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Wed, 16 May 2018 16:08:49 -0400 Subject: [PATCH 07/21] Use a callback function to determine whether a row should be editable or not. --- js/src/qgrid.widget.js | 59 ++++------------------------------------ qgrid/grid.py | 16 ++++++++--- qgrid/tests/test_grid.py | 24 ++++++++++++++++ 3 files changed, 42 insertions(+), 57 deletions(-) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index c6815c04..a5eb5de4 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -204,7 +204,6 @@ class QgridView extends widgets.DOMWidgetView { this.data_view = this.create_data_view(df_json.data); this.grid_options = this.model.get('grid_options'); this.column_definitions = this.model.get('column_definitions'); - this.row_edit_conditions = this.model.get('row_edit_conditions'); this.index_col_name = this.model.get("_index_col_name"); this.row_styles = this.model.get("_row_styles"); @@ -500,58 +499,12 @@ class QgridView extends widgets.DOMWidgetView { }); // set up callbacks - - // evaluate conditions under which cells in a row should be disabled (contingent on values of other cells in the same row) - var evaluateRowEditConditions = function(current_row, obj) { - var result; - - for (var op in obj) { - if (op == 'AND') { - if (result == null) { - result = true; - } - for (var cond in obj[op]) { - if (cond == 'AND' || cond == 'OR' || cond == 'NOT') { - result = result && evaluateRowEditConditions(current_row, {[cond]: obj[op][cond]}); - } else { - result = result && (current_row[cond] == obj[op][cond]); - } - } - } else if (op == 'OR') { - if (result == null) { - result = false; - } - var or_result = false; - for (var cond in obj[op]) { - if (cond == 'AND' || cond == 'OR' || cond == 'NAND' || cond == 'NOR') { - result = result || evaluateRowEditConditions(current_row, {[cond]: obj[op][cond]}); - } else { - result = result || (current_row[cond] == obj[op][cond]); - } - } - } else if (op == 'NAND') { - if (result == null) { - result = true; - } - result = result && !evaluateRowEditConditions(current_row, {'AND': obj[op]}); - } else if (op == 'NOR') { - if (result == null) { - result = false; - } - result = result || !evaluateRowEditConditions(current_row, {'OR': obj[op]}); - } else { - alert("Unsupported operation '" + op + "' found in row edit conditions!") - } - } - return result; - } - - if ( ! (this.row_edit_conditions == null)) { - var conditions = this.row_edit_conditions; - var grid = this.slick_grid; - this.slick_grid.onBeforeEditCell.subscribe(function(e, args) { - return evaluateRowEditConditions(grid.getDataItem(args.row), conditions); - }); + 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) => { diff --git a/qgrid/grid.py b/qgrid/grid.py index afb13cdf..8c35faaf 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 ( @@ -311,7 +312,7 @@ def disable(): def show_grid(data_frame, show_toolbar=None, precision=None, grid_options=None, column_options=None, column_definitions=None, - row_edit_conditions=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 @@ -366,7 +367,6 @@ def show_grid(data_frame, show_toolbar=None, "data_frame must be DataFrame or Series, not %s" % type(data_frame) ) - row_edit_conditions = (row_edit_conditions or {}) column_definitions = (column_definitions or {}) # create a visualization for the dataframe @@ -374,7 +374,7 @@ def show_grid(data_frame, show_toolbar=None, grid_options=grid_options, column_options=column_options, column_definitions=column_definitions, - row_edit_conditions=row_edit_conditions, + row_edit_callback=row_edit_callback, show_toolbar=show_toolbar) @@ -508,6 +508,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) @@ -534,7 +535,7 @@ class QgridWidget(widgets.DOMWidget): grid_options = Dict(sync=True) column_options = Dict(sync=True) column_definitions = Dict({}) - row_edit_conditions = Dict(sync=True) + row_edit_callback = Instance(FunctionType, sync=False, allow_none=True) show_toolbar = Bool(False, sync=True) id = Unicode(sync=True) @@ -960,6 +961,13 @@ 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', diff --git a/qgrid/tests/test_grid.py b/qgrid/tests/test_grid.py index 181708b9..024b173a 100644 --- a/qgrid/tests/test_grid.py +++ b/qgrid/tests/test_grid.py @@ -219,6 +219,30 @@ def test_nans(): }) +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': 'sort_changed', + '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) From be4044e88cdc364a5887de271fe05e3aea36a34b Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Wed, 16 May 2018 16:13:16 -0400 Subject: [PATCH 08/21] Compare to false so that if the editable property is null we use the default behavior of having the column be editable. --- js/src/qgrid.widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index a5eb5de4..3b1e7e85 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -370,7 +370,7 @@ class QgridView extends widgets.DOMWidgetView { continue; } - if ( ! (cur_column.editable) ) { + if (cur_column.editable == false) { slick_column.editor = null; } From 330c6f22a82fc6978871bcb5e45b6897ee7eb49f Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Mon, 2 Jul 2018 15:27:07 -0400 Subject: [PATCH 09/21] Adds support for a bunch of additional column options: defaultSortAsc, maxWidth, minWidth, and width. --- js/src/qgrid.widget.js | 27 ++++++++++++++++----------- qgrid/grid.py | 19 +++++++++++++++++-- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index 3b1e7e85..50768d1d 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -319,15 +319,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, - toolTip: cur_column.toolTip - }; + var slick_column = cur_column Object.assign(slick_column, type_info); @@ -374,6 +366,10 @@ class QgridView extends widgets.DOMWidgetView { slick_column.editor = null; } + if (cur_column.width == null){ + delete slick_column.width; + } + this.columns.push(slick_column); } @@ -444,7 +440,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) { @@ -452,11 +447,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'; diff --git a/qgrid/grid.py b/qgrid/grid.py index 8c35faaf..d2708c06 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -57,7 +57,14 @@ def __init__(self): } 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 @@ -125,7 +132,10 @@ def notify_listeners(self, event, qgrid_widget): handlers = _EventHandlers() -def set_defaults(show_toolbar=None, precision=None, grid_options=None, column_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 @@ -919,6 +929,10 @@ def should_be_stringified(col_series): 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) @@ -965,7 +979,8 @@ def should_be_stringified(col_series): 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) + editable_rows[int(row[self._index_col_name])] = \ + self.row_edit_callback(row) self._editable_rows = editable_rows if fire_data_change_event: From 1b4f03ccef4044ce2f9ae622c6b06c149f631967 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Thu, 5 Jul 2018 14:29:21 -0400 Subject: [PATCH 10/21] Rename "set_value_internal" to "edit_cell" to be consistent with the naming of the "cell_edited" event that will be fired as a result of this action. --- js/src/qgrid.widget.js | 12 ++++++------ qgrid/grid.py | 26 ++++++++++++++++++++------ qgrid/tests/test_grid.py | 26 ++++++++++++++++++++++---- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index 50768d1d..0686d012 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -203,7 +203,6 @@ class QgridView extends widgets.DOMWidgetView { var columns = this.model.get('_columns'); this.data_view = this.create_data_view(df_json.data); this.grid_options = this.model.get('grid_options'); - this.column_definitions = this.model.get('column_definitions'); this.index_col_name = this.model.get("_index_col_name"); this.row_styles = this.model.get("_row_styles"); @@ -319,7 +318,7 @@ class QgridView extends widgets.DOMWidgetView { var type_info = this.type_infos[cur_column.type] || {}; - var slick_column = cur_column + var slick_column = cur_column; Object.assign(slick_column, type_info); @@ -339,6 +338,10 @@ class QgridView extends widgets.DOMWidgetView { this.filter_list.push(cur_filter); } + if (cur_column.width == null){ + delete slick_column.width; + } + // don't allow editing index columns if (cur_column.is_index) { slick_column.editor = editors.IndexEditor; @@ -366,9 +369,6 @@ class QgridView extends widgets.DOMWidgetView { slick_column.editor = null; } - if (cur_column.width == null){ - delete slick_column.width; - } this.columns.push(slick_column); } @@ -517,7 +517,7 @@ class QgridView extends widgets.DOMWidgetView { 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); }); diff --git a/qgrid/grid.py b/qgrid/grid.py index d2708c06..b83bf2c5 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -1358,7 +1358,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']], @@ -1379,7 +1379,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): @@ -1584,13 +1585,26 @@ def add_row_internally(self, row): 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) - self._trigger_df_change_event() + self._notify_listeners({ + 'name': 'row_added', + 'index': index_col_val + }) - def set_value_internally(self, index, column, value): + 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='cell_change', fire_data_change_event=True) - self._trigger_df_change_event() + 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_row(self): """ diff --git a/qgrid/tests/test_grid.py b/qgrid/tests/test_grid.py index 024b173a..9f12adf6 100644 --- a/qgrid/tests/test_grid.py +++ b/qgrid/tests/test_grid.py @@ -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 @@ -734,12 +735,29 @@ def test_add_row_internally(): assert q._df.loc[42, 'foo'] == 'hello' -def test_set_value_internally(): +def test_edit_cell_via_api(): 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.set_value_internally(42, 'foo', 'hola') + 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' + } + ] From ca88a7efdb718d421eec4c7ac9fd25a4d2d0276d Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Fri, 6 Jul 2018 14:19:56 -0400 Subject: [PATCH 11/21] Rename the events that get sent from js to python, to be more consistent with the naming scheme of the events API. --- js/src/qgrid.filterbase.js | 4 +- js/src/qgrid.textfilter.js | 6 +-- js/src/qgrid.widget.js | 12 +++--- qgrid/grid.py | 34 ++++++++-------- qgrid/tests/test_grid.py | 80 +++++++++++++++++++------------------- 5 files changed, 68 insertions(+), 68 deletions(-) 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 0686d012..6992275d 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -476,7 +476,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 }; @@ -494,7 +494,7 @@ class QgridView extends widgets.DOMWidgetView { this.viewport_timeout = setTimeout(() => { this.last_vp = this.slick_grid.getViewport(); var msg = { - 'type': 'viewport_changed', + 'type': 'change_viewport', 'top': this.last_vp.top, 'bottom': this.last_vp.bottom }; @@ -522,7 +522,7 @@ class QgridView extends widgets.DOMWidgetView { }); this.slick_grid.onSelectedRowsChanged.subscribe((e, args) => { - var msg = {'rows': args.rows, 'type': 'selection_changed'}; + var msg = {'rows': args.rows, 'type': 'change_selection'}; this.send(msg); }); @@ -674,7 +674,7 @@ 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_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' @@ -720,7 +720,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); } @@ -730,7 +730,7 @@ class QgridView extends widgets.DOMWidgetView { }); this.send({ 'rows': selected_rows, - 'type': 'selection_changed' + 'type': 'change_selection' }); }, 100); } else if (msg.type == 'toggle_editable') { diff --git a/qgrid/grid.py b/qgrid/grid.py index b83bf2c5..a483d3da 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -638,10 +638,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. @@ -1064,7 +1064,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']: @@ -1310,7 +1310,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] @@ -1343,7 +1343,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): @@ -1394,7 +1394,7 @@ def _handle_qgrid_msg_helper(self, content): 'triggered_by': 'add_row' }) return - elif content['type'] == 'selection_changed': + elif content['type'] == 'change_selection': old_selection = self._selected_rows self._selected_rows = content['rows'] @@ -1408,10 +1408,10 @@ def _handle_qgrid_msg_helper(self, content): 'old': old_selection, 'new': self._selected_rows }) - elif content['type'] == 'viewport_changed': + elif content['type'] == 'change_viewport': 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, @@ -1430,7 +1430,7 @@ def _handle_qgrid_msg_helper(self, content): 'name': 'row_removed', 'indices': removed_indices }) - 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] @@ -1455,14 +1455,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': { @@ -1474,14 +1474,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'] diff --git a/qgrid/tests/test_grid.py b/qgrid/tests/test_grid.py index 9f12adf6..fb648a7c 100644 --- a/qgrid/tests/test_grid.py +++ b/qgrid/tests/test_grid.py @@ -105,7 +105,7 @@ def check_edit_success(widget, widget._handle_qgrid_msg_helper({ 'column': col_name, 'row_index': row_index, - 'type': "edit_cell", + 'type': 'edit_cell', 'unfiltered_index': row_index, 'value': new_val_json }) @@ -167,7 +167,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({ @@ -192,12 +192,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 }) @@ -209,12 +209,12 @@ 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 }) @@ -229,7 +229,7 @@ def can_edit_row(row): view = QgridWidget(df=sample_df, row_edit_callback=can_edit_row) view._handle_qgrid_msg_helper({ - 'type': 'sort_changed', + 'type': 'change_sort', 'sort_field': 'index', 'sort_ascending': True }) @@ -249,22 +249,22 @@ def test_period_object_column(): 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 }) @@ -276,7 +276,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 @@ -293,7 +293,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 @@ -302,7 +302,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 }) @@ -314,7 +314,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 @@ -328,12 +328,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 }) @@ -345,7 +345,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 @@ -356,7 +356,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', @@ -374,19 +374,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, @@ -397,7 +397,7 @@ def test_multi_index(): }) widget._handle_qgrid_msg_helper({ - 'type': 'filter_changed', + 'type': 'change_filter', 'field': 3, 'filter_info': { 'field': 3, @@ -408,7 +408,7 @@ def test_multi_index(): }) widget._handle_qgrid_msg_helper({ - 'type': 'filter_changed', + 'type': 'change_filter', 'field': 'level_1', 'filter_info': { 'field': 'level_1', @@ -419,13 +419,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 }) @@ -539,7 +539,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 }) @@ -551,7 +551,7 @@ def test_object_dtype(): 'type': "text", 'excluded': [] }, - 'type': "filter_changed" + 'type': "change_filter" }) filter_table = widget._filter_tables['a'] @@ -590,7 +590,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 }) @@ -602,18 +602,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 }) @@ -621,7 +621,7 @@ def test_viewport_changed(): assert event_history == [ { 'name': 'json_updated', - 'triggered_by': 'viewport_changed', + 'triggered_by': 'change_viewport', 'range': (7024, 7224) }, { @@ -632,25 +632,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 @@ -676,17 +676,17 @@ def test_viewport_changed_filter(): ] -def test_selection_changed(): +def test_change_selection(): widget = QgridWidget(df=create_df()) event_history = init_event_history('selection_changed', widget=widget) widget._handle_qgrid_msg_helper({ - 'type': 'selection_changed', + 'type': 'change_selection', 'rows': [5] }) widget._handle_qgrid_msg_helper({ - 'type': 'selection_changed', + 'type': 'change_selection', 'rows': [7, 8] }) From b9cc1539ac688fb59ff748eb4dd4b63d1fd8ee63 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Fri, 6 Jul 2018 14:21:02 -0400 Subject: [PATCH 12/21] Prevent js error in the console for the sorting icon that comes with slick grid by default. We use a font-awesome icon instead. --- js/src/qgrid.css | 5 +++++ 1 file changed, 5 insertions(+) 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; +} From 6e7fcfb99f818edae91bc7579196c29ac0afd120 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Fri, 6 Jul 2018 14:26:24 -0400 Subject: [PATCH 13/21] Fix issue where scroll event could be sent repeatedly, causing the grid to flicker and the kernel to remain in use. --- qgrid/grid.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/qgrid/grid.py b/qgrid/grid.py index a483d3da..1c798fb1 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -1411,6 +1411,11 @@ def _handle_qgrid_msg_helper(self, content): elif content['type'] == 'change_viewport': old_viewport_range = self._viewport_range self._viewport_range = (content['top'], content['bottom']) + + # if the viewport didn't change, do nothing + if old_viewport_range == self._viewport_range: + return + self._update_table(triggered_by='change_viewport') self._notify_listeners({ 'name': 'viewport_changed', From ac5f99d03397d5568293ff7004f81cbe0d556eb5 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Fri, 6 Jul 2018 14:44:02 -0400 Subject: [PATCH 14/21] Flake8 style fixes. --- qgrid/grid.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/qgrid/grid.py b/qgrid/grid.py index 1c798fb1..8c91500f 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -925,7 +925,8 @@ 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 @@ -1561,8 +1562,9 @@ def add_row(self): def add_row_internally(self, row): """ - Append a new row to the end of the dataframe given a list of 2-tuples of (column name, column value). - This feature will work for dataframes with arbitrary index types. + Append a new row to the end of the dataframe given a list of 2-tuples + of (column name, column value). This feature will work for dataframes + with arbitrary index types. """ df = self._df @@ -1571,10 +1573,13 @@ def add_row_internally(self, row): 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} + # 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" + msg = "Cannot add row -- column names don't match in "\ + "the existing dataframe" self.send({ 'type': 'show_error', 'error_msg': msg, @@ -1589,7 +1594,9 @@ def add_row_internally(self, row): 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) + self._update_table(triggered_by='add_row', + scroll_to_row=df.index.get_loc(index_col_val), + fire_data_change_event=True) self._notify_listeners({ 'name': 'row_added', 'index': index_col_val From 1dc1f1d31cb82301b52d4527e4f7c207c4b69b66 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Sun, 8 Jul 2018 11:09:45 -0400 Subject: [PATCH 15/21] Renaming, moving some things around to simplify the API a bit, improved docs for add/remove row API methods. There's now just 'add_row' and 'remove_rows' methods (previously called 'remove_row'). Specify whether an add/remove event came from a button click or an API call. --- qgrid/grid.py | 113 ++++++++++++++++++++++++++++++--------- qgrid/tests/test_grid.py | 56 ++++++++++++++++--- 2 files changed, 136 insertions(+), 33 deletions(-) diff --git a/qgrid/grid.py b/qgrid/grid.py index 8c91500f..7ab457f2 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -1425,16 +1425,18 @@ 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'] == 'change_filter_viewport': col_name = content['field'] @@ -1533,10 +1535,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 by duplicating the - last row and incrementing it's index by 1. The feature is only + 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). + """ + 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 @@ -1560,10 +1593,10 @@ def add_row(self): scroll_to_row=df.index.get_loc(last.name)) return last.name - def add_row_internally(self, row): + def _add_row(self, row): """ - Append a new row to the end of the dataframe given a list of 2-tuples - of (column name, column value). This feature will work for dataframes + 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. """ df = self._df @@ -1597,10 +1630,8 @@ def add_row_internally(self, row): self._update_table(triggered_by='add_row', scroll_to_row=df.index.get_loc(index_col_val), fire_data_change_event=True) - self._notify_listeners({ - 'name': 'row_added', - 'index': index_col_val - }) + + return index_col_val def edit_cell(self, index, column, value): old_value = self._df.loc[index, column] @@ -1618,20 +1649,50 @@ def edit_cell(self, index, column, value): 'source': 'api' }) - def remove_row(self): + def remove_rows(self, rows=None): """ - Remove the currently selected row (or rows) from the table. + 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. """ - if self._multi_index: - msg = "Cannot remove a row from a table with a multi index" - self.send({ - 'type': 'show_error', - 'error_msg': msg, - 'triggered_by': 'remove_row' - }) - return - selected_names = \ - list(map(lambda x: self._df.iloc[x].name, self._selected_rows)) + 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 = [] diff --git a/qgrid/tests/test_grid.py b/qgrid/tests/test_grid.py index fb648a7c..dfc3237b 100644 --- a/qgrid/tests/test_grid.py +++ b/qgrid/tests/test_grid.py @@ -136,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) @@ -146,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 @@ -159,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) @@ -182,7 +183,8 @@ def test_remove_row(): }, { 'name': 'row_removed', - 'indices': selected_rows + 'indices': selected_rows, + 'source': 'gui' } ] @@ -716,7 +718,8 @@ def test_instance_created(): assert qgrid_widget.id -def test_add_row_internally(): +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) @@ -729,13 +732,52 @@ def test_add_row_internally(): ('foo', "new foo") ] - q.add_row_internally(new_row) + 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_via_api(): +def test_edit_cell(): df = pd.DataFrame({'foo': ['hello'], 'bar': ['world'], 'baz': [42], 'boo': [57]}) df.set_index('baz', inplace=True, drop=True) From b930715d6757ba0baf8ce991baf43e7ca4d5d334 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Sun, 8 Jul 2018 17:33:53 -0400 Subject: [PATCH 16/21] Adding a 'change_selection' method which allows changed the selected row (or rows) in the grid. --- js/src/qgrid.widget.js | 18 +++++++++++-- qgrid/grid.py | 55 ++++++++++++++++++++++++++++++---------- qgrid/tests/test_grid.py | 35 ++++++++++++++++++++----- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index 6992275d..9f353bf8 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -215,6 +215,7 @@ class QgridView extends widgets.DOMWidgetView { this.sort_in_progress = false; this.sort_indicator = null; this.resizing_column = false; + this.ignore_selection_changed = false; var number_type_info = { filter: slider_filter.SliderFilter, @@ -342,6 +343,10 @@ class QgridView extends widgets.DOMWidgetView { 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; @@ -522,8 +527,10 @@ class QgridView extends widgets.DOMWidgetView { }); this.slick_grid.onSelectedRowsChanged.subscribe((e, args) => { - var msg = {'rows': args.rows, 'type': 'change_selection'}; - this.send(msg); + if (!this.ignore_selection_changed) { + var msg = {'rows': args.rows, 'type': 'change_selection'}; + this.send(msg); + } }); setTimeout(() => { @@ -739,6 +746,13 @@ class QgridView extends widgets.DOMWidgetView { } 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/grid.py b/qgrid/grid.py index 7ab457f2..63d83b27 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -1396,19 +1396,7 @@ def _handle_qgrid_msg_helper(self, content): }) return elif content['type'] == 'change_selection': - old_selection = self._selected_rows - self._selected_rows = content['rows'] - - # if the selection didn't change, just return without firing - # the event - if old_selection == self._selected_rows: - return - - self._notify_listeners({ - 'name': 'selection_changed', - 'old': old_selection, - 'new': self._selected_rows - }) + self._change_selection(content['rows'], 'gui') elif content['type'] == 'change_viewport': old_viewport_range = self._viewport_range self._viewport_range = (content['top'], content['bottom']) @@ -1699,6 +1687,47 @@ def _remove_rows(self, rows=None): 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 dfc3237b..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 @@ -179,7 +179,8 @@ def test_remove_row_button(): { 'name': 'selection_changed', 'old': [], - 'new': selected_rows + 'new': selected_rows, + 'source': 'gui' }, { 'name': 'row_removed', @@ -679,29 +680,51 @@ def test_change_filter_viewport(): def test_change_selection(): - widget = QgridWidget(df=create_df()) + widget = QgridWidget(df=create_large_df(size=10)) event_history = init_event_history('selection_changed', widget=widget) widget._handle_qgrid_msg_helper({ 'type': 'change_selection', 'rows': [5] }) + assert widget._selected_rows == [5] widget._handle_qgrid_msg_helper({ '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' }, ] From 870534acf05479e7be456e969d6d999544c4adee Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Sun, 8 Jul 2018 23:12:11 -0400 Subject: [PATCH 17/21] Updating API docs to describe the new parameters for 'show_grid', etc --- qgrid/grid.py | 119 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 89 insertions(+), 30 deletions(-) diff --git a/qgrid/grid.py b/qgrid/grid.py index 63d83b27..ad9f13a4 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -319,9 +319,12 @@ def disable(): enable(dataframe=False, series=False) -def show_grid(data_frame, show_toolbar=None, - precision=None, grid_options=None, - column_options=None, column_definitions=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 @@ -428,6 +431,26 @@ class QgridWidget(widgets.DOMWidget): 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 ----- @@ -435,6 +458,7 @@ class QgridWidget(widgets.DOMWidget): provided explicitly:: { + # SlickGrid options 'fullWidthRows': True, 'syncColumnCellResize': True, 'forceFitColumns': True, @@ -445,6 +469,8 @@ class QgridWidget(widgets.DOMWidget): 'editable': True, 'autoEdit': False, 'explicitInitialization': True, + + # Qgrid options 'maxVisibleRows': 15, 'minVisibleRows': 8, 'sortable': True, @@ -453,37 +479,59 @@ class QgridWidget(widgets.DOMWidget): 'highlightSelectedRow': True } - 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 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 the QgridWidget constructor, with the exception of - the ``df`` parameter. + the ``df`` and the ``column_definitions`` parameter. set_grid_option : Permanently set global defaults for individual - SlickGrid options. Does so by changing the default + grid options. Does so by changing the default for the ``grid_options`` parameter of the QgridWidget constructor. @@ -496,11 +544,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. """ @@ -543,7 +596,7 @@ class QgridWidget(widgets.DOMWidget): df = Instance(pd.DataFrame) precision = Integer(6, sync=True) grid_options = Dict(sync=True) - column_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) @@ -649,12 +702,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. @@ -663,6 +720,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. From b377885fa0e1efc57e479e8f8c5d37b894c41972 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Mon, 9 Jul 2018 09:55:33 -0400 Subject: [PATCH 18/21] Fix for issue where lots of subsequent scroll events would result in a backlog of events that had to be processed, causing scrolling to appear to be really slow. This was especially apparent in hosted environments like Q where latency is more of an issue. --- js/src/qgrid.widget.js | 36 ++++++++++++++++++++++++++++-------- qgrid/grid.py | 5 ++++- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index 9f353bf8..01cfbcd3 100644 --- a/js/src/qgrid.widget.js +++ b/js/src/qgrid.widget.js @@ -216,6 +216,8 @@ class QgridView extends widgets.DOMWidgetView { 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, @@ -374,7 +376,6 @@ class QgridView extends widgets.DOMWidgetView { slick_column.editor = null; } - this.columns.push(slick_column); } @@ -498,14 +499,23 @@ class QgridView extends widgets.DOMWidgetView { } this.viewport_timeout = setTimeout(() => { this.last_vp = this.slick_grid.getViewport(); - var msg = { - 'type': 'change_viewport', - '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 @@ -681,6 +691,16 @@ 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 === '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( diff --git a/qgrid/grid.py b/qgrid/grid.py index ad9f13a4..5f728a50 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -586,7 +586,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) From 9beed9c70627aa84c8efa1176882151b430f7829 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Mon, 9 Jul 2018 14:35:30 -0400 Subject: [PATCH 19/21] Fix for issue where editing a categorical fails when selecting an item with single quotes, issue #209. --- js/src/qgrid.editors.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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(); } From 588bb0c26c08ad53fc9425e6f48c8ec39befa902 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Mon, 9 Jul 2018 15:05:57 -0400 Subject: [PATCH 20/21] Bump to 1.1.0 beta 0. --- js/package.json | 2 +- js/src/qgrid.widget.js | 4 ++-- qgrid/_version.py | 2 +- qgrid/grid.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) 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.widget.js b/js/src/qgrid.widget.js index 01cfbcd3..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: {} }); 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 5f728a50..b367aa58 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -561,8 +561,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) From bddad218d6075b0118c2a46d34113df87bb361f5 Mon Sep 17 00:00:00 2001 From: Tim Shawver Date: Mon, 9 Jul 2018 23:58:45 -0400 Subject: [PATCH 21/21] Update docs to make it clearer that 'show_grid' is the preferred way of constructing a QgridWidget instance. --- qgrid/grid.py | 163 ++++++++++++++++++++++++++------------------------ 1 file changed, 84 insertions(+), 79 deletions(-) diff --git a/qgrid/grid.py b/qgrid/grid.py index b367aa58..c567311f 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -338,84 +338,11 @@ def show_grid(data_frame, 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 - See Also - -------- - QgridWidget : The widget class that is instantiated and returned by this - function. - """ - - if show_toolbar is None: - show_toolbar = defaults.show_toolbar - if precision is 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: - options = defaults.grid_options.copy() - options.update(grid_options) - grid_options = options - if not isinstance(grid_options, dict): - raise TypeError( - "grid_options must be dict, not %s" % type(grid_options) - ) - - # if a Series is passed in, convert it to a DataFrame - if isinstance(data_frame, pd.Series): - data_frame = pd.DataFrame(data_frame) - elif not isinstance(data_frame, pd.DataFrame): - raise TypeError( - "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) - - -PAGE_SIZE = 100 - - -def stringify(x): - if isinstance(x, string_types): - return x - else: - return str(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 + data_frame : DataFrame The DataFrame that will be displayed by this instance of QgridWidget. grid_options : dict @@ -528,12 +455,90 @@ class QgridWidget(widgets.DOMWidget): See Also -------- set_defaults : Permanently set global defaults for the parameters - of the QgridWidget constructor, with the exception of - the ``df`` and the ``column_definitions`` parameter. + 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 default - for the ``grid_options`` parameter of the QgridWidget - constructor. + 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 + method. + + """ + + if show_toolbar is None: + show_toolbar = defaults.show_toolbar + if precision is 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: + options = defaults.grid_options.copy() + options.update(grid_options) + grid_options = options + if not isinstance(grid_options, dict): + raise TypeError( + "grid_options must be dict, not %s" % type(grid_options) + ) + + # if a Series is passed in, convert it to a DataFrame + if isinstance(data_frame, pd.Series): + data_frame = pd.DataFrame(data_frame) + elif not isinstance(data_frame, pd.DataFrame): + raise TypeError( + "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) + + +PAGE_SIZE = 100 + + +def stringify(x): + if isinstance(x, string_types): + return x + else: + return str(x) + + +@widgets.register() +class QgridWidget(widgets.DOMWidget): + """ + 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). + + 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 + -------- + show_grid : The method that should be used to construct QgridWidget + instances, because it provides reasonable defaults for all + of the qgrid options. Attributes ----------