feat(explore): Implement conditional formatting component (#15651)

* feat(explore): Implement conditional formatting

* Improved validation

* Fix undefined error

* Refactor after code review

* Add licenses

* Remove redundant div

* Remove formatters when corresponding column is removed
This commit is contained in:
Kamil Gabryjelski
2021-07-13 18:05:16 +02:00
committed by GitHub
parent 8efd94a14f
commit a914e3c1cb
6 changed files with 560 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { styled, css, t, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import ControlHeader from 'src/explore/components/ControlHeader';
import { useComponentDidUpdate } from 'src/common/hooks/useComponentDidUpdate';
import { FormattingPopover } from './FormattingPopover';
import {
COMPARATOR,
ConditionalFormattingConfig,
ConditionalFormattingControlProps,
} from './types';
import {
AddControlLabel,
CaretContainer,
Label,
OptionControlContainer,
} from '../OptionControls';
const FormattersContainer = styled.div`
${({ theme }) => css`
padding: ${theme.gridUnit}px;
border: solid 1px ${theme.colors.grayscale.light2};
border-radius: ${theme.gridUnit}px;
`}
`;
export const FormatterContainer = styled(OptionControlContainer)`
&,
& > div {
margin-bottom: ${({ theme }) => theme.gridUnit}px;
:last-child {
margin-bottom: 0;
}
}
`;
export const CloseButton = styled.button`
${({ theme }) => css`
color: ${theme.colors.grayscale.light1};
height: 100%;
width: ${theme.gridUnit * 6}px;
border: none;
border-right: solid 1px ${theme.colors.grayscale.dark2}0C;
padding: 0;
outline: none;
border-bottom-left-radius: 3px;
border-top-left-radius: 3px;
`}
`;
const ConditionalFormattingControl = ({
value,
onChange,
columnOptions,
verboseMap,
...props
}: ConditionalFormattingControlProps) => {
const theme = useTheme();
const [
conditionalFormattingConfigs,
setConditionalFormattingConfigs,
] = useState<ConditionalFormattingConfig[]>(value ?? []);
useEffect(() => {
if (onChange) {
onChange(conditionalFormattingConfigs);
}
}, [conditionalFormattingConfigs, onChange]);
// remove formatter when corresponding column is removed from controls
const removeFormattersWhenColumnsChange = useCallback(() => {
const newFormattingConfigs = conditionalFormattingConfigs.filter(config =>
columnOptions.some(option => option?.value === config?.column),
);
if (
newFormattingConfigs.length !== conditionalFormattingConfigs.length &&
onChange
) {
setConditionalFormattingConfigs(newFormattingConfigs);
onChange(newFormattingConfigs);
}
}, [JSON.stringify(columnOptions)]);
useComponentDidUpdate(removeFormattersWhenColumnsChange);
const onDelete = (index: number) => {
setConditionalFormattingConfigs(prevConfigs =>
prevConfigs.filter((_, i) => i !== index),
);
};
const onSave = (config: ConditionalFormattingConfig) => {
setConditionalFormattingConfigs(prevConfigs => [...prevConfigs, config]);
};
const onEdit = (newConfig: ConditionalFormattingConfig, index: number) => {
const newConfigs = [...conditionalFormattingConfigs];
newConfigs.splice(index, 1, newConfig);
setConditionalFormattingConfigs(newConfigs);
};
const createLabel = ({
column,
operator,
targetValue,
targetValueLeft,
targetValueRight,
}: ConditionalFormattingConfig) => {
const columnName = (column && verboseMap?.[column]) ?? column;
switch (operator) {
case COMPARATOR.BETWEEN:
return `${targetValueLeft} ${COMPARATOR.LESS_THAN} ${columnName} ${COMPARATOR.LESS_THAN} ${targetValueRight}`;
case COMPARATOR.BETWEEN_OR_EQUAL:
return `${targetValueLeft} ${COMPARATOR.LESS_OR_EQUAL} ${columnName} ${COMPARATOR.LESS_OR_EQUAL} ${targetValueRight}`;
case COMPARATOR.BETWEEN_OR_LEFT_EQUAL:
return `${targetValueLeft} ${COMPARATOR.LESS_OR_EQUAL} ${columnName} ${COMPARATOR.LESS_THAN} ${targetValueRight}`;
case COMPARATOR.BETWEEN_OR_RIGHT_EQUAL:
return `${targetValueLeft} ${COMPARATOR.LESS_THAN} ${columnName} ${COMPARATOR.LESS_OR_EQUAL} ${targetValueRight}`;
default:
return `${columnName} ${operator} ${targetValue}`;
}
};
return (
<div>
<ControlHeader {...props} />
<FormattersContainer>
{conditionalFormattingConfigs.map((config, index) => (
<FormatterContainer key={index}>
<CloseButton onClick={() => onDelete(index)}>
<Icons.XSmall iconColor={theme.colors.grayscale.light1} />
</CloseButton>
<FormattingPopover
title={t('Edit formatter')}
config={config}
columns={columnOptions}
onChange={(newConfig: ConditionalFormattingConfig) =>
onEdit(newConfig, index)
}
destroyTooltipOnHide
>
<OptionControlContainer withCaret>
<Label>{createLabel(config)}</Label>
<CaretContainer>
<Icons.CaretRight iconColor={theme.colors.grayscale.light1} />
</CaretContainer>
</OptionControlContainer>
</FormattingPopover>
</FormatterContainer>
))}
<FormattingPopover
title={t('Add new formatter')}
columns={columnOptions}
onChange={onSave}
destroyTooltipOnHide
>
<AddControlLabel>
<Icons.PlusSmall iconColor={theme.colors.grayscale.light1} />
{t('Add new color formatter')}
</AddControlLabel>
</FormattingPopover>
</FormattersContainer>
</div>
);
};
export default ConditionalFormattingControl;

View File

@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useState } from 'react';
import Popover from 'src/components/Popover';
import { FormattingPopoverContent } from './FormattingPopoverContent';
import { ConditionalFormattingConfig, FormattingPopoverProps } from './types';
export const FormattingPopover = ({
title,
columns,
onChange,
config,
children,
...props
}: FormattingPopoverProps) => {
const [visible, setVisible] = useState(false);
const handleSave = useCallback(
(newConfig: ConditionalFormattingConfig) => {
setVisible(false);
onChange(newConfig);
},
[onChange],
);
return (
<Popover
title={title}
content={
<FormattingPopoverContent
onChange={handleSave}
config={config}
columns={columns}
/>
}
visible={visible}
onVisibleChange={setVisible}
trigger={['click']}
overlayStyle={{ width: '450px' }}
{...props}
>
{children}
</Popover>
);
};

View File

@@ -0,0 +1,223 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useMemo } from 'react';
import { styled, t } from '@superset-ui/core';
import { Form, FormItem } from 'src/components/Form';
import { Select } from 'src/components';
import { Col, InputNumber, Row } from 'src/common/components';
import Button from 'src/components/Button';
import {
COMPARATOR,
ConditionalFormattingConfig,
MULTIPLE_VALUE_COMPARATORS,
} from './types';
const FullWidthInputNumber = styled(InputNumber)`
width: 100%;
`;
const JustifyEnd = styled.div`
display: flex;
justify-content: flex-end;
`;
const colorSchemeOptions = [
{ value: 'rgb(0,255,0)', label: t('green') },
{ value: 'rgb(255,255,0)', label: t('yellow') },
{ value: 'rgb(255,0,0)', label: t('red') },
];
const operatorOptions = [
{ value: COMPARATOR.GREATER_THAN, label: '>' },
{ value: COMPARATOR.LESS_THAN, label: '<' },
{ value: COMPARATOR.GREATER_OR_EQUAL, label: '≥' },
{ value: COMPARATOR.LESS_OR_EQUAL, label: '≤' },
{ value: COMPARATOR.EQUAL, label: '=' },
{ value: COMPARATOR.NOT_EQUAL, label: '≠' },
{ value: COMPARATOR.BETWEEN, label: '< x <' },
{ value: COMPARATOR.BETWEEN_OR_EQUAL, label: '≤ x ≤' },
{ value: COMPARATOR.BETWEEN_OR_LEFT_EQUAL, label: '≤ x <' },
{ value: COMPARATOR.BETWEEN_OR_RIGHT_EQUAL, label: '< x ≤' },
];
export const FormattingPopoverContent = ({
config,
onChange,
columns = [],
}: {
config?: ConditionalFormattingConfig;
onChange: (config: ConditionalFormattingConfig) => void;
columns: { label: string; value: string }[];
}) => {
const isOperatorMultiValue = (operator?: COMPARATOR) =>
operator && MULTIPLE_VALUE_COMPARATORS.includes(operator);
const operatorField = useMemo(
() => (
<FormItem
name="operator"
label={t('Operator')}
rules={[{ required: true, message: t('Required') }]}
initialValue={operatorOptions[0].value}
>
<Select ariaLabel={t('Operator')} options={operatorOptions} />
</FormItem>
),
[],
);
const targetValueLeftValidator = useCallback(
(rightValue?: number) => (_: any, value?: number) => {
if (!value || !rightValue || rightValue > value) {
return Promise.resolve();
}
return Promise.reject(
new Error(
t('This value should be smaller than the right target value'),
),
);
},
[],
);
const targetValueRightValidator = useCallback(
(leftValue?: number) => (_: any, value?: number) => {
if (!value || !leftValue || leftValue < value) {
return Promise.resolve();
}
return Promise.reject(
new Error(
t('This value should be smaller than the right target value'),
),
);
},
[],
);
return (
<Form
onFinish={onChange}
initialValues={config}
requiredMark="optional"
layout="vertical"
>
<Row gutter={12}>
<Col span={12}>
<FormItem
name="column"
label={t('Column')}
rules={[{ required: true, message: t('Required') }]}
initialValue={columns[0]?.value}
>
<Select ariaLabel={t('Select column')} options={columns} />
</FormItem>
</Col>
<Col span={12}>
<FormItem
name="colorScheme"
label={t('Color scheme')}
rules={[{ required: true, message: t('Required') }]}
initialValue={colorSchemeOptions[0].value}
>
<Select
ariaLabel={t('Color scheme')}
options={colorSchemeOptions}
/>
</FormItem>
</Col>
</Row>
<FormItem
noStyle
shouldUpdate={(
prevValues: ConditionalFormattingConfig,
currentValues: ConditionalFormattingConfig,
) =>
isOperatorMultiValue(prevValues.operator) !==
isOperatorMultiValue(currentValues.operator)
}
>
{({ getFieldValue }) =>
isOperatorMultiValue(getFieldValue('operator')) ? (
<Row gutter={12}>
<Col span={9}>
<FormItem
name="targetValueLeft"
label={t('Left value')}
rules={[
{ required: true, message: t('Required') },
({ getFieldValue }) => ({
validator: targetValueLeftValidator(
getFieldValue('targetValueRight'),
),
}),
]}
dependencies={['targetValueRight']}
validateTrigger="onBlur"
trigger="onBlur"
>
<FullWidthInputNumber />
</FormItem>
</Col>
<Col span={6}>{operatorField}</Col>
<Col span={9}>
<FormItem
name="targetValueRight"
label={t('Right value')}
rules={[
{ required: true, message: t('Required') },
({ getFieldValue }) => ({
validator: targetValueRightValidator(
getFieldValue('targetValueLeft'),
),
}),
]}
dependencies={['targetValueLeft']}
validateTrigger="onBlur"
trigger="onBlur"
>
<FullWidthInputNumber />
</FormItem>
</Col>
</Row>
) : (
<Row gutter={12}>
<Col span={6}>{operatorField}</Col>
<Col span={18}>
<FormItem
name="targetValue"
label={t('Target value')}
rules={[{ required: true, message: t('Required') }]}
>
<FullWidthInputNumber />
</FormItem>
</Col>
</Row>
)
}
</FormItem>
<FormItem>
<JustifyEnd>
<Button htmlType="submit" buttonStyle="primary">
{t('Apply')}
</Button>
</JustifyEnd>
</FormItem>
</Form>
);
};

View File

@@ -0,0 +1,22 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import ConditionalFormattingControl from './ConditionalFormattingControl';
export * from './types';
export default ConditionalFormattingControl;

View File

@@ -0,0 +1,68 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import { PopoverProps } from 'antd/lib/popover';
import { ControlComponentProps } from '@superset-ui/chart-controls/lib/shared-controls/components/types';
export enum COMPARATOR {
GREATER_THAN = '>',
LESS_THAN = '<',
GREATER_OR_EQUAL = '≥',
LESS_OR_EQUAL = '≤',
EQUAL = '=',
NOT_EQUAL = '≠',
BETWEEN = '< x <',
BETWEEN_OR_EQUAL = '≤ x ≤',
BETWEEN_OR_LEFT_EQUAL = '≤ x <',
BETWEEN_OR_RIGHT_EQUAL = '< x ≤',
}
export const MULTIPLE_VALUE_COMPARATORS = [
COMPARATOR.BETWEEN,
COMPARATOR.BETWEEN_OR_EQUAL,
COMPARATOR.BETWEEN_OR_LEFT_EQUAL,
COMPARATOR.BETWEEN_OR_RIGHT_EQUAL,
];
export type ConditionalFormattingConfig = {
operator?: COMPARATOR;
targetValue?: number;
targetValueLeft?: number;
targetValueRight?: number;
column?: string;
colorScheme?: string;
};
export type ConditionalFormattingControlProps = ControlComponentProps<
ConditionalFormattingConfig[]
> & {
columnOptions: { label: string; value: string }[];
verboseMap: Record<string, string>;
label: string;
description: string;
};
export type FormattingPopoverProps = PopoverProps & {
columns: { label: string; value: string }[];
onChange: (value: ConditionalFormattingConfig) => void;
config?: ConditionalFormattingConfig;
title: string;
children: ReactNode;
};

View File

@@ -40,6 +40,7 @@ import VizTypeControl from './VizTypeControl';
import MetricsControl from './MetricControl/MetricsControl';
import AdhocFilterControl from './FilterControl/AdhocFilterControl';
import FilterBoxItemControl from './FilterBoxItemControl';
import ConditionalFormattingControl from './ConditionalFormattingControl';
import DndColumnSelectControl, {
DndColumnSelect,
DndFilterSelect,
@@ -74,6 +75,7 @@ const controlMap = {
MetricsControl,
AdhocFilterControl,
FilterBoxItemControl,
ConditionalFormattingControl,
...sharedControlComponents,
};
export default controlMap;