/** * 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 { t, SupersetClient, getCategoricalSchemeRegistry, getChartMetadataRegistry, validateNonEmpty, isValidExpression, styled, withTheme, } 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'; import { EmptyStateSmall } from 'src/components/EmptyState'; 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, showLabel: 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, showLabel: false, titleColumn: '', descriptionColumns: [], timeColumn: '', intervalEndColumn: '', addAnnotationLayer: () => {}, removeAnnotationLayer: () => {}, close: () => {}, }; const NotFoundContentWrapper = styled.div` && > div:first-child { padding-left: 0; padding-right: 0; } `; const NotFoundContent = () => ( {t('Add an annotation layer')}{' '} {t('here')} . } image="empty.svg" /> ); class AnnotationLayer extends React.PureComponent { constructor(props) { super(props); const { name, annotationType, sourceType, color, opacity, style, width, showMarkers, hideLine, value, overrides, show, showLabel, 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, showLabel, // 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; } isValidFormulaAnnotation(expression, annotationType) { if (annotationType === ANNOTATION_TYPES.FORMULA) { return isValidExpression(expression); } return true; } 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.isValidFormulaAnnotation(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: [], 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', 'showLabel', '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 ( {option.label} ); } 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 = t( `Use another existing chart as a source for annotations and overlays. Your chart must be one of these visualization types: [%s]`, 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 ( } /> ); } if (annotationType === ANNOTATION_TYPES.FORMULA) { return ( ); } 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 (
{(annotationType === ANNOTATION_TYPES.EVENT || annotationType === ANNOTATION_TYPES.INTERVAL) && ( this.setState({ timeColumn: v })} /> )} {annotationType === ANNOTATION_TYPES.INTERVAL && ( this.setState({ intervalEndColumn: value })} /> )} this.setState({ titleColumn: value })} /> {annotationType !== ANNOTATION_TYPES.TIME_SERIES && ( this.setState({ descriptionColumns: value })} /> )}
{ delete overrides.time_range; if (v) { this.setState({ overrides: { ...overrides, time_range: null }, }); } else { this.setState({ overrides: { ...overrides } }); } }} /> { 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 } }); } }} /> this.setState({ overrides: { ...overrides, time_shift: v } }) } />
); } 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 ( this.setState({ style: v })} /> this.setState({ opacity: value })} />
this.setState({ color: v.hex })} />
this.setState({ width: v })} /> {annotationType === ANNOTATION_TYPES.TIME_SERIES && ( this.setState({ showMarkers: v })} /> )} {annotationType === ANNOTATION_TYPES.TIME_SERIES && ( this.setState({ hideLine: v })} /> )}
); } render() { const { isNew, name, annotationType, sourceType, show, showLabel } = 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 && ( ERROR: {this.props.error} )}
this.setState({ name: v })} validationErrors={!name ? [t('Mandatory')] : []} /> this.setState({ show: !v })} /> this.setState({ showLabel: v })} /> {supportedSourceTypes.length > 0 && ( } value={sourceType} onChange={this.handleAnnotationSourceType} validationErrors={!sourceType ? [t('Mandatory')] : []} /> )} {this.renderValueConfiguration()}
{this.renderSliceConfiguration()} {this.renderDisplayConfiguration()}
{isNew ? ( ) : ( )}
); } } AnnotationLayer.propTypes = propTypes; AnnotationLayer.defaultProps = defaultProps; export default withTheme(AnnotationLayer);