test: Adds tests to the AnnotationLayer component (#13748)

This commit is contained in:
Michael S. Molina
2021-04-01 13:10:42 -03:00
committed by GitHub
parent 9156f089ff
commit bb677b8ef1
6 changed files with 209 additions and 35 deletions

View File

@@ -0,0 +1,782 @@
/**
* 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 from 'react';
import PropTypes from 'prop-types';
import { CompactPicker } from 'react-color';
import Button from 'src/components/Button';
import { parse as mathjsParse } from 'mathjs';
import {
t,
SupersetClient,
getCategoricalSchemeRegistry,
getChartMetadataRegistry,
validateNonEmpty,
} from '@superset-ui/core';
import SelectControl from 'src/explore/components/controls/SelectControl';
import TextControl from 'src/explore/components/controls/TextControl';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
import {
ANNOTATION_SOURCE_TYPES,
ANNOTATION_TYPES,
ANNOTATION_TYPES_METADATA,
DEFAULT_ANNOTATION_TYPE,
requiresQuery,
ANNOTATION_SOURCE_TYPES_METADATA,
} from 'src/modules/AnnotationTypes';
import PopoverSection from 'src/components/PopoverSection';
import ControlHeader from 'src/explore/components/ControlHeader';
const AUTOMATIC_COLOR = '';
const propTypes = {
name: PropTypes.string,
annotationType: PropTypes.string,
sourceType: PropTypes.string,
color: PropTypes.string,
opacity: PropTypes.string,
style: PropTypes.string,
width: PropTypes.number,
showMarkers: PropTypes.bool,
hideLine: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
overrides: PropTypes.object,
show: PropTypes.bool,
titleColumn: PropTypes.string,
descriptionColumns: PropTypes.arrayOf(PropTypes.string),
timeColumn: PropTypes.string,
intervalEndColumn: PropTypes.string,
vizType: PropTypes.string,
error: PropTypes.string,
colorScheme: PropTypes.string,
addAnnotationLayer: PropTypes.func,
removeAnnotationLayer: PropTypes.func,
close: PropTypes.func,
};
const defaultProps = {
name: '',
annotationType: DEFAULT_ANNOTATION_TYPE,
sourceType: '',
color: AUTOMATIC_COLOR,
opacity: '',
style: 'solid',
width: 1,
showMarkers: false,
hideLine: false,
overrides: {},
colorScheme: 'd3Category10',
show: true,
titleColumn: '',
descriptionColumns: [],
timeColumn: '',
intervalEndColumn: '',
addAnnotationLayer: () => {},
removeAnnotationLayer: () => {},
close: () => {},
};
export default class AnnotationLayer extends React.PureComponent {
constructor(props) {
super(props);
const {
name,
annotationType,
sourceType,
color,
opacity,
style,
width,
showMarkers,
hideLine,
value,
overrides,
show,
titleColumn,
descriptionColumns,
timeColumn,
intervalEndColumn,
vizType,
} = props;
// Only allow override whole time_range
if ('since' in overrides || 'until' in overrides) {
overrides.time_range = null;
delete overrides.since;
delete overrides.until;
}
// Check if annotationType is supported by this chart
const metadata = getChartMetadataRegistry().get(vizType);
const supportedAnnotationTypes = metadata?.supportedAnnotationTypes || [];
const validAnnotationType = supportedAnnotationTypes.includes(
annotationType,
)
? annotationType
: supportedAnnotationTypes[0];
this.state = {
// base
name,
annotationType: validAnnotationType,
sourceType,
value,
overrides,
show,
// slice
titleColumn,
descriptionColumns,
timeColumn,
intervalEndColumn,
// display
color: color || AUTOMATIC_COLOR,
opacity,
style,
width,
showMarkers,
hideLine,
// refData
isNew: !name,
isLoadingOptions: true,
valueOptions: [],
};
this.submitAnnotation = this.submitAnnotation.bind(this);
this.deleteAnnotation = this.deleteAnnotation.bind(this);
this.applyAnnotation = this.applyAnnotation.bind(this);
this.fetchOptions = this.fetchOptions.bind(this);
this.handleAnnotationType = this.handleAnnotationType.bind(this);
this.handleAnnotationSourceType = this.handleAnnotationSourceType.bind(
this,
);
this.handleValue = this.handleValue.bind(this);
this.isValidForm = this.isValidForm.bind(this);
}
componentDidMount() {
const { annotationType, sourceType, isLoadingOptions } = this.state;
this.fetchOptions(annotationType, sourceType, isLoadingOptions);
}
componentDidUpdate(prevProps, prevState) {
if (prevState.sourceType !== this.state.sourceType) {
this.fetchOptions(this.state.annotationType, this.state.sourceType, true);
}
}
getSupportedSourceTypes(annotationType) {
// Get vis types that can be source.
const sources = getChartMetadataRegistry()
.entries()
.filter(({ value: chartMetadata }) =>
chartMetadata.canBeAnnotationType(annotationType),
)
.map(({ key, value: chartMetadata }) => ({
value: key,
label: chartMetadata.name,
}));
// Prepend native source if applicable
if (ANNOTATION_TYPES_METADATA[annotationType]?.supportNativeSource) {
sources.unshift(ANNOTATION_SOURCE_TYPES_METADATA.NATIVE);
}
return sources;
}
isValidFormula(value, annotationType) {
if (annotationType === ANNOTATION_TYPES.FORMULA) {
try {
mathjsParse(value).compile().evaluate({ x: 0 });
} catch (err) {
return true;
}
}
return false;
}
isValidForm() {
const {
name,
annotationType,
sourceType,
value,
timeColumn,
intervalEndColumn,
} = this.state;
const errors = [
validateNonEmpty(name),
validateNonEmpty(annotationType),
validateNonEmpty(value),
];
if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE) {
if (annotationType === ANNOTATION_TYPES.EVENT) {
errors.push(validateNonEmpty(timeColumn));
}
if (annotationType === ANNOTATION_TYPES.INTERVAL) {
errors.push(validateNonEmpty(timeColumn));
errors.push(validateNonEmpty(intervalEndColumn));
}
}
errors.push(this.isValidFormula(value, annotationType));
return !errors.filter(x => x).length;
}
handleAnnotationType(annotationType) {
this.setState({
annotationType,
sourceType: null,
value: null,
});
}
handleAnnotationSourceType(sourceType) {
const { sourceType: prevSourceType } = this.state;
if (prevSourceType !== sourceType) {
this.setState({ sourceType, value: null, isLoadingOptions: true });
}
}
handleValue(value) {
this.setState({
value,
descriptionColumns: null,
intervalEndColumn: null,
timeColumn: null,
titleColumn: null,
overrides: { time_range: null },
});
}
fetchOptions(annotationType, sourceType, isLoadingOptions) {
if (isLoadingOptions) {
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
SupersetClient.get({
endpoint: '/annotationlayermodelview/api/read?',
}).then(({ json }) => {
const layers = json
? json.result.map(layer => ({
value: layer.id,
label: layer.name,
}))
: [];
this.setState({
isLoadingOptions: false,
valueOptions: layers,
});
});
} else if (requiresQuery(sourceType)) {
SupersetClient.get({ endpoint: '/superset/user_slices' }).then(
({ json }) => {
const registry = getChartMetadataRegistry();
this.setState({
isLoadingOptions: false,
valueOptions: json
.filter(x => {
const metadata = registry.get(x.viz_type);
return (
metadata && metadata.canBeAnnotationType(annotationType)
);
})
.map(x => ({ value: x.id, label: x.title, slice: x })),
});
},
);
} else {
this.setState({
isLoadingOptions: false,
valueOptions: [],
});
}
}
}
deleteAnnotation() {
this.props.removeAnnotationLayer();
this.props.close();
}
applyAnnotation() {
if (this.isValidForm()) {
const annotationFields = [
'name',
'annotationType',
'sourceType',
'color',
'opacity',
'style',
'width',
'showMarkers',
'hideLine',
'value',
'overrides',
'show',
'titleColumn',
'descriptionColumns',
'timeColumn',
'intervalEndColumn',
];
const newAnnotation = {};
annotationFields.forEach(field => {
if (this.state[field] !== null) {
newAnnotation[field] = this.state[field];
}
});
if (newAnnotation.color === AUTOMATIC_COLOR) {
newAnnotation.color = null;
}
this.props.addAnnotationLayer(newAnnotation);
this.setState({ isNew: false });
}
}
submitAnnotation() {
this.applyAnnotation();
this.props.close();
}
renderOption(option) {
return (
<span
css={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={option.label}
>
{option.label}
</span>
);
}
renderValueConfiguration() {
const {
annotationType,
sourceType,
value,
valueOptions,
isLoadingOptions,
} = this.state;
let label = '';
let description = '';
if (requiresQuery(sourceType)) {
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
label = 'Annotation layer';
description = 'Select the Annotation Layer you would like to use.';
} else {
label = t('Chart');
description = `Use a pre defined Superset Chart as a source for annotations and overlays.
your chart must be one of these visualization types:
[${this.getSupportedSourceTypes(annotationType)
.map(x => x.label)
.join(', ')}]`;
}
} else if (annotationType === ANNOTATION_TYPES.FORMULA) {
label = 'Formula';
description = `Expects a formula with depending time parameter 'x'
in milliseconds since epoch. mathjs is used to evaluate the formulas.
Example: '2x+5'`;
}
if (requiresQuery(sourceType)) {
return (
<SelectControl
name="annotation-layer-value"
showHeader
hovered
description={description}
label={label}
placeholder=""
options={valueOptions}
isLoading={isLoadingOptions}
value={value}
onChange={this.handleValue}
validationErrors={!value ? ['Mandatory'] : []}
optionRenderer={this.renderOption}
/>
);
}
if (annotationType === ANNOTATION_TYPES.FORMULA) {
return (
<TextControl
name="annotation-layer-value"
hovered
showHeader
description={description}
label={label}
placeholder=""
value={value}
onChange={this.handleValue}
validationErrors={
this.isValidFormula(value, annotationType) ? ['Bad formula.'] : []
}
/>
);
}
return '';
}
renderSliceConfiguration() {
const {
annotationType,
sourceType,
value,
valueOptions,
overrides,
titleColumn,
timeColumn,
intervalEndColumn,
descriptionColumns,
} = this.state;
const { slice } = valueOptions.find(x => x.value === value) || {};
if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE && slice) {
const columns = (slice.data.groupby || [])
.concat(slice.data.all_columns || [])
.map(x => ({ value: x, label: x }));
const timeColumnOptions = slice.data.include_time
? [{ value: '__timestamp', label: '__timestamp' }].concat(columns)
: columns;
return (
<div style={{ marginRight: '2rem' }}>
<PopoverSection
isSelected
title={t('Annotation Slice Configuration')}
info={t(`This section allows you to configure how to use the slice
to generate annotations.`)}
>
{(annotationType === ANNOTATION_TYPES.EVENT ||
annotationType === ANNOTATION_TYPES.INTERVAL) && (
<SelectControl
hovered
name="annotation-layer-time-column"
label={
annotationType === ANNOTATION_TYPES.INTERVAL
? 'Interval start column'
: 'Event time column'
}
description="This column must contain date/time information."
validationErrors={!timeColumn ? ['Mandatory'] : []}
clearable={false}
options={timeColumnOptions}
value={timeColumn}
onChange={v => this.setState({ timeColumn: v })}
/>
)}
{annotationType === ANNOTATION_TYPES.INTERVAL && (
<SelectControl
hovered
name="annotation-layer-intervalEnd"
label="Interval End column"
description="This column must contain date/time information."
validationErrors={!intervalEndColumn ? ['Mandatory'] : []}
options={columns}
value={intervalEndColumn}
onChange={v => this.setState({ intervalEndColumn: v })}
/>
)}
<SelectControl
hovered
name="annotation-layer-title"
label="Title Column"
description="Pick a title for you annotation."
options={[{ value: '', label: 'None' }].concat(columns)}
value={titleColumn}
onChange={v => this.setState({ titleColumn: v })}
/>
{annotationType !== ANNOTATION_TYPES.TIME_SERIES && (
<SelectControl
hovered
name="annotation-layer-title"
label="Description Columns"
description={`Pick one or more columns that should be shown in the
annotation. If you don't select a column all of them will be shown.`}
multi
options={columns}
value={descriptionColumns}
onChange={v => this.setState({ descriptionColumns: v })}
/>
)}
<div style={{ marginTop: '1rem' }}>
<CheckboxControl
hovered
name="annotation-override-time_range"
label="Override time range"
description={`This controls whether the "time_range" field from the current
view should be passed down to the chart containing the annotation data.`}
value={'time_range' in overrides}
onChange={v => {
delete overrides.time_range;
if (v) {
this.setState({
overrides: { ...overrides, time_range: null },
});
} else {
this.setState({ overrides: { ...overrides } });
}
}}
/>
<CheckboxControl
hovered
name="annotation-override-timegrain"
label="Override time grain"
description={`This controls whether the time grain field from the current
view should be passed down to the chart containing the annotation data.`}
value={'time_grain_sqla' in overrides}
onChange={v => {
delete overrides.time_grain_sqla;
delete overrides.granularity;
if (v) {
this.setState({
overrides: {
...overrides,
time_grain_sqla: null,
granularity: null,
},
});
} else {
this.setState({ overrides: { ...overrides } });
}
}}
/>
<TextControl
hovered
name="annotation-layer-timeshift"
label="Time Shift"
description={`Time delta in natural language
(example: 24 hours, 7 days, 56 weeks, 365 days)`}
placeholder=""
value={overrides.time_shift}
onChange={v =>
this.setState({ overrides: { ...overrides, time_shift: v } })
}
/>
</div>
</PopoverSection>
</div>
);
}
return '';
}
renderDisplayConfiguration() {
const {
color,
opacity,
style,
width,
showMarkers,
hideLine,
annotationType,
} = this.state;
const colorScheme = getCategoricalSchemeRegistry()
.get(this.props.colorScheme)
.colors.concat();
if (
color &&
color !== AUTOMATIC_COLOR &&
!colorScheme.find(x => x.toLowerCase() === color.toLowerCase())
) {
colorScheme.push(color);
}
return (
<PopoverSection
isSelected
title={t('Display configuration')}
info={t('Configure your how you overlay is displayed here.')}
>
<SelectControl
name="annotation-layer-stroke"
label={t('Style')}
// see '../../../visualizations/nvd3_vis.css'
options={[
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'longDashed', label: 'Long dashed' },
{ value: 'dotted', label: 'Dotted' },
]}
value={style}
clearable={false}
onChange={v => this.setState({ style: v })}
/>
<SelectControl
name="annotation-layer-opacity"
label={t('Opacity')}
// see '../../../visualizations/nvd3_vis.css'
options={[
{ value: '', label: 'Solid' },
{ value: 'opacityLow', label: '0.2' },
{ value: 'opacityMedium', label: '0.5' },
{ value: 'opacityHigh', label: '0.8' },
]}
value={opacity}
onChange={v => this.setState({ opacity: v })}
/>
<div>
<ControlHeader label={t('Color')} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<CompactPicker
color={color}
colors={colorScheme}
onChangeComplete={v => this.setState({ color: v.hex })}
/>
<Button
style={{ marginTop: '0.5rem', marginBottom: '0.5rem' }}
buttonStyle={color === AUTOMATIC_COLOR ? 'success' : 'default'}
buttonSize="xsmall"
onClick={() => this.setState({ color: AUTOMATIC_COLOR })}
>
Automatic Color
</Button>
</div>
</div>
<TextControl
name="annotation-layer-stroke-width"
label={t('Line width')}
isInt
value={width}
onChange={v => this.setState({ width: v })}
/>
{annotationType === ANNOTATION_TYPES.TIME_SERIES && (
<CheckboxControl
hovered
name="annotation-layer-show-markers"
label="Show Markers"
description="Shows or hides markers for the time series"
value={showMarkers}
onChange={v => this.setState({ showMarkers: v })}
/>
)}
{annotationType === ANNOTATION_TYPES.TIME_SERIES && (
<CheckboxControl
hovered
name="annotation-layer-hide-line"
label="Hide Line"
description="Hides the Line for the time series"
value={hideLine}
onChange={v => this.setState({ hideLine: v })}
/>
)}
</PopoverSection>
);
}
render() {
const { isNew, name, annotationType, sourceType, show } = this.state;
const isValid = this.isValidForm();
const metadata = getChartMetadataRegistry().get(this.props.vizType);
const supportedAnnotationTypes = metadata
? metadata.supportedAnnotationTypes.map(
type => ANNOTATION_TYPES_METADATA[type],
)
: [];
const supportedSourceTypes = this.getSupportedSourceTypes(annotationType);
return (
<>
{this.props.error && (
<span style={{ color: 'red' }}>ERROR: {this.props.error}</span>
)}
<div style={{ display: 'flex', flexDirection: 'row' }}>
<div style={{ marginRight: '2rem' }}>
<PopoverSection
isSelected
title={t('Layer configuration')}
info={t('Configure the basics of your Annotation Layer.')}
>
<TextControl
name="annotation-layer-name"
label={t('Name')}
placeholder=""
value={name}
onChange={v => this.setState({ name: v })}
validationErrors={!name ? [t('Mandatory')] : []}
/>
<CheckboxControl
name="annotation-layer-hide"
label={t('Hide layer')}
value={!show}
onChange={v => this.setState({ show: !v })}
/>
<SelectControl
hovered
description={t('Choose the annotation layer type')}
label={t('Annotation layer type')}
name="annotation-layer-type"
clearable={false}
options={supportedAnnotationTypes}
value={annotationType}
onChange={this.handleAnnotationType}
/>
{supportedSourceTypes.length > 0 && (
<SelectControl
hovered
description="Choose the source of your annotations"
label="Annotation Source"
name="annotation-source-type"
options={supportedSourceTypes}
value={sourceType}
onChange={this.handleAnnotationSourceType}
validationErrors={!sourceType ? [t('Mandatory')] : []}
/>
)}
{this.renderValueConfiguration()}
</PopoverSection>
</div>
{this.renderSliceConfiguration()}
{this.renderDisplayConfiguration()}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{isNew ? (
<Button buttonSize="small" onClick={() => this.props.close()}>
{t('Cancel')}
</Button>
) : (
<Button buttonSize="small" onClick={this.deleteAnnotation}>
{t('Remove')}
</Button>
)}
<div>
<Button
buttonSize="small"
disabled={!isValid}
onClick={this.applyAnnotation}
>
{t('Apply')}
</Button>
<Button
buttonSize="small"
buttonStyle="primary"
disabled={!isValid}
onClick={this.submitAnnotation}
>
{t('OK')}
</Button>
</div>
</div>
</>
);
}
}
AnnotationLayer.propTypes = propTypes;
AnnotationLayer.defaultProps = defaultProps;

View File

@@ -0,0 +1,190 @@
/**
* 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 from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { getChartMetadataRegistry, ChartMetadata } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import setupColors from 'src/setup/setupColors';
import { ANNOTATION_TYPES_METADATA } from 'src/modules/AnnotationTypes';
import AnnotationLayer from './AnnotationLayer';
const defaultProps = {
value: '',
vizType: 'table',
annotationType: ANNOTATION_TYPES_METADATA.FORMULA.value,
};
beforeAll(() => {
const supportedAnnotationTypes = Object.values(ANNOTATION_TYPES_METADATA).map(
value => value.value,
);
fetchMock.get('glob:*/annotationlayermodelview/api/read?*', {
result: [{ label: 'Chart A', value: 'a' }],
});
fetchMock.get('glob:*/superset/user_slices*', [
{ id: 'a', title: 'Chart A', viz_type: 'table', data: {} },
]);
setupColors();
getChartMetadataRegistry().registerValue(
'table',
new ChartMetadata({
name: 'Table',
thumbnail: '',
supportedAnnotationTypes,
canBeAnnotationTypes: ['EVENT'],
}),
);
});
test('renders with default props', () => {
const { container } = render(<AnnotationLayer {...defaultProps} />);
expect(container).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'OK' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
});
test('renders extra checkboxes when type is time series', () => {
render(<AnnotationLayer {...defaultProps} />);
expect(
screen.queryByRole('button', { name: 'Show Markers' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Hide Line' }),
).not.toBeInTheDocument();
userEvent.click(screen.getAllByText('Formula')[0]);
userEvent.click(screen.getByText('Time series'));
expect(
screen.getByRole('button', { name: 'Show Markers' }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Hide Line' })).toBeInTheDocument();
});
test('enables apply and ok buttons', async () => {
render(<AnnotationLayer {...defaultProps} />);
userEvent.type(screen.getByLabelText('Name'), 'Test');
userEvent.type(screen.getByLabelText('Formula'), '2x');
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'OK' })).toBeEnabled();
});
});
test('triggers addAnnotationLayer when apply button is clicked', () => {
const addAnnotationLayer = jest.fn();
render(
<AnnotationLayer
{...defaultProps}
name="Test"
value="2x"
addAnnotationLayer={addAnnotationLayer}
/>,
);
userEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(addAnnotationLayer).toHaveBeenCalled();
});
test('triggers addAnnotationLayer and close when ok button is clicked', () => {
const addAnnotationLayer = jest.fn();
const close = jest.fn();
render(
<AnnotationLayer
{...defaultProps}
name="Test"
value="2x"
addAnnotationLayer={addAnnotationLayer}
close={close}
/>,
);
userEvent.click(screen.getByRole('button', { name: 'OK' }));
expect(addAnnotationLayer).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});
test('triggers close when cancel button is clicked', () => {
const close = jest.fn();
render(<AnnotationLayer {...defaultProps} close={close} />);
userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(close).toHaveBeenCalled();
});
test('triggers removeAnnotationLayer and close when remove button is clicked', () => {
const removeAnnotationLayer = jest.fn();
const close = jest.fn();
render(
<AnnotationLayer
{...defaultProps}
name="Test"
value="2x"
removeAnnotationLayer={removeAnnotationLayer}
close={close}
/>,
);
userEvent.click(screen.getByRole('button', { name: 'Remove' }));
expect(removeAnnotationLayer).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});
test('renders chart options', async () => {
render(
<AnnotationLayer
{...defaultProps}
annotationType={ANNOTATION_TYPES_METADATA.EVENT.value}
/>,
);
userEvent.click(screen.getByText('2 option(s)'));
userEvent.click(screen.getByText('Superset annotation'));
expect(await screen.findByLabelText('Annotation layer')).toBeInTheDocument();
userEvent.click(screen.getByText('Superset annotation'));
userEvent.click(screen.getByText('Table'));
expect(await screen.findByLabelText('Chart')).toBeInTheDocument();
});
test('keeps apply disabled when missing required fields', async () => {
render(
<AnnotationLayer
{...defaultProps}
annotationType={ANNOTATION_TYPES_METADATA.EVENT.value}
sourceType="Table"
/>,
);
userEvent.click(await screen.findByText('1 option(s)'));
userEvent.click(screen.getByText('Chart A'));
expect(
screen.getByText('Annotation Slice Configuration'),
).toBeInTheDocument();
userEvent.click(screen.getByRole('button', { name: 'Automatic Color' }));
userEvent.click(screen.getByLabelText('Title Column'));
userEvent.click(screen.getByText('None'));
userEvent.click(screen.getByLabelText('Style'));
userEvent.click(screen.getByText('Dashed'));
userEvent.click(screen.getByLabelText('Opacity'));
userEvent.click(screen.getByText('0.5'));
const checkboxes = screen.getAllByRole('checkbox');
checkboxes.forEach(checkbox => userEvent.click(checkbox));
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
});

View File

@@ -0,0 +1,242 @@
/**
* 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 from 'react';
import PropTypes from 'prop-types';
import { ListGroup, ListGroupItem } from 'react-bootstrap';
import { connect } from 'react-redux';
import { t, withTheme } from '@superset-ui/core';
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import Popover from 'src/components/Popover';
import AsyncEsmComponent from 'src/components/AsyncEsmComponent';
import { getChartKey } from 'src/explore/exploreUtils';
import { runAnnotationQuery } from 'src/chart/chartAction';
const AnnotationLayer = AsyncEsmComponent(
() => import('./AnnotationLayer'),
// size of overlay inner content
() => <div style={{ width: 450, height: 368 }} />,
);
const propTypes = {
colorScheme: PropTypes.string.isRequired,
annotationError: PropTypes.object,
annotationQuery: PropTypes.object,
vizType: PropTypes.string,
validationErrors: PropTypes.array,
name: PropTypes.string.isRequired,
actions: PropTypes.object,
value: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func,
refreshAnnotationData: PropTypes.func,
};
const defaultProps = {
vizType: '',
value: [],
annotationError: {},
annotationQuery: {},
onChange: () => {},
};
class AnnotationLayerControl extends React.PureComponent {
constructor(props) {
super(props);
this.state = { popoverVisible: {}, addedAnnotationIndex: null };
this.addAnnotationLayer = this.addAnnotationLayer.bind(this);
this.removeAnnotationLayer = this.removeAnnotationLayer.bind(this);
this.handleVisibleChange = this.handleVisibleChange.bind(this);
}
componentDidMount() {
// preload the AnotationLayer component and dependent libraries i.e. mathjs
AnnotationLayer.preload();
}
UNSAFE_componentWillReceiveProps(nextProps) {
const { name, annotationError, validationErrors, value } = nextProps;
if (Object.keys(annotationError).length && !validationErrors.length) {
this.props.actions.setControlValue(
name,
value,
Object.keys(annotationError),
);
}
if (!Object.keys(annotationError).length && validationErrors.length) {
this.props.actions.setControlValue(name, value, []);
}
}
addAnnotationLayer(originalAnnotation, newAnnotation) {
let annotations = this.props.value;
if (annotations.includes(originalAnnotation)) {
annotations = annotations.map(anno =>
anno === originalAnnotation ? newAnnotation : anno,
);
} else {
annotations = [...annotations, newAnnotation];
this.setState({ addedAnnotationIndex: annotations.length - 1 });
}
this.props.refreshAnnotationData(newAnnotation);
this.props.onChange(annotations);
}
handleVisibleChange(visible, popoverKey) {
this.setState(prevState => ({
popoverVisible: { ...prevState.popoverVisible, [popoverKey]: visible },
}));
}
removeAnnotationLayer(annotation) {
const annotations = this.props.value.filter(anno => anno !== annotation);
this.props.onChange(annotations);
}
renderPopover(popoverKey, annotation, error) {
const id = annotation?.name || '_new';
return (
<div id={`annotation-pop-${id}`} data-test="popover-content">
<AnnotationLayer
{...annotation}
error={error}
colorScheme={this.props.colorScheme}
vizType={this.props.vizType}
addAnnotationLayer={newAnnotation =>
this.addAnnotationLayer(annotation, newAnnotation)
}
removeAnnotationLayer={() => this.removeAnnotationLayer(annotation)}
close={() => {
this.handleVisibleChange(false, popoverKey);
this.setState({ addedAnnotationIndex: null });
}}
/>
</div>
);
}
renderInfo(anno) {
const { annotationError, annotationQuery } = this.props;
if (annotationQuery[anno.name]) {
return (
<i className="fa fa-refresh" style={{ color: 'orange' }} aria-hidden />
);
}
if (annotationError[anno.name]) {
return (
<InfoTooltipWithTrigger
label="validation-errors"
bsStyle="danger"
tooltip={annotationError[anno.name]}
/>
);
}
if (!anno.show) {
return <span style={{ color: 'red' }}> Hidden </span>;
}
return '';
}
render() {
const { addedAnnotationIndex } = this.state;
const addedAnnotation = this.props.value[addedAnnotationIndex];
const annotations = this.props.value.map((anno, i) => (
<Popover
key={i}
trigger="click"
placement="right"
title={t('Edit annotation layer')}
content={this.renderPopover(
i,
anno,
this.props.annotationError[anno.name],
)}
visible={this.state.popoverVisible[i]}
onVisibleChange={visible => this.handleVisibleChange(visible, i)}
>
<ListGroupItem>
<span>{anno.name}</span>
<span style={{ float: 'right' }}>{this.renderInfo(anno)}</span>
</ListGroupItem>
</Popover>
));
const addLayerPopoverKey = 'add';
return (
<div>
<ListGroup>
{annotations}
<Popover
trigger="click"
placement="right"
content={this.renderPopover(addLayerPopoverKey, addedAnnotation)}
title={t('Add annotation layer')}
visible={this.state.popoverVisible[addLayerPopoverKey]}
destroyTooltipOnHide
onVisibleChange={visible =>
this.handleVisibleChange(visible, addLayerPopoverKey)
}
>
<ListGroupItem>
<i
data-test="add-annotation-layer-button"
className="fa fa-plus"
/>{' '}
&nbsp; {t('Add annotation layer')}
</ListGroupItem>
</Popover>
</ListGroup>
</div>
);
}
}
AnnotationLayerControl.propTypes = propTypes;
AnnotationLayerControl.defaultProps = defaultProps;
// Tried to hook this up through stores/control.jsx instead of using redux
// directly, could not figure out how to get access to the color_scheme
function mapStateToProps({ charts, explore }) {
const chartKey = getChartKey(explore);
const chart = charts[chartKey] || charts[0] || {};
return {
// eslint-disable-next-line camelcase
colorScheme: explore.controls?.color_scheme?.value,
annotationError: chart.annotationError,
annotationQuery: chart.annotationQuery,
vizType: explore.controls.viz_type.value,
};
}
function mapDispatchToProps(dispatch) {
return {
refreshAnnotationData: annotationLayer =>
dispatch(runAnnotationQuery(annotationLayer)),
};
}
const themedAnnotationLayerControl = withTheme(AnnotationLayerControl);
export default connect(
mapStateToProps,
mapDispatchToProps,
)(themedAnnotationLayerControl);