Skip to content

Commit

Permalink
Migrate Funnel visualization to React (#4267)
Browse files Browse the repository at this point in the history
* Migrate Funnel visualization to React: Editor

* Migrate Funnel visualization to React: Renderer

* Replace Auto sort options with Sort Column + Reverse Order

* Add option for items limit (instead of hard-coded value)

* Add number formatting options

* Replace d3.max with lodash.maxBy; fix bug in prepareData

* Add options for min/max percent values

* Debounce inputs

* Tests

* Refine Renderer: split components, use Ant Table for rendering, fix data handling

* Extract utility function to own file

* Fix tests

* Fix: sometimes after updating options, funnel shows "ghost" rows from previous dataset

* Sort by value column by default
  • Loading branch information
kravets-levko authored and arikfr committed Nov 14, 2019
1 parent 1a95904 commit b44fa51
Show file tree
Hide file tree
Showing 13 changed files with 638 additions and 298 deletions.
120 changes: 120 additions & 0 deletions client/app/visualizations/funnel/Editor/AppearanceSettings.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React from 'react';
import { useDebouncedCallback } from 'use-debounce';
import Input from 'antd/lib/input';
import InputNumber from 'antd/lib/input-number';
import Popover from 'antd/lib/popover';
import Icon from 'antd/lib/icon';
import * as Grid from 'antd/lib/grid';
import { EditorPropTypes } from '@/visualizations';

export default function AppearanceSettings({ options, onOptionsChange }) {
const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);

return (
<React.Fragment>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-number-format">
Number Values Format
<Popover
content={(
<React.Fragment>
Format&nbsp;
<a href="https://redash.io/help/user-guide/visualizations/formatting-numbers" target="_blank" rel="noopener noreferrer">specs.</a>
</React.Fragment>
)}
>
<Icon className="m-l-5" type="question-circle" theme="filled" />
</Popover>
</label>
</Grid.Col>
<Grid.Col span={12}>
<Input
id="funnel-editor-step-column-title"
className="w-100"
data-test="Funnel.NumberFormat"
defaultValue={options.numberFormat}
onChange={event => onOptionsChangeDebounced({ numberFormat: event.target.value })}
/>
</Grid.Col>
</Grid.Row>

<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-number-format">
Percent Values Format
<Popover
content={(
<React.Fragment>
Format&nbsp;
<a href="https://redash.io/help/user-guide/visualizations/formatting-numbers" target="_blank" rel="noopener noreferrer">specs.</a>
</React.Fragment>
)}
>
<Icon className="m-l-5" type="question-circle" theme="filled" />
</Popover>
</label>
</Grid.Col>
<Grid.Col span={12}>
<Input
id="funnel-editor-step-column-title"
className="w-100"
data-test="Funnel.PercentFormat"
defaultValue={options.percentFormat}
onChange={event => onOptionsChangeDebounced({ percentFormat: event.target.value })}
/>
</Grid.Col>
</Grid.Row>

<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-items-limit">Items Count Limit</label>
</Grid.Col>
<Grid.Col span={12}>
<InputNumber
id="funnel-editor-items-limit"
className="w-100"
data-test="Funnel.ItemsLimit"
min={2}
defaultValue={options.itemsLimit}
onChange={itemsLimit => onOptionsChangeDebounced({ itemsLimit })}
/>
</Grid.Col>
</Grid.Row>

<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-percent-values-range-min">Min Percent Value</label>
</Grid.Col>
<Grid.Col span={12}>
<InputNumber
id="funnel-editor-percent-values-range-min"
className="w-100"
data-test="Funnel.PercentRangeMin"
min={0}
defaultValue={options.percentValuesRange.min}
onChange={min => onOptionsChangeDebounced({ percentValuesRange: { min } })}
/>
</Grid.Col>
</Grid.Row>

<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-percent-values-range-max">Max Percent Value</label>
</Grid.Col>
<Grid.Col span={12}>
<InputNumber
id="funnel-editor-percent-values-range-max"
className="w-100"
data-test="Funnel.PercentRangeMax"
min={0}
defaultValue={options.percentValuesRange.max}
onChange={max => onOptionsChangeDebounced({ percentValuesRange: { max } })}
/>
</Grid.Col>
</Grid.Row>
</React.Fragment>
);
}

AppearanceSettings.propTypes = EditorPropTypes;
147 changes: 147 additions & 0 deletions client/app/visualizations/funnel/Editor/GeneralSettings.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { map } from 'lodash';
import React, { useMemo } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import Select from 'antd/lib/select';
import Input from 'antd/lib/input';
import Checkbox from 'antd/lib/checkbox';
import * as Grid from 'antd/lib/grid';
import { EditorPropTypes } from '@/visualizations';

export default function GeneralSettings({ options, data, onOptionsChange }) {
const columnNames = useMemo(() => map(data.columns, c => c.name), [data]);

const [onOptionsChangeDebounced] = useDebouncedCallback(onOptionsChange, 200);

return (
<React.Fragment>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-step-column-name">Step Column</label>
</Grid.Col>
<Grid.Col span={12}>
<Select
id="funnel-editor-step-column-name"
className="w-100"
data-test="Funnel.StepColumn"
placeholder="Choose column..."
defaultValue={options.stepCol.colName || undefined}
onChange={colName => onOptionsChange({ stepCol: { colName: colName || null } })}
>
{map(columnNames, col => (
<Select.Option key={col} data-test={`Funnel.StepColumn.${col}`}>{col}</Select.Option>
))}
</Select>
</Grid.Col>
</Grid.Row>

<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-step-column-title">Step Column Title</label>
</Grid.Col>
<Grid.Col span={12}>
<Input
id="funnel-editor-step-column-title"
className="w-100"
data-test="Funnel.StepColumnTitle"
defaultValue={options.stepCol.displayAs}
onChange={event => onOptionsChangeDebounced({ stepCol: { displayAs: event.target.value } })}
/>
</Grid.Col>
</Grid.Row>

<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-value-column-name">Value Column</label>
</Grid.Col>
<Grid.Col span={12}>
<Select
id="funnel-editor-value-column-name"
className="w-100"
data-test="Funnel.ValueColumn"
placeholder="Choose column..."
defaultValue={options.valueCol.colName || undefined}
onChange={colName => onOptionsChange({ valueCol: { colName: colName || null } })}
>
{map(columnNames, col => (
<Select.Option key={col} data-test={`Funnel.ValueColumn.${col}`}>{col}</Select.Option>
))}
</Select>
</Grid.Col>
</Grid.Row>

<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-value-column-title">Value Column Title</label>
</Grid.Col>
<Grid.Col span={12}>
<Input
id="funnel-editor-value-column-title"
className="w-100"
data-test="Funnel.ValueColumnTitle"
defaultValue={options.valueCol.displayAs}
onChange={event => onOptionsChangeDebounced({ valueCol: { displayAs: event.target.value } })}
/>
</Grid.Col>
</Grid.Row>


<div className="m-b-15">
<label htmlFor="funnel-editor-custom-sort">
<Checkbox
id="funnel-editor-custom-sort"
data-test="Funnel.CustomSort"
checked={!options.autoSort}
onChange={event => onOptionsChange({ autoSort: !event.target.checked })}
/>
<span>Custom Sorting</span>
</label>
</div>

{!options.autoSort && (
<React.Fragment>
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-sort-column-name">Sort Column</label>
</Grid.Col>
<Grid.Col span={12}>
<Select
id="funnel-editor-sort-column-name"
className="w-100"
data-test="Funnel.SortColumn"
allowClear
placeholder="Choose column..."
defaultValue={options.sortKeyCol.colName || undefined}
onChange={colName => onOptionsChange({ sortKeyCol: { colName: colName || null } })}
>
{map(columnNames, col => (
<Select.Option key={col} data-test={`Funnel.SortColumn.${col}`}>{col}</Select.Option>
))}
</Select>
</Grid.Col>
</Grid.Row>

<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor="funnel-editor-sort-reverse">Sort Order</label>
</Grid.Col>
<Grid.Col span={12}>
<Select
id="funnel-editor-sort-reverse"
className="w-100"
data-test="Funnel.SortDirection"
disabled={!options.sortKeyCol.colName}
defaultValue={options.sortKeyCol.reverse ? 'desc' : 'asc'}
onChange={order => onOptionsChange({ sortKeyCol: { reverse: order === 'desc' } })}
>
<Select.Option value="asc" data-test="Funnel.SortDirection.Ascending">ascending</Select.Option>
<Select.Option value="desc" data-test="Funnel.SortDirection.Descending">descending</Select.Option>
</Select>
</Grid.Col>
</Grid.Row>
</React.Fragment>
)}
</React.Fragment>
);
}

GeneralSettings.propTypes = EditorPropTypes;
28 changes: 28 additions & 0 deletions client/app/visualizations/funnel/Editor/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { merge } from 'lodash';
import React from 'react';
import Tabs from 'antd/lib/tabs';
import { EditorPropTypes } from '@/visualizations';

import GeneralSettings from './GeneralSettings';
import AppearanceSettings from './AppearanceSettings';

export default function Editor(props) {
const { options, onOptionsChange } = props;

const optionsChanged = (newOptions) => {
onOptionsChange(merge({}, options, newOptions));
};

return (
<Tabs animated={false} tabBarGutter={0}>
<Tabs.TabPane key="general" tab={<span data-test="Funnel.EditorTabs.General">General</span>}>
<GeneralSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
<Tabs.TabPane key="appearance" tab={<span data-test="Funnel.EditorTabs.Appearance">Appearance</span>}>
<AppearanceSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
</Tabs>
);
}

Editor.propTypes = EditorPropTypes;
33 changes: 33 additions & 0 deletions client/app/visualizations/funnel/Renderer/FunnelBar.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';

import './funnel-bar.less';

export default function FunnelBar({ color, value, align, className, children }) {
return (
<div className={cx('funnel-bar', `funnel-bar-${align}`, className)}>
<div
className="funnel-bar-value"
style={{ backgroundColor: color, width: value + '%' }}
/>
<div className="funnel-bar-label">{children}</div>
</div>
);
}

FunnelBar.propTypes = {
color: PropTypes.string,
value: PropTypes.number,
align: PropTypes.oneOf(['left', 'center', 'right']),
className: PropTypes.string,
children: PropTypes.node,
};

FunnelBar.defaultProps = {
color: '#dadada',
value: 0.0,
align: 'left',
className: null,
children: null,
};
32 changes: 32 additions & 0 deletions client/app/visualizations/funnel/Renderer/funnel-bar.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.funnel-bar {
@height: 30px;

position: relative;
height: @height;
line-height: @height;

&-left {
text-align: left;
}
&-center {
text-align: center;
}
&-right {
text-align: right;
}

.funnel-bar-value {
display: inline-block;
vertical-align: top;
height: @height;
}

.funnel-bar-label {
display: inline-block;
text-align: center;
vertical-align: middle;
position: absolute;
left: 0;
right: 0;
}
}
Loading

0 comments on commit b44fa51

Please sign in to comment.