/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import rison from 'rison'; import { Button, AsyncSelect, EmptyState, ColorPicker, } from '@superset-ui/core/components'; import { SupersetClient, getCategoricalSchemeRegistry, getChartMetadataRegistry, validateNonEmpty, isValidExpression, getColumnLabel, VizType, type QueryFormColumn, } from '@superset-ui/core'; import { t } from '@apache-superset/core/translation'; import { styled, useTheme } from '@apache-superset/core/theme'; 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 PopoverSection from '@superset-ui/core/components/PopoverSection'; import ControlHeader from 'src/explore/components/ControlHeader'; import { ANNOTATION_SOURCE_TYPES, ANNOTATION_TYPES, ANNOTATION_TYPES_METADATA, DEFAULT_ANNOTATION_TYPE, requiresQuery, ANNOTATION_SOURCE_TYPES_METADATA, } from './AnnotationTypes'; interface SelectOption { value: string | number; label: string; viz_type?: string; [key: string]: unknown; } interface SliceData { data: { groupby?: string[]; all_columns?: string[]; include_time?: boolean; [key: string]: unknown; }; } interface AnnotationOverrides { time_range?: string | null; time_grain_sqla?: string | null; granularity?: string | null; time_shift?: string; [key: string]: unknown; } interface AnnotationLayerProps { name?: string; annotationType?: string; sourceType?: string; color?: string; opacity?: string; style?: string; width?: number; showMarkers?: boolean; hideLine?: boolean; value?: string | number | SelectOption; overrides?: AnnotationOverrides; show?: boolean; showLabel?: boolean; titleColumn?: string; descriptionColumns?: string[]; timeColumn?: string; intervalEndColumn?: string; vizType?: string; error?: string; colorScheme?: string; addAnnotationLayer?: (annotation: Record) => void; removeAnnotationLayer?: () => void; close?: () => void; } const AUTOMATIC_COLOR = ''; const NotFoundContentWrapper = styled.div` && > div:first-of-type { padding-left: 0; padding-right: 0; } `; const NotFoundContent = () => ( {t('Add an annotation layer')}{' '} {t('here')} . } image="empty.svg" /> ); function AnnotationLayer({ name: propName = '', annotationType: propAnnotationType = DEFAULT_ANNOTATION_TYPE, sourceType: propSourceType = '', color: propColor = AUTOMATIC_COLOR, opacity: propOpacity = '', style: propStyle = 'solid', width: propWidth = 1, showMarkers: propShowMarkers = false, hideLine: propHideLine = false, value: propValue, overrides: propOverrides = {}, show: propShow = true, showLabel: propShowLabel = false, titleColumn: propTitleColumn = '', descriptionColumns: propDescriptionColumns = [], timeColumn: propTimeColumn = '', intervalEndColumn: propIntervalEndColumn = '', vizType, error, colorScheme = 'd3Category10', addAnnotationLayer = () => {}, removeAnnotationLayer = () => {}, close = () => {}, }: AnnotationLayerProps) { const theme = useTheme(); // Process overrides - only allow override whole time_range const processedOverrides = useMemo((): AnnotationOverrides => { const result: AnnotationOverrides = propOverrides ? { ...propOverrides } : {}; if ('since' in result || 'until' in result) { result.time_range = null; delete result.since; delete result.until; } return result; }, [propOverrides]); // Check if annotationType is supported by this chart const validAnnotationType = useMemo(() => { const metadata = vizType ? getChartMetadataRegistry().get(vizType) : null; const supportedAnnotationTypes = metadata?.supportedAnnotationTypes || []; const resolvedAnnotationType = propAnnotationType || DEFAULT_ANNOTATION_TYPE; const isValid = supportedAnnotationTypes.includes(resolvedAnnotationType); return isValid ? resolvedAnnotationType : supportedAnnotationTypes[0] || DEFAULT_ANNOTATION_TYPE; }, [vizType, propAnnotationType]); // State const [name, setName] = useState(propName || ''); const [annotationType, setAnnotationType] = useState(validAnnotationType); const [sourceType, setSourceType] = useState( propSourceType || null, ); const [value, setValue] = useState( propValue || null, ); const [overrides, setOverrides] = useState(processedOverrides); const [show, setShow] = useState(propShow ?? true); const [showLabel, setShowLabel] = useState(propShowLabel ?? false); const [titleColumn, setTitleColumn] = useState(propTitleColumn || ''); const [descriptionColumns, setDescriptionColumns] = useState( propDescriptionColumns || [], ); const [timeColumn, setTimeColumn] = useState(propTimeColumn || ''); const [intervalEndColumn, setIntervalEndColumn] = useState( propIntervalEndColumn || '', ); const [color, setColor] = useState(propColor || AUTOMATIC_COLOR); const [opacity, setOpacity] = useState(propOpacity || ''); const [style, setStyle] = useState(propStyle || 'solid'); const [width, setWidth] = useState(propWidth ?? 1); const [showMarkers, setShowMarkers] = useState(propShowMarkers ?? false); const [hideLine, setHideLine] = useState(propHideLine ?? false); const [isNew, setIsNew] = useState(!propName); const [slice, setSlice] = useState(null); const getSupportedSourceTypes = useCallback( (annoType: string): SelectOption[] => { // Get vis types that can be source. const sources = getChartMetadataRegistry() .entries() .filter(({ value: chartMetadata }) => chartMetadata?.canBeAnnotationType(annoType), ) .map(({ key, value: chartMetadata }) => ({ value: key === VizType.Line ? 'line' : key, label: chartMetadata?.name || key, })); // Prepend native source if applicable const annotationMeta = ANNOTATION_TYPES_METADATA[ annoType as keyof typeof ANNOTATION_TYPES_METADATA ]; if (annotationMeta && 'supportNativeSource' in annotationMeta) { sources.unshift(ANNOTATION_SOURCE_TYPES_METADATA.NATIVE); } return sources; }, [], ); const shouldFetchAppliedAnnotation = useCallback( (): boolean => !!value && requiresQuery(sourceType ?? undefined), [value, sourceType], ); const fetchNativeAnnotations = useCallback( async ( search: string, page: number, pageSize: number, ): Promise<{ data: SelectOption[]; totalCount: number }> => { const queryParams = rison.encode({ filters: [ { col: 'name', opr: 'ct', value: search, }, ], columns: ['id', 'name'], page, page_size: pageSize, }); const { json } = await SupersetClient.get({ endpoint: `/api/v1/annotation_layer/?q=${queryParams}`, }); const { result, count } = json; const layersArray = result.map((layer: { id: number; name: string }) => ({ value: layer.id, label: layer.name, })); return { data: layersArray, totalCount: count, }; }, [], ); const fetchCharts = useCallback( async ( search: string, page: number, pageSize: number, ): Promise<{ data: SelectOption[]; totalCount: number }> => { const queryParams = rison.encode({ filters: [ { col: 'slice_name', opr: 'chart_all_text', value: search }, { col: 'id', opr: 'chart_owned_created_favored_by_me', value: true, }, ], columns: ['id', 'slice_name', 'viz_type'], order_column: 'slice_name', order_direction: 'asc', page, page_size: pageSize, }); const { json } = await SupersetClient.get({ endpoint: `/api/v1/chart/?q=${queryParams}`, }); const { result, count } = json; const registry = getChartMetadataRegistry(); const chartsArray = result .filter( (chart: { id: number; slice_name: string; viz_type: string }) => { const metadata = registry.get(chart.viz_type); return metadata && metadata.canBeAnnotationType(annotationType); }, ) .map((chart: { id: number; slice_name: string; viz_type: string }) => ({ value: chart.id, label: chart.slice_name, viz_type: chart.viz_type, })); return { data: chartsArray, totalCount: count, }; }, [annotationType], ); const fetchOptions = useCallback( ( search: string, page: number, pageSize: number, ): Promise<{ data: SelectOption[]; totalCount: number }> => { if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { return fetchNativeAnnotations(search, page, pageSize); } return fetchCharts(search, page, pageSize); }, [sourceType, fetchNativeAnnotations, fetchCharts], ); const fetchSliceData = useCallback((id: string | number): void => { const queryParams = rison.encode({ columns: ['query_context'], }); SupersetClient.get({ endpoint: `/api/v1/chart/${id}?q=${queryParams}`, }).then(({ json }) => { const { result } = json; const queryContext = result.query_context; const formData = JSON.parse(queryContext).form_data; const dataObject = { data: { ...formData, groupby: formData.groupby?.map((column: QueryFormColumn) => getColumnLabel(column), ), }, }; setSlice(dataObject); }); }, []); const fetchAppliedChart = useCallback( (id: string | number): void => { const registry = getChartMetadataRegistry(); const queryParams = rison.encode({ columns: ['slice_name', 'query_context', 'viz_type'], }); SupersetClient.get({ endpoint: `/api/v1/chart/${id}?q=${queryParams}`, }).then(({ json }) => { const { result } = json; const sliceName = result.slice_name; const queryContext = result.query_context; const chartVizType = result.viz_type; const formData = JSON.parse(queryContext).form_data; const metadata = registry.get(chartVizType); const canBeAnnotationType = metadata && metadata.canBeAnnotationType(annotationType); if (canBeAnnotationType) { setValue({ value: id, label: sliceName, }); setSlice({ data: { ...formData, groupby: formData.groupby?.map((column: QueryFormColumn) => getColumnLabel(column), ), }, }); } }); }, [annotationType], ); const fetchAppliedNativeAnnotation = useCallback( (id: string | number): void => { SupersetClient.get({ endpoint: `/api/v1/annotation_layer/${id}`, }).then(({ json }) => { const { result } = json; const layer = result; setValue({ value: layer.id, label: layer.name, }); }); }, [], ); const fetchAppliedAnnotation = useCallback( (id: string | number): void => { if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { fetchAppliedNativeAnnotation(id); } else { fetchAppliedChart(id); } }, [sourceType, fetchAppliedNativeAnnotation, fetchAppliedChart], ); // componentDidMount - fetch applied annotation if needed useEffect(() => { if (shouldFetchAppliedAnnotation()) { /* The value prop is the id of the chart/native. This function will set value in state to an object with the id as value.value to be used by AsyncSelect */ if ( propValue !== null && propValue !== undefined && typeof propValue !== 'object' ) { fetchAppliedAnnotation(propValue); } } // Only run on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Track previous value for componentDidUpdate comparison const [prevValue, setPrevValue] = useState< string | number | SelectOption | null >(value); // componentDidUpdate - fetch slice data when value changes useEffect(() => { if (value !== prevValue) { const isChart = sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE && requiresQuery(sourceType ?? undefined); if (isChart && value && typeof value === 'object' && 'value' in value) { fetchSliceData(value.value); } setPrevValue(value); } }, [value, prevValue, sourceType, fetchSliceData]); const isValidFormulaAnnotation = useCallback( ( expression: string | number | SelectOption | null, annoType: string, ): boolean => { if (annoType === ANNOTATION_TYPES.FORMULA) { return isValidExpression(expression as string); } return true; }, [], ); const isValidForm = useCallback((): boolean => { 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)); } } if (!isValidFormulaAnnotation(value, annotationType)) { errors.push(t('Invalid formula expression')); } return !errors.filter(x => x).length; }, [ name, annotationType, sourceType, value, timeColumn, intervalEndColumn, isValidFormulaAnnotation, ]); const handleAnnotationType = useCallback((annoType: string): void => { setAnnotationType(annoType); setSourceType(null); setValue(null); setSlice(null); }, []); const handleAnnotationSourceType = useCallback( (newSourceType: string): void => { if (sourceType !== newSourceType) { setSourceType(newSourceType); setValue(null); setSlice(null); } }, [sourceType], ); const handleSelectValue = useCallback( (selectedValueObject: SelectOption): void => { setValue(selectedValueObject); setDescriptionColumns([]); setIntervalEndColumn(''); setTimeColumn(''); setTitleColumn(''); setOverrides({ time_range: null }); }, [], ); const handleTextValue = useCallback((inputValue: string): void => { setValue(inputValue); }, []); const deleteAnnotation = useCallback((): void => { removeAnnotationLayer?.(); close?.(); }, [removeAnnotationLayer, close]); const applyAnnotation = useCallback((): void => { if (isValidForm()) { const stateValues = { name, annotationType, sourceType, color, opacity, style, width, showMarkers, hideLine, overrides, show, showLabel, titleColumn, descriptionColumns, timeColumn, intervalEndColumn, }; const newAnnotation: Record = {}; Object.entries(stateValues).forEach(([field, fieldValue]) => { if (fieldValue !== null) { newAnnotation[field] = fieldValue; } }); // Prepare newAnnotation.value for use in runAnnotationQuery() const applicableValue = requiresQuery(sourceType ?? undefined) && value && typeof value === 'object' ? (value as SelectOption).value : value; newAnnotation.value = applicableValue; if (newAnnotation.color === AUTOMATIC_COLOR) { newAnnotation.color = null; } addAnnotationLayer?.(newAnnotation); setIsNew(false); } }, [ isValidForm, name, annotationType, sourceType, color, opacity, style, width, showMarkers, hideLine, overrides, show, showLabel, titleColumn, descriptionColumns, timeColumn, intervalEndColumn, value, addAnnotationLayer, ]); const submitAnnotation = useCallback((): void => { applyAnnotation(); close?.(); }, [applyAnnotation, close]); const renderChartHeader = useCallback( ( label: string, description: string, val: string | number | SelectOption | null, ): React.ReactNode => ( ), [], ); const renderValueConfiguration = useCallback((): React.ReactNode => { let label = ''; let description = ''; if (requiresQuery(sourceType ?? undefined)) { if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { label = t('Annotation layer'); description = t('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]`, getSupportedSourceTypes(annotationType) .map(x => x.label) .join(', '), ); } } else if (annotationType === ANNOTATION_TYPES.FORMULA) { label = t('Formula'); description = t(`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 ?? undefined)) { return ( } /> ); } if (annotationType === ANNOTATION_TYPES.FORMULA) { // Extract primitive value for TextControl (formula is always a string) const textValue = typeof value === 'object' ? null : value; return ( ); } return ''; }, [ sourceType, annotationType, value, getSupportedSourceTypes, renderChartHeader, fetchOptions, handleSelectValue, handleTextValue, isValidFormulaAnnotation, ]); const renderSliceConfiguration = useCallback((): React.ReactNode => { if (!slice || !value) { return ''; } 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) && ( setTimeColumn(String(v ?? ''))} /> )} {annotationType === ANNOTATION_TYPES.INTERVAL && ( setIntervalEndColumn(String(v ?? ''))} /> )} setTitleColumn(String(v ?? ''))} /> {annotationType !== ANNOTATION_TYPES.TIME_SERIES && ( { const cols = Array.isArray(v) ? v.map(String) : []; setDescriptionColumns(cols); }} /> )}
{ const newOverrides = { ...overrides }; delete newOverrides.time_range; if (v) { setOverrides({ ...newOverrides, time_range: null }); } else { setOverrides({ ...newOverrides }); } }} /> { const newOverrides = { ...overrides }; delete newOverrides.time_grain_sqla; delete newOverrides.granularity; if (v) { setOverrides({ ...newOverrides, time_grain_sqla: null, granularity: null, }); } else { setOverrides({ ...newOverrides }); } }} /> setOverrides({ ...overrides, time_shift: v })} />
); } return ''; }, [ slice, value, sourceType, annotationType, timeColumn, intervalEndColumn, titleColumn, descriptionColumns, overrides, ]); const renderDisplayConfiguration = useCallback((): React.ReactNode => { const schemeColors = getCategoricalSchemeRegistry().get(colorScheme)?.colors.concat() ?? []; if ( color && color !== AUTOMATIC_COLOR && !schemeColors.some(x => x.toLowerCase() === color.toLowerCase()) ) { schemeColors.push(color); } return ( setStyle(String(v ?? 'solid'))} /> setOpacity(String(v ?? ''))} />
{ if (useAutomatic) { setColor(AUTOMATIC_COLOR); } else { // Set to first theme color or dark color as fallback setColor(schemeColors[0] || theme.colorTextBase); } }} /> {color !== AUTOMATIC_COLOR && (
setColor(colorValue.toHexString()) } showText />
)}
setWidth(v)} /> {annotationType === ANNOTATION_TYPES.TIME_SERIES && ( setShowMarkers(v)} /> )} {annotationType === ANNOTATION_TYPES.TIME_SERIES && ( setHideLine(v)} /> )}
); }, [ colorScheme, color, opacity, style, width, showMarkers, hideLine, annotationType, theme, ]); const isValid = isValidForm(); const metadata = vizType ? getChartMetadataRegistry().get(vizType) : null; const supportedAnnotationTypes = metadata ? metadata.supportedAnnotationTypes.map( type => ANNOTATION_TYPES_METADATA[ type as keyof typeof ANNOTATION_TYPES_METADATA ], ) : []; const supportedSourceTypes = getSupportedSourceTypes(annotationType); return ( <> {error && ( {t('ERROR')}: {error} )}
setName(v)} validationErrors={!name ? [t('Mandatory')] : []} /> setShow(!v)} /> setShowLabel(v)} /> {supportedSourceTypes.length > 0 && ( } value={sourceType} onChange={handleAnnotationSourceType} validationErrors={!sourceType ? [t('Mandatory')] : []} /> )} {renderValueConfiguration()}
{renderSliceConfiguration()} {renderDisplayConfiguration()}
{isNew ? ( ) : ( )}
); } export default AnnotationLayer;