-
Notifications
You must be signed in to change notification settings - Fork 4.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
1a95904
commit b44fa51
Showing
13 changed files
with
638 additions
and
298 deletions.
There are no files selected for viewing
120 changes: 120 additions & 0 deletions
120
client/app/visualizations/funnel/Editor/AppearanceSettings.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
<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 changes: 147 additions & 0 deletions
147
client/app/visualizations/funnel/Editor/GeneralSettings.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.