/** * 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, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import rison from 'rison'; import { Button, AsyncSelect, EmptyState, ColorPicker, Typography, } 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 { logging } from '@apache-superset/core/utils'; 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 { CONTROL_SECTIONS_ID } from 'src/explore/constants'; import { ensureAppRoot } from 'src/utils/navigationUtils'; 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; canReadAnnotation: boolean; addAnnotationLayer?: (annotation: Record) => void; removeAnnotationLayer?: () => void; close?: () => void; } const AUTOMATIC_COLOR = ''; /** Space between the popover's side-by-side configuration sections, in size units. */ const SECTION_GAP_UNITS = 8; /** Breathing room kept between the popover and the viewport edge, in size units. */ const VIEWPORT_INSET_UNITS = 2; const SectionsRow = styled.div<{ $maxWidth: number | string }>` display: flex; flex-direction: row; /* A third section can outgrow a narrow viewport, and an oversized popover * cannot be shrunk by antd, only flipped. Bounding the row compresses the * sections instead of pushing the footer off screen. */ flex-wrap: wrap; gap: ${({ theme }) => theme.sizeUnit * SECTION_GAP_UNITS}px; max-width: ${({ $maxWidth }) => typeof $maxWidth === 'number' ? `${$maxWidth}px` : $maxWidth}; `; interface ChartApiResult { params?: string | null; query_context?: string | null; } const parseJsonObject = ( raw: string | null | undefined, ): Record | null => { if (!raw) { return null; } try { const parsed: unknown = JSON.parse(raw); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null; } catch { return null; } }; // `query_context` is only backfilled once a chart is opened in Explore, so fall // back to `params`, which holds the same form data and is always present. const getSliceFormData = ( result: ChartApiResult, ): Record | null => { const formData = parseJsonObject(result.query_context)?.form_data; if (formData && typeof formData === 'object') { return formData as Record; } return parseJsonObject(result.params); }; const reportChartFailure = (id: string | number) => (error: unknown) => logging.error(`Failed to load annotation source chart ${id}`, error); const reportAnnotationLayerFailure = (id: string | number) => (error: unknown) => logging.error(`Failed to load annotation layer ${id}`, error); const toSliceData = (formData: Record): SliceData => ({ data: { ...formData, groupby: (formData.groupby as QueryFormColumn[] | undefined)?.map(column => getColumnLabel(column), ), }, }); const NotFoundContentWrapper = styled.div` && > div:first-of-type { padding-left: 0; padding-right: 0; } `; const NotFoundContent = () => ( {t('Add an annotation layer')}{' '} ` sink. The string fed in is a // URL-normalised path (`/seg/seg`) so encodeURI is idempotent in // practice — it does not alter the navigation target. href={encodeURI(ensureAppRoot('/annotationlayer/list'))} target="_blank" rel="noopener noreferrer" > {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', canReadAnnotation, 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 sectionsRef = useRef(null); const [sectionsMaxWidth, setSectionsMaxWidth] = useState(); 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. Listing native annotation layers // requires can_read on Annotation; without it the option is offered only // while it is the layer's current selection, so a saved native layer // stays intact instead of being silently invalidated. const annotationMeta = ANNOTATION_TYPES_METADATA[ annoType as keyof typeof ANNOTATION_TYPES_METADATA ]; if ( annotationMeta && 'supportNativeSource' in annotationMeta && (canReadAnnotation || sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) ) { sources.unshift(ANNOTATION_SOURCE_TYPES_METADATA.NATIVE); } return sources; }, [canReadAnnotation, sourceType], ); 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], ); // Both the mount and the selection path load a chart to fill the slice // configuration from, so they share how a loaded chart is applied and how a // failure is reported. const applySliceFormData = useCallback( (id: string | number, result: ChartApiResult): void => { const formData = getSliceFormData(result); if (formData) { setSlice(toSliceData(formData)); return; } logging.warn( `Annotation source chart ${id} has no usable form data; ` + 'the slice configuration fields cannot be populated.', ); }, [], ); const fetchSliceData = useCallback( (id: string | number): void => { const queryParams = rison.encode({ columns: ['params', 'query_context'], }); SupersetClient.get({ endpoint: `/api/v1/chart/${id}?q=${queryParams}`, }) .then(({ json }) => applySliceFormData(id, json.result)) .catch(reportChartFailure(id)); }, [applySliceFormData], ); const fetchAppliedChart = useCallback( (id: string | number): void => { const registry = getChartMetadataRegistry(); const queryParams = rison.encode({ columns: ['slice_name', 'params', 'query_context', 'viz_type'], }); SupersetClient.get({ endpoint: `/api/v1/chart/${id}?q=${queryParams}`, }) .then(({ json }) => { const { result } = json; const metadata = registry.get(result.viz_type); if (!metadata?.canBeAnnotationType(annotationType)) { return; } setValue({ value: id, label: result.slice_name, }); applySliceFormData(id, result); }) .catch(reportChartFailure(id)); }, [annotationType, applySliceFormData], ); 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, }); }) .catch(reportAnnotationLayerFailure(id)); }, [], ); const fetchAppliedAnnotation = useCallback( (id: string | number): void => { if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { // Without can_read on Annotation the request is known to 403; keep the // raw id as the value so the saved layer remains valid and untouched. if (canReadAnnotation) { fetchAppliedNativeAnnotation(id); } } else { fetchAppliedChart(id); } }, [ sourceType, canReadAnnotation, 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); // Fetch slice data when value changes — but skip when slice is already // populated, which means the change came from `fetchAppliedChart` (the // mount-time hydration path that sets both value and slice in lockstep). // The user-selection path (`handleSelectValue`) clears slice to null // before setting the new value, so this watcher fires correctly there. useEffect(() => { if (value !== prevValue) { const isChart = sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE && requiresQuery(sourceType ?? undefined); if ( isChart && value && typeof value === 'object' && 'value' in value && !slice ) { fetchSliceData(value.value); } setPrevValue(value); } }, [value, prevValue, sourceType, fetchSliceData, slice]); 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); // Clear slice so the value-change watcher refetches for the new chart; // see the watcher block above for why this gate exists. setSlice(null); 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]); // Inlined: this is a pure helper that produces JSX from its args. The // previous useCallback wrapper added no value — `renderChartHeader(label, // description, value)` was called inline, so the produced ReactNode was // recreated each call anyway, and no downstream consumer depended on the // function's identity. const buildChartHeader = ( 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)) { // Listing native annotation layers requires can_read on Annotation. // Keep the select visible but inert so the saved reference can still be // removed, restyled, or switched to a permitted source; the select stays // lazy, so no forbidden request is ever fired. const isBlockedNativeSource = sourceType === ANNOTATION_SOURCE_TYPES.NATIVE && !canReadAnnotation; return ( <> } disabled={isBlockedNativeSource} /> {isBlockedNativeSource && (
{t("You don't have permission to view annotation layers.")}
)} ); } 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, canReadAnnotation, getSupportedSourceTypes, 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 sliceConfiguration = renderSliceConfiguration(); const hasSliceConfiguration = !!sliceConfiguration; const sectionGap = theme.sizeUnit * SECTION_GAP_UNITS; const viewportInset = theme.sizeUnit * VIEWPORT_INSET_UNITS; const measureSectionsMaxWidth = useCallback(() => { const row = sectionsRef.current; const popover = row?.closest('.ant-popover'); const panel = document.getElementById(CONTROL_SECTIONS_ID); // The cap is measured against Explore's control panel. Rendered anywhere // else - or not inside a popover - there is nothing to measure from, so the // row keeps the viewport-wide fallback below rather than being capped. if (!row || !popover || !panel) { return; } // Padding and border the popover adds around the row. const popoverInsetWidth = popover.getBoundingClientRect().width - row.getBoundingClientRect().width; const viewport = document.documentElement.clientWidth; const sections = Array.from(row.children); // Room the sections need to stay on one line. Wrapping them makes the popover // roughly twice as tall, which no shift can pull back inside a short viewport. const oneLine = sections.reduce( (total, section) => total + section.getBoundingClientRect().width, 0, ) + sectionGap * Math.max(sections.length - 1, 0); // Fitting beside the panel needs no shift, avoiding the offset antd leaves // behind when it shifts. Wrapping to get there costs more than the offset, so // anything narrower falls back to the viewport and antd shifts across the panel. const besidePanel = viewport - panel.getBoundingClientRect().right - popoverInsetWidth - viewportInset; const available = besidePanel >= oneLine ? besidePanel : viewport - popoverInsetWidth - viewportInset * 2; if (available > 0) { setSectionsMaxWidth(available); } }, [sectionGap, viewportInset]); // Resizing the viewport or dragging the panel moves the edge the cap derives // from, so remeasure while the popover is open. Observing the panel and not the // popover keeps it stable: the cap changes the popover's width, never the panel's. useLayoutEffect(() => { measureSectionsMaxWidth(); const panel = document.getElementById(CONTROL_SECTIONS_ID); const observer = new ResizeObserver(measureSectionsMaxWidth); if (panel) { observer.observe(panel); } window.addEventListener('resize', measureSectionsMaxWidth); return () => { observer.disconnect(); window.removeEventListener('resize', measureSectionsMaxWidth); }; }, [hasSliceConfiguration, measureSectionsMaxWidth]); 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()}
{sliceConfiguration} {renderDisplayConfiguration()}
{isNew ? ( ) : ( )}
); } export default AnnotationLayer;