diff --git a/js/package.json b/js/package.json index 8f174eb3..2bac0205 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "qgrid", - "version": "1.0.0", + "version": "1.0.1", "description": "An Interactive Grid for Sorting and Filtering DataFrames in Jupyter Notebook", "author": "Quantopian Inc.", "main": "src/index.js", diff --git a/js/src/index.js b/js/src/index.js index 874bc497..65a87ddf 100644 --- a/js/src/index.js +++ b/js/src/index.js @@ -1,12 +1,4 @@ // Entry point for the notebook bundle containing custom model definitions. -// -// Setup notebook base URL -// -// Some static assets may be required by the custom widget javascript. The base -// url for the notebook is not known at build time and is therefore computed -// dynamically. -__webpack_public_path__ = document.querySelector('body').getAttribute('data-base-url') + 'nbextensions/qgrid/'; - // Export widget models and views, and the npm package version number. module.exports = require('./qgrid.widget.js'); module.exports.version = require('../package.json').version; diff --git a/js/src/qgrid.booleanfilter.js b/js/src/qgrid.booleanfilter.js index 04a61a68..574306d2 100644 --- a/js/src/qgrid.booleanfilter.js +++ b/js/src/qgrid.booleanfilter.js @@ -79,6 +79,7 @@ class BooleanFilter extends filter_base.FilterBase { reset_filter() { this.radio_buttons.prop('checked', false); this.selected = null; + this.send_filter_changed(); } get_filter_info() { diff --git a/js/src/qgrid.css b/js/src/qgrid.css index 088a52fb..b7234d36 100644 --- a/js/src/qgrid.css +++ b/js/src/qgrid.css @@ -284,8 +284,8 @@ } .q-grid .slick-cell { - border-bottom: 1px solid #e1e8ed !important; - border-right: none !important; + border-bottom: 1px solid #e1e8ed; + border-right: none; border-top: 1px solid transparent; border-left: 1px solid transparent; font-size: 13px; @@ -294,6 +294,32 @@ padding-left: 0px; } +.q-grid.highlight-selected-row .slick-cell.selected { + background-color: #deeaf7; +} + +.q-grid.highlight-selected-cell .slick-cell.active { + border: 2px solid rgb(65, 165, 245); + padding-top: 2px; + padding-bottom: 1px; + padding-left: 3px; +} + +.q-grid .slick-cell.editable:not(.idx-col) { + border: 2px solid rgb(65, 165, 245); + padding-top: 2px; + padding-bottom: 1px; + padding-left: 3px !important; + background-color: #FFF; + -webkit-box-shadow: 0 2px 5px rgba(0,0,0,0.4); + -moz-box-shadow: 0 2px 5px rgba(0,0,0,0.4); + box-shadow: 0 2px 5px rgba(0,0,0,0.4); +} + +.q-grid .slick-cell.editable .editor-select:focus { + outline-style: none; +} + .q-grid .slick-cell.idx-col { font-weight: bold; } @@ -304,7 +330,7 @@ } .q-grid .slick-cell.selected { - background-color: #deeaf7; + background-color: transparent; } /* Filter button */ @@ -605,18 +631,6 @@ input.bool-filter-radio { margin-left: -23px; } -.slick-cell.editable:not(.idx-col) { - background-color: rgb(255, 247, 141) !important; - border: 1px solid rgba(0, 0, 0, 0.26) !important; - margin-top: -1px; - padding-top: 4px; - padding-bottom: 2px; -} - -.slick-cell.editable:not(:last-child):not(.idx-col) { - margin-right: 3px; -} - .slick-cell { -webkit-touch-callout: none; /* iOS Safari */ -webkit-user-select: none; /* Safari */ @@ -627,6 +641,5 @@ input.bool-filter-radio { } .slick-row .slick-cell:not(:first-child) { - margin-left: -4px; padding-left: 4px; } diff --git a/js/src/qgrid.datefilter.js b/js/src/qgrid.datefilter.js index d4ee941c..dc49b780 100644 --- a/js/src/qgrid.datefilter.js +++ b/js/src/qgrid.datefilter.js @@ -51,6 +51,7 @@ class DateFilter extends filter_base.FilterBase { this.filter_start_date = null; this.filter_end_date = null; + this.send_filter_changed(); } initialize_controls() { diff --git a/js/src/qgrid.editors.js b/js/src/qgrid.editors.js index 89ba4a98..502efeff 100644 --- a/js/src/qgrid.editors.js +++ b/js/src/qgrid.editors.js @@ -16,11 +16,11 @@ class IndexEditor { this.$cell.tooltip(); this.$cell.tooltip('enable'); this.$cell.tooltip("open"); - this.$cell.off('tooltipclose'); - this.$cell.on("tooltipclose", (event, ui) => { + // automatically hide it after 4 seconds + setTimeout((event, ui) => { this.$cell.tooltip('destroy'); args.cancelChanges(); - }); + }, 3000); } destroy() {} diff --git a/js/src/qgrid.filterbase.js b/js/src/qgrid.filterbase.js index 997faa19..47b41402 100644 --- a/js/src/qgrid.filterbase.js +++ b/js/src/qgrid.filterbase.js @@ -152,10 +152,10 @@ class FilterBase { initialize_controls() { this.filter_elem.find("a.reset-link").click( - (e) => this.handle_reset_filter_clicked(e) + (e) => this.reset_filter() ); this.filter_elem.find("i.close-button").click( - (e) => this.handle_close_button_clicked(e) + (e) => this.hide_filter() ); $(document.body).bind("mousedown", (e) => this.handle_body_mouse_down(e) @@ -180,22 +180,6 @@ class FilterBase { this.widget_model.send(msg); } - handle_reset_filter_clicked(e) { - this.reset_filter(); - this.send_filter_changed(); - // The "false" parameter tells backtest_table_manager that we want to recalculate the min/max values for this filter - // based on the rows that are still included in the grid. This is because if this filter was already active, - // its min/max could be out-of-date because we don't adjust the min/max on active filters (to prevent confusion). - // This is currently the only filter_changed case where it's appropriate to have this filter's min/max recalculated, - // because you wouldn't want to adjust a slider's min/max while the user was moving the slider, for example. - return false; - } - - handle_close_button_clicked(e) { - this.hide_filter(); - return false; - } - handle_body_mouse_down(e) { if (this.filter_elem && this.filter_elem[0] != e.target && !$.contains(this.filter_elem[0], e.target) && !$.contains(this.filter_btn[0], e.target) && diff --git a/js/src/qgrid.sliderfilter.js b/js/src/qgrid.sliderfilter.js index 31837e93..82517489 100644 --- a/js/src/qgrid.sliderfilter.js +++ b/js/src/qgrid.sliderfilter.js @@ -100,6 +100,7 @@ class SliderFilter extends filter_base.FilterBase { }); this.set_value(this.min_value, this.max_value); } + this.send_filter_changed(); } is_active() { diff --git a/js/src/qgrid.textfilter.js b/js/src/qgrid.textfilter.js index 9069b7a3..bc66fb9f 100644 --- a/js/src/qgrid.textfilter.js +++ b/js/src/qgrid.textfilter.js @@ -355,11 +355,20 @@ class TextFilter extends filter_base.FilterBase { } reset_filter() { + this.ignore_selection_changed = true; this.search_string = ""; this.excluded_rows = null; this.security_search.val(""); this.row_selection_model.setSelectedRows([]); this.filter_list = null; + this.send_filter_changed(); + var msg = { + 'type': 'get_column_min_max', + 'field': this.field, + 'search_val': this.search_string + }; + this.widget_model.send(msg); + this.ignore_selection_changed = false; } get_filter_info() { diff --git a/js/src/qgrid.widget.js b/js/src/qgrid.widget.js index f60863dc..b2b60d81 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.0', - _view_module_version : '^1.0.0', + _model_module_version : '^1.0.1', + _view_module_version : '^1.0.1', _df_json: '', _columns: {} }); @@ -370,6 +370,16 @@ class QgridView extends widgets.DOMWidgetView { this.grid_elem.addClass('force-fit-columns'); } + if (this.grid_options.highlightSelectedCell) { + this.grid_elem.addClass('highlight-selected-cell'); + } + + // compare to false since we still want to show row + // selection if this option is excluded entirely + if (this.grid_options.highlightSelectedRow != false) { + this.grid_elem.addClass('highlight-selected-row'); + } + setTimeout(() => { this.slick_grid.init(); this.update_size(); @@ -378,12 +388,16 @@ class QgridView extends widgets.DOMWidgetView { this.slick_grid.setSelectionModel(new Slick.RowSelectionModel()); this.slick_grid.render(); - this.slick_grid.onHeaderCellRendered.subscribe((e, args) => { + var render_header_cell = (e, args) => { var cur_filter = this.filters[args.column.id]; - if (cur_filter){ - cur_filter.render_filter_button($(args.node), this.slick_grid); - } - }); + if (cur_filter) { + cur_filter.render_filter_button($(args.node), this.slick_grid); + } + }; + + if (this.grid_options.filterable != false) { + this.slick_grid.onHeaderCellRendered.subscribe(render_header_cell); + } // Force the grid to re-render the column headers so the // onHeaderCellRendered event is triggered. @@ -396,7 +410,7 @@ class QgridView extends widgets.DOMWidgetView { this.slick_grid.setSortColumns([]); this.grid_header = this.$el.find('.slick-header-columns'); - this.grid_header.click((e) => { + var handle_header_click = (e) => { if (this.resizing_column) { return; } @@ -436,7 +450,11 @@ class QgridView extends widgets.DOMWidgetView { 'sort_ascending': this.sort_ascending }; this.send(msg); - }); + }; + + if (this.grid_options.sortable != false) { + this.grid_header.click(handle_header_click) + } this.slick_grid.onViewportChanged.subscribe((e) => { if (this.viewport_timeout){ @@ -491,6 +509,18 @@ class QgridView extends widgets.DOMWidgetView { }, 1); } + processPhosphorMessage(msg) { + super.processPhosphorMessage(msg) + switch (msg.type) { + case 'resize': + case 'after-show': + if (this.slick_grid){ + this.slick_grid.resizeCanvas(); + } + break; + } + } + has_active_filter() { for (var i=0; i < this.filter_list.length; i++){ var cur_filter = this.filter_list[i]; diff --git a/qgrid/_version.py b/qgrid/_version.py index e1b56266..ef015a50 100644 --- a/qgrid/_version.py +++ b/qgrid/_version.py @@ -1,4 +1,4 @@ -version_info = (1, 0, 0, 'final') +version_info = (1, 0, 1, 'final') _specifier_ = {'alpha': 'a', 'beta': 'b', 'candidate': 'rc', 'final': ''} diff --git a/qgrid/grid.py b/qgrid/grid.py index 9bc9340a..296c1d45 100644 --- a/qgrid/grid.py +++ b/qgrid/grid.py @@ -6,6 +6,7 @@ from IPython.display import display from numbers import Integral from traitlets import Unicode, Instance, Bool, Integer, Dict, List, Tuple, Any +from traitlets.utils.bunch import Bunch # versions of pandas prior to version 0.20.0 don't support the orient='table' # when calling the 'to_json' function on DataFrames. to get around this we @@ -33,7 +34,11 @@ def __init__(self): 'autoEdit': False, 'explicitInitialization': True, 'maxVisibleRows': 15, - 'minVisibleRows': 8 + 'minVisibleRows': 8, + 'sortable': True, + 'filterable': True, + 'highlightSelectedCell': False, + 'highlightSelectedRow': True } self._show_toolbar = False self._precision = None # Defer to pandas.get_option @@ -216,6 +221,8 @@ def show_grid(data_frame, show_toolbar=None, show_toolbar=show_toolbar) +PAGE_SIZE = 100 + @widgets.register() class QgridWidget(widgets.DOMWidget): """ @@ -264,17 +271,36 @@ class QgridWidget(widgets.DOMWidget): 'autoEdit': False, 'explicitInitialization': True, 'maxVisibleRows': 15, - 'minVisibleRows': 8 + 'minVisibleRows': 8, + 'sortable': True, + 'filterable': True, + 'highlightSelectedCell': False, + 'highlightSelectedRow': True } Most of these options are SlickGrid options which are described in the `SlickGrid documentation `_. The - two exceptions are `maxVisibleRows` and `minVisibleRows`, which - are options that were added specifically for Qgrid and therefore - are not documented in the SlickGrid documentation. These options - 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. + 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. See Also -------- @@ -307,12 +333,11 @@ 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.0').tag(sync=True) - _model_module_version = Unicode('1.0.0').tag(sync=True) + _view_module_version = Unicode('1.0.1').tag(sync=True) + _model_module_version = Unicode('1.0.1').tag(sync=True) _df = Instance(pd.DataFrame) _df_json = Unicode('', sync=True) - _page_size = Integer(100, sync=True) _primary_key = List() _columns = Dict({}, sync=True) _filter_tables = Dict({}) @@ -403,8 +428,8 @@ def _update_table(self, update_columns=False, triggered_by=None, scroll_to_row=None): df = self._df.copy() - from_index = max(self._viewport_range[0] - self._page_size, 0) - to_index = max(self._viewport_range[0] + self._page_size, 0) + from_index = max(self._viewport_range[0] - PAGE_SIZE, 0) + to_index = max(self._viewport_range[0] + PAGE_SIZE, 0) new_df_range = (from_index, to_index) if triggered_by is 'viewport_changed' and \ @@ -424,7 +449,7 @@ def _update_table(self, update_columns=False, triggered_by=None, if update_columns: self._string_columns = list(df.select_dtypes( - include=[np.dtype('O')] + include=[np.dtype('O'), 'category'] ).columns.values) # call map(str) for all columns identified as string columns, in @@ -649,7 +674,7 @@ def _handle_get_column_min_max(self, content): return else: if col_info['type'] == 'any': - unique_list = col_info['constraints']['enum'] + unique_list = col_series.dtype.categories else: if col_name in self._sorted_column_cache: unique_list = self._sorted_column_cache[col_name] @@ -724,7 +749,7 @@ def get_value_from_filter_table(k): if col_info['type'] == 'any': col_info['value_range'] = (0, length) else: - max_items = self._page_size * 2 + max_items = PAGE_SIZE * 2 range_max = length if length > max_items: col_info['values'] = col_info['values'][:max_items] @@ -885,6 +910,7 @@ def _handle_qgrid_msg_helper(self, content): query = self._unfiltered_df[self._index_col_name] == \ content['unfiltered_index'] self._unfiltered_df.loc[query, content['column']] = val_to_set + self._trigger_df_change_event() except (ValueError, TypeError): msg = "Error occurred while attempting to edit the " \ "DataFrame. Check the notebook server logs for more " \ @@ -910,8 +936,8 @@ def _handle_qgrid_msg_helper(self, content): col_info = self._columns[col_name] col_filter_table = self._filter_tables[col_name] - from_index = max(content['top'] - self._page_size, 0) - to_index = max(content['top'] + self._page_size, 0) + from_index = max(content['top'] - PAGE_SIZE, 0) + to_index = max(content['top'] + PAGE_SIZE, 0) col_info['values'] = col_filter_table[from_index:to_index] col_info['value_range'] = (from_index, to_index) @@ -927,11 +953,21 @@ def _handle_qgrid_msg_helper(self, content): self._sorted_column_cache = {} self._update_sort() self._update_table(triggered_by='sort_changed') + self._trigger_df_change_event() elif content['type'] == 'get_column_min_max': self._handle_get_column_min_max(content) elif content['type'] == 'filter_changed': self._handle_filter_changed(content) + def _trigger_df_change_event(self): + self.notify_change(Bunch( + name='_df', + old=None, + new=self._df, + owner=self, + type='change', + )) + def get_changed_df(self): """ Get a copy of the DataFrame that was used to create the current @@ -990,6 +1026,7 @@ def add_row(self): self._unfiltered_df.loc[last.name] = last.values self._update_table(triggered_by='add_row', scroll_to_row=df.index.get_loc(last.name)) + self._trigger_df_change_event() def remove_row(self): """ @@ -1009,6 +1046,7 @@ def remove_row(self): self._unfiltered_df.drop(selected_names, inplace=True) self._selected_rows = [] self._update_table(triggered_by='remove_row') + self._trigger_df_change_event() # Alias for legacy support, since we changed the capitalization diff --git a/qgrid/tests/test_grid.py b/qgrid/tests/test_grid.py index 56f84e07..5c486366 100644 --- a/qgrid/tests/test_grid.py +++ b/qgrid/tests/test_grid.py @@ -5,6 +5,7 @@ ) import numpy as np import pandas as pd +import json def create_df(): return pd.DataFrame({ @@ -37,6 +38,15 @@ def create_interval_index_df(): def test_edit_date(): view = QgridWidget(df=create_df()) + observer_called = False + + def on_value_change(change): + nonlocal observer_called + observer_called = True + assert change['new']['Date'][3] == pd.Timestamp('2013-01-16 00:00:00') + + view.observe(on_value_change, names=['_df']) + view._handle_qgrid_msg_helper({ 'column': "Date", 'row_index': 3, @@ -45,12 +55,25 @@ def test_edit_date(): 'value': "2013-01-16T00:00:00.000+00:00" }) + assert observer_called + def test_add_row(): view = QgridWidget(df=create_df()) + + observer_called = False + def on_value_change(change): + nonlocal observer_called + observer_called = True + assert len(change['new']) == 5 + + view.observe(on_value_change, names=['_df']) + view._handle_qgrid_msg_helper({ 'type': 'add_row' }) + assert observer_called + def test_mixed_type_column(): df = pd.DataFrame({'A': [1.2, 'xy', 4], 'B': [3, 4, 5]}) df = df.set_index(pd.Index(['yz', 7, 3.2])) @@ -201,6 +224,13 @@ def test_date_index(): def test_multi_index(): view = QgridWidget(df=create_multi_index_df()) + observer_count = 0 + def on_value_change(change): + nonlocal observer_count + observer_count += 1 + + view.observe(on_value_change, names=['_df']) + view._handle_qgrid_msg_helper({ 'type': 'get_column_min_max', 'field': 'level_0', @@ -218,6 +248,17 @@ def test_multi_index(): } }) + view._handle_qgrid_msg_helper({ + 'type': 'filter_changed', + 'field': 3, + 'filter_info': { + 'field': 3, + 'type': 'slider', + 'min': None, + 'max': None + } + }) + view._handle_qgrid_msg_helper({ 'type': 'sort_changed', 'sort_field': 3, @@ -230,6 +271,8 @@ def test_multi_index(): 'sort_ascending': True }) + assert observer_count == 4 + def test_interval_index(): df = create_interval_index_df() df.set_index('time_bin', inplace=True) @@ -274,3 +317,68 @@ def assert_widget_vals_b(widget): view = QgridWidget(df=df) assert_widget_vals_b(view) + +class MyObject(object): + def __init__(self, obj): + self.obj = obj + + +my_object_vals = [MyObject(MyObject(None)), MyObject(None)] + + +def test_object_dtype(): + df = pd.DataFrame({'a': my_object_vals}) + widget = QgridWidget(df=df) + grid_data = json.loads(widget._df_json)['data'] + + widget._handle_qgrid_msg_helper({ + 'type': 'get_column_min_max', + 'field': 'a', + 'search_val': None + }) + widget._handle_qgrid_msg_helper({ + 'field': "a", + 'filter_info': { + 'field': "a", + 'selected': [0, 1], + 'type': "text", + 'excluded': [] + }, + 'type': "filter_changed" + }) + + filter_table = widget._filter_tables['a'] + assert not isinstance(filter_table[0], dict) + assert not isinstance(filter_table[1], dict) + + assert not isinstance(grid_data[0]['a'], dict) + assert not isinstance(grid_data[1]['a'], dict) + + +def test_object_dtype_categorical(): + cat_series = pd.Series( + pd.Categorical(my_object_vals, + categories=my_object_vals) + ) + widget = show_grid(cat_series) + constraints_enum = widget._columns[0]['constraints']['enum'] + assert not isinstance(constraints_enum[0], dict) + assert not isinstance(constraints_enum[1], dict) + + widget._handle_qgrid_msg_helper({ + 'type': 'get_column_min_max', + 'field': 0, + 'search_val': None + }) + widget._handle_qgrid_msg_helper({ + 'field': 0, + 'filter_info': { + 'field': 0, + 'selected': [0], + 'type': "text", + 'excluded': [] + }, + 'type': "filter_changed" + }) + assert len(widget._df) == 1 + assert widget._df[0][0] == cat_series[0]