diff --git a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.tsx b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.tsx new file mode 100644 index 0000000000000..bc72f983abc82 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.tsx @@ -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(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 ( +
+ + + {conditionalFormattingConfigs.map((config, index) => ( + + onDelete(index)}> + + + + onEdit(newConfig, index) + } + destroyTooltipOnHide + > + + + + + + + + + ))} + + + + {t('Add new color formatter')} + + + +
+ ); +}; + +export default ConditionalFormattingControl; diff --git a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopover.tsx b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopover.tsx new file mode 100644 index 0000000000000..c44ca76235467 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopover.tsx @@ -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 ( + + } + visible={visible} + onVisibleChange={setVisible} + trigger={['click']} + overlayStyle={{ width: '450px' }} + {...props} + > + {children} + + ); +}; diff --git a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopoverContent.tsx b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopoverContent.tsx new file mode 100644 index 0000000000000..97abc1063b006 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopoverContent.tsx @@ -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( + () => ( + + + + + + +