Migrate Funnel visualization to React (#4267)

* 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
This commit is contained in:
Levko Kravets 2019-11-14 15:47:17 +02:00 committed by Arik Fraimovich
parent 1a95904ffd
commit b44fa51829
13 changed files with 638 additions and 298 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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,
};

View File

@ -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;
}
}

View File

@ -0,0 +1,99 @@
import { maxBy } from 'lodash';
import React, { useMemo } from 'react';
import Table from 'antd/lib/table';
import Tooltip from 'antd/lib/tooltip';
import { RendererPropTypes } from '@/visualizations';
import ColorPalette from '@/visualizations/ColorPalette';
import { createNumberFormatter } from '@/lib/value-format';
import prepareData from './prepareData';
import FunnelBar from './FunnelBar';
import './index.less';
function generateRowKeyPrefix() {
return Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER).toString(36) + ':';
}
export default function Renderer({ data, options }) {
const funnelData = useMemo(() => prepareData(data.rows, options), [data, options]);
const rowKeyPrefix = useMemo(() => generateRowKeyPrefix(), [funnelData]);
const formatValue = useMemo(() => createNumberFormatter(options.numberFormat), [options.numberFormat]);
const formatPercentValue = useMemo(() => {
const format = createNumberFormatter(options.percentFormat);
return (value) => {
if (value < options.percentValuesRange.min) {
return `<${format(options.percentValuesRange.min)}`;
}
if (value > options.percentValuesRange.max) {
return `>${format(options.percentValuesRange.max)}`;
}
return format(value);
};
}, [options.percentFormat, options.percentValuesRange]);
const columns = useMemo(() => {
if (funnelData.length === 0) {
return [];
}
const maxToPrevious = maxBy(funnelData, d => (isFinite(d.pctPrevious) ? d.pctPrevious : 0)).pctPrevious;
return [
{
title: options.stepCol.displayAs,
dataIndex: 'step',
width: '25%',
className: 'text-ellipsis',
render: text => <Tooltip title={text} mouseEnterDelay={0} mouseLeaveDelay={0}>{text}</Tooltip>,
},
{
title: options.valueCol.displayAs,
dataIndex: 'value',
width: '45%',
align: 'center',
render: (value, item) => (
<FunnelBar align="center" color={ColorPalette.Cyan} value={item.pctMax}>{formatValue(value)}</FunnelBar>
),
},
{
title: '% Max',
dataIndex: 'pctMax',
width: '15%',
align: 'center',
render: value => formatPercentValue(value),
},
{
title: '% Previous',
dataIndex: 'pctPrevious',
width: '15%',
align: 'center',
render: value => (
<FunnelBar className="funnel-percent-column" value={(value / maxToPrevious) * 100.0}>
{formatPercentValue(value)}
</FunnelBar>
),
},
];
}, [
options.stepCol.displayAs, options.valueCol.displayAs,
funnelData, formatValue, formatPercentValue,
]);
if (funnelData.length === 0) {
return null;
}
return (
<div className="funnel-visualization-container">
<Table
columns={columns}
dataSource={funnelData}
rowKey={(record, index) => rowKeyPrefix + index}
pagination={false}
/>
</div>
);
}
Renderer.propTypes = RendererPropTypes;

View File

@ -1,10 +1,17 @@
.funnel-visualization-container { .funnel-visualization-container {
table { table {
min-width: 450px; min-width: 450px;
} table-layout: fixed;
.table-borderless td, .table-borderless th {
border: 0; tbody tr td {
vertical-align: middle; border: none;
&.text-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
} }
.step { .step {
max-width: 0; max-width: 0;
@ -28,22 +35,4 @@
.step:hover .step-name { .step:hover .step-name {
visibility: visible; visibility: visible;
} }
div.bar {
height: 30px;
}
div.bar.centered {
margin: auto;
}
.value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.container {
position: relative;
padding: 0;
text-align: center;
}
} }

View File

@ -0,0 +1,28 @@
import { map, maxBy, sortBy } from 'lodash';
export default function prepareData(rows, options) {
if ((rows.length === 0) || !options.stepCol.colName || !options.valueCol.colName) {
return [];
}
rows = [...rows];
if (options.sortKeyCol.colName) {
rows = sortBy(rows, options.sortKeyCol.colName);
}
if (options.sortKeyCol.reverse) {
rows = rows.reverse();
}
const data = map(rows, row => ({
step: row[options.stepCol.colName],
value: parseFloat(row[options.valueCol.colName]) || 0.0,
}));
const maxVal = maxBy(data, d => d.value).value;
data.forEach((d, i) => {
d.pctMax = (d.value / maxVal) * 100.0;
d.pctPrevious = (i === 0) || (d.value === data[i - 1].value) ? 100.0 : (d.value / data[i - 1].value) * 100.0;
});
return data.slice(0, options.itemsLimit);
}

View File

@ -1,46 +0,0 @@
<div class="form-horizontal">
<div style="margin-bottom: 20px;">
This visualization constructs funnel chart. Please notice that value column only accept number for values.
</div>
<div class="form-group">
<label class="col-lg-6">Step Column Name</label>
<div class="col-lg-6">
<select ng-options="col.name as col.name for col in $ctrl.data.columns"
ng-model="$ctrl.options.stepCol.colName" class="form-control"></select>
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Step Column Display Name</label>
<div class="col-lg-6">
<input type="text" ng-model="$ctrl.options.stepCol.displayAs" class="form-control">
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Funnel Value Column Name</label>
<div class="col-lg-6">
<select ng-options="col.name as col.name for col in $ctrl.data.columns"
ng-model="$ctrl.options.valueCol.colName" class="form-control"></select>
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Funnel Value Column Display Name</label>
<div class="col-lg-6">
<input type="text" ng-model="$ctrl.options.valueCol.displayAs" class="form-control">
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Auto Sort Record By Value</label>
<div class="col-lg-6">
<input type="checkbox" ng-model="$ctrl.options.autoSort">
</div>
</div>
<div ng-show="!$ctrl.options.autoSort">
<div class="form-group">
<label class="col-lg-6">Funnel Value Columns Name</label>
<div class="col-lg-6">
<select ng-options="col.name as col.name for col in $ctrl.data.columns"
ng-model="$ctrl.options.sortKeyCol.colName" class="form-control"></select>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,42 @@
import { isFinite, map, merge, includes } from 'lodash';
const DEFAULT_OPTIONS = {
stepCol: { colName: null, displayAs: 'Steps' },
valueCol: { colName: null, displayAs: 'Value' },
autoSort: true,
sortKeyCol: { colName: null, reverse: false },
itemsLimit: 100,
percentValuesRange: { min: 0.01, max: 1000.0 },
numberFormat: '0,0[.]00',
percentFormat: '0[.]00%',
};
export default function getOptions(options, { columns }) {
options = merge({}, DEFAULT_OPTIONS, options);
// Validate
const availableColumns = map(columns, c => c.name);
if (!includes(availableColumns, options.stepCol.colName)) {
options.stepCol.colName = null;
}
if (!includes(availableColumns, options.valueCol.colName)) {
options.valueCol.colName = null;
}
if (!includes(availableColumns, options.sortKeyCol.colName)) {
options.sortKeyCol.colName = null;
}
if (!isFinite(options.itemsLimit)) {
options.itemsLimit = DEFAULT_OPTIONS.itemsLimit;
}
if (options.itemsLimit < 2) {
options.itemsLimit = 2;
}
if (options.autoSort) {
options.sortKeyCol.colName = options.valueCol.colName;
options.sortKeyCol.reverse = true;
}
return options;
}

View File

@ -1,236 +1,18 @@
import { debounce, sortBy, isFinite, every, difference, merge, map } from 'lodash';
import d3 from 'd3';
import angular from 'angular';
import { angular2react } from 'angular2react';
import { registerVisualization } from '@/visualizations'; import { registerVisualization } from '@/visualizations';
import { normalizeValue } from '@/visualizations/chart/plotly/utils'; import getOptions from './getOptions';
import ColorPalette from '@/visualizations/ColorPalette'; import Renderer from './Renderer';
import editorTemplate from './funnel-editor.html'; import Editor from './Editor';
import './funnel.less';
const DEFAULT_OPTIONS = { export default function init() {
stepCol: { colName: '', displayAs: 'Steps' }, registerVisualization({
valueCol: { colName: '', displayAs: 'Value' }, type: 'FUNNEL',
sortKeyCol: { colName: '' }, name: 'Funnel',
autoSort: true, getOptions,
}; Renderer,
Editor,
function normalizePercentage(num) { defaultRows: 10,
if (num < 0.01) {
return '<0.01%';
}
if (num > 1000) {
return '>1000%';
}
return num.toFixed(2) + '%';
}
function Funnel(scope, element) {
this.element = element;
this.watches = [];
const vis = d3.select(element);
const options = scope.$ctrl.options;
function drawFunnel(data) {
const maxToPrevious = d3.max(data, d => d.pctPrevious);
// Table
const table = vis.append('table').attr('class', 'table table-condensed table-hover table-borderless');
// Header
const header = table.append('thead').append('tr');
header.append('th').text(options.stepCol.displayAs);
header
.append('th')
.attr('class', 'text-center')
.text(options.valueCol.displayAs);
header
.append('th')
.attr('class', 'text-center')
.text('% Max');
header
.append('th')
.attr('class', 'text-center')
.text('% Previous');
// Body
const trs = table
.append('tbody')
.selectAll('tr')
.data(data)
.enter()
.append('tr');
// Steps row
trs
.append('td')
.attr('class', 'col-xs-3 step')
.attr('title', d => d.step)
.text(d => d.step);
// Funnel bars
const valContainers = trs
.append('td')
.attr('class', 'col-xs-5')
.append('div')
.attr('class', 'container');
valContainers
.append('div')
.attr('class', 'bar centered')
.style('background-color', ColorPalette.Cyan)
.style('width', d => d.pctMax + '%');
valContainers
.append('div')
.attr('class', 'value')
.text(d => d.value.toLocaleString());
// pctMax
trs
.append('td')
.attr('class', 'col-xs-2 text-center')
.text(d => normalizePercentage(d.pctMax));
// pctPrevious
const pctContainers = trs
.append('td')
.attr('class', 'col-xs-2')
.append('div')
.attr('class', 'container');
pctContainers
.append('div')
.attr('class', 'bar')
.style('background-color', ColorPalette.Gray)
.style('opacity', '0.2')
.style('width', d => (d.pctPrevious / maxToPrevious) * 100.0 + '%');
pctContainers
.append('div')
.attr('class', 'value')
.text(d => normalizePercentage(d.pctPrevious));
}
function createVisualization(data) {
drawFunnel(data); // draw funnel
}
function removeVisualization() {
vis.selectAll('table').remove();
}
function prepareData(queryData) {
if (queryData.length === 0) {
return [];
}
const data = queryData.map(
row => ({
step: normalizeValue(row[options.stepCol.colName]),
value: Number(row[options.valueCol.colName]),
sortVal: options.autoSort ? '' : row[options.sortKeyCol.colName],
}),
[],
);
let sortedData;
if (options.autoSort) {
sortedData = sortBy(data, 'value').reverse();
} else {
sortedData = sortBy(data, 'sortVal');
}
// Column validity
if (sortedData[0].value === 0 || !every(sortedData, d => isFinite(d.value))) {
return;
}
const maxVal = d3.max(data, d => d.value);
sortedData.forEach((d, i) => {
d.pctMax = (d.value / maxVal) * 100.0;
d.pctPrevious = i === 0 ? 100.0 : (d.value / sortedData[i - 1].value) * 100.0;
});
return sortedData.slice(0, 100);
}
function invalidColNames() {
const colNames = map(scope.$ctrl.data.columns, col => col.name);
const colToCheck = [options.stepCol.colName, options.valueCol.colName];
if (!options.autoSort) {
colToCheck.push(options.sortKeyCol.colName);
}
return difference(colToCheck, colNames).length > 0;
}
function refresh() {
removeVisualization();
if (invalidColNames()) {
return;
}
const queryData = scope.$ctrl.data.rows;
const data = prepareData(queryData, options);
if (data.length > 0) {
createVisualization(data); // draw funnel
}
}
refresh();
this.watches.push(scope.$watch('$ctrl.data', refresh));
this.watches.push(scope.$watch('$ctrl.options', refresh, true));
}
Funnel.prototype.remove = function remove() {
this.watches.forEach((unregister) => {
unregister();
});
angular.element(this.element).empty();
};
const FunnelRenderer = {
template: '<div class="funnel-visualization-container" resize-event="handleResize()"></div>',
bindings: {
data: '<',
options: '<',
},
controller($scope, $element) {
const container = $element[0].querySelector('.funnel-visualization-container');
let funnel = new Funnel($scope, container);
const update = () => {
funnel.remove();
funnel = new Funnel($scope, container);
};
$scope.handleResize = debounce(update, 50);
$scope.$watch('$ctrl.data', update);
$scope.$watch('$ctrl.options', update, true);
},
};
const FunnelEditor = {
template: editorTemplate,
bindings: {
data: '<',
options: '<',
onOptionsChange: '<',
},
controller($scope) {
$scope.$watch('$ctrl.options', (options) => {
this.onOptionsChange(options);
}, true);
},
};
export default function init(ngModule) {
ngModule.component('funnelRenderer', FunnelRenderer);
ngModule.component('funnelEditor', FunnelEditor);
ngModule.run(($injector) => {
registerVisualization({
type: 'FUNNEL',
name: 'Funnel',
getOptions: options => merge({}, DEFAULT_OPTIONS, options),
Renderer: angular2react('funnelRenderer', FunnelRenderer, $injector),
Editor: angular2react('funnelEditor', FunnelEditor, $injector),
defaultRows: 10,
});
}); });
} }

View File

@ -0,0 +1,83 @@
/* global cy, Cypress */
import { createQuery } from '../../support/redash-api';
const SQL = `
SELECT 'a.01' AS a, 1.758831600227 AS b UNION ALL
SELECT 'a.02' AS a, 613.4456936572 AS b UNION ALL
SELECT 'a.03' AS a, 9.045647090023 AS b UNION ALL
SELECT 'a.04' AS a, 29.37836413439 AS b UNION ALL
SELECT 'a.05' AS a, 642.9434910444 AS b UNION ALL
SELECT 'a.06' AS a, 176.7634164480 AS b UNION ALL
SELECT 'a.07' AS a, 279.4880059198 AS b UNION ALL
SELECT 'a.08' AS a, 78.48128609207 AS b UNION ALL
SELECT 'a.09' AS a, 14.10443892662 AS b UNION ALL
SELECT 'a.10' AS a, 59.25097112438 AS b UNION ALL
SELECT 'a.11' AS a, 61.58610868125 AS b UNION ALL
SELECT 'a.12' AS a, 277.8473055268 AS b UNION ALL
SELECT 'a.13' AS a, 621.1535090415 AS b UNION ALL
SELECT 'a.14' AS a, 261.1409234646 AS b UNION ALL
SELECT 'a.15' AS a, 72.94883358030 AS b
`;
describe('Funnel', () => {
const viewportWidth = Cypress.config('viewportWidth');
beforeEach(() => {
cy.login();
createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`);
cy.getByTestId('ExecuteButton').click();
});
});
it('creates visualization', () => {
cy.clickThrough(`
NewVisualization
VisualizationType
VisualizationType.FUNNEL
`);
cy.clickThrough(`
Funnel.EditorTabs.General
Funnel.StepColumn
Funnel.StepColumn.a
Funnel.ValueColumn
Funnel.ValueColumn.b
Funnel.CustomSort
Funnel.SortColumn
Funnel.SortColumn.b
Funnel.SortDirection
Funnel.SortDirection.Ascending
`);
cy.fillInputs({
'Funnel.StepColumnTitle': 'Column A',
'Funnel.ValueColumnTitle': 'Column B',
}, { wait: 200 }); // inputs are debounced
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId('VisualizationPreview').find('table').should('exist');
cy.percySnapshot('Visualizations - Funnel (basic)', { widths: [viewportWidth] });
cy.clickThrough(`
Funnel.EditorTabs.Appearance
`);
cy.fillInputs({
'Funnel.NumberFormat': '0[.]00',
'Funnel.PercentFormat': '0[.]0000%',
'Funnel.ItemsLimit': '10',
'Funnel.PercentRangeMin': '10',
'Funnel.PercentRangeMax': '90',
}, { wait: 200 }); // inputs are debounced
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId('VisualizationPreview').find('table').should('exist');
cy.percySnapshot('Visualizations - Funnel (extra options)', { widths: [viewportWidth] });
});
});

View File

@ -45,9 +45,12 @@ Cypress.Commands.add('clickThrough', (...args) => {
return undefined; return undefined;
}); });
Cypress.Commands.add('fillInputs', (elements) => { Cypress.Commands.add('fillInputs', (elements, { wait = 0 } = {}) => {
each(elements, (value, testId) => { each(elements, (value, testId) => {
cy.getByTestId(testId).clear().type(value); cy.getByTestId(testId).clear().type(value);
if (wait > 0) {
cy.wait(wait); // eslint-disable-line cypress/no-unnecessary-waiting
}
}); });
}); });