mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Migrate Chart visualization to React Part 2: Editor (#4139)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
106
client/app/visualizations/chart/Editor/AxisSettings.jsx
Normal file
106
client/app/visualizations/chart/Editor/AxisSettings.jsx
Normal 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: () => {},
|
||||
};
|
||||
36
client/app/visualizations/chart/Editor/ChartTypeSelect.jsx
Normal file
36
client/app/visualizations/chart/Editor/ChartTypeSelect.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
client/app/visualizations/chart/Editor/ColorsSettings.jsx
Normal file
18
client/app/visualizations/chart/Editor/ColorsSettings.jsx
Normal 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;
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
137
client/app/visualizations/chart/Editor/DataLabelsSettings.jsx
Normal file
137
client/app/visualizations/chart/Editor/DataLabelsSettings.jsx
Normal 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
|
||||
<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
|
||||
<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
|
||||
<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;
|
||||
@@ -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 }}' } });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
219
client/app/visualizations/chart/Editor/GeneralSettings.jsx
Normal file
219
client/app/visualizations/chart/Editor/GeneralSettings.jsx
Normal 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;
|
||||
152
client/app/visualizations/chart/Editor/GeneralSettings.test.js
Normal file
152
client/app/visualizations/chart/Editor/GeneralSettings.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
75
client/app/visualizations/chart/Editor/PieColorsSettings.jsx
Normal file
75
client/app/visualizations/chart/Editor/PieColorsSettings.jsx
Normal 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;
|
||||
138
client/app/visualizations/chart/Editor/SeriesSettings.jsx
Normal file
138
client/app/visualizations/chart/Editor/SeriesSettings.jsx
Normal 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;
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
56
client/app/visualizations/chart/Editor/XAxisSettings.jsx
Normal file
56
client/app/visualizations/chart/Editor/XAxisSettings.jsx
Normal 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;
|
||||
72
client/app/visualizations/chart/Editor/XAxisSettings.test.js
Normal file
72
client/app/visualizations/chart/Editor/XAxisSettings.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
65
client/app/visualizations/chart/Editor/YAxisSettings.jsx
Normal file
65
client/app/visualizations/chart/Editor/YAxisSettings.jsx
Normal 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;
|
||||
107
client/app/visualizations/chart/Editor/YAxisSettings.test.js
Normal file
107
client/app/visualizations/chart/Editor/YAxisSettings.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
33
client/app/visualizations/chart/Editor/editor.less
Normal file
33
client/app/visualizations/chart/Editor/editor.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
client/app/visualizations/chart/Editor/index.jsx
Normal file
65
client/app/visualizations/chart/Editor/index.jsx
Normal 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;
|
||||
55
client/app/visualizations/chart/Editor/index.test.js
Normal file
55
client/app/visualizations/chart/Editor/index.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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="https://redash.io/help/user-guide/visualizations/formatting-numbers" target="_blank">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="https://redash.io/help/user-guide/visualizations/formatting-numbers" target="_blank">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="https://momentjs.com/docs/#/displaying/format/" target="_blank">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>
|
||||
39
client/app/visualizations/chart/getOptions.js
Normal file
39
client/app/visualizations/chart/getOptions.js
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user