mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
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:
committed by
GitHub
parent
8efd94a14f
commit
a914e3c1cb
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user