diff --git a/dtale/column_analysis.py b/dtale/column_analysis.py index f227f840..38beeaac 100644 --- a/dtale/column_analysis.py +++ b/dtale/column_analysis.py @@ -17,6 +17,7 @@ find_dtype, find_dtype_formatter, find_selected_column, + get_bool_arg, get_int_arg, get_str_arg, grid_columns, @@ -147,9 +148,11 @@ class HistogramAnalysis(object): def __init__(self, req): self.bins = get_int_arg(req, "bins", 20) self.target = get_str_arg(req, "target") + self.density = get_bool_arg(req, "density") def build_histogram_data(self, series): - hist_data, hist_labels = np.histogram(series, bins=self.bins) + hist_kwargs = {"density": True} if self.density else {"bins": self.bins} + hist_data, hist_labels = np.histogram(series, **hist_kwargs) hist_data = [json_float(h) for h in hist_data] return ( dict( @@ -225,9 +228,13 @@ def _build_code(self, parent, kde_code, desc_code): ).format(col=parent.selected_col) ) if self.target is None: + hist_kwargs = ( + "density=True" if self.density else "bins={}".format(self.bins) + ) code.append( - "chart, labels = np.histogram(s['{col}'], bins={bins})".format( - col=parent.selected_col, bins=self.bins + "chart, labels = np.histogram(s['{col}'], {hist_kwargs})".format( + col=parent.selected_col, + hist_kwargs=hist_kwargs, ) ) code += kde_code + desc_code diff --git a/package.json b/package.json index 1ea9d2db..103e3392 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "/static/**/*.{js, jsx}" ], "coveragePathIgnorePatterns": [ - "/static/__tests__/" + "/static/__tests__/", + "/state/dash/lib/custom.js" ], "coverageDirectory": "./JS_coverage", "coverageReporters": [ diff --git a/static/ButtonToggle.jsx b/static/ButtonToggle.jsx index 23660607..a047849c 100644 --- a/static/ButtonToggle.jsx +++ b/static/ButtonToggle.jsx @@ -43,7 +43,7 @@ ButtonToggle.displayName = "ButtonToggle"; ButtonToggle.propTypes = { options: PropTypes.array, update: PropTypes.func, - defaultValue: PropTypes.string, + defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number]), allowDeselect: PropTypes.bool, disabled: PropTypes.bool, className: PropTypes.string, diff --git a/static/__tests__/popups/analysis/columnAnalysisUtils-test.js b/static/__tests__/popups/analysis/columnAnalysisUtils-test.js index 2b4fb948..6c8731ba 100644 --- a/static/__tests__/popups/analysis/columnAnalysisUtils-test.js +++ b/static/__tests__/popups/analysis/columnAnalysisUtils-test.js @@ -2,9 +2,10 @@ import $ from "jquery"; import { withGlobalJquery } from "../../test-utils"; import chartUtils from "../../../chartUtils"; +import * as fetcher from "../../../fetcher"; describe("columnAnalysisUtils", () => { - let createChart, createChartSpy; + let createChart, dataLoader, createChartSpy, fetchJsonSpy; beforeEach(() => { const mockJquery = withGlobalJquery(() => selector => { @@ -17,9 +18,16 @@ describe("columnAnalysisUtils", () => { jest.mock("jquery", () => mockJquery); createChartSpy = jest.spyOn(chartUtils, "createChart"); createChartSpy.mockImplementation(() => undefined); - createChart = require("../../../popups/analysis/columnAnalysisUtils").createChart; + fetchJsonSpy = jest.spyOn(fetcher, "fetchJson"); + fetchJsonSpy.mockImplementation(() => undefined); + + const columnAnalysisUtils = require("../../../popups/analysis/columnAnalysisUtils"); + createChart = columnAnalysisUtils.createChart; + dataLoader = columnAnalysisUtils.dataLoader; }); + afterEach(jest.restoreAllMocks); + it("correctly handles targeted histogram data", () => { const fetchedData = { targets: [ @@ -34,4 +42,13 @@ describe("columnAnalysisUtils", () => { const finalChart = createChartSpy.mock.calls[0][1]; expect(finalChart.data.datasets.map(d => d.label)).toEqual(["foo", "bar"]); }); + + it("correctly handles probability histogram load", () => { + const propagateState = jest.fn(); + const props = { chartData: { selectedCol: "foo" }, height: 400, dataId: "1" }; + dataLoader(props, {}, propagateState, { type: "histogram", density: true }); + expect(fetchJsonSpy).toHaveBeenCalled(); + const params = Object.fromEntries(new URLSearchParams(fetchJsonSpy.mock.calls[0][0].split("?")[1])); + expect(params).toMatchObject({ density: "true" }); + }); }); diff --git a/static/__tests__/popups/analysis/filters/DescribeFilters-test.jsx b/static/__tests__/popups/analysis/filters/DescribeFilters-test.jsx index 9a03b11e..c7ffee07 100644 --- a/static/__tests__/popups/analysis/filters/DescribeFilters-test.jsx +++ b/static/__tests__/popups/analysis/filters/DescribeFilters-test.jsx @@ -8,6 +8,7 @@ import { expect, it } from "@jest/globals"; import ButtonToggle from "../../../../ButtonToggle"; import CategoryInputs from "../../../../popups/analysis/filters/CategoryInputs"; import DescribeFilters from "../../../../popups/analysis/filters/DescribeFilters"; +import GeoFilters from "../../../../popups/analysis/filters/GeoFilters"; import OrdinalInputs from "../../../../popups/analysis/filters/OrdinalInputs"; import TextEnterFilter from "../../../../popups/analysis/filters/TextEnterFilter"; @@ -43,7 +44,7 @@ describe("DescribeFilters tests", () => { expect(buildChart.mock.calls).toHaveLength(2); }); - describe(" rendering int column", () => { + describe("rendering int column", () => { it("rendering boxplot", () => { expect(result.find(OrdinalInputs)).toHaveLength(0); expect(result.find(CategoryInputs)).toHaveLength(0); @@ -61,6 +62,9 @@ describe("DescribeFilters tests", () => { expect(result.find(OrdinalInputs)).toHaveLength(0); expect(result.find(CategoryInputs)).toHaveLength(0); expect(result.find(TextEnterFilter)).toHaveLength(1); + result.find(ButtonToggle).last().props().update(true); + expect(buildChart).toHaveBeenCalled(); + expect(result.state()).toMatchObject({ density: true }); }); it("rendering value_counts", () => { @@ -71,7 +75,7 @@ describe("DescribeFilters tests", () => { }); }); - describe(" rendering float column", () => { + describe("rendering float column", () => { beforeEach(() => { result.setProps({ dtype: "float64", details: { type: "float64" } }); result.update(); @@ -104,7 +108,7 @@ describe("DescribeFilters tests", () => { }); }); - describe(" rendering datetime column", () => { + describe("rendering datetime column", () => { beforeEach(() => { result.setProps({ dtype: "datetime[ns]", details: { type: "datetime" } }); result.update(); @@ -130,6 +134,31 @@ describe("DescribeFilters tests", () => { }); }); + describe("rendering geolocation column", () => { + beforeEach(() => { + result.setProps({ + dtype: "float", + coord: "lat", + details: { type: "float" }, + }); + result.update(); + }); + + it("loading options", () => { + expect(_.map(result.find(ButtonToggle).prop("options"), "value")).toEqual([ + "boxplot", + "histogram", + "categories", + "qq", + ]); + }); + + it("rendering geolocation", () => { + result.setState({ type: "geolocation" }); + expect(result.find(GeoFilters)).toHaveLength(1); + }); + }); + describe("chart navigation", () => { const move = prop => result.find(GlobalHotKeys).props().handlers[prop](); diff --git a/static/__tests__/popups/timeseries/BKFilter-test.jsx b/static/__tests__/popups/timeseries/BKFilter-test.jsx new file mode 100644 index 00000000..c0c5640b --- /dev/null +++ b/static/__tests__/popups/timeseries/BKFilter-test.jsx @@ -0,0 +1,60 @@ +import { shallow } from "enzyme"; +import React from "react"; + +import { expect, it } from "@jest/globals"; + +import { BKFilter, chartConfig } from "../../../popups/timeseries/BKFilter"; + +describe("BKFilter", () => { + let wrapper, props; + + beforeEach(() => { + props = { + baseCfg: {}, + cfg: {}, + updateState: jest.fn(), + }; + wrapper = shallow(); + }); + + it("renders successfully", () => { + expect(wrapper.find("div.col-md-4")).toHaveLength(3); + expect(props.updateState).toHaveBeenCalledTimes(1); + }); + + it("updates state", () => { + wrapper.find("input").forEach(input => { + input.simulate("change", { target: { value: 5 } }); + input.simulate("keyDown", { key: "Enter" }); + }); + expect(props.updateState).toHaveBeenLastCalledWith({ + cfg: { low: 5, high: 5, K: 5 }, + }); + }); + + it("updates state on baseCfg update", () => { + props.updateState.mockReset(); + wrapper.setProps({ baseCfg: { col: "foo" } }); + expect(props.updateState).toHaveBeenCalledTimes(1); + }); + + it("builds chart config correctly", () => { + const cfg = chartConfig( + { col: "foo" }, + { + data: { datasets: [{}, {}] }, + options: { + scales: { + "y-cycle": {}, + "y-foo": {}, + x: { title: { display: false } }, + }, + plugins: {}, + }, + } + ); + expect(cfg.options.scales["y-foo"].position).toBe("left"); + expect(cfg.options.scales["y-cycle"].position).toBe("right"); + expect(cfg.options.plugins.legend.display).toBe(true); + }); +}); diff --git a/static/__tests__/popups/timeseries/BaseInputs-test.jsx b/static/__tests__/popups/timeseries/BaseInputs-test.jsx new file mode 100644 index 00000000..94359549 --- /dev/null +++ b/static/__tests__/popups/timeseries/BaseInputs-test.jsx @@ -0,0 +1,43 @@ +import { shallow } from "enzyme"; +import React from "react"; +import Select from "react-select"; + +import { expect, it } from "@jest/globals"; + +import { BaseInputs } from "../../../popups/timeseries/BaseInputs"; + +describe("BaseInputs", () => { + let wrapper, props; + + beforeEach(() => { + props = { + columns: [{ dtype: "int", name: "foo" }], + cfg: {}, + updateState: jest.fn(), + }; + wrapper = shallow(); + }); + + it("renders successfully", () => { + expect(wrapper.find("div.col-md-4")).toHaveLength(3); + }); + + it("updates state", () => { + wrapper + .find("ColumnSelect") + .first() + .props() + .updateState({ index: { value: "date" } }); + wrapper + .find("ColumnSelect") + .last() + .props() + .updateState({ col: { value: "foo" } }); + wrapper.find(Select).props().onChange({ value: "sum" }); + expect(props.updateState).toHaveBeenLastCalledWith({ + index: "date", + col: "foo", + agg: "sum", + }); + }); +}); diff --git a/static/__tests__/popups/timeseries/CFFilter-test.jsx b/static/__tests__/popups/timeseries/CFFilter-test.jsx new file mode 100644 index 00000000..1ec0e1f3 --- /dev/null +++ b/static/__tests__/popups/timeseries/CFFilter-test.jsx @@ -0,0 +1,64 @@ +import { shallow } from "enzyme"; +import React from "react"; + +import { expect, it } from "@jest/globals"; + +import { CFFilter, chartConfig } from "../../../popups/timeseries/CFFilter"; + +describe("CFFilter", () => { + let wrapper, props; + + beforeEach(() => { + props = { + baseCfg: {}, + cfg: {}, + updateState: jest.fn(), + }; + wrapper = shallow(); + }); + + it("renders successfully", () => { + expect(wrapper.find("div.col-md-4")).toHaveLength(3); + expect(props.updateState).toHaveBeenCalledTimes(1); + }); + + it("updates state", () => { + wrapper.find("input").forEach(input => { + input.simulate("change", { target: { value: 5 } }); + input.simulate("keyDown", { key: "Enter" }); + }); + wrapper.find("i").simulate("click"); + expect(props.updateState).toHaveBeenLastCalledWith({ + cfg: { low: 5, high: 5, drift: true }, + }); + }); + + it("updates state on baseCfg update", () => { + props.updateState.mockReset(); + wrapper.setProps({ baseCfg: { col: "foo" } }); + expect(props.updateState).toHaveBeenCalledTimes(1); + }); + + it("builds chart config correctly", () => { + const cfg = chartConfig( + { col: "foo" }, + { + data: { datasets: [{}, {}, {}] }, + options: { + scales: { + "y-cycle": {}, + "y-trend": {}, + "y-foo": { title: {} }, + x: { title: { display: false } }, + }, + plugins: {}, + }, + } + ); + expect(cfg.options.scales["y-foo"].position).toBe("left"); + expect(cfg.options.scales["y-foo"].title.text).toBe("foo, trend"); + expect(cfg.options.scales["y-cycle"].position).toBe("right"); + expect(cfg.options.scales["y-trend"].display).toBe(false); + expect(cfg.options.plugins.legend.display).toBe(true); + }); +}); diff --git a/static/__tests__/popups/timeseries/HPFilter-test.jsx b/static/__tests__/popups/timeseries/HPFilter-test.jsx new file mode 100644 index 00000000..4d9181ea --- /dev/null +++ b/static/__tests__/popups/timeseries/HPFilter-test.jsx @@ -0,0 +1,61 @@ +import { shallow } from "enzyme"; +import React from "react"; + +import { expect, it } from "@jest/globals"; + +import { chartConfig, HPFilter } from "../../../popups/timeseries/HPFilter"; + +describe("HPFilter", () => { + let wrapper, props; + + beforeEach(() => { + props = { + baseCfg: {}, + cfg: {}, + updateState: jest.fn(), + }; + wrapper = shallow(); + }); + + it("renders successfully", () => { + expect(wrapper.find("div.col-md-4")).toHaveLength(1); + expect(props.updateState).toHaveBeenCalledTimes(1); + }); + + it("updates state", () => { + wrapper.find("input").forEach(input => { + input.simulate("change", { target: { value: 5 } }); + input.simulate("keyDown", { key: "Enter" }); + }); + expect(props.updateState).toHaveBeenLastCalledWith({ cfg: { lamb: 5 } }); + }); + + it("updates state on baseCfg update", () => { + props.updateState.mockReset(); + wrapper.setProps({ baseCfg: { col: "foo" } }); + expect(props.updateState).toHaveBeenCalledTimes(1); + }); + + it("builds chart config correctly", () => { + const cfg = chartConfig( + { col: "foo" }, + { + data: { datasets: [{}, {}, {}] }, + options: { + scales: { + "y-cycle": {}, + "y-trend": {}, + "y-foo": { title: {} }, + x: { title: { display: false } }, + }, + plugins: {}, + }, + } + ); + expect(cfg.options.scales["y-foo"].position).toBe("left"); + expect(cfg.options.scales["y-foo"].title.text).toBe("foo, trend"); + expect(cfg.options.scales["y-cycle"].position).toBe("right"); + expect(cfg.options.scales["y-trend"].display).toBe(false); + expect(cfg.options.plugins.legend.display).toBe(true); + }); +}); diff --git a/static/__tests__/popups/timeseries/SeasonalDecompose-test.jsx b/static/__tests__/popups/timeseries/SeasonalDecompose-test.jsx new file mode 100644 index 00000000..89f59a3e --- /dev/null +++ b/static/__tests__/popups/timeseries/SeasonalDecompose-test.jsx @@ -0,0 +1,104 @@ +import { shallow } from "enzyme"; +import React from "react"; + +import { expect, it } from "@jest/globals"; + +import { chartConfig, SeasonalDecompose } from "../../../popups/timeseries/SeasonalDecompose"; + +describe("SeasonalDecompose", () => { + let wrapper, props; + + beforeEach(() => { + props = { + baseCfg: {}, + cfg: {}, + type: "seasonal_decompose", + updateState: jest.fn(), + }; + wrapper = shallow(); + }); + + it("renders successfully", () => { + expect(wrapper.find("div.col-md-4")).toHaveLength(1); + expect(props.updateState).toHaveBeenCalledTimes(1); + }); + + it("updates state", () => { + wrapper.find("button").last().simulate("click"); + expect(props.updateState).toHaveBeenLastCalledWith({ + cfg: { model: "multiplicative" }, + }); + }); + + it("updates state on baseCfg update", () => { + props.updateState.mockReset(); + wrapper.setProps({ baseCfg: { col: "foo" } }); + expect(props.updateState).toHaveBeenCalledTimes(1); + }); + + it("builds chart config correctly", () => { + const cfg = chartConfig( + { col: "foo" }, + { + data: { datasets: [{}, {}, {}, {}] }, + options: { + scales: { + "y-cycle": {}, + "y-seasonal": { title: {} }, + "y-resid": {}, + "y-trend": {}, + "y-foo": { title: {} }, + x: { title: { display: false } }, + }, + plugins: {}, + }, + } + ); + expect(cfg.options.scales["y-foo"].position).toBe("left"); + expect(cfg.options.scales["y-foo"].title.text).toBe("foo, trend"); + expect(cfg.options.scales["y-seasonal"].title.text).toBe("seasonal, resid"); + expect(cfg.options.scales["y-seasonal"].position).toBe("right"); + expect(cfg.options.scales["y-trend"].display).toBe(false); + expect(cfg.options.scales["y-resid"].display).toBe(false); + expect(cfg.options.plugins.legend.display).toBe(true); + }); + + describe("stl", () => { + beforeEach(() => { + jest.resetAllMocks(); + wrapper.setProps({ type: "stl" }); + }); + + it("renders successfully", () => { + expect(wrapper.find("div.col-md-4")).toHaveLength(0); + expect(props.updateState).toHaveBeenCalledTimes(1); + }); + + it("builds chart config correctly", () => { + const cfg = chartConfig( + { col: "foo" }, + { + data: { datasets: [{}, {}, {}, {}] }, + options: { + scales: { + "y-cycle": {}, + "y-seasonal": { title: {} }, + "y-resid": {}, + "y-trend": {}, + "y-foo": { title: {} }, + x: { title: { display: false } }, + }, + plugins: {}, + }, + } + ); + expect(cfg.options.scales["y-foo"].position).toBe("left"); + expect(cfg.options.scales["y-foo"].title.text).toBe("foo, trend"); + expect(cfg.options.scales["y-seasonal"].title.text).toBe("seasonal, resid"); + expect(cfg.options.scales["y-seasonal"].position).toBe("right"); + expect(cfg.options.scales["y-trend"].display).toBe(false); + expect(cfg.options.scales["y-resid"].display).toBe(false); + expect(cfg.options.plugins.legend.display).toBe(true); + }); + }); +}); diff --git a/static/__tests__/popups/timeseries/TimeseriesAnalysis-test.jsx b/static/__tests__/popups/timeseries/TimeseriesAnalysis-test.jsx index 0c68049f..cb24044a 100644 --- a/static/__tests__/popups/timeseries/TimeseriesAnalysis-test.jsx +++ b/static/__tests__/popups/timeseries/TimeseriesAnalysis-test.jsx @@ -6,6 +6,7 @@ import React from "react"; import { expect, it } from "@jest/globals"; import * as fetcher from "../../../fetcher"; +import ChartsBody from "../../../popups/charts/ChartsBody"; import { BKFilter } from "../../../popups/timeseries/BKFilter"; import { BaseInputs } from "../../../popups/timeseries/BaseInputs"; import { CFFilter } from "../../../popups/timeseries/CFFilter"; @@ -35,6 +36,56 @@ describe("TimeseriesAnalysis", () => { expect(fetchJsonSpy.mock.calls[0][0].startsWith("/dtale/dtypes/1")).toBe(true); }); + it("mounts successfully", () => { + wrapper.instance().componentDidMount(); + expect(fetchJsonSpy.mock.calls[0][0]).toBe("/dtale/dtypes/1"); + fetchJsonSpy.mock.calls[0][1]({ + dtypes: [{ dtype: "datetime", name: "index" }], + }); + expect(wrapper.state().baseCfg).toMatchObject({ index: "index" }); + }); + + it("builds chart configs successfully", () => { + wrapper.setState({ + url: "/dtale/blah/1", + baseCfg: { col: "foo", index: "date" }, + }); + const { configHandler } = wrapper.find(ChartsBody).props(); + let cfg = configHandler({ + data: { datasets: [{}, {}, {}] }, + options: { + scales: { + "y-cycle": {}, + "y-trend": {}, + "y-foo": { title: {} }, + x: { title: { display: false } }, + }, + plugins: {}, + }, + }); + expect(cfg).toBeDefined(); + wrapper.setState({ multiChart: true }); + cfg = configHandler({ + data: { datasets: [{ label: "foo", data: [] }, {}, {}] }, + options: { + scales: { + "y-cycle": {}, + "y-trend": {}, + "y-foo": { title: {} }, + x: { title: { display: false } }, + }, + plugins: {}, + }, + }); + expect(Object.keys(cfg.options.scales)).toEqual(["y-foo", "x"]); + }); + + it("handles mount error", () => { + wrapper.instance().componentDidMount(); + fetchJsonSpy.mock.calls[0][1]({ error: "failure" }); + expect(wrapper.state().error).toBeDefined(); + }); + it("renders report inputs successfully", () => { wrapper.setState({ type: "bkfilter" }); expect(wrapper.find(BKFilter)).toHaveLength(1); diff --git a/static/popups/analysis/columnAnalysisUtils.js b/static/popups/analysis/columnAnalysisUtils.js index d81ead09..78190503 100644 --- a/static/popups/analysis/columnAnalysisUtils.js +++ b/static/popups/analysis/columnAnalysisUtils.js @@ -85,8 +85,9 @@ function targetColor(idx) { function buildHistogramAxes(baseCfg, fetchedData, chartOpts) { const { data, targets, kde } = fetchedData; const xAxes = { x: { title: { display: true, text: "Bin" } } }; + const yLabel = chartOpts.density ? "Probability" : "Frequency"; const yAxes = { - y: { title: { display: true, text: "Frequency" } }, + y: { title: { display: true, text: yLabel } }, }; let datasets = []; if (targets) { @@ -102,7 +103,7 @@ function buildHistogramAxes(baseCfg, fetchedData, chartOpts) { backgroundColor: targetColor(idx), })); } else { - datasets.push({ label: "Frequency", type: "bar", data: data, backgroundColor: "rgb(42, 145, 209)", yAxisID: "y" }); + datasets.push({ label: yLabel, type: "bar", data: data, backgroundColor: "rgb(42, 145, 209)", yAxisID: "y" }); if (kde) { yAxes.y.position = "left"; yAxes["y-2"] = { @@ -184,7 +185,7 @@ const emptyVal = val => ( const PARAM_PROPS = _.concat( ["selectedCol", "bins", "top", "type", "ordinalCol", "ordinalAgg", "categoryCol"], - ["categoryAgg", "cleaners", "latCol", "lonCol", "target", "filtered"] + ["categoryAgg", "cleaners", "latCol", "lonCol", "target", "filtered", "density"] ); const isPlotly = type => _.includes(["geolocation", "qq"], type); @@ -193,7 +194,7 @@ function dataLoader(props, state, propagateState, chartParams) { const { chartData, height, dataId } = props; const finalParams = chartParams || state.chartParams; const { selectedCol } = chartData; - const params = _.assignIn({}, chartData, _.pick(finalParams, ["bins", "top"])); + const params = _.assignIn({}, chartData, _.pick(finalParams, ["bins", "top", "density"])); params.type = _.get(finalParams, "type"); params.filtered = props.filtered ?? true; if (isPlotly(params.type) || finalParams?.target) { @@ -215,6 +216,7 @@ function dataLoader(props, state, propagateState, chartParams) { } else if (_.includes(["value_counts", "word_value_counts"], params.type)) { subProps = ["ordinalCol", "ordinalAgg"]; } else if (params.type === "histogram") { + params.density = finalParams.density ?? false; subProps = ["target"]; } else if (params.type === "qq") { subProps = []; diff --git a/static/popups/analysis/filters/DescribeFilters.jsx b/static/popups/analysis/filters/DescribeFilters.jsx index 5a85638e..012f5298 100644 --- a/static/popups/analysis/filters/DescribeFilters.jsx +++ b/static/popups/analysis/filters/DescribeFilters.jsx @@ -1,3 +1,4 @@ +/* eslint max-lines: "off" */ import _ from "lodash"; import PropTypes from "prop-types"; import React from "react"; @@ -33,6 +34,7 @@ function buildState(props) { categoryAgg: _.find(analysisAggs(props.t), { value: "mean" }), ...loadCoordVals(props.selectedCol, props.cols), target: null, + density: false, }; } @@ -51,6 +53,7 @@ class DescribeFilters extends React.Component { this.toggleLeft = this.toggleLeft.bind(this); this.toggleRight = this.toggleRight.bind(this); this.targetSelect = this.targetSelect.bind(this); + this.densityToggle = this.densityToggle.bind(this); } shouldComponentUpdate(newProps, newState) { @@ -140,7 +143,7 @@ class DescribeFilters extends React.Component { ); } - buildFilter(prop) { + buildFilter(prop, disabled = false) { const propagateState = state => this.setState(state); return ( ); @@ -223,11 +227,27 @@ class DescribeFilters extends React.Component { ); } + densityToggle() { + return ( + this.setState({ density }, this.buildChart)} + defaultValue={this.state.density} + className="pr-0" + /> + ); + } + render() { if (_.isNull(this.props.type)) { return null; } const { code, dtype } = this.props; + const { density } = this.state; const colType = gu.findColType(dtype); let filterMarkup = null; if (this.state.type === "boxplot" || this.state.type === "qq") { @@ -237,7 +257,11 @@ class DescribeFilters extends React.Component { } else if ("int" === colType) { // int -> Value Counts or Histogram if (this.state.type === "histogram") { - filterMarkup = wrapFilterMarkup([this.buildFilter("bins"), this.targetSelect()]); + filterMarkup = wrapFilterMarkup([ + this.densityToggle(), + density ? null : this.buildFilter("bins"), + this.targetSelect(), + ]); } else { filterMarkup = wrapFilterMarkup([ this.buildFilter("top"), @@ -247,7 +271,11 @@ class DescribeFilters extends React.Component { } else if ("float" === colType) { // floats -> Histogram or Categories if (this.state.type === "histogram") { - filterMarkup = wrapFilterMarkup([this.buildFilter("bins"), this.targetSelect()]); + filterMarkup = wrapFilterMarkup([ + this.densityToggle(), + density ? null : this.buildFilter("bins"), + this.targetSelect(), + ]); } else { filterMarkup = wrapFilterMarkup([ this.buildFilter("top"), @@ -259,6 +287,7 @@ class DescribeFilters extends React.Component { filterMarkup = wrapFilterMarkup([ this.buildFilter("top"), , + this.state.type === "histogram" ? this.densityToggle() : null, ]); } return ( diff --git a/static/popups/analysis/filters/TextEnterFilter.jsx b/static/popups/analysis/filters/TextEnterFilter.jsx index 9ec27220..e601c593 100644 --- a/static/popups/analysis/filters/TextEnterFilter.jsx +++ b/static/popups/analysis/filters/TextEnterFilter.jsx @@ -25,7 +25,7 @@ class TextEnterFilter extends React.Component { } render() { - const { prop, dtype, buildChart, t } = this.props; + const { prop, dtype, buildChart, t, disabled } = this.props; const colType = gu.findColType(dtype); const updateFilter = e => { if (e.key === "Enter") { @@ -52,6 +52,7 @@ class TextEnterFilter extends React.Component { value={this.state[prop] ?? ""} onChange={this.updateValue} onKeyDown={updateFilter} + disabled={disabled} /> @@ -66,6 +67,10 @@ TextEnterFilter.propTypes = { buildChart: PropTypes.func, defaultValue: PropTypes.string, t: PropTypes.func, + disabled: PropTypes.bool, +}; +TextEnterFilter.defaultProps = { + disabled: false, }; export default withTranslation("text_enter")(TextEnterFilter); diff --git a/tests/dtale/test_column_analysis.py b/tests/dtale/test_column_analysis.py index 01e52188..0a87aad6 100644 --- a/tests/dtale/test_column_analysis.py +++ b/tests/dtale/test_column_analysis.py @@ -237,6 +237,24 @@ def test_get_column_analysis(unittest, test_data): ) +@pytest.mark.unit +def test_probability_histogram(unittest, test_data): + import dtale.views as views + + with app.test_client() as c: + with ExitStack(): + build_data_inst({c.port: test_data}) + build_dtypes({c.port: views.build_dtypes_state(test_data)}) + build_settings({c.port: {}}) + response = c.get( + "/dtale/column-analysis/{}".format(c.port), + query_string=dict(col="foo", density="true"), + ) + response_data = json.loads(response.data) + assert response.status_code == 200 + assert "np.histogram(s['foo'], density=True)" in response_data["code"] + + @pytest.mark.unit def test_get_column_analysis_word_value_count(unittest): df = pd.DataFrame(dict(a=["a b c", "d e f", "g h i"], b=[3, 4, 5]))