Migrate Chart visualization to React Part 2: Editor (#4139)

This commit is contained in:
Levko Kravets
2019-11-20 21:57:12 +02:00
committed by GitHub
parent c6a2725f0a
commit 818649bbec
33 changed files with 2157 additions and 801 deletions

View File

@@ -14,7 +14,7 @@ export const SortableContainerWrapper = sortableContainer(({ children }) => chil
export const SortableElement = sortableElement(({ children }) => children);
export function SortableContainer({ disabled, containerProps, children, ...wrapperProps }) {
export function SortableContainer({ disabled, containerComponent, containerProps, children, ...wrapperProps }) {
const containerRef = useRef();
const [isDragging, setIsDragging] = useState(false);
@@ -59,22 +59,24 @@ export function SortableContainer({ disabled, containerProps, children, ...wrapp
containerProps.ref = containerRef;
}
// order of props matters - we override some of them
const ContainerComponent = containerComponent;
return (
<SortableContainerWrapper {...wrapperProps}>
<div {...containerProps}>{children}</div>
<ContainerComponent {...containerProps}>{children}</ContainerComponent>
</SortableContainerWrapper>
);
}
SortableContainer.propTypes = {
disabled: PropTypes.bool,
containerComponent: PropTypes.elementType,
containerProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types
children: PropTypes.node,
};
SortableContainer.defaultProps = {
disabled: false,
containerComponent: 'div',
containerProps: {},
children: null,
};

View File

@@ -0,0 +1,106 @@
import { isString, isObject, isFinite, isNumber, merge } from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import { useDebouncedCallback } from 'use-debounce';
import Select from 'antd/lib/select';
import Input from 'antd/lib/input';
import InputNumber from 'antd/lib/input-number';
import * as Grid from 'antd/lib/grid';
function toNumber(value) {
value = isNumber(value) ? value : parseFloat(value);
return isFinite(value) ? value : null;
}
export default function AxisSettings({ id, options, features, onChange }) {
function optionsChanged(newOptions) {
onChange(merge({}, options, newOptions));
}
const [handleNameChange] = useDebouncedCallback((text) => {
const title = isString(text) && (text !== '') ? { text } : null;
optionsChanged({ title });
}, 200);
const [handleMinMaxChange] = useDebouncedCallback(opts => optionsChanged(opts), 200);
return (
<React.Fragment>
<div className="m-b-15">
<label htmlFor={`chart-editor-${id}-type`}>Scale</label>
<Select
id={`chart-editor-${id}-type`}
className="w-100"
data-test={`Chart.${id}.Type`}
defaultValue={options.type}
onChange={type => optionsChanged({ type })}
>
{features.autoDetectType && <Select.Option value="-" data-test={`Chart.${id}.Type.Auto`}>Auto Detect</Select.Option>}
<Select.Option value="datetime" data-test={`Chart.${id}.Type.DateTime`}>Datetime</Select.Option>
<Select.Option value="linear" data-test={`Chart.${id}.Type.Linear`}>Linear</Select.Option>
<Select.Option value="logarithmic" data-test={`Chart.${id}.Type.Logarithmic`}>Logarithmic</Select.Option>
<Select.Option value="category" data-test={`Chart.${id}.Type.Category`}>Category</Select.Option>
</Select>
</div>
<div className="m-b-15">
<label htmlFor={`chart-editor-${id}-name`}>Name</label>
<Input
id={`chart-editor-${id}-name`}
data-test={`Chart.${id}.Name`}
defaultValue={isObject(options.title) ? options.title.text : null}
onChange={event => handleNameChange(event.target.value)}
/>
</div>
{features.range && (
<Grid.Row gutter={15} type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label htmlFor={`chart-editor-${id}-range-min`}>Min Value</label>
<InputNumber
id={`chart-editor-${id}-range-min`}
className="w-100"
placeholder="Auto"
data-test={`Chart.${id}.RangeMin`}
defaultValue={toNumber(options.rangeMin)}
onChange={value => handleMinMaxChange({ rangeMin: toNumber(value) })}
/>
</Grid.Col>
<Grid.Col span={12}>
<label htmlFor={`chart-editor-${id}-range-max`}>Max Value</label>
<InputNumber
id={`chart-editor-${id}-range-max`}
className="w-100"
placeholder="Auto"
data-test={`Chart.${id}.RangeMax`}
defaultValue={toNumber(options.rangeMax)}
onChange={value => handleMinMaxChange({ rangeMax: toNumber(value) })}
/>
</Grid.Col>
</Grid.Row>
)}
</React.Fragment>
);
}
AxisSettings.propTypes = {
id: PropTypes.string.isRequired,
options: PropTypes.shape({
type: PropTypes.string.isRequired,
title: PropTypes.shape({
text: PropTypes.string,
}),
rangeMin: PropTypes.number,
rangeMax: PropTypes.number,
}).isRequired,
features: PropTypes.shape({
autoDetectType: PropTypes.bool,
range: PropTypes.bool,
}),
onChange: PropTypes.func,
};
AxisSettings.defaultProps = {
features: {},
onChange: () => {},
};

View File

@@ -0,0 +1,36 @@
import { map } from 'lodash';
import React, { useMemo } from 'react';
import Select from 'antd/lib/select';
import { clientConfig } from '@/services/auth';
export default function ChartTypeSelect(props) {
const chartTypes = useMemo(() => {
const result = [
{ type: 'line', name: 'Line', icon: 'line-chart' },
{ type: 'column', name: 'Bar', icon: 'bar-chart' },
{ type: 'area', name: 'Area', icon: 'area-chart' },
{ type: 'pie', name: 'Pie', icon: 'pie-chart' },
{ type: 'scatter', name: 'Scatter', icon: 'circle-o' },
{ type: 'bubble', name: 'Bubble', icon: 'circle-o' },
{ type: 'heatmap', name: 'Heatmap', icon: 'th' },
{ type: 'box', name: 'Box', icon: 'square-o' },
];
if (clientConfig.allowCustomJSVisualizations) {
result.push({ type: 'custom', name: 'Custom', icon: 'code' });
}
return result;
}, []);
return (
<Select {...props}>
{map(chartTypes, ({ type, name, icon }) => (
<Select.Option key={type} value={type} data-test={`Chart.ChartType.${type}`}>
<i className={`m-r-5 fa fa-${icon}`} />
{name}
</Select.Option>
))}
</Select>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { EditorPropTypes } from '@/visualizations';
import PieColorsSettings from './PieColorsSettings';
import HeatmapColorsSettings from './HeatmapColorsSettings';
import DefaultColorsSettings from './DefaultColorsSettings';
const components = {
pie: PieColorsSettings,
heatmap: HeatmapColorsSettings,
};
export default function ColorsSettings({ options, ...props }) {
const Component = components[options.globalSeriesType] || DefaultColorsSettings;
return <Component options={options} {...props} />;
}
ColorsSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,84 @@
import { after } from 'lodash';
import React from 'react';
import enzyme from 'enzyme';
import getOptions from '../getOptions';
import ColorsSettings from './ColorsSettings';
function findByTestID(wrapper, testId) {
return wrapper.find(`[data-test="${testId}"]`);
}
function mount(options, done) {
options = getOptions(options);
return enzyme.mount((
<ColorsSettings
visualizationName="Test"
data={{
columns: [{ name: 'a', type: 'string' }, { name: 'b', type: 'number' }],
rows: [{ a: 'v', b: 3.14 }],
}}
options={options}
onOptionsChange={(changedOptions) => {
expect(changedOptions).toMatchSnapshot();
done();
}}
/>
));
}
describe('Visualizations -> Chart -> Editor -> Colors Settings', () => {
describe('for pie', () => {
test('Changes series color', (done) => {
const el = mount({
globalSeriesType: 'pie',
columnMapping: { a: 'x', b: 'y' },
}, done);
findByTestID(el, 'Chart.Series.v.Color').first().simulate('click');
findByTestID(el, 'ColorPicker').first().find('input')
.simulate('change', { target: { value: 'red' } });
});
});
describe('for heatmap', () => {
test('Changes color scheme', (done) => {
const el = mount({
globalSeriesType: 'heatmap',
columnMapping: { a: 'x', b: 'y' },
}, done);
findByTestID(el, 'Chart.Colors.Heatmap.ColorScheme').first().simulate('click');
findByTestID(el, 'Chart.Colors.Heatmap.ColorScheme.RdBu').first().simulate('click');
});
test('Sets custom color scheme', async (done) => {
const el = mount({
globalSeriesType: 'heatmap',
columnMapping: { a: 'x', b: 'y' },
colorScheme: 'Custom...',
}, after(2, done)); // we will perform 2 actions, so call `done` after all of them completed
findByTestID(el, 'Chart.Colors.Heatmap.MinColor').first().simulate('click');
findByTestID(el, 'ColorPicker').first().find('input')
.simulate('change', { target: { value: 'yellow' } });
findByTestID(el, 'Chart.Colors.Heatmap.MaxColor').first().simulate('click');
findByTestID(el, 'ColorPicker').first().find('input')
.simulate('change', { target: { value: 'red' } });
});
});
describe('for all except of pie and heatmap', () => {
test('Changes series color', (done) => {
const el = mount({
globalSeriesType: 'column',
columnMapping: { a: 'x', b: 'y' },
}, done);
findByTestID(el, 'Chart.Series.b.Color').first().simulate('click');
findByTestID(el, 'ColorPicker').first().find('input')
.simulate('change', { target: { value: 'red' } });
});
});
});

View File

@@ -0,0 +1,60 @@
import { isString, map, uniq, flatten, filter, sortBy, keys } from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import Select from 'antd/lib/select';
const MappingTypes = {
x: { label: 'X Column' },
y: { label: 'Y Columns', multiple: true },
series: { label: 'Group by' },
yError: { label: 'Errors column' },
size: { label: 'Bubble size column' },
zVal: { label: 'Color Column' },
};
export default function ColumnMappingSelect({ value, availableColumns, type, onChange }) {
const options = sortBy(filter(
uniq(flatten([availableColumns, value])),
v => isString(v) && (v !== ''),
));
const { label, multiple } = MappingTypes[type];
return (
<div className="m-b-15">
<label htmlFor={`chart-editor-column-mapping-${type}`}>{label}</label>
<Select
id={`chart-editor-column-mapping-${type}`}
className="w-100"
data-test={`Chart.ColumnMapping.${type}`}
mode={multiple ? 'multiple' : 'default'}
allowClear
placeholder={multiple ? 'Choose columns...' : 'Choose column...'}
value={value || undefined}
onChange={column => onChange(column || null, type)}
>
{map(options, c => (
<Select.Option key={c} value={c} data-test={`Chart.ColumnMapping.${type}.${c}`}>{c}</Select.Option>
))}
</Select>
</div>
);
}
ColumnMappingSelect.propTypes = {
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
availableColumns: PropTypes.arrayOf(PropTypes.string),
type: PropTypes.oneOf(keys(MappingTypes)),
onChange: PropTypes.func,
};
ColumnMappingSelect.defaultProps = {
value: null,
availableColumns: [],
type: null,
onChange: () => {},
};
ColumnMappingSelect.MappingTypes = MappingTypes;

View File

@@ -0,0 +1,58 @@
import { isNil, trimStart } from 'lodash';
import React from 'react';
import Switch from 'antd/lib/switch';
import Input from 'antd/lib/input';
import { EditorPropTypes } from '@/visualizations';
const { TextArea } = Input;
const defaultCustomCode = trimStart(`
// Available variables are x, ys, element, and Plotly
// Type console.log(x, ys); for more info about x and ys
// To plot your graph call Plotly.plot(element, ...)
// Plotly examples and docs: https://plot.ly/javascript/
`);
export default function CustomChartSettings({ options, onOptionsChange }) {
return (
<React.Fragment>
<div className="m-b-15">
<label htmlFor="chart-editor-custom-code">Custom code</label>
<TextArea
id="chart-editor-custom-code"
data-test="Chart.Custom.Code"
className="form-control v-resizable"
rows="10"
defaultValue={isNil(options.customCode) ? defaultCustomCode : options.customCode}
onChange={event => onOptionsChange({ customCode: event.target.value })}
/>
</div>
<div className="m-b-15">
<label className="d-flex align-items-center" htmlFor="chart-editor-enable-console-logs">
<Switch
id="chart-editor-enable-console-logs"
data-test="Chart.Custom.EnableConsoleLogs"
defaultChecked={options.enableConsoleLogs}
onChange={enableConsoleLogs => onOptionsChange({ enableConsoleLogs })}
/>
<span className="m-l-10">Show errors in the console</span>
</label>
</div>
<div className="m-b-15">
<label className="d-flex align-items-center" htmlFor="chart-editor-auto-update-custom-chart">
<Switch
id="chart-editor-auto-update-custom-chart"
data-test="Chart.Custom.AutoUpdate"
defaultChecked={options.autoRedraw}
onChange={autoRedraw => onOptionsChange({ autoRedraw })}
/>
<span className="m-l-10">Auto update graph</span>
</label>
</div>
</React.Fragment>
);
}
CustomChartSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,137 @@
import { includes } from 'lodash';
import React from 'react';
import { useDebouncedCallback } from 'use-debounce';
import Checkbox from 'antd/lib/checkbox';
import Input from 'antd/lib/input';
import Popover from 'antd/lib/popover';
import Icon from 'antd/lib/icon';
import { EditorPropTypes } from '@/visualizations';
export default function DataLabelsSettings({ options, onOptionsChange }) {
const isShowDataLabelsAvailable = includes(
['line', 'area', 'column', 'scatter', 'pie', 'heatmap'],
options.globalSeriesType,
);
const [debouncedOnOptionsChange] = useDebouncedCallback(onOptionsChange, 200);
return (
<React.Fragment>
{ isShowDataLabelsAvailable && (
<div className="m-b-15">
<label htmlFor="chart-editor-show-data-labels">
<Checkbox
id="chart-editor-show-data-labels"
data-test="Chart.DataLabels.ShowDataLabels"
defaultChecked={options.showDataLabels}
onChange={event => onOptionsChange({ showDataLabels: event.target.checked })}
/>
<span>Show Data Labels</span>
</label>
</div>
)}
<div className="m-b-15">
<label htmlFor="chart-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>
<Input
id="chart-editor-number-format"
data-test="Chart.DataLabels.NumberFormat"
defaultValue={options.numberFormat}
onChange={e => debouncedOnOptionsChange({ numberFormat: e.target.value })}
/>
</div>
<div className="m-b-15">
<label htmlFor="chart-editor-percent-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>
<Input
id="chart-editor-percent-format"
data-test="Chart.DataLabels.PercentFormat"
defaultValue={options.percentFormat}
onChange={e => debouncedOnOptionsChange({ percentFormat: e.target.value })}
/>
</div>
<div className="m-b-15">
<label htmlFor="chart-editor-datetime-format">
Date/Time Values Format
<Popover
content={(
<React.Fragment>
Format&nbsp;
<a href="https://momentjs.com/docs/#/displaying/format/" target="_blank" rel="noopener noreferrer">specs.</a>
</React.Fragment>
)}
>
<Icon className="m-l-5" type="question-circle" theme="filled" />
</Popover>
</label>
<Input
id="chart-editor-datetime-format"
data-test="Chart.DataLabels.DateTimeFormat"
defaultValue={options.dateTimeFormat}
onChange={e => debouncedOnOptionsChange({ dateTimeFormat: e.target.value })}
/>
</div>
<div className="m-b-15">
<label htmlFor="chart-editor-text-format">
Data Labels
<Popover
placement="topRight"
arrowPointAtCenter
content={(
<React.Fragment>
<div className="p-b-5">Use special names to access additional properties:</div>
<div><code>{'{{ @@name }}'}</code> series name;</div>
<div><code>{'{{ @@x }}'}</code> x-value;</div>
<div><code>{'{{ @@y }}'}</code> y-value;</div>
<div><code>{'{{ @@yPercent }}'}</code> relative y-value;</div>
<div><code>{'{{ @@yError }}'}</code> y deviation;</div>
<div><code>{'{{ @@size }}'}</code> bubble size;</div>
<div className="p-t-5">
Also, all query result columns can be referenced<br />using
<code className="text-nowrap">{'{{ column_name }}'}</code> syntax.
</div>
</React.Fragment>
)}
>
<Icon className="m-l-5" type="question-circle" theme="filled" />
</Popover>
</label>
<Input
id="chart-editor-text-format"
data-test="Chart.DataLabels.TextFormat"
placeholder="(auto)"
defaultValue={options.textFormat}
onChange={e => debouncedOnOptionsChange({ textFormat: e.target.value })}
/>
</div>
</React.Fragment>
);
}
DataLabelsSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import enzyme from 'enzyme';
import getOptions from '../getOptions';
import DataLabelsSettings from './DataLabelsSettings';
function findByTestID(wrapper, testId) {
return wrapper.find(`[data-test="${testId}"]`);
}
function mount(options, done) {
options = getOptions(options);
return enzyme.mount((
<DataLabelsSettings
visualizationName="Test"
data={{ columns: [], rows: [] }}
options={options}
onOptionsChange={(changedOptions) => {
expect(changedOptions).toMatchSnapshot();
done();
}}
/>
));
}
describe('Visualizations -> Chart -> Editor -> Data Labels Settings', () => {
test('Sets Show Data Labels option', (done) => {
const el = mount({
globalSeriesType: 'column',
showDataLabels: false,
}, done);
findByTestID(el, 'Chart.DataLabels.ShowDataLabels').first().find('input')
.simulate('change', { target: { checked: true } });
});
test('Changes number format', (done) => {
const el = mount({
globalSeriesType: 'column',
numberFormat: '0[.]0000',
}, done);
findByTestID(el, 'Chart.DataLabels.NumberFormat').first()
.simulate('change', { target: { value: '0.00' } });
});
test('Changes percent values format', (done) => {
const el = mount({
globalSeriesType: 'column',
percentFormat: '0[.]00%',
}, done);
findByTestID(el, 'Chart.DataLabels.PercentFormat').first()
.simulate('change', { target: { value: '0.0%' } });
});
test('Changes date/time format', (done) => {
const el = mount({
globalSeriesType: 'column',
dateTimeFormat: 'YYYY-MM-DD HH:mm:ss',
}, done);
findByTestID(el, 'Chart.DataLabels.DateTimeFormat').first()
.simulate('change', { target: { value: 'YYYY MMM DD' } });
});
test('Changes data labels format', (done) => {
const el = mount({
globalSeriesType: 'column',
textFormat: null,
}, done);
findByTestID(el, 'Chart.DataLabels.TextFormat').first()
.simulate('change', { target: { value: '{{ @@x }} :: {{ @@y }} / {{ @@yPercent }}' } });
});
});

View File

@@ -0,0 +1,65 @@
import { map } from 'lodash';
import React, { useMemo, useCallback } from 'react';
import Table from 'antd/lib/table';
import ColorPicker from '@/components/ColorPicker';
import { EditorPropTypes } from '@/visualizations';
import ColorPalette from '@/visualizations/ColorPalette';
import getChartData from '../getChartData';
export default function DefaultColorsSettings({ options, data, onOptionsChange }) {
const colors = useMemo(() => ({
Automatic: null,
...ColorPalette,
}), []);
const series = useMemo(() => map(
getChartData(data.rows, options),
({ name }) => ({ key: name, color: (options.seriesOptions[name] || {}).color || null }),
), [options, data]);
const updateSeriesOption = useCallback((key, prop, value) => {
onOptionsChange({
seriesOptions: {
[key]: {
[prop]: value,
},
},
});
}, [onOptionsChange]);
const columns = [
{
title: 'Series',
dataIndex: 'key',
},
{
title: 'Color',
dataIndex: 'color',
width: '1%',
render: (unused, item) => (
<div className="text-nowrap">
<ColorPicker
data-test={`Chart.Series.${item.key}.Color`}
interactive
presetColors={colors}
placement="topRight"
color={item.color}
onChange={value => updateSeriesOption(item.key, 'color', value)}
/>
<ColorPicker.Label color={item.color} presetColors={colors} />
</div>
),
},
];
return (
<Table
showHeader={false}
dataSource={series}
columns={columns}
pagination={false}
/>
);
}
DefaultColorsSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,219 @@
import { isArray, map, mapValues, includes, some, each, difference } from 'lodash';
import React, { useMemo } from 'react';
import Select from 'antd/lib/select';
import Checkbox from 'antd/lib/checkbox';
import { EditorPropTypes } from '@/visualizations';
import ChartTypeSelect from './ChartTypeSelect';
import ColumnMappingSelect from './ColumnMappingSelect';
function getAvailableColumnMappingTypes(options) {
const result = ['x', 'y'];
if (!includes(['custom', 'heatmap'], options.globalSeriesType)) {
result.push('series');
}
if (some(options.seriesOptions, { type: 'bubble' })) {
result.push('size');
}
if (some(options.seriesOptions, { type: 'heatmap' })) {
result.push('zVal');
}
if (!includes(['custom', 'heatmap'], options.globalSeriesType)) {
result.push('yError');
}
return result;
}
function getMappedColumns(options, availableColumns) {
const mappedColumns = {};
const availableTypes = getAvailableColumnMappingTypes(options);
each(availableTypes, (type) => {
mappedColumns[type] = ColumnMappingSelect.MappingTypes[type].multiple ? [] : null;
});
availableColumns = map(availableColumns, c => c.name);
const usedColumns = [];
each(options.columnMapping, (type, column) => {
if (includes(availableColumns, column) && includes(availableTypes, type)) {
const { multiple } = ColumnMappingSelect.MappingTypes[type];
if (multiple) {
mappedColumns[type].push(column);
} else {
mappedColumns[type] = column;
}
usedColumns.push(column);
}
});
return {
mappedColumns,
unusedColumns: difference(availableColumns, usedColumns),
};
}
function mappedColumnsToColumnMappings(mappedColumns) {
const result = {};
each(mappedColumns, (value, type) => {
if (isArray(value)) {
each(value, (v) => {
result[v] = type;
});
} else {
if (value) {
result[value] = type;
}
}
});
return result;
}
export default function GeneralSettings({ options, data, onOptionsChange }) {
const { mappedColumns, unusedColumns } = useMemo(
() => getMappedColumns(options, data.columns),
[options, data.columns],
);
function handleGlobalSeriesTypeChange(globalSeriesType) {
onOptionsChange({
globalSeriesType,
showDataLabels: globalSeriesType === 'pie',
seriesOptions: mapValues(options.seriesOptions, series => ({
...series,
type: globalSeriesType,
})),
});
}
function handleColumnMappingChange(column, type) {
const columnMapping = mappedColumnsToColumnMappings({
...mappedColumns,
[type]: column,
});
onOptionsChange({ columnMapping }, false);
}
return (
<React.Fragment>
<div className="m-b-15">
<label htmlFor="chart-editor-global-series-type">Chart Type</label>
<ChartTypeSelect
id="chart-editor-global-series-type"
className="w-100"
data-test="Chart.GlobalSeriesType"
defaultValue={options.globalSeriesType}
onChange={handleGlobalSeriesTypeChange}
/>
</div>
{map(mappedColumns, (value, type) => (
<ColumnMappingSelect
key={type}
type={type}
value={value}
availableColumns={unusedColumns}
onChange={handleColumnMappingChange}
/>
))}
{includes(['pie'], options.globalSeriesType) && (
<div className="m-b-15">
<label htmlFor="chart-editor-pie-direction">Direction</label>
<Select
id="chart-editor-pie-direction"
className="w-100"
data-test="Chart.PieDirection"
defaultValue={options.direction.type}
onChange={type => onOptionsChange({ direction: { type } })}
>
<Select.Option value="counterclockwise" data-test="Chart.PieDirection.Counterclockwise">Counterclockwise</Select.Option>
<Select.Option value="clockwise" data-test="Chart.PieDirection.Clockwise">Clockwise</Select.Option>
</Select>
</div>
)}
{!includes(['custom', 'heatmap'], options.globalSeriesType) && (
<div className="m-b-15">
<label htmlFor="chart-editor-show-legend">
<Checkbox
id="chart-editor-show-legend"
data-test="Chart.ShowLegend"
defaultChecked={options.legend.enabled}
onChange={event => onOptionsChange({ legend: { enabled: event.target.checked } })}
/>
<span>Show Legend</span>
</label>
</div>
)}
{includes(['box'], options.globalSeriesType) && (
<div className="m-b-15">
<label htmlFor="chart-editor-show-points">
<Checkbox
id="chart-editor-show-points"
data-test="Chart.ShowPoints"
defaultChecked={options.showpoints}
onChange={event => onOptionsChange({ showpoints: event.target.checked })}
/>
<span>Show All Points</span>
</label>
</div>
)}
{!includes(['custom', 'heatmap'], options.globalSeriesType) && (
<div className="m-b-15">
<label htmlFor="chart-editor-stacking">Stacking</label>
<Select
id="chart-editor-stacking"
className="w-100"
data-test="Chart.Stacking"
defaultValue={options.series.stacking}
disabled={!includes(['line', 'area', 'column'], options.globalSeriesType)}
onChange={stacking => onOptionsChange({ series: { stacking } })}
>
<Select.Option value={null} data-test="Chart.Stacking.Disabled">Disabled</Select.Option>
<Select.Option value="stack" data-test="Chart.Stacking.Stack">Stack</Select.Option>
</Select>
</div>
)}
{includes(['line', 'area', 'column'], options.globalSeriesType) && (
<div className="m-b-15">
<label htmlFor="chart-editor-normalize-values">
<Checkbox
id="chart-editor-normalize-values"
data-test="Chart.NormalizeValues"
defaultChecked={options.series.percentValues}
onChange={event => onOptionsChange({ series: { percentValues: event.target.checked } })}
/>
<span>Normalize values to percentage</span>
</label>
</div>
)}
{!includes(['custom', 'heatmap', 'bubble', 'scatter'], options.globalSeriesType) && (
<div className="m-b-15">
<label className="d-flex align-items-center" htmlFor="chart-editor-missing-values">Missing and NULL values</label>
<Select
id="chart-editor-missing-values"
className="w-100"
data-test="Chart.MissingValues"
defaultValue={options.missingValuesAsZero ? 1 : 0}
onChange={value => onOptionsChange({ missingValuesAsZero: !!value })}
>
<Select.Option value={0} data-test="Chart.MissingValues.Keep">Do not display in chart</Select.Option>
<Select.Option value={1} data-test="Chart.MissingValues.Zero">Convert to 0 and display in chart</Select.Option>
</Select>
</div>
)}
</React.Fragment>
);
}
GeneralSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,152 @@
import React from 'react';
import enzyme from 'enzyme';
import getOptions from '../getOptions';
import GeneralSettings from './GeneralSettings';
function findByTestID(wrapper, testId) {
return wrapper.find(`[data-test="${testId}"]`);
}
function elementExists(wrapper, testId) {
return findByTestID(wrapper, testId).length > 0;
}
function mount(options, done) {
options = getOptions(options);
return enzyme.mount((
<GeneralSettings
visualizationName="Test"
data={{ columns: [], rows: [] }}
options={options}
onOptionsChange={(changedOptions) => {
expect(changedOptions).toMatchSnapshot();
done();
}}
/>
));
}
describe('Visualizations -> Chart -> Editor -> General Settings', () => {
test('Changes global series type', (done) => {
const el = mount({
globalSeriesType: 'column',
showDataLabels: false,
seriesOptions: {
a: { type: 'column' },
b: { type: 'line' },
},
}, done);
findByTestID(el, 'Chart.GlobalSeriesType').first().simulate('click');
findByTestID(el, 'Chart.ChartType.pie').first().simulate('click');
});
test('Pie: changes direction', (done) => {
const el = mount({
globalSeriesType: 'pie',
direction: { type: 'counterclockwise' },
}, done);
findByTestID(el, 'Chart.PieDirection').first().simulate('click');
findByTestID(el, 'Chart.PieDirection.Clockwise').first().simulate('click');
});
test('Toggles legend', (done) => {
const el = mount({
globalSeriesType: 'column',
legend: { enabled: true },
}, done);
findByTestID(el, 'Chart.ShowLegend').first().find('input')
.simulate('change', { target: { checked: false } });
});
test('Box: toggles show points', (done) => {
const el = mount({
globalSeriesType: 'box',
showpoints: false,
}, done);
findByTestID(el, 'Chart.ShowPoints').first().find('input')
.simulate('change', { target: { checked: true } });
});
test('Enables stacking', (done) => {
const el = mount({
globalSeriesType: 'column',
series: {},
}, done);
findByTestID(el, 'Chart.Stacking').first().simulate('click');
findByTestID(el, 'Chart.Stacking.Stack').first().simulate('click');
});
test('Toggles normalize values to percentage', (done) => {
const el = mount({
globalSeriesType: 'column',
series: {},
}, done);
findByTestID(el, 'Chart.NormalizeValues').first().find('input')
.simulate('change', { target: { checked: true } });
});
test('Keep missing/null values', (done) => {
const el = mount({
globalSeriesType: 'column',
missingValuesAsZero: true,
}, done);
findByTestID(el, 'Chart.MissingValues').first().simulate('click');
findByTestID(el, 'Chart.MissingValues.Keep').first().simulate('click');
});
describe('Column mappings should be available', () => {
test('for bubble', () => {
const el = mount({
globalSeriesType: 'column',
seriesOptions: {
a: { type: 'column' },
b: { type: 'bubble' },
c: { type: 'heatmap' },
},
});
expect(elementExists(el, 'Chart.ColumnMapping.x')).toBeTruthy();
expect(elementExists(el, 'Chart.ColumnMapping.y')).toBeTruthy();
expect(elementExists(el, 'Chart.ColumnMapping.size')).toBeTruthy();
});
test('for heatmap', () => {
const el = mount({
globalSeriesType: 'column',
seriesOptions: {
a: { type: 'column' },
b: { type: 'bubble' },
c: { type: 'heatmap' },
},
});
expect(elementExists(el, 'Chart.ColumnMapping.x')).toBeTruthy();
expect(elementExists(el, 'Chart.ColumnMapping.y')).toBeTruthy();
expect(elementExists(el, 'Chart.ColumnMapping.zVal')).toBeTruthy();
});
test('for all types except of bubble, heatmap and custom', () => {
const el = mount({
globalSeriesType: 'column',
seriesOptions: {
a: { type: 'column' },
b: { type: 'bubble' },
c: { type: 'heatmap' },
},
});
expect(elementExists(el, 'Chart.ColumnMapping.x')).toBeTruthy();
expect(elementExists(el, 'Chart.ColumnMapping.y')).toBeTruthy();
expect(elementExists(el, 'Chart.ColumnMapping.series')).toBeTruthy();
expect(elementExists(el, 'Chart.ColumnMapping.yError')).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,70 @@
import { map } from 'lodash';
import React from 'react';
import Select from 'antd/lib/select';
import * as Grid from 'antd/lib/grid';
import ColorPicker from '@/components/ColorPicker';
import { EditorPropTypes } from '@/visualizations';
import ColorPalette from '@/visualizations/ColorPalette';
const ColorSchemes = [
'Blackbody', 'Bluered', 'Blues', 'Earth', 'Electric',
'Greens', 'Greys', 'Hot', 'Jet', 'Picnic', 'Portland',
'Rainbow', 'RdBu', 'Reds', 'Viridis', 'YlGnBu', 'YlOrRd',
'Custom...',
];
export default function HeatmapColorsSettings({ options, onOptionsChange }) {
return (
<React.Fragment>
<div className="m-b-15">
<label htmlFor="chart-editor-colors-heatmap-scheme">Color Scheme</label>
<Select
id="chart-editor-colors-heatmap-scheme"
className="w-100"
data-test="Chart.Colors.Heatmap.ColorScheme"
placeholder="Choose Color Scheme..."
allowClear
value={options.colorScheme || undefined}
onChange={value => onOptionsChange({ colorScheme: value || null })}
>
{map(ColorSchemes, scheme => (
<Select.Option key={scheme} value={scheme} data-test={`Chart.Colors.Heatmap.ColorScheme.${scheme}`}>
{scheme}
</Select.Option>
))}
</Select>
</div>
{(options.colorScheme === 'Custom...') && (
<Grid.Row type="flex" align="middle" className="m-b-15">
<Grid.Col span={12}>
<label className="m-r-10" htmlFor="chart-editor-colors-heatmap-min-color">Min Color:</label>
<ColorPicker
data-test="Chart.Colors.Heatmap.MinColor"
id="chart-editor-colors-heatmap-min-color"
interactive
placement="topLeft"
presetColors={ColorPalette}
color={options.heatMinColor}
onChange={heatMinColor => onOptionsChange({ heatMinColor })}
/>
</Grid.Col>
<Grid.Col span={12}>
<label className="m-r-10" htmlFor="chart-editor-colors-heatmap-max-color">Max Color:</label>
<ColorPicker
data-test="Chart.Colors.Heatmap.MaxColor"
id="chart-editor-colors-heatmap-max-color"
interactive
placement="topRight"
presetColors={ColorPalette}
color={options.heatMaxColor}
onChange={heatMaxColor => onOptionsChange({ heatMaxColor })}
/>
</Grid.Col>
</Grid.Row>
)}
</React.Fragment>
);
}
HeatmapColorsSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,75 @@
import { each, map } from 'lodash';
import React, { useMemo, useCallback } from 'react';
import Table from 'antd/lib/table';
import ColorPicker from '@/components/ColorPicker';
import { EditorPropTypes } from '@/visualizations';
import ColorPalette from '@/visualizations/ColorPalette';
import getChartData from '../getChartData';
function getUniqueValues(chartData) {
const uniqueValuesNames = new Set();
each(chartData, (series) => {
each(series.data, (row) => {
uniqueValuesNames.add(row.x);
});
});
return [...uniqueValuesNames];
}
export default function PieColorsSettings({ options, data, onOptionsChange }) {
const colors = useMemo(() => ({
Automatic: null,
...ColorPalette,
}), []);
const series = useMemo(() => map(
getUniqueValues(getChartData(data.rows, options)),
value => ({ key: value, color: (options.valuesOptions[value] || {}).color || null }),
), [options, data]);
const updateValuesOption = useCallback((key, prop, value) => {
onOptionsChange({
valuesOptions: {
[key]: {
[prop]: value,
},
},
});
}, [onOptionsChange]);
const columns = [
{
title: 'Values',
dataIndex: 'key',
},
{
title: 'Color',
dataIndex: 'color',
width: '1%',
render: (unused, item) => (
<div className="text-nowrap">
<ColorPicker
data-test={`Chart.Series.${item.key}.Color`}
interactive
presetColors={colors}
placement="topRight"
color={item.color}
onChange={value => updateValuesOption(item.key, 'color', value)}
/>
<ColorPicker.Label color={item.color} presetColors={colors} />
</div>
),
},
];
return (
<Table
showHeader={false}
dataSource={series}
columns={columns}
pagination={false}
/>
);
}
PieColorsSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,138 @@
import { includes, map, extend, fromPairs } from 'lodash';
import React, { useMemo, useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import Table from 'antd/lib/table';
import Input from 'antd/lib/input';
import Radio from 'antd/lib/radio';
import { sortableElement } from 'react-sortable-hoc';
import { SortableContainer, DragHandle } from '@/components/sortable';
import { EditorPropTypes } from '@/visualizations';
import ChartTypeSelect from './ChartTypeSelect';
import getChartData from '../getChartData';
const SortableBodyRow = sortableElement(props => <tr {...props} />);
function getTableColumns(options, updateSeriesOption, debouncedUpdateSeriesOption) {
const result = [
{
title: 'Order',
dataIndex: 'zIndex',
className: 'text-nowrap',
render: (unused, item) => (
<span className="d-flex align-items-center">
<DragHandle />
{item.zIndex + 1}
</span>
),
},
{
title: 'Label',
dataIndex: 'name',
className: 'text-nowrap',
render: (unused, item) => (
<Input
data-test={`Chart.Series.${item.key}.Label`}
placeholder={item.key}
defaultValue={item.name}
onChange={event => debouncedUpdateSeriesOption(item.key, 'name', event.target.value)}
/>
),
},
];
if (!includes(['pie', 'heatmap'], options.globalSeriesType)) {
result.push({
title: 'Y Axis',
dataIndex: 'yAxis',
className: 'text-nowrap',
render: (unused, item) => (
<Radio.Group
className="text-nowrap"
value={item.yAxis === 1 ? 1 : 0}
onChange={event => updateSeriesOption(item.key, 'yAxis', event.target.value)}
>
<Radio value={0} data-test={`Chart.Series.${item.key}.UseLeftAxis`}>left</Radio>
<Radio value={1} data-test={`Chart.Series.${item.key}.UseRightAxis`}>right</Radio>
</Radio.Group>
),
});
result.push({
title: 'Type',
dataIndex: 'type',
className: 'text-nowrap',
render: (unused, item) => (
<ChartTypeSelect
className="w-100"
data-test={`Chart.Series.${item.key}.Type`}
dropdownMatchSelectWidth={false}
value={item.type}
onChange={value => updateSeriesOption(item.key, 'type', value)}
/>
),
});
}
return result;
}
export default function SeriesSettings({ options, data, onOptionsChange }) {
const series = useMemo(() => map(
getChartData(data.rows, options), // returns sorted series
({ name }, zIndex) => extend(
{ key: name, type: options.globalSeriesType },
options.seriesOptions[name],
{ zIndex },
),
), [options, data]);
const handleSortEnd = useCallback(({ oldIndex, newIndex }) => {
const seriesOptions = [...series];
seriesOptions.splice(newIndex, 0, ...seriesOptions.splice(oldIndex, 1));
onOptionsChange({ seriesOptions: fromPairs(map(seriesOptions, ({ key }, zIndex) => ([key, { zIndex }]))) });
}, [series]);
const updateSeriesOption = useCallback((key, prop, value) => {
onOptionsChange({
seriesOptions: {
[key]: {
[prop]: value,
},
},
});
}, [onOptionsChange]);
const [debouncedUpdateSeriesOption] = useDebouncedCallback(updateSeriesOption, 200);
const columns = useMemo(
() => getTableColumns(options, updateSeriesOption, debouncedUpdateSeriesOption),
[options, updateSeriesOption, debouncedUpdateSeriesOption],
);
return (
<SortableContainer
axis="y"
lockAxis="y"
lockToContainerEdges
useDragHandle
helperClass="chart-editor-series-dragged-item"
helperContainer={container => container.querySelector('tbody')}
onSortEnd={handleSortEnd}
containerProps={{
className: 'chart-editor-series',
}}
>
<Table
dataSource={series}
columns={columns}
components={{
body: {
row: SortableBodyRow,
},
}}
onRow={item => ({ index: item.zIndex })}
pagination={false}
/>
</SortableContainer>
);
}
SeriesSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,64 @@
import React from 'react';
import enzyme from 'enzyme';
import getOptions from '../getOptions';
import SeriesSettings from './SeriesSettings';
function findByTestID(wrapper, testId) {
return wrapper.find(`[data-test="${testId}"]`);
}
function mount(options, done) {
options = getOptions(options);
return enzyme.mount((
<SeriesSettings
visualizationName="Test"
data={{ columns: [{ name: 'a', type: 'string' }], rows: [{ a: 'test' }] }}
options={options}
onOptionsChange={(changedOptions) => {
expect(changedOptions).toMatchSnapshot();
done();
}}
/>
));
}
describe('Visualizations -> Chart -> Editor -> Series Settings', () => {
test('Changes series type', (done) => {
const el = mount({
globalSeriesType: 'column',
columnMapping: { a: 'y' },
seriesOptions: {
a: { type: 'column', label: 'a', yAxis: 0 },
},
}, done);
findByTestID(el, 'Chart.Series.a.Type').first().simulate('click');
findByTestID(el, 'Chart.ChartType.area').first().simulate('click');
});
test('Changes series label', (done) => {
const el = mount({
globalSeriesType: 'column',
columnMapping: { a: 'y' },
seriesOptions: {
a: { type: 'column', label: 'a', yAxis: 0 },
},
}, done);
findByTestID(el, 'Chart.Series.a.Label').first().simulate('change', { target: { value: 'test' } });
});
test('Changes series axis', (done) => {
const el = mount({
globalSeriesType: 'column',
columnMapping: { a: 'y' },
seriesOptions: {
a: { type: 'column', name: 'a', yAxis: 0 },
},
}, done);
findByTestID(el, 'Chart.Series.a.UseRightAxis').first().find('input')
.simulate('change', { target: { checked: true } });
});
});

View File

@@ -0,0 +1,56 @@
import React from 'react';
import Switch from 'antd/lib/switch';
import { EditorPropTypes } from '@/visualizations';
import AxisSettings from './AxisSettings';
export default function XAxisSettings({ options, onOptionsChange }) {
return (
<React.Fragment>
<AxisSettings
id="XAxis"
features={{ autoDetectType: true }}
options={options.xAxis}
onChange={xAxis => onOptionsChange({ xAxis })}
/>
<div className="m-b-15">
<label className="d-flex align-items-center" htmlFor="chart-editor-x-axis-sort">
<Switch
id="chart-editor-x-axis-sort"
data-test="Chart.XAxis.Sort"
defaultChecked={options.sortX}
onChange={sortX => onOptionsChange({ sortX })}
/>
<span className="m-l-10">Sort Values</span>
</label>
</div>
<div className="m-b-15">
<label className="d-flex align-items-center" htmlFor="chart-editor-x-axis-reverse">
<Switch
id="chart-editor-x-axis-reverse"
data-test="Chart.XAxis.Reverse"
defaultChecked={options.reverseX}
onChange={reverseX => onOptionsChange({ reverseX })}
/>
<span className="m-l-10">Reverse Order</span>
</label>
</div>
<div className="m-b-15">
<label className="d-flex align-items-center" htmlFor="chart-editor-x-axis-show-labels">
<Switch
id="chart-editor-x-axis-show-labels"
data-test="Chart.XAxis.ShowLabels"
defaultChecked={options.xAxis.labels.enabled}
onChange={enabled => onOptionsChange({ xAxis: { labels: { enabled } } })}
/>
<span className="m-l-10">Show Labels</span>
</label>
</div>
</React.Fragment>
);
}
XAxisSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,72 @@
import React from 'react';
import enzyme from 'enzyme';
import getOptions from '../getOptions';
import XAxisSettings from './XAxisSettings';
function findByTestID(wrapper, testId) {
return wrapper.find(`[data-test="${testId}"]`);
}
function mount(options, done) {
options = getOptions(options);
return enzyme.mount((
<XAxisSettings
visualizationName="Test"
data={{ columns: [], rows: [] }}
options={options}
onOptionsChange={(changedOptions) => {
expect(changedOptions).toMatchSnapshot();
done();
}}
/>
));
}
describe('Visualizations -> Chart -> Editor -> X-Axis Settings', () => {
test('Changes axis type', (done) => {
const el = mount({
globalSeriesType: 'column',
xAxis: { type: '-', labels: { enabled: true } },
}, done);
findByTestID(el, 'Chart.XAxis.Type').first().simulate('click');
findByTestID(el, 'Chart.XAxis.Type.Linear').first().simulate('click');
});
test('Changes axis name', (done) => {
const el = mount({
globalSeriesType: 'column',
xAxis: { type: '-', labels: { enabled: true } },
}, done);
findByTestID(el, 'Chart.XAxis.Name').first().simulate('change', { target: { value: 'test' } });
});
test('Sets Show Labels option', (done) => {
const el = mount({
globalSeriesType: 'column',
xAxis: { type: '-', labels: { enabled: false } },
}, done);
findByTestID(el, 'Chart.XAxis.ShowLabels').first().simulate('click');
});
test('Sets Sort X Values option', (done) => {
const el = mount({
globalSeriesType: 'column',
sortX: false,
}, done);
findByTestID(el, 'Chart.XAxis.Sort').first().simulate('click');
});
test('Sets Reverse X Values option', (done) => {
const el = mount({
globalSeriesType: 'column',
reverseX: false,
}, done);
findByTestID(el, 'Chart.XAxis.Reverse').first().simulate('click');
});
});

View File

@@ -0,0 +1,65 @@
import React from 'react';
import Switch from 'antd/lib/switch';
import { EditorPropTypes } from '@/visualizations';
import AxisSettings from './AxisSettings';
export default function YAxisSettings({ options, onOptionsChange }) {
const [leftYAxis, rightYAxis] = options.yAxis;
return (
<React.Fragment>
<div className="m-b-15">
<h4>Left Y Axis</h4>
<AxisSettings
id="LeftYAxis"
features={{ range: true }}
options={leftYAxis}
onChange={axis => onOptionsChange({ yAxis: [axis, rightYAxis] })}
/>
</div>
{(options.globalSeriesType !== 'heatmap') && (
<div className="m-b-15">
<h4>Right Y Axis</h4>
<AxisSettings
id="RightYAxis"
features={{ range: true }}
options={rightYAxis}
onChange={axis => onOptionsChange({ yAxis: [leftYAxis, axis] })}
/>
</div>
)}
{(options.globalSeriesType === 'heatmap') && (
<React.Fragment>
<div className="m-b-15">
<label className="d-flex align-items-center" htmlFor="chart-editor-y-axis-sort">
<Switch
id="chart-editor-y-axis-sort"
data-test="Chart.LeftYAxis.Sort"
defaultChecked={options.sortY}
onChange={sortY => onOptionsChange({ sortY })}
/>
<span className="m-l-10">Sort Values</span>
</label>
</div>
<div className="m-b-15">
<label className="d-flex align-items-center" htmlFor="chart-editor-y-axis-reverse">
<Switch
id="chart-editor-y-axis-reverse"
data-test="Chart.LeftYAxis.Reverse"
defaultChecked={options.reverseY}
onChange={reverseY => onOptionsChange({ reverseY })}
/>
<span className="m-l-10">Reverse Order</span>
</label>
</div>
</React.Fragment>
)}
</React.Fragment>
);
}
YAxisSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,107 @@
import React from 'react';
import enzyme from 'enzyme';
import getOptions from '../getOptions';
import YAxisSettings from './YAxisSettings';
function findByTestID(wrapper, testId) {
return wrapper.find(`[data-test="${testId}"]`);
}
function elementExists(wrapper, testId) {
return findByTestID(wrapper, testId).length > 0;
}
function mount(options, done) {
options = getOptions(options);
return enzyme.mount((
<YAxisSettings
visualizationName="Test"
data={{ columns: [], rows: [] }}
options={options}
onOptionsChange={(changedOptions) => {
expect(changedOptions).toMatchSnapshot();
done();
}}
/>
));
}
describe('Visualizations -> Chart -> Editor -> Y-Axis Settings', () => {
test('Changes axis type', (done) => {
const el = mount({
globalSeriesType: 'column',
yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
}, done);
findByTestID(el, 'Chart.LeftYAxis.Type').first().simulate('click');
findByTestID(el, 'Chart.LeftYAxis.Type.Category').first().simulate('click');
});
test('Changes axis name', (done) => {
const el = mount({
globalSeriesType: 'column',
yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
}, done);
findByTestID(el, 'Chart.LeftYAxis.Name').first().simulate('change', { target: { value: 'test' } });
});
test('Changes axis min value', (done) => {
const el = mount({
globalSeriesType: 'column',
yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
}, done);
findByTestID(el, 'Chart.LeftYAxis.RangeMin').find('input').first().simulate('change', { target: { value: '50' } });
});
test('Changes axis max value', (done) => {
const el = mount({
globalSeriesType: 'column',
yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
}, done);
findByTestID(el, 'Chart.LeftYAxis.RangeMax').find('input').first().simulate('change', { target: { value: '200' } });
});
describe('for non-heatmap', () => {
test('Right Y Axis should be available', () => {
const el = mount({
globalSeriesType: 'column',
yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
});
expect(elementExists(el, 'Chart.RightYAxis.Type')).toBeTruthy();
});
});
describe('for heatmap', () => {
test('Right Y Axis should not be available', () => {
const el = mount({
globalSeriesType: 'heatmap',
yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
});
expect(elementExists(el, 'Chart.RightYAxis.Type')).toBeFalsy();
});
test('Sets Sort X Values option', (done) => {
const el = mount({
globalSeriesType: 'heatmap',
sortY: false,
}, done);
findByTestID(el, 'Chart.LeftYAxis.Sort').first().simulate('click');
});
test('Sets Reverse Y Values option', (done) => {
const el = mount({
globalSeriesType: 'heatmap',
reverseY: false,
}, done);
findByTestID(el, 'Chart.LeftYAxis.Reverse').first().simulate('click');
});
});
});

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Visualizations -> Chart -> Editor -> Colors Settings for all except of pie and heatmap Changes series color 1`] = `
Object {
"seriesOptions": Object {
"b": Object {
"color": "#FF0000",
},
},
}
`;
exports[`Visualizations -> Chart -> Editor -> Colors Settings for heatmap Changes color scheme 1`] = `
Object {
"colorScheme": "RdBu",
}
`;
exports[`Visualizations -> Chart -> Editor -> Colors Settings for heatmap Sets custom color scheme 1`] = `
Object {
"heatMinColor": "#FFFF00",
}
`;
exports[`Visualizations -> Chart -> Editor -> Colors Settings for heatmap Sets custom color scheme 2`] = `
Object {
"heatMinColor": "#FF0000",
}
`;
exports[`Visualizations -> Chart -> Editor -> Colors Settings for pie Changes series color 1`] = `
Object {
"valuesOptions": Object {
"v": Object {
"color": "#FF0000",
},
},
}
`;

View File

@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Changes data labels format 1`] = `
Object {
"textFormat": "{{ @@x }} :: {{ @@y }} / {{ @@yPercent }}",
}
`;
exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Changes date/time format 1`] = `
Object {
"dateTimeFormat": "YYYY MMM DD",
}
`;
exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Changes number format 1`] = `
Object {
"numberFormat": "0.00",
}
`;
exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Changes percent values format 1`] = `
Object {
"percentFormat": "0.0%",
}
`;
exports[`Visualizations -> Chart -> Editor -> Data Labels Settings Sets Show Data Labels option 1`] = `
Object {
"showDataLabels": true,
}
`;

View File

@@ -0,0 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Visualizations -> Chart -> Editor -> General Settings Box: toggles show points 1`] = `
Object {
"showpoints": true,
}
`;
exports[`Visualizations -> Chart -> Editor -> General Settings Changes global series type 1`] = `
Object {
"globalSeriesType": "pie",
"seriesOptions": Object {
"a": Object {
"type": "pie",
},
"b": Object {
"type": "pie",
},
},
"showDataLabels": true,
}
`;
exports[`Visualizations -> Chart -> Editor -> General Settings Enables stacking 1`] = `
Object {
"series": Object {
"stacking": "stack",
},
}
`;
exports[`Visualizations -> Chart -> Editor -> General Settings Keep missing/null values 1`] = `
Object {
"missingValuesAsZero": false,
}
`;
exports[`Visualizations -> Chart -> Editor -> General Settings Pie: changes direction 1`] = `
Object {
"direction": Object {
"type": "clockwise",
},
}
`;
exports[`Visualizations -> Chart -> Editor -> General Settings Toggles legend 1`] = `
Object {
"legend": Object {
"enabled": false,
},
}
`;
exports[`Visualizations -> Chart -> Editor -> General Settings Toggles normalize values to percentage 1`] = `
Object {
"series": Object {
"percentValues": true,
},
}
`;

View File

@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Visualizations -> Chart -> Editor -> Series Settings Changes series axis 1`] = `
Object {
"seriesOptions": Object {
"a": Object {
"yAxis": 1,
},
},
}
`;
exports[`Visualizations -> Chart -> Editor -> Series Settings Changes series label 1`] = `
Object {
"seriesOptions": Object {
"a": Object {
"name": "test",
},
},
}
`;
exports[`Visualizations -> Chart -> Editor -> Series Settings Changes series type 1`] = `
Object {
"seriesOptions": Object {
"a": Object {
"type": "area",
},
},
}
`;

View File

@@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Changes axis name 1`] = `
Object {
"xAxis": Object {
"labels": Object {
"enabled": true,
},
"title": Object {
"text": "test",
},
"type": "-",
},
}
`;
exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Changes axis type 1`] = `
Object {
"xAxis": Object {
"labels": Object {
"enabled": true,
},
"type": "linear",
},
}
`;
exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Sets Reverse X Values option 1`] = `
Object {
"reverseX": true,
}
`;
exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Sets Show Labels option 1`] = `
Object {
"xAxis": Object {
"labels": Object {
"enabled": true,
},
},
}
`;
exports[`Visualizations -> Chart -> Editor -> X-Axis Settings Sets Sort X Values option 1`] = `
Object {
"sortX": true,
}
`;

View File

@@ -0,0 +1,74 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings Changes axis max value 1`] = `
Object {
"yAxis": Array [
Object {
"rangeMax": 200,
"type": "linear",
},
Object {
"opposite": true,
"type": "linear",
},
],
}
`;
exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings Changes axis min value 1`] = `
Object {
"yAxis": Array [
Object {
"rangeMin": 50,
"type": "linear",
},
Object {
"opposite": true,
"type": "linear",
},
],
}
`;
exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings Changes axis name 1`] = `
Object {
"yAxis": Array [
Object {
"title": Object {
"text": "test",
},
"type": "linear",
},
Object {
"opposite": true,
"type": "linear",
},
],
}
`;
exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings Changes axis type 1`] = `
Object {
"yAxis": Array [
Object {
"type": "category",
},
Object {
"opposite": true,
"type": "linear",
},
],
}
`;
exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings for heatmap Sets Reverse Y Values option 1`] = `
Object {
"reverseY": true,
}
`;
exports[`Visualizations -> Chart -> Editor -> Y-Axis Settings for heatmap Sets Sort X Values option 1`] = `
Object {
"sortY": true,
}
`;

View File

@@ -0,0 +1,33 @@
.chart-editor-series {
.drag-handle {
height: 28px;
padding: 0 5px;
margin-left: -5px;
}
&.sortable-container {
table {
background: transparent;
}
thead th {
// TODO: replace with @table-header-bg
// Cannot do it not because of conflict between Antd and Bootstrap variables
background: mix(#ffffff, rgb(102, 136, 153), 97%) !important;
}
&.sortable-container-dragging tbody {
td {
background: transparent !important;
}
.chart-editor-series-dragged-item {
td {
// TODO: replace with @table-row-hover-bg
// Cannot do it not because of conflict between Antd and Bootstrap variables
background: mix(#ffffff, rgb(102, 136, 153), 95%) !important;
}
}
}
}
}

View File

@@ -0,0 +1,65 @@
import { merge, extend } from 'lodash';
import React from 'react';
import Tabs from 'antd/lib/tabs';
import { EditorPropTypes } from '@/visualizations';
import GeneralSettings from './GeneralSettings';
import XAxisSettings from './XAxisSettings';
import YAxisSettings from './YAxisSettings';
import SeriesSettings from './SeriesSettings';
import ColorsSettings from './ColorsSettings';
import DataLabelsSettings from './DataLabelsSettings';
import CustomChartSettings from './CustomChartSettings';
import './editor.less';
export default function Editor(props) {
const { options, onOptionsChange } = props;
const optionsChanged = (newOptions, deepUpdate = true) => {
if (deepUpdate) {
onOptionsChange(merge({}, options, newOptions));
} else {
onOptionsChange(extend({}, options, newOptions));
}
};
const isCustomChart = options.globalSeriesType === 'custom';
const isPieChart = options.globalSeriesType === 'pie';
return (
<Tabs animated={false} tabBarGutter={0}>
<Tabs.TabPane key="general" tab={<span data-test="Chart.EditorTabs.General">General</span>}>
<GeneralSettings {...props} onOptionsChange={optionsChanged} />
{isCustomChart && <CustomChartSettings {...props} onOptionsChange={optionsChanged} />}
</Tabs.TabPane>
{!isCustomChart && !isPieChart && (
<Tabs.TabPane key="x-axis" tab={<span data-test="Chart.EditorTabs.XAxis">X Axis</span>}>
<XAxisSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
)}
{!isCustomChart && !isPieChart && (
<Tabs.TabPane key="y-axis" tab={<span data-test="Chart.EditorTabs.YAxis">Y Axis</span>}>
<YAxisSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
)}
{!isCustomChart && (
<Tabs.TabPane key="series" tab={<span data-test="Chart.EditorTabs.Series">Series</span>}>
<SeriesSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
)}
{!isCustomChart && (
<Tabs.TabPane key="colors" tab={<span data-test="Chart.EditorTabs.Colors">Colors</span>}>
<ColorsSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
)}
{!isCustomChart && (
<Tabs.TabPane key="data labels" tab={<span data-test="Chart.EditorTabs.DataLabels">Data Labels</span>}>
<DataLabelsSettings {...props} onOptionsChange={optionsChanged} />
</Tabs.TabPane>
)}
</Tabs>
);
}
Editor.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import enzyme from 'enzyme';
import getOptions from '../getOptions';
import Editor from './index';
function findByTestID(wrapper, testId) {
return wrapper.find(`[data-test="${testId}"]`);
}
function elementExists(wrapper, testId) {
return findByTestID(wrapper, testId).length > 0;
}
function mount(options, data) {
options = getOptions(options);
return enzyme.mount((
<Editor
visualizationName="Test"
data={data}
options={options}
onOptionsChange={() => {}}
/>
));
}
describe('Visualizations -> Chart -> Editor (wrapper)', () => {
test('Renders generic wrapper', () => {
const el = mount({ globalSeriesType: 'column' }, { columns: [], rows: [] });
expect(elementExists(el, 'Chart.EditorTabs.General')).toBeTruthy();
expect(elementExists(el, 'Chart.EditorTabs.XAxis')).toBeTruthy();
expect(elementExists(el, 'Chart.EditorTabs.YAxis')).toBeTruthy();
expect(elementExists(el, 'Chart.EditorTabs.Series')).toBeTruthy();
expect(elementExists(el, 'Chart.EditorTabs.Colors')).toBeTruthy();
expect(elementExists(el, 'Chart.EditorTabs.DataLabels')).toBeTruthy();
expect(elementExists(el, 'Chart.GlobalSeriesType')).toBeTruthy(); // general settings block exists
expect(elementExists(el, 'Chart.Custom.Code')).toBeFalsy(); // custom settings block does not exist
});
test('Renders wrapper for custom charts', () => {
const el = mount({ globalSeriesType: 'custom' }, { columns: [], rows: [] });
expect(elementExists(el, 'Chart.EditorTabs.General')).toBeTruthy();
expect(elementExists(el, 'Chart.EditorTabs.XAxis')).toBeFalsy();
expect(elementExists(el, 'Chart.EditorTabs.YAxis')).toBeFalsy();
expect(elementExists(el, 'Chart.EditorTabs.Series')).toBeFalsy();
expect(elementExists(el, 'Chart.EditorTabs.Colors')).toBeFalsy();
expect(elementExists(el, 'Chart.EditorTabs.DataLabels')).toBeFalsy();
expect(elementExists(el, 'Chart.GlobalSeriesType')).toBeTruthy(); // general settings block exists
expect(elementExists(el, 'Chart.Custom.Code')).toBeTruthy(); // custom settings block exists
});
});

View File

@@ -1,483 +0,0 @@
<div>
<ul class="tab-nav">
<li ng-class="{active: $ctrl.currentTab == 'general'}">
<a ng-click="$ctrl.setCurrentTab('general')">General</a>
</li>
<li ng-class="{active: $ctrl.currentTab == 'xAxis'}" ng-if="$ctrl.options.globalSeriesType != 'custom'">
<a ng-click="$ctrl.setCurrentTab('xAxis')">X Axis</a>
</li>
<li ng-class="{active: $ctrl.currentTab == 'yAxis'}" ng-if="$ctrl.options.globalSeriesType != 'custom'">
<a ng-click="$ctrl.setCurrentTab('yAxis')">Y Axis</a>
</li>
<li ng-class="{active: $ctrl.currentTab == 'series'}" ng-if="$ctrl.options.globalSeriesType != 'custom'">
<a ng-click="$ctrl.setCurrentTab('series')">Series</a>
</li>
<li ng-class="{active: $ctrl.currentTab == 'colors'}" ng-if="$ctrl.options.globalSeriesType != 'custom'">
<a ng-click="$ctrl.setCurrentTab('colors')">Colors</a>
</li>
<li ng-class="{active: $ctrl.currentTab == 'dataLabels'}" ng-if="$ctrl.options.globalSeriesType != 'custom'">
<a ng-click="$ctrl.setCurrentTab('dataLabels')">Data Labels</a>
</li>
</ul>
<div ng-if="$ctrl.currentTab == 'general'" class="m-t-10 m-b-10">
<div class="form-group">
<label class="control-label">Chart Type</label>
<div ng-if="$ctrl.chartTypes">
<!--the if is a weird workaround-->
<ui-select ng-model="$ctrl.options.globalSeriesType" on-select="$ctrl.chartTypeChanged()">
<ui-select-match placeholder="Choose chart type...">
<div>
<i class="fa fa-{{$select.selected.value.icon}}"></i>
{{$select.selected.value.name}}
</div>
</ui-select-match>
<ui-select-choices repeat="info.chartType as (chartType, info) in $ctrl.chartTypes">
<div>
<i class="fa fa-{{info.value.icon}}"></i>
<span> </span>
<span ng-bind-html="info.value.name | highlight: $select.search"></span>
</div>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="form-group" ng-class="{'has-error': chartEditor.xAxisColumn.$invalid}">
<label class="control-label">X Column</label>
<ui-select name="xAxisColumn" required ng-model="$ctrl.form.xAxisColumn">
<ui-select-match placeholder="Choose column...">{{$select.selected}}</ui-select-match>
<ui-select-choices repeat="column in $ctrl.columnNames | remove:$ctrl.form.yAxisColumns | remove:$ctrl.form.groupby">
<span ng-bind-html="column | highlight: $select.search"></span>
<span> </span>
<small class="text-muted" ng-bind="$ctrl.columns[column].type"></small>
</ui-select-choices>
</ui-select>
</div>
<!-- not using regular validation (chartEditor.yAxisColumns.$invalid) due to a bug in ui-select with multiple choices-->
<div class="form-group" ng-class="{'has-error': !$ctrl.form.yAxisColumns || $ctrl.form.yAxisColumns.length == 0}">
<label class="control-label">Y Columns</label>
<ui-select multiple name="yAxisColumns" required ng-model="$ctrl.form.yAxisColumns">
<ui-select-match placeholder="Choose columns...">{{$item}}</ui-select-match>
<ui-select-choices repeat="column in $ctrl.columnNames | remove:$ctrl.form.groupby | remove:$ctrl.form.xAxisColumn">
<span ng-bind-html="column | highlight: $select.search"></span>
<span> </span>
<small class="text-muted" ng-bind="$ctrl.columns[column].type"></small>
</ui-select-choices>
</ui-select>
</div>
<div class="form-group" ng-if="['custom', 'heatmap'].indexOf($ctrl.options.globalSeriesType) == -1">
<label class="control-label">Group by</label>
<ui-select name="groupby" ng-model="$ctrl.form.groupby" class="clearable">
<ui-select-match allow-clear="true" placeholder="Choose column...">
{{$select.selected}}
</ui-select-match>
<ui-select-choices repeat="column in $ctrl.columnNames | remove:$ctrl.form.yAxisColumns | remove:$ctrl.form.xAxisColumn">
<span ng-bind-html="column | highlight: $select.search"></span>
<span> </span>
<small class="text-muted" ng-bind="$ctrl.columns[column].type"></small>
</ui-select-choices>
</ui-select>
</div>
<div class="form-group" ng-if="$ctrl.showSizeColumnPicker()">
<label class="control-label">Bubble size column</label>
<ui-select name="sizeColumn" ng-model="$ctrl.form.sizeColumn">
<ui-select-match allow-clear="true" placeholder="Choose column...">{{$select.selected}}</ui-select-match>
<ui-select-choices repeat="column in $ctrl.columnNames | remove:$ctrl.form.yAxisColumns | remove:$ctrl.form.groupby">
<span ng-bind-html="column | highlight: $select.search"></span>
<span> </span>
<small class="text-muted" ng-bind="$ctrl.columns[column].type"></small>
</ui-select-choices>
</ui-select>
</div>
<div class="form-group" ng-if="$ctrl.showZColumnPicker()">
<label class="control-label">Color Column</label>
<ui-select name="zValColumn" ng-model="$ctrl.form.zValColumn">
<ui-select-match allow-clear="true" placeholder="Choose column...">{{$select.selected}}</ui-select-match>
<ui-select-choices repeat="column in $ctrl.columnNames | remove:$ctrl.form.yAxisColumns | remove:$ctrl.form.groupby">
<span ng-bind-html="column | highlight: $select.search"></span><span> </span>
<small class="text-muted" ng-bind="$ctrl.columns[column].type"></small>
</ui-select-choices>
</ui-select>
</div>
<div class="form-group" ng-if="['custom', 'heatmap'].indexOf($ctrl.options.globalSeriesType) == -1">
<label class="control-label">Errors column</label>
<ui-select name="errorColumn" ng-model="$ctrl.form.errorColumn">
<ui-select-match allow-clear="true" placeholder="Choose column...">{{$select.selected}}</ui-select-match>
<ui-select-choices repeat="column in $ctrl.columnNames | remove:$ctrl.form.yAxisColumns | remove:$ctrl.form.groupby">
<span ng-bind-html="column | highlight: $select.search"></span>
<span> </span>
<small class="text-muted" ng-bind="$ctrl.columns[column].type"></small>
</ui-select-choices>
</ui-select>
</div>
<div class="form-group" ng-if="$ctrl.options.globalSeriesType === 'pie'">
<label class="control-label">Direction</label>
<ui-select ng-model="$ctrl.options.direction.type">
<ui-select-match placeholder="Choose Direction...">{{$select.selected.label}}</ui-select-match>
<ui-select-choices repeat="direction.value as direction in $ctrl.directions">
<div ng-bind-html="direction.label | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>
</div>
<div class="checkbox" ng-if="['custom', 'heatmap'].indexOf($ctrl.options.globalSeriesType) == -1">
<label>
<input type="checkbox" ng-model="$ctrl.options.legend.enabled">
<i class="input-helper"></i> Show Legend
</label>
</div>
<div class="checkbox" ng-if="$ctrl.options.globalSeriesType == 'box'">
<label>
<input type="checkbox" ng-model="$ctrl.options.showpoints">
<i class="input-helper"></i> Show All Points
</label>
</div>
<div class="form-group" ng-if="['custom', 'heatmap'].indexOf($ctrl.options.globalSeriesType) == -1">
<label class="control-label">Stacking</label>
<div ng-if="$ctrl.stackingOptions">
<!--the if is a weird workaround-->
<ui-select
ng-model="$ctrl.options.series.stacking"
ng-disabled="['line', 'area', 'column'].indexOf($ctrl.options.globalSeriesType) == -1"
>
<ui-select-match placeholder="Choose stacking...">{{ $select.selected.key }}</ui-select-match>
<ui-select-choices repeat="value.value as (key, value) in $ctrl.stackingOptions">
<div ng-bind-html="value.key | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>
</div>
<div ng-if="['line', 'area', 'column'].indexOf($ctrl.options.globalSeriesType) >= 0" class="checkbox">
<label class="control-label">
<input type="checkbox" ng-model="$ctrl.options.series.percentValues"> Normalize values to percentage
</label>
</div>
<div ng-if="['bubble', 'scatter'].indexOf($ctrl.options.globalSeriesType) === -1" class="checkbox">
<label class="control-label">
<input type="checkbox" ng-model="$ctrl.options.missingValuesAsZero"> Treat missing/null values as 0
</label>
</div>
</div>
</div>
<div ng-if="$ctrl.options.globalSeriesType == 'custom'">
<div class="form-group">
<label class="control-label">Custom code</label>
<textarea ng-model="$ctrl.options.customCode" ng-model-options="{ debounce: 300 }" class="form-control v-resizable" rows="10">
</textarea>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="$ctrl.options.enableConsoleLogs">
<i class="input-helper"></i> Show errors in the console
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="$ctrl.options.autoRedraw">
<i class="input-helper"></i> Auto update graph
</label>
</div>
</div>
<div ng-if="$ctrl.currentTab == 'xAxis'" class="m-t-10 m-b-10">
<div class="form-group">
<label class="control-label">Scale</label>
<ui-select ng-model="$ctrl.options.xAxis.type">
<ui-select-match placeholder="Choose Scale...">{{$select.selected.label}}</ui-select-match>
<ui-select-choices repeat="scaleType.value as scaleType in $ctrl.xAxisScales">
<div ng-bind-html="scaleType.label | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>
</div>
<div class="form-group">
<label class="control-label">Name</label>
<input ng-model="$ctrl.options.xAxis.title.text" type="text" class="form-control">
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="$ctrl.options.sortX">
<i class="input-helper"></i> Sort Values
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="$ctrl.options.reverseX">
<i class="input-helper"></i> Reverse Order
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="$ctrl.options.xAxis.labels.enabled">
<i class="input-helper"></i> Show Labels
</label>
</div>
</div>
<div ng-if="$ctrl.currentTab == 'yAxis'" class="m-t-10 m-b-10">
<div ng-repeat="yAxis in $ctrl.options.yAxis">
<h4>{{$index == 0 ? 'Left' : 'Right'}} Y Axis</h4>
<div class="form-group">
<label class="control-label">Scale</label>
<ui-select ng-model="yAxis.type">
<ui-select-match placeholder="Choose Scale...">{{$select.selected | capitalize}}</ui-select-match>
<ui-select-choices repeat="scaleType in $ctrl.yAxisScales">
<div ng-bind-html="scaleType | capitalize | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>
</div>
<div class="form-group">
<label class="control-label">Name</label>
<input ng-model="yAxis.title.text" type="text" class="form-control">
</div>
<div class="form-group">
<label class="control-label">Min Value</label>
<input ng-model="yAxis.rangeMin" type="number" step="any" placeholder="Auto" class="form-control">
</div>
<div class="form-group">
<label class="control-label">Max Value</label>
<input ng-model="yAxis.rangeMax" type="number" step="any" placeholder="Auto" class="form-control">
</div>
<div class="checkbox" ng-if="$ctrl.options.globalSeriesType == 'heatmap'">
<label>
<input type="checkbox" ng-model="$ctrl.options.sortY">
<i class="input-helper"></i> Sort Values
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="$ctrl.options.reverseY">
<i class="input-helper"></i> Reverse Order
</label>
</div>
</div>
</div>
<div ng-if="$ctrl.currentTab == 'series'" class="m-t-10 m-b-10">
<table class="table table-condensed col-table">
<thead>
<th style="width: 1%;">zIndex</th>
<th ng-if="$ctrl.options.globalSeriesType != 'pie'">Left Y Axis</th>
<th ng-if="$ctrl.options.globalSeriesType != 'pie'">Right Y Axis</th>
<th>Label</th>
<th ng-if="$ctrl.options.globalSeriesType != 'pie'">Type</th>
</thead>
<tbody ui-sortable ng-model="$ctrl.form.seriesList">
<tr ng-repeat="name in $ctrl.form.seriesList">
<td style="width: 1%; cursor: move;">
<i class="fa fa-arrows-v"></i>
<span ng-bind="$ctrl.options.seriesOptions[name].zIndex + 1"></span>
</td>
<td ng-if="$ctrl.options.globalSeriesType != 'pie'">
<input type="radio" ng-value="0" ng-model="$ctrl.options.seriesOptions[name].yAxis">
</td>
<td ng-if="$ctrl.options.globalSeriesType != 'pie'">
<input type="radio" ng-value="1" ng-model="$ctrl.options.seriesOptions[name].yAxis">
</td>
<td style="padding: 3px; width: 140px;">
<input
placeholder="{{name}}"
class="form-control input-sm super-small-input" type="text"
ng-model="$ctrl.options.seriesOptions[name].name"
>
</td>
<td ng-if="$ctrl.options.globalSeriesType != 'pie'" style="padding: 3px; width: 105px;">
<ui-select ng-model="$ctrl.options.seriesOptions[name].type">
<ui-select-match placeholder="Chart Type">
<div>
<i class="fa fa-{{$select.selected.value.icon}}"></i>
{{$select.selected.value.name}}
</div>
</ui-select-match>
<ui-select-choices repeat="info.chartType as (chartType, info) in $ctrl.chartTypes">
<div>
<i class="fa fa-{{info.value.icon}}"></i>
<span> </span>
<span ng-bind-html="info.value.name | highlight: $select.search"></span>
</div>
</ui-select-choices>
</ui-select>
</td>
</tr>
</tbody>
</table>
</div>
<div ng-if="($ctrl.currentTab == 'colors') && (['pie', 'heatmap'].indexOf($ctrl.options.globalSeriesType) == -1)" class="m-t-10 m-b-10">
<table class="table table-condensed col-table">
<tbody>
<tr ng-repeat="name in $ctrl.form.seriesList">
<td style="padding: 3px; width: 140px;">
<div>{{ name }}</div>
</td>
<td style="padding: 3px; width: 35px;">
<ui-select ng-model="$ctrl.options.seriesOptions[name].color">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in $ctrl.colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</td>
</tr>
</tbody>
</table>
</div>
<div ng-if="($ctrl.currentTab == 'colors') && ($ctrl.options.globalSeriesType == 'heatmap')" class="m-t-10 m-b-10">
<div class="form-group" ng-if="$ctrl.options.globalSeriesType == 'heatmap'">
<label class="control-label">Color Scheme</label>
<ui-select ng-model="$ctrl.options.colorScheme">
<ui-select-match allow-clear="true" placeholder="Choose Color Scheme...">{{$select.selected | capitalize}}</ui-select-match>
<ui-select-choices repeat="value in $ctrl.colorScheme">
<div ng-bind-html="value | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group" ng-if="$ctrl.options.colorScheme == 'Custom...'">
<label class="control-label">Min Color</label>
<ui-select ng-model="$ctrl.options.heatMinColor">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in $ctrl.colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group" ng-if="$ctrl.options.colorScheme == 'Custom...'">
<label class="control-label">Max Color</label>
<ui-select ng-model="$ctrl.options.heatMaxColor">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in $ctrl.colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
</div>
</div>
<div ng-if="($ctrl.currentTab == 'colors') && ($ctrl.options.globalSeriesType == 'pie')" class="m-t-10 m-b-10">
<table class="table table-condensed col-table">
<tbody>
<tr ng-repeat="name in $ctrl.form.valuesList">
<td style="padding: 3px; width: 140px;">
<div>{{ name }}</div>
</td>
<td style="padding: 3px; width: 35px;">
<ui-select ng-model="$ctrl.options.valuesOptions[name].color">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in $ctrl.colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</td>
</tr>
</tbody>
</table>
</div>
<div ng-if="$ctrl.currentTab == 'dataLabels'" class="m-t-10 m-b-10">
<div ng-if="['line', 'area', 'column', 'scatter', 'pie', 'heatmap'].indexOf($ctrl.options.globalSeriesType) >= 0" class="checkbox">
<label>
<input type="checkbox" ng-model="$ctrl.options.showDataLabels"> Show Data Labels</label>
</div>
<div class="form-group">
<label for="chart-editor-number-format">
Number Values Format
<span class="m-l-5" uib-popover-html="'Format <a href=&quot;https://redash.io/help/user-guide/visualizations/formatting-numbers&quot; target=&quot;_blank&quot;>specs.</a>'"
popover-trigger="'click outsideClick'">
<i class="fa fa-question-circle"></i>
</span>
</label>
<input
class="form-control" ng-model="$ctrl.options.numberFormat"
ng-model-options="{ allowInvalid: true, debounce: 200 }" id="chart-editor-number-format"
>
</div>
<div class="form-group">
<label for="chart-editor-percent-format">
Percent Values Format
<span class="m-l-5" uib-popover-html="'Format <a href=&quot;https://redash.io/help/user-guide/visualizations/formatting-numbers&quot; target=&quot;_blank&quot;>specs.</a>'"
popover-trigger="'click outsideClick'">
<i class="fa fa-question-circle"></i>
</span>
</label>
<input
class="form-control" ng-model="$ctrl.options.percentFormat"
ng-model-options="{ allowInvalid: true, debounce: 200 }" id="chart-editor-percent-format"
>
</div>
<div class="form-group">
<label for="chart-editor-datetime-format">
Date/Time Values Format
<span class="m-l-5" uib-popover-html="'Format <a href=&quot;https://momentjs.com/docs/#/displaying/format/&quot; target=&quot;_blank&quot;>specs.</a>'"
popover-trigger="'click outsideClick'">
<i class="fa fa-question-circle"></i>
</span>
</label>
<input
class="form-control" ng-model="$ctrl.options.dateTimeFormat"
ng-model-options="{ allowInvalid: true, debounce: 200 }" id="chart-editor-datetime-format"
>
</div>
<div class="form-group">
<label for="chart-editor-text">
Data Labels
<i class="fa fa-question-circle m-l-5" uib-popover-html="$ctrl.templateHint"
popover-trigger="'click outsideClick'" popover-placement="top-left"></i>
</label>
<input class="form-control" ng-model="$ctrl.options.textFormat"
ng-model-options="{ allowInvalid: true, debounce: 200 }" id="chart-editor-text"
placeholder="(auto)"
>
</div>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import { merge } from 'lodash';
import { clientConfig } from '@/services/auth';
const DEFAULT_OPTIONS = {
globalSeriesType: 'column',
sortX: true,
legend: { enabled: true },
yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
xAxis: { type: '-', labels: { enabled: true } },
error_y: { type: 'data', visible: true },
series: { stacking: null, error_y: { type: 'data', visible: true } },
seriesOptions: {},
valuesOptions: {},
columnMapping: {},
direction: { type: 'counterclockwise' },
// showDataLabels: false, // depends on chart type
numberFormat: '0,0[.]00000',
percentFormat: '0[.]00%',
// dateTimeFormat: 'DD/MM/YYYY HH:mm', // will be set from clientConfig
textFormat: '', // default: combination of {{ @@yPercent }} ({{ @@y }} ± {{ @@yError }})
missingValuesAsZero: true,
};
export default function getOptions(options) {
const result = merge({}, DEFAULT_OPTIONS, {
showDataLabels: options.globalSeriesType === 'pie',
dateTimeFormat: clientConfig.dateTimeFormat,
}, options);
// Backward compatibility
if (['normal', 'percent'].indexOf(result.series.stacking) >= 0) {
result.series.percentValues = result.series.stacking === 'percent';
result.series.stacking = 'stack';
}
return result;
}

View File

@@ -1,321 +1,22 @@
import {
some, partial, intersection, without, includes, sortBy, each, map, keys, difference, merge, isNil, trim, pick,
} from 'lodash';
import { angular2react } from 'angular2react';
import { registerVisualization } from '@/visualizations';
import { clientConfig } from '@/services/auth';
import ColorPalette from '@/visualizations/ColorPalette';
import getChartData from './getChartData';
import editorTemplate from './chart-editor.html';
import getOptions from './getOptions';
import Renderer from './Renderer';
import Editor from './Editor';
const DEFAULT_OPTIONS = {
globalSeriesType: 'column',
sortX: true,
legend: { enabled: true },
yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }],
xAxis: { type: '-', labels: { enabled: true } },
error_y: { type: 'data', visible: true },
series: { stacking: null, error_y: { type: 'data', visible: true } },
seriesOptions: {},
valuesOptions: {},
columnMapping: {},
direction: { type: 'counterclockwise' },
export default function init() {
registerVisualization({
type: 'CHART',
name: 'Chart',
isDefault: true,
getOptions,
Renderer,
Editor,
// showDataLabels: false, // depends on chart type
numberFormat: '0,0[.]00000',
percentFormat: '0[.]00%',
// dateTimeFormat: 'DD/MM/YYYY HH:mm', // will be set from clientConfig
textFormat: '', // default: combination of {{ @@yPercent }} ({{ @@y }} ± {{ @@yError }})
missingValuesAsZero: true,
};
function initEditorForm(options, columns) {
const result = {
yAxisColumns: [],
seriesList: sortBy(keys(options.seriesOptions), name => options.seriesOptions[name].zIndex),
valuesList: keys(options.valuesOptions),
};
// Use only mappings for columns that exists in query results
const mappings = pick(
options.columnMapping,
map(columns, c => c.name),
);
each(mappings, (type, column) => {
switch (type) {
case 'x':
result.xAxisColumn = column;
break;
case 'y':
result.yAxisColumns.push(column);
break;
case 'series':
result.groupby = column;
break;
case 'yError':
result.errorColumn = column;
break;
case 'size':
result.sizeColumn = column;
break;
case 'zVal':
result.zValColumn = column;
break;
// no default
}
});
return result;
}
const ChartEditor = {
template: editorTemplate,
bindings: {
data: '<',
options: '<',
onOptionsChange: '<',
},
controller($scope) {
this.currentTab = 'general';
this.setCurrentTab = (tab) => {
this.currentTab = tab;
};
this.colors = {
Automatic: null,
...ColorPalette,
};
this.stackingOptions = {
Disabled: null,
Stack: 'stack',
};
this.chartTypes = {
line: { name: 'Line', icon: 'line-chart' },
column: { name: 'Bar', icon: 'bar-chart' },
area: { name: 'Area', icon: 'area-chart' },
pie: { name: 'Pie', icon: 'pie-chart' },
scatter: { name: 'Scatter', icon: 'circle-o' },
bubble: { name: 'Bubble', icon: 'circle-o' },
heatmap: { name: 'Heatmap', icon: 'th' },
box: { name: 'Box', icon: 'square-o' },
};
if (clientConfig.allowCustomJSVisualizations) {
this.chartTypes.custom = { name: 'Custom', icon: 'code' };
}
this.directions = [
{ label: 'Counterclockwise', value: 'counterclockwise' },
{ label: 'Clockwise', value: 'clockwise' },
];
this.xAxisScales = [
{ label: 'Auto Detect', value: '-' },
{ label: 'Datetime', value: 'datetime' },
{ label: 'Linear', value: 'linear' },
{ label: 'Logarithmic', value: 'logarithmic' },
{ label: 'Category', value: 'category' },
];
this.yAxisScales = ['linear', 'logarithmic', 'datetime', 'category'];
this.colorScheme = ['Blackbody', 'Bluered', 'Blues', 'Earth', 'Electric',
'Greens', 'Greys', 'Hot', 'Jet', 'Picnic', 'Portland',
'Rainbow', 'RdBu', 'Reds', 'Viridis', 'YlGnBu', 'YlOrRd', 'Custom...'];
this.chartTypeChanged = () => {
keys(this.options.seriesOptions).forEach((key) => {
this.options.seriesOptions[key].type = this.options.globalSeriesType;
});
this.options.showDataLabels = this.options.globalSeriesType === 'pie';
$scope.$applyAsync();
};
this.showSizeColumnPicker = () => some(this.options.seriesOptions, options => options.type === 'bubble');
this.showZColumnPicker = () => some(this.options.seriesOptions, options => options.type === 'heatmap');
if (isNil(this.options.customCode)) {
this.options.customCode = trim(`
// Available variables are x, ys, element, and Plotly
// Type console.log(x, ys); for more info about x and ys
// To plot your graph call Plotly.plot(element, ...)
// Plotly examples and docs: https://plot.ly/javascript/
`);
}
this.form = initEditorForm(this.options, this.data.columns);
const refreshColumns = () => {
this.columns = this.data.columns;
this.columnNames = map(this.columns, c => c.name);
if (this.columnNames.length > 0) {
each(difference(keys(this.options.columnMapping), this.columnNames), (column) => {
delete this.options.columnMapping[column];
});
}
};
const refreshColumnsAndForm = () => {
refreshColumns();
const data = this.data;
if (data && (data.columns.length > 0) && (data.rows.length > 0)) {
this.form.yAxisColumns = intersection(this.form.yAxisColumns, this.columnNames);
if (!includes(this.columnNames, this.form.xAxisColumn)) {
this.form.xAxisColumn = undefined;
}
if (!includes(this.columnNames, this.form.groupby)) {
this.form.groupby = undefined;
}
}
};
const refreshSeries = () => {
const chartData = getChartData(this.data.rows, this.options);
const seriesNames = map(chartData, s => s.name);
const existing = keys(this.options.seriesOptions);
each(difference(seriesNames, existing), (name) => {
this.options.seriesOptions[name] = {
type: this.options.globalSeriesType,
yAxis: 0,
};
this.form.seriesList.push(name);
});
each(difference(existing, seriesNames), (name) => {
this.form.seriesList = without(this.form.seriesList, name);
delete this.options.seriesOptions[name];
});
if (this.options.globalSeriesType === 'pie') {
const uniqueValuesNames = new Set();
each(chartData, (series) => {
each(series.data, (row) => {
uniqueValuesNames.add(row.x);
});
});
const valuesNames = [];
uniqueValuesNames.forEach(v => valuesNames.push(v));
// initialize newly added values
const newValues = difference(valuesNames, keys(this.options.valuesOptions));
each(newValues, (name) => {
this.options.valuesOptions[name] = {};
this.form.valuesList.push(name);
});
// remove settings for values that are no longer available
each(keys(this.options.valuesOptions), (name) => {
if (valuesNames.indexOf(name) === -1) {
delete this.options.valuesOptions[name];
}
});
this.form.valuesList = intersection(this.form.valuesList, valuesNames);
}
};
const setColumnRole = (role, column) => {
this.options.columnMapping[column] = role;
};
const unsetColumn = column => setColumnRole('unused', column);
refreshColumns();
$scope.$watch('$ctrl.options.columnMapping', refreshSeries, true);
$scope.$watch('$ctrl.data', () => {
refreshColumnsAndForm();
refreshSeries();
});
$scope.$watchCollection('$ctrl.form.seriesList', (value) => {
each(value, (name, index) => {
this.options.seriesOptions[name].zIndex = index;
this.options.seriesOptions[name].index = 0; // is this needed?
});
});
$scope.$watchCollection('$ctrl.form.yAxisColumns', (value, old) => {
each(old, unsetColumn);
each(value, partial(setColumnRole, 'y'));
});
$scope.$watch('$ctrl.form.xAxisColumn', (value, old) => {
if (old !== undefined) { unsetColumn(old); }
if (value !== undefined) { setColumnRole('x', value); }
});
$scope.$watch('$ctrl.form.errorColumn', (value, old) => {
if (old !== undefined) { unsetColumn(old); }
if (value !== undefined) { setColumnRole('yError', value); }
});
$scope.$watch('$ctrl.form.sizeColumn', (value, old) => {
if (old !== undefined) { unsetColumn(old); }
if (value !== undefined) { setColumnRole('size', value); }
});
$scope.$watch('$ctrl.form.zValColumn', (value, old) => {
if (old !== undefined) { unsetColumn(old); }
if (value !== undefined) { setColumnRole('zVal', value); }
});
$scope.$watch('$ctrl.form.groupby', (value, old) => {
if (old !== undefined) { unsetColumn(old); }
if (value !== undefined) { setColumnRole('series', value); }
});
$scope.$watch('$ctrl.options', (options) => {
this.onOptionsChange(options);
}, true);
this.templateHint = `
<div class="p-b-5">Use special names to access additional properties:</div>
<div><code>{{ @@name }}</code> series name;</div>
<div><code>{{ @@x }}</code> x-value;</div>
<div><code>{{ @@y }}</code> y-value;</div>
<div><code>{{ @@yPercent }}</code> relative y-value;</div>
<div><code>{{ @@yError }}</code> y deviation;</div>
<div><code>{{ @@size }}</code> bubble size;</div>
<div class="p-t-5">Also, all query result columns can be referenced using
<code class="text-nowrap">{{ column_name }}</code> syntax.</div>
`;
},
};
export default function init(ngModule) {
ngModule.component('chartEditor', ChartEditor);
ngModule.run(($injector) => {
registerVisualization({
type: 'CHART',
name: 'Chart',
isDefault: true,
getOptions: (options) => {
const result = merge({}, DEFAULT_OPTIONS, {
showDataLabels: options.globalSeriesType === 'pie',
dateTimeFormat: clientConfig.dateTimeFormat,
}, options);
// Backward compatibility
if (['normal', 'percent'].indexOf(result.series.stacking) >= 0) {
result.series.percentValues = result.series.stacking === 'percent';
result.series.stacking = 'stack';
}
return result;
},
Renderer,
Editor: angular2react('chartEditor', ChartEditor, $injector),
defaultColumns: 3,
defaultRows: 8,
minColumns: 1,
minRows: 5,
});
defaultColumns: 3,
defaultRows: 8,
minColumns: 1,
minRows: 5,
});
}

View File

@@ -1,4 +1,4 @@
import { isNil, each, includes, isString, map, sortBy } from 'lodash';
import { isNil, isString, extend, each, includes, map, sortBy } from 'lodash';
import { cleanNumber, normalizeValue, getSeriesAxis } from './utils';
import { ColorPaletteArray } from '@/visualizations/ColorPalette';
@@ -101,7 +101,10 @@ function prepareBoxSeries(series, options, { seriesColor }) {
function prepareSeries(series, options, additionalOptions) {
const { hoverInfoPattern, index } = additionalOptions;
const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType };
const seriesOptions = extend(
{ type: options.globalSeriesType, yAxis: 0 },
options.seriesOptions[series.name],
);
const seriesColor = getSeriesColor(seriesOptions, index);
const seriesYAxis = getSeriesAxis(series, options);