diff --git a/example/package/react-autoql-0.0.0-semantically-released.tgz b/example/package/react-autoql-0.0.0-semantically-released.tgz index ee34d8513..15e7050db 100644 Binary files a/example/package/react-autoql-0.0.0-semantically-released.tgz and b/example/package/react-autoql-0.0.0-semantically-released.tgz differ diff --git a/example/src/App.js b/example/src/App.js index 36c48f3c7..6ce3b3246 100644 --- a/example/src/App.js +++ b/example/src/App.js @@ -111,7 +111,6 @@ export default class App extends Component { currentPage: 'drawer', isNewDashboardModalOpen: false, componentKey: uuid(), - isVisible: false, placement: 'right', showHandle: true, theme: 'light', diff --git a/example/src/index.css b/example/src/index.css index e8daea425..5fde7699f 100644 --- a/example/src/index.css +++ b/example/src/index.css @@ -1,3 +1,57 @@ +h1 { + display: block; + font-size: 2em; + margin-top: 0.67em; + margin-bottom: 0.67em; + margin-left: 0; + margin-right: 0; + font-weight: bold; +} +h2 { + display: block; + font-size: 1.5em; + margin-top: 0.83em; + margin-bottom: 0.83em; + margin-left: 0; + margin-right: 0; + font-weight: bold; +} +h3 { + display: block; + font-size: 1.17em; + margin-top: 1em; + margin-bottom: 1em; + margin-left: 0; + margin-right: 0; + font-weight: bold; +} +h4 { + display: block; + margin-top: 1.33em; + margin-bottom: 1.33em; + margin-left: 0; + margin-right: 0; + font-weight: bold; +} +h5 { + display: block; + font-size: 0.83em; + margin-top: 1.67em; + margin-bottom: 1.67em; + margin-left: 0; + margin-right: 0; + font-weight: bold; +} +h6 { + display: block; + font-size: 0.67em; + margin-top: 2.33em; + margin-bottom: 2.33em; + margin-left: 0; + margin-right: 0; + font-weight: bold; +} + body { height: 100vh; width: 100vw; @@ -13,15 +67,6 @@ body { height: auto; } -.test-page-container h4 { - margin-bottom: 5px; - margin-top: 15px; -} - -.test-page-container input { - min-width: 250px; -} - .ui-overlay { width: 100%; height: 100vh; @@ -49,22 +94,6 @@ body { max-width: unset; } -.chat-voice-record-button { - margin: 10px; -} - -.switch { - position: relative; - display: inline-block; - width: 90px; - height: 34px; - margin-top: 10px; -} - -.switch input { - display: none; -} - #clear-rating-button { margin-top: 25px; padding: 5px; diff --git a/package-lock.json b/package-lock.json index bfeba5b2f..ef52244a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16035,8 +16035,7 @@ "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", - "dev": true + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" }, "lodash.unescape": { "version": "4.0.1", @@ -23501,6 +23500,14 @@ "resolved": "https://registry.npmjs.org/react-tiny-popover/-/react-tiny-popover-3.4.5.tgz", "integrity": "sha512-vZHIMCsphBwgUcz6wnwq+BZnukNzZhSXYTpe5qqTYGr+mTyVKN6q8NXsNkokh5QGnTzO3FpB3P33J64F1oiTqg==" }, + "react-toastify": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.0.1.tgz", + "integrity": "sha512-c2zeZHkCX+WXuItS/JRqQ/8CH8Qm/je+M0rt09xe9fnu5YPJigtNOdD8zX4fwLA093V2am3abkGfOowwpkrwOQ==", + "requires": { + "clsx": "^1.1.1" + } + }, "react-tooltip": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-4.2.10.tgz", diff --git a/package.json b/package.json index aec1c7505..51ec6b546 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "lodash.omit": "^4.5.0", "lodash.reduce": "^4.6.0", "lodash.some": "^4.6.0", + "lodash.sortby": "^4.7.0", "lodash.uniq": "^4.5.0", "make-coverage-badge": "^1.2.0", "moment-timezone": "^0.5.32", @@ -99,6 +100,7 @@ "react-switch": "^6.0.0", "react-tabulator": "^0.13.2", "react-tiny-popover": "^3.4.5", + "react-toastify": "^9.0.1", "react-tooltip": "4.2.10", "recordrtc": "^5.6.1", "sanitize-html": "^2.3.2", diff --git a/public/badge.svg b/public/badge.svg index b73918906..facf7032e 100644 --- a/public/badge.svg +++ b/public/badge.svg @@ -1 +1 @@ -Coverage: 29.71%Coverage29.71% \ No newline at end of file +Coverage: 32.93%Coverage32.93% \ No newline at end of file diff --git a/src/components/Charts/Axes/Axes.js b/src/components/Charts/Axes/Axes.js index 1e9eea885..5072c7728 100644 --- a/src/components/Charts/Axes/Axes.js +++ b/src/components/Charts/Axes/Axes.js @@ -1,18 +1,19 @@ import React from 'react' -import PropTypes from 'prop-types' import _get from 'lodash.get' +import { v4 as uuid } from 'uuid' import { Axis } from '../Axis' -import { dataFormattingType, themeConfigType } from '../../../props/types' -import { - themeConfigDefault, - dataFormattingDefault, -} from '../../../props/defaults' +import AxisSelector from './AxisSelector' +import { getBBoxFromRef } from '../../../js/Util' +import { axesDefaultProps, axesPropTypes } from '../helpers' export default class Axes extends React.Component { constructor(props) { super(props) + this.xAxisKey = uuid() + this.yAxisKey = uuid() + this.labelInlineStyles = { fontSize: 12, fontFamily: 'inherit', @@ -22,63 +23,8 @@ export default class Axes extends React.Component { } } - static propTypes = { - dataFormatting: dataFormattingType, - themeConfig: themeConfigType, - - scales: PropTypes.shape({}).isRequired, - height: PropTypes.number.isRequired, - width: PropTypes.number.isRequired, - margins: PropTypes.shape({}), - xTicks: PropTypes.array, - yTicks: PropTypes.array, - rotateLabels: PropTypes.bool, - xCol: PropTypes.shape({}), - yCol: PropTypes.shape({}), - xGridLines: PropTypes.bool, - yGridLines: PropTypes.bool, - legendLabels: PropTypes.arrayOf(PropTypes.shape({})), - legendColumn: PropTypes.shape({}), - hasRightLegend: PropTypes.bool, - hasBottomLegend: PropTypes.bool, - onLegendClick: PropTypes.func, - onXAxisClick: PropTypes.func, - onYAxisClick: PropTypes.func, - hasXDropdown: PropTypes.bool, - hasYDropdown: PropTypes.bool, - xAxisTitle: PropTypes.string, - yAxisTitle: PropTypes.string, - legendTitle: PropTypes.string, - onLabelChange: PropTypes.func, - } - - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - xCol: {}, - yCol: {}, - xTicks: undefined, - yTicks: undefined, - legendTitle: undefined, - hasRightLegend: false, - hasBottomLegend: false, - hasXDropdown: false, - hasYDropdown: false, - xAxisTitle: undefined, - yAxisTitle: undefined, - legendTitle: undefined, - margins: { - right: 0, - left: 0, - top: 0, - bottom: 0, - }, - onLegendClick: () => {}, - onXAxisClick: () => {}, - onYAxisClick: () => {}, - onLabelChange: () => {}, - } + static propTypes = axesPropTypes + static defaultProps = axesDefaultProps componentDidMount = () => { this.props.onLabelChange() @@ -99,7 +45,7 @@ export default class Axes extends React.Component { return ( - {title}{' '} + {title} {hasDropdown && ( + {' '} ▼ )} @@ -114,69 +61,14 @@ export default class Axes extends React.Component { ) } - getBBoxFromRef = (ref) => { - let bbox - try { - if (ref) { - bbox = ref.getBBox() - } - } catch (error) { - console.error(error) - } - - return bbox - } - - renderXLabelDropdown = (xLabelX, xLabelY) => { - const xLabelBbox = this.getBBoxFromRef(this.xLabelRef) - const xLabelWidth = xLabelBbox ? xLabelBbox.width : 0 - const xLabelHeight = xLabelBbox ? xLabelBbox.height : 0 - - return ( - - ) - } - - renderYLabelDropdown = (yLabelX, yLabelY) => { - const yLabelBbox = this.getBBoxFromRef(this.yLabelRef) - const yLabelWidth = yLabelBbox ? yLabelBbox.width : 0 - const yLabelHeight = yLabelBbox ? yLabelBbox.height : 0 - - return ( - - ) - } - renderXAxisLabel = (xAxisTitle) => { const xLabelX = - (this.props.width - this.props.margins.left) / 2 + this.props.margins.left + (this.props.width - this.props.leftMargin) / 2 + this.props.leftMargin const xLabelY = - this.props.height - (this.props.margins.bottomLegend || 0) - 15 + this.props.height - (this.props.bottomLegendMargin || 0) - 15 + const xLabelBbox = getBBoxFromRef(this.xLabelRef) + const xLabelWidth = xLabelBbox ? xLabelBbox.width : 0 + const xLabelHeight = xLabelBbox ? xLabelBbox.height : 0 return ( @@ -192,14 +84,30 @@ export default class Axes extends React.Component { > {this.renderAxisLabel(xAxisTitle, this.props.hasXDropdown)} - {this.props.hasXDropdown && this.renderXLabelDropdown(xLabelX, xLabelY)} + {this.props.hasXDropdown && ( + + )} ) } renderYAxisLabel = (yAxisTitle) => { const yLabelY = 20 - const yLabelX = -((this.props.height - this.props.margins.bottom) / 2) + const yLabelX = -((this.props.height - this.props.bottomMargin) / 2) + const yLabelBbox = getBBoxFromRef(this.yLabelRef) + const yLabelWidth = yLabelBbox ? yLabelBbox.width : 0 + const yLabelHeight = yLabelBbox ? yLabelBbox.height : 0 return ( @@ -216,7 +124,21 @@ export default class Axes extends React.Component { > {this.renderAxisLabel(yAxisTitle, this.props.hasYDropdown)} - {this.props.hasYDropdown && this.renderYLabelDropdown(yLabelX, yLabelY)} + {this.props.hasYDropdown && ( + + )} ) } @@ -224,33 +146,20 @@ export default class Axes extends React.Component { renderXAxis = (xAxisTitle) => { return ( ) } @@ -258,38 +167,36 @@ export default class Axes extends React.Component { renderYAxis = (yAxisTitle) => { return ( ) } render = () => { if ( - !_get(this.props, 'scales.yScale') || - !_get(this.props, 'scales.xScale') || + !this.props.yScale || + !this.props.xScale || !this.props.height || !this.props.width ) { return null } - const xAxisTitle = this.props.xAxisTitle || this.props.xCol.title - const yAxisTitle = this.props.yAxisTitle || this.props.yCol.title + const xAxisTitle = this.props.xAxisTitle || this.props.xCol.display_name + const yAxisTitle = this.props.yAxisTitle || this.props.yCol.display_name return ( diff --git a/src/components/Charts/Axes/Axes.test.js b/src/components/Charts/Axes/Axes.test.js index 3620cbfd4..bc7d4cf37 100644 --- a/src/components/Charts/Axes/Axes.test.js +++ b/src/components/Charts/Axes/Axes.test.js @@ -2,26 +2,39 @@ // You must wrap it in an svg element and use mount for it to work properly import React from 'react' -import { mount, shallow } from 'enzyme' -import { scaleLinear, scaleBand, scaleOrdinal } from 'd3-scale' - +import { mount } from 'enzyme' import Axes from './Axes' - +import sampleProps from '../chartTestData' import { findByTestAttr, ignoreConsoleErrors } from '../../../../test/testUtils' var yLabelRef -const defaultProps = Axes.defaultProps +const pivotSampleProps = { + ...sampleProps.pivot, + xScale: sampleProps.pivot.numberScale, + yScale: sampleProps.pivot.stringScale, + xCol: sampleProps.pivot.columns[sampleProps.pivot.numberColumnIndex], + yCol: sampleProps.pivot.columns[sampleProps.pivot.stringColumnIndex], +} + +const datePivotSampleProps = { + ...sampleProps.datePivot, + xScale: sampleProps.datePivot.stringScale, + yScale: sampleProps.datePivot.numberScale, + xCol: sampleProps.datePivot.columns[sampleProps.datePivot.stringColumnIndex], + yCol: sampleProps.datePivot.columns[sampleProps.datePivot.numberColumnIndex], +} -const exampleProps = { - height: 300, - width: 300, - scales: { - xScale: scaleLinear(), - yScale: scaleOrdinal(), - }, +const listSampleProps = { + ...sampleProps.list, + xScale: sampleProps.list.stringScale, + yScale: sampleProps.list.numberScale, + xCol: sampleProps.list.columns[sampleProps.list.stringColumnIndex], + yCol: sampleProps.list.columns[sampleProps.list.numberColumnIndex], } +const defaultProps = Axes.defaultProps + const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } const wrapper = mount( @@ -33,35 +46,44 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - ignoreConsoleErrors(() => { - const wrapper = setup({ - ...exampleProps, - }) - const axesComponent = findByTestAttr(wrapper, 'react-autoql-axes') - expect(axesComponent.exists()).toBe(true) - }) + test('renders pivot data axes correctly', () => { + const wrapper = setup(pivotSampleProps) + const axesComponent = findByTestAttr(wrapper, 'react-autoql-axes') + expect(axesComponent.exists()).toBe(true) + }) + + test('renders date pivot data axes correctly', () => { + const wrapper = setup(datePivotSampleProps) + const axesComponent = findByTestAttr(wrapper, 'react-autoql-axes') + expect(axesComponent.exists()).toBe(true) + }) + + test('renders list axes correctly', () => { + const wrapper = setup(listSampleProps) + const axesComponent = findByTestAttr(wrapper, 'react-autoql-axes') + expect(axesComponent.exists()).toBe(true) }) }) describe('after mount', () => { describe('renders axis labels correctly', () => { - describe('short titles', () => { - const wrapper = setup({ - ...exampleProps, - xCol: { title: 'x title test' }, - yCol: { title: 'y title test' }, - }) - + describe('short titles - pivot data', () => { + const wrapper = setup(pivotSampleProps) test('renders x axis label', () => { const xLabel = findByTestAttr(wrapper, 'x-axis-label') const xLabelText = findByTestAttr(xLabel, 'axis-label') - expect(xLabelText.text()).toEqual('x title test ') + expect(xLabelText.text()).toEqual( + pivotSampleProps.columns[pivotSampleProps.numberColumnIndex] + .display_name + ) }) test('renders y axis label', () => { const yLabel = findByTestAttr(wrapper, 'y-axis-label') const yLabelText = findByTestAttr(yLabel, 'axis-label') - expect(yLabelText.text()).toEqual('y title test ') + expect(yLabelText.text()).toEqual( + pivotSampleProps.columns[pivotSampleProps.stringColumnIndex] + .display_name + ) }) test('does not render dropdowns by default', () => { const xLabelArrow = findByTestAttr(wrapper, 'dropdown-arrow') @@ -69,11 +91,19 @@ describe('after mount', () => { }) }) - describe('long titles', () => { + describe('long titles - date pivot data', () => { const wrapper = setup({ - ...exampleProps, - xCol: { title: 'x title test loooong title to test ellipsis overflow' }, - yCol: { title: 'y title test loooong title to test ellipsis overflow' }, + ...datePivotSampleProps, + xCol: { + ...datePivotSampleProps.xCol, + display_name: + 'x title test loooong title to test a very very very very very very very long title', + }, + yCol: { + ...datePivotSampleProps.yCol, + display_name: + 'y title test loooong title to test a very very very very very very very long title', + }, }) test('renders long x axis label with ellipsis', () => { @@ -91,102 +121,5 @@ describe('after mount', () => { ) }) }) - - describe('renders dropdowns correctly', () => { - const wrapper = setup({ - ...exampleProps, - xCol: { title: 'x title test' }, - yCol: { title: 'y title test' }, - hasXDropdown: true, - hasYDropdown: true, - }) - - test('calculates bbox', () => { - ignoreConsoleErrors(() => { - const yLabel = mount( - { - yLabelRef = r - }} - > - y title test - - ) - yLabel.mount() - - const wrapper = mount( - - ) - wrapper.mount() - wrapper.instance().getBBoxFromRef(yLabelRef) - }) - }) - - test('renders x dropdown arrow', () => { - const xLabel = findByTestAttr(wrapper, 'x-axis-label') - const xLabelArrow = findByTestAttr(xLabel, 'dropdown-arrow') - expect(xLabelArrow.exists()).toBe(true) - }) - - test('renders y dropdown arrow', () => { - const yLabel = findByTestAttr(wrapper, 'y-axis-label') - const yLabelArrow = findByTestAttr(yLabel, 'dropdown-arrow') - expect(yLabelArrow.exists()).toBe(true) - }) - - describe('x label button', () => { - const xLabelBorder = findByTestAttr(wrapper, 'x-axis-label-border') - test('renders border', () => { - expect(xLabelBorder.exists()).toBe(true) - }) - test('doesnt crash when clicked and callback is not provided', () => { - xLabelBorder.simulate('click') - }) - test('calls onXAxisClick when provided', () => { - const onXAxisClick = jest.fn() - const wrapper = setup({ - ...exampleProps, - xCol: { title: 'x title test' }, - yCol: { title: 'y title test' }, - hasXDropdown: true, - hasYDropdown: true, - onXAxisClick, - }) - const xLabelBorder = findByTestAttr(wrapper, 'x-axis-label-border') - xLabelBorder.simulate('click') - expect(onXAxisClick).toHaveBeenCalled() - }) - }) - - describe('y label button', () => { - const yLabelBorder = findByTestAttr(wrapper, 'y-axis-label-border') - test('renders border', () => { - expect(yLabelBorder.exists()).toBe(true) - }) - test('doesnt crash when clicked and callback is not provided', () => { - yLabelBorder.simulate('click') - }) - test('calls onYAxisClick when provided', () => { - const onYAxisClick = jest.fn() - const wrapper = setup({ - ...exampleProps, - xCol: { title: 'x title test' }, - yCol: { title: 'y title test' }, - hasXDropdown: true, - hasYDropdown: true, - onYAxisClick, - }) - const yLabelBorder = findByTestAttr(wrapper, 'y-axis-label-border') - yLabelBorder.simulate('click') - expect(onYAxisClick).toHaveBeenCalled() - }) - }) - }) }) }) diff --git a/src/components/Charts/Axes/AxisSelector.js b/src/components/Charts/Axes/AxisSelector.js new file mode 100644 index 000000000..8f9fef01c --- /dev/null +++ b/src/components/Charts/Axes/AxisSelector.js @@ -0,0 +1,30 @@ +import React from 'react' +import _get from 'lodash.get' +import _isEqual from 'lodash.isequal' +import NumberAxisSelector from './NumberAxisSelector' +import StringAxisSelector from './StringAxisSelector' + +import { + isColumnNumberType, + isColumnStringType, +} from '../../QueryOutput/columnHelpers' +import { axesDefaultProps, axesPropTypes } from '../helpers' + +export default class AxisSelector extends React.Component { + static propTypes = axesPropTypes + static defaultProps = axesDefaultProps + + render = () => { + if (isColumnNumberType(this.props.column)) { + return ( + + ) + } else if (isColumnStringType(this.props.column)) { + return ( + + ) + } + + return null + } +} diff --git a/src/components/Charts/Axes/AxisSelector.test.js b/src/components/Charts/Axes/AxisSelector.test.js new file mode 100644 index 000000000..7a10c7fb2 --- /dev/null +++ b/src/components/Charts/Axes/AxisSelector.test.js @@ -0,0 +1,52 @@ +import React from 'react' +import { mount } from 'enzyme' +import AxisSelector from './AxisSelector' +import sampleProps from '../chartTestData' +import { findByTestAttr } from '../../../../test/testUtils' + +const pivotSampleProps = sampleProps.pivot +const defaultProps = AxisSelector.defaultProps + +const setup = (props = {}, state = null) => { + const setupProps = { ...defaultProps, ...props } + const wrapper = mount( + + + + ) + return wrapper +} + +describe('renders correctly', () => { + test('renders number axis selector if number column provided', () => { + const wrapper = setup({ + ...pivotSampleProps, + column: { + display_name: 'Total Volume', + groupable: false, + is_visible: true, + multi_series: false, + name: 'sum(v_volume.volume)', + type: 'QUANTITY', + }, + }) + const numberSelector = findByTestAttr(wrapper, 'number-axis-selector') + expect(numberSelector.exists()).toBe(true) + }) + + test('renders string axis selector if number column provided', () => { + const wrapper = setup({ + ...pivotSampleProps, + column: { + display_name: 'Sector', + groupable: true, + is_visible: true, + multi_series: false, + name: 'company.sector', + type: 'STRING', + }, + }) + const stringSelector = findByTestAttr(wrapper, 'string-axis-selector') + expect(stringSelector.exists()).toBe(true) + }) +}) diff --git a/src/components/Charts/Axes/NumberAxisSelector.js b/src/components/Charts/Axes/NumberAxisSelector.js new file mode 100644 index 000000000..1c6d4eae6 --- /dev/null +++ b/src/components/Charts/Axes/NumberAxisSelector.js @@ -0,0 +1,273 @@ +import React, { Fragment } from 'react' +import _get from 'lodash.get' +import _isEqual from 'lodash.isequal' +import Popover from 'react-tiny-popover' +import { SelectableList } from '../../SelectableList' +import { Button } from '../../Button' +import { + axesDefaultProps, + axesPropTypes, + dataStructureChanged, +} from '../helpers' + +export default class NumberAxisSelector extends React.Component { + constructor(props) { + super(props) + + this.currencySelectRef + this.quantitySelectRef + this.ratioSelectRef + + this.state = { + ...this.getSelectorState(props), + isOpen: false, + } + } + + static propTypes = axesPropTypes + static defaultProps = axesDefaultProps + + componentDidUpdate = (prevProps) => { + if (dataStructureChanged(this.props, prevProps)) { + this.setState({ + ...this.getSelectorState(this.props), + }) + } + } + + openSelector = () => { + this.setState({ isOpen: true }) + } + + closeSelector = () => { + this.setState({ isOpen: false }) + } + + getSelectorState = (props) => { + const { columns, numberColumnIndices } = props + + if (!columns || !numberColumnIndices) { + return + } + + const currencyItems = [] + const quantityItems = [] + const ratioItems = [] + + columns.forEach((col, i) => { + if (!col.is_visible) { + return + } + + const item = { + content: col.title, + checked: numberColumnIndices.includes(i), + columnIndex: i, + } + + if (col.type === 'DOLLAR_AMT') { + currencyItems.push(item) + } else if (col.type === 'QUANTITY') { + quantityItems.push(item) + } else if (col.type === 'RATIO' || col.type === 'PERCENT') { + ratioItems.push(item) + } + }) + + return { + activeNumberType: _get(columns, `[${numberColumnIndices[0]}].type`), + currencySelectorState: currencyItems, + quantitySelectorState: quantityItems, + ratioSelectorState: ratioItems, + } + } + + renderSelectorContent = (selectedColumn) => { + const { + currencySelectorState, + quantitySelectorState, + ratioSelectorState, + } = this.state + + return ( +
{ + e.stopPropagation() + }} + > +
+ {!!currencySelectorState.length && ( + +
+ {this.props.columns && this.props.legendColumn !== undefined + ? this.props.legendColumn.display_name + : 'Currency'} +
+ (this.currencySelectRef = r)} + items={currencySelectorState} + onSelect={() => { + this.quantitySelectRef && this.quantitySelectRef.unselectAll() + this.ratioSelectRef && this.ratioSelectRef.unselectAll() + }} + onChange={(currencySelectorState) => { + const newQuantitySelectorState = quantitySelectorState.map( + (item) => { + return { ...item, checked: false } + } + ) + const newRatioSelectorState = ratioSelectorState.map( + (item) => { + return { ...item, checked: false } + } + ) + + this.setState({ + activeNumberType: 'DOLLAR_AMT', + currencySelectorState, + quantitySelectorState: newQuantitySelectorState, + ratioSelectorState: newRatioSelectorState, + }) + }} + /> +
+ )} + + {!!quantitySelectorState.length && ( + +
+ {' '} + {this.props.columns && this.props.legendColumn !== undefined + ? this.props.legendColumn.display_name + : 'Quantity'} +
+ (this.quantitySelectRef = r)} + items={quantitySelectorState} + onSelect={() => { + this.currencySelectRef && this.currencySelectRef.unselectAll() + this.ratioSelectRef && this.ratioSelectRef.unselectAll() + }} + onChange={(quantitySelectorState) => { + const newCurrencySelectorState = currencySelectorState.map( + (item) => { + return { ...item, checked: false } + } + ) + const newRatioSelectorState = ratioSelectorState.map( + (item) => { + return { ...item, checked: false } + } + ) + this.setState({ + activeNumberType: 'QUANTITY', + quantitySelectorState, + currencySelectorState: newCurrencySelectorState, + ratioSelectorState: newRatioSelectorState, + }) + }} + /> +
+ )} + + {!!ratioSelectorState.length && ( + +
+ {' '} + {this.props.columns && this.props.legendColumn !== undefined + ? this.props.legendColumn.display_name + : 'Ratio'} +
+ (this.ratioSelectRef = r)} + items={ratioSelectorState} + onSelect={() => { + this.currencySelectRef && this.currencySelectRef.unselectAll() + this.quantitySelectRef && this.quantitySelectRef.unselectAll() + }} + onChange={(ratioSelectorState) => { + const newCurrencySelectorState = currencySelectorState.map( + (item) => { + return { ...item, checked: false } + } + ) + const newQuantitySelectorState = quantitySelectorState.map( + (item) => { + return { ...item, checked: false } + } + ) + + this.setState({ + activeNumberType: 'RATIO', + ratioSelectorState, + currencySelectorState: newCurrencySelectorState, + quantitySelectorState: newQuantitySelectorState, + }) + }} + /> +
+ )} +
+
+ +
+
+ ) + } + + render = () => { + return ( + + + + ) + } +} diff --git a/src/components/Charts/Axes/NumberAxisSelector.test.js b/src/components/Charts/Axes/NumberAxisSelector.test.js new file mode 100644 index 000000000..e3fea0bb1 --- /dev/null +++ b/src/components/Charts/Axes/NumberAxisSelector.test.js @@ -0,0 +1,26 @@ +import React from 'react' +import { mount } from 'enzyme' +import NumberAxisSelector from './NumberAxisSelector' +import sampleProps from '../chartTestData' +import { findByTestAttr } from '../../../../test/testUtils' + +const pivotSampleProps = sampleProps.pivot +const defaultProps = NumberAxisSelector.defaultProps + +const setup = (props = {}, state = null) => { + const setupProps = { ...defaultProps, ...props } + const wrapper = mount( + + + + ) + return wrapper +} + +describe('renders correctly', () => { + test('renders number axis selector if number column provided', () => { + const wrapper = setup(pivotSampleProps) + const numberSelector = findByTestAttr(wrapper, 'axis-label-border') + expect(numberSelector.exists()).toBe(true) + }) +}) diff --git a/src/components/Charts/Axes/StringAxisSelector.js b/src/components/Charts/Axes/StringAxisSelector.js new file mode 100644 index 000000000..eadf5ce4c --- /dev/null +++ b/src/components/Charts/Axes/StringAxisSelector.js @@ -0,0 +1,81 @@ +import React from 'react' +import _get from 'lodash.get' +import _isEqual from 'lodash.isequal' +import { v4 as uuid } from 'uuid' +import Popover from 'react-tiny-popover' +import { axesDefaultProps, axesPropTypes } from '../helpers' + +export default class StringAxisSelector extends React.Component { + constructor(props) { + super(props) + + this.state = { + isOpen: false, + } + } + + static propTypes = axesPropTypes + static defaultProps = axesDefaultProps + + openSelector = () => { + this.setState({ isOpen: true }) + } + + closeSelector = () => { + this.setState({ isOpen: false }) + } + + renderSelectorContent = () => { + return ( +
{ + e.stopPropagation() + }} + > +
    + {this.props.stringColumnIndices.map((colIndex, i) => { + return ( +
  • { + this.closeSelector() + this.props.changeStringColumnIndex(colIndex) + }} + > + {_get(this.props.columns, `[${colIndex}].title`)} +
  • + ) + })} +
+
+ ) + } + + render = () => { + return ( + + + + ) + } +} diff --git a/src/components/Charts/Axes/StringAxisSelector.test.js b/src/components/Charts/Axes/StringAxisSelector.test.js new file mode 100644 index 000000000..ac49cfd36 --- /dev/null +++ b/src/components/Charts/Axes/StringAxisSelector.test.js @@ -0,0 +1,26 @@ +import React from 'react' +import { mount } from 'enzyme' +import StringAxisSelector from './StringAxisSelector' +import sampleProps from '../chartTestData' +import { findByTestAttr } from '../../../../test/testUtils' + +const pivotSampleProps = sampleProps.pivot +const defaultProps = StringAxisSelector.defaultProps + +const setup = (props = {}, state = null) => { + const setupProps = { ...defaultProps, ...props } + const wrapper = mount( + + + + ) + return wrapper +} + +describe('renders correctly', () => { + test('renders number axis selector if number column provided', () => { + const wrapper = setup(pivotSampleProps) + const stringSelector = findByTestAttr(wrapper, 'axis-label-border') + expect(stringSelector.exists()).toBe(true) + }) +}) diff --git a/src/components/Charts/Axis/Axis.js b/src/components/Charts/Axis/Axis.js index 7d968fa39..77aee1ae1 100644 --- a/src/components/Charts/Axis/Axis.js +++ b/src/components/Charts/Axis/Axis.js @@ -9,15 +9,12 @@ import { axisLeft, axisBottom } from 'd3-axis' import { legendColor } from 'd3-svg-legend' import { symbol, symbolCircle } from 'd3-shape' import { scaleOrdinal } from 'd3-scale' +import LegendSelector from './LegendSelector' import { formatChartLabel, removeFromDOM } from '../../../js/Util.js' +import { axesDefaultProps, axesPropTypes } from '../helpers.js' import './Axis.scss' -import { themeConfigType, dataFormattingType } from '../../../props/types.js' -import { - themeConfigDefault, - dataFormattingDefault, -} from '../../../props/defaults.js' export default class Axis extends Component { constructor(props) { @@ -29,44 +26,21 @@ export default class Axis extends Component { } static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - + ...axesPropTypes, scale: PropTypes.func.isRequired, - margins: PropTypes.shape({}), - height: PropTypes.number, + col: PropTypes.shape({}).isRequired, + ticks: PropTypes.array, orient: PropTypes.string, tickSizeInner: PropTypes.number, translate: PropTypes.string, - ticks: PropTypes.array, - rotateLabels: PropTypes.bool, - type: PropTypes.string, - col: PropTypes.shape({}), - hasRightLegend: PropTypes.bool, - hasBottomLegend: PropTypes.bool, - onLegendClick: PropTypes.func, - onLegendTitleClick: PropTypes.func, - onLabelChange: PropTypes.func, - legendLabels: PropTypes.arrayOf(PropTypes.shape({})), } static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - margins: { - top: 0, - right: 0, - bottom: 0, - left: 0, - }, + ...axesDefaultProps, orient: 'Bottom', - hasRightLegend: false, - hasBottomLegend: false, - onLegendClick: () => {}, - onLabelChange: () => {}, - onLegendTitleClick: undefined, - legendLabels: undefined, + ticks: undefined, + tickSizeInner: undefined, + translate: undefined, } componentDidMount = () => { @@ -75,6 +49,7 @@ export default class Axis extends Component { // https://d3-legend.susielu.com/ this.renderLegend() } + this.props.onLabelChange() } @@ -84,7 +59,6 @@ export default class Axis extends Component { // only render legend once... unless labels changed if ( (this.props.hasRightLegend || this.props.hasBottomLegend) && - this.props.legendLabels?.length && !_isEqual(this.props.legendLabels, prevProps.legendLabels) ) { this.renderLegend() @@ -96,12 +70,7 @@ export default class Axis extends Component { } componentWillUnmount = () => { - removeFromDOM(this.axisElement) - removeFromDOM(this.legendBorder) removeFromDOM(this.legendElement) - removeFromDOM(this.legendClippingContainer) - removeFromDOM(this.bottomLegendElement) - removeFromDOM(this.rightLegendElement) removeFromDOM(this.legendSwatchElements) removeFromDOM(this.swatchElements) } @@ -184,7 +153,7 @@ export default class Axis extends Component { return } - const legendScale = this.getLegendScale() + const legendScale = this.getLegendScale(legendLabels) if (this.props.hasRightLegend) { this.legendSVG = select(this.rightLegendElement) @@ -208,7 +177,10 @@ export default class Axis extends Component { .labelWrap(100) .scale(legendScale) .on('cellclick', function(d) { - self.props.onLegendClick(d) + self.props.onLabelChange({ setLoading: false }) + self.props.onLegendClick( + legendLabels.find((label) => label.label === d) + ) }) if (this.props.legendTitle) { @@ -263,7 +235,7 @@ export default class Axis extends Component { this.legendSVG.call(legendOrdinal).style('font-family', 'inherit') } - this.applyStylesForHiddenSeries() + this.applyStylesForHiddenSeries(legendLabels) // todo: get this working properly // this.removeOverlappingLegendLabels() } catch (error) { @@ -271,9 +243,9 @@ export default class Axis extends Component { } } - applyStylesForHiddenSeries = () => { + applyStylesForHiddenSeries = (legendLabels) => { try { - const legendLabelTexts = this.props.legendLabels + const legendLabelTexts = legendLabels .filter((l) => { return l.hidden }) @@ -305,14 +277,14 @@ export default class Axis extends Component { } } - getLegendScale = () => { - const colorRange = this.props.legendLabels.map((obj) => { + getLegendScale = (legendLabels) => { + const colorRange = legendLabels.map((obj) => { return obj.color }) return scaleOrdinal() .domain( - this.props.legendLabels.map((obj) => { + legendLabels.map((obj) => { return obj.label }) ) @@ -406,10 +378,9 @@ export default class Axis extends Component { } render = () => { - const numSeries = - (this.props.legendLabels && this.props.legendLabels.length) || 0 + const numSeries = this.props.numberColumnIndices?.length || 0 const legendDx = (this.LEGEND_PADDING * (numSeries - 1)) / 2 - const marginLeft = this.props.margins.left || 0 + const marginLeft = this.props.leftMargin || 0 return ( @@ -441,22 +412,29 @@ export default class Axis extends Component { id={`legend-bounding-box-${this.LEGEND_ID}`} height={ this.props.height - - this.props.margins.top - + this.props.topMargin - // make legend smaller if labels are not rotated // because they might overlap the legend - (!this.props.rotateLabels ? this.props.margins.bottom : 44) + // distance to bottom of axis labels + (!this.props.rotateLabels ? this.props.bottomMargin : 44) + // distance to bottom of axis labels 20 // account for translation } - width={this.props.margins.right + 30} + width={this.props.rightMargin + 30} style={{ transform: 'translate(-30px, -30px)' }} /> - {this.props.onLegendTitleClick && ( - { - this.legendBorder = el + {this.props.legendColumn && ( + (this.legendBorder = r), + x: _get(this.titleBBox, 'x', 0) - 10, + y: _get(this.titleBBox, 'y', 0) - 10, + width: _get(this.titleBBox, 'width', 0) + 20, + height: _get(this.titleBBox, 'height', 0) + 10, }} - onClick={this.props.onLegendTitleClick} /> )} diff --git a/src/components/Charts/Axis/Axis.scss b/src/components/Charts/Axis/Axis.scss index 9e4e05c87..3152053d0 100644 --- a/src/components/Charts/Axis/Axis.scss +++ b/src/components/Charts/Axis/Axis.scss @@ -6,8 +6,7 @@ g.legendOrdinal tspan { cursor: pointer; } -.x-axis-label-border, -.y-axis-label-border, +.axis-label-border, .legend-title-border { cursor: pointer; stroke: transparent; diff --git a/src/components/Charts/Axis/Axis.test.js b/src/components/Charts/Axis/Axis.test.js index 8b91368aa..d362927c1 100644 --- a/src/components/Charts/Axis/Axis.test.js +++ b/src/components/Charts/Axis/Axis.test.js @@ -1,24 +1,14 @@ import React from 'react' import { mount } from 'enzyme' -import { scaleLinear, scaleBand } from 'd3-scale' - import Axis from './Axis' - import { findByTestAttr } from '../../../../test/testUtils' +import sampleProps from '../chartTestData' -const legendLabels = [ - { color: 'red', label: 'first' }, - { color: 'green', label: 'second' }, - { color: 'blue', label: 'third' }, -] +const pivotSampleProps = sampleProps.pivot +const datePivotSampleProps = sampleProps.datePivot +const listSampleProps = sampleProps.list -const defaultProps = { - height: 300, - width: 300, - scale: scaleBand() - .domain([50, 2, 35, 87]) - .range([0, 300]), -} +const defaultProps = Axis.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -31,47 +21,33 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders list data chart correctly', () => { + const wrapper = setup({ + ...listSampleProps, + col: listSampleProps.columns[listSampleProps.stringColumnIndex], + scale: listSampleProps.stringScale, + }) const axisComponent = findByTestAttr(wrapper, 'axis') expect(axisComponent.exists()).toBe(true) }) - describe('legend', () => { - test('does not render right legend by default', () => { - const wrapper = setup() - const legendElement = findByTestAttr(wrapper, 'right-legend') - expect(legendElement.exists()).toBe(false) + test('renders pivot data chart correctly', () => { + const wrapper = setup({ + ...pivotSampleProps, + col: pivotSampleProps.columns[pivotSampleProps.numberColumnIndex], + scale: pivotSampleProps.numberScale, }) + const axisComponent = findByTestAttr(wrapper, 'axis') + expect(axisComponent.exists()).toBe(true) + }) - test('does not render bottom legend by default', () => { - const wrapper = setup() - const legendElement = findByTestAttr(wrapper, 'bottom-legend') - expect(legendElement.exists()).toBe(false) + test('renders date pivot data chart correctly', () => { + const wrapper = setup({ + ...datePivotSampleProps, + col: datePivotSampleProps.columns[datePivotSampleProps.stringColumnIndex], + scale: datePivotSampleProps.stringScale, }) - - // todo: find a different way to test the d3 dom stuff because this doesnt work - - // test('renders right legend without title', () => { - // const wrapper = setup({ hasRightLegend: true, legendLabels }) - // const legendElement = findByTestAttr(wrapper, 'right-legend') - // expect(legendElement.exists()).toBe(true) - // }) - - // test('renders bottom legend without title', () => { - // const wrapper = setup({ hasBottomLegend: true, legendLabels }) - // const legendElement = findByTestAttr(wrapper, 'bottom-legend') - // expect(legendElement.exists()).toBe(true) - // }) - - // test('renders right legend with title', () => { - // const wrapper = setup({ - // hasRightLegend: true, - // legendTitle: 'Legend', - // legendLabels, - // }) - // const legendTitle = findByTestAttr(wrapper, 'legend-title') - // expect(legendTitle.exists()).toBe(true) - // }) + const axisComponent = findByTestAttr(wrapper, 'axis') + expect(axisComponent.exists()).toBe(true) }) }) diff --git a/src/components/Charts/Axis/LegendSelector.js b/src/components/Charts/Axis/LegendSelector.js new file mode 100644 index 000000000..443f3421a --- /dev/null +++ b/src/components/Charts/Axis/LegendSelector.js @@ -0,0 +1,79 @@ +import React from 'react' +import _get from 'lodash.get' +import _isEqual from 'lodash.isequal' +import { v4 as uuid } from 'uuid' +import Popover from 'react-tiny-popover' +import { axesDefaultProps, axesPropTypes } from '../helpers' + +export default class StringAxisSelector extends React.Component { + constructor(props) { + super(props) + + this.state = { + isOpen: false, + } + } + + static propTypes = axesPropTypes + static defaultProps = axesDefaultProps + + openSelector = () => { + this.setState({ isOpen: true }) + } + + closeSelector = () => { + this.setState({ isOpen: false }) + } + + renderSelectorContent = () => { + return ( +
{ + e.stopPropagation() + }} + > +
    + {this.props.stringColumnIndices.map((legendItem, i) => { + return ( +
  • { + this.props.onChangeLegendColumnIndex(i) + this.closeSelector() + }} + > + {legendItem.label} +
  • + ) + })} +
+
+ ) + } + + render = () => { + return ( + + + + ) + } +} diff --git a/src/components/Charts/Bars/Bars.js b/src/components/Charts/Bars/Bars.js index 65d3aa2f6..2b65e1566 100644 --- a/src/components/Charts/Bars/Bars.js +++ b/src/components/Charts/Bars/Bars.js @@ -1,91 +1,116 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' import _get from 'lodash.get' +import { + chartElementDefaultProps, + chartElementPropTypes, + getTooltipContent, + scaleZero, + getKey, +} from '../helpers' export default class Bars extends Component { - static propTypes = { - data: PropTypes.array.isRequired, - scales: PropTypes.shape({}).isRequired, - labelValue: PropTypes.string.isRequired, - activeKey: PropTypes.string, - onChartClick: PropTypes.func, - } - - static defaultProps = { - activeKey: undefined, - onChartClick: () => {}, - } + static propTypes = chartElementPropTypes + static defaultProps = chartElementDefaultProps state = { - activeKey: this.props.activeKey, - } - - X0 = () => { - const minValue = this.props.scales.xScale.domain()[0] - if (minValue > 0) { - return this.props.scales.xScale(minValue) - } - return this.props.scales.xScale(0) + activeKey: this.props.activeChartElementKey, } - X = (d, i) => this.props.scales.xScale(_get(d, `cells[${i}].value`)) - getKey = (d, i) => { - const { labelValue } = this.props - return `${d[labelValue]}-${d.cells[i].label}` - } + onBarClick = (row, colIndex, rowIndex) => { + const newActiveKey = getKey(colIndex, rowIndex) - onBarClick = (d, i) => { - const newActiveKey = this.getKey(d, i) - this.props.onChartClick({ - activeKey: newActiveKey, - drilldownData: d.cells[i].drilldownData, - }) + this.props.onChartClick( + row, + colIndex, + this.props.columns, + this.props.stringColumnIndex, + this.props.legendColumn, + this.props.numberColumnIndex, + newActiveKey + ) this.setState({ activeKey: newActiveKey }) } render = () => { - const { scales, labelValue } = this.props - const { yScale } = scales + const { + columns, + legendColumn, + numberColumnIndices, + stringColumnIndex, + dataFormatting, + yScale, + xScale, + } = this.props - const numberOfSeries = this.props.data[0].cells.length - const barHeight = yScale.bandwidth() / numberOfSeries + const visibleSeries = numberColumnIndices.filter((colIndex) => { + return !columns[colIndex].isSeriesHidden + }) - // Loop through each data value to make each series - const allBars = [] - for (let i = 0; i < numberOfSeries; i++) { - allBars.push( - this.props.data.map((d, index) => { - const y0 = yScale(d[labelValue]) - const dY = i * barHeight - const finalBarYPosition = y0 + dY - - let width = Math.abs(this.X(d, i) - this.X0()) - if (Number.isNaN(width)) { - width = 0 - } - - return ( - 0 ? this.X0() : this.X(d, i)} - width={width} - height={barHeight} - onClick={() => this.onBarClick(d, i)} - data-tip={_get(d, `cells[${i}].tooltipData`)} - data-for="chart-element-tooltip" - style={{ fill: d.cells[i].color, fillOpacity: 0.7 }} - /> - ) - }) - ) + if (!visibleSeries.length) { + return null } + const allBars = [] + const barHeight = yScale.bandwidth() / visibleSeries.length + + let visibleIndex = 0 + numberColumnIndices.forEach((colIndex, i) => { + if (!columns[colIndex].isSeriesHidden) { + allBars.push( + this.props.data.map((d, index) => { + const value = d[colIndex] + if (!value) { + return null + } + + let width = Math.abs(xScale(value) - scaleZero(xScale)) + if (Number.isNaN(width)) { + width = 0 + } + + if (width < 0.05) { + return null + } + + const y0 = yScale(d[stringColumnIndex]) + const dY = visibleIndex * barHeight + const finalBarYPosition = y0 + dY + + const tooltip = getTooltipContent({ + row: d, + columns, + colIndex, + stringColumnIndex, + legendColumn, + dataFormatting, + }) + + return ( + 0 ? scaleZero(xScale) : xScale(value)} + width={width} + height={barHeight} + onClick={() => this.onBarClick(d, colIndex, index)} + data-tip={tooltip} + data-for="chart-element-tooltip" + style={{ fill: this.props.colorScale(i), fillOpacity: 0.7 }} + /> + ) + }) + ) + visibleIndex += 1 + } + }) + return {allBars} } } diff --git a/src/components/Charts/Bars/Bars.test.js b/src/components/Charts/Bars/Bars.test.js index e6a6d0e47..b24ed5adf 100644 --- a/src/components/Charts/Bars/Bars.test.js +++ b/src/components/Charts/Bars/Bars.test.js @@ -1,27 +1,31 @@ import React from 'react' import { shallow } from 'enzyme' -import { scaleLinear, scaleBand } from 'd3-scale' - import { findByTestAttr } from '../../../../test/testUtils' import Bars from './Bars' +import sampleProps from '../chartTestData' + +const scales = { + xScale: sampleProps.pivot.numberScale, + yScale: sampleProps.pivot.stringScale, +} + +const pivotSampleProps = { + ...sampleProps.pivot, + ...scales, +} -const defaultProps = { - labelValue: 'label', - data: [ - { cells: [{ value: 50 }, { value: -75 }], label: 'label1`' }, - { cells: [{ value: 30 }, { value: 65 }], label: 'label2`' }, - ], - scales: { - xScale: scaleLinear() - .domain([0, 100]) - .range([0, 300]), - yScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - }, +const datePivotSampleProps = { + ...sampleProps.datePivot, + ...scales, } +const listSampleProps = { + ...sampleProps.list, + ...scales, +} + +const defaultProps = Bars.defaultProps + const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } const wrapper = shallow() @@ -32,37 +36,39 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders list data chart correctly', () => { + const wrapper = setup(listSampleProps) + const barsComponent = findByTestAttr(wrapper, 'bars') + expect(barsComponent.exists()).toBe(true) + }) + + test('renders date pivot data chart correctly', () => { + const wrapper = setup(datePivotSampleProps) + const barsComponent = findByTestAttr(wrapper, 'bars') + expect(barsComponent.exists()).toBe(true) + }) + + test('renders date pivot data chart correctly', () => { + const wrapper = setup(pivotSampleProps) const barsComponent = findByTestAttr(wrapper, 'bars') expect(barsComponent.exists()).toBe(true) }) test('renders correctly when min value is greater than 0', () => { - const wrapper = setup({ - scales: { - xScale: scaleLinear() - .domain([50, 100]) - .range([0, 300]), - yScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - }, - }) + const wrapper = setup(listSampleProps) const barsComponent = findByTestAttr(wrapper, 'bars') expect(barsComponent.exists()).toBe(true) }) test('renders first bar correctly', () => { - const wrapper = setup() + const wrapper = setup(listSampleProps) const firstBar = findByTestAttr(wrapper, 'bar-0-0') expect(firstBar.exists()).toBe(true) }) describe('active key', () => { test('no active key by default', () => { - const wrapper = setup() + const wrapper = setup(listSampleProps) const activeKey = wrapper.state(['activeKey']) expect(activeKey).toBeUndefined() }) @@ -70,21 +76,21 @@ describe('renders correctly', () => { describe('on bar click', () => { test('does not crash when onChartClick is not provided', () => { - const wrapper = setup() + const wrapper = setup(listSampleProps) const firstBar = findByTestAttr(wrapper, 'bar-0-0') firstBar.simulate('click') }) test('calls onChartClick when bar is clicked', () => { const onChartClick = jest.fn() - const wrapper = setup({ onChartClick }) + const wrapper = setup({ ...listSampleProps, onChartClick }) const firstBar = findByTestAttr(wrapper, 'bar-0-0') firstBar.simulate('click') expect(onChartClick).toHaveBeenCalled() }) test('sets active bar when clicked', () => { - const wrapper = setup() + const wrapper = setup(listSampleProps) const firstBar = findByTestAttr(wrapper, 'bar-0-0') firstBar.simulate('click') const activeKey = wrapper.state(['activeKey']) diff --git a/src/components/Charts/ChataBarChart/ChataBarChart.js b/src/components/Charts/ChataBarChart/ChataBarChart.js index a453ae417..bb26059ab 100644 --- a/src/components/Charts/ChataBarChart/ChataBarChart.js +++ b/src/components/Charts/ChataBarChart/ChataBarChart.js @@ -1,23 +1,20 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' -import { Axes } from '../Axes' -import { Bars } from '../Bars' import { scaleLinear, scaleBand } from 'd3-scale' +import _isEqual from 'lodash.isequal' import _get from 'lodash.get' -import { getMinAndMaxValues, getTickValues } from '../helpers.js' -import { - shouldLabelsRotate, - getLongestLabelInPx, - formatChartLabel, -} from '../../../js/Util' -import { themeConfigType, dataFormattingType } from '../../../props/types' +import { Axes } from '../Axes' +import { Bars } from '../Bars' + import { - themeConfigDefault, - dataFormattingDefault, - getDataFormatting, - getThemeConfig, -} from '../../../props/defaults' + getTickValues, + chartPropTypes, + chartDefaultProps, + getMinAndMaxValues, + shouldRecalculateLongestLabel, +} from '../helpers.js' +import { shouldLabelsRotate, getLongestLabelInPx } from '../../../js/Util' +import { getDataFormatting } from '../../../props/defaults' export default class ChataBarChart extends Component { constructor(props) { @@ -28,41 +25,8 @@ export default class ChataBarChart extends Component { this.setLabelRotationValue(props) } - static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - leftMargin: PropTypes.number, - rightMargin: PropTypes.number, - topMargin: PropTypes.number, - bottomMargin: PropTypes.number, - labelValue: PropTypes.string, - onLabelChange: PropTypes.func, - numberColumnIndices: PropTypes.arrayOf(PropTypes.number), - stringColumnIndex: PropTypes.number, - numberColumnIndex: PropTypes.number, - onXAxisClick: PropTypes.func, - onYAxisClick: PropTypes.func, - } - - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - leftMargin: 0, - rightMargin: 0, - topMargin: 0, - bottomMargin: 0, - labelValue: 'label', - numberColumnIndices: [], - onXAxisClick: () => {}, - onYAxisClick: () => {}, - onLabelChange: () => {}, - } + static propTypes = chartPropTypes + static defaultProps = chartDefaultProps componentDidMount = () => { this.props.onLabelChange() @@ -73,10 +37,7 @@ export default class ChataBarChart extends Component { } componentDidUpdate = (prevProps) => { - if ( - this.props.marginAdjustmentFinished && - prevProps?.data?.length !== this.props.data?.length - ) { + if (shouldRecalculateLongestLabel(prevProps, this.props)) { this.setLongestLabelWidth(this.props) } } @@ -96,15 +57,19 @@ export default class ChataBarChart extends Component { setLongestLabelWidth = (props) => { this.longestLabelWidth = getLongestLabelInPx( this.xLabelArray, - this.props.columns[this.props.numberColumnIndex], + props.columns[props.numberColumnIndex], getDataFormatting(props.dataFormatting) ) } setChartData = (props) => { - const { minValue, maxValue } = getMinAndMaxValues(props.data) - this.minValue = minValue - this.maxValue = maxValue + let numberColumnIndices = props.numberColumnIndices + if (props.visibleSeriesIndices?.length) + numberColumnIndices = props.visibleSeriesIndices + const { minValue, maxValue } = getMinAndMaxValues( + props.data, + numberColumnIndices + ) this.xScale = scaleLinear() .domain([minValue, maxValue]) @@ -112,12 +77,15 @@ export default class ChataBarChart extends Component { .nice() this.yScale = scaleBand() - .domain(props.data.map((d) => d[props.labelValue])) + .domain(props.data.map((d) => d[props.stringColumnIndex])) .range([props.height - props.bottomMargin, props.topMargin]) .paddingInner(props.innerPadding) .paddingOuter(props.outerPadding) - this.yLabelArray = props.data.map((element) => element[props.labelValue]) + this.yLabelArray = props.data.map( + (element) => element[props.stringColumnIndex] + ) + this.xLabelArray = this.xScale.ticks() this.barHeight = props.height / props.data.length @@ -135,31 +103,15 @@ export default class ChataBarChart extends Component { return ( {this.props.marginAdjustmentFinished && ( - + )} ) diff --git a/src/components/Charts/ChataBarChart/ChataBarChart.test.js b/src/components/Charts/ChataBarChart/ChataBarChart.test.js index 9bbd776e2..43dd11317 100644 --- a/src/components/Charts/ChataBarChart/ChataBarChart.test.js +++ b/src/components/Charts/ChataBarChart/ChataBarChart.test.js @@ -1,29 +1,14 @@ import React from 'react' import { shallow } from 'enzyme' - import { findByTestAttr } from '../../../../test/testUtils' import ChataBarChart from './ChataBarChart' +import sampleProps from '../chartTestData' -const defaultProps = { - data: [ - { - cells: [{ value: 50 }, { value: 75 }], - label: 'label1', - origRow: ['label1', 50, 75], - }, - { - cells: [{ value: 30 }, { value: 65 }], - label: 'label2', - origRow: ['label2', 30, 65], - }, - ], - numberColumnIndex: 1, - numberColumnIndices: [1, 2], - stringColumnIndex: 1, - columns: [{}, {}, {}], - height: 300, - width: 300, -} +const pivotSampleProps = sampleProps.pivot +const datePivotSampleProps = sampleProps.datePivot +const listSampleProps = sampleProps.list + +const defaultProps = ChataBarChart.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -35,42 +20,21 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders list data chart correctly', () => { + const wrapper = setup(listSampleProps) + const barChartComponent = findByTestAttr(wrapper, 'react-autoql-bar-chart') + expect(barChartComponent.exists()).toBe(true) + }) + + test('renders pivot data chart correctly', () => { + const wrapper = setup(pivotSampleProps) const barChartComponent = findByTestAttr(wrapper, 'react-autoql-bar-chart') expect(barChartComponent.exists()).toBe(true) }) -}) -// describe('onLabelChange is called when labels get rotated', () => { -// const onLabelChange = jest.fn() -// const wrapper = setup({ onLabelChange }) -// wrapper.setProps({ -// data: [ -// { -// cells: [ -// { -// value: 50, -// label: 'label1', -// }, -// { -// value: 75, -// label: -// 'label1 thats really really really really really really really really long', -// }, -// ], -// label: 'test label', -// origRow: ['label1', 50, 75], -// }, -// { -// cells: [ -// { value: 30, label: 'one' }, -// { value: 65, label: 'two' }, -// ], -// label: 'test label 2', -// origRow: ['label2', 30, 65], -// }, -// ], -// }) -// expect(onLabelChange).toHaveBeenCalled() -// }) + test('renders date pivot data chart correctly', () => { + const wrapper = setup(datePivotSampleProps) + const barChartComponent = findByTestAttr(wrapper, 'react-autoql-bar-chart') + expect(barChartComponent.exists()).toBe(true) + }) +}) diff --git a/src/components/Charts/ChataBubbleChart/ChataBubbleChart.js b/src/components/Charts/ChataBubbleChart/ChataBubbleChart.js index 0d984ad87..1274c1d47 100644 --- a/src/components/Charts/ChataBubbleChart/ChataBubbleChart.js +++ b/src/components/Charts/ChataBubbleChart/ChataBubbleChart.js @@ -1,62 +1,28 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' import { scaleBand } from 'd3-scale' -import { max, min } from 'd3-array' import { Axes } from '../Axes' import { Circles } from '../Circles' import { shouldLabelsRotate, getLongestLabelInPx } from '../../../js/Util.js' -import { themeConfigType, dataFormattingType } from '../../../props/types' +import { getDataFormatting } from '../../../props/defaults' import { - themeConfigDefault, - dataFormattingDefault, - getDataFormatting, - getThemeConfig, -} from '../../../props/defaults' + chartDefaultProps, + chartPropTypes, + getTickValues, + shouldRecalculateLongestLabel, +} from '../helpers.js' export default class ChataBubbleChart extends Component { constructor(props) { super(props) + this.setChartData(props) this.setLongestLabelWidth(props) this.setLabelRotationValue(props) } - static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - leftMargin: PropTypes.number, - rightMargin: PropTypes.number, - topMargin: PropTypes.number, - bottomMargin: PropTypes.number, - dataValue: PropTypes.string, - labelValueX: PropTypes.string, - labelValueY: PropTypes.string, - onLabelChange: PropTypes.func, - onXAxisClick: PropTypes.func, - onYAxisClick: PropTypes.func, - } - - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - leftMargin: 0, - rightMargin: 0, - topMargin: 0, - bottomMargin: 0, - dataValue: 'value', - labelValueX: 'labelX', - labelValueY: 'labelY', - onXAxisClick: () => {}, - onYAxisClick: () => {}, - onLabelChange: () => {}, - } + static propTypes = chartPropTypes + static defaultProps = chartDefaultProps componentDidMount = () => { this.props.onLabelChange() @@ -67,17 +33,14 @@ export default class ChataBubbleChart extends Component { } componentDidUpdate = (prevProps) => { - if ( - this.props.marginAdjustmentFinished && - prevProps?.data?.length !== this.props.data?.length - ) { + if (shouldRecalculateLongestLabel(prevProps, this.props)) { this.setLongestLabelWidth(this.props) } } setLabelRotationValue = (props) => { const rotateLabels = shouldLabelsRotate( - this.squareWidth, + this.xScale.bandwidth(), this.longestLabelWidth ) @@ -88,78 +51,27 @@ export default class ChataBubbleChart extends Component { setLongestLabelWidth = (props) => { this.longestLabelWidth = getLongestLabelInPx( - this.uniqueXLabels, - this.props.columns[0], + this.xTickValues, + props.columns[props.stringColumnIndex], getDataFormatting(props.dataFormatting) ) } setChartData = (props) => { - this.maxValue = max(props.data, (d) => max(d.cells, (cell) => cell.value)) - this.minValue = min(props.data, (d) => min(d.cells, (cell) => cell.value)) - this.uniqueXLabels = props.data.map((d) => d.label) - this.uniqueYLabels = props.data[0].cells.map((cell) => cell.label) - this.xScale = scaleBand() - .domain(this.uniqueXLabels) + .domain(props.data.map((d) => d[props.stringColumnIndex])) .range([props.leftMargin, props.width - props.rightMargin]) .paddingOuter(0.5) this.yScale = scaleBand() - .domain(this.uniqueYLabels) + .domain(props.legendLabels.map((d) => d.label)) .range([props.height - props.bottomMargin, props.topMargin]) - this.squareWidth = this.xScale.bandwidth() - - this.xTickValues = this.getXTickValues() - this.yTickValues = this.getYTickValues() - } - - getXTickValues = () => { - try { - const interval = Math.ceil( - (this.props.data.length * 16) / this.props.width - ) // we should take into account the outer padding here - let xTickValues - - if (this.squareWidth < 16) { - xTickValues = [] - this.uniqueXLabels.forEach((label, index) => { - if (index % interval === 0) { - xTickValues.push(label) - } - }) - } - - return xTickValues - } catch (error) { - console.error(error) - return [] - } - } - - getYTickValues = () => { - this.squareHeight = this.props.height / this.uniqueYLabels.length - const intervalHeight = Math.ceil( - (this.uniqueYLabels.length * 16) / this.props.height + this.xTickValues = getTickValues( + this.xScale.bandwidth(), + props.width, + this.xScale.domain() ) - - try { - let yTickValues - if (this.squareHeight < 16) { - yTickValues = [] - this.uniqueYLabels.forEach((element, index) => { - if (index % intervalHeight === 0) { - yTickValues.push(element) - } - }) - } - - return yTickValues - } catch (error) { - console.error(error) - return [] - } } render = () => { @@ -172,50 +84,16 @@ export default class ChataBubbleChart extends Component { data-test="react-autoql-bubble-chart" > - +
) } diff --git a/src/components/Charts/ChataBubbleChart/ChataBubbleChart.test.js b/src/components/Charts/ChataBubbleChart/ChataBubbleChart.test.js index 8cd16caa1..86be23561 100644 --- a/src/components/Charts/ChataBubbleChart/ChataBubbleChart.test.js +++ b/src/components/Charts/ChataBubbleChart/ChataBubbleChart.test.js @@ -1,26 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' - import { findByTestAttr } from '../../../../test/testUtils' import ChataBubbleChart from './ChataBubbleChart' +import sampleProps from '../chartTestData' -const defaultProps = { - data: [ - { - cells: [{ value: 50 }, { value: 75 }], - label: 'label1`', - origRow: ['label1', 50, 75], - }, - { - cells: [{ value: 30 }, { value: 65 }], - label: 'label2`', - origRow: ['label2', 30, 65], - }, - ], - columns: [{}, {}, {}], - height: 300, - width: 300, -} +const pivotSampleProps = sampleProps.pivot +const defaultProps = ChataBubbleChart.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -33,7 +18,7 @@ const setup = (props = {}, state = null) => { describe('renders correctly', () => { test('renders correctly with required props', () => { - const wrapper = setup() + const wrapper = setup(pivotSampleProps) const bubbleChartComponent = findByTestAttr( wrapper, 'react-autoql-bubble-chart' diff --git a/src/components/Charts/ChataChart/ChataChart.js b/src/components/Charts/ChataChart/ChataChart.js index 9992d62e3..5dd3d0f64 100644 --- a/src/components/Charts/ChataChart/ChataChart.js +++ b/src/components/Charts/ChataChart/ChataChart.js @@ -4,7 +4,9 @@ import { v4 as uuid } from 'uuid' import _get from 'lodash.get' import _isEqual from 'lodash.isequal' import _reduce from 'lodash.reduce' -import disableScroll from 'disable-scroll' +import _sortBy from 'lodash.sortby' +import _cloneDeep from 'lodash.clonedeep' +import _isEmpty from 'lodash.isempty' import { select } from 'd3-selection' import { scaleOrdinal } from 'd3-scale' @@ -17,26 +19,25 @@ import { ChataHeatmapChart } from '../ChataHeatmapChart' import { ChataBubbleChart } from '../ChataBubbleChart' import { ChataStackedBarChart } from '../ChataStackedBarChart' import { ChataStackedColumnChart } from '../ChataStackedColumnChart' -import { SelectableList } from '../../SelectableList' -import { Button } from '../../Button' +import { ChataStackedLineChart } from '../ChataStackedLineChart' +import { getThemeConfig } from '../../../props/defaults' import ErrorBoundary from '../../../containers/ErrorHOC/ErrorHOC' - import { svgToPng } from '../../../js/Util.js' -import { getLegendLabelsForMultiSeries, getLegendLocation } from '../helpers.js' -import './ChataChart.scss' -import Popover from 'react-tiny-popover' -import { ChataStackedLineChart } from '../ChataStackedLineChart' import { - themeConfigType, - dataFormattingType, - dataConfigType, -} from '../../../props/types' + chartContainerDefaultProps, + chartContainerPropTypes, + dataStructureChanged, + getLegendLabelsForMultiSeries, + getLegendLocation, +} from '../helpers.js' + +import './ChataChart.scss' import { - themeConfigDefault, - dataFormattingDefault, - getThemeConfig, -} from '../../../props/defaults' + getColumnTypeAmounts, + getTotalNumberColumns, + getTotalStringColumns, +} from '../../QueryOutput/columnHelpers' export default class ChataChart extends Component { INNER_PADDING = 0.25 @@ -51,13 +52,13 @@ export default class ChataChart extends Component { this.X_AXIS_LABEL_HEIGHT = 15 this.PADDING = 20 - this.colorScale = scaleOrdinal().range(chartColors) - this.filteredSeriesData = this.getFilteredSeriesData(props.data) this.firstRender = true this.recursiveUpdateCount = 0 + this.colorScale = scaleOrdinal().range(chartColors) + this.state = { - ...this.getNumberColumnSelectorState(props), + aggregatedData: !props.isPivot ? this.aggregateRowData(props) : undefined, leftMargin: this.PADDING, rightMargin: this.PADDING, topMargin: this.PADDING, @@ -75,31 +76,11 @@ export default class ChataChart extends Component { } static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - dataConfig: dataConfigType, - - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - tableColumns: PropTypes.arrayOf(PropTypes.shape({})), + ...chartContainerPropTypes, type: PropTypes.string.isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - onLegendClick: PropTypes.func, - enableDynamicCharting: PropTypes.bool, - isResizing: PropTypes.bool, } - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - dataConfig: undefined, - - tableColumns: [], - enableDynamicCharting: true, - isResizing: false, - onLegendClick: () => {}, - } + static defaultProps = chartContainerDefaultProps componentDidMount = () => { // The first render is to determine the chart size based on its parent container @@ -122,20 +103,24 @@ export default class ChataChart extends Component { } componentDidUpdate = (prevProps, prevState) => { + let newState = {} + let shouldForceUpdate = false + let shouldUpdateMargins = false + const chartWidth = _get(this.chartContainerRef, 'offsetWidth', 0) const chartHeight = _get(this.chartContainerRef, 'offsetHeight', 0) + if ( !this.state.isLoading && this.recursiveUpdateCount < 2 && (chartWidth !== this.chartWidth || chartHeight !== this.chartHeight) ) { - // Be careful with this to avoid infinite loop + shouldForceUpdate = true this.recursiveUpdateCount++ clearTimeout(this.recursiveUpdateTimeout) this.recursiveUpdateTimeout = setTimeout(() => { this.recursiveUpdateCount = 0 }, 500) - this.forceUpdate() } if (!this.props.isResizing && prevProps.isResizing) { @@ -143,29 +128,32 @@ export default class ChataChart extends Component { // No need to update margins, they should stay the same if (this.chartContainerRef) { this.chartContainerRef.style.flexBasis = '100vh' - this.forceUpdate() + shouldForceUpdate = true } } - if (!_isEqual(this.props.dataConfig, prevProps.dataConfig)) { - this.updateMargins() + if (dataStructureChanged(this.props, prevProps)) { + shouldUpdateMargins = true + if (!this.props.isPivot) { + newState.aggregatedData = this.aggregateRowData(this.props) + } } - if ( - !_isEqual(this.props.columns, prevProps.columns) || - !_isEqual(this.props.tableColumns, prevProps.tableColumns) || - !_isEqual(this.props.dataConfig, prevProps.dataConfig) || - (this.props.type === 'pie' && !_isEqual(this.props.data, prevProps.data)) - ) { - this.filteredSeriesData = this.getFilteredSeriesData(this.props.data) - this.setNumberColumnSelectorState() + // --------- Only update state once after checking new props ----------- + // ----------- keep this at the bottom of componentDidMount ------------ + if (!_isEmpty(newState)) { + shouldForceUpdate = false + this.setState(newState, () => { + if (shouldUpdateMargins) this.updateMargins() + }) + return + } else if (shouldUpdateMargins) { + this.updateMargins() + return } - // Disable scrolling while the popover is open - if (!prevState.activeAxisSelector && this.state.activeAxisSelector) { - disableScroll.on() - } else if (prevState.activeAxisSelector && !this.state.activeAxisSelector) { - disableScroll.off() + if (shouldForceUpdate) { + this.forceUpdate() } } @@ -181,44 +169,36 @@ export default class ChataChart extends Component { this.axes = undefined } - getNumberColumnSelectorState = (props) => { - const { tableColumns } = props - const { numberColumnIndices } = props.dataConfig + aggregateRowData = (props) => { + const { stringColumnIndex, numberColumnIndices } = props + const sortedData = _sortBy(props.data, (row) => row?.[stringColumnIndex]) + const aggregatedData = [] - if (!tableColumns || !numberColumnIndices) { - return - } - - const currencyItems = [] - const quantityItems = [] - const ratioItems = [] - - tableColumns.forEach((col, i) => { - const item = { - content: col.title, - checked: numberColumnIndices.includes(i), - columnIndex: i, - } - - if (col.type === 'DOLLAR_AMT') { - currencyItems.push(item) - } else if (col.type === 'QUANTITY') { - quantityItems.push(item) - } else if (col.type === 'RATIO' || col.type === 'PERCENT') { - ratioItems.push(item) + let rowSum = _cloneDeep(sortedData[0]) + sortedData.forEach((currentRow, i) => { + if (i === sortedData.length - 1) { + aggregatedData.push(rowSum) + } else if (i === 0) { + return + } else if ( + currentRow?.[stringColumnIndex] !== rowSum?.[stringColumnIndex] + ) { + aggregatedData.push(rowSum) + rowSum = _cloneDeep(currentRow) + } else { + const newRow = _cloneDeep(currentRow) + numberColumnIndices.forEach((columnIndex) => { + let currentValue = Number(newRow[columnIndex]) + let sumValue = Number(rowSum[columnIndex]) + if (isNaN(currentValue)) currentValue = 0 + if (isNaN(sumValue)) sumValue = 0 + newRow[columnIndex] = currentValue + sumValue + }) + rowSum = newRow } }) - return { - activeNumberType: _get(tableColumns, `[${numberColumnIndices[0]}].type`), - currencySelectorState: currencyItems, - quantitySelectorState: quantityItems, - ratioSelectorState: ratioItems, - } - } - - setNumberColumnSelectorState = () => { - this.setState(this.getNumberColumnSelectorState(this.props)) + return aggregatedData } getNewLeftMargin = (chartContainerBbox, axesBbox) => { @@ -250,6 +230,14 @@ export default class ChataChart extends Component { return rightMargin } + getLegendLabels = () => { + return getLegendLabelsForMultiSeries( + this.props.columns, + this.colorScale, + this.props.numberColumnIndices + ) + } + getNewBottomMargin = (chartContainerBbox) => { let legendBBox this.legend = select(this.chartRef) @@ -259,15 +247,17 @@ export default class ChataChart extends Component { let bottomLegendMargin = 0 const legendLocation = getLegendLocation( - _get(this.props.dataConfig, 'numberColumnIndices'), + this.props.numberColumnIndices, this.props.type ) if (legendLocation === 'bottom' && _get(legendBBox, 'height')) { bottomLegendMargin = legendBBox.height + 10 } + this.xAxis = select(this.chartRef) .select('.axis-Bottom') .node() + const xAxisBBox = this.xAxis ? this.xAxis.getBBox() : {} let bottomMargin = Math.ceil(xAxisBBox.height) + bottomLegendMargin + 40 // margin to include axis label if (xAxisBBox.height === 0) { @@ -311,14 +301,14 @@ export default class ChataChart extends Component { }, 500) } - updateMargins = (delay = 100) => { - if (!this.state.isLoading) { + updateMargins = ({ setLoading = true, delay = 100 } = {}) => { + if (!this.state.isLoading && setLoading) { this.setState({ isLoading: true }) } clearTimeout(this.updateMarginsThrottled) this.updateMarginsThrottled = setTimeout(() => { - this.updateMarginsToThrottle(delay) + this.updateMarginsToThrottle() }, delay) } @@ -434,19 +424,21 @@ export default class ChataChart extends Component { } getStringAxisTitle = () => { - return _get( - this.props.tableColumns, - `[${_get(this.props, 'dataConfig.stringColumnIndex')}].display_name` - ) + const { columns, stringColumnIndex } = this.props + return columns?.[stringColumnIndex]?.display_name } getNumberAxisTitle = () => { + const { columns, numberColumnIndices } = this.props try { - const { columns, dataConfig } = this.props const numberColumns = columns.filter((col, i) => { - return _get(dataConfig, 'numberColumnIndices').includes(i) + return numberColumnIndices.includes(i) }) + if (!numberColumns?.length) { + return undefined + } + // If there are different titles for any of the columns, return a generic label based on the type const allTitlesEqual = !numberColumns.find((col) => { return col.display_name !== numberColumns[0].display_name @@ -483,524 +475,87 @@ export default class ChataChart extends Component { bottomLegendMargin, } = this.state - const { - activeChartElementKey, - enableDynamicCharting, - dataFormatting, - onLegendClick, - onChartClick, - tableColumns, - themeConfig, - columns, - } = this.props - - const { - stringColumnIndices, - numberColumnIndices, - stringColumnIndex, - numberColumnIndex, - legendColumnIndex, - } = this.props.dataConfig + const { numberColumnIndices, columns } = this.props let innerPadding = this.INNER_PADDING if (numberColumnIndices.length > 1) { innerPadding = 0.1 } + const { + amountOfNumberColumns, + amountOfStringColumns, + } = getColumnTypeAmounts(columns) + const hasMultipleNumberColumns = amountOfNumberColumns > 1 + const hasMultipleStringColumns = amountOfStringColumns > 1 + + const visibleSeriesIndices = numberColumnIndices.filter( + (colIndex) => !columns[colIndex].isSeriesHidden + ) + return { - data: this.filteredSeriesData, + ...this.props, + key: undefined, + data: this.state.aggregatedData || this.props.data, colorScale: this.colorScale, innerPadding, outerPadding: this.OUTER_PADDING, - onXAxisClick: (e) => { - this.setState({ - activeAxisSelector: 'x', - axisSelectorLocation: { left: e.pageX, top: e.pageY }, - }) - }, - onYAxisClick: (e) => { - this.setState({ - activeAxisSelector: 'y', - axisSelectorLocation: { left: e.pageX, top: e.pageY }, - }) - }, - onLegendTitleClick: !!this.props.tableColumns[legendColumnIndex] - ? (e) => { - this.setState({ - activeAxisSelector: 'legend', - axisSelectorLocation: { left: e.pageX, top: e.pageY }, - }) - } - : undefined, - onLabelChange: this.updateMargins, + colorScale: this.colorScale, height: this.chartHeight, width: this.chartWidth, - columns, - tableColumns, topMargin, bottomMargin, rightMargin, leftMargin, - isResizing: this.props.isResizing, - isAnimatingContainer: this.props.isAnimatingContainer, - marginAdjustmentFinished: this.state.loading, bottomLegendMargin, - onChartClick, - dataFormatting, - themeConfig, - activeChartElementKey, - onLegendClick, - stringColumnIndex, - stringColumnIndices, - numberColumnIndex, - numberColumnIndices, + hasMultipleNumberColumns, + hasMultipleStringColumns, + marginAdjustmentFinished: this.state.loading, + legendTitle: this.props.legendColumn?.title || 'Category', + legendLocation: getLegendLocation(numberColumnIndices, this.props.type), + legendLabels: this.getLegendLabels(), + visibleSeriesIndices, numberAxisTitle: this.getNumberAxisTitle(), stringAxisTitle: this.getStringAxisTitle(), - enableDynamicCharting, - hasMultipleNumberColumns: - [ - ...this.state.currencySelectorState, - ...this.state.quantitySelectorState, - ...this.state.ratioSelectorState, - ].length > 1, - hasMultipleStringColumns: stringColumnIndices.length > 1, - legendLocation: getLegendLocation(numberColumnIndices, this.props.type), - legendColumn: this.props.tableColumns[legendColumnIndex], - legendLabels: getLegendLabelsForMultiSeries( - this.props.columns, - this.colorScale, - numberColumnIndices - ), - } - } - - getFilteredSeriesData = (data) => { - if (_get(data, '[0].cells')) { - try { - const filteredSeriesData = data.map((d) => { - const newCells = d.cells.filter((cell) => { - return !cell.hidden - }) - - return { - ...d, - cells: newCells, - } - }) - - return filteredSeriesData - } catch (error) { - console.error(error) - return data - } + onStringColumnSelect: this.onStringColumnSelect, + onLabelChange: this.updateMargins, } - return data } moveIndexToFront = (index, array) => { - const newArray = [...array] + const newArray = _cloneDeep(array) const itemToRemove = array[index] newArray.slice(index, index + 1) newArray.unshift(itemToRemove) return newArray } - renderStringColumnSelectorContent = () => { - return ( -
{ - e.stopPropagation() - }} - > -
    - {this.props.dataConfig.stringColumnIndices.map((colIndex, i) => { - return ( -
  • { - this.props.changeStringColumnIndex(colIndex) - this.setState({ activeAxisSelector: undefined }) - }} - > - {_get(this.props.tableColumns, `[${colIndex}].title`)} -
  • - ) - })} -
-
- ) - } - - renderNumberColumnSelectorContent = () => { - const { - currencySelectorState, - quantitySelectorState, - ratioSelectorState, - } = this.state - - return ( -
{ - e.stopPropagation() - }} - > -
- {!!currencySelectorState.length && ( - -
- {this.props.dataConfig && - this.props.tableColumns && - this.props.dataConfig.legendColumnIndex !== undefined - ? this.props.tableColumns[ - this.props.dataConfig.legendColumnIndex - ].display_name - : 'Currency'} -
- (this.currencySelectRef = r)} - items={currencySelectorState} - onSelect={() => { - this.quantitySelectRef && this.quantitySelectRef.unselectAll() - this.ratioSelectRef && this.ratioSelectRef.unselectAll() - }} - onChange={(currencySelectorState) => { - const newQuantitySelectorState = quantitySelectorState.map( - (item) => { - return { ...item, checked: false } - } - ) - const newRatioSelectorState = ratioSelectorState.map( - (item) => { - return { ...item, checked: false } - } - ) - - this.setState({ - activeNumberType: 'DOLLAR_AMT', - currencySelectorState, - quantitySelectorState: newQuantitySelectorState, - ratioSelectorState: newRatioSelectorState, - }) - }} - /> -
- )} - - {!!quantitySelectorState.length && ( - -
- {' '} - {this.props.dataConfig && - this.props.tableColumns && - this.props.dataConfig.legendColumnIndex !== undefined - ? this.props.tableColumns[ - this.props.dataConfig.legendColumnIndex - ].display_name - : 'Quantity'} -
- (this.quantitySelectRef = r)} - items={quantitySelectorState} - onSelect={() => { - this.currencySelectRef && this.currencySelectRef.unselectAll() - this.ratioSelectRef && this.ratioSelectRef.unselectAll() - }} - onChange={(quantitySelectorState) => { - const newCurrencySelectorState = currencySelectorState.map( - (item) => { - return { ...item, checked: false } - } - ) - const newRatioSelectorState = ratioSelectorState.map( - (item) => { - return { ...item, checked: false } - } - ) - this.setState({ - activeNumberType: 'QUANTITY', - quantitySelectorState, - currencySelectorState: newCurrencySelectorState, - ratioSelectorState: newRatioSelectorState, - }) - }} - /> -
- )} - - {!!ratioSelectorState.length && ( - -
- {' '} - {this.props.dataConfig && - this.props.tableColumns && - this.props.dataConfig.legendColumnIndex !== undefined - ? this.props.tableColumns[ - this.props.dataConfig.legendColumnIndex - ].display_name - : 'Ratio'} -
- (this.ratioSelectRef = r)} - items={ratioSelectorState} - onSelect={() => { - this.currencySelectRef && this.currencySelectRef.unselectAll() - this.quantitySelectRef && this.quantitySelectRef.unselectAll() - }} - onChange={(ratioSelectorState) => { - const newCurrencySelectorState = currencySelectorState.map( - (item) => { - return { ...item, checked: false } - } - ) - const newQuantitySelectorState = quantitySelectorState.map( - (item) => { - return { ...item, checked: false } - } - ) - - this.setState({ - activeNumberType: 'RATIO', - ratioSelectorState, - currencySelectorState: newCurrencySelectorState, - quantitySelectorState: newQuantitySelectorState, - }) - }} - /> -
- )} -
-
- -
-
- ) - } - - renderLegendSelectorContent = () => { - if (_get(this.props.dataConfig, 'stringColumnIndices.length', 0) < 2) { - return null - } - - return ( -
{ - e.stopPropagation() - }} - > -
    - {this.props.dataConfig.stringColumnIndices.map((colIndex, i) => { - return ( -
  • { - this.props.changeLegendColumnIndex(colIndex) - this.setState({ activeAxisSelector: undefined }) - }} - > - {_get(this.props.tableColumns, `[${colIndex}].title`)} -
  • - ) - })} -
-
- ) - } - - renderAxisSelectorContent = (axis) => { - try { - const { type } = this.props - let content = null - - const hasNumberXAxis = type === 'bar' || type === 'stacked_bar' - const hasStringYAxis = type === 'bar' || type === 'stacked_bar' - const hasStringXAxis = - type === 'column' || - type === 'line' || - type === 'stacked_column' || - type === 'stacked_line' - const hasNumberYAxis = - type === 'column' || - type === 'line' || - type === 'stacked_column' || - type === 'stacked_line' - - if ( - (axis === 'x' && hasStringXAxis) || - (axis === 'y' && hasStringYAxis) - ) { - content = this.renderStringColumnSelectorContent() - } else if ( - (axis === 'x' && hasNumberXAxis) || - (axis === 'y' && hasNumberYAxis) - ) { - content = this.renderNumberColumnSelectorContent() - } else if (axis === 'legend') { - content = this.renderLegendSelectorContent() - } - - return content - } catch (error) { - console.error(error) - return null - } - } - - renderAxisSelector = (axis) => { - if (this.state.activeAxisSelector !== axis) { - return null - } - - const popoverContent = this.renderAxisSelectorContent(axis) - - return ( - { - if ( - e.pageX !== this.state.axisSelectorLocation.left && - e.pageY !== this.state.axisSelectorLocation.top - ) { - // Reset axis selections to original - this.setNumberColumnSelectorState() - this.setState({ activeAxisSelector: undefined }) - } - }} - contentLocation={({ - targetRect, - popoverRect, - position, - align, - nudgedLeft, - nudgedTop, - }) => { - let topPosition = _get(this.state.axisSelectorLocation, 'top', 0) - 50 - let leftPosition = - _get(this.state.axisSelectorLocation, 'left', 0) - 75 - const bottomPosition = topPosition + popoverRect.height - - if (bottomPosition > window.innerHeight) { - topPosition -= bottomPosition - window.innerHeight + 10 - } - - if (leftPosition < 0) { - leftPosition = 10 - } - - return { - top: topPosition, - left: leftPosition, - } - }} - > -
- - ) - } - - renderAxisSelectors = () => { - return ( - - {this.renderAxisSelector('x')} - {this.renderAxisSelector('y')} - {this.renderAxisSelector('legend')} - - ) - } - - renderColumnChart = () => ( - + renderColumnChart = (dataType) => ( + ) - renderBarChart = () => ( - + renderBarChart = (dataType) => ( + ) - renderLineChart = () => ( - + renderLineChart = (dataType) => ( + ) - renderPieChart = () => { - return ( - - ) - } + renderPieChart = () => renderHeatmapChart = () => ( - + ) - renderBubbleChart = () => ( - - ) + renderBubbleChart = () => renderStackedColumnChart = () => ( ) - renderStackedBarChart = () => ( - + renderStackedBarChart = (dataType) => ( + ) renderStackedLineChart = () => ( @@ -1092,7 +647,7 @@ export default class ChataChart extends Component { {this.renderChart()} - {this.renderAxisSelectors()} + {/* {this.renderAxisSelectors()} */} )}
diff --git a/src/components/Charts/ChataChart/ChataChart.test.js b/src/components/Charts/ChataChart/ChataChart.test.js index 0dacbce1c..3623d2990 100644 --- a/src/components/Charts/ChataChart/ChataChart.test.js +++ b/src/components/Charts/ChataChart/ChataChart.test.js @@ -1,31 +1,14 @@ import React from 'react' import { shallow } from 'enzyme' - import ChataChart from './ChataChart' - import { findByTestAttr } from '../../../../test/testUtils' -import { themeConfigDefault, dataConfigDefault } from '../../../props/defaults' +import sampleProps from '../chartTestData' -const defaultProps = { - themeConfig: themeConfigDefault, - data: [ - { - cells: [{ value: 50 }, { value: 75 }], - label: 'label1`', - origRow: ['label1', 50, 75], - }, - { - cells: [{ value: 30 }, { value: 65 }], - label: 'label2`', - origRow: ['label2', 30, 65], - }, - ], - columns: [{}, {}, {}], - dataConfig: dataConfigDefault, - height: 300, - width: 300, - type: 'bar', -} +const pivotSampleProps = sampleProps.pivot +const datePivotSampleProps = sampleProps.datePivot +const listSampleProps = sampleProps.list + +const defaultProps = ChataChart.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -37,9 +20,27 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() - const chartComponent = findByTestAttr(wrapper, 'react-autoql-chart') - expect(chartComponent.exists()).toBe(true) + describe('list data', () => { + test('bar', () => { + const wrapper = setup({ ...listSampleProps, type: 'bar' }) + const chartComponent = findByTestAttr(wrapper, 'react-autoql-chart') + expect(chartComponent.exists()).toBe(true) + }) + }) + + describe('pivot data', () => { + test('stacked-column', () => { + const wrapper = setup({ ...pivotSampleProps, type: 'stacked-column' }) + const chartComponent = findByTestAttr(wrapper, 'react-autoql-chart') + expect(chartComponent.exists()).toBe(true) + }) + }) + + describe('date pivot data', () => { + test('heatmap', () => { + const wrapper = setup({ ...datePivotSampleProps, type: 'heatmap' }) + const chartComponent = findByTestAttr(wrapper, 'react-autoql-chart') + expect(chartComponent.exists()).toBe(true) + }) }) }) diff --git a/src/components/Charts/ChataColumnChart/ChataColumnChart.js b/src/components/Charts/ChataColumnChart/ChataColumnChart.js index cad3041b6..af71419bf 100644 --- a/src/components/Charts/ChataColumnChart/ChataColumnChart.js +++ b/src/components/Charts/ChataColumnChart/ChataColumnChart.js @@ -1,22 +1,22 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' import { Axes } from '../Axes' import { Columns } from '../Columns' import { scaleLinear, scaleBand } from 'd3-scale' import _get from 'lodash.get' -import { getMinAndMaxValues, getTickValues } from '../helpers.js' +import { + chartDefaultProps, + chartPropTypes, + getMinAndMaxValues, + getTickValues, + shouldRecalculateLongestLabel, +} from '../helpers.js' import { shouldLabelsRotate, getTickWidth, getLongestLabelInPx, } from '../../../js/Util' -import { themeConfigType, dataFormattingType } from '../../../props/types' -import { - themeConfigDefault, - dataFormattingDefault, - getDataFormatting, -} from '../../../props/defaults' +import { getDataFormatting } from '../../../props/defaults' export default class ChataColumnChart extends Component { constructor(props) { @@ -27,41 +27,8 @@ export default class ChataColumnChart extends Component { this.setLabelRotationValue(props) } - static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - leftMargin: PropTypes.number, - rightMargin: PropTypes.number, - topMargin: PropTypes.number, - bottomMargin: PropTypes.number, - labelValue: PropTypes.string, - onLabelChange: PropTypes.func, - numberColumnIndices: PropTypes.arrayOf(PropTypes.number), - onXAxisClick: PropTypes.func, - onYAxisClick: PropTypes.func, - legendLocation: PropTypes.string, - } - - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - leftMargin: 0, - rightMargin: 0, - topMargin: 0, - bottomMargin: 0, - labelValue: 'label', - numberColumnIndices: [], - legendLocation: undefined, - onXAxisClick: () => {}, - onYAxisClick: () => {}, - onLabelChange: () => {}, - } + static propTypes = chartPropTypes + static defaultProps = chartDefaultProps componentDidMount = () => { this.props.onLabelChange() @@ -72,10 +39,7 @@ export default class ChataColumnChart extends Component { } componentDidUpdate = (prevProps) => { - if ( - this.props.marginAdjustmentFinished && - prevProps?.data?.length !== this.props.data?.length - ) { + if (shouldRecalculateLongestLabel(prevProps, this.props)) { this.setLongestLabelWidth(this.props) } } @@ -93,16 +57,24 @@ export default class ChataColumnChart extends Component { setLongestLabelWidth = (props) => { this.longestLabelWidth = getLongestLabelInPx( - this.labelArray, - this.props.columns[this.props.stringColumnIndex], + this.xTickValues, + props.columns[props.stringColumnIndex], getDataFormatting(props.dataFormatting) ) } setChartData = (props) => { - const { minValue, maxValue } = getMinAndMaxValues(props.data) + let numberColumnIndices = props.numberColumnIndices + if (props.visibleSeriesIndices?.length) + numberColumnIndices = props.visibleSeriesIndices + + const { minValue, maxValue } = getMinAndMaxValues( + props.data, + numberColumnIndices + ) + this.xScale = scaleBand() - .domain(props.data.map((d) => d[props.labelValue])) + .domain(props.data.map((d) => d[props.stringColumnIndex])) .range([props.leftMargin, props.width - props.rightMargin]) .paddingInner(props.innerPadding) .paddingOuter(props.outerPadding) @@ -113,12 +85,10 @@ export default class ChataColumnChart extends Component { .nice() this.tickWidth = getTickWidth(this.xScale, props.innerPadding) - this.labelArray = props.data.map((element) => element[props.labelValue]) - this.xTickValues = getTickValues( this.tickWidth, props.width, - this.labelArray + this.xScale.domain() ) } @@ -129,34 +99,15 @@ export default class ChataColumnChart extends Component { return ( {this.props.marginAdjustmentFinished && ( - + )} ) diff --git a/src/components/Charts/ChataColumnChart/ChataColumnChart.test.js b/src/components/Charts/ChataColumnChart/ChataColumnChart.test.js index 24b3e39d9..eede21b4d 100644 --- a/src/components/Charts/ChataColumnChart/ChataColumnChart.test.js +++ b/src/components/Charts/ChataColumnChart/ChataColumnChart.test.js @@ -1,26 +1,14 @@ import React from 'react' import { shallow } from 'enzyme' - import { findByTestAttr } from '../../../../test/testUtils' import ChataColumnChart from './ChataColumnChart' +import sampleProps from '../chartTestData' -const defaultProps = { - data: [ - { - cells: [{ value: 50 }, { value: 75 }], - label: 'label1`', - origRow: ['label1', 50, 75], - }, - { - cells: [{ value: 30 }, { value: 65 }], - label: 'label2`', - origRow: ['label2', 30, 65], - }, - ], - columns: [{}, {}, {}], - height: 300, - width: 300, -} +const pivotSampleProps = sampleProps.pivot +const datePivotSampleProps = sampleProps.datePivot +const listSampleProps = sampleProps.list + +const defaultProps = ChataColumnChart.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -32,8 +20,26 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders list data chart correctly', () => { + const wrapper = setup(listSampleProps) + const columnChartComponent = findByTestAttr( + wrapper, + 'react-autoql-column-chart' + ) + expect(columnChartComponent.exists()).toBe(true) + }) + + test('renders pivot data chart correctly', () => { + const wrapper = setup(pivotSampleProps) + const columnChartComponent = findByTestAttr( + wrapper, + 'react-autoql-column-chart' + ) + expect(columnChartComponent.exists()).toBe(true) + }) + + test('renders date pivot data chart correctly', () => { + const wrapper = setup(datePivotSampleProps) const columnChartComponent = findByTestAttr( wrapper, 'react-autoql-column-chart' diff --git a/src/components/Charts/ChataHeatmapChart/ChataHeatmapChart.js b/src/components/Charts/ChataHeatmapChart/ChataHeatmapChart.js index 5bbed44c1..d71accd9c 100644 --- a/src/components/Charts/ChataHeatmapChart/ChataHeatmapChart.js +++ b/src/components/Charts/ChataHeatmapChart/ChataHeatmapChart.js @@ -1,62 +1,28 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' import { scaleBand } from 'd3-scale' -import { max } from 'd3-array' import { Axes } from '../Axes' import { Squares } from '../Squares' import { shouldLabelsRotate, getLongestLabelInPx } from '../../../js/Util.js' +import { getDataFormatting } from '../../../props/defaults' import { - themeConfigDefault, - dataFormattingDefault, - getDataFormatting, - getThemeConfig, -} from '../../../props/defaults' -import { themeConfigType, dataFormattingType } from '../../../props/types' + chartDefaultProps, + chartPropTypes, + getTickValues, + shouldRecalculateLongestLabel, +} from '../helpers.js' export default class ChataHeatmapChart extends Component { constructor(props) { super(props) + this.setChartData(props) this.setLongestLabelWidth(props) this.setLabelRotationValue(props) } - static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - leftMargin: PropTypes.number, - rightMargin: PropTypes.number, - topMargin: PropTypes.number, - bottomMargin: PropTypes.number, - dataValue: PropTypes.string, - labelValueX: PropTypes.string, - labelValueY: PropTypes.string, - onLabelChange: PropTypes.func, - onXAxisClick: PropTypes.func, - onYAxisClick: PropTypes.func, - } - - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - leftMargin: 0, - rightMargin: 0, - topMargin: 0, - bottomMargin: 0, - dataValue: 'value', - labelValueX: 'labelX', - labelValueY: 'labelY', - onXAxisClick: () => {}, - onYAxisClick: () => {}, - onLabelChange: () => {}, - } + static propTypes = chartPropTypes + static defaultProps = chartDefaultProps componentDidMount = () => { this.props.onLabelChange() @@ -67,17 +33,14 @@ export default class ChataHeatmapChart extends Component { } componentDidUpdate = (prevProps) => { - if ( - this.props.marginAdjustmentFinished && - prevProps?.data?.length !== this.props.data?.length - ) { + if (shouldRecalculateLongestLabel(prevProps, this.props)) { this.setLongestLabelWidth(this.props) } } setLabelRotationValue = (props) => { const rotateLabels = shouldLabelsRotate( - this.squareWidth, + this.xScale.bandwidth(), this.longestLabelWidth ) @@ -88,79 +51,28 @@ export default class ChataHeatmapChart extends Component { setLongestLabelWidth = (props) => { this.longestLabelWidth = getLongestLabelInPx( - this.uniqueXLabels, - this.props.columns[0], + this.xTickValues, + props.columns[props.stringColumnIndex], getDataFormatting(props.dataFormatting) ) } setChartData = (props) => { - this.maxValue = max(this.props.data, (d) => - max(d.cells, (cell) => cell.value) - ) - this.uniqueXLabels = props.data.map((d) => d.label) - this.uniqueYLabels = this.props.data[0].cells.map((cell) => cell.label) - this.xScale = scaleBand() - .domain(this.uniqueXLabels) + .domain(props.data.map((d) => d[props.stringColumnIndex])) .range([props.leftMargin + 10, props.width - props.rightMargin]) .paddingInner(0.01) this.yScale = scaleBand() - .domain(this.uniqueYLabels) + .domain(props.legendLabels.map((d) => d.label)) .range([props.height - props.bottomMargin, props.topMargin]) .paddingInner(0.01) - this.squareWidth = this.xScale.bandwidth() - this.xTickValues = this.getXTickValues() - this.yTickValues = this.getYTickValues() - } - - getXTickValues = () => { - try { - const interval = Math.ceil( - (this.props.data.length * 16) / this.props.width - ) // we should take into account the outer padding here - let xTickValues - - if (this.squareWidth < 16) { - xTickValues = [] - this.uniqueXLabels.forEach((label, index) => { - if (index % interval === 0) { - xTickValues.push(label) - } - }) - } - - return xTickValues - } catch (error) { - console.error(error) - return [] - } - } - - getYTickValues = () => { - this.squareHeight = this.props.height / this.uniqueYLabels.length - const intervalHeight = Math.ceil( - (this.uniqueYLabels.length * 16) / this.props.height + this.xTickValues = getTickValues( + this.xScale.bandwidth(), + props.width, + this.xScale.domain() ) - - try { - let yTickValues - if (this.squareHeight < 16) { - yTickValues = [] - this.uniqueYLabels.forEach((element, index) => { - if (index % intervalHeight === 0) { - yTickValues.push(element) - } - }) - } - - return yTickValues - } catch (error) { - console.error(error) - return [] - } } render = () => { @@ -173,50 +85,17 @@ export default class ChataHeatmapChart extends Component { className="react-autoql-heatmap-chart" > {this.props.marginAdjustmentFinished && ( - + )} ) diff --git a/src/components/Charts/ChataHeatmapChart/ChataHeatmapChart.test.js b/src/components/Charts/ChataHeatmapChart/ChataHeatmapChart.test.js index 13750ebd7..c3f9a9993 100644 --- a/src/components/Charts/ChataHeatmapChart/ChataHeatmapChart.test.js +++ b/src/components/Charts/ChataHeatmapChart/ChataHeatmapChart.test.js @@ -1,26 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' - import { findByTestAttr } from '../../../../test/testUtils' import ChataHeatmapChart from './ChataHeatmapChart' +import sampleProps from '../chartTestData' -const defaultProps = { - data: [ - { - cells: [{ value: 50 }, { value: 75 }], - label: 'label1`', - origRow: ['label1', 50, 75], - }, - { - cells: [{ value: 30 }, { value: 65 }], - label: 'label2`', - origRow: ['label2', 30, 65], - }, - ], - columns: [{}, {}, {}], - height: 300, - width: 300, -} +const pivotSampleProps = sampleProps.pivot +const defaultProps = ChataHeatmapChart.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -33,7 +18,7 @@ const setup = (props = {}, state = null) => { describe('renders correctly', () => { test('renders correctly with required props', () => { - const wrapper = setup() + const wrapper = setup(pivotSampleProps) const heatmapChartComponent = findByTestAttr( wrapper, 'react-autoql-heatmap-chart' diff --git a/src/components/Charts/ChataLineChart/ChataLineChart.js b/src/components/Charts/ChataLineChart/ChataLineChart.js index 99133b7f4..aa763d2f1 100644 --- a/src/components/Charts/ChataLineChart/ChataLineChart.js +++ b/src/components/Charts/ChataLineChart/ChataLineChart.js @@ -1,22 +1,18 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' import { Axes } from '../Axes' import { Line } from '../Line' import { scaleLinear, scaleBand } from 'd3-scale' import _get from 'lodash.get' -import { getMinAndMaxValues, getTickValues } from '../helpers.js' +import { shouldLabelsRotate, getLongestLabelInPx } from '../../../js/Util' +import { getDataFormatting } from '../../../props/defaults' import { - shouldLabelsRotate, - getTickWidth, - getLongestLabelInPx, -} from '../../../js/Util' -import { themeConfigType, dataFormattingType } from '../../../props/types' -import { - themeConfigDefault, - dataFormattingDefault, - getDataFormatting, -} from '../../../props/defaults' + chartDefaultProps, + chartPropTypes, + getMinAndMaxValues, + getTickValues, + shouldRecalculateLongestLabel, +} from '../helpers.js' export default class ChataLineChart extends Component { constructor(props) { @@ -27,42 +23,8 @@ export default class ChataLineChart extends Component { this.setLabelRotationValue(props) } - static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - leftMargin: PropTypes.number, - rightMargin: PropTypes.number, - topMargin: PropTypes.number, - bottomMargin: PropTypes.number, - labelValue: PropTypes.string, - onLegendClick: PropTypes.func, - onLabelChange: PropTypes.func, - numberColumnIndices: PropTypes.arrayOf(PropTypes.number), - onXAxisClick: PropTypes.func, - onYAxisClick: PropTypes.func, - legendLocation: PropTypes.string, - } - - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - leftMargin: 0, - rightMargin: 0, - topMargin: 0, - bottomMargin: 0, - labelValue: 'label', - numberColumnIndices: [], - legendLocation: undefined, - onXAxisClick: () => {}, - onYAxisClick: () => {}, - onLabelChange: () => {}, - } + static propTypes = chartPropTypes + static defaultProps = chartDefaultProps componentDidMount = () => { this.props.onLabelChange() @@ -73,10 +35,7 @@ export default class ChataLineChart extends Component { } componentDidUpdate = (prevProps) => { - if ( - this.props.marginAdjustmentFinished && - prevProps?.data?.length !== this.props.data?.length - ) { + if (shouldRecalculateLongestLabel(prevProps, this.props)) { this.setLongestLabelWidth(this.props) } } @@ -94,35 +53,37 @@ export default class ChataLineChart extends Component { setLongestLabelWidth = (props) => { this.longestLabelWidth = getLongestLabelInPx( - this.labelArray, - this.props.columns[this.props.stringColumnIndex], + this.xTickValues, + props.columns[props.stringColumnIndex], getDataFormatting(props.dataFormatting) ) } setChartData = (props) => { - // Get max and min values from all series - const { minValue, maxValue } = getMinAndMaxValues(props.data) - this.minValue = minValue - this.maxValue = maxValue + let numberColumnIndices = props.numberColumnIndices + if (props.visibleSeriesIndices?.length) + numberColumnIndices = props.visibleSeriesIndices + const { minValue, maxValue } = getMinAndMaxValues( + props.data, + numberColumnIndices + ) this.xScale = scaleBand() - .domain(props.data.map((d) => d[props.labelValue])) + .domain(props.data.map((d) => d[props.stringColumnIndex])) .range([props.leftMargin, props.width - props.rightMargin]) - .paddingInner(props.innerPadding) - .paddingOuter(0.1) + .paddingInner(1) + .paddingOuter(0) this.yScale = scaleLinear() .domain([minValue, maxValue]) .range([props.height - props.bottomMargin, props.topMargin]) .nice() - this.labelArray = props.data.map((element) => element[props.labelValue]) - this.tickWidth = getTickWidth(this.xScale, props.innerPadding) + this.tickWidth = props.width / (this.xScale?.domain()?.length || 1) this.xTickValues = getTickValues( this.tickWidth, props.width, - this.labelArray + this.xScale.domain() ) } @@ -133,32 +94,15 @@ export default class ChataLineChart extends Component { return ( {this.props.marginAdjustmentFinished && ( - + )} ) diff --git a/src/components/Charts/ChataLineChart/ChataLineChart.test.js b/src/components/Charts/ChataLineChart/ChataLineChart.test.js index 77a41e3bd..24de92361 100644 --- a/src/components/Charts/ChataLineChart/ChataLineChart.test.js +++ b/src/components/Charts/ChataLineChart/ChataLineChart.test.js @@ -1,26 +1,13 @@ import React from 'react' import { shallow } from 'enzyme' - import { findByTestAttr } from '../../../../test/testUtils' import ChataLineChart from './ChataLineChart' +import sampleProps from '../chartTestData' -const defaultProps = { - data: [ - { - cells: [{ value: 50 }, { value: 75 }], - label: 'label1`', - origRow: ['label1', 50, 75], - }, - { - cells: [{ value: 30 }, { value: 65 }], - label: 'label2`', - origRow: ['label2', 30, 65], - }, - ], - columns: [{}, {}, {}], - height: 300, - width: 300, -} +const pivotSampleProps = sampleProps.pivot +const datePivotSampleProps = sampleProps.datePivot +const listSampleProps = sampleProps.list +const defaultProps = ChataLineChart.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -32,8 +19,26 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders list data chart correctly', () => { + const wrapper = setup(listSampleProps) + const lineChartComponent = findByTestAttr( + wrapper, + 'react-autoql-line-chart' + ) + expect(lineChartComponent.exists()).toBe(true) + }) + + test('renders list data chart correctly', () => { + const wrapper = setup(pivotSampleProps) + const lineChartComponent = findByTestAttr( + wrapper, + 'react-autoql-line-chart' + ) + expect(lineChartComponent.exists()).toBe(true) + }) + + test('renders list data chart correctly', () => { + const wrapper = setup(datePivotSampleProps) const lineChartComponent = findByTestAttr( wrapper, 'react-autoql-line-chart' diff --git a/src/components/Charts/ChataPieChart/ChataPieChart.js b/src/components/Charts/ChataPieChart/ChataPieChart.js index 8060f40f3..8eddc73dc 100644 --- a/src/components/Charts/ChataPieChart/ChataPieChart.js +++ b/src/components/Charts/ChataPieChart/ChataPieChart.js @@ -2,6 +2,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import _get from 'lodash.get' import _isEqual from 'lodash.isequal' +import _cloneDeep from 'lodash.clonedeep' import { v4 as uuid } from 'uuid' import { select } from 'd3-selection' @@ -12,45 +13,73 @@ import { legendColor } from 'd3-svg-legend' import 'd3-transition' import { formatElement, removeFromDOM } from '../../../js/Util' -import { themeConfigType, dataFormattingType } from '../../../props/types' +import { getDataFormatting, getThemeConfig } from '../../../props/defaults' import { - themeConfigDefault, - dataFormattingDefault, - getDataFormatting, - getThemeConfig, -} from '../../../props/defaults' + chartDefaultProps, + chartPropTypes, + getTooltipContent, +} from '../helpers' +import ReactTooltip from 'react-tooltip' export default class Axis extends Component { - CHART_ID = uuid() + constructor(props) { + super(props) + + this.CHART_ID = uuid() + this.LEGEND_ID = `react-autoql-pie-legend-${uuid()}` + + this.sortedData = props.data + .concat() // this copies the array so the original isn't mutated + .sort((aRow, bRow) => { + const a = aRow[props.numberColumnIndex] || 0 + const b = bRow[props.numberColumnIndex] || 0 + return parseFloat(b) - parseFloat(a) + }) + + const { chartColors } = getThemeConfig(props.themeConfig) + this.colorScale = scaleOrdinal() + .domain( + this.sortedData.map((d) => { + return d[props.stringColumnIndex] + }) + ) + .range(chartColors) + + const legendLabels = this.sortedData.map((d, i) => { + const legendString = `${formatElement({ + element: d[props.stringColumnIndex] || 'Untitled Category', + column: props.columns?.[props.stringColumnIndex], + })}: ${formatElement({ + element: d[props.numberColumnIndex] || 0, + column: _get(props, `columns[${props.numberColumnIndex}]`), + config: props.columns?.[props.dataFormatting], + })}` + return { + label: legendString.trim(), + hidden: false, + dataIndex: i, + } + }) + + this.state = { + activeKey: this.props.activeChartElementKey, + legendLabels, + } + } static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - - height: PropTypes.number.isRequired, - width: PropTypes.number.isRequired, - data: PropTypes.arrayOf(PropTypes.shape()).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape()).isRequired, - onChartClick: PropTypes.func, - margin: PropTypes.number, + ...chartPropTypes, backgroundColor: PropTypes.string, + margin: PropTypes.number, } static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - margin: 40, + ...chartDefaultProps, backgroundColor: 'transparent', - onChartClick: () => {}, - } - - state = { - activeKey: this.props.activeChartElementKey, + margin: 40, } componentDidMount = () => { - this.LEGEND_ID = `react-autoql-pie-legend-${uuid()}` this.renderPie() } @@ -71,6 +100,35 @@ export default class Axis extends Component { removeFromDOM(this.pieChartContainer) } + renderPie = () => { + removeFromDOM(this.pieChartContainer) + + const self = this + + this.setPieRadius() + + this.outerArc = arc() + .innerRadius(self.outerRadius * 1.1) + .outerRadius(self.outerRadius * 1.1) + + const pieChart = pie().value((d, i) => { + return d.value[self.props.numberColumnIndex] + }) + + this.dataReady = pieChart( + entries( + self.sortedData.filter((d, i) => !this.state.legendLabels?.[i]?.hidden) + ) + ) + + this.renderPieContainer() + this.renderPieSlices() + this.renderLegend() + + // Finally, translate container of legend and pie chart to center of parent container + this.centerVisualization() + } + renderPieContainer = () => { const { width, height } = this.props @@ -86,23 +144,23 @@ export default class Axis extends Component { ) } - setColorScale = () => { - const self = this - const { chartColors } = getThemeConfig(this.props.themeConfig) - - this.color = scaleOrdinal() - .domain( - self.sortedData.map((d) => { - return d[self.props.labelValue] - }) + onSliceClick = (d) => { + const newActiveKey = d.data.key + if (newActiveKey === this.state.activeKey) { + // Put it back if it is expanded + this.setState({ activeKey: null }) + } else { + this.props.onChartClick( + d.data.value, + this.props.numberColumnIndex, + this.props.columns, + this.props.stringColumnIndex, + this.props.legendColumn, + this.props.numberColumnIndex, + newActiveKey ) - .range(chartColors) - - const pieChart = pie().value((d) => { - return d.value.cells[0].value - }) - - this.dataReady = pieChart(entries(self.sortedData.filter((d) => !d.hidden))) + this.setState({ activeKey: newActiveKey }) + } } renderPieSlices = () => { @@ -122,11 +180,18 @@ export default class Axis extends Component { .outerRadius(self.outerRadius) ) .attr('fill', (d) => { - return self.color(d.data.value[self.props.labelValue]) + return self.colorScale(d.data.value[self.props.stringColumnIndex]) }) .attr('data-for', 'chart-element-tooltip') .attr('data-tip', function(d) { - return _get(d, 'data.value.cells[0].tooltipData') + return getTooltipContent({ + row: d.data.value, + columns: self.props.columns, + colIndex: self.props.numberColumnIndex, + stringColumnIndex: self.props.stringColumnIndex, + legendColumn: self.props.legendColumn, + dataFormatting: self.props.dataFormatting, + }) }) .style('fill-opacity', 0.85) .attr('stroke-width', '0.5px') @@ -137,31 +202,17 @@ export default class Axis extends Component { .on('mouseout', function(d) { select(this).style('fill-opacity', 0.85) }) - .on('click', function(d) { - const newActiveKey = _get(d, `data.value[${self.props.labelValue}]`) - if (newActiveKey === self.state.activeKey) { - // Put it back if it is expanded - self.setState({ activeKey: null }) - } else { - self.props.onChartClick({ - drilldownData: _get(d, 'data.value.cells[0].drilldownData'), - activeKey: newActiveKey, - }) - self.setState({ activeKey: newActiveKey }) - } - }) + .on('click', this.onSliceClick) // render active pie slice if there is one self.pieChartContainer.selectAll('path.slice').each(function(slice) { select(this) .transition() .duration(500) - .attr('transform', function(data) { - if (data.data.value[self.props.labelValue] === self.state.activeKey) { + .attr('transform', function(d) { + if (d.data.key === self.state.activeKey) { const a = - data.startAngle + - (data.endAngle - data.startAngle) / 2 - - Math.PI / 2 + d.startAngle + (d.endAngle - d.startAngle) / 2 - Math.PI / 2 const x = Math.cos(a) * 10 const y = Math.sin(a) * 10 // move it away from the circle center @@ -192,36 +243,26 @@ export default class Axis extends Component { ) } + onLegendClick = (legendObjStr) => { + const legendObj = JSON.parse(legendObjStr) + const index = legendObj?.dataIndex + if (!!this.state.legendLabels?.[index]) { + const newLegendLabels = _cloneDeep(this.state.legendLabels) + newLegendLabels[index].hidden = !this.state.legendLabels[index].hidden + this.setState({ legendLabels: newLegendLabels }) + } + ReactTooltip.rebuild() + } + renderLegend = () => { const self = this - const { - height, - margin, - labelValue, - stringColumnIndex, - numberColumnIndex, - } = this.props + const { height } = this.props const { chartColors } = getThemeConfig(this.props.themeConfig) - this.legendLabels = this.sortedData.map((d) => { - const legendString = `${formatElement({ - element: d[labelValue] || 'Untitled Category', - column: _get(this.props, `columns[${stringColumnIndex}]`), - })}: ${formatElement({ - element: d.cells[0].value || 0, - column: _get(this.props, `columns[${numberColumnIndex}]`), - config: getDataFormatting(this.props.dataFormatting), - })}` - return { - hidden: d.hidden, - label: legendString.trim(), - } - }) - let legendScale - if (this.legendLabels) { + if (this.state.legendLabels) { legendScale = scaleOrdinal() - .domain(self.legendLabels.map((item) => item.label)) + .domain(this.state.legendLabels.map((obj) => JSON.stringify(obj))) .range(chartColors) } else { return @@ -249,17 +290,10 @@ export default class Axis extends Component { ) .orient('vertical') .shapePadding(5) + .labels(self.state.legendLabels.map((labelObj) => labelObj.label)) .labelWrap(legendWrapLength) .scale(legendScale) - .on('cellclick', function(d) { - const dataIndex = self.legendLabels.findIndex((legendObj) => { - return legendObj.label === d - }) - - if (dataIndex >= 0) { - self.props.onLegendClick(self.sortedData[dataIndex]) - } - }) + .on('cellclick', this.onLegendClick) this.legend.select('.legendOrdinal').call(legendOrdinal) @@ -283,7 +317,7 @@ export default class Axis extends Component { } applyStylesForHiddenSeries = () => { - const legendLabelTexts = this.legendLabels + const legendLabelTexts = this.state.legendLabels .filter((l) => l.hidden) .map((l) => l.label) @@ -323,32 +357,6 @@ export default class Axis extends Component { this.innerRadius = this.outerRadius - 50 > 15 ? this.outerRadius - 50 : 0 } - renderPie = () => { - removeFromDOM(this.pieChartContainer) - - const self = this - - this.setPieRadius() - - this.outerArc = arc() - .innerRadius(self.outerRadius * 1.1) - .outerRadius(self.outerRadius * 1.1) - - this.sortedData = this.props.data - .concat() // this copies the array so the original isn't mutated - .sort( - (a, b) => parseFloat(a.cells[0].value) - parseFloat(b.cells[0].value) - ) - - this.renderPieContainer() - this.setColorScale() - this.renderPieSlices() - this.renderLegend() - - // Finally, translate container of legend and pie chart to center of parent container - this.centerVisualization() - } - render = () => { return ( { const setupProps = { ...defaultProps, ...props } @@ -35,7 +18,7 @@ const setup = (props = {}, state = null) => { describe('renders correctly', () => { test('renders correctly with required props', () => { - const wrapper = setup() + const wrapper = setup(datePivotSampleProps) const pieChartComponent = findByTestAttr(wrapper, 'react-autoql-pie-chart') expect(pieChartComponent.exists()).toBe(true) }) diff --git a/src/components/Charts/ChataStackedBarChart/ChataStackedBarChart.js b/src/components/Charts/ChataStackedBarChart/ChataStackedBarChart.js index 49ba2b23c..a54140120 100644 --- a/src/components/Charts/ChataStackedBarChart/ChataStackedBarChart.js +++ b/src/components/Charts/ChataStackedBarChart/ChataStackedBarChart.js @@ -1,5 +1,4 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' import { Axes } from '../Axes' import { StackedBars } from '../StackedBars' import { scaleLinear, scaleBand } from 'd3-scale' @@ -7,16 +6,13 @@ import _get from 'lodash.get' import { calculateMinAndMaxSums, - shouldLabelsRotate, - getLongestLabelInPx, -} from '../../../js/Util' -import { getTickValues } from '../helpers' -import { themeConfigType, dataFormattingType } from '../../../props/types' -import { - themeConfigDefault, - dataFormattingDefault, - getDataFormatting, -} from '../../../props/defaults' + chartDefaultProps, + chartPropTypes, + getTickValues, + shouldRecalculateLongestLabel, +} from '../helpers.js' +import { shouldLabelsRotate, getLongestLabelInPx } from '../../../js/Util' +import { getDataFormatting } from '../../../props/defaults' export default class ChataStackedBarChart extends Component { constructor(props) { @@ -27,39 +23,8 @@ export default class ChataStackedBarChart extends Component { this.setLabelRotationValue(props) } - static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - leftMargin: PropTypes.number, - rightMargin: PropTypes.number, - topMargin: PropTypes.number, - bottomMargin: PropTypes.number, - onLabelChange: PropTypes.func, - numberColumnIndices: PropTypes.arrayOf(PropTypes.number), - onXAxisClick: PropTypes.func, - onYAxisClick: PropTypes.func, - legendLocation: PropTypes.string, - } - - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - leftMargin: 0, - rightMargin: 0, - topMargin: 0, - bottomMargin: 0, - numberColumnIndices: [], - legendLocation: undefined, - onXAxisClick: () => {}, - onYAxisClick: () => {}, - onLabelChange: () => {}, - } + static propTypes = chartPropTypes + static defaultProps = chartDefaultProps componentDidMount = () => { this.props.onLabelChange() @@ -70,10 +35,7 @@ export default class ChataStackedBarChart extends Component { } componentDidUpdate = (prevProps) => { - if ( - this.props.marginAdjustmentFinished && - prevProps?.data?.length !== this.props.data?.length - ) { + if (shouldRecalculateLongestLabel(prevProps, this.props)) { this.setLongestLabelWidth(this.props) } } @@ -98,22 +60,30 @@ export default class ChataStackedBarChart extends Component { } setChartData = (props) => { - const { maxValue, minValue } = calculateMinAndMaxSums(props.data) - this.maxValue = maxValue - this.minValue = minValue + let numberColumnIndices = props.numberColumnIndices + if (props.visibleSeriesIndices?.length) + numberColumnIndices = props.visibleSeriesIndices + const { maxValue, minValue } = calculateMinAndMaxSums( + props.data, + props.stringColumnIndex, + numberColumnIndices + ) this.xScale = scaleLinear() - .domain([this.minValue, this.maxValue]) + .domain([minValue, maxValue]) .range([props.leftMargin, props.width - props.rightMargin]) .nice() this.yScale = scaleBand() - .domain(props.data.map((d) => d.label)) + .domain(props.data.map((d) => d[props.stringColumnIndex])) .range([props.height - props.bottomMargin, props.topMargin]) .paddingInner(props.innerPadding) .paddingOuter(props.outerPadding) - this.yLabelArray = props.data.map((element) => element.label) + this.yLabelArray = props.data.map( + (element) => element[props.stringColumnIndex] + ) + this.xLabelArray = this.xScale.ticks() this.barHeight = props.height / props.data.length @@ -131,37 +101,15 @@ export default class ChataStackedBarChart extends Component { return ( {this.props.marginAdjustmentFinished && ( )} diff --git a/src/components/Charts/ChataStackedBarChart/ChataStackedBarChart.test.js b/src/components/Charts/ChataStackedBarChart/ChataStackedBarChart.test.js index 47f6f6789..33a9e73a3 100644 --- a/src/components/Charts/ChataStackedBarChart/ChataStackedBarChart.test.js +++ b/src/components/Charts/ChataStackedBarChart/ChataStackedBarChart.test.js @@ -1,30 +1,12 @@ import React from 'react' import { shallow } from 'enzyme' - import { findByTestAttr } from '../../../../test/testUtils' import ChataStackedBarChart from './ChataStackedBarChart' +import sampleProps from '../chartTestData' +const pivotSampleProps = sampleProps.pivot const defaultProps = ChataStackedBarChart.defaultProps -const sampleProps = { - data: [ - { - cells: [{ value: 50 }, { value: 75 }], - label: 'label1`', - origRow: ['label1', 50, 75], - }, - { - cells: [{ value: 30 }, { value: 65 }], - label: 'label2`', - origRow: ['label2', 30, 65], - }, - ], - columns: [{}, {}, {}], - tableColumns: [{}, {}, {}], - height: 300, - width: 300, -} - const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...sampleProps, ...props } const wrapper = shallow() @@ -36,7 +18,7 @@ const setup = (props = {}, state = null) => { describe('renders correctly', () => { test('renders correctly with required props', () => { - const wrapper = setup() + const wrapper = setup(pivotSampleProps) const stackedBarChartComponent = findByTestAttr( wrapper, 'react-autoql-stacked-bar-chart' diff --git a/src/components/Charts/ChataStackedColumnChart/ChataStackedColumnChart.js b/src/components/Charts/ChataStackedColumnChart/ChataStackedColumnChart.js index 73d855be0..f94c364c4 100644 --- a/src/components/Charts/ChataStackedColumnChart/ChataStackedColumnChart.js +++ b/src/components/Charts/ChataStackedColumnChart/ChataStackedColumnChart.js @@ -1,5 +1,4 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' import { Axes } from '../Axes' import { StackedColumns } from '../StackedColumns' import { scaleLinear, scaleBand } from 'd3-scale' @@ -8,18 +7,19 @@ import _isEqual from 'lodash.isequal' import { calculateMinAndMaxSums, + chartDefaultProps, + chartPropTypes, + getTickValues, + shouldRecalculateLongestLabel, +} from '../helpers.js' + +import { shouldLabelsRotate, getTickWidth, getLongestLabelInPx, } from '../../../js/Util' -import { getTickValues } from '../helpers' -import { - themeConfigDefault, - dataFormattingDefault, - getDataFormatting, -} from '../../../props/defaults' -import { themeConfigType, dataFormattingType } from '../../../props/types' +import { getDataFormatting } from '../../../props/defaults' export default class ChataStackedColumnChart extends Component { constructor(props) { @@ -30,39 +30,8 @@ export default class ChataStackedColumnChart extends Component { this.setLabelRotationValue(props) } - static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - tableColumns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - leftMargin: PropTypes.number, - rightMargin: PropTypes.number, - topMargin: PropTypes.number, - bottomMargin: PropTypes.number, - onLabelChange: PropTypes.func, - onXAxisClick: PropTypes.func, - onYAxisClick: PropTypes.func, - legendLocation: PropTypes.string, - } - - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - leftMargin: 0, - rightMargin: 0, - topMargin: 0, - bottomMargin: 0, - numberColumnIndices: [], - legendLocation: undefined, - onXAxisClick: () => {}, - onYAxisClick: () => {}, - onLabelChange: () => {}, - } + static propTypes = chartPropTypes + static defaultProps = chartDefaultProps componentDidMount = () => { this.props.onLabelChange() @@ -73,10 +42,7 @@ export default class ChataStackedColumnChart extends Component { } componentDidUpdate = (prevProps) => { - if ( - this.props.marginAdjustmentFinished && - prevProps?.data?.length !== this.props.data?.length - ) { + if (shouldRecalculateLongestLabel(prevProps, this.props)) { this.setLongestLabelWidth(this.props) } } @@ -94,26 +60,30 @@ export default class ChataStackedColumnChart extends Component { setLongestLabelWidth = (props) => { this.longestLabelWidth = getLongestLabelInPx( - this.labelArray, - props.columns[0], + this.xTickValues, + props.columns[props.stringColumnIndex], getDataFormatting(props.dataFormatting) ) } - setChartData = (props, prevProps) => { - this.labelArray = props.data.map((element) => element.label) - const { maxValue, minValue } = calculateMinAndMaxSums(props.data) - this.maxValue = maxValue - this.minValue = minValue + setChartData = (props) => { + let numberColumnIndices = props.numberColumnIndices + if (props.visibleSeriesIndices?.length) + numberColumnIndices = props.visibleSeriesIndices + const { maxValue, minValue } = calculateMinAndMaxSums( + props.data, + props.stringColumnIndex, + numberColumnIndices + ) this.xScale = scaleBand() - .domain(this.labelArray) + .domain(props.data.map((d) => d[props.stringColumnIndex])) .range([props.leftMargin, props.width - props.rightMargin]) .paddingInner(props.innerPadding) .paddingOuter(props.outerPadding) this.yScale = scaleLinear() - .domain([this.minValue, this.maxValue]) + .domain([minValue, maxValue]) .range([props.height - props.bottomMargin, props.topMargin]) .nice() @@ -121,7 +91,7 @@ export default class ChataStackedColumnChart extends Component { this.xTickValues = getTickValues( this.tickWidth, props.width, - this.labelArray + this.xScale.domain() ) } @@ -132,35 +102,15 @@ export default class ChataStackedColumnChart extends Component { return ( {this.props.marginAdjustmentFinished && ( )} diff --git a/src/components/Charts/ChataStackedColumnChart/ChataStackedColumnChart.test.js b/src/components/Charts/ChataStackedColumnChart/ChataStackedColumnChart.test.js index cb6ea3344..3ee20ec92 100644 --- a/src/components/Charts/ChataStackedColumnChart/ChataStackedColumnChart.test.js +++ b/src/components/Charts/ChataStackedColumnChart/ChataStackedColumnChart.test.js @@ -1,27 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' - import { findByTestAttr } from '../../../../test/testUtils' import ChataStackedColumnChart from './ChataStackedColumnChart' +import sampleProps from '../chartTestData' -const defaultProps = { - data: [ - { - cells: [{ value: 50 }, { value: 75 }], - label: 'label1`', - origRow: ['label1', 50, 75], - }, - { - cells: [{ value: 30 }, { value: 65 }], - label: 'label2`', - origRow: ['label2', 30, 65], - }, - ], - columns: [{}, {}, {}], - tableColumns: [{}, {}, {}], - height: 300, - width: 300, -} +const pivotSampleProps = sampleProps.pivot +const defaultProps = ChataStackedColumnChart.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -34,7 +18,7 @@ const setup = (props = {}, state = null) => { describe('renders correctly', () => { test('renders correctly with required props', () => { - const wrapper = setup() + const wrapper = setup(pivotSampleProps) const stackedColumnChartComponent = findByTestAttr( wrapper, 'react-autoql-stacked-column-chart' diff --git a/src/components/Charts/ChataStackedLineChart/ChataStackedLineChart.js b/src/components/Charts/ChataStackedLineChart/ChataStackedLineChart.js index 612addc19..35d210e12 100644 --- a/src/components/Charts/ChataStackedLineChart/ChataStackedLineChart.js +++ b/src/components/Charts/ChataStackedLineChart/ChataStackedLineChart.js @@ -1,5 +1,4 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' import { Axes } from '../Axes' import { StackedLines } from '../StackedLines' import { scaleLinear, scaleBand } from 'd3-scale' @@ -7,58 +6,26 @@ import _get from 'lodash.get' import { calculateMinAndMaxSums, - shouldLabelsRotate, - getLongestLabelInPx, -} from '../../../js/Util' -import { getTickValues } from '../helpers' -import { dataFormattingType, themeConfigType } from '../../../props/types' -import { - dataFormattingDefault, - themeConfigDefault, - getDataFormatting, -} from '../../../props/defaults' + chartDefaultProps, + chartPropTypes, + getTickValues, + shouldRecalculateLongestLabel, +} from '../helpers.js' + +import { shouldLabelsRotate, getLongestLabelInPx } from '../../../js/Util' +import { getDataFormatting } from '../../../props/defaults' export default class ChataStackedLineChart extends Component { constructor(props) { super(props) + this.setChartData(props) this.setLongestLabelWidth(props) this.setLabelRotationValue(props) } - static propTypes = { - themeConfig: themeConfigType, - dataFormatting: dataFormattingType, - - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - tableColumns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - leftMargin: PropTypes.number, - rightMargin: PropTypes.number, - topMargin: PropTypes.number, - bottomMargin: PropTypes.number, - onLabelChange: PropTypes.func, - onXAxisClick: PropTypes.func, - onYAxisClick: PropTypes.func, - legendLocation: PropTypes.string, - } - - static defaultProps = { - themeConfig: themeConfigDefault, - dataFormatting: dataFormattingDefault, - - leftMargin: 0, - rightMargin: 0, - topMargin: 0, - bottomMargin: 0, - numberColumnIndices: [], - legendLocation: undefined, - onXAxisClick: () => {}, - onYAxisClick: () => {}, - onLabelChange: () => {}, - } + static propTypes = chartPropTypes + static defaultProps = chartDefaultProps componentDidMount = () => { this.props.onLabelChange() @@ -69,10 +36,7 @@ export default class ChataStackedLineChart extends Component { } componentDidUpdate = (prevProps) => { - if ( - this.props.marginAdjustmentFinished && - prevProps?.data?.length !== this.props.data?.length - ) { + if (shouldRecalculateLongestLabel(prevProps, this.props)) { this.setLongestLabelWidth(this.props) } } @@ -90,37 +54,38 @@ export default class ChataStackedLineChart extends Component { setLongestLabelWidth = (props) => { this.longestLabelWidth = getLongestLabelInPx( - this.labelArray, - this.props.columns[0], + this.xTickValues, + props.columns[props.stringColumnIndex], getDataFormatting(props.dataFormatting) ) } setChartData = (props) => { - const { maxValue, minValue } = calculateMinAndMaxSums(props.data) - this.maxValue = maxValue - this.minValue = minValue + let numberColumnIndices = props.numberColumnIndices + if (props.visibleSeriesIndices?.length) + numberColumnIndices = props.visibleSeriesIndices + const { maxValue, minValue } = calculateMinAndMaxSums( + props.data, + props.stringColumnIndex, + numberColumnIndices + ) this.xScale = scaleBand() - .domain(props.data.map((d) => d.label)) + .domain(props.data.map((d) => d[props.stringColumnIndex])) .range([props.leftMargin, props.width - props.rightMargin]) .paddingInner(1) .paddingOuter(0) this.yScale = scaleLinear() - .domain([this.minValue, this.maxValue]) // do we want to deal with negative values for these visualizations? + .domain([minValue, maxValue]) .range([props.height - props.bottomMargin, props.topMargin]) .nice() - this.labelArray = props.data.map((element) => element.label) - this.tickWidth = Math.abs( - this.xScale(_get(props.data, '[0].label')) - - this.xScale(_get(props.data, '[1].label')) - ) + this.tickWidth = props.width / (this.xScale?.domain()?.length || 1) this.xTickValues = getTickValues( this.tickWidth, props.width, - this.labelArray + this.xScale.domain() ) } @@ -131,55 +96,31 @@ export default class ChataStackedLineChart extends Component { return ( {this.props.marginAdjustmentFinished && ( )} diff --git a/src/components/Charts/ChataStackedLineChart/ChataStackedLineChart.test.js b/src/components/Charts/ChataStackedLineChart/ChataStackedLineChart.test.js index 4288af1b9..e2aaabb1e 100644 --- a/src/components/Charts/ChataStackedLineChart/ChataStackedLineChart.test.js +++ b/src/components/Charts/ChataStackedLineChart/ChataStackedLineChart.test.js @@ -1,27 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' - import { findByTestAttr } from '../../../../test/testUtils' import ChataStackedLineChart from './ChataStackedLineChart' +import sampleProps from '../chartTestData' -const defaultProps = { - data: [ - { - cells: [{ value: 50 }, { value: 75 }], - label: 'label1`', - origRow: ['label1', 50, 75], - }, - { - cells: [{ value: 30 }, { value: 65 }], - label: 'label2`', - origRow: ['label2', 30, 65], - }, - ], - columns: [{}, {}, {}], - tableColumns: [{}, {}, {}], - height: 300, - width: 300, -} +const pivotSampleProps = sampleProps.pivot +const defaultProps = ChataStackedLineChart.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -33,8 +17,8 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders regular pivot chart data correctly', () => { + const wrapper = setup(pivotSampleProps) const stackedLineChartComponent = findByTestAttr( wrapper, 'react-autoql-stacked-line-chart' diff --git a/src/components/Charts/Circles/Circles.js b/src/components/Charts/Circles/Circles.js index 6ff11bb2f..6d2f92b4c 100644 --- a/src/components/Charts/Circles/Circles.js +++ b/src/components/Charts/Circles/Circles.js @@ -1,80 +1,127 @@ import React, { Component } from 'react' -import PropTypes from 'prop-types' -import _get from 'lodash.get' import { scaleLinear } from 'd3-scale' -import { themeConfigDefault, getThemeConfig } from '../../../props/defaults' -import { themeConfigType } from '../../../props/types' +import { max, min } from 'd3-array' +import _get from 'lodash.get' + +import { + chartElementDefaultProps, + chartElementPropTypes, + getTooltipContent, + getKey, +} from '../helpers' export default class Circles extends Component { - static propTypes = { - themeConfig: themeConfigType, + constructor(props) { + super(props) - columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - maxValue: PropTypes.number, - minValue: PropTypes.number, - } + const maxValue = max( + props.data.map((row) => + max(row.filter((value, i) => props.numberColumnIndices.includes(i))) + ) + ) - static defaultProps = { - themeConfig: themeConfigDefault, - maxValue: 0, - minValue: 0, + const minValue = min( + props.data.map((row) => + min(row.filter((value, i) => props.numberColumnIndices.includes(i))) + ) + ) + + this.radiusScale = scaleLinear() + .domain([minValue, maxValue]) + .range([0, Math.min(props.xScale.bandwidth(), props.yScale.bandwidth())]) } + static propTypes = chartElementPropTypes + static defaultProps = chartElementDefaultProps state = { - activeKey: this.props.activeKey, + activeKey: this.props.activeChartElementKey, + } + + onCircleClick = (row, colIndex, rowIndex) => { + const newActiveKey = getKey(colIndex, rowIndex) + + this.props.onChartClick( + row, + colIndex, + this.props.columns, + this.props.stringColumnIndex, + this.props.legendColumn, + this.props.numberColumnIndex, + newActiveKey + ) + + this.setState({ activeKey: newActiveKey }) } render = () => { - const { scales } = this.props - const { xScale, yScale } = scales + const { + columns, + legendColumn, + numberColumnIndices, + stringColumnIndex, + dataFormatting, + legendLabels, + colorScale, + yScale, + xScale, + } = this.props + + const visibleSeries = numberColumnIndices.filter((colIndex) => { + return !columns[colIndex].isSeriesHidden + }) - const radiusScale = scaleLinear() - .domain([this.props.minValue, this.props.maxValue]) - .range([0, Math.min(xScale.bandwidth(), yScale.bandwidth())]) + if (!visibleSeries.length) { + return null + } const circles = [] - this.props.data.forEach((d) => { - d.cells.forEach((cell) => { - circles.push( - { - this.setState({ - activeKey: `${cell.label}-${d.label}`, - }) - this.props.onChartClick({ - activeKey: `${cell.label}-${d.label}`, - drilldownData: cell.drilldownData, - }) - }} - data-tip={cell.tooltipData} - data-for="chart-element-tooltip" - style={{ - stroke: 'transparent', - strokeWidth: 10, - fill: - this.state.activeKey === `${cell.label}-${d.label}` - ? _get( - getThemeConfig(this.props.themeConfig), - 'chartColors[1]' - ) - : _get( - getThemeConfig(this.props.themeConfig), - 'chartColors[0]' - ), - fillOpacity: 0.7, - }} - /> - ) + + this.props.data.forEach((row, index) => { + numberColumnIndices.forEach((colIndex, i) => { + if (!columns[colIndex].isSeriesHidden) { + const rawValue = row[colIndex] + const valueNumber = Number(rawValue) + const value = !Number.isNaN(valueNumber) ? valueNumber : 0 + + const xLabel = row[stringColumnIndex] + const yLabel = legendLabels[i].label + + const tooltip = getTooltipContent({ + row, + columns, + colIndex, + stringColumnIndex, + legendColumn, + dataFormatting, + }) + + circles.push( + this.onCircleClick(row, colIndex, index)} + data-tip={tooltip} + data-for="chart-element-tooltip" + style={{ + stroke: 'transparent', + strokeWidth: 10, + fill: + this.state.activeKey === getKey(colIndex, index) + ? colorScale(1) + : colorScale(0), + fillOpacity: 0.7, + }} + /> + ) + } }) }) return {circles} diff --git a/src/components/Charts/Circles/Circles.test.js b/src/components/Charts/Circles/Circles.test.js index 29e68d00c..8b7a535ad 100644 --- a/src/components/Charts/Circles/Circles.test.js +++ b/src/components/Charts/Circles/Circles.test.js @@ -1,29 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' -import { scaleBand } from 'd3-scale' - import { findByTestAttr } from '../../../../test/testUtils' import Circles from './Circles' +import sampleProps from '../chartTestData' -const defaultProps = { - labelValueX: 'label', - labelValueY: 'label', - data: [ - { cells: [{ value: 50 }, { value: 75 }], label: 'label1`' }, - { cells: [{ value: 30 }, { value: 65 }], label: 'label2`' }, - ], - columns: [{}, {}], - scales: { - xScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - yScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - }, -} +const pivotSampleProps = sampleProps.pivot +const defaultProps = Circles.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -35,8 +17,12 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders regular pivot chart data correctly', () => { + const wrapper = setup({ + ...pivotSampleProps, + xScale: pivotSampleProps.stringScale, + yScale: pivotSampleProps.stringScale, + }) const circlesComponent = findByTestAttr(wrapper, 'circles') expect(circlesComponent.exists()).toBe(true) }) diff --git a/src/components/Charts/Columns/Columns.js b/src/components/Charts/Columns/Columns.js index 6d935b02b..f0acb34f2 100644 --- a/src/components/Charts/Columns/Columns.js +++ b/src/components/Charts/Columns/Columns.js @@ -1,78 +1,119 @@ import React, { Component } from 'react' import _get from 'lodash.get' +import { + chartElementDefaultProps, + chartElementPropTypes, + getTooltipContent, + scaleZero, + getKey, +} from '../helpers' export default class Columns extends Component { - static propTypes = {} + static propTypes = chartElementPropTypes + static defaultProps = chartElementDefaultProps state = { - activeKey: this.props.activeKey, + activeKey: this.props.activeChartElementKey, } - Y0 = () => { - const minValue = this.props.scales.yScale.domain()[0] - if (minValue > 0) { - return this.props.scales.yScale(minValue) - } - return this.props.scales.yScale(0) - } - Y = (d, i) => this.props.scales.yScale(_get(d, `cells[${i}].value`)) + onColumnClick = (row, colIndex, rowIndex) => { + const newActiveKey = getKey(colIndex, rowIndex) - getKey = (d, i) => { - const { labelValue } = this.props - return `${d[labelValue]}-${d.cells[i].label}` - } - - onColumnClick = (d, i) => { - const newActiveKey = this.getKey(d, i) - this.props.onChartClick({ - activeKey: newActiveKey, - drilldownData: d.cells[i].drilldownData, - }) + this.props.onChartClick( + row, + colIndex, + this.props.columns, + this.props.stringColumnIndex, + this.props.legendColumn, + this.props.numberColumnIndex, + newActiveKey + ) this.setState({ activeKey: newActiveKey }) } render = () => { - const { scales, labelValue } = this.props - const { xScale } = scales + const { + columns, + legendColumn, + numberColumnIndices, + stringColumnIndex, + dataFormatting, + xScale, + yScale, + } = this.props - const numberOfSeries = this.props.data[0].cells.length - const barWidth = xScale.bandwidth() / numberOfSeries + const visibleSeries = numberColumnIndices.filter((colIndex) => { + return !columns[colIndex].isSeriesHidden + }) - // Loop through each data value to make each series - const allBars = [] - for (let i = 0; i < numberOfSeries; i++) { - allBars.push( - this.props.data.map((d) => { - const x0 = xScale(d[labelValue]) - const dX = i * barWidth - const finalBarXPosition = x0 + dX - - let height = Math.abs(this.Y(d, i) - this.Y0()) - if (Number.isNaN(height)) { - height = 0 - } - - return ( - this.onColumnClick(d, i)} - data-tip={_get(d, `cells[${i}].tooltipData`)} - data-for="chart-element-tooltip" - style={{ fill: d.cells[i].color, fillOpacity: 0.7 }} - /> - ) - }) - ) + if (!visibleSeries.length) { + return null } + const allBars = [] + const barWidth = xScale.bandwidth() / visibleSeries.length + + let visibleIndex = 0 + numberColumnIndices.forEach((colIndex, i) => { + if (!columns[colIndex].isSeriesHidden) { + allBars.push( + this.props.data.map((d, index) => { + const value = d[colIndex] + if (!value) { + return null + } + + let y = value < 0 ? scaleZero(yScale) : yScale(value) + let height = Math.abs(yScale(value) - scaleZero(yScale)) + + if (isNaN(height) || isNaN(value)) { + y = scaleZero(yScale) + height = 0 + } + + if (height < 0.05) { + return null + } + + const x0 = xScale(d[stringColumnIndex]) + const dX = visibleIndex * barWidth + const finalBarXPosition = x0 + dX + + const tooltip = getTooltipContent({ + row: d, + columns, + colIndex, + stringColumnIndex, + legendColumn, + dataFormatting, + }) + + const key = getKey(colIndex, index) + + return ( + this.onColumnClick(d, colIndex, index)} + data-tip={tooltip} + data-for="chart-element-tooltip" + style={{ fill: this.props.colorScale(i), fillOpacity: 0.7 }} + /> + ) + }) + ) + visibleIndex += 1 + } + }) + return {allBars} } } diff --git a/src/components/Charts/Columns/Columns.test.js b/src/components/Charts/Columns/Columns.test.js index f3e73a587..a944e562b 100644 --- a/src/components/Charts/Columns/Columns.test.js +++ b/src/components/Charts/Columns/Columns.test.js @@ -1,27 +1,31 @@ import React from 'react' import { shallow } from 'enzyme' -import { scaleLinear, scaleBand } from 'd3-scale' - import { findByTestAttr } from '../../../../test/testUtils' import Columns from './Columns' +import sampleProps from '../chartTestData' -const defaultProps = { - labelValue: 'label', - data: [ - { cells: [{ value: 50 }, { value: 75 }], label: 'label1`' }, - { cells: [{ value: 30 }, { value: 65 }], label: 'label2`' } - ], - scales: { - xScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - yScale: scaleLinear() - .domain([0, 100]) - .range([0, 300]) - } +const scales = { + xScale: sampleProps.pivot.stringScale, + yScale: sampleProps.pivot.numberScale, } +const pivotSampleProps = { + ...sampleProps.pivot, + ...scales, +} + +const datePivotSampleProps = { + ...sampleProps.datePivot, + ...scales, +} + +const listSampleProps = { + ...sampleProps.list, + ...scales, +} + +const defaultProps = Columns.defaultProps + const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } const wrapper = shallow() @@ -32,8 +36,20 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders list data chart correctly', () => { + const wrapper = setup(listSampleProps) + const columnsComponent = findByTestAttr(wrapper, 'columns') + expect(columnsComponent.exists()).toBe(true) + }) + + test('renders pivot data chart correctly', () => { + const wrapper = setup(pivotSampleProps) + const columnsComponent = findByTestAttr(wrapper, 'columns') + expect(columnsComponent.exists()).toBe(true) + }) + + test('renders date pivot data chart correctly', () => { + const wrapper = setup(datePivotSampleProps) const columnsComponent = findByTestAttr(wrapper, 'columns') expect(columnsComponent.exists()).toBe(true) }) diff --git a/src/components/Charts/Line/Line.js b/src/components/Charts/Line/Line.js index 5b095d8b3..2ddd4e957 100644 --- a/src/components/Charts/Line/Line.js +++ b/src/components/Charts/Line/Line.js @@ -1,114 +1,186 @@ import React, { Component } from 'react' import _get from 'lodash.get' +import { + chartElementDefaultProps, + chartElementPropTypes, + getKey, + getTooltipContent, +} from '../helpers' export default class Line extends Component { - static propTypes = {} + static propTypes = chartElementPropTypes + static defaultProps = chartElementDefaultProps state = { - activeKey: this.props.activeKey, + activeKey: this.props.activeChartElementKey, } - getKey = (d, i) => { - const { labelValue } = this.props - return `${d[labelValue]}-${d.cells[i].label}` - } + onDotClick = (row, colIndex, rowIndex) => { + const newActiveKey = getKey(colIndex, rowIndex) - onDotClick = (d, i) => { - const newActiveKey = this.getKey(d, i) - this.props.onChartClick({ - activeKey: newActiveKey, - drilldownData: d.cells[i].drilldownData, - }) + this.props.onChartClick( + row, + colIndex, + this.props.columns, + this.props.stringColumnIndex, + this.props.legendColumn, + this.props.numberColumnIndex, + newActiveKey + ) this.setState({ activeKey: newActiveKey }) } - makeLines = () => { - const { scales, labelValue } = this.props - const { xScale, yScale } = scales - - const numberOfSeries = this.props.data[0].cells.length - const allLines = [] - - for (let series = 0; series < numberOfSeries; series++) { - this.props.data.forEach((d, i) => { - const d2 = this.props.data[i + 1] - const xShift = xScale.bandwidth() / 2 - const minValue = yScale.domain()[0] - - allLines.push( - - ) - }) - } + makePolyline = () => { + const { + columns, + numberColumnIndices, + stringColumnIndex, + yScale, + xScale, + } = this.props + + let polylines = [] + numberColumnIndices.forEach((colIndex, i) => { + let vertices = [] + if (!columns[colIndex].isSeriesHidden) { + this.props.data.forEach((d, index) => { + const value = d[colIndex] + const prevRow = this.props.data[index - 1] + const nextRow = this.props.data[index + 1] + + // If the visual difference between vertices is not noticeable, dont even render + const isFirstOrLastPoint = + index === 0 || index === this.props.data.length - 1 + if ( + !isFirstOrLastPoint && + Math.abs(yScale(value) - yScale(prevRow?.[colIndex])) < 0.05 && + Math.abs( + yScale(prevRow?.[colIndex]) - yScale(nextRow?.[colIndex]) + ) < 0.05 + ) { + return + } + + const xShift = xScale.bandwidth() / 2 + const minValue = yScale.domain()[0] + + const x = xScale(d[stringColumnIndex]) + xShift + const y = yScale(value || minValue) + const xy = [x, y] + vertices.push(xy) + }) + } + + const polylinePoints = vertices + .map((xy) => { + return xy.join(',') + }) + .join(' ') + + const polyline = ( + + ) + + polylines.push(polyline) + }) - return allLines + return polylines } - makeDots = () => { - const { scales, labelValue } = this.props - const { xScale, yScale } = scales + makeDots = (numVisibleSeries) => { + const { + columns, + legendColumn, + numberColumnIndices, + stringColumnIndex, + dataFormatting, + yScale, + xScale, + } = this.props - const numberOfSeries = this.props.data[0].cells.length const allDots = [] + numberColumnIndices.forEach((colIndex, i) => { + if (!columns[colIndex].isSeriesHidden) { + this.props.data.forEach((d, index) => { + const value = d[colIndex] + if (!value) { + return + } + + const cy = yScale(value) + if (cy < 0.05) { + return + } + + const xShift = xScale.bandwidth() / 2 + + const tooltip = getTooltipContent({ + row: d, + columns, + colIndex, + stringColumnIndex, + legendColumn, + dataFormatting, + }) + + allDots.push( + this.onDotClick(d, colIndex, index)} + data-tip={tooltip} + data-for="chart-element-tooltip" + style={{ + cursor: 'pointer', + stroke: this.props.colorScale(i), + strokeWidth: 2, + strokeOpacity: 0.7, + fillOpacity: 1, + opacity: 0, + fill: + this.state.activeKey === getKey(colIndex, index) + ? this.props.colorScale(i) + : this.props.backgroundColor || '#fff', + }} + /> + ) + }) + } + }) - for (let series = 0; series < numberOfSeries; series++) { - this.props.data.forEach((d) => { - const xShift = xScale.bandwidth() / 2 - allDots.push( - this.onDotClick(d, series)} - data-tip={_get(d, `cells[${series}].tooltipData`)} - data-for="chart-element-tooltip" - style={{ - cursor: 'pointer', - stroke: d.cells[series].color, - strokeWidth: 2, - strokeOpacity: 0.7, - fillOpacity: 1, - opacity: 0, - fill: - this.state.activeKey === this.getKey(d, series) - ? d.cells[series].color - : this.props.backgroundColor || '#fff', - }} - // onHover={{}} - /> - ) - }) - } return allDots } render = () => { + const visibleSeries = this.props.numberColumnIndices.filter((colIndex) => { + return !this.props.columns[colIndex].isSeriesHidden + }) + + const numVisibleSeries = visibleSeries.length + if (!numVisibleSeries) { + return null + } + return ( - {this.makeLines()} - {this.makeDots()} + {this.makePolyline()} + {this.makeDots(numVisibleSeries)} ) } diff --git a/src/components/Charts/Line/Line.test.js b/src/components/Charts/Line/Line.test.js index d75be7d35..bfc116f40 100644 --- a/src/components/Charts/Line/Line.test.js +++ b/src/components/Charts/Line/Line.test.js @@ -1,27 +1,31 @@ import React from 'react' import { shallow } from 'enzyme' -import { scaleLinear, scaleBand } from 'd3-scale' - import { findByTestAttr } from '../../../../test/testUtils' import Line from './Line' +import sampleProps from '../chartTestData' -const defaultProps = { - labelValue: 'label', - data: [ - { cells: [{ value: 50 }, { value: 75 }], label: 'label1`' }, - { cells: [{ value: 30 }, { value: 65 }], label: 'label2`' } - ], - scales: { - xScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - yScale: scaleLinear() - .domain([0, 100]) - .range([0, 300]) - } +const scales = { + xScale: sampleProps.pivot.stringScale, + yScale: sampleProps.pivot.numberScale, } +const pivotSampleProps = { + ...sampleProps.pivot, + ...scales, +} + +const datePivotSampleProps = { + ...sampleProps.datePivot, + ...scales, +} + +const listSampleProps = { + ...sampleProps.list, + ...scales, +} + +const defaultProps = Line.defaultProps + const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } const wrapper = shallow() @@ -32,8 +36,20 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders list data chart correctly', () => { + const wrapper = setup(listSampleProps) + const lineComponent = findByTestAttr(wrapper, 'line') + expect(lineComponent.exists()).toBe(true) + }) + + test('renders pivot data chart correctly', () => { + const wrapper = setup(pivotSampleProps) + const lineComponent = findByTestAttr(wrapper, 'line') + expect(lineComponent.exists()).toBe(true) + }) + + test('renders date pivot data chart correctly', () => { + const wrapper = setup(datePivotSampleProps) const lineComponent = findByTestAttr(wrapper, 'line') expect(lineComponent.exists()).toBe(true) }) diff --git a/src/components/Charts/Squares/Squares.js b/src/components/Charts/Squares/Squares.js index 291e38c8a..eb6f49fd1 100644 --- a/src/components/Charts/Squares/Squares.js +++ b/src/components/Charts/Squares/Squares.js @@ -1,90 +1,139 @@ import React, { Component } from 'react' import { scaleLinear } from 'd3-scale' +import { max, min } from 'd3-array' import _get from 'lodash.get' -import { themeConfigType } from '../../../props/types' -import { themeConfigDefault, getThemeConfig } from '../../../props/defaults' +import { + chartElementDefaultProps, + chartElementPropTypes, + getTooltipContent, + getKey, +} from '../helpers' export default class Squares extends Component { constructor(props) { super(props) + const maxValue = max( + props.data.map((row) => + max(row.filter((value, i) => props.numberColumnIndices.includes(i))) + ) + ) + + const minValue = min( + props.data.map((row) => + min(row.filter((value, i) => props.numberColumnIndices.includes(i))) + ) + ) + this.opacityScale = scaleLinear() - .domain([0, this.props.maxValue]) + .domain([minValue, maxValue]) .range([0, 1]) this.state = { - activeKey: this.props.activeKey, + activeKey: this.props.activeChartElementKey, } } - static propTypes = { - themeConfig: themeConfigType, - } + static propTypes = chartElementPropTypes + static defaultProps = chartElementDefaultProps + + onSquareClick = (row, colIndex, rowIndex) => { + const newActiveKey = getKey(colIndex, rowIndex) - static defaultProps = { - themeConfig: themeConfigDefault, + this.props.onChartClick( + row, + colIndex, + this.props.columns, + this.props.stringColumnIndex, + this.props.legendColumn, + this.props.numberColumnIndex, + newActiveKey + ) + + this.setState({ activeKey: newActiveKey }) } render = () => { - const { scales } = this.props - const { xScale, yScale } = scales + const { + columns, + legendColumn, + numberColumnIndices, + stringColumnIndex, + dataFormatting, + legendLabels, + colorScale, + yScale, + xScale, + } = this.props + + const visibleSeries = numberColumnIndices.filter((colIndex) => { + return !columns[colIndex].isSeriesHidden + }) + + if (!visibleSeries.length) { + return null + } const squares = [] - this.props.data.forEach((d) => { - d.cells.forEach((cell, i) => { - const fillColor = - cell.value >= 0 - ? _get(getThemeConfig(this.props.themeConfig), 'chartColors[0]') - : 'rgba(221, 106, 106)' - const activeFillColor = _get( - getThemeConfig(this.props.themeConfig), - 'chartColors[1]' - ) - - squares.push( - { - this.setState({ - activeKey: `${cell.label}-${d.label}`, - }) - this.props.onChartClick({ - activeKey: `${cell.label}-${d.label}`, - drilldownData: cell.drilldownData, - }) - }} - data-tip={cell.tooltipData} - data-for="chart-element-tooltip" - stroke={activeFillColor} - strokeWidth="2px" - strokeOpacity={ - this.state.activeKey === `${cell.label}-${d.label}` ? 1 : 0 - } - // chosen color for positive values and red for negative values - fill={ - this.state.activeKey === `${cell.label}-${d.label}` - ? activeFillColor - : fillColor - } - fillOpacity={ - this.state.activeKey === `${cell.label}-${d.label}` - ? 1 - : this.opacityScale(Math.abs(cell.value)) - } - /> - ) + this.props.data.forEach((row, index) => { + numberColumnIndices.forEach((colIndex, i) => { + if (!columns[colIndex].isSeriesHidden) { + const rawValue = row[colIndex] + const valueNumber = Number(rawValue) + const value = !Number.isNaN(valueNumber) ? valueNumber : 0 + + const xLabel = row[stringColumnIndex] + const yLabel = legendLabels[i].label + + const fillColor = value >= 0 ? colorScale(0) : 'rgba(221, 106, 106)' + const activeFillColor = colorScale(1) + + const tooltip = getTooltipContent({ + row, + columns, + colIndex, + stringColumnIndex, + legendColumn, + dataFormatting, + }) + + squares.push( + this.onSquareClick(row, colIndex, index)} + data-tip={tooltip} + data-for="chart-element-tooltip" + stroke={activeFillColor} + strokeWidth="2px" + strokeOpacity={ + this.state.activeKey === getKey(colIndex, index) ? 1 : 0 + } + // chosen color for positive values and red for negative values + fill={ + this.state.activeKey === getKey(colIndex, index) + ? activeFillColor + : fillColor + } + fillOpacity={ + this.state.activeKey === getKey(colIndex, index) + ? 1 + : this.opacityScale(Math.abs(value)) + } + /> + ) + } }) }) return {squares} diff --git a/src/components/Charts/Squares/Squares.test.js b/src/components/Charts/Squares/Squares.test.js index 3f530fd49..564d5c134 100644 --- a/src/components/Charts/Squares/Squares.test.js +++ b/src/components/Charts/Squares/Squares.test.js @@ -1,28 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' -import { scaleBand } from 'd3-scale' - import { findByTestAttr } from '../../../../test/testUtils' import Squares from './Squares' +import sampleProps from '../chartTestData' -const defaultProps = { - labelValueX: 'label', - labelValueY: 'label', - data: [ - { cells: [{ value: 50 }, { value: 75 }], label: 'label1`' }, - { cells: [{ value: 30 }, { value: 65 }], label: 'label2`' }, - ], - scales: { - xScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - yScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - }, -} +const pivotSampleProps = sampleProps.pivot +const defaultProps = Squares.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -34,8 +17,12 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders regular pivot chart data correctly', () => { + const wrapper = setup({ + ...pivotSampleProps, + xScale: pivotSampleProps.stringScale, + yScale: pivotSampleProps.stringScale, + }) const squaresComponent = findByTestAttr(wrapper, 'squares') expect(squaresComponent.exists()).toBe(true) }) diff --git a/src/components/Charts/StackedBars/StackedBars.js b/src/components/Charts/StackedBars/StackedBars.js index d182279cf..10b28fece 100644 --- a/src/components/Charts/StackedBars/StackedBars.js +++ b/src/components/Charts/StackedBars/StackedBars.js @@ -1,76 +1,121 @@ import React, { Component } from 'react' import _get from 'lodash.get' +import { + chartElementDefaultProps, + chartElementPropTypes, + getTooltipContent, + getKey, +} from '../helpers' export default class StackedColumns extends Component { - static propTypes = {} + static propTypes = chartElementPropTypes + static defaultProps = chartElementDefaultProps state = { - activeKey: this.props.activeKey, + activeKey: this.props.activeChartElementKey, } - getKey = (d, i) => { - return `${d.label}-${d.cells[i].label}` - } + onColumnClick = (row, colIndex, rowIndex) => { + const newActiveKey = getKey(colIndex, rowIndex) - onColumnClick = (d, i) => { - const newActiveKey = this.getKey(d, i) - this.props.onChartClick({ - activeKey: newActiveKey, - drilldownData: d.cells[i].drilldownData, - }) + this.props.onChartClick( + row, + colIndex, + this.props.columns, + this.props.stringColumnIndex, + this.props.legendColumn, + this.props.numberColumnIndex, + newActiveKey + ) this.setState({ activeKey: newActiveKey }) } render = () => { - const { scales } = this.props - const { xScale, yScale } = scales + const { + columns, + legendColumn, + numberColumnIndices, + stringColumnIndex, + dataFormatting, + yScale, + xScale, + } = this.props - const stackedColumns = this.props.data.map((d) => { - let runningPositiveSumObject = {} - let runningNegativeSumObject = {} + const visibleSeries = numberColumnIndices.filter((colIndex) => { + return !columns[colIndex].isSeriesHidden + }) - return d.cells.map((cell, i) => { - const valueNumber = Number(cell.value) - const value = !Number.isNaN(valueNumber) ? valueNumber : 0 + if (!visibleSeries.length) { + return null + } - let x - let width - if (value >= 0) { - const previousSum = runningPositiveSumObject[d.label] || 0 - const nextSum = previousSum + value - runningPositiveSumObject[d.label] = nextSum + const stackedBars = this.props.data.map((d, index) => { + let prevPosValue = 0 + let prevNegValue = 0 + const bars = numberColumnIndices.map((colIndex, i) => { + if (!columns[colIndex].isSeriesHidden) { + const rawValue = d[colIndex] + const valueNumber = Number(rawValue) + const value = !Number.isNaN(valueNumber) ? valueNumber : 0 - width = Math.abs(xScale(value) - xScale(0) - 0.5) - x = xScale(previousSum) - } else { - const previousSum = runningNegativeSumObject[d.label] || 0 - const nextSum = previousSum + value - runningNegativeSumObject[d.label] = nextSum + if (!value) { + return null + } - width = Math.abs(xScale(Math.abs(value)) - xScale(0) - 0.5) - x = xScale(nextSum) - } + let x + let width + if (value >= 0) { + const nextPosValue = prevPosValue + value + width = Math.abs(xScale(value) - xScale(0) - 0.5) + x = xScale(prevPosValue) + prevPosValue = nextPosValue + } else { + const nextNegValue = prevNegValue + value + width = Math.abs(xScale(Math.abs(value)) - xScale(0) - 0.5) + x = xScale(nextNegValue) + prevNegValue = nextNegValue + } - return ( - this.onColumnClick(d, i)} - data-tip={cell.tooltipData} - data-for="chart-element-tooltip" - style={{ fill: cell.color, fillOpacity: 0.7 }} - /> - ) + if (width < 0.05) { + return null + } + + const tooltip = getTooltipContent({ + row: d, + columns, + colIndex, + stringColumnIndex, + legendColumn, + dataFormatting, + }) + + return ( + this.onColumnClick(d, colIndex, index)} + data-tip={tooltip} + data-for="chart-element-tooltip" + style={{ + fill: this.props.colorScale(i), + fillOpacity: 0.7, + }} + /> + ) + } }) + return bars }) - return {stackedColumns} + return {stackedBars} } } diff --git a/src/components/Charts/StackedBars/StackedBars.test.js b/src/components/Charts/StackedBars/StackedBars.test.js index 0888eb857..33b813822 100644 --- a/src/components/Charts/StackedBars/StackedBars.test.js +++ b/src/components/Charts/StackedBars/StackedBars.test.js @@ -1,26 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' -import { scaleLinear, scaleBand } from 'd3-scale' - import { findByTestAttr } from '../../../../test/testUtils' import StackedBars from './StackedBars' +import sampleProps from '../chartTestData' -const defaultProps = { - labelValue: 'label', - data: [ - { cells: [{ value: 50 }, { value: 75 }], label: 'label1`' }, - { cells: [{ value: 30 }, { value: 65 }], label: 'label2`' } - ], - scales: { - xScale: scaleLinear() - .domain([0, 100]) - .range([0, 300]), - yScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1) - } -} +const pivotSampleProps = sampleProps.pivot +const defaultProps = StackedBars.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -33,7 +18,11 @@ const setup = (props = {}, state = null) => { describe('renders correctly', () => { test('renders correctly with required props', () => { - const wrapper = setup() + const wrapper = setup({ + ...pivotSampleProps, + xScale: pivotSampleProps.numberScale, + yScale: pivotSampleProps.stringScale, + }) const stackedBarsComponent = findByTestAttr(wrapper, 'stacked-bars') expect(stackedBarsComponent.exists()).toBe(true) }) diff --git a/src/components/Charts/StackedColumns/StackedColumns.js b/src/components/Charts/StackedColumns/StackedColumns.js index 0f5c3eee1..673afb420 100644 --- a/src/components/Charts/StackedColumns/StackedColumns.js +++ b/src/components/Charts/StackedColumns/StackedColumns.js @@ -1,100 +1,118 @@ import React, { Component } from 'react' import _get from 'lodash.get' +import { + chartElementDefaultProps, + chartElementPropTypes, + getTooltipContent, + getKey, +} from '../helpers' export default class StackedColumns extends Component { - constructor(props) { - super(props) + static propTypes = chartElementPropTypes + static defaultProps = chartElementDefaultProps - this.state = { - activeKey: this.props.activeKey, - } - } - - static propTypes = {} - - shouldComponentUpdate = (nextProps) => { - if (this.props.activeKey !== nextProps.activeKey) { - return true - } - - if (this.props.data?.length !== nextProps.data?.length) { - return true - } - - if ( - this.props.xScale !== nextProps.xScale || - this.props.yScale !== nextProps.yScale - ) { - return true - } - - return false - } - - getKey = (d, i) => { - return `${d.label}-${d.cells[i].label}` + state = { + activeKey: this.props.activeChartElementKey, } - onColumnClick = (d, i) => { - const newActiveKey = this.getKey(d, i) - this.props.onChartClick({ - activeKey: newActiveKey, - drilldownData: d.cells[i].drilldownData, - }) + onColumnClick = (row, colIndex, rowIndex) => { + const newActiveKey = getKey(colIndex, rowIndex) + + this.props.onChartClick( + row, + colIndex, + this.props.columns, + this.props.stringColumnIndex, + this.props.legendColumn, + this.props.numberColumnIndex, + newActiveKey + ) this.setState({ activeKey: newActiveKey }) } render = () => { - return ( - - {this.props.data.map((d) => { - let runningPositiveSumObject = {} - let runningNegativeSumObject = {} - - return d.cells.map((cell, i) => { - const valueNumber = Number(cell.value) - const value = !Number.isNaN(valueNumber) ? valueNumber : 0 - - let y - let height - if (value >= 0) { - const previousSum = runningPositiveSumObject[d.label] || 0 - const nextSum = previousSum + value - runningPositiveSumObject[d.label] = nextSum - - height = - Math.abs(this.props.yScale(value) - this.props.yScale(0)) - 0.5 - y = this.props.yScale(nextSum) + 0.5 - } else { - const previousSum = runningNegativeSumObject[d.label] || 0 - const nextSum = previousSum + value - runningNegativeSumObject[d.label] = nextSum + const { + columns, + legendColumn, + numberColumnIndices, + stringColumnIndex, + dataFormatting, + yScale, + xScale, + } = this.props + + const visibleSeries = numberColumnIndices.filter((colIndex) => { + return !columns[colIndex].isSeriesHidden + }) - height = - Math.abs(this.props.yScale(value) - this.props.yScale(0)) - 0.5 - y = this.props.yScale(previousSum) + 0.5 - } + if (!visibleSeries.length) { + return null + } - return ( - this.onColumnClick(d, i)} - data-tip={cell.tooltipData} - data-for="chart-element-tooltip" - style={{ fill: cell.color, fillOpacity: 0.7 }} - /> - ) + const stackedColumns = this.props.data.map((d, index) => { + let prevPosValue = 0 + let prevNegValue = 0 + const bars = numberColumnIndices.map((colIndex, i) => { + if (!columns[colIndex].isSeriesHidden) { + const rawValue = d[colIndex] + const valueNumber = Number(rawValue) + const value = !Number.isNaN(valueNumber) ? valueNumber : 0 + + if (!value) { + return null + } + + let y + let height + + if (value >= 0) { + const nextPosValue = prevPosValue + value + height = Math.abs(yScale(value) - yScale(0)) - 0.5 + y = yScale(nextPosValue) + 0.5 + prevPosValue = nextPosValue + } else { + const nextNegValue = prevNegValue + value + height = Math.abs(yScale(value) - yScale(0)) - 0.5 + y = yScale(prevNegValue) + 0.5 + prevNegValue = nextNegValue + } + + if (height < 0.05) { + return null + } + + const tooltip = getTooltipContent({ + row: d, + columns, + colIndex, + stringColumnIndex, + legendColumn, + dataFormatting, }) - })} - - ) + + return ( + this.onColumnClick(d, colIndex, index)} + data-tip={tooltip} + data-for="chart-element-tooltip" + style={{ fill: this.props.colorScale(i), fillOpacity: 0.7 }} + /> + ) + } + }) + return bars + }) + return {stackedColumns} } } diff --git a/src/components/Charts/StackedColumns/StackedColumns.test.js b/src/components/Charts/StackedColumns/StackedColumns.test.js index e9a24fd78..1c28fbf29 100644 --- a/src/components/Charts/StackedColumns/StackedColumns.test.js +++ b/src/components/Charts/StackedColumns/StackedColumns.test.js @@ -1,25 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' -import { scaleLinear, scaleBand } from 'd3-scale' - import { findByTestAttr } from '../../../../test/testUtils' import StackedColumns from './StackedColumns' +import sampleProps from '../chartTestData' -const defaultProps = { - data: [ - { cells: [{ value: 50 }, { value: 75 }], label: 'label1`' }, - { cells: [{ value: 30 }, { value: 65 }], label: 'label2`' }, - ], - xScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - yScale: scaleLinear() - .domain([0, 100]) - .range([0, 300]), - height: 300, - width: 300, -} +const pivotSampleProps = sampleProps.pivot +const defaultProps = StackedColumns.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -31,8 +17,12 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders regular pivot chart data correctly', () => { + const wrapper = setup({ + ...pivotSampleProps, + xScale: pivotSampleProps.stringScale, + yScale: pivotSampleProps.numberScale, + }) const stackedColumnsComponent = findByTestAttr(wrapper, 'stacked-columns') expect(stackedColumnsComponent.exists()).toBe(true) }) diff --git a/src/components/Charts/StackedLines/StackedLines.js b/src/components/Charts/StackedLines/StackedLines.js index f23f946ed..928da510a 100644 --- a/src/components/Charts/StackedLines/StackedLines.js +++ b/src/components/Charts/StackedLines/StackedLines.js @@ -1,46 +1,62 @@ import React, { Component } from 'react' import _get from 'lodash.get' +import { + chartElementDefaultProps, + chartElementPropTypes, + getTooltipContent, + getKey, +} from '../helpers' export default class StackedLines extends Component { - static propTypes = {} + static propTypes = chartElementPropTypes + static defaultProps = chartElementDefaultProps state = { - activeKey: this.props.activeKey, + activeKey: this.props.activeChartElementKey, } - getKey = (d, i) => { - return `${d.label}-${d.cells[i].label}` - } - - onDotClick = (d, i) => { - const newActiveKey = this.getKey(d, i) - this.props.onChartClick({ - activeKey: newActiveKey, - drilldownData: d.cells[i].drilldownData, - }) + onDotClick = (row, colIndex, rowIndex) => { + const newActiveKey = getKey(colIndex, rowIndex) + + this.props.onChartClick( + row, + colIndex, + this.props.columns, + this.props.stringColumnIndex, + this.props.legendColumn, + this.props.numberColumnIndex, + newActiveKey + ) this.setState({ activeKey: newActiveKey }) } - createPolygonVertexDot = (d, series, x, y) => { - const cell = d.cells[series] + createPolygonVertexDot = (d, i, x, y, colIndex, index) => { + const tooltip = getTooltipContent({ + row: d, + columns: this.props.columns, + colIndex, + stringColumnIndex: this.props.stringColumnIndex, + legendColumn: this.props.legendColumn, + dataFormatting: this.props.dataFormatting, + }) return ( this.onDotClick(d, series)} - data-tip={cell.tooltipData} + onClick={() => this.onDotClick(d, colIndex, i)} + data-tip={tooltip} data-for="chart-element-tooltip" style={{ - opacity: this.state.activeKey === this.getKey(d, series) ? 1 : 0, + opacity: this.state.activeKey === getKey(colIndex, index) ? 1 : 0, cursor: 'pointer', - stroke: cell.color, + stroke: this.props.colorScale(i), strokeWidth: 3, strokeOpacity: 0.7, fillOpacity: 1, @@ -50,7 +66,8 @@ export default class StackedLines extends Component { ) } - createPolygon = (series, polygonVertices) => { + createPolygon = (i, polygonVertices) => { + const { stringColumnIndex } = this.props const polygonPoints = polygonVertices .map((xy) => { return xy.join(',') @@ -59,22 +76,17 @@ export default class StackedLines extends Component { return ( - ${this.props.legendTitle}: ${this.props.data[0].cells[series].label} + ${this.props.legendTitle}: ${this.props.legendLabels[i].label} `} data-for="chart-element-tooltip" data-effect="float" style={{ - fill: this.props.data[0].cells[series].color, + fill: this.props.colorScale(i), fillOpacity: 1, }} /> @@ -82,66 +94,74 @@ export default class StackedLines extends Component { } render = () => { - const { scales } = this.props - const { xScale, yScale } = scales + const { + columns, + numberColumnIndices, + stringColumnIndex, + yScale, + xScale, + } = this.props + + const visibleSeries = numberColumnIndices.filter((colIndex) => { + return !columns[colIndex].isSeriesHidden + }) + + if (!visibleSeries.length) { + return null + } const polygons = [] const polygonVertexDots = [] - const numPolygons = this.props.data[0].cells.length - - const firstPoint = [ - xScale(this.props.data[0].label), - yScale(this.props.minValue), - ] - const lastPoint = [ - xScale(this.props.data[this.props.data.length - 1].label), - yScale(this.props.minValue), - ] - - let runningPositiveSumObject = {} - let runningNegativeSumObject = {} - - for (let series = 0; series < numPolygons; series++) { - // First point goes on (0,0) - let polygonVertices = [firstPoint] - - this.props.data.forEach((d) => { - const cell = d.cells[series] - const valueNumber = Number(cell.value) - const value = !Number.isNaN(valueNumber) ? valueNumber : 0 - - let y - const x = xScale(d.label) - if (value >= 0) { - const previousSum = runningPositiveSumObject[d.label] || 0 - const nextSum = previousSum + value - runningPositiveSumObject[d.label] = nextSum - y = yScale(nextSum) + 0.5 - } else { - const previousSum = runningNegativeSumObject[d.label] || 0 - const nextSum = previousSum + value - runningNegativeSumObject[d.label] = nextSum - y = yScale(previousSum) + 0.5 - } - - polygonVertices.push([x, y]) - - if (cell.value !== 0) { - const polygonVertexDot = this.createPolygonVertexDot(d, series, x, y) - polygonVertexDots.push(polygonVertexDot) - } - }) - // Last points go on (max, 0) then back to the beginning (0,0) - polygonVertices.push(lastPoint) - polygonVertices.push(firstPoint) // this one might not be necessary + let minValue = yScale.domain()[0] + if (minValue < 0) minValue = 0 - // Add polygon to list - polygons.push(this.createPolygon(series, polygonVertices)) - } + let prevValues = [] + let prevPolygonVertices = [] + xScale.domain().forEach((xLabel) => { + prevValues.push(minValue) + prevPolygonVertices.push([xScale(xLabel), yScale(minValue)]) + }) - // Reverse order so smallest areas are drawn on top - polygons.reverse() + numberColumnIndices.forEach((colIndex, i) => { + let currentValues = [] + let currentPolygonVertices = [] + if (!columns[colIndex].isSeriesHidden) { + this.props.data.forEach((d, index) => { + const rawValue = d[colIndex] + const valueNumber = Number(rawValue) + const value = isNaN(valueNumber) ? 0 : valueNumber + + const x = xScale(d[stringColumnIndex]) + const prevValue = prevValues[index] + const currentValue = prevValue + value + const y = yScale(currentValue) + + currentValues[index] = currentValue + currentPolygonVertices.push([x, y]) + + if (value !== 0) { + const polygonVertexDot = this.createPolygonVertexDot( + d, + i, + x, + y, + colIndex, + index + ) + polygonVertexDots.push(polygonVertexDot) + } + }) + + // Add polygon to list + const reversedPrevVertices = prevPolygonVertices.reverse() + const polygon = reversedPrevVertices.concat(currentPolygonVertices) + polygons.push(this.createPolygon(i, polygon)) + + prevValues = currentValues + prevPolygonVertices = currentPolygonVertices + } + }) return ( diff --git a/src/components/Charts/StackedLines/StackedLines.test.js b/src/components/Charts/StackedLines/StackedLines.test.js index 487ddc904..92a546c75 100644 --- a/src/components/Charts/StackedLines/StackedLines.test.js +++ b/src/components/Charts/StackedLines/StackedLines.test.js @@ -1,26 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' -import { scaleLinear, scaleBand } from 'd3-scale' - import { findByTestAttr } from '../../../../test/testUtils' import StackedLines from './StackedLines' +import sampleProps from '../chartTestData' -const defaultProps = { - labelValue: 'label', - data: [ - { cells: [{ value: 50 }, { value: 75 }], label: 'label1`' }, - { cells: [{ value: 30 }, { value: 65 }], label: 'label2`' } - ], - scales: { - xScale: scaleBand() - .domain(['label1', 'label2']) - .range([0, 200]) - .paddingInner(0.1), - yScale: scaleLinear() - .domain([0, 100]) - .range([0, 300]) - } -} +const pivotSampleProps = sampleProps.pivot +const defaultProps = StackedLines.defaultProps const setup = (props = {}, state = null) => { const setupProps = { ...defaultProps, ...props } @@ -32,8 +17,12 @@ const setup = (props = {}, state = null) => { } describe('renders correctly', () => { - test('renders correctly with required props', () => { - const wrapper = setup() + test('renders regular pivot chart data correctly', () => { + const wrapper = setup({ + ...pivotSampleProps, + xScale: pivotSampleProps.stringScale, + yScale: pivotSampleProps.numberScale, + }) const stackedLinesComponent = findByTestAttr(wrapper, 'stacked-lines') expect(stackedLinesComponent.exists()).toBe(true) }) diff --git a/src/components/Charts/chartTestData.js b/src/components/Charts/chartTestData.js new file mode 100644 index 000000000..e3cbdd386 --- /dev/null +++ b/src/components/Charts/chartTestData.js @@ -0,0 +1,300 @@ +import { scaleLinear, scaleBand, scaleOrdinal } from 'd3-scale' + +const listColumns = [ + { + display_name: 'Trade Date', + groupable: false, + is_visible: true, + multi_series: false, + name: 'v_volume.trade_date', + type: 'DATE', + }, + { + display_name: 'Contract Name', + groupable: false, + is_visible: true, + multi_series: false, + name: 'v_volume.contract_name', + type: 'STRING', + }, + { + display_name: 'Alternate Contract Name', + groupable: false, + is_visible: true, + multi_series: false, + name: 'v_volume.alternate_contract_name', + type: 'STRING', + }, + { + display_name: 'Symbol', + groupable: false, + is_visible: true, + multi_series: false, + name: 'company.symbol', + type: 'STRING', + }, + { + display_name: 'Expiry Date', + groupable: false, + is_visible: true, + multi_series: false, + name: 'contract.expiry_date', + type: 'DATE', + }, + { + display_name: 'Put/Call', + groupable: false, + is_visible: true, + multi_series: false, + name: 'contract.put_call', + type: 'STRING', + }, + { + display_name: 'Strike', + groupable: false, + is_visible: true, + multi_series: false, + name: 'contract.strike', + type: 'QUANTITY', + }, + { + display_name: 'Daily Volume', + groupable: false, + is_visible: true, + multi_series: false, + name: 'v_volume.volume', + type: 'QUANTITY', + }, + { + display_name: 'DTX', + groupable: false, + is_visible: true, + multi_series: false, + name: 'contract.dtx', + type: 'QUANTITY', + }, +] + +const pivotColumns = [ + { + display_name: 'Put/Call', + groupable: true, + is_visible: true, + multi_series: false, + name: 'contract.put_call', + type: 'STRING', + }, + { + display_name: 'Sector', + groupable: true, + is_visible: true, + multi_series: false, + name: 'company.sector', + type: 'STRING', + }, + { + display_name: 'Total Volume', + groupable: false, + is_visible: true, + multi_series: false, + name: 'sum(v_volume.volume)', + type: 'QUANTITY', + }, +] + +const datePivotColumns = [ + { + type: 'DATE', + groupable: true, + active: false, + name: 'sale__transaction_date__month', + }, + { + active: false, + groupable: false, + name: 'sale__line_item___sum', + type: 'DOLLAR_AMT', + }, +] + +const listData = [ + [ + 1648684800, + 'ZS230721P00195000', + 'ZS 07/21/23 195P', + 'ZS', + 1689897600, + 'PUTS', + 195, + 100, + 431, + ], + [ + 1648684800, + 'WFC240119P00047500', + 'WFC 01/19/24 47.5P', + 'WFC', + 1705622400, + 'PUTS', + 47.5, + 650, + 613, + ], + [ + 1648684800, + 'TSLA220520C00840000', + 'TSLA 05/20/22 840C', + 'TSLA', + 1653004800, + 'CALLS', + 840, + 50, + 4, + ], + [ + 1648684800, + 'CF221118P00095000', + 'CF 11/18/22 95P', + 'CF', + 1668729600, + 'PUTS', + 95, + 88, + 186, + ], +] + +const pivotData = [ + ['CALLS', 'Technology', 15112902], + ['PUTS', 'Technology', 11532122], + ['CALLS', 'Energy ', 10108054], + ['CALLS', 'Media', 9479177], + ['CALLS', 'Automobiles', 8522149], + ['PUTS', 'Media', 8401227], +] + +const datePivotData = [ + [1483142400, 12500], + [1488240000, 8742.68], + [1490918400, 11723.36], + [1493510400, 3243.12], + [1496188800, 14642.19], +] + +export default { + list: { + columns: listColumns, + data: listData, + numberColumnIndex: 7, + numberColumnIndices: [6, 7, 8], + visibleSeriesIndices: [6, 7], + stringColumnIndex: 0, + stringColumnIndices: [0, 1, 2, 3, 4, 5], + height: 500, + width: 500, + stringScale: scaleBand() + .domain(listData.map((d) => d[0])) + .range([0, 500]) + .paddingInner(1) + .paddingOuter(0), + numberScale: scaleLinear() + .domain([5000000, 20000000]) + .range([0, 500]) + .nice(), + colorScale: scaleOrdinal().range(['red', 'blue']), + onLabelChange: () => {}, + }, + pivot: { + columns: pivotColumns, + data: pivotData, + numberColumnIndex: 1, + numberColumnIndices: [1, 2], + visibleSeriesIndices: [1, 2], + stringColumnIndex: 0, + stringColumnIndices: [0], + stringScale: scaleBand() + .domain(pivotData.map((d) => d[0])) + .range([0, 500]) + .paddingInner(1) + .paddingOuter(0), + numberScale: scaleLinear() + .domain([5000000, 20000000]) + .range([0, 500]) + .nice(), + legendLabels: [ + { + color: '#26A7E9', + columnIndex: 1, + hidden: undefined, + label: 'PUTS', + column: { + display_name: 'Total Volume', + field: '1', + groupable: false, + headerContext: undefined, + id: 'f73a892b-7fb0-4801-9f94-2f3f5e7d7675', + is_visible: true, + multi_series: false, + name: 'PUTS', + origColumn: {}, + sorter: undefined, + title: 'PUTS', + type: 'QUANTITY', + visible: true, + widthGrow: 1, + widthShrink: 1, + }, + }, + { + color: '#A5CD39', + columnIndex: 2, + hidden: undefined, + label: 'CALLS', + column: { + display_name: 'Total Volume', + field: '2', + groupable: false, + headerContext: undefined, + hozAlign: 'center', + id: 'f73a892b-7fb0-4801-9f94-2f3f5e7d7675', + is_visible: true, + multi_series: false, + name: 'CALLS', + origColumn: {}, + sorter: undefined, + title: 'CALLS', + type: 'QUANTITY', + visible: true, + widthGrow: 1, + widthShrink: 1, + }, + }, + ], + colorScale: scaleOrdinal().range(['red', 'blue']), + height: 500, + width: 500, + onLabelChange: () => {}, + }, + datePivot: { + columns: datePivotColumns, + data: datePivotData, + numberColumnIndex: 1, + numberColumnIndices: [1], + visibleSeriesIndices: [1], + stringColumnIndex: 0, + stringColumnIndices: [0], + height: 500, + width: 500, + stringScale: scaleBand() + .domain(datePivotData.map((d) => d[0])) + .range([0, 500]) + .paddingInner(1) + .paddingOuter(0), + numberScale: scaleLinear() + .domain([0, 1000]) + .range([0, 500]) + .nice(), + colorScale: scaleOrdinal().range(['red', 'blue']), + onLabelChange: () => {}, + }, +} diff --git a/src/components/Charts/helpers.js b/src/components/Charts/helpers.js index 2fcba0e14..edb741f8f 100644 --- a/src/components/Charts/helpers.js +++ b/src/components/Charts/helpers.js @@ -1,21 +1,246 @@ +import PropTypes from 'prop-types' import { max, min } from 'd3-array' import _get from 'lodash.get' +import _isEqual from 'lodash.isequal' + +import { formatElement, onlyUnique } from '../../js/Util' +import { themeConfigType, dataFormattingType } from '../../props/types' +import { themeConfigDefault, dataFormattingDefault } from '../../props/defaults' + +export const chartContainerPropTypes = { + themeConfig: themeConfigType, + dataFormatting: dataFormattingType, + + data: PropTypes.arrayOf(PropTypes.array).isRequired, + columns: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + numberColumnIndices: PropTypes.arrayOf(PropTypes.number).isRequired, + stringColumnIndices: PropTypes.arrayOf(PropTypes.number).isRequired, + stringColumnIndex: PropTypes.number.isRequired, + numberColumnIndex: PropTypes.number.isRequired, + legendColumnIndex: PropTypes.number, + enableDynamicCharting: PropTypes.bool, + isResizing: PropTypes.bool, + onLegendClick: PropTypes.func, + onLabelChange: PropTypes.func, + onXAxisClick: PropTypes.func, + onYAxisClick: PropTypes.func, +} + +export const chartContainerDefaultProps = { + themeConfig: themeConfigDefault, + dataFormatting: dataFormattingDefault, + + enableDynamicCharting: true, + legendColumnIndex: undefined, + isResizing: false, + onLegendClick: () => {}, + onXAxisClick: () => {}, + onYAxisClick: () => {}, + onLabelChange: () => {}, +} + +export const chartPropTypes = { + ...chartContainerPropTypes, + visibleSeriesIndices: PropTypes.arrayOf(PropTypes.number).isRequired, + height: PropTypes.number.isRequired, + width: PropTypes.number.isRequired, + leftMargin: PropTypes.number, + rightMargin: PropTypes.number, + topMargin: PropTypes.number, + bottomMargin: PropTypes.number, +} + +export const chartDefaultProps = { + ...chartContainerDefaultProps, + leftMargin: 0, + rightMargin: 0, + topMargin: 0, + bottomMargin: 0, +} + +export const axesPropTypes = { + ...chartPropTypes, + xScale: PropTypes.func.isRequired, + yScale: PropTypes.func.isRequired, + xCol: PropTypes.shape({}).isRequired, + yCol: PropTypes.shape({}).isRequired, + xTicks: PropTypes.array, + yTicks: PropTypes.array, + xGridLines: PropTypes.bool, + yGridLines: PropTypes.bool, + rotateLabels: PropTypes.bool, + hasRightLegend: PropTypes.bool, + hasBottomLegend: PropTypes.bool, + hasXDropdown: PropTypes.bool, + hasYDropdown: PropTypes.bool, + xAxisTitle: PropTypes.string, + yAxisTitle: PropTypes.string, + legendTitle: PropTypes.string, +} + +export const axesDefaultProps = { + ...chartDefaultProps, + xTicks: undefined, + yTicks: undefined, + xGridLines: false, + yGridLines: false, + rotateLabels: false, + hasRightLegend: false, + hasBottomLegend: false, + hasXDropdown: false, + hasYDropdown: false, + xAxisTitle: undefined, + yAxisTitle: undefined, + legendTitle: undefined, +} + +export const chartElementPropTypes = { + ...chartPropTypes, + xScale: PropTypes.func.isRequired, + yScale: PropTypes.func.isRequired, +} + +export const chartElementDefaultProps = { + activeKey: undefined, + onChartClick: () => {}, +} + +export const dataStructureChanged = (props, prevProps) => { + return ( + props.data?.length !== prevProps.data?.length || + props.stringColumnIndex !== prevProps.stringColumnIndex || + props.numberColumnIndex !== prevProps.numberColumnIndex || + !_isEqual(props.legendColumn, prevProps.legendColumn) || + !_isEqual(props.columns, prevProps.columns) || + !_isEqual(props.numberColumnIndices, prevProps.numberColumnIndices) || + !_isEqual(props.stringColumnIndices, prevProps.stringColumnIndices) || + (props.type === 'pie' && !_isEqual(props.data, prevProps.data)) + ) +} + +export const scaleZero = (scale) => { + const domain = scale?.domain() + const domainLength = domain?.length + + if (!domainLength) { + return scale?.(0) || 0 + } + + let min = domain[0] + let max = domain[domain?.length - 1] + if (min > 0 && max > 0) return scale(min) + if (min < 0 && max < 0) return scale(max) + return scale(0) +} + +export const getKey = (rowIndex, cellIndex) => { + return `${rowIndex}-${cellIndex}` +} + +export const shouldRecalculateLongestLabel = (prevProps, props) => { + return ( + props.marginAdjustmentFinished && + (prevProps?.data?.length !== props.data?.length || + !_isEqual(prevProps.numberColumnIndices, props.numberColumnIndices) || + !_isEqual(prevProps.legendLabels, props.legendLabels) || + prevProps.stringColumnIndex !== props.stringColumnIndex) + ) +} + +export const getTooltipContent = ({ + row, + columns, + colIndex, + stringColumnIndex, + legendColumn, + dataFormatting, +}) => { + let tooltipElement = null + try { + const stringColumn = columns[stringColumnIndex] + const numberColumn = columns[colIndex] + + const stringTitle = stringColumn.title + const numberTitle = numberColumn.origColumn + ? numberColumn.origColumn.title + : numberColumn.title + + const stringValue = formatElement({ + element: row[stringColumnIndex], + column: stringColumn, + config: dataFormatting, + }) + + const numberValue = formatElement({ + element: row[colIndex] || 0, + column: columns[colIndex], + config: dataFormatting, + }) + + const tooltipLine1 = !!legendColumn + ? `
${legendColumn.title}: ${columns?.[colIndex]?.title}
` + : '' + const tooltipLine2 = `
${stringTitle}: ${stringValue}
` + const tooltipLine3 = `
${numberTitle}: ${numberValue}
` + + tooltipElement = `
+ ${tooltipLine1} + ${tooltipLine2} + ${tooltipLine3} +
` + + return tooltipElement + } catch (error) { + console.error(error) + return null + } +} + +// export const getLegendLabelsForMultiSeries = ( +// data, +// columns, +// legendColumnIndex, +// colorScale +// ) => { +// if (isNaN(legendColumnIndex)) { +// return undefined +// } + +// try { +// const labelArray = data.map((r) => r[legendColumnIndex]) +// const uniqueLabels = [...new Set(labelArray)] +// const legendLabels = {} +// uniqueLabels.forEach((label, i) => { +// legendLabels[label] = { +// color: colorScale(i), +// hidden: columns[columnIndex].isSeriesHidden, +// } +// }) +// return uniqueLabels +// } catch (error) { +// console.error(error) +// return [] +// } +// } export const getLegendLabelsForMultiSeries = ( columns, colorScale, - seriesIndices = [] + numberColumnIndices = [] ) => { try { - if (seriesIndices.length < 1) { + if (numberColumnIndices.length < 1) { return [] } - const legendLabels = seriesIndices.map((columnIndex, i) => { + const legendLabels = numberColumnIndices.map((columnIndex, i) => { + const column = columns[columnIndex] return { - label: columns[columnIndex].title, + label: column.title, color: colorScale(i), - hidden: columns[columnIndex].isSeriesHidden, + hidden: column.isSeriesHidden, + columnIndex, + column, } }) return legendLabels @@ -35,16 +260,110 @@ export const getNumberOfSeries = (data) => { } } -export const getMinAndMaxValues = (data) => { +export const convertToNumber = (value) => { + try { + let number = Number(value) + if (isNaN(number)) { + return 0 + } + return number + } catch (error) { + return 0 + } +} + +export const calculateMinAndMaxSums = ( + data, + stringColumnIndex, + numberColumnIndices +) => { + const positiveSumsObject = {} + const negativeSumsObject = {} + + // Loop through data array to get maximum and minimum sums of postive and negative values + // These will be used to get the max and min values for the x Scale (data values) + data.forEach((row) => { + const label = row[stringColumnIndex] + numberColumnIndices.forEach((colIndex) => { + const rawValue = row[colIndex] + let value = Number(rawValue) + if (isNaN(value)) value = 0 + + if (value >= 0) { + // Calculate positive sum + if (positiveSumsObject[label]) { + positiveSumsObject[label] += value + } else { + positiveSumsObject[label] = value + } + } else if (value < 0) { + // Calculate negative sum + if (negativeSumsObject[label]) { + negativeSumsObject[label] -= value + } else { + negativeSumsObject[label] = value + } + } + }) + }) + + // Get max and min sums from those sum objects + const maxValue = getMaxValueFromKeyValueObj(positiveSumsObject) + const minValue = getMinValueFromKeyValueObj(negativeSumsObject) + + return { + maxValue, + minValue, + } +} + +export const getObjSize = (obj) => { + if (typeof obj !== 'object') { + return undefined + } + + return Object.keys(obj).length +} + +export const getMaxValueFromKeyValueObj = (obj) => { + const size = getObjSize(obj) + + let maxValue = 0 + if (size === 1) { + maxValue = obj[Object.keys(obj)[0]] + } else if (size > 1) { + const numberValues = [...Object.values(obj)].filter((value) => { + return !Number.isNaN(Number(value)) + }) + maxValue = Math.max(...numberValues) + } + return maxValue +} + +export const getMinValueFromKeyValueObj = (obj) => { + const size = getObjSize(obj) + + let minValue = 0 + if (size === 1) { + minValue = obj[Object.keys(obj)[0]] + } else if (size > 1) { + const numberValues = [...Object.values(obj)].filter((value) => { + return !Number.isNaN(Number(value)) + }) + minValue = Math.min(...numberValues) + } + return minValue +} + +export const getMinAndMaxValues = (data, numberColumnIndices) => { try { - const numSeries = getNumberOfSeries(data) const maxValuesFromArrays = [] const minValuesFromArrays = [] - for (let i = 0; i < numSeries; i++) { - maxValuesFromArrays.push(max(data, (d) => d.cells[i].value)) - minValuesFromArrays.push(min(data, (d) => d.cells[i].value)) - } + numberColumnIndices.forEach((colIndex, i) => { + maxValuesFromArrays.push(max(data, (d) => convertToNumber(d[colIndex]))) + minValuesFromArrays.push(min(data, (d) => convertToNumber(d[colIndex]))) + }) let maxValue = max(maxValuesFromArrays) let minValue = min(minValuesFromArrays) @@ -70,6 +389,10 @@ export const getMinAndMaxValues = (data) => { } export const getLegendLocation = (seriesArray, displayType) => { + if (seriesArray?.length < 2) { + return undefined + } + if ( displayType === 'pie' || displayType === 'heatmap' || @@ -129,20 +452,18 @@ export const doesElementOverflowContainer = (element, container) => { export const getTickValues = (labelWidth, fullWidth, labelArray) => { try { const interval = Math.ceil((labelArray.length * 20) / fullWidth) - let tickValues - if (labelWidth < 20) { - tickValues = [] + const tickValues = [] labelArray.forEach((label, index) => { if (index % interval === 0) { tickValues.push(label) } }) + return tickValues } - - return tickValues } catch (error) { console.error(error) - return [] } + + return labelArray } diff --git a/src/components/ChatMessage/ChatMessage.js b/src/components/ChatMessage/ChatMessage.js index cb085036d..5e847cdac 100644 --- a/src/components/ChatMessage/ChatMessage.js +++ b/src/components/ChatMessage/ChatMessage.js @@ -25,7 +25,6 @@ import { import { QueryOutput } from '../QueryOutput' import { VizToolbar } from '../VizToolbar' -import { Icon } from '../Icon' import { OptionsToolbar } from '../OptionsToolbar' import ErrorBoundary from '../../containers/ErrorHOC/ErrorHOC' @@ -33,20 +32,16 @@ import { getDefaultDisplayType, isChartType, getSupportedDisplayTypes, - areAllColumnsHidden, - isTableType, - removeFromDOM, } from '../../js/Util' import errorMessages from '../../js/errorMessages' import './ChatMessage.scss' -import { exportCSV } from '../../js/queryService' +import { Spinner } from '../Spinner' export default class ChatMessage extends React.Component { constructor(props) { super(props) - this.supportedDisplayTypes = [] this.filtering = false this.PIE_CHART_HEIGHT = 330 this.MESSAGE_HEIGHT_MARGINS = 40 @@ -64,9 +59,9 @@ export default class ChatMessage extends React.Component { props.response, props.autoChartAggregations ), - supportedDisplayTypes: getSupportedDisplayTypes(props.response), - chartHeight: this.getChartHeight(displayType), - chartWidth: this.getChartWidth(), + supportedDisplayTypes: getSupportedDisplayTypes({ + response: props.response, + }), isAnimatingMessageBubble: true, isSettingColumnVisibility: false, activeMenu: undefined, @@ -104,7 +99,6 @@ export default class ChatMessage extends React.Component { onConditionClickCallback: PropTypes.func, onResponseCallback: PropTypes.func, addMessageToDM: PropTypes.func, - onCSVExportClick: PropTypes.func, csvDownloadProgress: PropTypes.number, onRTValueLabelClick: PropTypes.func, messageContainerHeight: PropTypes.number, @@ -149,43 +143,6 @@ export default class ChatMessage extends React.Component { this.props.scrollToBottom() }, 100) - if ( - this.props.isCSVProgressMessage && - typeof this.state.csvDownloadProgress === 'undefined' - ) { - this.props.setCSVDownloadProgress(this.props.id, 0) - const linkedQueryResponseRef = this.props.linkedQueryResponseRef - const queryDisplayType = _get(linkedQueryResponseRef, 'props.displayType') - - if (queryDisplayType === 'pivot_table') { - if (_get(linkedQueryResponseRef, 'pivotTableRef')) { - linkedQueryResponseRef.pivotTableRef.saveAsCSV().then(() => { - this.props.setCSVDownloadProgress(this.props.id, 100) - this.onCSVExportFinish(undefined, true) - }) - } - } else { - exportCSV({ - queryId: this.props.queryId, - ...getAuthentication(this.props.authentication), - csvProgressCallback: (percentCompleted) => - this.props.setCSVDownloadProgress(this.props.id, percentCompleted), - }) - .then((response) => { - const url = window.URL.createObjectURL(new Blob([response.data])) - const link = document.createElement('a') - link.href = url - link.setAttribute('download', 'export.csv') - document.body.appendChild(link) - link.click() - this.onCSVExportFinish(response) - }) - .catch((error) => { - console.error(error) - }) - } - } - // Wait until message bubble animation finishes to show query output content clearTimeout(this.animationTimeout) this.animationTimeout = setTimeout(() => { @@ -205,16 +162,6 @@ export default class ChatMessage extends React.Component { } componentDidUpdate = (prevProps, prevState) => { - if ( - prevProps.messageContainerHeight !== this.props.messageContainerHeight || - prevProps.messageContainerWidth !== this.props.messageContainerWidth || - this.state.displayType !== prevState.displayType - ) { - this.setState({ - chartHeight: this.getChartHeight(this.state.displayType), - chartWidth: this.getChartWidth(), - }) - } ReactTooltip.hide() } @@ -224,14 +171,9 @@ export default class ChatMessage extends React.Component { clearTimeout(this.animationTimeout) } - onCSVExportFinish = (response, isPivotTable) => { - let CSVFileSizeMb = _get(response, 'headers.content-length') / 1000000 - const CSVtotal_rows = _get(response, 'headers.total_rows') - const CSVreturned_rows = _get(response, 'headers.returned_rows') - let CSVexportLimit = _get(response, 'headers.export_limit') - if (!isPivotTable && CSVFileSizeMb && CSVexportLimit) { - CSVFileSizeMb = parseInt(CSVFileSizeMb) - CSVexportLimit = parseInt(CSVexportLimit) + onCSVDownloadFinish = ({ error, exportLimit, totalRows, returnedRows }) => { + if (error) { + return this.props.addMessageToDM({ response: error }) } this.props.addMessageToDM({ @@ -242,46 +184,14 @@ export default class ChatMessage extends React.Component { {this.props.queryText} . - {!isPivotTable && CSVFileSizeMb >= CSVexportLimit ? ( + {totalRows && returnedRows && totalRows > returnedRows ? ( <>

- WARNING: The file you’ve requested is larger than{' '} - {CSVexportLimit}. This exceeds the maximum download size and you - will only receive partial data. -

- - ) : null} - - ), - }) - } - - onCSVExportFinish = (response, isPivotTable) => { - let CSVFileSizeMb = _get(response, 'headers.content-length') / 1000000 - const CSVtotal_rows = _get(response, 'headers.total_rows') - const CSVreturned_rows = _get(response, 'headers.returned_rows') - let CSVexportLimit = _get(response, 'headers.export_limit') - if (!isPivotTable && CSVFileSizeMb && CSVexportLimit) { - CSVFileSizeMb = parseInt(CSVFileSizeMb) - CSVexportLimit = parseInt(CSVexportLimit) - } - - this.props.addMessageToDM({ - content: ( - <> - Your file has successfully been downloaded with the query{' '} - - {this.props.queryText} - - . - {!isPivotTable && CSVFileSizeMb >= CSVexportLimit ? ( - <> -
-

- WARNING: The file you’ve requested is larger than{' '} - {CSVexportLimit}. This exceeds the maximum download size and you - will only receive partial data. +
+ WARNING: The file you’ve requested is larger than {exportLimit} + MB. This exceeds the maximum download size and you will only + receive partial data.

) : null} @@ -335,7 +245,14 @@ export default class ChatMessage extends React.Component { } renderCSVProgressMessage = () => { - return `Fetching your file ... ${this.state.csvDownloadProgress || 0}%` + if (isNaN(this.state.csvDownloadProgress)) { + return ( +
+ Fetching your file +
+ ) + } + return `Downloading your file ... ${this.state.csvDownloadProgress}%` } renderContent = () => { @@ -355,7 +272,7 @@ export default class ChatMessage extends React.Component { ref={(ref) => (this.responseRef = ref)} authentication={getAuthentication(this.props.authentication)} autoQLConfig={getAutoQLConfig(this.props.autoQLConfig)} - onDataClick={this.props.processDrilldown} + onDataClick={this.props.onDataClick} queryResponse={this.props.response} displayType={this.state.displayType} onSuggestionClick={this.props.onSuggestionClick} @@ -366,16 +283,8 @@ export default class ChatMessage extends React.Component { dataFormatting={getDataFormatting(this.props.dataFormatting)} appliedFilters={this.props.appliedFilters} onUpdate={this.props.onQueryOutputUpdate} - height={ - isChartType(this.state.displayType) - ? this.state.chartHeight - : undefined - } - width={ - isChartType(this.state.displayType) - ? this.state.chartWidth - : undefined - } + onDrilldownStart={this.props.onDrilldownStart} + onDrilldownEnd={this.props.onDrilldownEnd} demo={getAuthentication(this.props.authentication).demo} onSupportedDisplayTypesChange={this.onSupportedDisplayTypesChange} backgroundColor={document.documentElement.style.getPropertyValue( @@ -391,8 +300,8 @@ export default class ChatMessage extends React.Component { } isAnimatingContainer={this.state.isAnimatingMessageBubble} enableDynamicCharting={this.props.enableDynamicCharting} - dataConfig={this.state.dataConfig} - onDataConfigChange={this.updateDataConfig} + tableConfig={this.state.dataConfig} + onTableConfigChange={this.updateDataConfig} optionsToolbarRef={this.optionsToolbarRef} onNoneOfTheseClick={this.props.onNoneOfTheseClick} autoChartAggregations={this.props.autoChartAggregations} @@ -419,13 +328,12 @@ export default class ChatMessage extends React.Component { } } - onCSVExportClick = (queryId, query) => { + onCSVDownloadStart = ({ id, queryId, query }) => { this.props.addMessageToDM({ - content: `Fetching your file ... 0%`, + id, query, - isCSVProgressMessage: true, queryId, - linkedQueryResponseRef: this.responseRef, + isCSVProgressMessage: true, }) } @@ -439,12 +347,14 @@ export default class ChatMessage extends React.Component { (this.optionsToolbarRef = r)} className={`chat-message-toolbar right`} - authentication={getAuthentication(this.props.authentication)} + authentication={this.props.authentication} autoQLConfig={getAutoQLConfig(this.props.autoQLConfig)} themeConfig={getThemeConfig(this.props.themeConfig)} responseRef={this.responseRef} displayType={this.state.displayType} - onCSVExportClick={this.onCSVExportClick} + onCSVDownloadStart={this.onCSVDownloadStart} + onCSVDownloadFinish={this.onCSVDownloadFinish} + onCSVDownloadProgress={this.props.onCSVDownloadProgress} onSuccessAlert={this.props.onSuccessAlert} onErrorCallback={this.props.onErrorCallback} enableDeleteBtn={!this.props.isIntroMessage} @@ -496,40 +406,6 @@ export default class ChatMessage extends React.Component { return null } - // TODO(Nikki): handle this in chatachart not here - getChartWidth = () => { - return this.props.messageContainerWidth - 70 - } - - // TODO(Nikki): handle this in chatachart not here - getChartHeight = (displayType) => { - if (displayType === 'pie') { - return this.PIE_CHART_HEIGHT - } - - return 0.85 * this.props.messageContainerHeight - 40 // 85% of chat height minus message margins - } - - renderDataLimitWarning = () => { - const numRows = _get(this.props, 'response.data.data.rows.length') - const maxRowLimit = _get(this.props, 'response.data.data.row_limit') - - if ( - maxRowLimit && - numRows === maxRowLimit && - !areAllColumnsHidden(this.props.response) - ) { - return ( - - ) - } - } - render = () => { return ( @@ -554,7 +430,6 @@ export default class ChatMessage extends React.Component { {this.renderRightToolbar()} {this.renderLeftToolbar()} - {this.renderDataLimitWarning()} )} diff --git a/src/components/ChatMessage/ChatMessage.scss b/src/components/ChatMessage/ChatMessage.scss index df473278d..ec34a1e5c 100644 --- a/src/components/ChatMessage/ChatMessage.scss +++ b/src/components/ChatMessage/ChatMessage.scss @@ -122,13 +122,6 @@ padding: 5px 10px; outline: none !important; } -.data-limit-warning-icon { - color: var(--react-autoql-warning-color) !important; - position: absolute !important; - bottom: 2px; - right: 4px; - font-size: 20px; -} .condition-info-icon-left-align { color: var(--react-autoql-accent-color) !important; position: absolute !important; diff --git a/src/components/ChataTable/ChataTable.js b/src/components/ChataTable/ChataTable.js index 7965f8cc1..a06e58c45 100644 --- a/src/components/ChataTable/ChataTable.js +++ b/src/components/ChataTable/ChataTable.js @@ -6,7 +6,8 @@ import _isEqual from 'lodash.isequal' import { ReactTabulator } from 'react-tabulator' import ErrorBoundary from '../../containers/ErrorHOC/ErrorHOC' -import { setCSSVars, isAggregation } from '../../js/Util' +import { setCSSVars } from '../../js/Util' +import { isAggregation } from '../QueryOutput/columnHelpers' import { themeConfigType } from '../../props/types' import { themeConfigDefault, getThemeConfig } from '../../props/defaults' diff --git a/src/components/ChataTable/ChataTable.scss b/src/components/ChataTable/ChataTable.scss index 9abd699d3..2dba65eb8 100644 --- a/src/components/ChataTable/ChataTable.scss +++ b/src/components/ChataTable/ChataTable.scss @@ -1,6 +1,5 @@ .react-autoql-table-container { - flex-grow: 1; - flex-shrink: 1; + flex: 1; max-width: 100%; width: 100%; background-color: inherit; @@ -23,7 +22,6 @@ .react-autoql-table.tabulator .tabulator-row.tabulator-unselectable:hover { cursor: pointer !important; - background-color: var(--react-autoql-hover-color) !important; } .react-autoql-table.tabulator .tabulator-header diff --git a/src/components/Checkbox/Checkbox.js b/src/components/Checkbox/Checkbox.js index fe4e9257f..6e8ec77f5 100644 --- a/src/components/Checkbox/Checkbox.js +++ b/src/components/Checkbox/Checkbox.js @@ -6,17 +6,12 @@ import _isEqual from 'lodash.isequal' import { Icon } from '../Icon' import ErrorBoundary from '../../containers/ErrorHOC/ErrorHOC' -import { setCSSVars } from '../../js/Util' -import { themeConfigType } from '../../props/types' -import { themeConfigDefault, getThemeConfig } from '../../props/defaults' - import './Checkbox.scss' export default class Checkbox extends React.Component { ID = uuid() static propTypes = { - themeConfig: themeConfigType, hasError: PropTypes.bool, indeterminate: PropTypes.bool, label: PropTypes.string, @@ -26,7 +21,6 @@ export default class Checkbox extends React.Component { } static defaultProps = { - themeConfig: themeConfigDefault, hasError: false, indeterminate: undefined, type: 'default', @@ -40,23 +34,12 @@ export default class Checkbox extends React.Component { if (this.selector) { this.selector.indeterminate = this.props.indeterminate } - - setCSSVars(getThemeConfig(this.props.themeConfig)) } componentDidUpdate = (prevProps) => { if (prevProps.indeterminate !== this.props.indeterminate) { this.selector.indeterminate = this.props.indeterminate } - - if ( - !_isEqual( - getThemeConfig(this.props.themeConfig), - getThemeConfig(prevProps.themeConfig) - ) - ) { - setCSSVars(getThemeConfig(this.props.themeConfig)) - } } onCheckedChange = (e) => { @@ -70,7 +53,6 @@ export default class Checkbox extends React.Component { indeterminate, hasError, style, - themeConfig, onChange, checked, ...nativeProps @@ -96,13 +78,11 @@ export default class Checkbox extends React.Component { return (
-
+
{}, - isOpen: false, - authentication: undefined, - initFilterText: undefined, - themeConfig: themeConfigDefault, - conditions: [], - onConditionChangeCallback: () => {}, - } - - componentDidMount = () => { - if (this.props.initFilterText) { - for (let i = 0; i < this.props.conditions.length; i++) { - if (this.props.conditions[i].keyword === this.props.initFilterText) { - this.handleHighlightFilterRow(i) - return - } - } - this.animateInputTextAndSubmit(this.props.initFilterText) - } - } - - componentDidUpdate = (prevProps, predState) => { - if ( - !_isEqual( - getThemeConfig(this.props.themeConfig), - getThemeConfig(prevProps.themeConfig) - ) - ) { - setCSSVars(getThemeConfig(this.props.themeConfig)) - } - } - - componentWillUnmount = () => { - removeFromDOM(this.el1) - removeFromDOM(this.el2) - removeFromDOM(this.el3) - removeFromDOM(this.el4) - removeFromDOM(this.el5) - removeFromDOM(this.el6) - } - - handleFetchFilteredList() { - fetchConditions(getAuthentication(this.props.authentication)).then( - (response) => { - let conditions = _get(response, 'data.data.data') - let array = [] - for (let i = 0; i < conditions.length; i++) { - array.push({ - id: conditions[i].id, - keyword: conditions[i].value, - value: conditions[i].value, - show_message: conditions[i].show_message, - key: conditions[i].key, - lock_flag: conditions[i].lock_flag, - }) - } - if (JSON.parse(sessionStorage.getItem('conditions')) !== null) { - var sessionConditions = JSON.parse( - sessionStorage.getItem('conditions') - ) - for (let i = 0; i < sessionConditions.length; i++) { - array.push({ - id: sessionConditions[i].id, - keyword: sessionConditions[i].value, - value: sessionConditions[i].value, - show_message: sessionConditions[i].show_message, - key: sessionConditions[i].key, - lock_flag: sessionConditions[i].lock_flag, - }) - } - } - - array.sort((a, b) => { - return a.keyword.toUpperCase() < b.keyword.toUpperCase() - ? -1 - : a.keyword > b.keyword - ? 1 - : 0 - }) - - this.props.onConditionChangeCallback(array) - this.setState({ - inputValue: '', - isFetchingConditions: false, - }) - } - ) - } - - /** - * When suggestion is clicked, Autosuggest populates the input - * based on the clicked suggestion. Teach Autosuggest how to calculate the - * input value for every given suggestion. - * @param {*} suggestion - */ - getSuggestionValue = (suggestion) => { - let array = [...this.props.conditions] - - if ( - array.some( - (item) => - item.key === suggestion.name.canonical && - item.value === suggestion.name.keyword - ) - ) { - this.handleShowMessage( - 'warning', - 'This condition has already been applied.' - ) - } else { - array.push({ - keyword: suggestion.name.keyword, - value: suggestion.name.keyword, - show_message: suggestion.name.show_message, - key: suggestion.name.canonical, - lock_flag: 1, // persist by default - }) - this.setState({ inputValue: '' }) - setConditions({ - ...getAuthentication(this.props.authentication), - conditions: array, - }).then(() => { - this.handleShowMessage( - 'lock', - `${suggestion.name.keyword} has been locked` - ) - this.handleFetchFilteredList() - }) - } - } - - onInputChange = (e) => { - if (e.keyCode === 38 || e.keyCode === 40) { - // keyup or keydown - return // return to let the component handle it... - } - - if (e && e.target && (e.target.value || e.target.value === '')) { - this.setState({ inputValue: e.target.value }) - } - } - - /** - * Removes condition from the list. - * @param {*} item - */ - removeCondition = (item, index) => { - const sessionConditions = JSON.parse(sessionStorage.getItem('conditions')) - let sessionIndex - if (sessionConditions) { - sessionIndex = sessionConditions.findIndex( - (condition) => _get(condition, 'key') === _get(item, 'key') - ) - } - - if ( - sessionIndex !== -1 && - sessionIndex !== undefined && - sessionIndex !== null - ) { - sessionConditions.splice(sessionIndex, 1) - sessionStorage.setItem('conditions', JSON.stringify(sessionConditions)) - } else { - unsetCondition({ - ...getAuthentication(this.props.authentication), - condition: item, - }) - } - - const array = [...this.props.conditions] - array.splice(index, 1) - this.props.onConditionChangeCallback(array) - this.handleShowMessage('unlock', 'Filter removed.') - ReactTooltip.hide() - } - - handlePersistConditionToggle = (item) => { - var index = this.props.conditions.findIndex( - (condition) => condition.id === item.id - ) - var sessionConditions = JSON.parse(sessionStorage.getItem('conditions')) - - if (index === -1) { - // handle error - } else { - const array = [ - ...this.props.conditions.slice(0, index), - Object.assign( - {}, - this.props.conditions[index], - item.lock_flag === 1 - ? (this.props.conditions[index].lock_flag = 0) - : (this.props.conditions[index].lock_flag = 1) - ), - ...this.props.conditions.slice(index + 1), - ] - this.props.onConditionChangeCallback(array) - setConditions({ - ...getAuthentication(this.props.authentication), - conditions: array, - }) - if (item.lock_flag === 0) { - if (sessionConditions == null) sessionConditions = [] - sessionConditions.push(item) - sessionStorage.setItem('conditions', JSON.stringify(sessionConditions)) - } else { - var sessionIndex = sessionConditions.findIndex( - (condition) => condition.id === item.id - ) - sessionConditions.splice(sessionIndex, 1) - sessionStorage.setItem('conditions', JSON.stringify(sessionConditions)) - } - } - } - - onSuggestionsFetchRequested = ({ value }) => { - if (this.autoCompleteTimer) { - clearTimeout(this.autoCompleteTimer) - } - this.autoCompleteTimer = setTimeout(() => { - fetchValueLabelAutocomplete({ - suggestion: value, - ...getAuthentication(this.props.authentication), - }) - .then((response) => { - const body = _get(response, 'data.data') - const sortingArray = [] - let suggestionsMatchArray = [] - autoCompleteArray = [] - suggestionsMatchArray = body.matches - for (let i = 0; i < suggestionsMatchArray.length; i++) { - sortingArray.push(suggestionsMatchArray[i]) - } - - sortingArray.sort((a, b) => { - return a.keyword.toUpperCase() < b.keyword.toUpperCase() - ? -1 - : a.keyword > b.keyword - ? 1 - : 0 - }) - for (let idx = 0; idx < sortingArray.length; idx++) { - const anObject = { - name: sortingArray[idx], - } - autoCompleteArray.push(anObject) - } - this.setState({ - suggestions: autoCompleteArray, - }) - }) - .catch((error) => { - console.error(error) - }) - }, 300) - } - - onSuggestionsClearRequested = () => { - this.setState({ - suggestions: [], - }) - } - - handleShowMessage(type, message) { - this.el6 = document.getElementById('react-autoql-condition-show-message') - this.el6.className = 'show' - this.el6.style.animation = 'none' - setTimeout(() => { - this.el6.style.animation = '' - }, 10) - setTimeout(() => { - this.el6.className = this.el6.className.replace('show', '') - }, 3000) - this.setState({ - inputValue: '', - showMessage: { - type: type, - message: message, - }, - }) - } - - handleHighlightFilterRow(index) { - this.el1 = document.getElementById( - `react-autoql-condition-table-list-item-${index}` - ) - if (this.el1) { - this.el1.className = 'react-autoql-highlight-row' - setTimeout(() => { - this.el1.className = this.el1.className.replace( - 'react-autoql-highlight-row', - '' - ) - }, 1800) - } - } - - onEnterFilterHeaderInfo = () => { - this.el2 = document.getElementById('react-autoql-filter-description-id') - this.timer = setTimeout(() => { - this.el2.className = 'show' - }, 500) - } - - onLeaveFilterHeaderInfo = () => { - this.el3 = document.getElementById('react-autoql-filter-description-id') - this.el3.className = this.el3.className.replace('show', '') - clearTimeout(this.timer) - } - - onEnterFilterSettingInfo = () => { - this.el4 = document.getElementById('react-autoql-filter-setting-info-card') - this.timer = setTimeout(() => { - this.el4.className = 'show' - }, 500) - } - - onLeaveFilterSettingInfo = () => { - this.el5 = document.getElementById('react-autoql-filter-setting-info-card') - this.el5.className = this.el5.className.replace('show', '') - clearTimeout(this.timer) - } - - animateInputTextAndSubmit = (text) => { - if (typeof text === 'string' && _get(text, 'length')) { - for (let i = 1; i <= text.length; i++) { - setTimeout(() => { - this.setState({ - inputValue: text.slice(0, i), - }) - if (i === text.length) { - setTimeout(() => { - this.inputElement = document.querySelector( - '#react-autoql-filter-menu-input' - ) - if (this.inputElement) { - this.inputElement.focus() - } - }, 300) - } - }, i * 50) - } - } - } - - renderShowMessage = () => ( -
- {' '} - {this.state.showMessage.message} -
- ) - - render = () => { - const { containerWidth } = this.props - - return ( - -
- {this.renderShowMessage()} -
{ - e.stopPropagation() - }} - > -
-

- {lang.filterLockingTitle}{' '} - -

- -
-
-
- -

- Filters can be applied to narrow down your query results. - Locking a filter ensures that only the specific data you wish - to see is returned. -

-
- { - this.autoSuggest = ref - }} - id="react-autoql-filter-menu-input" - highlightFirstSuggestion - suggestions={this.state.suggestions} - onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} - onSuggestionsClearRequested={this.onSuggestionsClearRequested} - getSuggestionValue={this.getSuggestionValue} - renderSuggestion={(suggestion) => ( - - - - - {suggestion.name.keyword} - - - {suggestion.name.show_message} - - - - - )} - inputProps={{ - onChange: this.onInputChange, - value: this.state.inputValue, - disabled: this.state.isFetchingConditions, - placeholder: 'Search & select a filter', - className: 'react-autoql-condition-locking-input', - id: 'react-autoql-filter-menu-input', - }} - /> -
-

- Persistent filters - remain locked at all times, unless the filter is removed. -
- Session filters remain - locked until you end your browser session. -

-
-
-
- {this.state.isFetchingConditions ? ( -
- -
- ) : ( -
- {_get(this.props.conditions, 'length') === 0 ? ( -
-

- {lang.noFiltersLocked} -

-
- ) : ( -
-
- - - - - - - - - - {this.props.conditions.map((item, index) => { - return ( - - - - - - ) - })} - - -
Filter - Settings - -
- {item.keyword} {`(${item.show_message})`} - - - - this.handlePersistConditionToggle( - item, - index - ) - } - checked={item.lock_flag} - onColor={accentColorAssist( - getThemeConfig(this.props.themeConfig) - .accentColor, - 180 - )} - onHandleColor={ - getThemeConfig( - getThemeConfig(this.props.themeConfig) - ).accentColor - } - uncheckedIcon={false} - checkedIcon={false} - boxShadow="0px 1px 5px rgba(0, 0, 0, 0.6)" - activeBoxShadow="0px 0px 1px 1px rgba(0, 0, 0, 0.2)" - handleDiameter={16} - height={18} - width={34} - />{' '} - {item.lock_flag ? 'Persistent' : 'Session'} - - - - - this.removeCondition(item, index) - } - /> -
-
-
- )} -
- )} -
- -
-
-
- ) - } -} diff --git a/src/components/ConditionLockMenu/ConditionLockMenu.scss b/src/components/ConditionLockMenu/ConditionLockMenu.scss deleted file mode 100644 index b94448929..000000000 --- a/src/components/ConditionLockMenu/ConditionLockMenu.scss +++ /dev/null @@ -1,437 +0,0 @@ -.condition-menu-dropdown { - display: block; -} - -.react-autoql-condition-lock-menu { - min-width: 400px !important; -} - -.react-autoql-filter-locking-title-container { - display: inline-block; - width: 100%; - padding-top: 4px; - padding-left: 6px; - h3 { - width: 95%; - float: left; - color: var(--react-autoql-text-color-primary); - margin-bottom: 0; - margin-top: 0; - display: block; - font-size: 1.17em !important; - font-weight: bold; - - span { - color: var(--react-autoql-accent-color); - } - } - button { - width: 5%; - float: right; - :hover { - cursor: pointer; - color: var(--react-autoql-hover-color); - } - } -} - -#react-autoql-filter-description-id { - visibility: hidden; - color: var(--react-autoql-text-color-primary); - background-color: var(--react-autoql-background-color-secondary); - border-radius: 5px; - padding: 2px 6px 2px 6px; - margin: 4px 10px 4px 10px; - display: inline-block; - position: absolute; - top: 20px; - box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.4); - z-index: 999999; - - span { - color: var(--react-autoql-accent-color); - } - p { - width: 96%; - float: right; - margin-bottom: 0; - } -} - -#react-autoql-filter-description-id.show { - visibility: visible; - animation: fadeIn 1s; -} - -.react-autoql-condition-locking-input { - padding: 5px; - padding-left: 20px; - margin: 8px; - height: 32px; - box-sizing: border-box; - border-radius: 24px; - font-size: 12px; - font-family: inherit; - letter-spacing: 0.04em; - outline: none !important; - width: calc(100% - 20px); - font-family: inherit; - - /* Default styles outside of data messenger */ - border: 1px solid var(--react-autoql-border-color, rgba(0, 0, 0, 0.1)); - background: var(--react-autoql-background-color-primary); - color: var(--react-autoql-text-color-primary); - - &::placeholder { - color: rgba(var(--react-autoql-text-color-placeholder), 2) !important; - } -} - -.react-autoql-condition-locking-input:hover { - box-shadow: 0px 0px 5px 1px rgba(0, 0, 0, 0.1); -} - -.react-autoql-condition-table { - min-width: 95%; - margin: 10px auto; - table-layout: fixed; -} - -.react-autoql-condition-table thead { - padding-left: 10px; - display: table-header-group; - width: 100%; - flex: 1 1 auto; -} - -.react-autoql-condition-table .react-autoql-condition-table-list-item { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - width: 60%; -} - -.react-autoql-condition-lock-menu-footer { - bottom: 0; - right: 0; - - button { - float: right; - margin: 6px !important; - } -} - -.react-autoql-condition-lock-menu-footer { - bottom: 0; - right: 0; - - button { - float: right; - margin: 6px !important; - } -} - -.react-autoql-accept-conditions-button { - text-align: right; - margin-bottom: 12px; - padding-right: 10px; - color: var(--react-autoql-text-color-primary, rgba(0, 0, 0, 0.4)); - transition: color 0.1s ease; - - span { - opacity: 0.8; - cursor: pointer; - &:hover { - opacity: 1; - } - } -} - -.react-autoql-condition-list { - padding: 0; - margin: 0 auto; - min-height: 182px; -} - -tbody::-webkit-scrollbar { - -webkit-appearance: none; - width: 7px; -} - -tbody::-webkit-scrollbar-thumb { - border-radius: 5px; - background-color: rgba(0, 0, 0, 0.5); - box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); -} - -.react-autoql-condition-list .react-autoql-condition-table thead { - text-align: left; - width: 100%; - margin: 0 10px 0 10px; - font-weight: 800; - - span { - color: var(--react-autoql-accent-color); - padding: 5px; - } -} -.react-autoql-condition-list .react-autoql-condition-table tbody { - display: block; - overflow-y: scroll; - max-height: 260px; - width: 100%; - flex: 1 1 auto; - padding-left: 10px; -} - -.react-autoql-condition-list .react-autoql-condition-table tr { - display: table; - table-layout: fixed; - width: 100%; -} - -.react-autoql-condition-list .react-autoql-condition-table th { - display: table-cell; - padding-left: 10px; -} - -.react-autoql-condition-list td { - display: table-cell; - padding: 2px; - padding-left: 10px; - vertical-align: middle; -} -#react-autoql-remove-filter-container { - width: 35px; -} -#react-autoql-condition-table-settings { - width: 110px; -} - -.react-autoql-condition-list-loading-container { - display: flex; - height: 180px; - width: 100%; - padding: 20px; - align-items: center; - justify-content: center; -} - -.react-autoql-empty-condition-list { - text-align: center; - padding: 20px; - height: 140px; - p { - margin-top: 20px !important; - vertical-align: middle; - } -} - -.autoql-close-button { - background-color: inherit; - border: none; - text-align: left; - font-size: 20px; - margin-top: -20px; - float: none !important; - color: inherit; -} - -.react-tiny-popover-container { - background: var(--react-autoql-background-color-primary); -} - -.autoql-condition-locking-menu-container .react-autosuggest__container { - flex-grow: 1; - display: inline; - width: 90%; - height: 100%; -} - -.autoql-condition-locking-menu-container - .react-autosuggest__suggestions-container--open { - background-color: var(--react-autoql-background-color-primary); - border: 1px solid var(--react-autoql-border-color); - color: var(--react-autoql-text-color-primary); - position: absolute; - top: unset; - bottom: unset; - overflow-y: scroll !important; - padding-top: 5px; - padding-bottom: 5px; - display: block; - font-family: inherit; - font-size: 15px; - font-weight: normal; - z-index: 2; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.13); - text-align: left; - border-radius: 4px; - margin: -8px auto; - height: auto; - max-height: 80vh; - overflow-y: scroll !important; - transform: translateX(4%); - width: 92% !important; -} - -.autoql-condition-locking-menu-list { - table-layout: fixed; - width: 100%; -} - -.autoql-condition-locking-menu-container .react-autosuggest__suggestion { - color: var(--react-autoql-text-color-primary); -} - -.autoql-condition-locking-menu-container input::placeholder { - opacity: 0.5; -} - -.autoql-condition-locking-menu-container .react-autosuggest__suggestions-list { - margin: 0; - padding: 0; - list-style-type: none; - max-height: 200px; - overflow-y: auto; -} - -.autoql-condition-locking-menu-container .react-autosuggest__suggestion { - cursor: pointer; - padding: 2px; - padding-left: 18px; - letter-spacing: 0.05em; - line-height: 22.5px; -} - -.autoql-condition-locking-menu-container - .react-autosuggest__suggestion--highlighted { - background-color: rgba(0, 0, 0, 0.1) !important; -} - -.autoql-condition-locking-menu-container .react-autosuggest__section-title { - padding: 10px 0 0 10px; - font-size: 12px; - color: #777; -} - -#react-autoql-filter-setting-info-card { - visibility: hidden; - color: var(--react-autoql-text-color-primary); - background-color: var(--react-autoql-background-color-secondary); - border-radius: 5px; - padding: 2px 6px 2px 6px; - margin: 4px 10px 4px 10px; - display: inline-block; - position: absolute; - top: 40px; - box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.4); - z-index: 9999; - - span { - color: var(--react-autoql-accent-color); - } - - p { - margin-bottom: 0 !important; - span { - color: var(--react-autoql-accent-color); - } - } -} - -.react-autoql-highlight-row { - background-color: var(--react-autoql-accent-color); - color: white; - animation-name: highlightIn, highlightOut; - animation-duration: 400ms, 400ms; - animation-delay: 0ms, 1500ms; - animation-timing-function: ease-in, ease-out; - animation-iteration-count: 1, 1; -} - -#react-autoql-filter-setting-info-card.show { - visibility: visible; - animation: fadeIn 1s; -} - -#react-autoql-condition-show-message { - visibility: hidden; - min-width: 200px; - line-height: 1 !important; - font-size: 14px !important; - background-color: var(--react-autoql-background-color-secondary); - text-align: center; - border-radius: 4px; - padding: 6px; - position: fixed; - top: 20px; - display: block; - z-index: 999999999; - left: 50%; - transform: translate(-50%, -50%); - box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.4); - - span { - color: var(--react-autoql-accent-color); - } -} - -#react-autoql-condition-show-message.show { - visibility: visible; - animation: snackbarFadein 1s, snackbarFadeout 1s 2s; -} - -@keyframes snackbarFadein { - from { - top: 0; - opacity: 0; - } - to { - top: 20px; - opacity: 1; - } -} - -@keyframes snackbarFadeout { - from { - top: 20px; - opacity: 1; - } - to { - top: 0; - opacity: 0; - } -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translate3d(0, -20%, 0); - } - to { - opacity: 1; - transform: translate3d(0, 0, 0); - } -} - -@keyframes highlightOut { - 100% { - background-color: var(--react-autoql-background-color-primary); - color: var(--react-autoql-text-color-primary); - } - 0% { - background-color: var(--react-autoql-accent-color); - color: white; - } -} -@keyframes highlightIn { - 100% { - background-color: var(--react-autoql-accent-color); - color: white; - } - 0% { - background-color: var(--react-autoql-background-color-primary); - color: var(--react-autoql-text-color-primary); - } -} diff --git a/src/components/ConditionLockMenu/ConditionLockMenu.test.js b/src/components/ConditionLockMenu/ConditionLockMenu.test.js deleted file mode 100644 index 8a9a9e686..000000000 --- a/src/components/ConditionLockMenu/ConditionLockMenu.test.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react' -import { shallow, mount } from 'enzyme' - -import { checkProps } from '../../../test/testUtils' -import { ConditionLockMenu } from './' - -const defaultProps = { - containerWidth: 500, - onClose: () => {}, - isOpen: false, - initFilterText: undefined, - authentication: { - apiKey: 'test-apikey', - }, -} - -const setup = (props = {}, state = null) => { - const setupProps = { ...defaultProps, ...props } - const wrapper = shallow() - if (state) { - wrapper.setState(state) - } - return wrapper -} - -describe('renders correctly', () => { - test('renders correctly with only token prop', () => { - const wrapper = shallow( - - ) - expect(wrapper.exists()).toBe(true) - }) - test('renders correctly with default props', () => { - const wrapper = setup() - expect(wrapper.exists()).toBe(true) - }) -}) - -describe('props', () => { - test('does not throw warning with expected props', () => { - checkProps(ConditionLockMenu, defaultProps) - }) - - describe('containerWidth', () => { - test('containerWidth is applied', () => { - const html = mount() - const menu = html.find('#react-autoql-filter-menu') - expect(menu.exists()).toBe(false) - }) - test('renders correctly with containerWidth', () => { - const wrapper = setup({ containerWidth: '500px' }) - expect(wrapper.instance().props.containerWidth).toBe('500px') - }) - test('renders correctly without containerWidth', () => { - const wrapper = setup({ containerWidth: undefined }) - expect(wrapper.exists()).toBe(true) - }) - }) - - describe('isOpen', () => { - test('renders correctly with isOpen false', () => { - const wrapper = setup({ isOpen: false }) - expect(wrapper.instance().props.isOpen).toBe(false) - }) - test('renders correctly with isOpen true', () => { - const wrapper = setup({ isOpen: true }) - expect(wrapper.instance().props.isOpen).toBe(true) - }) - }) - - describe('initFilterText', () => { - test('renders correctly with initFilterText', () => { - const wrapper = setup({ isOpen: true, initFilterText: 'asdsdfsd' }) - expect(wrapper.instance().props.initFilterText).toEqual('asdsdfsd') - }) - test('renders correctly with initFilterText undefined', () => { - const wrapper = setup({ isOpen: true }) - expect(wrapper.instance().props.initFilterText).toEqual(undefined) - }) - test('does not render with initFilterText 300', () => { - const wrapper = setup({ isOpen: true, initFilterText: 300 }) - expect(wrapper.instance().props.initFilterText).toEqual(300) - }) - }) -}) diff --git a/src/components/ConditionLockMenu/helpers.js b/src/components/ConditionLockMenu/helpers.js deleted file mode 100644 index d4cad217d..000000000 --- a/src/components/ConditionLockMenu/helpers.js +++ /dev/null @@ -1,25 +0,0 @@ -export function accentColorAssist(col,amt) { - var usePound = false; - if ( col[0] == "#" ) { - col = col.slice(1); - usePound = true; - } - - var num = parseInt(col,16); - var r = (num >> 16) + amt; - - if ( r > 255 ) r = 255; - else if (r < 0) r = 0; - - var b = ((num >> 8) & 0x00FF) + amt; - - if ( b > 255 ) b = 255; - else if (b < 0) b = 0; - - var g = (num & 0x0000FF) + amt; - - if ( g > 255 ) g = 255; - else if ( g < 0 ) g = 0; - - return (usePound?"#":"") + (g | (b << 8) | (r << 16)).toString(16); -} \ No newline at end of file diff --git a/src/components/ConditionLockMenu/index.js b/src/components/ConditionLockMenu/index.js deleted file mode 100644 index f8504bb8d..000000000 --- a/src/components/ConditionLockMenu/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as ConditionLockMenu } from './ConditionLockMenu' diff --git a/src/components/Dashboard/Dashboard.js b/src/components/Dashboard/Dashboard.js index cc6d6b75d..0403801b5 100644 --- a/src/components/Dashboard/Dashboard.js +++ b/src/components/Dashboard/Dashboard.js @@ -16,7 +16,7 @@ import { runDrilldown } from '../../js/queryService' import { LoadingDots } from '../LoadingDots' import ErrorBoundary from '../../containers/ErrorHOC/ErrorHOC' import { CHART_TYPES } from '../../js/Constants' -import { setCSSVars, filterDataForDrilldown } from '../../js/Util' +import { setCSSVars } from '../../js/Util' import { authenticationType, autoQLConfigType, @@ -81,6 +81,9 @@ class Dashboard extends React.Component { onErrorCallback: PropTypes.func, onSuccessCallback: PropTypes.func, autoChartAggregations: PropTypes.bool, + onCSVDownloadStart: PropTypes.func, + onCSVDownloadProgress: PropTypes.func, + onCSVDownloadFinish: PropTypes.func, } static defaultProps = { @@ -101,6 +104,9 @@ class Dashboard extends React.Component { onErrorCallback: () => {}, onSuccessCallback: () => {}, onChange: () => {}, + onCSVDownloadStart: () => {}, + onCSVDownloadProgress: () => {}, + onCSVDownloadFinish: () => {}, } state = { @@ -418,68 +424,43 @@ class Dashboard extends React.Component { }) } - runFilterDrilldown = (data, tileId, isSecondHalf) => { - try { - const tile = this.props.tiles.find((tile) => tile.i === tileId) - if (!tile) { - return - } - - const queryResponse = isSecondHalf - ? tile.secondQueryResponse - : tile.queryResponse - - const drilldownResponse = filterDataForDrilldown(queryResponse, data) - - this.drillingDownTimeout = setTimeout(() => { - this.setState({ - isDrilldownRunning: false, - activeDrilldownResponse: drilldownResponse, - }) - }, 1500) - } catch (error) { - console.error(error) - this.props.onErrorCallback(error) - } - } - - startDrilldown = (drilldownData, queryID, tileId, isSecondHalf) => { - this.setState({ isDrilldownRunning: true, isDrilldownChartHidden: false }) - - if (drilldownData.supportedByAPI) { - this.runDrilldownFromAPI(drilldownData.data, queryID, isSecondHalf) - } else { - this.runFilterDrilldown(drilldownData.data, tileId, isSecondHalf) - } - } - - processDrilldown = ({ - tileId, - drilldownData, - queryID, - activeKey, - isSecondHalf, - }) => { + onDrilldownStart = ({ tileId, activeKey, isSecondHalf }) => { if (getAutoQLConfig(this.props.autoQLConfig).enableDrilldowns) { - if (!drilldownData || !drilldownData.data) { - return - } this.setState({ + isDrilldownRunning: true, + isDrilldownChartHidden: false, isDrilldownModalVisible: true, isDrilldownSecondHalf: isSecondHalf, - activeDrilldownTile: tileId, + activeDrilldownTile: tileId || this.state.activeDrilldownTile, activeDrilldownResponse: null, activeDrilldownChartElementKey: activeKey, - isAnimatingModal: true, + isAnimatingModal: !this.state.isDrilldownModalVisible, }) - this.startDrilldown(drilldownData, queryID, tileId, isSecondHalf) - this.animationTimeout = setTimeout(() => { this.setState({ isAnimatingModal: false, }) - }, 200) + }, 500) + } + } + + onDrilldownEnd = ({ response, error }) => { + if (response) { + if (this._isMounted) { + this.setState({ + activeDrilldownResponse: response, + isDrilldownRunning: false, + }) + } + } else if (error) { + console.error(error) + if (this._isMounted) { + this.setState({ + isDrilldownRunning: false, + activeDrilldownResponse: undefined, + }) + } } } @@ -611,18 +592,22 @@ class Dashboard extends React.Component { dataFormatting={getDataFormatting( this.props.dataFormatting )} - queryResponse={queryResponse} + queryResponse={_cloneDeep(queryResponse)} displayType={displayType} - dataConfig={dataConfig} + tableConfig={_cloneDeep(dataConfig)} isDashboardQuery={true} onUpdate={this.rebuildTooltips} isAnimatingContainer={this.state.isAnimatingModal} autoChartAggregations={ this.props.autoChartAggregations } - onDataClick={(drilldownData, queryID) => { - this.startDrilldown(drilldownData, queryID, tile.i) - }} + onDrilldownStart={(activeKey) => + this.onDrilldownStart({ + tileId: tile.i, + activeKey, + }) + } + onDrilldownEnd={this.onDrilldownEnd} activeChartElementKey={ this.state.activeDrilldownChartElementKey } @@ -744,12 +729,17 @@ class Dashboard extends React.Component { deleteTile={this.deleteTile} dataFormatting={getDataFormatting(this.props.dataFormatting)} notExecutedText={this.props.notExecutedText} - processDrilldown={this.processDrilldown} + onDataClick={this.processDrilldown} enableDynamicCharting={this.props.enableDynamicCharting} onErrorCallback={this.props.onErrorCallback} onSuccessCallback={this.props.onSuccessCallback} autoChartAggregations={this.props.autoChartAggregations} onQueryOutputUpdate={this.rebuildTooltips} + onDrilldownStart={this.onDrilldownStart} + onDrilldownEnd={this.onDrilldownEnd} + onCSVDownloadStart={this.props.onCSVDownloadStart} + onCSVDownloadProgress={this.props.onCSVDownloadProgress} + onCSVDownloadFinish={this.props.onCSVDownloadFinish} /> ))} diff --git a/src/components/Dashboard/DashboardTile/DashboardTile.js b/src/components/Dashboard/DashboardTile/DashboardTile.js index 0c73850c0..b9af99fd6 100644 --- a/src/components/Dashboard/DashboardTile/DashboardTile.js +++ b/src/components/Dashboard/DashboardTile/DashboardTile.js @@ -51,9 +51,9 @@ class DashboardTile extends React.Component { this.autoCompleteTimer = undefined const supportedDisplayTypes = - getSupportedDisplayTypes(props.queryResponse) || [] + getSupportedDisplayTypes({ response: props.queryResponse }) || [] const secondSupportedDisplayTypes = - getSupportedDisplayTypes(props.secondQueryResponse) || [] + getSupportedDisplayTypes({ response: props.secondQueryResponse }) || [] this.state = { query: props.tile.query, @@ -84,6 +84,9 @@ class DashboardTile extends React.Component { onErrorCallback: PropTypes.func, onSuccessCallback: PropTypes.func, autoChartAggregations: PropTypes.bool, + onCSVDownloadStart: PropTypes.func, + onCSVDownloadProgress: PropTypes.func, + onCSVDownloadFinish: PropTypes.func, } static defaultProps = { @@ -102,6 +105,9 @@ class DashboardTile extends React.Component { autoChartAggregations: true, onErrorCallback: () => {}, onSuccessCallback: () => {}, + onCSVDownloadStart: () => {}, + onCSVDownloadProgress: () => {}, + onCSVDownloadFinish: () => {}, } componentDidMount = () => { @@ -130,9 +136,9 @@ class DashboardTile extends React.Component { if (_get(this.props, 'tile.query') !== _get(prevProps, 'tile.query')) { this.setState({ query: _get(this.props, 'tile.query') }, () => { this.setState({ - supportedDisplayTypes: getSupportedDisplayTypes( - _get(this.props, 'queryResponse') - ), + supportedDisplayTypes: getSupportedDisplayTypes({ + response: _get(this.props, 'queryResponse'), + }), }) }) } @@ -303,7 +309,6 @@ class DashboardTile extends React.Component { } }) .catch((response) => { - console.error('CAUGHT ERROR IN PROCESS QUERY') if (this._isMounted) this.endTopQuery({ response }) }) } @@ -962,6 +967,24 @@ class DashboardTile extends React.Component { }) } + onCSVDownloadStart = (params) => + this.props.onCSVDownloadStart({ + ...params, + tileId: this.props.tile.i, + }) + + onCSVDownloadProgress = (params) => + this.props.onCSVDownloadProgress({ + ...params, + tileId: this.props.tile.i, + }) + + onCSVDownloadFinish = (params) => + this.props.onCSVDownloadFinish({ + ...params, + tileId: this.props.tile.i, + }) + renderSuggestionMessage = (customMessage) => { if (customMessage) { return customMessage @@ -1008,7 +1031,6 @@ class DashboardTile extends React.Component { {...queryOutputProps} /> )} - {this.renderDataLimitWarning()} {!this.props.isDragging && this.props.isEditing && (
@@ -1028,6 +1050,9 @@ class DashboardTile extends React.Component { themeConfig={getThemeConfig(this.props.themeConfig)} onErrorCallback={this.props.onErrorCallback} onSuccessAlert={this.props.onSuccessCallback} + onCSVDownloadStart={this.onCSVDownloadStart} + onCSVDownloadProgress={this.onCSVDownloadProgress} + onCSVDownloadFinish={this.onCSVDownloadFinish} {...optionsToolbarProps} /> )} @@ -1062,15 +1087,13 @@ class DashboardTile extends React.Component { onSuggestionClick: this.onSuggestionClick, selectedSuggestion: _get(this.props.tile, 'selectedSuggestion'), onNoneOfTheseClick: this.onNoneOfTheseClick, - onDataClick: (drilldownData, queryID, activeKey) => { - this.props.processDrilldown({ + onDrilldownStart: (activeKey) => + this.props.onDrilldownStart({ tileId: this.props.tile.i, - drilldownData, - queryID, - activeKey, isSecondHalf: false, - }) - }, + activeKey, + }), + onDrilldownEnd: this.props.onDrilldownEnd, onQueryValidationSelectOption: this.onQueryValidationSelectOption, onSupportedDisplayTypesChange: this.onSupportedDisplayTypesChange, onRecommendedDisplayType: (displayType, supportedDisplayTypes) => { @@ -1144,15 +1167,13 @@ class DashboardTile extends React.Component { this.onSecondDisplayTypeChange(displayType) }, onNoneOfTheseClick: this.secondOnNoneOfTheseClick, - onDataClick: (drilldownData, queryID, activeKey) => { - this.props.processDrilldown({ + onDrilldownStart: (activeKey) => + this.props.onDrilldownStart({ tileId: this.props.tile.i, - drilldownData, - queryID, - activeKey, isSecondHalf: true, - }) - }, + activeKey, + }), + onDrilldownEnd: this.props.onDrilldownEnd, onQueryValidationSelectOption: this.onSecondQueryValidationSelectOption, }, vizToolbarProps: { @@ -1202,22 +1223,6 @@ class DashboardTile extends React.Component { ) } - renderDataLimitWarning = () => { - const numRows = _get(this.props, 'queryResponse.data.data.rows.length') - const maxRowLimit = _get(this.props, 'queryResponse.data.data.row_limit') - - if (maxRowLimit && numRows === maxRowLimit) { - return ( - - ) - } - } - render = () => { const { onMouseDown, onMouseUp, onTouchStart, onTouchEnd } = this.props const propsToPassToDragHandle = { diff --git a/src/components/Dashboard/DashboardTile/DashboardTile.scss b/src/components/Dashboard/DashboardTile/DashboardTile.scss index b9a24a239..a7023d92f 100644 --- a/src/components/Dashboard/DashboardTile/DashboardTile.scss +++ b/src/components/Dashboard/DashboardTile/DashboardTile.scss @@ -21,15 +21,6 @@ align-items: center; } -// Data limit warning tooltip in Dashboardtile -.dashboard-data-limit-warning-icon { - color: var(--react-autoql-warning-color) !important; - position: absolute !important; - bottom: 40px; - right: 16px; - font-size: 20px; -} - // Drag handles .react-autoql-dashboard-tile-drag-handle { transition: opacity 0.3s ease; diff --git a/src/components/DataMessenger/DataMessenger.js b/src/components/DataMessenger/DataMessenger.js index f8f04afbd..024a2deb3 100644 --- a/src/components/DataMessenger/DataMessenger.js +++ b/src/components/DataMessenger/DataMessenger.js @@ -21,16 +21,11 @@ import { dataFormattingDefault, themeConfigDefault, getAuthentication, - getDataFormatting, getAutoQLConfig, getThemeConfig, } from '../../props/defaults' -import { - setCSSVars, - filterDataForDrilldown, - removeFromDOM, -} from '../../js/Util' +import { setCSSVars } from '../../js/Util' import errorMessages from '../../js/errorMessages' import { lang, setLanguage } from '../../js/Localization' @@ -44,13 +39,8 @@ import { Cascader } from '../Cascader' import { DataAlertModal } from '../Notifications/DataAlertModal' import { NotificationIcon } from '../Notifications/NotificationIcon' import { NotificationFeed } from '../Notifications/NotificationFeed' -import { - runDrilldown, - fetchQueryTips, - fetchConditions, - fetchTopics, -} from '../../js/queryService' -import { ConditionLockMenu } from '../ConditionLockMenu' +import { fetchQueryTips, fetchTopics } from '../../js/queryService' +import { FilterLockPopover } from '../FilterLockPopover' import { CustomScrollbars } from '../CustomScrollbars' // Styles @@ -80,9 +70,8 @@ export default class DataMessenger extends React.Component { placement: this.getPlacementProp(props.placement), lastMessageId: undefined, isOptionsDropdownOpen: false, - isFilterLockingMenuOpen: false, + isFilterLockMenuOpen: false, selectedValueLabel: undefined, - conditions: undefined, messages: [], topicsMessageContent: undefined, queryTipsList: undefined, @@ -91,7 +80,6 @@ export default class DataMessenger extends React.Component { queryTipsTotalPages: undefined, queryTipsCurrentPage: 1, isSizeMaximum: false, - selectedConditions: [], } } @@ -196,61 +184,6 @@ export default class DataMessenger extends React.Component { this.setState({ hasError: true }) } - fetchConditions(getAuthentication(this.props.authentication)).then( - (response) => { - let conditions = _get(response, 'data.data.data') - let array = [...this.state.selectedConditions] - for (let i = 0; i < conditions.length; i++) { - array.push({ - id: conditions[i].id, - keyword: conditions[i].value, - value: conditions[i].value, - show_message: conditions[i].show_message, - key: conditions[i].key, - lock_flag: conditions[i].lock_flag, - }) - } - if (JSON.parse(sessionStorage.getItem('conditions')) !== null) { - var sessionConditions = JSON.parse( - sessionStorage.getItem('conditions') - ) - for (let i = 0; i < sessionConditions.length; i++) { - array.push({ - id: sessionConditions[i].id, - keyword: sessionConditions[i].value, - value: sessionConditions[i].value, - show_message: sessionConditions[i].show_message, - key: sessionConditions[i].key, - lock_flag: sessionConditions[i].lock_flag, - }) - } - } - array.sort((a, b) => { - return a.keyword.toUpperCase() < b.keyword.toUpperCase() - ? -1 - : a.keyword > b.keyword - ? 1 - : 0 - }) - if (this.props.initFilterText && this.props.initFilterText !== '') { - this.setState({ - selectedConditions: array, - }) - for (let i = 0; i < array.length; i++) { - if (array[i].keyword === this.props.initFilterText) { - this.handleHighlightFilterRow(i) - return - } - } - this.animateInputTextAndSubmit(this.props.initFilterText) - } else { - this.setState({ - selectedConditions: array, - }) - } - } - ) - if (this.props.enableQueryQuickStartTopics) { fetchTopics(getAuthentication(this.props.authentication)) .then((response) => { @@ -314,7 +247,7 @@ export default class DataMessenger extends React.Component { } if (this.state.activePage !== prevState.activePage) { - nextState.isFilterLockingMenuOpen = false + nextState.isFilterLockMenuOpen = false nextState.selectedValueLabel = undefined } @@ -479,7 +412,7 @@ export default class DataMessenger extends React.Component { messages: introMessages, lastMessageId: introMessages[introMessages.length - 1].id, isOptionsDropdownOpen: false, - isFilterLockingMenuOpen: false, + isFilterLockMenuOpen: false, }) } @@ -552,7 +485,7 @@ export default class DataMessenger extends React.Component { onDrawerChange = (isOpen) => { if (!isOpen) { this.setState({ - isFilterLockingMenuOpen: false, + isFilterLockMenuOpen: false, selectedValueLabel: undefined, isVisible: false, }) @@ -591,9 +524,7 @@ export default class DataMessenger extends React.Component { getIsSuggestionResponse = (response) => { return !!_get(response, 'data.data.items') } - getIsDownloadingCSVResponse = (response) => { - return !!_get(response, 'config.onDownloadProgress') - } + onResponse = (response, query) => { if (this.getIsSuggestionResponse(response)) { this.addResponseMessage({ @@ -627,66 +558,6 @@ export default class DataMessenger extends React.Component { } } - runDrilldownFromAPI = (data, queryID) => { - runDrilldown({ - ...getAuthentication(getAuthentication(this.props.authentication)), - ...getAutoQLConfig(getAutoQLConfig(this.props.autoQLConfig)), - queryID, - data, - }) - .then((response) => { - this.addResponseMessage({ - response: { ...response, enableDrilldowns: true }, - }) - this.setState({ isChataThinking: false }) - }) - .catch((error) => { - console.error(error) - this.addResponseMessage({ - content: _get(error, 'message'), - }) - this.setState({ isChataThinking: false }) - }) - } - - runFilterDrilldown = (drilldownData, messageId) => { - const response = this.state.messages.find( - (message) => message.id === messageId - ).response - - if (!response) { - return - } - - const drilldownResponse = filterDataForDrilldown(response, drilldownData) - - clearTimeout(this.responseTimeout) - this.responseTimeout = setTimeout(() => { - this.addResponseMessage({ - response: drilldownResponse, - }) - this.setState({ isChataThinking: false }) - }, 1500) - } - - processDrilldown = (drilldownData, queryID, messageId) => { - if ( - getAutoQLConfig(getAutoQLConfig(this.props.autoQLConfig)).enableDrilldowns - ) { - if (!drilldownData || !drilldownData.data) { - return - } - - this.setState({ isChataThinking: true }) - - if (!drilldownData.supportedByAPI) { - this.runFilterDrilldown(drilldownData.data, messageId) - } else { - this.runDrilldownFromAPI(drilldownData.data, queryID) - } - } - } - clearMessages = () => { if (this.queryInputRef) { this.queryInputRef.focus() @@ -730,28 +601,27 @@ export default class DataMessenger extends React.Component { } createMessage = ({ + id, response, content, query, isCSVProgressMessage, queryId, appliedFilters, - linkedQueryResponseRef, }) => { - const id = uuid() + const uniqueId = id || uuid() this.setState({ lastMessageId: id }) return { content, response, query, - id, + id: uniqueId, appliedFilters, type: _get(response, 'data.data.display_type'), isResponse: true, isCSVProgressMessage, queryId, - linkedQueryResponseRef, } } @@ -777,12 +647,12 @@ export default class DataMessenger extends React.Component { } addResponseMessage = ({ + id, response, content, query, isCSVProgressMessage, queryId, - linkedQueryResponseRef, }) => { let currentMessages = this.state.messages @@ -803,11 +673,11 @@ export default class DataMessenger extends React.Component { message = this.createErrorMessage() } else if (isCSVProgressMessage) { message = this.createMessage({ + id, content, query, isCSVProgressMessage, queryId, - linkedQueryResponseRef, }) } else if (!response && !content) { message = this.createErrorMessage() @@ -870,10 +740,14 @@ export default class DataMessenger extends React.Component { } onRTValueLabelClick = (text) => { - this.setState({ - isFilterLockingMenuOpen: true, - selectedValueLabel: text, - }) + this.setState( + { + isFilterLockMenuOpen: true, + }, + () => { + this.filterLockRef?.insertFilter(text) + } + ) } renderTabs = () => { @@ -965,100 +839,72 @@ export default class DataMessenger extends React.Component { } } - renderOptionsDropdown = () => { - if (this.state.activePage === 'data-messenger') { - return ( - <> - {getAutoQLConfig(this.props.autoQLConfig).enableFilterLocking && ( - - - - )} - { - this.setState({ isOptionsDropdownOpen: false }) - }} - position="bottom" // preferred position - content={ -
-
-
{ + return ( + <> + {getAutoQLConfig(this.props.autoQLConfig).enableFilterLocking && + this.renderFilterLockPopover()} + { + this.setState({ isOptionsDropdownOpen: false }) + }} + position="bottom" // preferred position + content={ +
+
+
+ + + {lang.clearDataResponses} + +
+
(this.clearQueriesDropdown = r)} + id="clear-queries-dropdown" + style={{ display: 'none' }} + > +
-
(this.clearQueriesDropdown = r)} - id="clear-queries-dropdown" - style={{ display: 'none' }} + Cancel + + - -
+ Clear +
+
+ } + > + - - - ) - } + + + + + ) } renderHeaderTitle = () => { @@ -1075,34 +921,52 @@ export default class DataMessenger extends React.Component { return
{title}
} - closeFilterLockingMenu = () => { - if (this.state.isFilterLockingMenuOpen) { + openFilterLockMenu = () => { + if (!this.state.isFilterLockMenuOpen) { this.setState({ - isFilterLockingMenuOpen: false, - selectedValueLabel: undefined, + isFilterLockMenuOpen: true, }) } } - renderFLPopoverContent = () => { + closeFilterLockMenu = () => { + if (this.state.isFilterLockMenuOpen) { + this.setState({ + isFilterLockMenuOpen: false, + }) + } + } + + onFilterChange = (allFilters) => { + const sessionFilters = allFilters.filter((filter) => filter.isSession) + this.setState({ sessionFilters, hasFilters: !!allFilters?.length }) + } + + renderFilterLockPopover = () => { return ( -
e.stopPropagation()}> - (this.conditionLockMenuRef = ref)} - data-test="react-autoql-filter-menu" - id="react-autoql-filter-menu" - authentication={this.props.authentication} - containerWidth={this.getDrawerWidth()} - isOpen={this.state.isFilterLockingMenuOpen} - themeConfig={this.props.themeConfig} - initFilterText={this.state.selectedValueLabel} - onClose={this.closeFilterLockingMenu} - conditions={this.state.selectedConditions} - onConditionChangeCallback={(selectedConditions) => { - this.setState({ selectedConditions }) - }} - /> -
+ (this.filterLockRef = r)} + authentication={this.props.authentication} + themeConfig={this.props.themeConfig} + isOpen={this.state.isFilterLockMenuOpen} + onChange={this.onFilterChange} + onClose={this.closeFilterLockMenu} + > + + ) } @@ -1148,7 +1012,7 @@ export default class DataMessenger extends React.Component { {this.renderHeaderTitle()}
- {this.renderOptionsDropdown()} + {this.renderRightHeaderContent()}
) @@ -1178,11 +1042,27 @@ export default class DataMessenger extends React.Component { }, 1000) } - setCSVDownloadProgress = (id, percentCompleted) => { - this.csvProgressLog[id] = percentCompleted + onCSVDownloadProgress = ({ id, progress }) => { + this.csvProgressLog[id] = progress if (this.messageRefs[id]) { this.messageRefs[id].setState({ - csvDownloadProgress: percentCompleted, + csvDownloadProgress: progress, + }) + } + } + + onDrilldownStart = () => { + this.setState({ isChataThinking: true }) + } + + onDrilldownEnd = ({ response, error } = {}) => { + this.setState({ isChataThinking: false }) + + if (response) { + this.addResponseMessage({ response }) + } else if (error) { + this.addResponseMessage({ + content: error, }) } } @@ -1216,10 +1096,9 @@ export default class DataMessenger extends React.Component { themeConfig={getThemeConfig( getThemeConfig(this.props.themeConfig) )} - linkedQueryResponseRef={message.linkedQueryResponseRef} isCSVProgressMessage={message.isCSVProgressMessage} initialCSVDownloadProgress={this.csvProgressLog[message.id]} - setCSVDownloadProgress={this.setCSVDownloadProgress} + onCSVDownloadProgress={this.onCSVDownloadProgress} queryId={message.queryId} queryText={message.query} scrollRef={this.messengerScrollComponent} @@ -1227,9 +1106,8 @@ export default class DataMessenger extends React.Component { setActiveMessage={this.setActiveMessage} isActive={this.state.activeMessageId === message.id} addMessageToDM={this.addResponseMessage} - processDrilldown={(drilldownData, queryID) => - this.processDrilldown(drilldownData, queryID, message.id) - } + onDrilldownStart={this.onDrilldownStart} + onDrilldownEnd={this.onDrilldownEnd} isResponse={message.isResponse} isChataThinking={this.state.isChataThinking} onSuggestionClick={this.onSuggestionClick} @@ -1237,9 +1115,7 @@ export default class DataMessenger extends React.Component { scrollToBottom={this.scrollToBottom} lastMessageId={this.state.lastMessageId} onQueryOutputUpdate={this.rebuildTooltips} - dataFormatting={getDataFormatting( - getDataFormatting(this.props.dataFormatting) - )} + dataFormatting={this.props.dataFormatting} displayType={ message.displayType || _get(message, 'response.data.data.display_type') @@ -1302,6 +1178,7 @@ export default class DataMessenger extends React.Component { hideInput={this.props.hideInput} source={['data_messenger']} AutoAEId={this.props.AutoAEId} + queryFilters={this.state.sessionFilters} />
diff --git a/src/components/DataMessenger/DataMessenger.scss b/src/components/DataMessenger/DataMessenger.scss index 30bcbdb3a..6fed901cb 100644 --- a/src/components/DataMessenger/DataMessenger.scss +++ b/src/components/DataMessenger/DataMessenger.scss @@ -62,6 +62,10 @@ float: none; opacity: 1; + &.hidden { + display: none; + } + &.close span.react-autoql-icon svg { width: 27px; height: 27px; @@ -116,29 +120,29 @@ font-size: 13px; } -.react-autoql-drawer .condition-list-container { - position: absolute; - border: 1px solid var(--react-autoql-text-color-primary); - border-radius: 100rem; - background-color: transparent; - color: var(--react-autoql-text-color-primary); - padding: 0 5px 0 5px; - transition: background-color 250ms ease, box-shadow 250ms ease; - z-index: 9999999; - width: fit-content; - height: fit-content; -} - -.condition-list-display { - display: none; - background-color: var(--react-autoql-background-color-primary); - border-radius: 4px; - padding: 5px; - width: fit-content; - height: fit-content; - padding: 10px; - box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.4); -} +// .react-autoql-drawer .condition-list-container { +// position: absolute; +// border: 1px solid var(--react-autoql-text-color-primary); +// border-radius: 100rem; +// background-color: transparent; +// color: var(--react-autoql-text-color-primary); +// padding: 0 5px 0 5px; +// transition: background-color 250ms ease, box-shadow 250ms ease; +// z-index: 9999999; +// width: fit-content; +// height: fit-content; +// } + +// .condition-list-display { +// display: none; +// background-color: var(--react-autoql-background-color-primary); +// border-radius: 4px; +// padding: 5px; +// width: fit-content; +// height: fit-content; +// padding: 10px; +// box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.4); +// } .react-autoql-drawer .react-autoql-drawer:focus { outline: none; @@ -308,7 +312,7 @@ .react-autoql-drawer-tooltip { font-family: var(--react-autoql-font-family), sans-serif; letter-spacing: 0.04em; - line-height: 0.9em; + line-height: 1.5em; padding: 7px 15px; transition: none; opacity: 1 !important; @@ -326,14 +330,14 @@ z-index: 99999 !important; /* necessary to show up on top of rc drawer */ } -.react-autoql-condition-lock-menu { - color: var(--react-autoql-text-color-primary); - background: var(--react-autoql-background-color-primary); - min-height: 300px; -} +// .react-autoql-condition-lock-menu { +// color: var(--react-autoql-text-color-primary); +// background: var(--react-autoql-background-color-primary); +// min-height: 300px; +// } .react-tiny-popover-container { - z-index: 9999; + z-index: 99999; border: 1px solid #ababab52; border-radius: 4px; box-shadow: 0px 0 8px rgba(0, 0, 0, 0.15); diff --git a/src/components/FilterLockPopover/FilterLockPopover.js b/src/components/FilterLockPopover/FilterLockPopover.js new file mode 100644 index 000000000..360e0ae3e --- /dev/null +++ b/src/components/FilterLockPopover/FilterLockPopover.js @@ -0,0 +1,564 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Autosuggest from 'react-autosuggest' +import { lang } from '../../js/Localization' +import { v4 as uuid } from 'uuid' +import _get from 'lodash.get' +import _isEqual from 'lodash.isequal' +import ReactTooltip from 'react-tooltip' +import _cloneDeep from 'lodash.clonedeep' +import { ToastContainer, toast } from 'react-toastify' +import { Slide } from 'react-toastify' +import Popover from 'react-tiny-popover' + +import { Icon } from '../Icon' +import { Button } from '../Button' +import { LoadingDots } from '../LoadingDots' +import { Checkbox } from '../Checkbox' +import ErrorBoundary from '../../containers/ErrorHOC/ErrorHOC' + +import { + fetchVLAutocomplete, + setFilters, + unsetFilterFromAPI, + fetchFilters, +} from '../../js/queryService' + +import { authenticationType, themeConfigType } from '../../props/types' +import { + authenticationDefault, + getAuthentication, + getThemeConfig, + themeConfigDefault, +} from '../../props/defaults' + +import './FilterLockPopover.scss' +import 'react-toastify/dist/ReactToastify.css' + +let autoCompleteArray = [] + +export default class FilterLockPopover extends React.Component { + constructor(props) { + super(props) + + this.contentKey = uuid() + + this.state = { + filters: [], + suggestions: [], + inputValue: '', + } + } + + static propTypes = { + authentication: authenticationType, + themeConfig: themeConfigType, + + isOpen: PropTypes.bool, + position: PropTypes.string, + align: PropTypes.string, + onChange: PropTypes.func, + } + + static defaultProps = { + authentication: authenticationDefault, + themeConfig: themeConfigDefault, + + isOpen: false, + position: 'bottom', + align: 'center', + onChange: () => {}, + } + + componentDidMount = () => { + this._isMounted = true + this.initialize() + } + + componentDidUpdate = (prevProps, prevState) => { + if (!_isEqual(this.state.filters, prevState.filters)) { + this.props.onChange(this.state.filters) + } + + if (!this.props.isOpen && prevProps.isOpen) { + this.setState({ inputValue: '' }) + } + } + + componentWillUnmount = () => { + this._isMounted = false + clearTimeout(this.animateTextTimeout) + clearTimeout(this.focusInputTimeout) + clearTimeout(this.highlightFilterEndTimeout) + clearTimeout(this.highlightFilterStartTimeout) + } + + initialize = () => { + this.setState({ isFetchingFilters: true }) + fetchFilters(getAuthentication(this.props.authentication)) + .then((response) => { + const filters = response?.data?.data?.data || [] + if (this._isMounted) { + this.setState({ filters, isFetchingFilters: false }) + } + }) + .catch((error) => { + console.error(error) + if (this._isMounted) { + this.setState({ isFetchingFilters: false }) + } + }) + } + + handleHighlightFilterRow(filterKey) { + toast.info('This filter has already been applied.') + const startAt = 0 + const duration = 1300 + + this.highlightFilterStartTimeout = setTimeout(() => { + this.setState({ highlightedFilter: filterKey }) + }, startAt) + + this.highlightFilterEndTimeout = setTimeout(() => { + this.setState({ highlightedFilter: undefined }) + }, duration) + } + + animateInputTextAndSubmit = (text) => { + if (typeof text === 'string' && _get(text, 'length')) { + const totalTime = 2000 + const timePerChar = totalTime / text.length + for (let i = 1; i <= text.length; i++) { + this.animateTextTimeout = setTimeout(() => { + this.setState({ inputValue: text.slice(0, i) }) + if (i === text.length) { + this.focusInputTimeout = setTimeout(() => { + this.inputElement = document.querySelector( + '#react-autoql-filter-menu-input' + ) + this.inputElement?.focus() + }, 300) + } + }, timePerChar) + } + } + } + + insertFilter = (filterText) => { + const existingFilter = this.findFilter({ filterText }) + if (filterText && existingFilter) { + this.handleHighlightFilterRow(this.getKey(existingFilter)) + } else { + this.animateInputTextAndSubmit(filterText) + } + } + + getAllFilters = () => { + return this.state.filters + } + + getPersistedFilters = () => { + return _cloneDeep(this.state.filters.filter((filter) => !filter.isSession)) + } + + findFilter = ({ filterText, canonical, keyword, value, key }) => { + const allFilters = this.state.filters + + if (canonical && keyword) { + return allFilters.find( + (filter) => filter.key === canonical && filter.value === keyword + ) + } else if (value && key) { + return allFilters.find( + (filter) => filter.key === key && filter.value === value + ) + } else if (filterText) { + return allFilters.find((filter) => filter.value === filterText) + } + + return undefined + } + + onSuggestionsFetchRequested = ({ value }) => { + clearTimeout(this.autoCompleteTimer) + this.autoCompleteTimer = setTimeout(() => { + fetchVLAutocomplete({ + ...getAuthentication(this.props.authentication), + suggestion: value, + }) + .then((response) => { + const body = _get(response, 'data.data') + const sortingArray = [] + let suggestionsMatchArray = [] + autoCompleteArray = [] + suggestionsMatchArray = body.matches + for (let i = 0; i < suggestionsMatchArray.length; i++) { + sortingArray.push(suggestionsMatchArray[i]) + } + + sortingArray.sort((a, b) => { + return a.keyword?.toUpperCase() < b.keyword?.toUpperCase() + ? -1 + : a.keyword > b.keyword + ? 1 + : 0 + }) + for (let idx = 0; idx < sortingArray.length; idx++) { + const anObject = { + name: sortingArray[idx], + } + autoCompleteArray.push(anObject) + } + this.setState({ suggestions: autoCompleteArray }) + }) + .catch((error) => console.error(error)) + }, 300) + } + + onSuggestionsClearRequested = () => { + this.setState({ + suggestions: [], + }) + } + + createNewFilterFromSuggestion = (suggestion) => { + const newFilter = { + value: suggestion.keyword, + show_message: suggestion.show_message, + key: suggestion.canonical, + filter_type: 'include', + } + return newFilter + } + + setFilter = (newFilter) => { + const auth = getAuthentication(this.props.authentication) + + return setFilters({ ...auth, filters: [newFilter] }) + .then((response) => { + const updatedFilter = response?.data?.data?.data[0] + if (!updatedFilter) throw new Error('No filter in api response') + + if (this.findFilter(newFilter)) { + const updatedFilters = this.state.filters.map((filter) => { + if (this.getKey(filter) === this.getKey(updatedFilter)) + return updatedFilter + return filter + }) + this.setState({ filters: updatedFilters }) + } else { + this.setState({ + filters: [...this.state.filters, updatedFilter], + inputValue: '', + }) + } + return Promise.resolve() + }) + .catch((error) => { + console.error(error) + return Promise.reject() + }) + } + + unsetFilter = (filter) => { + try { + const auth = getAuthentication(this.props.authentication) + return unsetFilterFromAPI({ ...auth, filter }) + } catch (error) { + return Promise.reject(error) + } + } + + getSuggestionValue = (sugg) => { + if (!!this.findFilter(sugg)) { + this.handleHighlightFilterRow(this.getKey(sugg)) + } else { + let newFilter = this.createNewFilterFromSuggestion(sugg) + + this.setFilter(newFilter) + .then(() => toast.success(`${sugg.keyword} has been locked.`)) + .catch(() => toast.error(`Something went wrong. Please try again.`)) + } + } + + handlePersistToggle = async (clickedFilter) => { + const oldFilters = this.state.filters + const toggledFilter = { + ...clickedFilter, + isSession: !clickedFilter.isSession, + id: undefined, + } + const newFilters = this.state.filters.map((filter) => { + const isSession = clickedFilter.isSession + if (this.getKey(filter) === this.getKey(clickedFilter)) { + return toggledFilter + } + + return filter + }) + + this.setState({ filters: newFilters }) + + try { + if (clickedFilter.isSession) { + await this.setFilter(toggledFilter) + } else { + await this.unsetFilter(clickedFilter) + } + } catch (error) { + console.error(error) + toast.error('Something went wrong. Please try again.') + this.setState({ filters: oldFilters }) + } + } + + removeFilter = async (clickedFilter) => { + const oldFilters = this.state.filters + const newFilters = this.state.filters.filter( + (filter) => this.getKey(filter) !== this.getKey(clickedFilter) + ) + + this.setState({ filters: newFilters }) + + try { + if (!clickedFilter.isSession) { + await this.unsetFilter(clickedFilter) + } + } catch (error) { + console.error(error) + toast.error('Something went wrong. Please try again.') + this.setState({ filters: oldFilters }) + } + + toast.success('Filter removed.') + ReactTooltip.hide() + } + + onInputChange = (e) => { + if (e.keyCode === 38 || e.keyCode === 40) { + return // keyup or keydown + } + + if (e && e.target && (e.target.value || e.target.value === '')) { + this.setState({ inputValue: e.target.value }) + } + } + + getKey = (filter) => { + const key = filter.key || filter.canonical + const value = filter.value || filter.keyword + return `${key}-${value}` + } + + renderTitle = () => { + return ( +
+

+ {lang.filterLockingTitle}{' '} + +

+
+ ) + } + + renderHeader = () => { + return ( +
+ {this.renderTitle()} + {this.renderCloseBtn()} +
+ ) + } + + renderCloseBtn = () => { + return ( + + ) + } + + renderFooter = () => { + return ( +
+ +
+ ) + } + + renderVLInput = () => { + return ( + this.getSuggestionValue(sugg.name)} + renderSuggestion={(suggestion) => ( +
    +
  • {suggestion.name.keyword}
  • +
  • {suggestion.name.show_message}
  • +
+ )} + inputProps={{ + onChange: this.onInputChange, + value: this.state.inputValue, + disabled: this.state.isFetchingFilters, + placeholder: 'Search & select a filter', + className: 'react-autoql-condition-locking-input', + id: 'react-autoql-filter-menu-input', + }} + /> + ) + } + + renderFilterList = () => { + if (this.state.isFetchingFilters) { + return ( +
+ +
+ ) + } + + if (!this.state.filters?.length) { + return ( +
+ {lang.noFiltersLocked} +
+ ) + } + + return ( +
+
+
+

Filter

+
+
+

+ Persist{' '} + +

+
+
+
+ {this.state.filters.map((filter) => { + const key = this.getKey(filter) + return ( +
+
+ {`${filter.value} (${filter.show_message})`} +
+
+ this.handlePersistToggle(filter)} + /> + this.removeFilter(filter)} + /> +
+
+ ) + })} +
+
+ ) + } + + renderContent = () => { + return ( + + +
e.stopPropagation()} + > + {this.renderHeader()} + {this.renderVLInput()} + {this.renderFilterList()} + {this.renderFooter()} + +
+
+ ) + } + + render = () => { + if (this.props.isOpen) { + return ( + + {this.props.children || null} + + ) + } + return this.props.children || null + } +} diff --git a/src/components/FilterLockPopover/FilterLockPopover.scss b/src/components/FilterLockPopover/FilterLockPopover.scss new file mode 100644 index 000000000..dd86c9a4e --- /dev/null +++ b/src/components/FilterLockPopover/FilterLockPopover.scss @@ -0,0 +1,226 @@ +.filter-lock-menu { + z-index: 99999 !important; + transition: opacity 0.1s ease 0s !important; + border: 1px solid #ababab52; + border-radius: 4px; + box-shadow: 0px 0 8px rgba(0, 0, 0, 0.15); + opacity: 1; + min-width: 500px; + max-width: 70vw; + height: 350px; + background: var(--react-autoql-background-color-primary); + color: var(--react-autoql-text-color-primary); + + .filter-lock-toast-container { + top: 0; + position: absolute; + + .Toastify__toast { + min-height: 48px; + } + + .Toastify__toast-body { + justify-content: center; + } + + .Toastify__toast-body > div:last-child { + flex: unset; + } + } +} + +.filter-lock-menu-content { + padding: 15px 20px; + display: flex; + flex-direction: column; + justify-content: stretch; + height: 100%; + + &.closed { + opacity: 0; + } + + h3 { + margin-top: 3px; + margin-bottom: 15px; + color: var(--react-autoql-text-color-primary); + } + + h4 { + margin-top: 3px; + margin-bottom: 5px; + color: var(--react-autoql-text-color-primary); + } + + .react-autoql-condition-list-loading-container, + .react-autoql-empty-condition-list, + .react-autoql-filter-list-container { + flex: 1; + } + + .filter-lock-menu-header { + flex: 0; + width: 100%; + display: flex; + align-items: flex-start; + justify-content: space-between; + + .filter-locking-close-btn { + border: none; + margin: 0; + padding: 0; + font-size: 20px; + } + } + .filter-lock-menu-footer { + width: 100%; + display: flex; + justify-content: flex-end; + } + + .react-autoql-empty-condition-list { + opacity: 0.8; + width: 100%; + text-align: center; + padding-top: 70px; + } + + .react-autoql-filter-list-container { + margin-top: 10px; + + .flex { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: nowrap; + white-space: nowrap; + margin-bottom: 4px; + + .persist-toggle-column, + .react-autoql-condition-table-list-item { + margin-right: 25px; + } + + .react-autoql-remove-filter-icon { + cursor: pointer; + + &:hover { + color: var(--react-autoql-danger-color); + } + } + } + + .react-autoql-highlight-row { + background-color: var(--react-autoql-accent-color); + color: white; + animation-name: highlightIn, highlightOut; + animation-duration: 300ms, 300ms; + animation-delay: 0ms, 1000ms; + animation-timing-function: ease-in, ease-out; + animation-iteration-count: 1, 1; + } + } + + .react-autoql-condition-locking-input { + padding: 5px; + padding-left: 20px; + height: 32px; + box-sizing: border-box; + border-radius: 24px; + font-size: 12px; + font-family: inherit; + letter-spacing: 0.04em; + outline: none !important; + width: 100%; + font-family: inherit; + border: 1px solid var(--react-autoql-border-color, rgba(0, 0, 0, 0.1)); + background: var(--react-autoql-background-color-primary); + color: var(--react-autoql-text-color-primary); + + &:hover { + box-shadow: 0px 0px 5px 1px rgba(0, 0, 0, 0.1); + } + + &::placeholder { + color: rgba(var(--react-autoql-text-color-placeholder), 2) !important; + opacity: 0.5; + } + } + + // ------------------- Autocomplete styles ------------------- + .react-autosuggest__container--open { + position: relative; + + .react-autosuggest__suggestions-container--open { + background-color: var(--react-autoql-background-color-primary); + border: 1px solid var(--react-autoql-border-color); + color: var(--react-autoql-text-color-primary); + position: absolute; + padding: 10px 0; + display: block; + font-family: inherit; + font-size: 15px; + font-weight: normal; + z-index: 2; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.13); + text-align: left; + border-radius: 4px; + height: auto; + width: 100%; + max-height: 200px; + overflow-y: scroll !important; + + ul.filter-lock-suggestion-item { + cursor: pointer; + width: 100%; + columns: 2; + -webkit-columns: 2; + list-style: none; + -moz-columns: 2; + letter-spacing: 0.05em; + line-height: 22.5px; + padding: 0px 20px 3px 20px; + } + + .react-autosuggest__suggestion { + color: var(--react-autoql-text-color-primary); + } + .react-autosuggest__suggestions-list { + margin: 0; + padding: 0; + list-style-type: none; + } + .react-autosuggest__suggestion--highlighted { + background-color: rgba(0, 0, 0, 0.1) !important; + } + .react-autosuggest__section-title { + padding: 10px 0 0 10px; + font-size: 12px; + color: #777; + } + } + } + // ------------------------------------------------------------------------ +} + +@keyframes highlightOut { + 100% { + background-color: var(--react-autoql-background-color-primary); + color: var(--react-autoql-text-color-primary); + } + 0% { + background-color: var(--react-autoql-accent-color); + color: white; + } +} +@keyframes highlightIn { + 100% { + background-color: var(--react-autoql-accent-color); + color: white; + } + 0% { + background-color: var(--react-autoql-background-color-primary); + color: var(--react-autoql-text-color-primary); + } +} diff --git a/src/components/FilterLockPopover/FilterLockPopover.test.js b/src/components/FilterLockPopover/FilterLockPopover.test.js new file mode 100644 index 000000000..0479f55cd --- /dev/null +++ b/src/components/FilterLockPopover/FilterLockPopover.test.js @@ -0,0 +1,68 @@ +import React from 'react' +import { shallow, mount } from 'enzyme' + +import { checkProps, ignoreConsoleErrors } from '../../../test/testUtils' +import { FilterLockPopover } from '.' + +const defaultProps = FilterLockPopover.defaultProps +const sampleAuth = { + apiKey: 'testKey', + domain: 'http://www.test.com', + token: 'rand0mtok3n', +} + +const setup = (props = {}, state = null) => { + const setupProps = { ...defaultProps, authentication: sampleAuth, ...props } + const wrapper = shallow() + if (state) { + wrapper.setState(state) + } + return wrapper +} + +describe('renders correctly', () => { + test('renders correctly with only token prop', () => { + ignoreConsoleErrors(async () => { + const wrapper = await shallow( + + ) + expect(wrapper.exists()).toBe(true) + }) + }) + + test('renders correctly with default props', () => { + const wrapper = setup() + expect(wrapper.exists()).toBe(true) + }) + test('renders correctly when no children provided', () => { + const mounted = mount( + + ) + expect(mounted.exists()).toBe(true) + }) + test('renders correctly with children', () => { + const mounted = mount( + + + + ) + expect(mounted.exists()).toBe(true) + }) +}) + +describe('props', () => { + test('does not throw warning with expected props', () => { + checkProps(FilterLockPopover, defaultProps) + }) + + describe('isOpen', () => { + test('renders correctly with isOpen false', () => { + const wrapper = setup({ isOpen: false }) + expect(wrapper.instance().props.isOpen).toBe(false) + }) + test('renders correctly with isOpen true', () => { + const wrapper = setup({ isOpen: true }) + expect(wrapper.instance().props.isOpen).toBe(true) + }) + }) +}) diff --git a/src/components/FilterLockPopover/index.js b/src/components/FilterLockPopover/index.js new file mode 100644 index 000000000..14f97c75d --- /dev/null +++ b/src/components/FilterLockPopover/index.js @@ -0,0 +1 @@ +export { default as FilterLockPopover } from './FilterLockPopover' diff --git a/src/components/Icon/Icon.scss b/src/components/Icon/Icon.scss index 917ec992d..9eb258769 100644 --- a/src/components/Icon/Icon.scss +++ b/src/components/Icon/Icon.scss @@ -27,7 +27,7 @@ span.react-autoql-icon { padding: 0; opacity: 1; height: 1em; - margin-bottom: -0.1em; + margin-bottom: -0.15em; } } diff --git a/src/components/Modal/Modal.js b/src/components/Modal/Modal.js index 37bdd92cf..7d93f1bf3 100644 --- a/src/components/Modal/Modal.js +++ b/src/components/Modal/Modal.js @@ -60,21 +60,6 @@ export default class Modal extends React.Component { isConfirmCloseModalVisible: false, } - componentDidMount = () => { - setCSSVars(getThemeConfig(this.props.themeConfig)) - } - - componentDidUpdate = (prevProps) => { - if ( - !_isEqual( - getThemeConfig(this.props.themeConfig), - getThemeConfig(prevProps.themeConfig) - ) - ) { - setCSSVars(getThemeConfig(this.props.themeConfig)) - } - } - onClose = (deleteFromPortal = true) => { if (this.props.confirmOnClose && deleteFromPortal) { this.setState({ isConfirmCloseModalVisible: true }) diff --git a/src/components/Notifications/DataAlerts/DataAlerts.js b/src/components/Notifications/DataAlerts/DataAlerts.js index 094aa9e2d..424da55fb 100644 --- a/src/components/Notifications/DataAlerts/DataAlerts.js +++ b/src/components/Notifications/DataAlerts/DataAlerts.js @@ -199,7 +199,7 @@ export default class DataAlerts extends React.Component { return ( (this.editModalRef = r)} - themeConfig={getThemeConfig(this.props.themeConfig)} + themeConfig={this.props.themeConfig} key={this.COMPONENT_KEY} authentication={getAuthentication(this.props.authentication)} isVisible={this.state.isEditModalVisible} @@ -377,14 +377,12 @@ export default class DataAlerts extends React.Component { )} ) : ( {}, onColumnVisibilitySave: () => {}, onResponseCallback: () => {}, + onCSVDownloadStart: () => {}, + onCSVDownloadFinish: () => {}, + onCSVDownloadProgress: () => {}, } state = { @@ -109,6 +114,7 @@ export default class OptionsToolbar extends React.Component { componentWillUnmount = () => { this._isMounted = false clearTimeout(this.temporaryStateTimeout) + clearTimeout(this.pivotTableCSVDownloadTimeout) } onTableFilter = (newTableData) => { @@ -145,14 +151,19 @@ export default class OptionsToolbar extends React.Component { } fetchCSVAndExport = () => { - const queryId = _get( - this.props.responseRef, - 'queryResponse.data.data.query_id' - ) + const queryId = this.props.responseRef?.queryResponse?.data?.data?.query_id + const query = this.props.responseRef?.queryResponse?.data?.data?.text + const uniqueId = uuid() + this.props.onCSVDownloadStart({ id: uniqueId, queryId, query }) exportCSV({ queryId, ...getAuthentication(this.props.authentication), + csvProgressCallback: (percentCompleted) => + this.props.onCSVDownloadProgress({ + id: uniqueId, + progress: percentCompleted, + }), }) .then((response) => { const url = window.URL.createObjectURL(new Blob([response.data])) @@ -161,8 +172,26 @@ export default class OptionsToolbar extends React.Component { link.setAttribute('download', 'export.csv') document.body.appendChild(link) link.click() + + let fileSizeMb = _get(response, 'headers.content-length') / 1000000 + let totalRows = _get(response, 'headers.total_rows') + let returnedRows = _get(response, 'headers.returned_rows') + let exportLimit = _get(response, 'headers.export_limit') + fileSizeMb = parseInt(fileSizeMb) + exportLimit = parseInt(exportLimit) + totalRows = parseInt(totalRows) + returnedRows = parseInt(returnedRows) + + this.props.onCSVDownloadFinish({ + id: uniqueId, + fileSizeMb, + exportLimit, + totalRows, + returnedRows, + }) }) .catch((error) => { + this.props.onCSVDownloadFinish({ id: uniqueId, error }) console.error(error) }) } @@ -171,23 +200,17 @@ export default class OptionsToolbar extends React.Component { this.setState({ activeMenu: undefined }) const displayType = _get(this.props.responseRef, 'props.displayType') const isPivotTable = displayType === 'pivot_table' + const uniqueId = uuid() - if (this.props.onCSVExportClick) { - // Only use this different behaviour for Data Messenger - // Export directly from here if using this component - // outside of Data Messenger - const queryId = _get( - this.props.responseRef, - 'queryResponse.data.data.query_id' - ) - const queryText = _get( - this.props.responseRef, - 'queryResponse.data.data.text' - ) - this.props.onCSVExportClick(queryId, queryText, isPivotTable) - } else if (isPivotTable) { + if (isPivotTable) { if (_get(this.props, 'responseRef.pivotTableRef._isMounted')) { - this.props.responseRef.pivotTableRef.saveAsCSV() + this.props.onCSVDownloadStart({ id: uniqueId }) + this.pivotTableCSVDownloadTimeout = setTimeout(() => { + this.props.responseRef.pivotTableRef.saveAsCSV().then(() => { + this.props.onCSVDownloadProgress({ id: uniqueId, progress: 100 }) + this.props.onCSVDownloadFinish({ id: uniqueId }) + }) + }, 2000) } } else { this.fetchCSVAndExport() @@ -752,10 +775,7 @@ export default class OptionsToolbar extends React.Component { showHiddenColsBadge: someColumnsHidden, showSQLButton: isDataResponse && autoQLConfig.debug, showSaveAsCSVButton: - isDataResponse && - hasMoreThanOneRow && - !allColumnsHidden && - autoQLConfig.enableCSVDownload, + isTable && hasMoreThanOneRow && autoQLConfig.enableCSVDownload, showDeleteButton: this.props.enableDeleteBtn, showReportProblemButton: !!_get(response, 'data.data.query_id'), diff --git a/src/components/QueryInput/QueryInput.js b/src/components/QueryInput/QueryInput.js index 8c8ad3e6e..60e8afba4 100644 --- a/src/components/QueryInput/QueryInput.js +++ b/src/components/QueryInput/QueryInput.js @@ -1,5 +1,5 @@ import React, { Fragment } from 'react' -import { bool, string, func } from 'prop-types' +import PropTypes from 'prop-types' import { v4 as uuid } from 'uuid' import _get from 'lodash.get' import _isEqual from 'lodash.isequal' @@ -16,7 +16,6 @@ import { dataFormattingDefault, themeConfigDefault, getAuthentication, - getDataFormatting, getAutoQLConfig, getThemeConfig, } from '../../props/defaults' @@ -51,15 +50,16 @@ export default class QueryInput extends React.Component { autoQLConfig: autoQLConfigType, dataFormatting: dataFormattingType, themeConfig: themeConfigType, - enableVoiceRecord: bool, - isDisabled: bool, - onSubmit: func, - onResponseCallback: func, - className: string, - autoCompletePlacement: string, - showLoadingDots: bool, - showChataIcon: bool, - inputValue: string, + enableVoiceRecord: PropTypes.bool, + isDisabled: PropTypes.bool, + onSubmit: PropTypes.func, + onResponseCallback: PropTypes.func, + className: PropTypes.string, + autoCompletePlacement: PropTypes.string, + showLoadingDots: PropTypes.bool, + showChataIcon: PropTypes.bool, + inputValue: PropTypes.string, + queryFilters: PropTypes.arrayOf(PropTypes.shape({})), } static defaultProps = { @@ -76,6 +76,7 @@ export default class QueryInput extends React.Component { isBackButtonClicked: false, inputValue: undefined, source: [], + queryFilters: undefined, onSubmit: () => {}, onResponseCallback: () => {}, } @@ -175,6 +176,7 @@ export default class QueryInput extends React.Component { ...getAutoQLConfig(this.props.autoQLConfig), source: newSource, AutoAEId: this.props.AutoAEId, + filters: this.props.queryFilters, }) .then((response) => { this.props.onResponseCallback(response, query) @@ -192,6 +194,7 @@ export default class QueryInput extends React.Component { ...getAutoQLConfig(this.props.autoQLConfig), source: newSource, AutoAEId: this.props.AutoAEId, + filters: this.props.queryFilters, }) .then((response) => { this.props.onResponseCallback(response, query) diff --git a/src/components/QueryOutput/QueryOutput.js b/src/components/QueryOutput/QueryOutput.js index ab70cc034..4f667848e 100644 --- a/src/components/QueryOutput/QueryOutput.js +++ b/src/components/QueryOutput/QueryOutput.js @@ -59,7 +59,6 @@ import { setCSSVars, getNumberOfGroupables, areAllColumnsHidden, - getVisibleColumns, } from '../../js/Util.js' import { @@ -69,16 +68,13 @@ import { getMultiSeriesColumnIndex, getDateColumnIndex, getStringColumnIndices, - shouldPlotMultiSeries, isColumnDateType, } from './columnHelpers.js' -import { sendSuggestion } from '../../js/queryService' +import { sendSuggestion, runDrilldown } from '../../js/queryService' import './QueryOutput.scss' import { MONTH_NAMES } from '../../js/Constants' -import { weekdays } from 'moment-timezone' -import WeekSelect from '../DateSelect/WeekSelect/WeekSelect' import { ReverseTranslation } from '../ReverseTranslation' String.prototype.isUpperCase = function() { @@ -102,7 +98,9 @@ export default class QueryOutput extends React.Component { this.QUERY_VALIDATION_KEY = uuid() this.queryResponse = props.queryResponse - this.supportedDisplayTypes = getSupportedDisplayTypes(props.queryResponse) + this.supportedDisplayTypes = getSupportedDisplayTypes({ + response: props.queryResponse, + }) this.queryID = _get(props.queryResponse, 'data.data.query_id') this.interpretation = _get(props.queryResponse, 'data.data.interpretation') this.tableID = uuid() @@ -110,8 +108,8 @@ export default class QueryOutput extends React.Component { // Set initial config if needed // If this config causes errors, it will be reset when the error occurs - if (props.dataConfig && this.isDataConfigValid(props.dataConfig)) { - this.dataConfig = _cloneDeep(props.dataConfig) + if (props.tableConfig && this.isTableConfigValid(props.tableConfig)) { + this.tableConfig = _cloneDeep(props.tableConfig) } const isProvidedDisplayTypeValid = isDisplayTypeValid( @@ -151,7 +149,7 @@ export default class QueryOutput extends React.Component { themeConfig: themeConfigType, autoQLConfig: autoQLConfigType, dataFormatting: dataFormattingType, - dataConfig: shape({}), + tableConfig: shape({}), onSuggestionClick: func, displayType: string, renderTooltips: bool, @@ -160,13 +158,11 @@ export default class QueryOutput extends React.Component { queryValidationSelections: arrayOf(shape({})), renderSuggestionsAsDropdown: bool, suggestionSelection: string, - height: number, - width: number, activeChartElementKey: string, enableColumnHeaderContextMenu: bool, isResizing: bool, enableDynamicCharting: bool, - onDataConfigChange: func, + onTableConfigChange: func, onNoneOfTheseClick: func, autoChartAggregations: bool, onSupportedDisplayTypesChange: func, @@ -176,6 +172,8 @@ export default class QueryOutput extends React.Component { defaultShowInterpretation: bool, isTaskModule: bool, onUpdate: func, + onDrilldownStart: func, + onDrilldownEnd: func, } static defaultProps = { @@ -183,7 +181,7 @@ export default class QueryOutput extends React.Component { themeConfig: themeConfigDefault, autoQLConfig: autoQLConfigDefault, dataFormatting: dataFormattingDefault, - dataConfig: undefined, + tableConfig: undefined, queryResponse: undefined, displayType: undefined, @@ -194,8 +192,6 @@ export default class QueryOutput extends React.Component { queryValidationSelections: undefined, renderSuggestionsAsDropdown: false, selectedSuggestion: undefined, - height: undefined, - width: undefined, activeChartElementKey: undefined, enableColumnHeaderContextMenu: false, isResizing: false, @@ -213,6 +209,8 @@ export default class QueryOutput extends React.Component { onRTValueLabelClick: () => {}, onRecommendedDisplayType: () => {}, onUpdate: () => {}, + onDrilldownStart: () => {}, + onDrilldownEnd: () => {}, } componentDidMount = () => { @@ -254,28 +252,28 @@ export default class QueryOutput extends React.Component { componentDidUpdate = (prevProps, prevState) => { try { // If data config was changed by a prop, change data config here - if (!_isEqual(this.props.dataConfig, prevProps.dataConfig)) { - if (this.props.dataConfig) { - this.dataConfig = _cloneDeep(this.props.dataConfig) + if (!_isEqual(this.props.tableConfig, prevProps.tableConfig)) { + if (this.props.tableConfig) { + this.tableConfig = _cloneDeep(this.props.tableConfig) } else { - this.setColumnIndices() + this.setTableConfig() } } // If data config was changed here, tell the parent if ( - !_isEqual(this.props.dataConfig, this.dataConfig) && - this.props.onDataConfigChange + !_isEqual(this.props.tableConfig, this.tableConfig) && + this.props.onTableConfigChange ) { - this.props.onDataConfigChange(this.dataConfig) + this.props.onTableConfigChange(this.tableConfig) } // If columns changed, we need to reset the column data config if ( !_isEqual(this.props.columns, prevProps.columns) && - this.props.onDataConfigChange + this.props.onTableConfigChange ) { - this.props.onDataConfigChange({}) + this.props.onTableConfigChange({}) } if ( @@ -355,6 +353,7 @@ export default class QueryOutput extends React.Component { } componentWillUnmount = () => { + clearTimeout(this.filterDrilldownTimeout) this._isMounted = false ReactTooltip.hide() } @@ -371,6 +370,10 @@ export default class QueryOutput extends React.Component { return true } + supportsPivot = () => { + return this.supportedDisplayTypes.includes('pivot_table') + } + onRecommendedDisplayType = (recommendedDisplayType) => { this.props.onRecommendedDisplayType( recommendedDisplayType, @@ -381,28 +384,28 @@ export default class QueryOutput extends React.Component { ) } - isDataConfigValid = (dataConfig) => { + isTableConfigValid = (tableConfig) => { try { if ( - !dataConfig || - !dataConfig.numberColumnIndices || - !dataConfig.stringColumnIndices || - Number.isNaN(Number(dataConfig.numberColumnIndex)) || - Number.isNaN(Number(dataConfig.stringColumnIndex)) + !tableConfig || + !tableConfig.numberColumnIndices || + !tableConfig.stringColumnIndices || + Number.isNaN(Number(tableConfig.numberColumnIndex)) || + Number.isNaN(Number(tableConfig.stringColumnIndex)) ) { return false } if ( - !Array.isArray(dataConfig.numberColumnIndices) || - !Array.isArray(dataConfig.stringColumnIndices) + !Array.isArray(tableConfig.numberColumnIndices) || + !Array.isArray(tableConfig.stringColumnIndices) ) { return false } const columns = _get(this.queryResponse, 'data.data.columns') - const areNumberColumnsValid = dataConfig.numberColumnIndices.every( + const areNumberColumnsValid = tableConfig.numberColumnIndices.every( (index) => { return columns[index] && isColumnNumberType(columns[index]) } @@ -411,7 +414,7 @@ export default class QueryOutput extends React.Component { return false } - const areStringColumnsValid = dataConfig.stringColumnIndices.every( + const areStringColumnsValid = tableConfig.stringColumnIndices.every( (index) => { return columns[index] && isColumnStringType(columns[index]) } @@ -432,16 +435,11 @@ export default class QueryOutput extends React.Component { this.queryResponse.data.data.columns = columns } - // Reset persisted column config data - this.dataConfig = undefined - const visibleColumns = getVisibleColumns(this.queryResponse) - this.setColumnIndices(visibleColumns) - // Get new supported display types after column change - const newSupportedDisplayTypes = getSupportedDisplayTypes( - this.queryResponse - ) - this.supportedDisplayTypes = newSupportedDisplayTypes + const newSupportedDisplayTypes = getSupportedDisplayTypes({ + response: this.queryResponse, + }) + this.setSupportedDisplayTypes(newSupportedDisplayTypes) this.props.onSupportedDisplayTypesChange(this.supportedDisplayTypes) if (areAllColumnsHidden(this.queryResponse)) { @@ -450,6 +448,8 @@ export default class QueryOutput extends React.Component { return } + // Reset persisted column config data + this.tableConfig = undefined // Generate new table data from new columns if (this.shouldGenerateTableData()) { this.generateTableData() @@ -466,13 +466,6 @@ export default class QueryOutput extends React.Component { this.tableRef.ref.table.setColumns(this.tableColumns) } - // Regenerate chart data from new columns - if (this.shouldGenerateChartData()) { - this.generateChartData() - } else { - this.chartData = undefined - } - // Call update on options toolbar to show appropriate tools if (this.props.optionsToolbarRef?._isMounted) { this.props.optionsToolbarRef.forceUpdate() @@ -493,10 +486,6 @@ export default class QueryOutput extends React.Component { if (this.shouldGeneratePivotData()) { this.generatePivotData({ isFirstGeneration: true }) } - - if (this.shouldGenerateChartData()) { - this.generateChartData() - } } } } @@ -509,20 +498,13 @@ export default class QueryOutput extends React.Component { } shouldGeneratePivotData = (newTableData) => { - return ( - (newTableData || this.tableData) && - this.supportedDisplayTypes.includes('pivot_table') - ) + return (newTableData || this.tableData) && this.supportsPivot() } shouldGenerateTableData = () => { return _get(this.queryResponse, 'data.data.rows.length') } - shouldGenerateChartData = () => { - return this.supportedDisplayTypes.length > 1 - } - dateSortFn = (a, b) => { // First try to convert to number. It will sort properly if its a plain year or a unix timestamp let aDate = Number(a) @@ -633,14 +615,12 @@ export default class QueryOutput extends React.Component { generateTableData = (columns) => { this.tableColumns = columns || this.formatColumnsForTable() - let filteredResponse = this.queryResponse.data.data.rows.filter( - (row) => row[0] !== null - ) - this.tableData = this.sortTableDataByDate(filteredResponse) + this.tableData = this.sortTableDataByDate( + this.queryResponse?.data?.data?.rows + ) - // this.numberOfTableRows = _get(this.tableData, 'length', 0) - this.setColumnIndices() + this.setTableConfig() } generatePivotData = ({ isFirstGeneration, newTableData } = {}) => { @@ -790,28 +770,116 @@ export default class QueryOutput extends React.Component { } } - processCellClick = (cell) => { - if (this.state.isContextMenuOpen) { - this.setState({ isContextMenuOpen: false }) - } else { - const drilldownData = { supportedByAPI: true, data: undefined } - if (this.pivotTableColumns && this.props.displayType === 'pivot_table') { - drilldownData.data = getGroupBysFromPivotTable( - cell, - this.tableColumns, - this.pivotTableColumns, - this.pivotOriginalColumnData - ) - } else { - drilldownData.data = getGroupBysFromTable(cell, this.tableColumns) - } + getFilterDrilldown = ({ stringColumnIndex, row }) => { + const filteredRows = this.tableData?.filter((origRow) => { + return `${origRow[stringColumnIndex]}` === `${row[stringColumnIndex]}` + }) + + const drilldownResponse = _cloneDeep(this.queryResponse) + drilldownResponse.data.data.rows = filteredRows + return drilldownResponse + } - this.props.onDataClick(drilldownData, this.queryID) + processDrilldown = async ({ + groupBys, + supportedByAPI, + row, + activeKey, + stringColumnIndex, + }) => { + if (getAutoQLConfig(this.props.autoQLConfig).enableDrilldowns) { + try { + if (supportedByAPI) { + this.props.onDrilldownStart(activeKey) + try { + const response = await runDrilldown({ + ...getAuthentication( + getAuthentication(this.props.authentication) + ), + ...getAutoQLConfig(getAutoQLConfig(this.props.autoQLConfig)), + queryID: this.queryID, + groupBys, + }) + this.props.onDrilldownEnd({ response }) + } catch (error) { + this.props.onDrilldownEnd({ response: error }) + } + } else if (!isNaN(stringColumnIndex) && !!row?.length) { + this.props.onDrilldownStart(activeKey) + const response = this.getFilterDrilldown({ stringColumnIndex, row }) + this.filterDrilldownTimeout = setTimeout(() => { + this.props.onDrilldownEnd({ response }) + }, 1500) + } + } catch (error) { + console.error(error) + this.props.onDrilldownEnd({ error: 'Error processing drilldown' }) + } } } - onChartClick = ({ activeKey, drilldownData, row, column, cellIndex }) => { - this.props.onDataClick(drilldownData, this.queryID, activeKey) + onTableCellClick = (cell) => { + let groupBys = {} + if (this.pivotTableColumns && this.props.displayType === 'pivot_table') { + groupBys = getGroupBysFromPivotTable( + cell, + this.tableColumns, + this.pivotTableColumns, + this.pivotOriginalColumnData + ) + } else { + groupBys = getGroupBysFromTable(cell, this.tableColumns) + } + + this.processDrilldown({ groupBys, supportedByAPI: !!groupBys }) + } + + onChartClick = ( + row, + columnIndex, + columns, + stringColumnIndex, + legendColumn, + numberColumnIndex, + activeKey + ) => { + const drilldownData = {} + const groupBys = [] + + const stringColumn = + columns?.[stringColumnIndex]?.origColumn || columns?.[stringColumnIndex] + + if (columns?.[stringColumnIndex]?.datePivot) { + const year = Number(columns?.[columnIndex]?.name) + const month = row?.[stringColumnIndex] + const value = `${this.pivotOriginalColumnData?.[year]?.[month]}` + groupBys.push({ + name: stringColumn.name, + value, + }) + } else if (stringColumn?.groupable) { + groupBys.push({ + name: stringColumn.name, + value: `${row?.[stringColumnIndex]}`, + }) + } + + if (legendColumn?.groupable) { + groupBys.push({ + name: legendColumn.name, + value: `${columns?.[numberColumnIndex]?.name}`, + }) + } + + drilldownData.data = groupBys + drilldownData.supportedByAPI = !!groupBys.length + this.processDrilldown({ + groupBys, + supportedByAPI: !!groupBys.length, + row, + activeKey, + stringColumnIndex, + }) } toggleTableFilter = ({ isFilteringTable }) => { @@ -840,23 +908,20 @@ export default class QueryOutput extends React.Component { this.prevRows >= 0 ? this.prevRows : this.tableData?.length if (numRows !== prevRows) { - this.setSupportedDisplayTypes( - getSupportedDisplayTypes( - this.queryResponse, - undefined, - undefined, - newTableData - ) - ) + // We dont use filtered table to chart currently. + // We can enable this if we add that feature back + // this.setSupportedDisplayTypes( + // getSupportedDisplayTypes({ + // response: this.queryResponse, + // dataLength: this.tableData?.length, + // pivotDataLength: this.pivotTableData?.length, + // }) + // ) if (this.shouldGeneratePivotData(newTableData)) { this.generatePivotData({ newTableData }) } - if (this.shouldGenerateChartData()) { - this.generateChartData(newTableData) - } - if (this.props.optionsToolbarRef?._isMounted) { this.props.optionsToolbarRef.forceUpdate() } @@ -867,145 +932,163 @@ export default class QueryOutput extends React.Component { } onLegendClick = (d) => { + const columnIndex = d?.columnIndex if (this.props.displayType === 'pie') { this.onPieChartLegendClick(d) } else { - const newChartData = this.chartData.map((data) => { - const newCells = data.cells.map((cell) => { - if (cell.label === d) { - return { - ...cell, - hidden: !cell.hidden, - } - } - return cell - }) - - return { - ...data, - cells: newCells, - } - }) - - let newColumns = [] - newColumns = this.chartTableColumns.map((col) => { - if (col.title === d) { - return { - ...col, - isSeriesHidden: !col.isSeriesHidden, - } - } - return col - }) - - this.chartTableColumns = newColumns - this.chartData = newChartData + const newColumns = this.supportsPivot() + ? [...this.pivotTableColumns] + : [...this.tableColumns] + newColumns[columnIndex].isSeriesHidden = !newColumns[columnIndex] + .isSeriesHidden + + if (this.supportsPivot()) { + this.pivotTableColumns = newColumns + } else { + this.tableColumns = newColumns + } } this.forceUpdate() } onPieChartLegendClick = (d) => { - const newChartData = this.chartData.map((data) => { - if (data.label === d.label) { - return { - ...data, - hidden: !_get(data, 'hidden', false), - } - } - return data - }) - - this.chartData = newChartData + // const newChartData = this.chartData.map((data) => { + // if (data.label === d.label) { + // return { + // ...data, + // hidden: !_get(data, 'hidden', false), + // } + // } + // return data + // }) + // this.chartData = newChartData } onChangeStringColumnIndex = (index) => { - if (this.dataConfig.legendColumnIndex === index) { - this.dataConfig.legendColumnIndex = undefined - } - this.dataConfig.stringColumnIndex = index - - if (this.supportedDisplayTypes.includes('pivot_table')) { + if (this.supportsPivot()) { + if (this.pivotTableConfig.legendColumnIndex === index) { + this.pivotTableConfig.legendColumnIndex = undefined + } + this.pivotTableConfig.stringColumnIndex = index this.generatePivotTableData() + } else { + if (this.tableConfig.legendColumnIndex === index) { + this.tableConfig.legendColumnIndex = undefined + } + this.tableConfig.stringColumnIndex = index } - this.generateChartData() + this.forceUpdate() } onChangeLegendColumnIndex = (index) => { - if (this.dataConfig.stringColumnIndex === index) { - this.dataConfig.stringColumnIndex = undefined - } - this.dataConfig.legendColumnIndex = index + if (this.supportsPivot()) { + if (this.pivotTableConfig.stringColumnIndex === index) { + this.pivotTableConfig.stringColumnIndex = undefined + } + this.pivotTableConfig.legendColumnIndex = index - if (this.supportedDisplayTypes.includes('pivot_table')) { this.generatePivotTableData() + } else { + if (this.tableConfig.stringColumnIndex === index) { + this.tableConfig.stringColumnIndex = undefined + } + this.tableConfig.legendColumnIndex = index } - this.generateChartData() this.forceUpdate() } onChangeNumberColumnIndices = (indices) => { - if ( - this.supportedDisplayTypes.includes('pivot_table') && - this.pivotTableColumns - ) { - // Add "hidden" attribute to pivot table columns for charts - this.pivotTableColumns = this.pivotTableColumns.map((column, i) => { - return { - ...column, - numberColHidden: !indices.includes(i), - } - }) + if (!indices) { + return } - if (indices) { - this.dataConfig.numberColumnIndices = indices - this.dataConfig.numberColumnIndex = indices[0] - this.generateChartData() - this.forceUpdate() + if (this.supportsPivot()) { + this.pivotTableConfig.numberColumnIndices = indices + this.pivotTableConfig.numberColumnIndex = indices[0] + } else { + this.tableConfig.numberColumnIndices = indices + this.tableConfig.numberColumnIndex = indices[0] } + + this.forceUpdate() } - setColumnIndices = (chartTableColumns) => { - const columns = chartTableColumns || this.chartTableColumns + setPivotTableConfig = () => { + const columns = this.pivotTableColumns if (!columns) { return } - if (!this.dataConfig) { - this.dataConfig = {} + if (!this.pivotTableConfig) { + this.pivotTableConfig = {} } // Set string type columns (ordinal axis) - if (!this.dataConfig.stringColumnIndices) { - const { stringColumnIndices } = getStringColumnIndices( - columns, - this.supportedDisplayTypes.includes('pivot_table') + if ( + !this.pivotTableConfig.stringColumnIndices || + !(this.pivotTableConfig.stringColumnIndex >= 0) + ) { + const { stringColumnIndices, stringColumnIndex } = getStringColumnIndices( + columns ) - this.dataConfig.stringColumnIndices = stringColumnIndices + this.pivotTableConfig.stringColumnIndices = stringColumnIndices + this.pivotTableConfig.stringColumnIndex = stringColumnIndex } - if (!(this.dataConfig.stringColumnIndex >= 0)) { - const { stringColumnIndex } = getStringColumnIndices( - columns, - this.supportedDisplayTypes.includes('pivot_table') + + // Set number type columns and number series columns (linear axis) + if (!this.pivotTableConfig.numberColumnIndices) { + const { numberColumnIndex, numberColumnIndices } = getNumberColumnIndices( + columns + ) + this.pivotTableConfig.numberColumnIndices = numberColumnIndices + this.pivotTableConfig.numberColumnIndex = numberColumnIndex + } + } + + setTableConfig = () => { + const columns = this.tableColumns + + if (!columns) { + return + } + + if (!this.tableConfig) { + this.tableConfig = {} + } + + // Set string type columns (ordinal axis) + if ( + !this.tableConfig.stringColumnIndices || + !(this.tableConfig.stringColumnIndex >= 0) + ) { + const { stringColumnIndices, stringColumnIndex } = getStringColumnIndices( + columns ) - this.dataConfig.stringColumnIndex = stringColumnIndex + this.tableConfig.stringColumnIndices = stringColumnIndices + this.tableConfig.stringColumnIndex = stringColumnIndex } // Set number type columns and number series columns (linear axis) - if (!this.dataConfig.numberColumnIndices) { + if ( + !this.tableConfig.numberColumnIndices || + !(this.tableConfig.numberColumnIndex >= 0) + ) { const { numberColumnIndex, numberColumnIndices } = getNumberColumnIndices( columns ) - this.dataConfig.numberColumnIndices = numberColumnIndices - this.dataConfig.numberColumnIndex = numberColumnIndex + this.tableConfig.numberColumnIndices = numberColumnIndices + this.tableConfig.numberColumnIndex = numberColumnIndex } - //else { - // //here is bug!!! - // this.dataConfig.numberColumnIndex = this.dataConfig.numberColumnIndices[0] - // } + + // Set legend index if there should be one + let legendColumnIndex = this.tableColumns.findIndex( + (col, i) => col.groupable && i !== this.tableConfig.stringColumnIndex + ) + if (legendColumnIndex >= 0) + this.tableConfig.legendColumnIndex = legendColumnIndex } setSupportedDisplayTypes = (supportedDisplayTypes, justMounted) => { @@ -1032,15 +1115,15 @@ export default class QueryOutput extends React.Component { getTooltipDataForCell = (row, columnIndex, numberValue) => { let tooltipElement = null try { - if (this.supportedDisplayTypes.includes('pivot_table')) { + if (this.supportsPivot()) { const stringColumn = this.tableColumns[ - this.dataConfig.stringColumnIndex + this.tableConfig.stringColumnIndex ] const numberColumn = this.tableColumns[ - this.dataConfig.numberColumnIndex + this.tableConfig.numberColumnIndex ] const legendColumn = this.tableColumns[ - this.dataConfig.legendColumnIndex + this.tableConfig.legendColumnIndex ] const tooltipLine1 = `
@@ -1072,15 +1155,15 @@ export default class QueryOutput extends React.Component { ${tooltipLine3}
` } else { - const stringColumn = this.chartTableColumns[ - this.dataConfig.stringColumnIndex + const stringColumn = this.tableColumns[ + this.tableConfig.stringColumnIndex ] - const numberColumn = this.chartTableColumns[columnIndex] + const numberColumn = this.tableColumns[columnIndex] tooltipElement = `
${stringColumn.title}: ${formatElement({ - element: row[this.dataConfig.stringColumnIndex], + element: row[this.tableConfig.stringColumnIndex], column: stringColumn, config: getDataFormatting(this.props.dataFormatting), })} @@ -1105,7 +1188,7 @@ export default class QueryOutput extends React.Component { getDrilldownDataForCell = (row, columnIndex) => { const supportedByAPI = getNumberOfGroupables(this.tableColumns) > 0 - if (this.supportedDisplayTypes.includes('pivot_table')) { + if (this.supportsPivot()) { return { supportedByAPI, data: [ @@ -1116,7 +1199,7 @@ export default class QueryOutput extends React.Component { { name: _get( this.tableColumns, - `[${this.dataConfig.legendColumnIndex}].name` + `[${this.tableConfig.legendColumnIndex}].name` ), value: `${_get(this.pivotTableColumns, `[${columnIndex}].name`)}`, }, @@ -1128,10 +1211,10 @@ export default class QueryOutput extends React.Component { data: [ { name: _get( - this.chartTableColumns, - `[${this.dataConfig.stringColumnIndex}].name` + this.tableColumns, + `[${this.tableConfig.stringColumnIndex}].name` ), - value: `${_get(row, `[${this.dataConfig.stringColumnIndex}]`)}`, + value: `${_get(row, `[${this.tableConfig.stringColumnIndex}]`)}`, }, ], } @@ -1139,15 +1222,12 @@ export default class QueryOutput extends React.Component { } isStringColumnDateType = () => { - const stringColumn = this.tableColumns[this.dataConfig.stringColumnIndex] + const stringColumn = this.tableColumns[this.tableConfig.stringColumnIndex] return isColumnDateType(stringColumn) } getMultiSeriesData = (columns, tableData) => { - const { stringColumnIndex } = getStringColumnIndices( - columns, - this.supportedDisplayTypes.includes('pivot_table') - ) + const { stringColumnIndex } = getStringColumnIndices(columns) const multiSeriesIndex = getMultiSeriesColumnIndex(columns) const { numberColumnIndex } = getNumberColumnIndices(columns) @@ -1164,7 +1244,7 @@ export default class QueryOutput extends React.Component { // make column index map for adding new data in next step const addedColumnIndexes = {} - this.chartTableColumns.forEach((col, index) => { + this.tableColumns.forEach((col, index) => { if (col.seriesCategory) { addedColumnIndexes[col.seriesCategory] = index } @@ -1179,7 +1259,7 @@ export default class QueryOutput extends React.Component { if (prevCategory !== category) { // make new row with original values - const cells = makeEmptyArray(this.chartTableColumns.length, 1, 0) + const cells = makeEmptyArray(this.tableColumns.length, 1, 0) cells[0] = category newTableData.push(cells) } @@ -1197,215 +1277,10 @@ export default class QueryOutput extends React.Component { return newTableData } - getMultiSeriesColumns = (columns) => { - const multiSeriesIndex = getMultiSeriesColumnIndex(columns) - const { numberColumnIndex } = getNumberColumnIndices(columns) - const { stringColumnIndex } = getStringColumnIndices( - columns, - this.supportedDisplayTypes.includes('pivot_table') - ) - - const allCategories = this.tableData.map((d) => { - return formatElement({ - element: d[multiSeriesIndex], - column: columns[multiSeriesIndex], - config: this.props.dataFormatting, - }) - }) - - const uniqueCategories = [...allCategories].filter(onlyUnique).sort() - const newNumberColumns = [] - uniqueCategories.forEach((category) => { - newNumberColumns.push({ - ...columns[numberColumnIndex], - title: category, - seriesCategory: category, - }) - }) - - return [columns[stringColumnIndex], ...newNumberColumns] - } - - getChartTableData = (newTableData) => { - let tableData = newTableData - ? _cloneDeep(newTableData) - : _cloneDeep(this.tableData) - - if (shouldPlotMultiSeries(this.tableColumns)) { - return this.getMultiSeriesData(this.tableColumns, tableData) - } - - if (this.supportedDisplayTypes.includes('pivot_table')) { - tableData = _cloneDeep(this.pivotTableData) - } - - return tableData - } - - getChartTableColumns = () => { - if (shouldPlotMultiSeries(this.tableColumns)) { - return this.getMultiSeriesColumns(this.tableColumns) - } - - if (this.supportedDisplayTypes.includes('pivot_table')) { - return _cloneDeep(this.pivotTableColumns) - } - - return _cloneDeep(this.tableColumns) - } - - generateChartData = (newTableData) => { - // check if table data has been generated... if not, generate it - try { - // Get columns for chart then reset indexes if necessary - const columns = this.getChartTableColumns() - - this.chartTableColumns = columns - - // Get table data for chart after columns - const tableData = this.getChartTableData(newTableData) - - if (!this.dataConfig || shouldPlotMultiSeries(this.tableColumns)) { - this.setColumnIndices() - } - - let stringIndex = this.dataConfig.stringColumnIndex - - if (this.supportedDisplayTypes.includes('pivot_table')) { - stringIndex = 0 - const newNumberColumnIndices = [] - this.pivotTableColumns.forEach((col, i) => { - if (!col.numberColHidden && i !== stringIndex) { - newNumberColumnIndices.push(i) - } - }) - - this.dataConfig.numberColumnIndices = newNumberColumnIndices - } - - // Todo: this should be done on the BACKEND - if (this.isStringColumnDateType()) { - tableData.reverse() - } - - let chartData = Object.values( - tableData.reduce((chartDataObject, row, rowIndex) => { - // Loop through columns and create a series for each - const cells = [] - - let nonPivotColorScaleValue = 0 - this.dataConfig.numberColumnIndices.forEach((columnIndex, i) => { - const value = row[columnIndex] - const colorScaleValue = this.supportedDisplayTypes.includes( - 'pivot_table' - ) - ? columnIndex - : nonPivotColorScaleValue - - const drilldownData = this.getDrilldownDataForCell(row, columnIndex) - const tooltipData = this.getTooltipDataForCell( - row, - columnIndex, - value - ) - - cells.push({ - value: !Number.isNaN(Number(value)) ? Number(value) : value, // this should always be able to convert to a number - label: columns[columnIndex].title, - color: this.colorScale(colorScaleValue), - hidden: false, - drilldownData, - tooltipData, - }) - - // Fixes bug where wrong chart colors are used after column visibility change - if ( - !this.supportedDisplayTypes.includes('pivot_table') && - this.chartTableColumns[columnIndex]?.is_visible - ) { - nonPivotColorScaleValue++ - } - }) - - // Make sure the row label doesn't exist already - if (!chartDataObject[row[stringIndex]]) { - chartDataObject[row[stringIndex]] = { - origRow: row, - label: row[stringIndex], - cells, - formatter: (value, column) => { - return formatElement({ - element: value, - column, - config: getDataFormatting(this.props.dataFormatting), - }) - }, - } - } else { - // If this label already exists, just add the values together - // The BE should prevent this from happening though - chartDataObject[row[stringIndex]].cells = chartDataObject[ - row[stringIndex] - ].cells.map((cell, index) => { - const newValue = cell.value + Number(cells[index].value) - return { - ...cell, - value: newValue, - tooltipData: this.getTooltipDataForCell( - row, - this.dataConfig.numberColumnIndices[index], - newValue - ), - } - }) - } - return chartDataObject - }, {}) - ) - - this.chartData = chartData - - // Update supported display types after table data has been recalculated - // there may be too many categories for a pie chart etc. - this.setSupportedDisplayTypes( - getSupportedDisplayTypes(this.queryResponse, this.chartData) - ) - - this.chartDataError = false - } catch (error) { - if (!this.chartDataError) { - // Try one more time after resetting data config settings - console.warn(error) - console.warn( - 'The above error was thrown while generating the chart data. Resetting the data config and trying again.' - ) - this.chartDataError = true - this.dataConfig = undefined - - if (this.props.onDataConfigChange) { - this.props.onDataConfigChange(undefined) - } else { - this.setColumnIndices() - this.generateChartData() - } - } else { - // Something went wrong a second time. Do not show chart options - this.setSupportedDisplayTypes(['text']) - this.chartData = undefined - console.error(error) - } - } - } - setFilterFunction = (col) => { const self = this if (col.type === 'DATE' || col.type === 'DATE_STRING') { return (headerValue, rowValue, rowData, filterParams) => { - // headerValue - the value of the header filter element - // rowValue - the value of the column in this row - // rowData - the data for the row being filtered - // filterParams - params object passed to the headerFilterFuncParams property - try { const formattedElement = formatElement({ element: rowValue, @@ -1431,11 +1306,6 @@ export default class QueryOutput extends React.Component { col.type === 'RATIO' ) { return (headerValue, rowValue, rowData, filterParams) => { - // headerValue - the value of the header filter element - // rowValue - the value of the column in this row - // rowData - the data for the row being filtered - // filterParams - params object passed to the headerFilterFuncParams property - try { const trimmedValue = headerValue.trim() if (trimmedValue.length >= 2) { @@ -1600,15 +1470,10 @@ export default class QueryOutput extends React.Component { [MONTH_NAMES[12]]: 11, } - if (!this.dataConfig) { - // We should change this to getColumnIndices since this wont be - // the final version that we use. We dont want to persist it - this.setColumnIndices(this.tableColumns) - } - const dateColumnIndex = getDateColumnIndex(this.tableColumns) - if (!(this.dataConfig.numberColumnIndex >= 0)) { - this.dataConfig.numberColumnIndex = this.tableColumns.findIndex( + let numberColumnIndex = this.tableConfig.numberColumnIndex + if (!(numberColumnIndex >= 0)) { + numberColumnIndex = this.tableColumns.findIndex( (col, index) => index !== dateColumnIndex && isColumnNumberType(col) ) } @@ -1643,24 +1508,23 @@ export default class QueryOutput extends React.Component { field: '0', frozen: true, visible: true, + is_visible: true, + type: 'DATE_STRING', + datePivot: true, + origColumn: this.tableColumns[dateColumnIndex], }, ] Object.keys(uniqueYears).forEach((year, i) => { pivotTableColumns.push({ - ...this.tableColumns[this.dataConfig.numberColumnIndex], - origColumn: this.tableColumns[this.dataConfig.numberColumnIndex], - drilldownData: [ - { - name: this.tableColumns[dateColumnIndex].name, - value: null, - }, - ], + ...this.tableColumns[numberColumnIndex], + origColumn: this.tableColumns[numberColumnIndex], name: year, title: year, field: `${i + 1}`, headerContext: undefined, visible: true, + is_visible: true, }) }) @@ -1680,8 +1544,7 @@ export default class QueryOutput extends React.Component { const monthNumber = uniqueMonths[month] if (monthNumber && yearNumber) { - pivotTableData[monthNumber][yearNumber] = - row[this.dataConfig.numberColumnIndex] + pivotTableData[monthNumber][yearNumber] = row[numberColumnIndex] pivotOriginalColumnData[year] = { ...pivotOriginalColumnData[year], [month]: row[dateColumnIndex], @@ -1693,10 +1556,13 @@ export default class QueryOutput extends React.Component { this.pivotTableColumns = pivotTableColumns this.pivotTableData = pivotTableData this.numberOfPivotTableRows = 12 + this.setPivotTableConfig() } catch (error) { console.error(error) - this.supportedDisplayTypes = this.supportedDisplayTypes.filter( - (displayType) => displayType !== 'pivot_table' + this.setSupportedDisplayTypes( + this.supportedDisplayTypes.filter( + (displayType) => displayType !== 'pivot_table' + ) ) this.onRecommendedDisplayType('table') @@ -1706,47 +1572,26 @@ export default class QueryOutput extends React.Component { getColumnFromIndexString = (colIndexString) => { return _get( this.tableColumns, - `[${_get(this.dataConfig, `[${colIndexString}]`)}]` + `[${_get(this.tableConfig, `[${colIndexString}]`)}]` ) } generatePivotTableData = ({ isFirstGeneration, newTableData } = {}) => { try { - let tableData = newTableData || _get(this.queryResponse, 'data.data.rows') + let tableData = + newTableData || + this.tableData || + _get(this.queryResponse, 'data.data.rows') tableData = tableData.filter((row) => row[0] !== null) - if (!this.dataConfig) { - // We should change this to getColumnIndices since this wont be - // the final version that we use. We dont want to persist it - this.setColumnIndices(this.tableColumns) - } - // Set the columns used for the 2 headers (ordinal and legend for charts) - // If one of the indices is already specified, use it - let dataConfigWasPersisted = false - if (_get(this.dataConfig, 'legendColumnIndex') >= 0) { - dataConfigWasPersisted = true - this.dataConfig.stringColumnIndex = this.tableColumns.findIndex( - (col, i) => col.groupable && i !== this.dataConfig.legendColumnIndex - ) - } else if (_get(this.dataConfig, 'stringColumnIndex') >= 0) { - this.dataConfig.legendColumnIndex = this.tableColumns.findIndex( - (col, i) => col.groupable && i !== this.dataConfig.stringColumnIndex - ) - } else { - this.dataConfig.stringColumnIndex = this.tableColumns.findIndex( - (col) => col.groupable - ) - this.dataConfig.legendColumnIndex = this.tableColumns.findIndex( - (col, i) => col.groupable && i !== this.dataConfig.stringColumnIndex - ) - } - // Set the number type column - if (_get(this.dataConfig, 'numberColumnIndex') >= 0) { - this.dataConfig.numberColumnIndex = this.tableColumns.findIndex( - (col, index) => isColumnNumberType(col) && !col.groupable - ) - } + + const { + legendColumnIndex, + stringColumnIndex, + numberColumnIndex, + } = this.tableConfig + let uniqueValues0 = this.sortTableDataByDate(tableData) - .map((d) => d[this.dataConfig.stringColumnIndex]) + .map((d) => d[stringColumnIndex]) .filter(onlyUnique) .reduce((map, title, i) => { map[title] = i @@ -1754,37 +1599,38 @@ export default class QueryOutput extends React.Component { }, {}) let uniqueValues1 = this.sortTableDataByDate(tableData) - .map((d) => d[this.dataConfig.legendColumnIndex]) + .map((d) => d[legendColumnIndex]) .filter(onlyUnique) .reduce((map, title, i) => { map[title] = i return map }, {}) + let newStringColumnIndex = stringColumnIndex + let newLegendColumnIndex = legendColumnIndex // Make sure the longer list is in the legend, UNLESS its a date type // DATE types should always go in the axis if possible if ( isFirstGeneration && Object.keys(uniqueValues1).length > Object.keys(uniqueValues0).length && - !dataConfigWasPersisted && !isColumnDateType(this.getColumnFromIndexString('stringColumnIndex')) ) { - const tempCol = this.dataConfig.legendColumnIndex - this.dataConfig.legendColumnIndex = this.dataConfig.stringColumnIndex - this.dataConfig.stringColumnIndex = tempCol + newStringColumnIndex = legendColumnIndex + newLegendColumnIndex = stringColumnIndex - const tempValues = { ...uniqueValues0 } - uniqueValues0 = { ...uniqueValues1 } - uniqueValues1 = { ...tempValues } + const tempValues = _cloneDeep(uniqueValues0) + uniqueValues0 = _cloneDeep(uniqueValues1) + uniqueValues1 = _cloneDeep(tempValues) } // Generate new column array const pivotTableColumns = [ { - ...this.tableColumns[this.dataConfig.stringColumnIndex], + ...this.tableColumns[newStringColumnIndex], frozen: true, headerContext: undefined, visible: true, + is_visible: true, field: '0', }, ] @@ -1792,27 +1638,18 @@ export default class QueryOutput extends React.Component { Object.keys(uniqueValues1).forEach((columnName, i) => { const formattedColumnName = formatElement({ element: columnName, - column: this.tableColumns[this.dataConfig.legendColumnIndex], + column: this.tableColumns[newLegendColumnIndex], config: getDataFormatting(this.props.dataFormatting), }) pivotTableColumns.push({ - ...this.tableColumns[this.dataConfig.numberColumnIndex], - origColumn: this.tableColumns[this.dataConfig.numberColumnIndex], - drilldownData: [ - { - name: this.tableColumns[this.dataConfig.stringColumnIndex].name, - value: null, - }, - { - name: this.tableColumns[this.dataConfig.legendColumnIndex].name, - value: columnName, - }, - ], + ...this.tableColumns[numberColumnIndex], + origColumn: this.tableColumns[numberColumnIndex], name: columnName, title: formattedColumnName, field: `${i + 1}`, headerContext: undefined, visible: true, + is_visible: true, }) }) @@ -1823,19 +1660,27 @@ export default class QueryOutput extends React.Component { tableData.forEach((row) => { // Populate first column - pivotTableData[ - uniqueValues0[row[this.dataConfig.stringColumnIndex]] - ][0] = row[this.dataConfig.stringColumnIndex] + pivotTableData[uniqueValues0[row[newStringColumnIndex]]][0] = + row[newStringColumnIndex] // Populate remaining columns - pivotTableData[uniqueValues0[row[this.dataConfig.stringColumnIndex]]][ - uniqueValues1[row[this.dataConfig.legendColumnIndex]] + 1 - ] = row[this.dataConfig.numberColumnIndex] + pivotTableData[uniqueValues0[row[newStringColumnIndex]]][ + uniqueValues1[row[newLegendColumnIndex]] + 1 + ] = row[numberColumnIndex] }) + // Pie charts might be available if dataset is small enough + const newSupportedDisplayTypes = getSupportedDisplayTypes({ + response: this.queryResponse, + }) + if (!_isEqual(newSupportedDisplayTypes, this.supportedDisplayTypes)) { + this.setSupportedDisplayTypes(newSupportedDisplayTypes) + } + this.pivotTableColumns = pivotTableColumns this.pivotTableData = pivotTableData this.numberOfPivotTableRows = _get(this.pivotTableData, 'length', 0) + this.setPivotTableConfig() } catch (error) { console.error(error) this.props.onErrorCallback(error) @@ -1945,7 +1790,7 @@ export default class QueryOutput extends React.Component { ref={(ref) => (this.pivotTableRef = ref)} columns={this.pivotTableColumns} data={this.pivotTableData} - onCellClick={this.processCellClick} + onCellClick={this.onTableCellClick} headerFilters={this.pivotHeaderFilters} onFilterCallback={this.onTableFilter} isResizing={this.props.isResizing} @@ -1964,7 +1809,7 @@ export default class QueryOutput extends React.Component { ref={(ref) => (this.tableRef = ref)} columns={this.tableColumns} data={this.tableData} - onCellClick={this.processCellClick} + onCellClick={this.onTableCellClick} headerFilters={this.headerFilters} onFilterCallback={this.onTableFilter} isResizing={this.props.isResizing} @@ -1973,10 +1818,24 @@ export default class QueryOutput extends React.Component { } renderChart = (displayType) => { - if (!this.chartData) { + if (!this.tableData || !this.tableColumns || !this.tableConfig) { + console.error('Required table data was missing') return 'Error: There was no data supplied for this chart' } + const supportsPivot = this.supportsPivot() + if ( + supportsPivot && + (!this.pivotTableData || + !this.pivotTableColumns || + !this.pivotTableConfig) + ) { + console.error('Required pivot table data was missing') + return 'Error: There was no data supplied for this chart' + } + + const dataConfig = supportsPivot ? this.pivotTableConfig : this.tableConfig + return ( (this.chartRef = ref)} type={displayType || this.props.displayType} - data={this.chartData} - tableColumns={this.tableColumns} - columns={this.chartTableColumns} - height={this.props.height || 0} - width={this.props.width || 0} + {...dataConfig} + data={supportsPivot ? this.pivotTableData : this.tableData} + columns={supportsPivot ? this.pivotTableColumns : this.tableColumns} + isPivot={supportsPivot} isShowingInterpretation={this.state.isShowingInterpretation} dataFormatting={getDataFormatting(this.props.dataFormatting)} backgroundColor={this.props.backgroundColor} activeChartElementKey={this.props.activeChartElementKey} onLegendClick={this.onLegendClick} - dataConfig={_cloneDeep(this.dataConfig)} + legendColumn={this.tableColumns[this.tableConfig?.legendColumnIndex]} changeStringColumnIndex={this.onChangeStringColumnIndex} changeLegendColumnIndex={this.onChangeLegendColumnIndex} changeNumberColumnIndices={this.onChangeNumberColumnIndices} @@ -2055,6 +1913,27 @@ export default class QueryOutput extends React.Component { return this.renderMessage() } + renderDataLimitWarning = () => { + const numRows = this.queryResponse?.data?.data?.rows?.length + const maxRowLimit = this.queryResponse?.data?.data?.row_limit + + if (maxRowLimit && numRows === maxRowLimit) { + return ( +
+ + Try querying a smaller time-frame to ensure
+ all your data is displayed.`} + data-for={`react-autoql-query-output-tooltip-${this.COMPONENT_KEY}`} + /> +
+ ) + } + + return null + } + renderMessage = (error) => { try { if (typeof error === 'object') { @@ -2202,6 +2081,13 @@ export default class QueryOutput extends React.Component { } renderReverseTranslation = () => { + if ( + !getAutoQLConfig(this.props.autoQLConfig).enableQueryInterpretation || + !this.props.showQueryInterpretation + ) { + return null + } + return ( { + return ( +
+ {this.renderReverseTranslation()} + {this.renderDataLimitWarning()} +
+ ) + } + render = () => { return ( @@ -2228,11 +2123,15 @@ export default class QueryOutput extends React.Component { ${isTableType(this.props.displayType) ? 'table' : ''}`} > {this.renderResponse()} - {getAutoQLConfig(this.props.autoQLConfig).enableQueryInterpretation && - this.props.showQueryInterpretation - ? this.renderReverseTranslation() - : null} + {this.renderFooter()}
+ ) } diff --git a/src/components/QueryOutput/QueryOutput.scss b/src/components/QueryOutput/QueryOutput.scss index 313eca004..cfba9cb27 100644 --- a/src/components/QueryOutput/QueryOutput.scss +++ b/src/components/QueryOutput/QueryOutput.scss @@ -107,6 +107,21 @@ } } +.query-output-footer { + display: flex; + justify-content: space-between; + align-items: flex-end; + padding-top: 5px; + max-height: 150px; + width: 100%; +} + +.dashboard-data-limit-warning-icon { + color: var(--react-autoql-warning-color) !important; + margin-left: 10px; + font-size: 20px; +} + /* query validation */ .react-autoql-query-validation-selector-container { display: inline-block; @@ -272,10 +287,6 @@ } .react-autoql-reverse-translation-container { - margin-right: auto; - padding-top: 10px; - max-height: 150px; - .react-autoql-reverse-translation { color: var(--react-autoql-text-color-primary); background-color: var(--react-autoql-background-color-secondary); diff --git a/src/components/QueryOutput/columnHelpers.js b/src/components/QueryOutput/columnHelpers.js index 122868991..4510b6fcf 100644 --- a/src/components/QueryOutput/columnHelpers.js +++ b/src/components/QueryOutput/columnHelpers.js @@ -1,8 +1,21 @@ import _get from 'lodash.get' -import { isAggregation } from '../../js/Util' + +export const isAggregation = (columns) => { + try { + let isAgg = false + if (columns) { + isAgg = !!columns.find((col) => col.groupable) + } + return isAgg + } catch (error) { + console.error(error) + return false + } +} export const isColumnNumberType = (col) => { - const { type } = col + const type = col?.type + return ( type === 'DOLLAR_AMT' || type === 'QUANTITY' || @@ -12,7 +25,7 @@ export const isColumnNumberType = (col) => { } export const isColumnStringType = (col) => { - const { type } = col + const type = col?.type return type === 'STRING' || type === 'DATE_STRING' || type === 'DATE' } @@ -32,12 +45,14 @@ export const getNumberColumnIndices = (columns) => { columns.forEach((col, index) => { const { type } = col - if (type === 'DOLLAR_AMT') { - dollarAmtIndices.push(index) - } else if (type === 'QUANTITY') { - quantityIndices.push(index) - } else if (type === 'PERCENT' || type === 'RATIO') { - ratioIndices.push(index) + if (col.is_visible) { + if (type === 'DOLLAR_AMT') { + dollarAmtIndices.push(index) + } else if (type === 'QUANTITY') { + quantityIndices.push(index) + } else if (type === 'PERCENT' || type === 'RATIO') { + ratioIndices.push(index) + } } }) @@ -83,12 +98,15 @@ export const getMultiSeriesColumnIndex = (columns) => { return undefined } - return columns.findIndex((col) => col && col.multi_series === true) + return columns.findIndex( + (col) => col && col.is_visible && col.multi_series === true + ) } export const getDateColumnIndex = (columns) => { return columns.findIndex( - (col) => col.type === 'DATE' || col.type === 'DATE_STRING' + (col) => + col.is_visible && (col.type === 'DATE' || col.type === 'DATE_STRING') ) } @@ -104,7 +122,8 @@ export const getStringColumnIndices = (columns, supportsPivot) => { columns.forEach((col, index) => { if ( (isColumnStringType(col) || col.groupable) && - index !== multiSeriesIndex + index !== multiSeriesIndex && + col.is_visible ) { stringColumnIndices.push(index) } @@ -134,9 +153,9 @@ export const getColumnTypeAmounts = (columns) => { let amountOfNumberColumns = 0 columns.forEach((col) => { - if (isColumnNumberType(col)) { + if (isColumnNumberType(col) && col.is_visible) { amountOfNumberColumns += 1 - } else if (isColumnStringType(col)) { + } else if (isColumnStringType(col) && col.is_visible) { amountOfStringColumns += 1 } }) diff --git a/src/components/ReverseTranslation/ReverseTranslation.js b/src/components/ReverseTranslation/ReverseTranslation.js index 569df6074..8f568465b 100644 --- a/src/components/ReverseTranslation/ReverseTranslation.js +++ b/src/components/ReverseTranslation/ReverseTranslation.js @@ -15,7 +15,7 @@ import { getAuthentication, } from '../../props/defaults' -import { fetchValueLabelAutocomplete } from '../../js/queryService' +import { fetchVLAutocomplete } from '../../js/queryService' import { setCSSVars } from '../../js/Util' import ErrorBoundary from '../../containers/ErrorHOC/ErrorHOC' @@ -86,7 +86,7 @@ export default class ReverseTranslation extends React.Component { this.reverseTranslationArray.forEach((chunk, i) => { if (chunk.c_type === 'VALUE_LABEL') { valueLabelValidationPromises.push( - fetchValueLabelAutocomplete({ + fetchVLAutocomplete({ suggestion: chunk.eng, ...getAuthentication(this.props.authentication), }).then((response) => { diff --git a/src/components/Spinner/Spinner.scss b/src/components/Spinner/Spinner.scss index ab05d951e..418690aa0 100644 --- a/src/components/Spinner/Spinner.scss +++ b/src/components/Spinner/Spinner.scss @@ -5,14 +5,15 @@ margin-right: 6px; } .spinner-loader:after { - content: " "; + content: ' '; display: block; width: 16px; height: 16px; margin: 0px; border-radius: 50%; - border: 1px solid currentColor; - border-color: currentColor transparent currentColor transparent; + border: 1px solid var(--react-autoql-accent-color); + border-color: var(--react-autoql-accent-color) transparent + var(--react-autoql-accent-color) transparent; animation: spinner-loader 1.2s linear infinite; } diff --git a/src/index.js b/src/index.js index 0a3f789d9..de1be53e0 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,7 @@ export * from './components/QueryInput' export * from './components/Dashboard' export * from './components/QueryOutput' export * from './components/SpeechToTextButton' -export * from './components/ConditionLockMenu' +export * from './components/FilterLockPopover' export * from './components/ReverseTranslation' export { NotificationIcon, diff --git a/src/js/Util.js b/src/js/Util.js index c17affa95..b2e860778 100644 --- a/src/js/Util.js +++ b/src/js/Util.js @@ -8,6 +8,7 @@ import { LIGHT_THEME, DARK_THEME } from './Themes' import { getColumnTypeAmounts, shouldPlotMultiSeries, + isAggregation, } from '../components/QueryOutput/columnHelpers' export const onlyUnique = (value, index, self) => { @@ -134,7 +135,11 @@ export const formatChartLabel = ({ d, col = {}, config = {} }) => { } if (!col || !col.type) { - return d + return { + fullWidthLabel: d, + formattedLabel: d, + isTruncated: false, + } } const { currencyCode, languageCode } = config @@ -341,6 +346,19 @@ export const getPNGBase64 = (svgElement) => { } } +export const getBBoxFromRef = (ref) => { + let bbox + try { + if (ref) { + bbox = ref.getBBox() + } + } catch (error) { + console.error(error) + } + + return bbox +} + /** * converts an svg string to base64 png using the domUrl * @param {string} svgElement the svgElement @@ -474,12 +492,11 @@ export const areAllColumnsHidden = (response) => { return hasColumns && !visibleColumns.length } -export const getSupportedDisplayTypes = ( +export const getSupportedDisplayTypes = ({ response, - chartData, shouldExcludePieChart, - newTableData -) => { + dataLength, +} = {}) => { try { if (!_get(response, 'data.data.display_type')) { return [] @@ -506,7 +523,8 @@ export const getSupportedDisplayTypes = ( return ['single-value'] } - const isTableEmpty = !!newTableData && !newTableData.length + const numRows = dataLength || rows.length + const isTableEmpty = dataLength === 0 if (supportsRegularPivotTable(columns) && !isTableEmpty) { // The only case where 3D charts are supported (ie. heatmap, bubble, etc.) let supportedDisplayTypes = ['table'] @@ -534,7 +552,7 @@ export const getSupportedDisplayTypes = ( // column, we should be able to chart anything const supportedDisplayTypes = ['table', 'column', 'bar', 'line'] - if (supportsPieChart(columns, chartData) && !shouldExcludePieChart) { + if (numRows <= 10 && !shouldExcludePieChart) { supportedDisplayTypes.push('pie') } @@ -581,7 +599,7 @@ export const getSupportedDisplayTypes = ( } export const isDisplayTypeValid = (response, displayType) => { - const supportedDisplayTypes = getSupportedDisplayTypes(response) + const supportedDisplayTypes = getSupportedDisplayTypes({ response }) const isValid = displayType && supportedDisplayTypes.includes(displayType) if (!isValid) { console.warn( @@ -604,7 +622,7 @@ export const getFirstChartDisplayType = (supportedDisplayTypes, fallback) => { } export const getDefaultDisplayType = (response, defaultToChart) => { - const supportedDisplayTypes = getSupportedDisplayTypes(response) + const supportedDisplayTypes = getSupportedDisplayTypes({ response }) const responseDisplayType = _get(response, 'data.data.display_type') // If the display type is a recognized non-chart or non-table type @@ -697,17 +715,25 @@ export const nameValueObject = (name, value) => { } } -export const isAggregation = (columns) => { - try { - let isAgg = false - if (columns) { - isAgg = !!columns.find((col) => col.groupable) - } - return isAgg - } catch (error) { - console.error(error) - return false +export const getGroupBys = (row, columns) => { + if (!columns?.length) { + return undefined + } + + const groupableColumns = getGroupableColumns(columns) + const numGroupables = groupableColumns.length + if (!numGroupables) { + return { groupBys: undefined, supportedByAPI: false } } + + const groupBys = [] + groupableColumns.forEach((colIndex) => { + const groupByName = columns[colIndex].name + const groupByValue = `${row[colIndex]}` + groupBys.push(nameValueObject(groupByName, groupByValue)) + }) + + return { groupBys, supportedByAPI: true } } export const getGroupBysFromTable = (cell, tableColumns) => { @@ -733,85 +759,6 @@ export const getGroupBysFromTable = (cell, tableColumns) => { return groupByArray } -export const getObjSize = (obj) => { - if (typeof obj !== 'object') { - return undefined - } - - return Object.keys(obj).length -} - -export const getMaxValueFromKeyValueObj = (obj) => { - const size = getObjSize(obj) - - let maxValue = 0 - if (size === 1) { - maxValue = obj[Object.keys(obj)[0]] - } else if (size > 1) { - const numberValues = [...Object.values(obj)].filter((value) => { - return !Number.isNaN(Number(value)) - }) - maxValue = Math.max(...numberValues) - } - return maxValue -} - -export const getMinValueFromKeyValueObj = (obj) => { - const size = getObjSize(obj) - - let minValue = 0 - if (size === 1) { - minValue = obj[Object.keys(obj)[0]] - } else if (size > 1) { - const numberValues = [...Object.values(obj)].filter((value) => { - return !Number.isNaN(Number(value)) - }) - minValue = Math.min(...numberValues) - } - return minValue -} - -export const calculateMinAndMaxSums = (data) => { - const positiveSumsObject = {} - const negativeSumsObject = {} - - // Loop through data array to get maximum and minimum sums of postive and negative values - // These will be used to get the max and min values for the x Scale (data values) - data.forEach((d) => { - const label = d.label - d.cells.forEach((cell) => { - const cellConvertedToNumber = Number(cell.value) - const value = !Number.isNaN(cellConvertedToNumber) - ? cellConvertedToNumber - : 0 - if (value >= 0) { - // Calculate positive sum - if (positiveSumsObject[label]) { - positiveSumsObject[label] += value - } else { - positiveSumsObject[label] = value - } - } else if (value < 0) { - // Calculate negative sum - if (negativeSumsObject[label]) { - negativeSumsObject[label] -= value - } else { - negativeSumsObject[label] = value - } - } - }) - }) - - // Get max and min sums from those sum objects - const maxValue = getMaxValueFromKeyValueObj(positiveSumsObject) - const minValue = getMinValueFromKeyValueObj(negativeSumsObject) - - return { - maxValue, - minValue, - } -} - export const getChartLabelTextWidthInPx = (text) => { try { const tempDiv = document.createElement('DIV') @@ -832,7 +779,14 @@ export const getChartLabelTextWidthInPx = (text) => { } export const getLongestLabelInPx = (labels, col, config) => { - let max = getChartLabelTextWidthInPx(labels[0]) + if (!labels?.length || !col) { + return 0 + } + + let max = getChartLabelTextWidthInPx( + formatChartLabel({ d: labels[0], col, config }) + ) + labels.forEach((label) => { const formattedLabel = formatChartLabel({ d: label, col, config }) .formattedLabel @@ -952,30 +906,6 @@ export const getQueryParams = (url) => { } } -export const filterDataForDrilldown = (response, drilldownData) => { - const drilldownDataObject = drilldownData[0] - const clickedColumnIndex = _get(response, 'data.data.columns', []).findIndex( - (col) => col.name === drilldownDataObject.name - ) - - const filteredRows = _get(response, 'data.data.rows', []).filter((row) => { - return `${row[clickedColumnIndex]}` === `${drilldownDataObject.value}` - }) - - const newResponseData = { - ...response, - data: { - ...response.data, - data: { - ...response.data.data, - rows: filteredRows, - }, - }, - } - - return newResponseData -} - export const getPadding = (element) => { const padding = { left: 0, right: 0, top: 0, bottom: 0 } try { diff --git a/src/js/Util.test.js b/src/js/Util.test.js index c31f322a1..684c013d0 100644 --- a/src/js/Util.test.js +++ b/src/js/Util.test.js @@ -17,18 +17,21 @@ import { isDisplayTypeValid, getDefaultDisplayType, nameValueObject, - isAggregation, - getObjSize, - getMaxValueFromKeyValueObj, - getMinValueFromKeyValueObj, } from './Util' import { + isAggregation, getColumnTypeAmounts, isColumnStringType, isColumnNumberType, } from '../components/QueryOutput/columnHelpers' +import { + getObjSize, + getMaxValueFromKeyValueObj, + getMinValueFromKeyValueObj, +} from '../components/Charts/helpers' + import responseTestCases from '../../test/responseTestCases' const sampleListResponse = { @@ -488,7 +491,7 @@ describe('getSupportedDisplayTypes', () => { }, } - expect(getSupportedDisplayTypes(response)).toEqual(['table']) + expect(getSupportedDisplayTypes({ response })).toEqual(['table']) }) test('supports 2d charts', () => { @@ -509,7 +512,7 @@ describe('getSupportedDisplayTypes', () => { }, } - expect(getSupportedDisplayTypes(response)).toEqual([ + expect(getSupportedDisplayTypes({ response })).toEqual([ 'table', 'column', 'bar', @@ -519,7 +522,9 @@ describe('getSupportedDisplayTypes', () => { }) test('supports 3d charts', () => { - expect(getSupportedDisplayTypes(sampleDoubleGroupableResponse)).toEqual([ + expect( + getSupportedDisplayTypes({ response: sampleDoubleGroupableResponse }) + ).toEqual([ 'table', 'pivot_table', 'stacked_column', @@ -657,7 +662,6 @@ describe('getMinValueFromKeyValueObj', () => { }) }) -// export const calculateMinAndMaxSums = data // export const getChartLabelTextWidthInPx = text // export const getLongestLabelInPx = (labels, col, config) // export const getTickWidth = (scale, innerPadding) @@ -665,7 +669,6 @@ describe('getMinValueFromKeyValueObj', () => { // export const setStyleVars = ({ themeStyles, prefix }) // export const getQueryParams = url // export const getNumberColumnIndices = columns -// export const filterDataForDrilldown = (response, drilldownData) // export const getPadding = element // export const capitalizeFirstChar = string // export const isSingleValueResponse = response diff --git a/src/js/dayjsWithPlugins.js b/src/js/dayjsWithPlugins.js index e31746d98..a757bea72 100644 --- a/src/js/dayjsWithPlugins.js +++ b/src/js/dayjsWithPlugins.js @@ -14,11 +14,11 @@ dayjs.extend(LocalizedFormat) try { // Check if file exists, then use it - require(`dayjs/locale/${specificLanguageCode}`) + require(`dayjs/locale/${specificLanguageCode}.js`) dayjs.locale(specificLanguageCode) } catch (error) { try { - require(`dayjs/locale/${genericLanguageCode}`) + require(`dayjs/locale/${genericLanguageCode}.js`) dayjs.locale(genericLanguageCode) } catch (error) {} } diff --git a/src/js/queryService.js b/src/js/queryService.js index 07d243a35..985587772 100644 --- a/src/js/queryService.js +++ b/src/js/queryService.js @@ -74,7 +74,6 @@ export const fetchSuggestions = ({ export const runQueryOnly = ({ query, - projectID, userSelection, debug, test, @@ -82,28 +81,23 @@ export const runQueryOnly = ({ apiKey, token, source, - AutoAEId, + filters, } = {}) => { const url = `${domain}/autoql/api/v1/query?key=${apiKey}` const finalUserSelection = transformUserSelection(userSelection) - const sessionConditions = JSON.parse(sessionStorage.getItem('conditions')) - let conditions = {} - if (sessionConditions !== null) { - for (let i = 0; i < sessionConditions.length; i++) { - if ( - Object.keys(conditions).some( - (item) => item === sessionConditions[i].key - ) - ) { - var item = Object.keys(conditions).find( - (key) => key === sessionConditions[i].key - ) - conditions[item].push(sessionConditions[i].value) - } else { - conditions[sessionConditions[i].key] = [sessionConditions[i].value] - } + let formattedFilters + try { + if (filters?.length) { + formattedFilters = {} + filters.forEach((filter) => { + const prevFilterValues = formattedFilters[filter.key] || [] + formattedFilters[filter.key] = [...prevFilterValues, filter.value] + }) } + } catch (error) { + console.error(error) + formattedFilters = undefined } const data = { @@ -112,14 +106,16 @@ export const runQueryOnly = ({ translation: debug ? 'include' : 'exclude', user_selection: finalUserSelection, test, - session_locked_conditions: conditions, + session_locked_conditions: formattedFilters, } if (!query || !query.trim()) { + console.error('No query supplied in request') return Promise.reject({ error: 'No query supplied' }) } if (!apiKey || !domain || !token) { + console.error('authentication invalid for request') return Promise.reject({ error: 'Unauthenticated' }) } @@ -145,6 +141,7 @@ export const runQueryOnly = ({ return Promise.resolve(response) }) .catch((error) => { + console.error(error) if (error.message === 'Parse error') { return Promise.reject({ error: 'Parse error' }) } @@ -165,75 +162,27 @@ export const runQueryOnly = ({ }) } -export const runQuery = ({ - query, - projectID, - userSelection, - debug, - test, - enableQueryValidation, - domain, - apiKey, - token, - source, - skipQueryValidation, - AutoAEId, -} = {}) => { - // Temp for demo: decode token to get project id - let id - let base64Url - if (token) { - base64Url = token.split('.')[1] - //bass64Url was failing unit tests due to undefined. - if (base64Url) { - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') - const buff = Buffer.from(base64, 'base64') - const payloadinit = buff.toString('ascii') - const payload = JSON.parse(payloadinit) - id = _get(payload, 'project_id') - } - } - - if (enableQueryValidation && !skipQueryValidation) { +export const runQuery = (params) => { + if (params?.enableQueryValidation && !params?.skipQueryValidation) { return runQueryValidation({ - text: query, - domain, - apiKey, - token, + text: params?.query, + domain: params?.domain, + apiKey: params?.apiKey, + token: params?.token, }) .then((response) => { if (failedValidation(response)) { return Promise.resolve(response) } - return runQueryOnly({ - query, - userSelection, - debug, - test, - domain, - apiKey, - token, - source, - AutoAEId, - }) + return runQueryOnly(params) }) .catch((error) => { + console.error(error) return Promise.reject(error) }) } - return runQueryOnly({ - query, - projectID, - userSelection, - debug, - test, - token, - domain, - apiKey, - source, - AutoAEId, - }) + return runQueryOnly(params) } export const exportCSV = ({ @@ -296,7 +245,7 @@ export const runQueryValidation = ({ text, domain, apiKey, token } = {}) => { export const runDrilldown = ({ queryID, - data, + groupBys, debug, test, domain, @@ -313,7 +262,7 @@ export const runDrilldown = ({ const requestData = { translation: debug ? 'include' : 'exclude', - columns: data, + columns: groupBys, test, } @@ -381,7 +330,7 @@ export const fetchAutocomplete = ({ .catch((error) => Promise.reject(_get(error, 'response.data'))) } -export const fetchValueLabelAutocomplete = ({ +export const fetchVLAutocomplete = ({ suggestion, domain, token, @@ -411,12 +360,12 @@ export const fetchValueLabelAutocomplete = ({ .catch((error) => Promise.reject(_get(error, 'response.data'))) } -export const fetchConditions = ({ apiKey, token, domain } = {}) => { +export const fetchFilters = ({ apiKey, token, domain } = {}) => { if (!domain || !apiKey || !token) { return Promise.reject(new Error('Unauthenticated')) } - const url = `${domain}/autoql/api/v1/query/condition-locking?key=${apiKey}` + const url = `${domain}/autoql/api/v1/query/filter-locking?key=${apiKey}` const config = { headers: { @@ -430,12 +379,16 @@ export const fetchConditions = ({ apiKey, token, domain } = {}) => { .catch((error) => Promise.reject(_get(error, 'response.data'))) } -export const setConditions = ({ apiKey, token, domain, conditions } = {}) => { +export const setFilters = ({ apiKey, token, domain, filters } = {}) => { if (!domain || !apiKey || !token) { return Promise.reject(new Error('Unauthenticated')) } - const url = `${domain}/autoql/api/v1/query/condition-locking?key=${apiKey}` + if (!filters?.length) { + return Promise.reject(new Error('No filters provided')) + } + + const url = `${domain}/autoql/api/v1/query/filter-locking?key=${apiKey}` const config = { headers: { @@ -443,21 +396,12 @@ export const setConditions = ({ apiKey, token, domain, conditions } = {}) => { }, } - // discard id of existing conditions before sending. - let array = [] - conditions.forEach((obj) => { - array.push({ - key: obj.key, - keyword: obj.keyword, - lock_flag: obj.lock_flag, - show_message: obj.show_message, - value: obj.value, - }) + // discard id of existing filters before sending. + const formattedFilters = filters.map((filter) => { + return { ...filter, id: undefined } }) - const data = { - columns: array, - } + const data = { columns: formattedFilters } return axios .put(url, data, config) @@ -465,12 +409,12 @@ export const setConditions = ({ apiKey, token, domain, conditions } = {}) => { .catch((error) => Promise.reject(_get(error, 'response.data'))) } -export const unsetCondition = ({ apiKey, token, domain, condition } = {}) => { +export const unsetFilterFromAPI = ({ apiKey, token, domain, filter } = {}) => { if (!domain || !apiKey || !token) { return Promise.reject(new Error('Unauthenticated')) } - const url = `${domain}/autoql/api/v1/query/condition-locking/${condition.id}?key=${apiKey}` + const url = `${domain}/autoql/api/v1/query/filter-locking/${filter.id}?key=${apiKey}` const config = { headers: {