Files
superset2/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx
T
2026-08-20 20:14:33 -07:00

1315 lines
42 KiB
TypeScript

/**
* 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<string, unknown>) => 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<string, unknown> | null => {
if (!raw) {
return null;
}
try {
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: 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<string, unknown> | null => {
const formData = parseJsonObject(result.query_context)?.form_data;
if (formData && typeof formData === 'object') {
return formData as Record<string, unknown>;
}
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<string, unknown>): 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 = () => (
<NotFoundContentWrapper>
<EmptyState
title={t('No annotation layers')}
size="small"
description={
<span>
{t('Add an annotation layer')}{' '}
<a
// encodeURI wraps the DOM-derived application-root prefix so
// CodeQL's `js/html-injection` sees a recognised through-function
// sanitiser between `applicationRoot()` (reads `data-bootstrap`
// from the DOM) and the `<a href>` 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')}
</a>
.
</span>
}
image="empty.svg"
/>
</NotFoundContentWrapper>
);
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<string | null>(
propSourceType || null,
);
const [value, setValue] = useState<string | number | SelectOption | null>(
propValue || null,
);
const [overrides, setOverrides] =
useState<AnnotationOverrides>(processedOverrides);
const [show, setShow] = useState(propShow ?? true);
const [showLabel, setShowLabel] = useState(propShowLabel ?? false);
const [titleColumn, setTitleColumn] = useState(propTitleColumn || '');
const [descriptionColumns, setDescriptionColumns] = useState<string[]>(
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<SliceData | null>(null);
const sectionsRef = useRef<HTMLDivElement>(null);
const [sectionsMaxWidth, setSectionsMaxWidth] = useState<number>();
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<string, unknown> = {};
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 => (
<ControlHeader
hovered
label={label}
description={description}
validationErrors={!val ? ['Mandatory'] : []}
/>
);
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 (
<>
<AsyncSelect
/* key to force re-render on sourceType change */
key={sourceType}
ariaLabel={t('Annotation layer value')}
name="annotation-layer-value"
header={buildChartHeader(label, description, value)}
options={fetchOptions}
value={value || null}
onChange={handleSelectValue}
notFoundContent={<NotFoundContent />}
disabled={isBlockedNativeSource}
/>
{isBlockedNativeSource && (
<div>
<Typography.Text type="secondary">
{t("You don't have permission to view annotation layers.")}
</Typography.Text>
</div>
)}
</>
);
}
if (annotationType === ANNOTATION_TYPES.FORMULA) {
// Extract primitive value for TextControl (formula is always a string)
const textValue = typeof value === 'object' ? null : value;
return (
<TextControl
name="annotation-layer-value"
hovered
showHeader
description={description}
label={label}
placeholder=""
value={textValue}
onChange={handleTextValue}
validationErrors={
!isValidFormulaAnnotation(value, annotationType)
? [t('Bad formula.')]
: []
}
/>
);
}
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 (
<div>
<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
ariaLabel={t('Annotation layer time column')}
hovered
name="annotation-layer-time-column"
label={
annotationType === ANNOTATION_TYPES.INTERVAL
? t('Interval start column')
: t('Event time column')
}
description={t(
'This column must contain date/time information.',
)}
validationErrors={!timeColumn ? ['Mandatory'] : []}
clearable={false}
options={timeColumnOptions}
value={timeColumn}
onChange={(
v: string | number | (string | number)[] | null | undefined,
) => setTimeColumn(String(v ?? ''))}
/>
)}
{annotationType === ANNOTATION_TYPES.INTERVAL && (
<SelectControl
ariaLabel={t('Annotation layer interval end')}
hovered
name="annotation-layer-intervalEnd"
label={t('Interval End column')}
description={t(
'This column must contain date/time information.',
)}
validationErrors={!intervalEndColumn ? ['Mandatory'] : []}
options={columns}
value={intervalEndColumn}
onChange={(
v: string | number | (string | number)[] | null | undefined,
) => setIntervalEndColumn(String(v ?? ''))}
/>
)}
<SelectControl
ariaLabel={t('Annotation layer title column')}
hovered
name="annotation-layer-title"
label={t('Title Column')}
description={t('Pick a title for you annotation.')}
options={[{ value: '', label: t('None') }].concat(columns)}
value={titleColumn}
onChange={(
v: string | number | (string | number)[] | null | undefined,
) => setTitleColumn(String(v ?? ''))}
/>
{annotationType !== ANNOTATION_TYPES.TIME_SERIES && (
<SelectControl
ariaLabel={t('Annotation layer description columns')}
hovered
name="annotation-layer-title"
label={t('Description Columns')}
description={t(
"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: string | number | (string | number)[] | null | undefined,
) => {
const cols = Array.isArray(v) ? v.map(String) : [];
setDescriptionColumns(cols);
}}
/>
)}
<div style={{ marginTop: '1rem' }}>
<CheckboxControl
hovered
name="annotation-override-time_range"
label={t('Override time range')}
description={t(`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 => {
const newOverrides = { ...overrides };
delete newOverrides.time_range;
if (v) {
setOverrides({ ...newOverrides, time_range: null });
} else {
setOverrides({ ...newOverrides });
}
}}
/>
<CheckboxControl
hovered
name="annotation-override-timegrain"
label={t('Override time grain')}
description={t(`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 => {
const newOverrides = { ...overrides };
delete newOverrides.time_grain_sqla;
delete newOverrides.granularity;
if (v) {
setOverrides({
...newOverrides,
time_grain_sqla: null,
granularity: null,
});
} else {
setOverrides({ ...newOverrides });
}
}}
/>
<TextControl
hovered
name="annotation-layer-timeshift"
label={t('Time Shift')}
description={t(`Time delta in natural language
(example: 24 hours, 7 days, 56 weeks, 365 days)`)}
placeholder=""
value={overrides.time_shift}
onChange={v => setOverrides({ ...overrides, time_shift: v })}
/>
</div>
</PopoverSection>
</div>
);
}
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 (
<PopoverSection
isSelected
title={t('Display configuration')}
info={t('Configure your how you overlay is displayed here.')}
>
<SelectControl
ariaLabel={t('Annotation layer stroke')}
name="annotation-layer-stroke"
label={t('Style')}
options={[
{ value: 'solid', label: t('Solid') },
{ value: 'dashed', label: t('Dashed') },
{ value: 'longDashed', label: t('Long dashed') },
{ value: 'dotted', label: t('Dotted') },
]}
value={style}
clearable={false}
onChange={(
v: string | number | (string | number)[] | null | undefined,
) => setStyle(String(v ?? 'solid'))}
/>
<SelectControl
ariaLabel={t('Annotation layer opacity')}
name="annotation-layer-opacity"
label={t('Opacity')}
options={[
{ value: '', label: t('Solid') },
{ value: 'opacityLow', label: '0.2' },
{ value: 'opacityMedium', label: '0.5' },
{ value: 'opacityHigh', label: '0.8' },
]}
value={opacity}
onChange={(
v: string | number | (string | number)[] | null | undefined,
) => setOpacity(String(v ?? ''))}
/>
<div
style={{
marginTop: theme.sizeUnit * 2,
marginBottom: theme.sizeUnit * 2,
}}
>
<CheckboxControl
name="annotation-layer-automatic-color"
label={t('Use automatic color')}
value={color === AUTOMATIC_COLOR}
onChange={useAutomatic => {
if (useAutomatic) {
setColor(AUTOMATIC_COLOR);
} else {
// Set to first theme color or dark color as fallback
setColor(schemeColors[0] || theme.colorTextBase);
}
}}
/>
{color !== AUTOMATIC_COLOR && (
<div style={{ marginTop: theme.sizeUnit * 2 }}>
<ControlHeader label={t('Color')} />
<ColorPicker
value={color}
presets={[{ label: 'Theme colors', colors: schemeColors }]}
onChangeComplete={colorValue =>
setColor(colorValue.toHexString())
}
showText
/>
</div>
)}
</div>
<TextControl
name="annotation-layer-stroke-width"
label={t('Line width')}
isInt
value={width}
onChange={v => setWidth(v)}
/>
{annotationType === ANNOTATION_TYPES.TIME_SERIES && (
<CheckboxControl
hovered
name="annotation-layer-show-markers"
label={t('Show Markers')}
description={t('Shows or hides markers for the time series')}
value={showMarkers}
onChange={v => setShowMarkers(v)}
/>
)}
{annotationType === ANNOTATION_TYPES.TIME_SERIES && (
<CheckboxControl
hovered
name="annotation-layer-hide-line"
label={t('Hide Line')}
description={t('Hides the Line for the time series')}
value={hideLine}
onChange={v => setHideLine(v)}
/>
)}
</PopoverSection>
);
}, [
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 && (
<span style={{ color: theme.colorError }}>
{t('ERROR')}: {error}
</span>
)}
<SectionsRow
ref={sectionsRef}
data-test="annotation-layer-sections"
$maxWidth={sectionsMaxWidth ?? `calc(100vw - ${sectionGap * 2}px)`}
>
<div>
<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 => setName(v)}
validationErrors={!name ? [t('Mandatory')] : []}
/>
<CheckboxControl
name="annotation-layer-hide"
label={t('Hide layer')}
value={!show}
onChange={v => setShow(!v)}
/>
<CheckboxControl
name="annotation-label-show"
label={t('Show label')}
value={showLabel}
hovered
description={t('Whether to always show the annotation label')}
onChange={v => setShowLabel(v)}
/>
<SelectControl
ariaLabel={t('Annotation layer type')}
hovered
description={t('Choose the annotation layer type')}
label={t('Annotation layer type')}
name="annotation-layer-type"
clearable={false}
options={supportedAnnotationTypes}
value={annotationType}
onChange={handleAnnotationType}
/>
{supportedSourceTypes.length > 0 && (
<SelectControl
ariaLabel={t('Annotation source type')}
hovered
description={t('Choose the source of your annotations')}
label={t('Annotation source')}
name="annotation-source-type"
options={supportedSourceTypes}
notFoundContent={<NotFoundContent />}
value={sourceType}
onChange={handleAnnotationSourceType}
validationErrors={!sourceType ? [t('Mandatory')] : []}
/>
)}
{renderValueConfiguration()}
</PopoverSection>
</div>
{sliceConfiguration}
{renderDisplayConfiguration()}
</SectionsRow>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{isNew ? (
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={() => close?.()}
>
{t('Cancel')}
</Button>
) : (
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={deleteAnnotation}
>
{t('Remove')}
</Button>
)}
<div>
<Button
buttonSize="small"
disabled={!isValid}
onClick={applyAnnotation}
>
{t('Apply')}
</Button>
<Button
buttonSize="small"
buttonStyle="primary"
disabled={!isValid}
onClick={submitAnnotation}
>
{t('Confirm')}
</Button>
</div>
</div>
</>
);
}
export default AnnotationLayer;