/** * 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 mathjs from 'mathjs'; import { t, SupersetClient, getCategoricalSchemeRegistry, getChartMetadataRegistry, ThemeProvider, validateNonEmpty, } from '@superset-ui/core'; import SelectControl from './SelectControl'; import TextControl from './TextControl'; import CheckboxControl from './CheckboxControl'; import { ANNOTATION_SOURCE_TYPES, ANNOTATION_TYPES, ANNOTATION_TYPES_METADATA, DEFAULT_ANNOTATION_TYPE, requiresQuery, ANNOTATION_SOURCE_TYPES_METADATA, } from '../../../modules/AnnotationTypes'; import PopoverSection from '../../../components/PopoverSection'; import ControlHeader from '../ControlHeader'; import './AnnotationLayer.less'; 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, theme: PropTypes.object, 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, } = props; const overridesKeys = Object.keys(overrides); if (overridesKeys.includes('since') || overridesKeys.includes('until')) { overrides.time_range = null; delete overrides.since; delete overrides.until; } this.state = { // base name, oldName: !this.props.name ? null : name, annotationType, sourceType, value, overrides, show, // slice titleColumn, descriptionColumns, timeColumn, intervalEndColumn, // display color: color || AUTOMATIC_COLOR, opacity, style, width, showMarkers, hideLine, // refData isNew: !this.props.name, isLoadingOptions: true, valueOptions: [], validationErrors: {}, }; 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 { mathjs.parse(value).compile().eval({ 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, validationErrors: {}, value: null, }); } handleAnnotationSourceType(sourceType) { const { sourceType: prevSourceType } = this.state; if (prevSourceType !== sourceType) { this.setState({ sourceType, isLoadingOptions: true, validationErrors: {}, value: null, }); } } handleValue(value) { this.setState({ value, descriptionColumns: null, intervalEndColumn: null, timeColumn: null, titleColumn: null, overrides: { time_range: null }, }); } fetchOptions(annotationType, sourceType, isLoadingOptions) { if (isLoadingOptions === true) { 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.close(); if (!this.state.isNew) { this.props.removeAnnotationLayer(this.state); } } applyAnnotation() { if (this.state.name.length) { const annotation = {}; Object.keys(this.state).forEach(k => { if (this.state[k] !== null) { annotation[k] = this.state[k]; } }); delete annotation.isNew; delete annotation.valueOptions; delete annotation.isLoadingOptions; delete annotation.validationErrors; annotation.color = annotation.color === AUTOMATIC_COLOR ? null : annotation.color; this.props.addAnnotationLayer(annotation); this.setState(prevState => ({ isNew: false, oldName: prevState.name })); } } 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 = 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 ( ); } 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 (
{}} title="Annotation Slice Configuration" info={`This section allows you to configure how to use the slice to generate annotations.`} > {(annotationType === ANNOTATION_TYPES.EVENT || annotationType === ANNOTATION_TYPES.INTERVAL) && ( this.setState({ timeColumn: v })} /> )} {annotationType === ANNOTATION_TYPES.INTERVAL && ( this.setState({ intervalEndColumn: v })} /> )} this.setState({ titleColumn: v })} /> {annotationType !== ANNOTATION_TYPES.TIME_SERIES && ( this.setState({ descriptionColumns: v })} /> )}
x === 'time_range')} onChange={v => { delete overrides.time_range; if (v) { this.setState({ overrides: { ...overrides, time_range: null }, }); } else { this.setState({ overrides: { ...overrides } }); } }} /> x === 'time_grain_sqla') } 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 } }); } }} /> 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 ( {}} title={t('Display configuration')} info={t('Configure your how you overlay is displayed here.')} > this.setState({ style: v })} /> this.setState({ opacity: v })} />
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 } = this.state; const isValid = this.isValidForm(); const { theme } = this.props; 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} )}
{}} title={t('Layer Configuration')} info={t('Configure the basics of your Annotation Layer.')} > this.setState({ name: v })} validationErrors={!name ? [t('Mandatory')] : []} /> this.setState({ show: !v })} /> {!!supportedSourceTypes.length && ( )} {this.renderValueConfiguration()}
{this.renderSliceConfiguration()} {this.renderDisplayConfiguration()}
); } } AnnotationLayer.propTypes = propTypes; AnnotationLayer.defaultProps = defaultProps;