diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index 37c1dd122..864bf8ee4 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -386,4 +386,12 @@ } } } -} \ No newline at end of file +} + +// overrides for checkbox +@checkbox-prefix-cls: ~'@{ant-prefix}-checkbox'; + +.@{checkbox-prefix-cls}-wrapper + span, +.@{checkbox-prefix-cls} + span { + padding-right: 0; +} diff --git a/client/app/components/ColorPicker/index.jsx b/client/app/components/ColorPicker/index.jsx index 32bd7703f..d057bbebb 100644 --- a/client/app/components/ColorPicker/index.jsx +++ b/client/app/components/ColorPicker/index.jsx @@ -2,11 +2,11 @@ import { toString } from 'lodash'; import React, { useState, useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; -import tinycolor from 'tinycolor2'; import Popover from 'antd/lib/popover'; import Card from 'antd/lib/card'; import Tooltip from 'antd/lib/tooltip'; import Icon from 'antd/lib/icon'; +import chooseTextColorForBackground from '@/lib/chooseTextColorForBackground'; import ColorInput from './Input'; import Swatch from './Swatch'; @@ -17,6 +17,7 @@ import './index.less'; export default function ColorPicker({ color, placement, presetColors, presetColumns, interactive, children, onChange, triggerProps, + addonBefore, addonAfter, }) { const [visible, setVisible] = useState(false); const validatedColor = useMemo(() => validateColor(color), [color]); @@ -61,45 +62,49 @@ export default function ColorPicker({ }, [validatedColor, visible]); return ( - - + {addonBefore} + + + + )} + trigger="click" + placement={placement} + visible={visible} + onVisibleChange={setVisible} + > + {children || ( + - - )} - trigger="click" - placement={placement} - visible={visible} - onVisibleChange={setVisible} - > - {children || ( - - )} - + )} + + {addonAfter} + ); } @@ -118,6 +123,8 @@ ColorPicker.propTypes = { interactive: PropTypes.bool, triggerProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types children: PropTypes.node, + addonBefore: PropTypes.node, + addonAfter: PropTypes.node, onChange: PropTypes.func, }; @@ -129,6 +136,8 @@ ColorPicker.defaultProps = { interactive: false, triggerProps: {}, children: null, + addonBefore: null, + addonAfter: null, onChange: () => {}, }; diff --git a/client/app/components/HelpTrigger.jsx b/client/app/components/HelpTrigger.jsx index 68fc112ab..857248cbf 100644 --- a/client/app/components/HelpTrigger.jsx +++ b/client/app/components/HelpTrigger.jsx @@ -1,3 +1,4 @@ +import { startsWith } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; @@ -87,29 +88,30 @@ export const TYPES = { '/user-guide/querying/writing-queries#Managing-Query-Permissions', 'Guide: Managing Query Permissions', ], + NUMBER_FORMAT_SPECS: [ + '/user-guide/visualizations/formatting-numbers', + 'Formatting Numbers', + ], }; export default class HelpTrigger extends React.Component { static propTypes = { type: PropTypes.oneOf(Object.keys(TYPES)).isRequired, className: PropTypes.string, + showTooltip: PropTypes.bool, children: PropTypes.node, }; static defaultProps = { className: null, + showTooltip: true, children: , }; - iframeRef = null; + iframeRef = React.createRef(); iframeLoadingTimeout = null; - constructor(props) { - super(props); - this.iframeRef = React.createRef(); - } - state = { visible: false, loading: false, @@ -118,7 +120,7 @@ export default class HelpTrigger extends React.Component { }; componentDidMount() { - window.addEventListener('message', this.onPostMessageReceived, DOMAIN); + window.addEventListener('message', this.onPostMessageReceived, false); } componentWillUnmount() { @@ -142,13 +144,17 @@ export default class HelpTrigger extends React.Component { }; onPostMessageReceived = (event) => { + if (!startsWith(event.origin, DOMAIN)) { + return; + } + const { type, message: currentUrl } = event.data || {}; if (type !== IFRAME_URL_UPDATE_MESSAGE) { return; } this.setState({ currentUrl }); - } + }; openDrawer = () => { this.setState({ visible: true }); @@ -174,7 +180,7 @@ export default class HelpTrigger extends React.Component { return ( - + {this.props.children} diff --git a/client/app/components/TextAlignmentSelect/index.jsx b/client/app/components/TextAlignmentSelect/index.jsx new file mode 100644 index 000000000..daa854f2d --- /dev/null +++ b/client/app/components/TextAlignmentSelect/index.jsx @@ -0,0 +1,45 @@ +import { pickBy, startsWith } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import Radio from 'antd/lib/radio'; +import Icon from 'antd/lib/icon'; +import Tooltip from 'antd/lib/tooltip'; + +import './index.less'; + +export default function TextAlignmentSelect({ className, ...props }) { + return ( + // Antd RadioGroup does not use any custom attributes +
startsWith(k, 'data-'))}> + + + + + + + + + + + + + + + + + +
+ ); +} + +TextAlignmentSelect.propTypes = { + className: PropTypes.string, +}; + +TextAlignmentSelect.defaultProps = { + className: null, +}; diff --git a/client/app/components/TextAlignmentSelect/index.less b/client/app/components/TextAlignmentSelect/index.less new file mode 100644 index 000000000..6cfcb76ac --- /dev/null +++ b/client/app/components/TextAlignmentSelect/index.less @@ -0,0 +1,13 @@ +.ant-radio-group.text-alignment-select { + display: flex; + align-items: stretch; + justify-content: stretch; + + .ant-radio-button-wrapper { + flex-grow: 1; + text-align: center; + // fit height + height: 35px; + line-height: 33px; + } +} diff --git a/client/app/components/visualizations/editor/ContextHelp.jsx b/client/app/components/visualizations/editor/ContextHelp.jsx new file mode 100644 index 000000000..24531fbbe --- /dev/null +++ b/client/app/components/visualizations/editor/ContextHelp.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Popover from 'antd/lib/popover'; +import Tooltip from 'antd/lib/tooltip'; +import Icon from 'antd/lib/icon'; +import HelpTrigger from '@/components/HelpTrigger'; + +import './context-help.less'; + +export default function ContextHelp({ icon, children, ...props }) { + return ( + {icon || ContextHelp.defaultIcon} + ); +} + +ContextHelp.propTypes = { + icon: PropTypes.node, + children: PropTypes.node, +}; + +ContextHelp.defaultProps = { + icon: null, + children: null, +}; + +ContextHelp.defaultIcon = ( + +); + +function NumberFormatSpecs() { + return ( + + {ContextHelp.defaultIcon} + + ); +} + +function DateTimeFormatSpecs() { + return ( + Formatting Dates and Times
)}> + + {ContextHelp.defaultIcon} + + + ); +} + +ContextHelp.NumberFormatSpecs = NumberFormatSpecs; +ContextHelp.DateTimeFormatSpecs = DateTimeFormatSpecs; diff --git a/client/app/components/visualizations/editor/Section.jsx b/client/app/components/visualizations/editor/Section.jsx new file mode 100644 index 000000000..948bb34ed --- /dev/null +++ b/client/app/components/visualizations/editor/Section.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +function SectionTitle({ className, children, ...props }) { + if (!children) { + return null; + } + + return

{children}

; +} + +SectionTitle.propTypes = { + className: PropTypes.string, + children: PropTypes.node, +}; + +SectionTitle.defaultProps = { + className: null, + children: null, +}; + +export default function Section({ className, children, ...props }) { + return ( +
+ {children} +
+ ); +} + +Section.propTypes = { + className: PropTypes.string, + children: PropTypes.node, +}; + +Section.defaultProps = { + className: null, + children: null, +}; + +Section.Title = SectionTitle; diff --git a/client/app/components/visualizations/editor/Switch.jsx b/client/app/components/visualizations/editor/Switch.jsx new file mode 100644 index 000000000..90ac84940 --- /dev/null +++ b/client/app/components/visualizations/editor/Switch.jsx @@ -0,0 +1,34 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import AntSwitch from 'antd/lib/switch'; +import Typography from 'antd/lib/typography'; + +export default function Switch({ id, children, disabled, ...props }) { + const fallbackId = useMemo(() => `visualization-editor-control-${Math.random().toString(36).substr(2, 10)}`, []); + id = id || fallbackId; + + if (children) { + return ( + + ); + } + + return ( + + ); +} + +Switch.propTypes = { + id: PropTypes.string, + disabled: PropTypes.bool, + children: PropTypes.node, +}; + +Switch.defaultProps = { + id: null, + disabled: false, + children: null, +}; diff --git a/client/app/components/visualizations/editor/context-help.less b/client/app/components/visualizations/editor/context-help.less new file mode 100644 index 000000000..287267fbb --- /dev/null +++ b/client/app/components/visualizations/editor/context-help.less @@ -0,0 +1,12 @@ +@import (reference, less) '~@/assets/less/main.less'; + +a.visualization-editor-context-help { + &, .ant-typography & { + font: inherit; + color: inherit; + + &:hover, &:active { + color: @link-hover-color; + } + } +} diff --git a/client/app/components/visualizations/editor/control-label.less b/client/app/components/visualizations/editor/control-label.less new file mode 100644 index 000000000..acb2f7975 --- /dev/null +++ b/client/app/components/visualizations/editor/control-label.less @@ -0,0 +1,7 @@ +.visualization-editor-control-label { + &.visualization-editor-control-label-horizontal { + label { + margin-bottom: 0; + } + } +} diff --git a/client/app/components/visualizations/editor/createTabbedEditor.jsx b/client/app/components/visualizations/editor/createTabbedEditor.jsx new file mode 100644 index 000000000..52f222a9e --- /dev/null +++ b/client/app/components/visualizations/editor/createTabbedEditor.jsx @@ -0,0 +1,51 @@ +import { isFunction, map, filter, extend, merge } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import Tabs from 'antd/lib/tabs'; +import { EditorPropTypes } from '@/visualizations'; + +export const UpdateOptionsStrategy = { + replace: (existingOptions, newOptions) => merge({}, newOptions), + shallowMerge: (existingOptions, newOptions) => extend({}, existingOptions, newOptions), + deepMerge: (existingOptions, newOptions) => merge({}, existingOptions, newOptions), +}; + +export function TabbedEditor({ tabs, options, data, onOptionsChange, ...restProps }) { + const optionsChanged = (newOptions, updateStrategy = UpdateOptionsStrategy.deepMerge) => { + onOptionsChange(updateStrategy(options, newOptions)); + }; + + tabs = filter(tabs, tab => (isFunction(tab.isAvailable) ? tab.isAvailable(options, data) : true)); + + return ( + + {map(tabs, ({ key, title, component: Component }) => ( + {title}}> + + + ))} + + ); +} + +TabbedEditor.propTypes = { + ...EditorPropTypes, + tabs: PropTypes.arrayOf(PropTypes.shape({ + key: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + isAvailable: PropTypes.func, // (options) => boolean + component: PropTypes.func.isRequired, + })), +}; + +TabbedEditor.defaultProps = { + tabs: [], +}; + +export default function createTabbedEditor(tabs) { + return function TabbedEditorWrapper(props) { + return ( + + ); + }; +} diff --git a/client/app/components/visualizations/editor/index.js b/client/app/components/visualizations/editor/index.js new file mode 100644 index 000000000..4760d4c9c --- /dev/null +++ b/client/app/components/visualizations/editor/index.js @@ -0,0 +1,30 @@ +import AntSelect from 'antd/lib/select'; +import AntInput from 'antd/lib/input'; +import AntInputNumber from 'antd/lib/input-number'; +import Checkbox from 'antd/lib/checkbox'; + +import RedashColorPicker from '@/components/ColorPicker'; +import RedashTextAlignmentSelect from '@/components/TextAlignmentSelect'; + +import withControlLabel, { ControlLabel } from './withControlLabel'; +import createTabbedEditor from './createTabbedEditor'; +import Section from './Section'; +import Switch from './Switch'; +import ContextHelp from './ContextHelp'; + +export { + Section, + ControlLabel, + Checkbox, + Switch, + ContextHelp, + + withControlLabel, + createTabbedEditor, +}; +export const Select = withControlLabel(AntSelect); +export const Input = withControlLabel(AntInput); +export const TextArea = withControlLabel(AntInput.TextArea); +export const InputNumber = withControlLabel(AntInputNumber); +export const ColorPicker = withControlLabel(RedashColorPicker); +export const TextAlignmentSelect = withControlLabel(RedashTextAlignmentSelect); diff --git a/client/app/components/visualizations/editor/withControlLabel.jsx b/client/app/components/visualizations/editor/withControlLabel.jsx new file mode 100644 index 000000000..0e2b9f83c --- /dev/null +++ b/client/app/components/visualizations/editor/withControlLabel.jsx @@ -0,0 +1,79 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import hoistNonReactStatics from 'hoist-non-react-statics'; +import * as Grid from 'antd/lib/grid'; +import Typography from 'antd/lib/typography'; + +import './control-label.less'; + +export function ControlLabel({ layout, label, labelProps, disabled, children }) { + if ((layout === 'vertical') && label) { + return ( +
+ + {children} +
+ ); + } + + if ((layout === 'horizontal') && label) { + return ( + + + + + + {children} + + + ); + } + + return children; +} + +ControlLabel.propTypes = { + layout: PropTypes.oneOf(['vertical', 'horizontal']), + label: PropTypes.node, + labelProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types + disabled: PropTypes.bool, + children: PropTypes.node, +}; + +ControlLabel.defaultProps = { + layout: 'vertical', + label: null, + disabled: false, + children: null, +}; + +export default function withControlLabel(WrappedControl) { + // eslint-disable-next-line react/prop-types + function ControlWrapper({ id, layout, label, labelProps, disabled, ...props }) { + const fallbackId = useMemo(() => `visualization-editor-control-${Math.random().toString(36).substr(2, 10)}`, []); + labelProps = { + ...labelProps, + htmlFor: id || fallbackId, + }; + + return ( + + + + ); + } + + // Copy static methods from `WrappedComponent` + hoistNonReactStatics(ControlWrapper, WrappedControl); + + return ControlWrapper; +} diff --git a/client/app/lib/chooseTextColorForBackground.js b/client/app/lib/chooseTextColorForBackground.js new file mode 100644 index 000000000..e9731a2db --- /dev/null +++ b/client/app/lib/chooseTextColorForBackground.js @@ -0,0 +1,11 @@ +import { maxBy } from 'lodash'; +import chroma from 'chroma-js'; + +export default function chooseTextColorForBackground(backgroundColor, textColors = ['#ffffff', '#333333']) { + try { + backgroundColor = chroma(backgroundColor); + return maxBy(textColors, color => chroma.contrast(backgroundColor, color)); + } catch (e) { + return null; + } +} diff --git a/client/app/visualizations/ColorPalette.js b/client/app/visualizations/ColorPalette.js index a33937922..7eaa45e07 100644 --- a/client/app/visualizations/ColorPalette.js +++ b/client/app/visualizations/ColorPalette.js @@ -23,7 +23,7 @@ export const AdditionalColors = { 'Indian Red': '#981717', 'Green 2': '#17BF51', 'Green 3': '#049235', - DarkTurquoise: '#00B6EB', + 'Dark Turquoise': '#00B6EB', 'Dark Violet': '#A58AFF', 'Pink 2': '#C63FA9', }; diff --git a/client/app/visualizations/box-plot/Editor.jsx b/client/app/visualizations/box-plot/Editor.jsx index 15231f47d..d3ff73c89 100644 --- a/client/app/visualizations/box-plot/Editor.jsx +++ b/client/app/visualizations/box-plot/Editor.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import Input from 'antd/lib/input'; +import { Section, Input } from '@/components/visualizations/editor'; import { EditorPropTypes } from '@/visualizations'; export default function Editor({ options, onOptionsChange }) { @@ -14,27 +14,25 @@ export default function Editor({ options, onOptionsChange }) { }; return ( -
-
- + +
onXAxisLabelChanged(event.target.value)} /> -
+ -
- +
onYAxisLabelChanged(event.target.value)} /> -
-
+ + ); } diff --git a/client/app/visualizations/chart/Editor/AxisSettings.jsx b/client/app/visualizations/chart/Editor/AxisSettings.jsx index 918b41a27..7996b299d 100644 --- a/client/app/visualizations/chart/Editor/AxisSettings.jsx +++ b/client/app/visualizations/chart/Editor/AxisSettings.jsx @@ -2,10 +2,8 @@ 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'; +import { Section, Select, Input, InputNumber } from '@/components/visualizations/editor'; function toNumber(value) { value = isNumber(value) ? value : parseFloat(value); @@ -26,10 +24,9 @@ export default function AxisSettings({ id, options, features, onChange }) { return ( -
- +
-
+ -
- +
handleNameChange(event.target.value)} /> -
+ {features.range && ( - - - - handleMinMaxChange({ rangeMin: toNumber(value) })} - /> - - - - handleMinMaxChange({ rangeMax: toNumber(value) })} - /> - - +
+ + + handleMinMaxChange({ rangeMin: toNumber(value) })} + /> + + + handleMinMaxChange({ rangeMax: toNumber(value) })} + /> + + +
)}
); diff --git a/client/app/visualizations/chart/Editor/ChartTypeSelect.jsx b/client/app/visualizations/chart/Editor/ChartTypeSelect.jsx index 9931130d1..a1a2980de 100644 --- a/client/app/visualizations/chart/Editor/ChartTypeSelect.jsx +++ b/client/app/visualizations/chart/Editor/ChartTypeSelect.jsx @@ -1,6 +1,6 @@ import { map } from 'lodash'; import React, { useMemo } from 'react'; -import Select from 'antd/lib/select'; +import { Select } from '@/components/visualizations/editor'; import { clientConfig } from '@/services/auth'; export default function ChartTypeSelect(props) { diff --git a/client/app/visualizations/chart/Editor/ColorsSettings.test.js b/client/app/visualizations/chart/Editor/ColorsSettings.test.js index 505f20d48..fa90ec39d 100644 --- a/client/app/visualizations/chart/Editor/ColorsSettings.test.js +++ b/client/app/visualizations/chart/Editor/ColorsSettings.test.js @@ -35,8 +35,10 @@ describe('Visualizations -> Chart -> Editor -> Colors Settings', () => { columnMapping: { a: 'x', b: 'y' }, }, done); - findByTestID(el, 'Chart.Series.v.Color').first().simulate('click'); - findByTestID(el, 'ColorPicker').first().find('input') + findByTestID(el, 'Chart.Series.v.Color') + .find('.color-picker-trigger').last() + .simulate('click'); + findByTestID(el, 'ColorPicker').last().find('input') .simulate('change', { target: { value: 'red' } }); }); }); @@ -48,8 +50,8 @@ describe('Visualizations -> Chart -> Editor -> Colors Settings', () => { 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'); + findByTestID(el, 'Chart.Colors.Heatmap.ColorScheme').last().simulate('click'); + findByTestID(el, 'Chart.Colors.Heatmap.ColorScheme.RdBu').last().simulate('click'); }); test('Sets custom color scheme', async (done) => { @@ -59,12 +61,16 @@ describe('Visualizations -> Chart -> Editor -> Colors Settings', () => { 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') + findByTestID(el, 'Chart.Colors.Heatmap.MinColor') + .find('.color-picker-trigger').last() + .simulate('click'); + findByTestID(el, 'ColorPicker').last().find('input') .simulate('change', { target: { value: 'yellow' } }); - findByTestID(el, 'Chart.Colors.Heatmap.MaxColor').first().simulate('click'); - findByTestID(el, 'ColorPicker').first().find('input') + findByTestID(el, 'Chart.Colors.Heatmap.MaxColor') + .find('.color-picker-trigger').last() + .simulate('click'); + findByTestID(el, 'ColorPicker').last().find('input') .simulate('change', { target: { value: 'red' } }); }); }); @@ -76,8 +82,11 @@ describe('Visualizations -> Chart -> Editor -> Colors Settings', () => { columnMapping: { a: 'x', b: 'y' }, }, done); - findByTestID(el, 'Chart.Series.b.Color').first().simulate('click'); - findByTestID(el, 'ColorPicker').first().find('input') + findByTestID(el, 'Chart.Series.b.Color') + .find('.color-picker-trigger').last() + .simulate('click'); + + findByTestID(el, 'ColorPicker').last().find('input') .simulate('change', { target: { value: 'red' } }); }); }); diff --git a/client/app/visualizations/chart/Editor/ColumnMappingSelect.jsx b/client/app/visualizations/chart/Editor/ColumnMappingSelect.jsx index 336c611b4..7117767e7 100644 --- a/client/app/visualizations/chart/Editor/ColumnMappingSelect.jsx +++ b/client/app/visualizations/chart/Editor/ColumnMappingSelect.jsx @@ -1,7 +1,7 @@ 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'; +import { Section, Select } from '@/components/visualizations/editor'; const MappingTypes = { x: { label: 'X Column' }, @@ -20,10 +20,9 @@ export default function ColumnMappingSelect({ value, availableColumns, type, onC const { label, multiple } = MappingTypes[type]; return ( -
- +
-
+ ); } diff --git a/client/app/visualizations/chart/Editor/CustomChartSettings.jsx b/client/app/visualizations/chart/Editor/CustomChartSettings.jsx index 9560f7558..51a27c5c6 100644 --- a/client/app/visualizations/chart/Editor/CustomChartSettings.jsx +++ b/client/app/visualizations/chart/Editor/CustomChartSettings.jsx @@ -1,11 +1,8 @@ import { isNil, trimStart } from 'lodash'; import React from 'react'; -import Switch from 'antd/lib/switch'; -import Input from 'antd/lib/input'; +import { Section, Switch, TextArea } from '@/components/visualizations/editor'; 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 @@ -16,41 +13,37 @@ const defaultCustomCode = trimStart(` export default function CustomChartSettings({ options, onOptionsChange }) { return ( -
- +