mirror of
https://github.com/apache/superset.git
synced 2026-09-01 04:51:23 +00:00
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Evan Rusackas <evan@preset.io>
1315 lines
42 KiB
TypeScript
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;
|