fix(explore): keep the annotation layer modal usable in a small viewport (#42614)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Enzo Martellucci
2026-07-30 19:09:58 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 59a5ae0df3
commit 473f447c1b
6 changed files with 684 additions and 56 deletions
@@ -16,7 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import rison from 'rison';
import {
Button,
@@ -36,11 +43,13 @@ import {
} 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,
@@ -103,6 +112,69 @@ interface AnnotationLayerProps {
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 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;
@@ -220,6 +292,8 @@ function AnnotationLayer({
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[] => {
@@ -352,62 +426,62 @@ function AnnotationLayer({
[sourceType, fetchNativeAnnotations, fetchCharts],
);
const fetchSliceData = useCallback((id: string | number): void => {
const queryParams = rison.encode({
columns: ['query_context'],
});
SupersetClient.get({
endpoint: `/api/v1/chart/${id}?q=${queryParams}`,
}).then(({ json }) => {
const { result } = json;
const queryContext = result.query_context;
const formData = JSON.parse(queryContext).form_data;
const dataObject = {
data: {
...formData,
groupby: formData.groupby?.map((column: QueryFormColumn) =>
getColumnLabel(column),
),
},
};
setSlice(dataObject);
});
}, []);
// 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', 'query_context', 'viz_type'],
columns: ['slice_name', 'params', 'query_context', 'viz_type'],
});
SupersetClient.get({
endpoint: `/api/v1/chart/${id}?q=${queryParams}`,
}).then(({ json }) => {
const { result } = json;
const sliceName = result.slice_name;
const queryContext = result.query_context;
const chartVizType = result.viz_type;
const formData = JSON.parse(queryContext).form_data;
const metadata = registry.get(chartVizType);
const canBeAnnotationType =
metadata && metadata.canBeAnnotationType(annotationType);
if (canBeAnnotationType) {
})
.then(({ json }) => {
const { result } = json;
const metadata = registry.get(result.viz_type);
if (!metadata?.canBeAnnotationType(annotationType)) {
return;
}
setValue({
value: id,
label: sliceName,
label: result.slice_name,
});
setSlice({
data: {
...formData,
groupby: formData.groupby?.map((column: QueryFormColumn) =>
getColumnLabel(column),
),
},
});
}
});
applySliceFormData(id, result);
})
.catch(reportChartFailure(id));
},
[annotationType],
[annotationType, applySliceFormData],
);
const fetchAppliedNativeAnnotation = useCallback(
@@ -740,7 +814,7 @@ function AnnotationLayer({
? [{ value: '__timestamp', label: '__timestamp' }].concat(columns)
: columns;
return (
<div style={{ marginRight: '2rem' }}>
<div>
<PopoverSection
isSelected
title={t('Annotation Slice Configuration')}
@@ -1010,6 +1084,69 @@ function AnnotationLayer({
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
@@ -1029,8 +1166,12 @@ function AnnotationLayer({
{t('ERROR')}: {error}
</span>
)}
<div style={{ display: 'flex', flexDirection: 'row' }}>
<div style={{ marginRight: '2rem' }}>
<SectionsRow
ref={sectionsRef}
data-test="annotation-layer-sections"
$maxWidth={sectionsMaxWidth ?? `calc(100vw - ${sectionGap * 2}px)`}
>
<div>
<PopoverSection
isSelected
title={t('Layer configuration')}
@@ -1086,9 +1227,9 @@ function AnnotationLayer({
{renderValueConfiguration()}
</PopoverSection>
</div>
{renderSliceConfiguration()}
{sliceConfiguration}
{renderDisplayConfiguration()}
</div>
</SectionsRow>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{isNew ? (
<Button