mirror of
https://github.com/valitydev/redash.git
synced 2024-11-07 09:28:51 +00:00
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:
parent
1a95904ffd
commit
b44fa51829
120
client/app/visualizations/funnel/Editor/AppearanceSettings.jsx
Normal file
120
client/app/visualizations/funnel/Editor/AppearanceSettings.jsx
Normal 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
|
||||
<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
|
||||
<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
client/app/visualizations/funnel/Editor/GeneralSettings.jsx
Normal file
147
client/app/visualizations/funnel/Editor/GeneralSettings.jsx
Normal 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;
|
28
client/app/visualizations/funnel/Editor/index.jsx
Normal file
28
client/app/visualizations/funnel/Editor/index.jsx
Normal 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;
|
33
client/app/visualizations/funnel/Renderer/FunnelBar.jsx
Normal file
33
client/app/visualizations/funnel/Renderer/FunnelBar.jsx
Normal 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,
|
||||
};
|
32
client/app/visualizations/funnel/Renderer/funnel-bar.less
Normal file
32
client/app/visualizations/funnel/Renderer/funnel-bar.less
Normal 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;
|
||||
}
|
||||
}
|
99
client/app/visualizations/funnel/Renderer/index.jsx
Normal file
99
client/app/visualizations/funnel/Renderer/index.jsx
Normal 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;
|
@ -1,10 +1,17 @@
|
||||
.funnel-visualization-container {
|
||||
table {
|
||||
min-width: 450px;
|
||||
}
|
||||
.table-borderless td, .table-borderless th {
|
||||
border: 0;
|
||||
vertical-align: middle;
|
||||
table-layout: fixed;
|
||||
|
||||
tbody tr td {
|
||||
border: none;
|
||||
|
||||
&.text-ellipsis {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
.step {
|
||||
max-width: 0;
|
||||
@ -28,22 +35,4 @@
|
||||
.step:hover .step-name {
|
||||
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;
|
||||
}
|
||||
}
|
28
client/app/visualizations/funnel/Renderer/prepareData.js
Normal file
28
client/app/visualizations/funnel/Renderer/prepareData.js
Normal 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);
|
||||
}
|
@ -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>
|
42
client/app/visualizations/funnel/getOptions.js
Normal file
42
client/app/visualizations/funnel/getOptions.js
Normal 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;
|
||||
}
|
@ -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 { normalizeValue } from '@/visualizations/chart/plotly/utils';
|
||||
import ColorPalette from '@/visualizations/ColorPalette';
|
||||
import editorTemplate from './funnel-editor.html';
|
||||
import './funnel.less';
|
||||
import getOptions from './getOptions';
|
||||
import Renderer from './Renderer';
|
||||
import Editor from './Editor';
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
stepCol: { colName: '', displayAs: 'Steps' },
|
||||
valueCol: { colName: '', displayAs: 'Value' },
|
||||
sortKeyCol: { colName: '' },
|
||||
autoSort: true,
|
||||
};
|
||||
export default function init() {
|
||||
registerVisualization({
|
||||
type: 'FUNNEL',
|
||||
name: 'Funnel',
|
||||
getOptions,
|
||||
Renderer,
|
||||
Editor,
|
||||
|
||||
function normalizePercentage(num) {
|
||||
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,
|
||||
});
|
||||
defaultRows: 10,
|
||||
});
|
||||
}
|
||||
|
||||
|
83
client/cypress/integration/visualizations/funnel_spec.js
Normal file
83
client/cypress/integration/visualizations/funnel_spec.js
Normal 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] });
|
||||
});
|
||||
});
|
@ -45,9 +45,12 @@ Cypress.Commands.add('clickThrough', (...args) => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
Cypress.Commands.add('fillInputs', (elements) => {
|
||||
Cypress.Commands.add('fillInputs', (elements, { wait = 0 } = {}) => {
|
||||
each(elements, (value, testId) => {
|
||||
cy.getByTestId(testId).clear().type(value);
|
||||
if (wait > 0) {
|
||||
cy.wait(wait); // eslint-disable-line cypress/no-unnecessary-waiting
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user