mirror of
https://github.com/apache/superset.git
synced 2026-09-08 00:04:36 +00:00
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:
co-authored by
Claude Opus 5
parent
59a5ae0df3
commit
473f447c1b
+191
-50
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user