mirror of
https://github.com/apache/superset.git
synced 2026-05-10 10:25:51 +00:00
test: Adds tests to the AnnotationLayer component (#13748)
This commit is contained in:
committed by
GitHub
parent
9156f089ff
commit
bb677b8ef1
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
/>{' '}
|
||||
{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);
|
||||
Reference in New Issue
Block a user