From 7c7622e081ec2251f7f8f26703e1c751729809bc Mon Sep 17 00:00:00 2001 From: Andrew Schonfeld Date: Mon, 6 Apr 2020 10:34:25 -0400 Subject: [PATCH] 1.8.7: * #161 * #156 * #163 * #162 * #155 * #141 * #137 * #160 --- .circleci/config.yml | 2 +- CHANGES.md | 9 + README.md | 66 +- docker/2_7/Dockerfile | 2 +- docker/3_6/Dockerfile | 2 +- docs/source/conf.py | 4 +- dtale/charts/utils.py | 110 +- dtale/column_filters.py | 20 +- dtale/dash_application/charts.py | 156 +- dtale/dash_application/layout.py | 28 +- dtale/dash_application/views.py | 13 +- dtale/static/css/main.css | 25 +- dtale/utils.py | 7 +- dtale/views.py | 82 +- package.json | 3 +- setup.py | 3 +- .../__tests__/dtale/DataViewer-base-test.jsx | 28 +- .../dtale/DataViewer-describe-test.jsx | 196 ++- .../DataViewer-dtype-highlighting-test.jsx | 71 + .../dtale/DataViewer-heatmap-test.jsx | 31 +- static/__tests__/dtale/gridUtils-test.jsx | 6 +- .../__tests__/iframe/DataViewer-base-test.jsx | 30 +- .../iframe/DataViewer-delete-test.jsx | 86 + static/__tests__/popups/Instances-test.jsx | 2 +- .../popups/formats/NumericFormatting-test.jsx | 5 +- static/__tests__/redux-test-utils.jsx | 17 +- static/actions/url-utils.js | 6 +- static/chartUtils.jsx | 4 +- static/dtale/DataViewer.jsx | 18 +- static/dtale/DataViewerInfo.jsx | 63 +- static/dtale/DataViewerMenu.jsx | 46 +- static/dtale/Header.jsx | 2 +- static/dtale/gridUtils.jsx | 253 ++- static/dtale/iframe/ColumnMenu.jsx | 16 +- static/dtale/menu-descriptions.json | 2 +- static/dtale/serverStateManagement.jsx | 1 + static/filters/ColumnFilter.jsx | 8 +- static/popups/ContextVariables.jsx | 8 +- static/popups/Describe.jsx | 18 +- static/popups/Filter.jsx | 15 +- static/popups/Instances.jsx | 4 +- .../popups/analysis/ColumnAnalysisFilters.jsx | 22 +- static/popups/charts/Aggregations.jsx | 4 +- static/popups/charts/ChartsBody.jsx | 4 +- .../popups/correlations/CorrelationsGrid.jsx | 2 +- static/popups/create/CreateBins.jsx | 2 +- static/popups/create/CreateColumn.jsx | 2 +- static/popups/create/CreateDatetime.jsx | 2 +- static/popups/create/CreateNumeric.jsx | 2 +- static/popups/describe/Details.jsx | 121 +- static/popups/describe/DtypesGrid.jsx | 4 +- static/popups/formats/Formatting.jsx | 2 +- static/popups/formats/NumericFormatting.jsx | 39 +- tests/conftest.py | 4 +- tests/dtale/test_dash.py | 44 +- tests/dtale/test_instance.py | 7 +- tests/dtale/test_views.py | 84 +- yarn.lock | 1474 ++++++++++------- 58 files changed, 2152 insertions(+), 1135 deletions(-) create mode 100644 static/__tests__/dtale/DataViewer-dtype-highlighting-test.jsx create mode 100644 static/__tests__/iframe/DataViewer-delete-test.jsx diff --git a/.circleci/config.yml b/.circleci/config.yml index ff878f3f..286301da 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,7 +5,7 @@ defaults: &defaults CIRCLE_ARTIFACTS: /tmp/circleci-artifacts CIRCLE_TEST_REPORTS: /tmp/circleci-test-results CODECOV_TOKEN: b0d35139-0a75-427a-907b-2c78a762f8f0 - VERSION: 1.8.6 + VERSION: 1.8.7 PANDOC_RELEASES_URL: https://github.com/jgm/pandoc/releases steps: - checkout diff --git a/CHANGES.md b/CHANGES.md index 69d02326..2e769bbe 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,14 @@ ## Changelog +### 1.8.7 (2020-4-8) + * [#137](https://github.com/man-group/dtale/issues/137) + * [#141](https://github.com/man-group/dtale/issues/141) + * [#156](https://github.com/man-group/dtale/issues/156) + * [#160](https://github.com/man-group/dtale/issues/160) + * [#161](https://github.com/man-group/dtale/issues/161) + * [#162](https://github.com/man-group/dtale/issues/162) + * [#163](https://github.com/man-group/dtale/issues/163) + ### 1.8.6 [hotfix] (2020-4-5) * updates to setup.py to include images diff --git a/README.md b/README.md index 390c7945..e4b9a7f2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ [![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Title.png)](https://github.com/man-group/dtale) -[Live Demo](http://alphatechadmin.pythonanywhere.com) +* [Live Demo](http://alphatechadmin.pythonanywhere.com) +* [Animated US COVID-19 Deaths By State](http://alphatechadmin.pythonanywhere.com/charts/3?chart_type=maps&query=date+%3E+%2720200301%27&agg=raw&map_type=choropleth&loc_mode=USA-states&loc=state_code&map_val=deaths&colorscale=Reds&cpg=false&animate_by=date) +* [3D Scatter Chart](http://alphatechadmin.pythonanywhere.com/charts/4?chart_type=3d_scatter&query=&x=date&z=Col0&agg=raw&cpg=false&y=%5B%22security_id%22%5D) +* [Surface Chart](http://alphatechadmin.pythonanywhere.com/charts/4?chart_type=surface&query=&x=date&z=Col0&agg=raw&cpg=false&y=%5B%22security_id%22%5D) ----------------- @@ -41,9 +44,9 @@ D-Tale was the product of a SAS to Python conversion. What was originally a per - [Dimensions/Main Menu](#dimensionsmain-menu) - [Header](#header) - [Main Menu Functions](#main-menu-functions) - - [Describe](#describe), [Custom Filter](#custom-filter), [Building Columns](#building-columns), [Summarize Data](#summarize-data), [Charts](#charts), [Coverage (Deprecated)](#coverage-deprecated), [Correlations](#correlations), [Heat Map](#heat-map), [Instances](#instances), [Code Exports](#code-exports), [About](#about), [Resize](#resize), [Shutdown](#shutdown) + - [Describe](#describe), [Outlier Detection](#outlier-detection), [Custom Filter](#custom-filter), [Building Columns](#building-columns), [Summarize Data](#summarize-data), [Charts](#charts), [Coverage (Deprecated)](#coverage-deprecated), [Correlations](#correlations), [Heat Map](#heat-map), [Highlight Dtypes](#highlight-dtypes), [Instances](#instances), [Code Exports](#code-exports), [About](#about), [Resize](#resize), [Shutdown](#shutdown) - [Column Menu Functions](#column-menu-functions) - - [Filtering](#filtering), [Moving Columns](#moving-columns), [Hiding Columns](#hiding-columns), [Lock](#lock), [Unlock](#unlock), [Sorting](#sorting), [Formats](#formats), [Column Analysis](#column-analysis) + - [Filtering](#filtering), [Moving Columns](#moving-columns), [Hiding Columns](#hiding-columns), [Delete](#delete), [Lock](#lock), [Unlock](#unlock), [Sorting](#sorting), [Formats](#formats), [Column Analysis](#column-analysis) - [Menu Functions Depending on Browser Dimensions](#menu-functions-depending-on-browser-dimensions) - [For Developers](#for-developers) - [Cloning](#cloning) @@ -333,9 +336,34 @@ View all the columns & their data types as well as individual details of each co |int|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Describe_int.png)|Anything with standard numeric classifications (min, max, 25%, 50%, 75%) will have a nice boxplot with the mean (if it exists) displayed as an outlier if you look closely.| |float|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Describe_float.png)|| +#### Outlier Detection +When viewing integer & float columns in the ["Describe" popup](#describe) you will see in the lower right-hand corner a toggle for Uniques & Outliers. + +|Outliers|Filter| +|--------|------| +|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/outliers.png)|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/outlier_filter.png)| + +If you click the "Outliers" toggle this will load the top 100 outliers in your column based on the following code snippet: +```python +s = df[column] +q1 = s.quantile(0.25) +q3 = s.quantile(0.75) +iqr = q3 - q1 +iqr_lower = q1 - 1.5 * iqr +iqr_upper = q3 + 1.5 * iqr +outliers = s[(s < iqr_lower) | (s > iqr_upper)] +``` +If you click on the "Apply outlier filter" link this will add an addtional "outlier" filter for this column which can be removed from the [header](#header) or the [custom filter](#custom-filter) shown in picture above to the right. + #### Custom Filter Apply a custom pandas `query` to your data (link to pandas documentation included in popup) +|Editing|Result| +|--------|:------:| +|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Filter_apply.png)|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Post_filter.png)| + +You can also see any outlier or column filters you've applied (which will be included in addition to your custom query) and remove them if you'd like. + Context Variables are user-defined values passed in via the `context_variables` argument to dtale.show(); they can be referenced in filters by prefixing the variable name with '@'. For example, here is how you can use context variables in a pandas query: @@ -355,12 +383,6 @@ And here is how you would pass that context variable to D-Tale: `dtale.show(df, Here's some nice documentation on the performance of [pandas queries](https://pandas.pydata.org/pandas-docs/stable/user_guide/enhancingperf.html#pandas-eval-performance) - -|Editing|Result| -|--------|:------:| -|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Filter_apply.png)|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Post_filter.png)| - - #### Building Columns [![](http://img.youtube.com/vi/G6wNS9-lG04/0.jpg)](http://www.youtube.com/watch?v=G6wNS9-lG04 "Build Columns in D-Tale") @@ -531,13 +553,25 @@ When the data being viewed in D-Tale has date or timestamp columns but for each |![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/rolling_corr_data.png)|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/rolling_corr.png)| #### Heat Map -This will hide any non-float columns (with the exception of the index on the right) and apply a color to the background of each cell +This will hide any non-float or non-int columns (with the exception of the index on the right) and apply a color to the background of each cell. + - Each float is renormalized to be a value between 0 and 1.0 + - You have two options for the renormalization + - **By Col**: each value is calculated based on the min/max of its column + - **Overall**: each value is caluclated by the overall min/max of all the non-hidden float/int columns in the dataset - Each renormalized value is passed to a color scale of red(0) - yellow(0.5) - green(1.0) ![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Heatmap.png) -Turn off Heat Map by clicking menu option again -![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Heatmap_toggle.png) +Turn off Heat Map by clicking menu option you previously selected one more time + +#### Highlight Dtypes +This is a quick way to check and see if your data has been categorized correctly. By clicking this menu option it will assign a specific background color to each column of a specific data type +|category|timedelta|float|int|date|string| +|--------|---------|-----|------|------|-----| +|#E1BEE7|#FFCC80|#B2DFDB|#BBDEFB|#F8BBD0|white| + +![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/highlight_dtypes.png) + #### Code Exports *Code Exports* are small snippets of code representing the current state of the grid you're viewing including things like: @@ -639,6 +673,10 @@ All column movements are saved on the server so refreshing your browser won't lo All column movements are saved on the server so refreshing your browser won't lose them :ok_hand: +#### Delete + +As simple as it sounds, click this button to delete this column from your dataframe. (Warning: not un-doable!) + #### Lock Adds your column to "locked" columns - "locked" means that if you scroll horizontally these columns will stay pinned to the right-hand side @@ -894,6 +932,7 @@ D-Tale works with: * Flask * Flask-Compress * Pandas + * plotly * scipy * six * Front-end @@ -909,11 +948,12 @@ Original concept and implementation: [Andrew Schonfeld](https://github.com/ascho Contributors: * [Phillip Dupuis](https://github.com/phillipdupuis) + * [Fernando Saravia Rajal](https://github.com/fersarr) * [Dominik Christ](https://github.com/DominikMChrist) + * [Reza Moshkzar](https://github.com/reza1615) * [Chris Boddy](https://github.com/cboddy) * [Jason Holden](https://github.com/jasonkholden) * [Tom Taylor](https://github.com/TomTaylorLondon) - * [Fernando Saravia Rajal](https://github.com/fersarr) * [Wilfred Hughes](https://github.com/Wilfred) * Mike Kelly * [Vincent Riemer](https://github.com/vincentriemer) diff --git a/docker/2_7/Dockerfile b/docker/2_7/Dockerfile index c73de3ff..bda55ee7 100644 --- a/docker/2_7/Dockerfile +++ b/docker/2_7/Dockerfile @@ -44,4 +44,4 @@ WORKDIR /app RUN set -eux \ ; . /root/.bashrc \ - ; easy_install dtale-1.8.6-py2.7.egg + ; easy_install dtale-1.8.7-py2.7.egg diff --git a/docker/3_6/Dockerfile b/docker/3_6/Dockerfile index aead29ce..50cf4da5 100644 --- a/docker/3_6/Dockerfile +++ b/docker/3_6/Dockerfile @@ -44,4 +44,4 @@ WORKDIR /app RUN set -eux \ ; . /root/.bashrc \ - ; easy_install dtale-1.8.6-py3.7.egg + ; easy_install dtale-1.8.7-py3.7.egg diff --git a/docs/source/conf.py b/docs/source/conf.py index c006d8e7..b8f04e07 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -64,9 +64,9 @@ # built documents. # # The short X.Y version. -version = u'1.8.6' +version = u'1.8.7' # The full version, including alpha/beta/rc tags. -release = u'1.8.6' +release = u'1.8.7' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/dtale/charts/utils.py b/dtale/charts/utils.py index ff30a0c0..a8ea88e4 100644 --- a/dtale/charts/utils.py +++ b/dtale/charts/utils.py @@ -1,8 +1,11 @@ +import copy + import pandas as pd -from dtale.utils import (ChartBuildingError, classify_type, +from dtale.utils import (ChartBuildingError, classify_type, find_dtype, find_dtype_formatter, flatten_lists, get_dtypes, - grid_columns, grid_formatter, json_int, make_list) + grid_columns, grid_formatter, json_int, make_list, + run_query) YAXIS_CHARTS = ['line', 'bar', 'scatter'] ZAXIS_CHARTS = ['heatmap', '3d_scatter', 'surface'] @@ -187,7 +190,7 @@ def retrieve_chart_data(df, *args, **kwargs): all_code = ["chart_data = pd.concat(["] + all_code + ["], axis=1)"] if len(make_list(kwargs.get('group_val'))): filters = build_group_inputs_filter(all_data, kwargs['group_val']) - all_data = all_data.query(filters) + all_data = run_query(all_data, filters) all_code.append('chart_data = chart_data.query({})'.format(filters)) return all_data, all_code @@ -236,7 +239,7 @@ def check_exceptions(df, allow_duplicates, unlimited_data=False, data_limit=1500 raise ChartBuildingError(limit_msg.format(data_limit)) -def build_agg_data(df, x, y, inputs, agg, z=None): +def build_agg_data(df, x, y, inputs, agg, z=None, animate_by=None): """ Builds aggregated data when an aggregation (sum, mean, max, min...) is selected from the front-end. @@ -280,14 +283,24 @@ def build_agg_data(df, x, y, inputs, agg, z=None): return agg_df, code if z_exists: - groups = df.groupby([x] + make_list(y)) - return getattr(groups[make_list(z)], agg)().reset_index(), [ + idx_cols = make_list(animate_by) + [x] + make_list(y) + groups = df.groupby(idx_cols) + groups = getattr(groups[make_list(z)], agg)() + if animate_by is not None: + full_idx = pd.MultiIndex.from_product([df[c].unique() for c in idx_cols], names=idx_cols) + groups = groups.reindex(full_idx).fillna(0) + return groups.reset_index(), [ "chart_data = chart_data.groupby(['{cols}'])[['{z}']].{agg}().reset_index()".format( cols="', '".join([x] + make_list(y)), z=z, agg=agg ) ] - groups = df.groupby(x) - return getattr(groups[y], agg)().reset_index(), [ + idx_cols = make_list(animate_by) + [x] + groups = df.groupby(idx_cols) + groups = getattr(groups[y], agg)() + if animate_by is not None: + full_idx = pd.MultiIndex.from_product([df[c].unique() for c in idx_cols], names=idx_cols) + groups = groups.reindex(full_idx).fillna(0) + return groups.reset_index(), [ "chart_data = chart_data.groupby('{x}')[['{y}']].{agg}().reset_index()".format( x=x, y=make_list(y)[0], agg=agg ) @@ -295,7 +308,7 @@ def build_agg_data(df, x, y, inputs, agg, z=None): def build_base_chart(raw_data, x, y, group_col=None, group_val=None, agg=None, allow_duplicates=False, return_raw=False, - unlimited_data=False, **kwargs): + unlimited_data=False, animate_by=None, **kwargs): """ Helper function to return data for 'chart-data' & 'correlations-ts' endpoints. Will return a dictionary of dictionaries (one for each series) which contain the data for the x & y axes of the chart as well as the minimum & @@ -318,29 +331,32 @@ def build_base_chart(raw_data, x, y, group_col=None, group_val=None, agg=None, a :type allow_duplicates: bool, optional :return: dict """ - - data, code = retrieve_chart_data(raw_data, x, y, kwargs.get('z'), group_col, group_val=group_val) + group_fmt_overrides = {'I': lambda v, as_string: json_int(v, as_string=as_string, fmt='{}')} + data, code = retrieve_chart_data(raw_data, x, y, kwargs.get('z'), group_col, animate_by, group_val=group_val) x_col = str('x') y_cols = make_list(y) z_col = kwargs.get('z') z_cols = make_list(z_col) if group_col is not None and len(group_col): - data = data.sort_values(group_col + [x]) - code.append("chart_data = chart_data.sort_values(['{cols}'])".format(cols="', '".join(group_col + [x]))) + main_group = group_col + if animate_by is not None: + main_group = [animate_by] + main_group + sort_cols = main_group + [x] + data = data.sort_values(sort_cols) + code.append("chart_data = chart_data.sort_values(['{cols}'])".format(cols="', '".join(sort_cols))) check_all_nan(data, [x] + y_cols) data = data.rename(columns={x: x_col}) code.append("chart_data = chart_data.rename(columns={'" + x + "': '" + x_col + "'})") if agg is not None and agg != 'raw': - data = data.groupby(group_col + [x_col]) + data = data.groupby(main_group + [x_col]) data = getattr(data, agg)().reset_index() code.append("chart_data = chart_data.groupby(['{cols}']).{agg}().reset_index()".format( - cols="', '".join(group_col + [x]), agg=agg + cols="', '".join(main_group + [x]), agg=agg )) MAX_GROUPS = 30 group_vals = data[group_col].drop_duplicates() if len(group_vals) > MAX_GROUPS: dtypes = get_dtypes(group_vals) - group_fmt_overrides = {'I': lambda v, as_string: json_int(v, as_string=as_string, fmt='{}')} group_fmts = {c: find_dtype_formatter(dtypes[c], overrides=group_fmt_overrides) for c in group_col} group_f, _ = build_formatters(group_vals) @@ -365,45 +381,73 @@ def build_base_chart(raw_data, x, y, group_col=None, group_val=None, agg=None, a ) dtypes = get_dtypes(data) - group_fmt_overrides = {'I': lambda v, as_string: json_int(v, as_string=as_string, fmt='{}')} group_fmts = {c: find_dtype_formatter(dtypes[c], overrides=group_fmt_overrides) for c in group_col} - for group_val, grp in data.groupby(group_col): - - def _group_filter(): - for gv, gc in zip(make_list(group_val), group_col): - classifier = classify_type(dtypes[gc]) - yield group_filter_handler(gc, group_fmts[gc](gv, as_string=True), classifier) - group_filter = ' and '.join(list(_group_filter())) - ret_data['data'][group_filter] = data_f.format_lists(grp) + + def _load_groups(df): + for group_val, grp in df.groupby(group_col): + + def _group_filter(): + for gv, gc in zip(make_list(group_val), group_col): + classifier = classify_type(dtypes[gc]) + yield group_filter_handler(gc, group_fmts[gc](gv, as_string=True), classifier) + + group_filter = ' and '.join(list(_group_filter())) + yield group_filter, data_f.format_lists(grp) + + if animate_by is not None: + frame_fmt = find_dtype_formatter(dtypes[animate_by], overrides=group_fmt_overrides) + ret_data['frames'] = [] + for frame_key, frame in data.sort_values(animate_by).groupby(animate_by): + ret_data['frames'].append( + dict(data=dict(_load_groups(frame)), name=frame_fmt(frame_key, as_string=True)) + ) + ret_data['data'] = copy.deepcopy(ret_data['frames'][-1]['data']) + else: + ret_data['data'] = dict(_load_groups(data)) return ret_data, code - sort_cols = [x] + (y_cols if len(z_cols) else []) + main_group = [x] + if animate_by is not None: + main_group = [animate_by] + main_group + sort_cols = main_group + (y_cols if len(z_cols) else []) data = data.sort_values(sort_cols) code.append("chart_data = chart_data.sort_values(['{cols}'])".format(cols="', '".join(sort_cols))) - check_all_nan(data, [x] + y_cols + z_cols) + check_all_nan(data, main_group + y_cols + z_cols) y_cols = [str(y_col) for y_col in y_cols] - data.columns = [x_col] + y_cols + z_cols - code.append("chart_data.columns = ['{cols}']".format(cols="', '".join([x_col] + y_cols + z_cols))) + data = data[main_group + y_cols + z_cols] + main_group[-1] = x_col + data.columns = main_group + y_cols + z_cols + code.append("chart_data.columns = ['{cols}']".format(cols="', '".join(main_group + y_cols + z_cols))) if agg is not None: - data, agg_code = build_agg_data(data, x_col, y_cols, kwargs, agg, z=z_col) + data, agg_code = build_agg_data(data, x_col, y_cols, kwargs, agg, z=z_col, animate_by=animate_by) code += agg_code data = data.dropna() if return_raw: return data.rename(columns={x_col: x}) code.append("chart_data = chart_data.dropna()") - dupe_cols = [x_col] + (y_cols if len(z_cols) else []) + dupe_cols = main_group + (y_cols if len(z_cols) else []) check_exceptions( data[dupe_cols].rename(columns={'x': x}), allow_duplicates or agg == 'raw', unlimited_data=unlimited_data, - data_limit=40000 if len(z_cols) else 15000 + data_limit=40000 if len(z_cols) or animate_by is not None else 15000 ) data_f, range_f = build_formatters(data) + ret_data = dict( - data={str('all'): data_f.format_lists(data)}, min={col: fmt(data[col].min(), None) for _, col, fmt in range_f.fmts if col in [x_col] + y_cols + z_cols}, max={col: fmt(data[col].max(), None) for _, col, fmt in range_f.fmts if col in [x_col] + y_cols + z_cols} ) + if animate_by is not None: + frame_fmt = find_dtype_formatter(find_dtype(data[animate_by]), overrides=group_fmt_overrides) + ret_data['frames'] = [] + for frame_key, frame in data.sort_values(animate_by).groupby(animate_by): + ret_data['frames'].append( + dict(data={str('all'): data_f.format_lists(frame)}, name=frame_fmt(frame_key, as_string=True)) + ) + ret_data['data'] = copy.deepcopy(ret_data['frames'][-1]['data']) + else: + ret_data['data'] = {str('all'): data_f.format_lists(data)} return ret_data, code diff --git a/dtale/column_filters.py b/dtale/column_filters.py index 49333aef..106e6a55 100644 --- a/dtale/column_filters.py +++ b/dtale/column_filters.py @@ -21,17 +21,33 @@ def __init__(self, data_id, column, cfg): self.builder = NumericFilter(column, self.classification, self.cfg) if self.cfg['type'] == 'date': self.builder = DateFilter(column, self.classification, self.cfg) + if self.cfg['type'] == 'outliers': + self.builder = OutlierFilter(column, self.classification, self.cfg) def save_filter(self): curr_settings = global_state.get_settings(self.data_id) - curr_filters = curr_settings.get('columnFilters') or {} + filters_key = '{}Filters'.format('outlier' if self.cfg['type'] == 'outliers' else 'column') + curr_filters = curr_settings.get(filters_key) or {} fltr = self.builder.build_filter() if fltr is None: curr_filters.pop(self.column, None) else: curr_filters[self.column] = fltr - curr_settings['columnFilters'] = curr_filters + curr_settings[filters_key] = curr_filters global_state.set_settings(self.data_id, curr_settings) + return curr_filters + + +class OutlierFilter(object): + def __init__(self, column, classification, cfg): + self.column = column + self.classification = classification + self.cfg = cfg + + def build_filter(self): + if self.cfg.get("query") is None: + return None + return self.cfg class MissingFilter(object): diff --git a/dtale/dash_application/charts.py b/dtale/dash_application/charts.py index 570dc0e4..2b995da6 100644 --- a/dtale/dash_application/charts.py +++ b/dtale/dash_application/charts.py @@ -66,6 +66,8 @@ def chart_url_params(search): if gp in params: params[gp] = json.loads(params[gp]) params['cpg'] = 'true' == params.get('cpg') + if params.get('chart_type') in ANIMATION_CHARTS: + params['animate'] = 'true' == params.get('animate') if 'window' in params: params['window'] = int(params['window']) if 'group_filter' in params: @@ -96,7 +98,9 @@ def chart_url_querystring(params, data=None, group_filter=None): final_params = {k: params[k] for k in base_props if params.get(k) is not None} final_params['cpg'] = 'true' if params.get('cpg') is True else 'false' - if (chart_type in ANIMATION_CHARTS or chart_type in ANIMATE_BY_CHARTS) and params.get('animate_by') is not None: + if chart_type in ANIMATION_CHARTS: + final_params['animate'] = 'true' if params.get('animate') is True else 'false' + if chart_type in ANIMATE_BY_CHARTS and params.get('animate_by') is not None: final_params['animate_by'] = params.get('animate_by') for gp in ['y', 'group', 'map_group', 'group_val']: list_param = [val for val in params.get(gp) or [] if val is not None] @@ -460,12 +464,12 @@ def _build_final_scatter(y_val): dict_merge(build_title(x, y_val, group, z=z, agg=agg), layout(axes_builder([y_val])[0])) ) } - if animate_by == 'chart_values': - def build_frame(i): - for series_key, series in data['data'].items(): + if animate_by is not None: + def build_frame(frame): + for series_key, series in frame.items(): if y_val in series and (group is None or group == series_key): yield scatter_func(**dict( - x=series['x'][:i], y=series[y_val][:i], mode='markers', opacity=0.7, + x=series['x'], y=series[y_val], mode='markers', opacity=0.7, name=series_key, marker=marker(series) )) @@ -587,11 +591,10 @@ def update_cfg_w_frames(cfg, frames, slider_steps): def build_frames(data, frame_builder): - x_data = next(iter(data['data'].values()), {}).get('x', []) frames, slider_steps = [], [] - for i, x in enumerate(x_data, 1): - frames.append(dict(data=list(frame_builder(i)), name=x)) - slider_steps.append(x) + for frame in data.get('frames', []): + frames.append(dict(data=list(frame_builder(frame['data'])), name=frame['name'])) + slider_steps.append(frame['name']) return frames, slider_steps @@ -680,19 +683,42 @@ def bar_builder(data, x, y, axes_builder, wrapper, cpg=False, barmode='group', b dict_merge(build_title(x, y, agg=kwargs.get('agg')), axes, dict(barmode=barmode or 'group')) ) } - if kwargs.get('animate_by') == 'chart_values': - - def build_frame(i): - for series_key, series in data['data'].items(): - for j, y2 in enumerate(y, 1): - yield dict_merge( - {'x': series['x'][:i], 'y': series[y2][:i], 'type': 'bar'}, - name_builder(y2, series_key), - {} if j == 1 or not allow_multiaxis else {'yaxis': 'y{}'.format(j)}, - hover_text.get(series_key) or {} + if kwargs.get('animate_by'): + def build_frame(frame): + data, layout = [], {} + for series_key, series in frame['data'].items(): + barsort_col = 'x' if barsort == x or barsort not in series else barsort + layout = {} + if barsort_col != 'x' or kwargs.get('agg') == 'raw': + df = pd.DataFrame(series) + df = df.sort_values(barsort_col) + series = dict_merge( + {c: df[c].values for c in df.columns}, + {'x': list(range(len(df['x']))), 'hovertext': df['x'].values, 'hoverinfo': 'y+text'} ) + layout['xaxis'] = dict_merge( + axes.get('xaxis', {}), + build_spaced_ticks(df['x'].values, mode='array') + ) + for i, y2 in enumerate(y, 1): + data.append(dict_merge( + {k: v for k, v in series.items() if k in ['x', 'hovertext', 'hoverinfo']}, + {'y': series[y2], 'type': 'bar'}, + name_builder(y2, series_key), + {} if i == 1 or not allow_multiaxis else {'yaxis': 'y{}'.format(i)}, + )) + if barmode == 'group' and allow_multiaxis: + data['data'] = list(build_grouped_bars_with_multi_yaxis(data['data'], y)) + return dict(data=data, layout=layout, name=frame['name']) - update_cfg_w_frames(figure_cfg, *build_frames(data, build_frame)) + def build_bar_frames(data, frame_builder): + frames, slider_steps = [], [] + for frame in data.get('frames', []): + frames.append(frame_builder(frame)) + slider_steps.append(frame['name']) + return frames, slider_steps + + update_cfg_w_frames(figure_cfg, *build_bar_frames(data, build_frame)) return wrapper(graph_wrapper(id='bar-graph', figure=figure_cfg)) @@ -765,7 +791,7 @@ def line_cfg(s): ]) figure_cfg = {'data': data_cfgs, 'layout': build_layout(dict_merge(build_title(x, y, agg=inputs.get('agg')), axes))} - if inputs.get('animate_by') == 'chart_values': + if inputs.get('animate') is True: def build_frame(i): for series_key, series in data['data'].items(): @@ -777,7 +803,13 @@ def build_frame(i): {} if j == 1 or not multi_yaxis else {'yaxis': 'y{}'.format(j)} )) - update_cfg_w_frames(figure_cfg, *build_frames(data, build_frame)) + x_data = next(iter(data['data'].values()), {}).get('x', []) + frames, slider_steps = [], [] + for i, x in enumerate(x_data, 1): + frames.append(dict(data=list(build_frame(i)), name=x)) + slider_steps.append(x) + + update_cfg_w_frames(figure_cfg, frames, slider_steps) return wrapper(graph_wrapper(id='line-graph', figure=figure_cfg)) @@ -958,6 +990,19 @@ def heatmap_builder(data_id, export=False, **inputs): return build_error(e, traceback.format_exc()), code +def build_map_frames(data, animate_by, frame_builder): + formatter = find_dtype_formatter(get_dtypes(data)[animate_by]) + frames, slider_steps = [], [] + for g_name, g in data.groupby(animate_by): + g_name = formatter(g_name) + frames.append(dict( + data=[frame_builder(g)], + name=g_name + )) + slider_steps.append(g_name) + return frames, slider_steps + + def map_builder(data_id, export=False, **inputs): code = None try: @@ -982,11 +1027,11 @@ def map_builder(data_id, export=False, **inputs): if map_type == 'scattergeo': data, code = retrieve_chart_data(raw_data, lat, lon, map_val, animate_by, map_group, group_val=group_val) if agg is not None: - data, agg_code = build_agg_data(raw_data, lat, lon, {}, agg, z=map_val) + data, agg_code = build_agg_data(raw_data, lat, lon, {}, agg, z=map_val, animate_by=animate_by) code += agg_code geo_layout = {} - if test_plotly_version('4.5.0'): + if test_plotly_version('4.5.0') and animate_by is None: geo_layout['fitbounds'] = 'locations' if scope is not None: geo_layout['scope'] = scope @@ -999,26 +1044,20 @@ def map_builder(data_id, export=False, **inputs): if map_val is not None: chart_kwargs['text'] = data[map_val] chart_kwargs['marker'] = dict( - color=data[map_val], + color=data[map_val], cmin=data[map_val].min(), cmax=data[map_val].max(), colorscale=inputs.get('colorscale') or 'Reds', colorbar_title=map_val ) figure_cfg = dict(data=[go.Scattergeo(**chart_kwargs)], layout=layout) if animate_by is not None: - def build_frames(): - formatter = find_dtype_formatter(get_dtypes(data)[animate_by]) - frames, slider_steps = [], [] - for g_name, g in data.groupby(animate_by): - g_name = formatter(g_name) - frame_kwargs = dict(lon=g[lon], lat=g[lat], mode='markers') - if map_val is not None: - frame_kwargs['text'] = g[map_val] - frame_kwargs['marker'] = dict(color=g[map_val]) - frames.append(dict(data=[frame_kwargs], name=g_name)) - slider_steps.append(g_name) - return frames, slider_steps - - update_cfg_w_frames(figure_cfg, *build_frames()) + def build_frame(df): + frame = dict(lon=df[lon], lat=df[lat], mode='markers') + if map_val is not None: + frame['text'] = df[map_val] + frame['marker'] = dict(color=df[map_val]) + return frame + + update_cfg_w_frames(figure_cfg, *build_map_frames(data, animate_by, build_frame)) chart = graph_wrapper( id='scattergeo-graph', style={'margin-right': 'auto', 'margin-left': 'auto', 'height': '95%'}, @@ -1028,34 +1067,23 @@ def build_frames(): else: data, code = retrieve_chart_data(raw_data, loc, map_val, map_group, animate_by, group_val=group_val) if agg is not None: - data, agg_code = build_agg_data(data, loc, map_val, {}, agg) + data, agg_code = build_agg_data(data, loc, map_val, {}, agg, animate_by=animate_by) code += agg_code if loc_mode == 'USA-states': layout['geo'] = dict(scope='usa') - figure_cfg = dict( data=[go.Choropleth( locations=data[loc], locationmode=loc_mode, z=data[map_val], - colorscale=inputs.get('colorscale') or 'Reds', colorbar_title=map_val + colorscale=inputs.get('colorscale') or 'Reds', colorbar_title=map_val, + zmin=data[map_val].min(), zmax=data[map_val].max() )], layout=layout ) if animate_by is not None: - def build_frames(): - formatter = find_dtype_formatter(get_dtypes(data)[animate_by]) - frames, slider_steps = [], [] - for g_name, g in data.groupby(animate_by): - g_name = formatter(g_name) - frames.append(dict( - data=[dict( - locations=g[loc], locationmode=loc_mode, z=g[map_val], text=g[loc] - )], - name=g_name - )) - slider_steps.append(g_name) - return frames, slider_steps + def build_frame(df): + return dict(locations=df[loc], locationmode=loc_mode, z=df[map_val], text=df[loc]) - update_cfg_w_frames(figure_cfg, *build_frames()) + update_cfg_w_frames(figure_cfg, *build_map_frames(data, animate_by, build_frame)) chart = graph_wrapper( id='choropleth-graph', @@ -1071,7 +1099,7 @@ def build_frames(): def build_figure_data(data_id, chart_type=None, query=None, x=None, y=None, z=None, group=None, group_val=None, - agg=None, window=None, rolling_comp=None, return_raw=False, **kwargs): + agg=None, window=None, rolling_comp=None, animate_by=None, **kwargs): """ Builds chart figure data for loading into dash:`dash_core_components.Graph ` components @@ -1101,8 +1129,7 @@ def build_figure_data(data_id, chart_type=None, query=None, x=None, y=None, z=No :return: dictionary of series data, min/max ranges of columns used in chart :rtype: dict """ - if not valid_chart(**dict(x=x, y=y, z=z, chart_type=chart_type, agg=agg, window=window, - rolling_comp=rolling_comp)): + if not valid_chart(**dict(x=x, y=y, z=z, chart_type=chart_type, agg=agg, window=window, rolling_comp=rolling_comp)): return None, None data = run_query( @@ -1116,6 +1143,8 @@ def build_figure_data(data_id, chart_type=None, query=None, x=None, y=None, z=No code = build_code_export(data_id, query=query) chart_kwargs = dict(group_col=group, group_val=group_val, agg=agg, allow_duplicates=chart_type == 'scatter', rolling_win=window, rolling_comp=rolling_comp) + if chart_type in ANIMATE_BY_CHARTS: + chart_kwargs['animate_by'] = animate_by if chart_type in ZAXIS_CHARTS: chart_kwargs['z'] = z del chart_kwargs['group_col'] @@ -1210,11 +1239,12 @@ def build_chart(data_id=None, **inputs): """ code = None try: - if inputs.get('chart_type') == 'heatmap': + chart_type = inputs.get('chart_type') + if chart_type == 'heatmap': chart, code = heatmap_builder(data_id, **inputs) return chart, None, code - if inputs.get('chart_type') == 'maps': + if chart_type == 'maps': chart, code = map_builder(data_id, **inputs) return chart, None, code @@ -1229,9 +1259,7 @@ def build_chart(data_id=None, **inputs): range_data = dict(min=data['min'], max=data['max']) axis_inputs = inputs.get('yaxis') or {} chart_builder = chart_wrapper(data_id, data, inputs) - chart_type, x, y, z, agg, group, animate_by = ( - inputs.get(p) for p in ['chart_type', 'x', 'y', 'z', 'agg', 'group', 'animate_by'] - ) + x, y, z, agg, group, animate_by = (inputs.get(p) for p in ['x', 'y', 'z', 'agg', 'group', 'animate_by']) z = z if chart_type in ZAXIS_CHARTS else None chart_inputs = {k: v for k, v in inputs.items() if k not in ['chart_type', 'x', 'y', 'z', 'group']} diff --git a/dtale/dash_application/layout.py b/dtale/dash_application/layout.py index d66b4fe8..3e56e5bb 100644 --- a/dtale/dash_application/layout.py +++ b/dtale/dash_application/layout.py @@ -276,8 +276,8 @@ def build_loc_mode_hover(loc_mode): COLORSCALES = ['Blackbody', 'Bluered', 'Blues', 'Earth', 'Electric', 'Greens', 'Greys', 'Hot', 'Jet', 'Picnic', 'Portland', 'Rainbow', 'RdBu', 'Reds', 'Viridis', 'YlGnBu', 'YlOrRd'] -ANIMATION_CHARTS = ['line', 'bar', '3d_scatter'] -ANIMATE_BY_CHARTS = ['line', 'bar', 'maps'] +ANIMATION_CHARTS = ['line'] +ANIMATE_BY_CHARTS = ['bar', '3d_scatter', 'maps'] def show_input_handler(chart_type): @@ -411,18 +411,18 @@ def colorscale_input_style(**inputs): return dict(display='block' if inputs.get('chart_type') in ['heatmap', 'maps'] else 'none') -def animate_by_style(df, **inputs): +def animate_styles(df, **inputs): chart_type, cpg = (inputs.get(p) for p in ['chart_type', 'cpg']) - if cpg: - return dict(display='none'), [] opts = [] + if cpg: + return dict(display='none'), dict(display='none'), opts + if chart_type in ANIMATION_CHARTS: + return dict(display='block'), dict(display='none'), opts if chart_type in ANIMATE_BY_CHARTS: opts = [build_option(v, l) for v, l in build_cols(df.columns, get_dtypes(df))] - if chart_type in ANIMATION_CHARTS: - opts = [build_option('chart_values', 'Values in Chart')] if len(opts): - return dict(display='block'), opts - return dict(display='none'), [] + return dict(display='none'), dict(display='block'), opts + return dict(display='none'), dict(display='none'), [] def show_chart_per_group(**inputs): @@ -510,7 +510,7 @@ def charts_layout(df, settings, **inputs): show_cpg = show_chart_per_group(**inputs) show_yaxis = show_yaxis_ranges(**inputs) bar_style = bar_input_style(**inputs) - animate_style, animate_opts = animate_by_style(df, **inputs) + animate_style, animate_by_style, animate_opts = animate_styles(df, **inputs) options = build_input_options(df, **inputs) x_options, y_multi_options, y_single_options, z_options, group_options, barsort_options, yaxis_options = options @@ -836,6 +836,14 @@ def show_map_style(show): id='colorscale-dropdown', options=[build_option(o) for o in COLORSCALES], value=inputs.get('colorscale') or default_cscale ), className='col-auto addon-min-width', style=cscale_style, id='colorscale-input'), + build_input( + 'Animate', + html.Div(daq.BooleanSwitch(id='animate-toggle', on=inputs.get('animate') or False), + className='toggle-wrapper'), + id='animate-input', + style=animate_style, + className='col-auto' + ), build_input('Animate By', dcc.Dropdown( id='animate-by-dropdown', options=animate_opts, value=inputs.get('animate_by') diff --git a/dtale/dash_application/views.py b/dtale/dash_application/views.py index d27883ad..9346537d 100644 --- a/dtale/dash_application/views.py +++ b/dtale/dash_application/views.py @@ -10,7 +10,7 @@ import dtale.global_state as global_state from dtale.charts.utils import MAX_GROUPS, ZAXIS_CHARTS from dtale.dash_application.charts import build_chart, chart_url_params -from dtale.dash_application.layout import (animate_by_style, bar_input_style, +from dtale.dash_application.layout import (animate_styles, bar_input_style, base_layout, build_group_val_options, build_input_options, @@ -241,6 +241,7 @@ def map_data(map_type, loc_mode, loc, lat, lon, map_val, scope, proj, group, pat Output('barmode-input', 'style'), Output('barsort-input', 'style'), Output('yaxis-input', 'style'), + Output('animate-input', 'style'), Output('animate-by-input', 'style'), Output('animate-by-dropdown', 'options') ], @@ -266,11 +267,11 @@ def input_toggles(_ts, inputs, pathname): data_id = get_data_id(pathname) df = global_state.get_data(data_id) - animate_style, animate_opts = animate_by_style(df, **inputs) + animate_style, animate_by_style, animate_opts = animate_styles(df, **inputs) return ( y_multi_style, y_single_style, z_style, group_style, rolling_style, cpg_style, bar_style, bar_style, - yaxis_style, animate_style, animate_opts + yaxis_style, animate_style, animate_by_style, animate_opts ) @dash_app.callback( @@ -280,17 +281,19 @@ def input_toggles(_ts, inputs, pathname): Input('barmode-dropdown', 'value'), Input('barsort-dropdown', 'value'), Input('colorscale-dropdown', 'value'), + Input('animate-toggle', 'on'), Input('animate-by-dropdown', 'value'), ] ) - def chart_input_data(cpg, barmode, barsort, colorscale, animate_by): + def chart_input_data(cpg, barmode, barsort, colorscale, animate, animate_by): """ dash callback for maintaining selections in chart-formatting inputs - chart per group flag - bar chart mode - bar chart sorting """ - return dict(cpg=cpg, barmode=barmode, barsort=barsort, colorscale=colorscale, animate_by=animate_by) + return dict(cpg=cpg, barmode=barmode, barsort=barsort, colorscale=colorscale, animate=animate, + animate_by=animate_by) @dash_app.callback( [ diff --git a/dtale/static/css/main.css b/dtale/static/css/main.css index edbf349f..c2627212 100644 --- a/dtale/static/css/main.css +++ b/dtale/static/css/main.css @@ -10166,7 +10166,7 @@ li.hoverable:hover { } .hoverable__content.menu-description { top: -0.8em; - left: 11.5em; + left: 13em; } .hoverable__content.map-types { min-width: 13em; @@ -10534,6 +10534,29 @@ div.container-fluid.describe > div#popup-content > div.modal-body { height: 450px; } +div.container-fluid.describe { + height: calc(100vh); +} +div.container-fluid.describe > div#popup-content { + height: calc(100vh - 30px); +} +div.container-fluid.describe > div#popup-content > div.modal-body { + height: calc(100vh - 75px); + overflow-y: scroll; +} +div.container-fluid.describe div.describe-dtypes-grid-col { + height: calc(100vh - 112px); +} +div.modal-dialog div.modal-body.describe-body { + height: 500px; + overflow-y: scroll; + overflow-x: hidden; +} + +div.modal-dialog div.modal-body.describe-body div.describe-dtypes-grid-col { + height: 470px; +} + div.container-fluid.filter > div#popup-content > div.modal-body { height: 400px; } diff --git a/dtale/utils.py b/dtale/utils.py index c447553e..486f13e1 100644 --- a/dtale/utils.py +++ b/dtale/utils.py @@ -648,10 +648,11 @@ def build_query(data_id, query=None): def inner_build_query(settings, query=None): - curr_filters = settings.get('columnFilters') or {} query_segs = [] - for col, filter_cfg in curr_filters.items(): - query_segs.append(filter_cfg['query']) + for p in ['columnFilters', 'outlierFilters']: + curr_filters = settings.get(p) or {} + for col, filter_cfg in curr_filters.items(): + query_segs.append(filter_cfg['query']) if query not in [None, '']: query_segs.append(query) return ' and '.join(query_segs) diff --git a/dtale/views.py b/dtale/views.py index 052ac2b9..c11c41a3 100644 --- a/dtale/views.py +++ b/dtale/views.py @@ -408,7 +408,7 @@ def _formatter(col_index, col): if prev_dtypes and col in prev_dtypes: visible = prev_dtypes[col].get('visible', True) dtype_data = dict(name=col, dtype=dtype, index=col_index, visible=visible) - if classify_type(dtype) == 'F' and not data[col].isnull().all() and col in data_ranges: # floats + if classify_type(dtype) in ['F', 'I'] and not data[col].isnull().all() and col in data_ranges: # floats/ints col_ranges = data_ranges[col] if not any((np.isnan(v) or np.isinf(v) for v in col_ranges.values())): dtype_data = dict_merge(col_ranges, dtype_data) @@ -1036,6 +1036,54 @@ def describe(data_id, column): return jsonify_error(e) +@dtale.route('/outliers//') +def outliers(data_id, column): + try: + df = global_state.get_data(data_id) + s = df[column] + q1 = s.quantile(0.25) + q3 = s.quantile(0.75) + iqr = q3 - q1 + iqr_lower = q1 - 1.5 * iqr + iqr_upper = q3 + 1.5 * iqr + formatter = find_dtype_formatter(get_dtypes(df)[column]) + outliers = s[(s < iqr_lower) | (s > iqr_upper)].unique() + top = len(outliers) > 100 + outliers = [formatter(v) for v in outliers[:100]] + query = '(({column} < {lower}) or ({column} > {upper}))'.format(column=column, lower=json_float(iqr_lower), + upper=json_float(iqr_upper)) + code = ( + "s = df['{column}']\n" + "q1 = s.quantile(0.25)\n" + "q3 = s.quantile(0.75)\n" + "iqr = q3 - q1\n" + "iqr_lower = q1 - 1.5 * iqr\n" + "iqr_upper = q3 + 1.5 * iqr\n" + "outliers = dict(s[(s < iqr_lower) | (s > iqr_upper)])" + ).format(column=column) + queryApplied = column in ((global_state.get_settings(data_id) or {}).get('outlierFilters') or {}) + return jsonify(outliers=outliers, query=query, code=code, queryApplied=queryApplied, top=top) + except BaseException as e: + return jsonify_error(e) + + +@dtale.route('/delete-col//') +def delete_col(data_id, column): + try: + data = global_state.get_data(data_id) + data = data[[c for c in data.columns if c != column]] + dtypes = global_state.get_dtypes(data_id) + dtypes = [dt for dt in dtypes if dt['name'] != column] + curr_settings = global_state.get_settings(data_id) + curr_settings['locked'] = [c for c in curr_settings.get('locked', []) if c != column] + global_state.set_data(data_id, data) + global_state.set_dtypes(data_id, dtypes) + global_state.set_settings(data_id, curr_settings) + return jsonify(success=True) + except BaseException as e: + return jsonify_error(e) + + @dtale.route('/column-filter-data//') def get_column_filter_data(data_id, column): try: @@ -1059,8 +1107,7 @@ def get_column_filter_data(data_id, column): @dtale.route('/save-column-filter//') def save_column_filter(data_id, column): try: - ColumnFilter(data_id, column, get_str_arg(request, 'cfg')).save_filter() - curr_filters = (global_state.get_settings(data_id) or {}).get('columnFilters') or {} + curr_filters = ColumnFilter(data_id, column, get_str_arg(request, 'cfg')).save_filter() return jsonify(success=True, currFilters=curr_filters) except BaseException as e: return jsonify_error(e) @@ -1217,11 +1264,21 @@ def handle_top(df, top): hist = pd.value_counts(data[selected_col]).to_frame(name='data').sort_index() code.append("chart = pd.value_counts(df[~pd.isnull(df['{col}'])]['{col}'])".format(col=selected_col)) if ordinal_col is not None: - ordinal_data = getattr(data.groupby(selected_col)[[ordinal_col]], ordinal_agg)() + if ordinal_agg == 'pctsum': + ordinal_data = data.groupby(selected_col)[[ordinal_col]].sum() + ordinal_data = ordinal_data / ordinal_data.sum() + code.append(( + "ordinal_data = df.groupby('{col}')[['{ordinal}']].sum()\n" + "ordinal_data = ordinal_data / ordinal_data.sum()" + ).format(col=selected_col, ordinal=ordinal_col)) + else: + ordinal_data = getattr(data.groupby(selected_col)[[ordinal_col]], ordinal_agg)() + code.append("ordinal_data = df.groupby('{col}')[['{ordinal}']].{agg}()".format( + col=selected_col, ordinal=ordinal_col, agg=ordinal_agg + )) hist['ordinal'] = ordinal_data hist = hist.sort_values('ordinal') code.append(( - "ordinal_data = df.groupby('{col}')[['{ordinal}']].{agg}()\n" "chart['ordinal'] = ordinal_data\n" "chart = chart.sort_values('ordinal')" ).format(col=selected_col, ordinal=ordinal_col, agg=ordinal_agg)) @@ -1233,13 +1290,16 @@ def handle_top(df, top): return_data = f.format_lists(hist) return_data['top'] = top elif data_type == 'categories': - hist = data.groupby(category_col)[[selected_col]].agg(['count', category_agg]) + aggs = ['count', 'sum' if category_agg == 'pctsum' else category_agg] + hist = data.groupby(category_col)[[selected_col]].agg(aggs) hist.columns = hist.columns.droplevel(0) hist.columns = ['count', 'data'] - code.append( - "chart = data.groupby('{cat}')[['{col}']].agg(['count', '{agg}'])".format( - cat=category_col, col=selected_col, agg=category_agg) - ) + code.append("chart = data.groupby('{cat}')[['{col}']].agg(['{aggs}'])".format( + cat=category_col, col=selected_col, aggs="', '".join(aggs) + )) + if category_agg == 'pctsum': + hist['data'] = hist['data'] / hist['data'].sum() + code.append('chart.loc[:, -1] = chart[chart.columns[-1]] / chart[chart.columns[-1]].sum()') hist.index.name = 'labels' hist = hist.reset_index() hist, top = handle_top(hist, get_int_arg(request, 'top')) @@ -1580,7 +1640,7 @@ def value_as_str(value): ctxt_vars = global_state.get_context_variables(data_id) or {} ctxt_vars = [dict(name=k, value=value_as_str(v)) for k, v in ctxt_vars.items()] curr_settings = global_state.get_settings(data_id) or {} - curr_settings = {k: v for k, v in curr_settings.items() if k in ['query', 'columnFilters']} + curr_settings = {k: v for k, v in curr_settings.items() if k in ['query', 'columnFilters', 'outlierFilters']} return jsonify(contextVars=ctxt_vars, success=True, **curr_settings) except BaseException as e: return jsonify(error=str(e), traceback=str(traceback.format_exc())) diff --git a/package.json b/package.json index 5cc5d55f..cc3eb0e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dtale", - "version": "1.8.6", + "version": "1.8.7", "description": "Visualizer for Pandas Data Structures", "main": "main.js", "directories": { @@ -121,6 +121,7 @@ "chartjs-plugin-zoom": "0.7.5", "chroma-js": "2.1.0", "create-react-class": "15.6.3", + "currency-symbol-map": "4.0.4", "dom-helpers": "5.1.3", "es6-object-assign": "1.1.0", "es6-promise": "4.2.8", diff --git a/setup.py b/setup.py index ad23c527..49caebd2 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ def run_tests(self): setup( name="dtale", - version="1.8.6", + version="1.8.7", author="MAN Alpha Technology", author_email="ManAlphaTech@man.com", description="Web Client for Visualizing Pandas Objects", @@ -107,6 +107,7 @@ def run_tests(self): "static/dash/*", "static/css/*", "static/fonts/*", + "static/images/*", "static/images/**/*", "static/maps/*", "templates/**/*", diff --git a/static/__tests__/dtale/DataViewer-base-test.jsx b/static/__tests__/dtale/DataViewer-base-test.jsx index c7f809fb..5427c8ac 100644 --- a/static/__tests__/dtale/DataViewer-base-test.jsx +++ b/static/__tests__/dtale/DataViewer-base-test.jsx @@ -13,33 +13,7 @@ import { buildInnerHTML, clickMainMenuButton, withGlobalJquery } from "../test-u const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); -const COL_PROPS = [ - { - locked: true, - width: 70, - name: "dtale_index", - dtype: "int64", - visible: true, - }, - { locked: false, width: 20, name: "col1", dtype: "int64", visible: true }, - { - locked: false, - width: 20, - name: "col2", - dtype: "float64", - visible: true, - min: 2.5, - max: 5.5, - }, - { locked: false, width: 20, name: "col3", dtype: "object", visible: true }, - { - locked: false, - width: 20, - name: "col4", - dtype: "datetime64[ns]", - visible: true, - }, -]; +const COL_PROPS = _.map(reduxUtils.DATA.columns, (c, i) => _.assignIn({ width: i == 0 ? 70 : 20, locked: i == 0 }, c)); describe("DataViewer tests", () => { beforeAll(() => { diff --git a/static/__tests__/dtale/DataViewer-describe-test.jsx b/static/__tests__/dtale/DataViewer-describe-test.jsx index 1ff0f611..9cd8bc76 100644 --- a/static/__tests__/dtale/DataViewer-describe-test.jsx +++ b/static/__tests__/dtale/DataViewer-describe-test.jsx @@ -70,6 +70,7 @@ describe("DataViewer tests", () => { const { DataViewer } = require("../../dtale/DataViewer"); const Describe = require("../../popups/Describe").ReactDescribe; const DtypesGrid = require("../../popups/describe/DtypesGrid").DtypesGrid; + const Details = require("../../popups/describe/Details").Details; const store = reduxUtils.createDtaleStore(); buildInnerHTML({ settings: "" }, store); @@ -94,100 +95,125 @@ describe("DataViewer tests", () => { clickMainMenuButton(result, "Describe"); setTimeout(() => { result.update(); - let dtypesGrid = result.find(DtypesGrid).first(); - t.equal(dtypesGrid.find("div[role='row']").length, 5, "should render dtypes"); - - dtypesGrid - .find("div[role='columnheader']") - .first() - .simulate("click"); - dtypesGrid = result.find(DtypesGrid).first(); - t.equal( - dtypesGrid - .find("div.headerCell") - .first() - .find("svg.ReactVirtualized__Table__sortableHeaderIcon--ASC").length, - 1, - "should sort col1 ASC" - ); - dtypesGrid - .find("div[role='columnheader']") - .first() - .simulate("click"); - dtypesGrid = result.find(DtypesGrid).first(); - t.equal( - dtypesGrid - .find("div.headerCell") - .first() - .find("svg.ReactVirtualized__Table__sortableHeaderIcon--DESC").length, - 1, - "should sort col1 DESC" - ); - dtypesGrid - .find("div[role='columnheader']") - .first() - .simulate("click"); - dtypesGrid = result.find(DtypesGrid).first(); - t.equal( - dtypesGrid - .find("div.headerCell") - .first() - .find("svg.ReactVirtualized__Table__sortableHeaderIcon").length, - 0, - "should remove col1 sort" - ); - dtypesGrid - .find("div.headerCell") + let details = result.find(Details).first(); + details + .find("div.row") .at(2) - .find("input") - .first() - .simulate("change", { target: { value: "1" } }); - dtypesGrid = result.find(DtypesGrid).first(); - t.equal(dtypesGrid.find("div[role='row']").length, 2, "should render filtered dtypes"); - - dtypesGrid - .find("div[title='col1']") - .first() + .find("button") + .last() .simulate("click"); setTimeout(() => { result.update(); + details = result.find(Details).first(); t.equal( - result - .find(Describe) - .first() - .find("h1") + details + .find("div.row") + .at(3) + .find("span.font-weight-bold") .first() .text(), - "col1", - "should describe col1" + "3 Outliers Found (top 100):" ); - - dtypesGrid = result.find(DtypesGrid).first(); - dtypesGrid - .find("div.headerCell") - .at(1) - .find("i.ico-check-box") - .simulate("click"); - dtypesGrid - .find("div.headerCell") - .at(1) - .find("i.ico-check-box") - .simulate("click"); - dtypesGrid - .find("i.ico-check-box") - .last() + details + .find("div.row") + .at(3) + .find("a") .simulate("click"); - result - .find("div.modal-footer") - .first() - .find("button") - .first() - .simulate("click"); - expect($.post.mock.calls[0][0]).toBe("/dtale/update-visibility/1"); - $.post.mock.calls[0][2](); // execute callback - result.update(); - expect($.post.mock.calls[0][1].visibility).toBe('{"col1":false,"col2":true,"col3":true,"col4":true}'); - done(); + setTimeout(() => { + let dtypesGrid = result.find(DtypesGrid).first(); + t.equal(dtypesGrid.find("div[role='row']").length, 5, "should render dtypes"); + + dtypesGrid + .find("div[role='columnheader']") + .first() + .simulate("click"); + dtypesGrid = result.find(DtypesGrid).first(); + t.equal( + dtypesGrid + .find("div.headerCell") + .first() + .find("svg.ReactVirtualized__Table__sortableHeaderIcon--ASC").length, + 1, + "should sort col1 ASC" + ); + dtypesGrid + .find("div[role='columnheader']") + .first() + .simulate("click"); + dtypesGrid = result.find(DtypesGrid).first(); + t.equal( + dtypesGrid + .find("div.headerCell") + .first() + .find("svg.ReactVirtualized__Table__sortableHeaderIcon--DESC").length, + 1, + "should sort col1 DESC" + ); + dtypesGrid + .find("div[role='columnheader']") + .first() + .simulate("click"); + dtypesGrid = result.find(DtypesGrid).first(); + t.equal( + dtypesGrid + .find("div.headerCell") + .first() + .find("svg.ReactVirtualized__Table__sortableHeaderIcon").length, + 0, + "should remove col1 sort" + ); + dtypesGrid + .find("div.headerCell") + .at(2) + .find("input") + .first() + .simulate("change", { target: { value: "1" } }); + dtypesGrid = result.find(DtypesGrid).first(); + t.equal(dtypesGrid.find("div[role='row']").length, 2, "should render filtered dtypes"); + dtypesGrid + .find("div[title='col1']") + .first() + .simulate("click"); + setTimeout(() => { + result.update(); + t.equal( + result + .find(Describe) + .first() + .find("h1") + .first() + .text(), + "col1", + "should describe col1" + ); + dtypesGrid = result.find(DtypesGrid).first(); + dtypesGrid + .find("div.headerCell") + .at(1) + .find("i.ico-check-box") + .simulate("click"); + dtypesGrid + .find("div.headerCell") + .at(1) + .find("i.ico-check-box") + .simulate("click"); + dtypesGrid + .find("i.ico-check-box") + .last() + .simulate("click"); + result + .find("div.modal-footer") + .first() + .find("button") + .first() + .simulate("click"); + expect($.post.mock.calls[0][0]).toBe("/dtale/update-visibility/1"); + $.post.mock.calls[0][2](); // execute callback + result.update(); + expect($.post.mock.calls[0][1].visibility).toBe('{"col1":false,"col2":true,"col3":true,"col4":true}'); + done(); + }, 400); + }, 400); }, 400); }, 400); }, 400); diff --git a/static/__tests__/dtale/DataViewer-dtype-highlighting-test.jsx b/static/__tests__/dtale/DataViewer-dtype-highlighting-test.jsx new file mode 100644 index 00000000..5cd0918f --- /dev/null +++ b/static/__tests__/dtale/DataViewer-dtype-highlighting-test.jsx @@ -0,0 +1,71 @@ +import { mount } from "enzyme"; +import _ from "lodash"; +import React from "react"; +import { Provider } from "react-redux"; + +import mockPopsicle from "../MockPopsicle"; +import * as t from "../jest-assertions"; +import reduxUtils from "../redux-test-utils"; +import { buildInnerHTML, clickMainMenuButton, withGlobalJquery } from "../test-utils"; + +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); + +describe("DataViewer heatmap tests", () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + value: 500, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + value: 500, + }); + + const mockBuildLibs = withGlobalJquery(() => + mockPopsicle.mock(url => { + const { urlFetcher } = require("../redux-test-utils").default; + return urlFetcher(url); + }) + ); + jest.mock("popsicle", () => mockBuildLibs); + }); + + afterAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + }); + + test("DataViewer: dtype highlighting", done => { + const { DataViewer, ReactDataViewer } = require("../../dtale/DataViewer"); + + const store = reduxUtils.createDtaleStore(); + buildInnerHTML({ settings: "", hideShutdown: "True", processes: 2 }, store); + const result = mount( + + + , + { attachTo: document.getElementById("content") } + ); + + setTimeout(() => { + result.update(); + clickMainMenuButton(result, "Highlight Dtypes"); + result.update(); + let dv = result.find(ReactDataViewer).instance().state; + t.deepEqual(_.pick(dv, ["dtypeHighlighting", "heatMapMode"]), { + dtypeHighlighting: true, + heatMapMode: null, + }); + clickMainMenuButton(result, "Highlight Dtypes"); + result.update(); + dv = result.find(ReactDataViewer).instance().state; + t.deepEqual(_.pick(dv, ["dtypeHighlighting", "heatMapMode"]), { + dtypeHighlighting: false, + heatMapMode: null, + }); + + done(); + }, 600); + }); +}); diff --git a/static/__tests__/dtale/DataViewer-heatmap-test.jsx b/static/__tests__/dtale/DataViewer-heatmap-test.jsx index a2e758e8..80ca8ec7 100644 --- a/static/__tests__/dtale/DataViewer-heatmap-test.jsx +++ b/static/__tests__/dtale/DataViewer-heatmap-test.jsx @@ -6,7 +6,7 @@ import { Provider } from "react-redux"; import mockPopsicle from "../MockPopsicle"; import * as t from "../jest-assertions"; import reduxUtils from "../redux-test-utils"; -import { buildInnerHTML, clickMainMenuButton, withGlobalJquery } from "../test-utils"; +import { buildInnerHTML, findMainMenuButton, withGlobalJquery } from "../test-utils"; const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); @@ -50,7 +50,11 @@ describe("DataViewer heatmap tests", () => { setTimeout(() => { result.update(); - clickMainMenuButton(result, "Heat Map"); + let heatMapBtn = findMainMenuButton(result, "By Col", "div.btn-group"); + heatMapBtn + .find("button") + .first() + .simulate("click"); result.update(); let dv = result.find(ReactDataViewer).instance().state; t.ok( @@ -67,10 +71,27 @@ describe("DataViewer heatmap tests", () => { .find(ReactDataViewer) .find("div.headerCell") .map(hc => hc.text()), - ["col2"], - "should render float column headers" + ["col1", "col2"], + "should render int/float column headers" ); - clickMainMenuButton(result, "Heat Map"); + heatMapBtn = findMainMenuButton(result, "By Col", "div.btn-group"); + heatMapBtn + .find("button") + .last() + .simulate("click"); + t.deepEqual( + result + .find(ReactDataViewer) + .find("div.headerCell") + .map(hc => hc.text()), + ["col1", "col2"], + "should render int/float column headers" + ); + heatMapBtn = findMainMenuButton(result, "By Col", "div.btn-group"); + heatMapBtn + .find("button") + .last() + .simulate("click"); dv = result.find(ReactDataViewer).instance().state; t.ok(_.filter(dv.columns, { visible: true }).length, 5, "should turn all columns back on"); t.ok( diff --git a/static/__tests__/dtale/gridUtils-test.jsx b/static/__tests__/dtale/gridUtils-test.jsx index 0587fe90..08bad018 100644 --- a/static/__tests__/dtale/gridUtils-test.jsx +++ b/static/__tests__/dtale/gridUtils-test.jsx @@ -1,13 +1,13 @@ -import { buildDataProps } from "../../dtale/gridUtils"; +import { exports as gu } from "../../dtale/gridUtils"; import * as t from "../jest-assertions"; describe("gridUtils tests", () => { test("gridUtils: testing buildDataProps", done => { - let dataProps = buildDataProps({ name: "foo", dtype: "foo" }, "bar", { + let dataProps = gu.buildDataProps({ name: "foo", dtype: "foo" }, "bar", { columnFormats: {}, }); t.deepEqual({ raw: "bar", view: "bar", style: {} }, dataProps, "should build data props around unknown dtype"); - dataProps = buildDataProps({ name: "foo", dtype: "foo" }, undefined, { + dataProps = gu.buildDataProps({ name: "foo", dtype: "foo" }, undefined, { columnFormats: {}, }); t.equal(dataProps.view, "", "should handle undefined"); diff --git a/static/__tests__/iframe/DataViewer-base-test.jsx b/static/__tests__/iframe/DataViewer-base-test.jsx index 7f4ec0a8..24840ac2 100644 --- a/static/__tests__/iframe/DataViewer-base-test.jsx +++ b/static/__tests__/iframe/DataViewer-base-test.jsx @@ -15,33 +15,7 @@ import { clickColMenuButton, clickColMenuSubButton } from "./iframe-utils"; const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); -const COL_PROPS = [ - { - locked: true, - width: 70, - name: "dtale_index", - dtype: "int64", - visible: true, - }, - { locked: false, width: 20, name: "col1", dtype: "int64", visible: true }, - { - locked: false, - width: 20, - name: "col2", - dtype: "float64", - visible: true, - min: 2.5, - max: 5.5, - }, - { locked: false, width: 20, name: "col3", dtype: "object", visible: true }, - { - locked: false, - width: 20, - name: "col4", - dtype: "datetime64[ns]", - visible: true, - }, -]; +const COL_PROPS = _.map(reduxUtils.DATA.columns, (c, i) => _.assignIn({ width: i == 0 ? 70 : 20, locked: i == 0 }, c)); class MockDateInput extends React.Component { render() { @@ -178,7 +152,7 @@ describe("DataViewer iframe tests", () => { ); t.deepEqual( colMenu.find("ul li span.font-weight-bold").map(s => s.text()), - ["Lock", "Hide", "Describe", "Column Analysis", "Formats"], + ["Lock", "Hide", "Delete", "Describe", "Column Analysis", "Formats"], "Should render column menu options" ); clickColMenuSubButton(result, "Asc"); diff --git a/static/__tests__/iframe/DataViewer-delete-test.jsx b/static/__tests__/iframe/DataViewer-delete-test.jsx new file mode 100644 index 00000000..72398395 --- /dev/null +++ b/static/__tests__/iframe/DataViewer-delete-test.jsx @@ -0,0 +1,86 @@ +import { mount } from "enzyme"; +import $ from "jquery"; +import React from "react"; +import { Provider } from "react-redux"; + +import mockPopsicle from "../MockPopsicle"; +import * as t from "../jest-assertions"; +import reduxUtils from "../redux-test-utils"; +import { buildInnerHTML, withGlobalJquery } from "../test-utils"; +import { clickColMenuButton } from "./iframe-utils"; + +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); + +describe("DataViewer iframe tests", () => { + const { post } = $; + + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + value: 500, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + value: 500, + }); + + const mockBuildLibs = withGlobalJquery(() => + mockPopsicle.mock(url => { + const { urlFetcher } = require("../redux-test-utils").default; + return urlFetcher(url); + }) + ); + + const mockChartUtils = withGlobalJquery(() => (ctx, cfg) => { + const chartCfg = { ctx, cfg, data: cfg.data, destroyed: false }; + chartCfg.destroy = () => (chartCfg.destroyed = true); + chartCfg.getElementsAtXAxis = _evt => [{ _index: 0 }]; + return chartCfg; + }); + + jest.mock("popsicle", () => mockBuildLibs); + jest.mock("chart.js", () => mockChartUtils); + jest.mock("chartjs-plugin-zoom", () => ({})); + jest.mock("chartjs-chart-box-and-violin-plot/build/Chart.BoxPlot.js", () => ({})); + }); + + afterAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + $.post = post; + }); + + test("DataViewer: hiding a column", done => { + const { DataViewer } = require("../../dtale/DataViewer"); + + const store = reduxUtils.createDtaleStore(); + buildInnerHTML({ settings: "", iframe: "True" }, store); + const result = mount( + + + , + { + attachTo: document.getElementById("content"), + } + ); + + setTimeout(() => { + result.update(); + result + .find(".main-grid div.headerCell div") + .last() + .simulate("click"); + clickColMenuButton(result, "Delete"); + setTimeout(() => { + result.update(); + t.deepEqual( + result.find(".main-grid div.headerCell").map(hc => hc.text()), + ["col1", "col2", "col3"], + "should render column headers" + ); + done(); + }, 400); + }, 600); + }); +}); diff --git a/static/__tests__/popups/Instances-test.jsx b/static/__tests__/popups/Instances-test.jsx index 18202068..0b94efeb 100644 --- a/static/__tests__/popups/Instances-test.jsx +++ b/static/__tests__/popups/Instances-test.jsx @@ -199,7 +199,7 @@ describe("Instances tests", () => { assignSpy.mockRestore(); global.window = origWindow; result - .find(".ico-remove-circle") + .find(".ico-delete") .first() .simulate("click"); setTimeout(() => { diff --git a/static/__tests__/popups/formats/NumericFormatting-test.jsx b/static/__tests__/popups/formats/NumericFormatting-test.jsx index 0dce655e..ff48964c 100644 --- a/static/__tests__/popups/formats/NumericFormatting-test.jsx +++ b/static/__tests__/popups/formats/NumericFormatting-test.jsx @@ -7,7 +7,9 @@ import * as t from "../../jest-assertions"; describe("NumericFormatting tests", () => { test("NumericFormatting test", done => { - const columnFormats = { col1: { fmt: "0,000.000", style: null } }; + const columnFormats = { + col1: { fmt: "0,000.000", style: { currency: "USD" } }, + }; const result = mount(); const state = { precision: 3, @@ -17,6 +19,7 @@ describe("NumericFormatting tests", () => { bps: false, redNegs: false, fmt: "0,000.000", + currency: { value: "USD", label: "USD ($)" }, }; t.deepEqual(result.state(), state, "should parse formatting"); done(); diff --git a/static/__tests__/redux-test-utils.jsx b/static/__tests__/redux-test-utils.jsx index 131b95ec..a4bc3201 100644 --- a/static/__tests__/redux-test-utils.jsx +++ b/static/__tests__/redux-test-utils.jsx @@ -22,7 +22,7 @@ const DATA = { ], columns: [ { name: "dtale_index", dtype: "int64", visible: true }, - { name: "col1", dtype: "int64", visible: true }, + { name: "col1", dtype: "int64", min: 2, max: 5, visible: true }, { name: "col2", dtype: "float64", min: 2.5, max: 5.5, visible: true }, { name: "col3", dtype: "object", visible: true }, { name: "col4", dtype: "datetime64[ns]", visible: true }, @@ -179,7 +179,10 @@ function urlFetcher(url) { return chartsData; } else if ( _.find( - ["/dtale/update-visibility", "/dtale/update-settings", "/dtale/update-locked", "/dtale/update-column-position"], + _.concat( + ["/dtale/update-visibility", "/dtale/update-settings", "/dtale/update-locked", "/dtale/update-column-position"], + ["/dtale/delete-col"] + ), prefix => _.startsWith(url, prefix) ) ) { @@ -224,6 +227,15 @@ function urlFetcher(url) { success: true, columnFilters: { foo: { query: "foo == 1", value: [1] } }, }; + } else if (_.startsWith(url, "/dtale/outliers")) { + return { + success: true, + outliers: [1, 2, 3], + query: "((a < 1) or ( a > 4))", + code: "test code", + queryApplied: true, + top: true, + }; } return {}; } @@ -235,4 +247,5 @@ function createDtaleStore() { export default { urlFetcher, createDtaleStore, + DATA, }; diff --git a/static/actions/url-utils.js b/static/actions/url-utils.js index 61772666..4597aa55 100644 --- a/static/actions/url-utils.js +++ b/static/actions/url-utils.js @@ -42,4 +42,8 @@ function dtypesUrl(dataId) { return `/dtale/dtypes/${dataId}`; } -export { buildURLParams, buildURLString, buildURL, dtypesUrl }; +function saveColFilterUrl(dataId, column) { + return `/dtale/save-column-filter/${dataId}/${column}`; +} + +export { buildURLParams, buildURLString, buildURL, dtypesUrl, saveColFilterUrl }; diff --git a/static/chartUtils.jsx b/static/chartUtils.jsx index ff4ce7cd..4baed473 100644 --- a/static/chartUtils.jsx +++ b/static/chartUtils.jsx @@ -7,7 +7,7 @@ import _ from "lodash"; import moment from "moment"; import { buildRGBA } from "./colors"; -import { isDateCol } from "./dtale/gridUtils"; +import { exports as gu } from "./dtale/gridUtils"; import { formatScatterPoints, getScatterMax, getScatterMin } from "./scatterChartUtils"; // needed to add these parameters because Chart.Zoom.js causes Chart.js to look for them @@ -268,7 +268,7 @@ function createPieCfg({ data, min, max }, { columns, x, y, additionalOptions, co cfg.type = "pie"; delete cfg.options.scales; delete cfg.options.tooltips; - if (isDateCol(_.find(columns, { name: x }).dtype)) { + if (gu.isDateCol(_.find(columns, { name: x }).dtype)) { cfg.data.labels = _.map(cfg.data.labels, l => moment(new Date(l)).format("YYYY-MM-DD")); } return configHandler(cfg); diff --git a/static/dtale/DataViewer.jsx b/static/dtale/DataViewer.jsx index ba2f72a5..fc7b5c77 100644 --- a/static/dtale/DataViewer.jsx +++ b/static/dtale/DataViewer.jsx @@ -17,7 +17,7 @@ import { DataViewerInfo } from "./DataViewerInfo"; import { DataViewerMenu } from "./DataViewerMenu"; import { Header } from "./Header"; import { MeasureText } from "./MeasureText"; -import * as gu from "./gridUtils"; +import { exports as gu } from "./gridUtils"; import { ColumnMenu } from "./iframe/ColumnMenu"; require("./DataViewer.css"); @@ -36,6 +36,9 @@ class ReactDataViewer extends React.Component { propagateState(state, callback = _.noop) { if (_.has(state, "columns") && !_.get(state, "formattingUpdate", false)) { state.columns = _.map(state.columns, c => _.assignIn(c, { width: gu.calcColWidth(c, this.state) })); + const totalRange = gu.getTotalRange(state.columns); + state.min = totalRange.min; + state.max = totalRange.max; } if (_.get(state, "refresh", false)) { this.getData(this.state.ids, true); @@ -50,7 +53,7 @@ class ReactDataViewer extends React.Component { } componentDidUpdate(_prevProps, prevState) { - const gridState = ["sortInfo", "query", "columnFilters"]; + const gridState = ["sortInfo", "query", "columnFilters", "outlierFilters"]; const refresh = !_.isEqual(_.pick(this.state, gridState), _.pick(prevState, gridState)); if (!this.state.loading && prevState.loading) { if (!_.isEmpty(this.state.loadQueue)) { @@ -127,7 +130,7 @@ class ReactDataViewer extends React.Component { }); return; } - const newState = { + let newState = { rowCount: data.total + 1, data: _.assignIn(savedData, formattedData), error: null, @@ -148,12 +151,14 @@ class ReactDataViewer extends React.Component { c ) ); + newState = _.assignIn(newState, gu.getTotalRange(newState.columns)); } else { const newCols = _.map( _.filter(data.columns, ({ name }) => !_.find(columns, { name })), c => _.assignIn({ locked: false, width: gu.calcColWidth(c, newState) }, c) ); newState.columns = _.concat(columns, newCols); + newState = _.assignIn(newState, gu.getTotalRange(newState.columns)); } let callback = _.noop; if (refresh) { @@ -190,9 +195,12 @@ class ReactDataViewer extends React.Component { const rec = _.get(this.state, ["data", rowIndex - 1, colCfg.name], {}); value = rec.view; valueStyle = _.get(rec, "style", {}); - if (this.state.heatMapMode) { + if (this.state.heatMapMode === "col") { valueStyle = _.assignIn(gu.heatMapBackground(rec, colCfg), valueStyle); } + if (this.state.heatMapMode === "all" && colCfg.name !== gu.IDX) { + valueStyle = _.assignIn(gu.heatMapBackground(rec, this.state), valueStyle); + } if (this.state.dtypeHighlighting) { valueStyle = _.assignIn(gu.dtypeHighlighting(colCfg), valueStyle); } @@ -265,7 +273,7 @@ class ReactDataViewer extends React.Component { /> diff --git a/static/dtale/DataViewerInfo.jsx b/static/dtale/DataViewerInfo.jsx index 29b39383..df0467f8 100644 --- a/static/dtale/DataViewerInfo.jsx +++ b/static/dtale/DataViewerInfo.jsx @@ -6,7 +6,7 @@ import { connect } from "react-redux"; import { RemovableError } from "../RemovableError"; import menuUtils from "../menuUtils"; -import * as gu from "./gridUtils"; +import { exports as gu } from "./gridUtils"; import serverState from "./serverStateManagement"; function buildMenuHandler(prop, propagateState) { @@ -24,6 +24,29 @@ function buildMenuHandler(prop, propagateState) { } ); } + +function displayQueries(props, prop) { + const queries = props[prop]; + return _.map(queries, (cfg, col) => { + const dropColFilter = () => { + const updatedSettings = { + [prop]: _.pickBy(queries, (_, k) => k !== col), + }; + serverState.updateSettings(updatedSettings, props.dataId, () => props.propagateState(updatedSettings)); + }; + return ( +
  • + + + + {cfg.query} +
  • + ); + }); +} + class ReactDataViewerInfo extends React.Component { constructor(props) { super(props); @@ -96,8 +119,8 @@ class ReactDataViewerInfo extends React.Component { } renderFilter() { - const { query, columnFilters, dataId, propagateState } = this.props; - if (_.isEmpty(query) && _.isEmpty(columnFilters)) { + const { query, columnFilters, outlierFilters, dataId, propagateState } = this.props; + if (gu.noFilters(this.props)) { return null; } const label = ( @@ -105,12 +128,16 @@ class ReactDataViewerInfo extends React.Component { Filter: ); - const filterSegs = _.map(columnFilters, "query"); + const filterSegs = _.concat(_.map(columnFilters, "query"), _.map(outlierFilters, "query")); if (query) { filterSegs.push(query); } const clearFilter = () => { - const settingsUpdates = { query: "", columnFilters: {} }; + const settingsUpdates = { + query: "", + columnFilters: {}, + outlierFilters: {}, + }; serverState.updateSettings(settingsUpdates, dataId, () => propagateState(settingsUpdates)); }; const clearAll = ( @@ -139,24 +166,8 @@ class ReactDataViewerInfo extends React.Component { hidden={this.state.menuOpen !== "filter"} style={{ minWidth: "8em", top: "1em" }}>
      - {_.map(columnFilters, (cfg, col) => { - const dropColFilter = () => { - const updatedSettings = { - columnFilters: _.pickBy(columnFilters, (_, k) => k !== col), - }; - serverState.updateSettings(updatedSettings, dataId, () => propagateState(updatedSettings)); - }; - return ( -
    • - - - - {cfg.query} -
    • - ); - })} + {displayQueries(this.props, "columnFilters")} + {displayQueries(this.props, "outlierFilters")} {query && (
    • @@ -255,10 +266,7 @@ class ReactDataViewerInfo extends React.Component { ); } - const hideSort = _.isEmpty(this.props.sortInfo); - const hideFilter = _.isEmpty(this.props.query) && _.isEmpty(this.props.columnFilters); - const hideHidden = gu.noHidden(this.props.columns); - if (hideSort && hideFilter && hideHidden) { + if (gu.hasNoInfo(this.props)) { return errorMarkup; } return [ @@ -281,6 +289,7 @@ ReactDataViewerInfo.propTypes = { columns: PropTypes.arrayOf(PropTypes.object), dataId: PropTypes.string, columnFilters: PropTypes.object, + outlierFilters: PropTypes.object, }; const ReduxDataViewerInfo = connect(({ dataId }) => ({ dataId }))(ReactDataViewerInfo); diff --git a/static/dtale/DataViewerMenu.jsx b/static/dtale/DataViewerMenu.jsx index b8ecd221..dd6d0757 100644 --- a/static/dtale/DataViewerMenu.jsx +++ b/static/dtale/DataViewerMenu.jsx @@ -25,15 +25,15 @@ class ReactDataViewerMenu extends React.Component { this.props.propagateState({ columns: _.map(this.props.columns, c => _.assignIn({}, c)), }); - const toggleHeatMap = () => + const toggleHeatMap = mode => () => this.props.propagateState({ - heatMapMode: !this.props.heatMapMode, + heatMapMode: this.props.heatMapMode == mode ? null : mode, dtypeHighlighting: false, }); const toggleDtypeHighlighting = () => this.props.propagateState({ dtypeHighlighting: !this.props.dtypeHighlighting, - heatMapMode: false, + heatMapMode: null, }); const exportFile = tsv => () => window.open(`/dtale/data-export/${dataId}?tsv=${tsv}&_id=${new Date().getTime()}`, "_blank"); @@ -100,13 +100,31 @@ class ReactDataViewerMenu extends React.Component {
      {Descriptions.charts}
    • -
    • +
    • - + + + {"Heat Map"} + +
      + {_.map( + [ + ["By Col", "col"], + ["Overall", "all"], + ], + ([label, mode]) => ( + + ) + )} +
      {Descriptions.heatmap}
    • @@ -114,9 +132,7 @@ class ReactDataViewerMenu extends React.Component { @@ -124,7 +140,7 @@ class ReactDataViewerMenu extends React.Component {
    • -
    • -
    • +
    • Export -
      +
      {_.map( [ ["CSV", "false"], @@ -239,7 +255,7 @@ ReactDataViewerMenu.propTypes = { menuOpen: PropTypes.bool, propagateState: PropTypes.func, openChart: PropTypes.func, - heatMapMode: PropTypes.bool, + heatMapMode: PropTypes.string, dtypeHighlighting: PropTypes.bool, hideShutdown: PropTypes.bool, dataId: PropTypes.string.isRequired, diff --git a/static/dtale/Header.jsx b/static/dtale/Header.jsx index d8522d71..f8784f42 100644 --- a/static/dtale/Header.jsx +++ b/static/dtale/Header.jsx @@ -5,7 +5,7 @@ import { connect } from "react-redux"; import actions from "../actions/dtale"; import menuUtils from "../menuUtils"; -import * as gu from "./gridUtils"; +import { exports as gu } from "./gridUtils"; import { ignoreMenuClicks } from "./iframe/ColumnMenu"; const SORT_CHARS = { diff --git a/static/dtale/gridUtils.jsx b/static/dtale/gridUtils.jsx index 39d24de5..04d4e2de 100644 --- a/static/dtale/gridUtils.jsx +++ b/static/dtale/gridUtils.jsx @@ -6,42 +6,33 @@ import numeral from "numeral"; import { measureText } from "./MeasureText"; import menuFuncs from "./dataViewerMenuUtils"; -const IDX = "dtale_index"; +const EXPORTS = {}; + +EXPORTS.IDX = "dtale_index"; const DEFAULT_COL_WIDTH = 70; numeral.nullFormat(""); -function isStringCol(dtype) { - return _.some(["string", "object", "unicode"], s => _.startsWith(dtype, s)); -} - -function isIntCol(dtype) { - return _.startsWith(dtype, "int"); -} - -function isFloatCol(dtype) { - return _.startsWith(dtype, "float"); -} +EXPORTS.isStringCol = dtype => _.some(["string", "object", "unicode"], s => _.startsWith(dtype, s)); +EXPORTS.isIntCol = dtype => _.startsWith(dtype, "int"); +EXPORTS.isFloatCol = dtype => _.startsWith(dtype, "float"); +EXPORTS.isDateCol = dtype => _.some(["timestamp", "datetime"], s => _.startsWith(dtype, s)); -function isDateCol(dtype) { - return _.some(["timestamp", "datetime"], s => _.startsWith(dtype, s)); -} - -function findColType(dtype) { - if (isStringCol(dtype)) { +EXPORTS.findColType = dtype => { + if (EXPORTS.isStringCol(dtype)) { return "string"; } - if (isIntCol(dtype)) { + if (EXPORTS.isIntCol(dtype)) { return "int"; } - if (isFloatCol(dtype)) { + if (EXPORTS.isFloatCol(dtype)) { return "float"; } - if (isDateCol(dtype)) { + if (EXPORTS.isDateCol(dtype)) { return "date"; } return "unknown"; -} +}; function buildNumeral(val, fmt) { return numeral(val).format(fmt); @@ -50,7 +41,7 @@ function buildNumeral(val, fmt) { function buildValue({ name, dtype }, rawValue, { columnFormats }) { if (!_.isUndefined(rawValue)) { const fmt = _.get(columnFormats, [name, "fmt"]); - switch (findColType((dtype || "").toLowerCase())) { + switch (EXPORTS.findColType((dtype || "").toLowerCase())) { case "float": return buildNumeral(rawValue, fmt || "0.00"); case "int": @@ -65,35 +56,29 @@ function buildValue({ name, dtype }, rawValue, { columnFormats }) { return ""; } -function buildDataProps({ name, dtype }, rawValue, { columnFormats }) { - return { - raw: rawValue, - view: buildValue({ name, dtype }, rawValue, { columnFormats }), - style: menuFuncs.buildStyling( - rawValue, - findColType((dtype || "").toLowerCase()), - _.get(columnFormats, [name, "style"], {}) - ), - }; -} +EXPORTS.buildDataProps = ({ name, dtype }, rawValue, { columnFormats }) => ({ + raw: rawValue, + view: buildValue({ name, dtype }, rawValue, { columnFormats }), + style: menuFuncs.buildStyling( + rawValue, + EXPORTS.findColType((dtype || "").toLowerCase()), + _.get(columnFormats, [name, "style"], {}) + ), +}); function getHeatActive(column) { - return (_.has(column, "min") || column.name === IDX) && column.visible; + return (_.has(column, "min") || column.name === EXPORTS.IDX) && column.visible; } -function getActiveCols({ columns, heatMapMode }) { - return _.filter(columns || [], c => (heatMapMode ? getHeatActive(c) : c.visible)); -} +EXPORTS.getActiveCols = ({ columns, heatMapMode }) => + _.filter(columns || [], c => (heatMapMode ? getHeatActive(c) : c.visible)); -function getCol(index, { columns, heatMapMode }) { - return _.get(getActiveCols({ columns, heatMapMode }), index, {}); -} +EXPORTS.getCol = (index, { columns, heatMapMode }) => _.get(EXPORTS.getActiveCols({ columns, heatMapMode }), index, {}); -function getColWidth(index, { columns, heatMapMode }) { - return _.get(getCol(index, { columns, heatMapMode }), "width", DEFAULT_COL_WIDTH); -} +EXPORTS.getColWidth = (index, { columns, heatMapMode }) => + _.get(EXPORTS.getCol(index, { columns, heatMapMode }), "width", DEFAULT_COL_WIDTH); -function getRanges(array) { +EXPORTS.getRanges = array => { const ranges = []; let rstart, rend; for (let i = 0; i < array.length; i++) { @@ -106,17 +91,17 @@ function getRanges(array) { ranges.push(rstart == rend ? rstart + "" : rstart + "-" + rend); } return ranges; -} +}; -function calcColWidth({ name, dtype }, { data, rowCount, sortInfo }) { +EXPORTS.calcColWidth = ({ name, dtype }, { data, rowCount, sortInfo }) => { let w = DEFAULT_COL_WIDTH; - if (name === IDX) { + if (name === EXPORTS.IDX) { w = measureText(rowCount - 1 + ""); w = w < DEFAULT_COL_WIDTH ? DEFAULT_COL_WIDTH : w; } else { const sortDir = (_.find(sortInfo, ([col, _dir]) => col === name) || [null, null])[1]; const headerWidth = measureText(name) + (_.includes(["ASC", "DESC"], sortDir) ? 10 : 0); - switch (findColType((dtype || "").toLowerCase())) { + switch (EXPORTS.findColType((dtype || "").toLowerCase())) { case "date": { let maxText = _.last(_.sortBy(data, d => _.get(d, [name, "view", "length"], 0))); maxText = _.get(maxText, [name, "view"], "").replace(new RegExp("[0-9]", "g"), "0"); // zero is widest number @@ -139,54 +124,60 @@ function calcColWidth({ name, dtype }, { data, rowCount, sortInfo }) { w = headerWidth > w ? headerWidth : w; } return w; -} +}; -const ROW_HEIGHT = 25; -const HEADER_HEIGHT = 35; +EXPORTS.ROW_HEIGHT = 25; +EXPORTS.HEADER_HEIGHT = 35; -function buildGridStyles(headerHeight = HEADER_HEIGHT) { - return { - style: { border: "1px solid #ddd" }, - styleBottomLeftGrid: { - borderRight: "2px solid #aaa", - backgroundColor: "#f7f7f7", - }, - styleTopLeftGrid: _.assignIn( - { height: headerHeight + 15 }, - { - borderBottom: "2px solid #aaa", - borderRight: "2px solid #aaa", - fontWeight: "bold", - } - ), - styleTopRightGrid: { - height: headerHeight + 15, +EXPORTS.buildGridStyles = (headerHeight = EXPORTS.HEADER_HEIGHT) => ({ + style: { border: "1px solid #ddd" }, + styleBottomLeftGrid: { + borderRight: "2px solid #aaa", + backgroundColor: "#f7f7f7", + }, + styleTopLeftGrid: _.assignIn( + { height: headerHeight + 15 }, + { borderBottom: "2px solid #aaa", + borderRight: "2px solid #aaa", fontWeight: "bold", - }, - enableFixedColumnScroll: true, - enableFixedRowScroll: true, - hideTopRightGridScrollbar: true, - hideBottomLeftGridScrollbar: true, - }; -} + } + ), + styleTopRightGrid: { + height: headerHeight + 15, + borderBottom: "2px solid #aaa", + fontWeight: "bold", + }, + enableFixedColumnScroll: true, + enableFixedRowScroll: true, + hideTopRightGridScrollbar: true, + hideBottomLeftGridScrollbar: true, +}); const heatMap = chroma.scale(["red", "yellow", "green"]).domain([0, 0.5, 1]); -function heatMapBackground({ raw, view }, { min, max }) { +EXPORTS.getTotalRange = columns => { + const activeCols = EXPORTS.getActiveCols({ columns }); + return { + min: _.min(_.map(activeCols, "min")), + max: _.max(_.map(activeCols, "max")), + }; +}; + +EXPORTS.heatMapBackground = ({ raw, view }, { min, max }) => { if (view === "") { return {}; } const factor = min * -1; return { background: heatMap((raw + factor) / (max + factor)) }; -} +}; -function dtypeHighlighting({ name, dtype }) { - if (name === IDX) { +EXPORTS.dtypeHighlighting = ({ name, dtype }) => { + if (name === EXPORTS.IDX) { return {}; } const lowerDtype = (dtype || "").toLowerCase(); - const colType = findColType(lowerDtype); + const colType = EXPORTS.findColType(lowerDtype); if (_.startsWith(lowerDtype, "category")) { return { background: "#E1BEE7" }; } else if (_.startsWith(lowerDtype, "timedelta")) { @@ -203,9 +194,9 @@ function dtypeHighlighting({ name, dtype }) { return { background: "#FFF59D" }; } return {}; -} +}; -const SORT_PROPS = [ +EXPORTS.SORT_PROPS = [ { dir: "ASC", full: { label: "Sort Ascending", icon: "fa fa-sort-down ml-4 mr-4" }, @@ -223,64 +214,48 @@ const SORT_PROPS = [ }, ]; -function buildToggleId(colName) { - return `col-${_.join(_.split(colName, " "), "_")}-toggle`; -} +EXPORTS.buildToggleId = colName => `col-${_.join(_.split(colName, " "), "_")}-toggle`; -function buildState(props) { - return { - ...buildGridStyles(), - columnFormats: _.get(props, "settings.formats", {}), - overscanColumnCount: 0, - overscanRowCount: 5, - rowHeight: ({ index }) => (index == 0 ? HEADER_HEIGHT : ROW_HEIGHT), - rowCount: 0, - fixedColumnCount: _.size(_.concat(_.get(props, "settings.locked", []), [IDX])), - fixedRowCount: 1, - data: {}, - loading: false, - ids: [0, 55], - loadQueue: [], - columns: [], - query: _.get(props, "settings.query", ""), - columnFilters: _.get(props, "settings.columnFilters", {}), - sortInfo: _.get(props, "settings.sort", []), - selectedCols: [], - menuOpen: false, - formattingOpen: false, - triggerResize: false, - heatMapMode: false, - dtypeHighlighting: false, - }; -} +EXPORTS.buildState = props => ({ + ...EXPORTS.buildGridStyles(), + columnFormats: _.get(props, "settings.formats", {}), + overscanColumnCount: 0, + overscanRowCount: 5, + rowHeight: ({ index }) => (index == 0 ? EXPORTS.HEADER_HEIGHT : EXPORTS.ROW_HEIGHT), + rowCount: 0, + fixedColumnCount: _.size(_.concat(_.get(props, "settings.locked", []), [EXPORTS.IDX])), + fixedRowCount: 1, + data: {}, + loading: false, + ids: [0, 55], + loadQueue: [], + columns: [], + query: _.get(props, "settings.query", ""), + columnFilters: _.get(props, "settings.columnFilters", {}), + outlierFilters: _.get(props, "settings.outlierFilters", {}), + sortInfo: _.get(props, "settings.sort", []), + selectedCols: [], + menuOpen: false, + formattingOpen: false, + triggerResize: false, + heatMapMode: null, + dtypeHighlighting: false, +}); -function noHidden(columns) { - return !_.some(columns, { visible: false }); -} +EXPORTS.noHidden = columns => !_.some(columns, { visible: false }); -function hasNoInfo({ sortInfo, query, columns, columnFilters }) { - return _.isEmpty(sortInfo) && _.isEmpty(query) && noHidden(columns) && _.isEmpty(columnFilters); -} +EXPORTS.noFilters = ({ query, columnFilters, outlierFilters }) => + _.isEmpty(query) && _.isEmpty(columnFilters) && _.isEmpty(outlierFilters); -export { - buildDataProps, - getActiveCols, - getCol, - getColWidth, - getRanges, - calcColWidth, - findColType, - isDateCol, - isStringCol, - IDX, - buildGridStyles, - ROW_HEIGHT, - HEADER_HEIGHT, - heatMapBackground, - dtypeHighlighting, - SORT_PROPS, - buildToggleId, - buildState, - noHidden, - hasNoInfo, +EXPORTS.hasNoInfo = ({ sortInfo, query, columns, columnFilters, outlierFilters }) => { + const hideSort = _.isEmpty(sortInfo); + const hideFilter = EXPORTS.noFilters({ + query, + columnFilters, + outlierFilters, + }); + const hideHidden = EXPORTS.noHidden(columns); + return hideSort && hideFilter && hideHidden; }; + +export { EXPORTS as exports }; diff --git a/static/dtale/iframe/ColumnMenu.jsx b/static/dtale/iframe/ColumnMenu.jsx index 479218a8..29ff465c 100644 --- a/static/dtale/iframe/ColumnMenu.jsx +++ b/static/dtale/iframe/ColumnMenu.jsx @@ -9,11 +9,12 @@ import { openChart } from "../../actions/charts"; import { buildURLString } from "../../actions/url-utils"; import ColumnFilter from "../../filters/ColumnFilter"; import menuFuncs from "../dataViewerMenuUtils"; -import { ROW_HEIGHT, SORT_PROPS } from "../gridUtils"; +import { exports as gu } from "../gridUtils"; import serverState from "../serverStateManagement"; require("./ColumnMenu.css"); +const { ROW_HEIGHT, SORT_PROPS } = gu; const MOVE_COLS = [ ["step-backward", serverState.moveToFront, "Move Column To Front", {}], ["caret-left", serverState.moveLeft, "Move Column Left", { fontSize: "1.2em", padding: 0, width: "1.3em" }], @@ -125,6 +126,11 @@ class ReactColumnMenu extends React.Component { }; serverState.toggleVisibility(dataId, selectedCol, hideCallback); }; + const deleteCol = () => + this.props.propagateState( + { columns: _.reject(this.props.columns, { name: selectedCol }) }, + serverState.deleteColumn(dataId, selectedCol) + ); return (
    • +
    • + + + +