diff --git a/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx b/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx index 3222b5501..7a1b02eb0 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx +++ b/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx @@ -355,10 +355,6 @@ const DeckMulti = (props: DeckMultiProps) => { handleFeatureClick(info, sliceFeatureInfoColumnNames), ); - if (!newLayer) { - return null; - } - const payloadData = payload?.data || []; const geometryType = getGeometryType(payloadData[0]?.geojson); let transformPropsGeojsonLayer = @@ -483,13 +479,15 @@ const DeckMulti = (props: DeckMultiProps) => { maxZoom: zoomSlider[1], }; - const newLayerStates = layerStatesGenerator( - newLayer, - newLayerStateOptions, - ); + // When the layer has no renderable data, return an entry with + // empty layerStates so the legend can show it as "loaded but + // empty" instead of spinning forever. + const newLayerStates = newLayer + ? layerStatesGenerator(newLayer, newLayerStateOptions) + : []; if (!newLayerStates.length) { - return null; + legendEntry.empty = true; } const layerFeatures: JsonObject[] = diff --git a/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx b/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx index befa2a92a..0d0205509 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx +++ b/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx @@ -151,6 +151,13 @@ const CategoryRow = styled.div` gap: 8px; `; +const NoDataLabel = styled.div` + font-size: 11px; + color: gray; + margin-bottom: 6px; + margin-left: -20px; +`; + const MetricBlock = styled.div` margin: 6px 0; `; @@ -176,12 +183,13 @@ const Bounds = styled.div( `, ); -const VisibilityCheckbox = styled.input` +const VisibilityCheckbox = styled.input<{ $empty?: boolean }>` width: 14px; height: 14px; cursor: pointer; margin: 0 !important; flex-shrink: 0; + ${({ $empty }) => $empty && 'accent-color: gray;'} `; // Checkbox that supports indeterminate state (shows minus sign when some but not all are selected) @@ -189,7 +197,8 @@ const IndeterminateCheckbox: React.FC<{ checked: boolean; indeterminate: boolean; onChange: (e: React.ChangeEvent) => void; -}> = ({ checked, indeterminate, onChange }) => { + $empty?: boolean; +}> = ({ checked, indeterminate, onChange, $empty }) => { const ref = useRef(null); useEffect(() => { @@ -204,6 +213,7 @@ const IndeterminateCheckbox: React.FC<{ type="checkbox" checked={checked} onChange={onChange} + $empty={$empty} /> ); }; @@ -249,17 +259,20 @@ const LegendEntryContent: React.FC<{ return (
- {/* SIMPLE - show icon and slice name (skip when sizeEntry with a range handles the display) */} + {legendEntry.empty && Visible but Empty} + {/* SIMPLE - show icon and slice name (skip when sizeEntry handles the display) */} {legendEntry.type === 'simple' && legendEntry.simpleStyle && (!legendEntry.sizeEntry || - legendEntry.sizeEntry.startSize === legendEntry.sizeEntry.endSize) && ( + legendEntry.sizeEntry.startSize === + legendEntry.sizeEntry.endSize) && ( {showEntryCheckbox && ( )} onToggleCategory(sliceId, item.label)} + $empty={legendEntry.empty} /> )} {item.label} @@ -338,6 +352,7 @@ const LegendEntryContent: React.FC<{ type="checkbox" checked={isEnabled} onChange={() => onToggleCategory(sliceId, cat.label)} + $empty={legendEntry.empty} /> )} = ({ someVisibleSomeNot || (isVisible && hasPartialCategories); const allLoading = entries.every(e => e.legendEntry.loading); + const allEmpty = + !allLoading && entries.every(e => e.legendEntry.empty); return ( @@ -523,6 +541,7 @@ export const MultiLegend: React.FC = ({ { e.stopPropagation(); setOptimisticVisibility(prev => ({ diff --git a/superset-frontend/plugins/geoset-map-chart/src/types.ts b/superset-frontend/plugins/geoset-map-chart/src/types.ts index 1dbf88156..756f625f5 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/types.ts +++ b/superset-frontend/plugins/geoset-map-chart/src/types.ts @@ -106,6 +106,7 @@ export type LegendEntry = { isCombinedMetricSize?: boolean; initialCollapsed?: boolean; // Whether this legend entry starts collapsed loading?: boolean; // True for stub entries whose layer data is still loading + empty?: boolean; // True when layer loaded successfully but returned no data }; export type LegendGroup = { diff --git a/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx b/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx index bf092dc0a..622b31f1e 100644 --- a/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx +++ b/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx @@ -7,7 +7,6 @@ import { createCategoricalLegendEntry, createMetricLegendEntry, createLegendGroup, - createSizeLegend, createCategoryEntry, RED, GREEN, @@ -591,14 +590,36 @@ describe('MultiLegend', () => { }); }); - describe('single-value size legend (startSize === endSize)', () => { - const collapsedSizeLegend = createSizeLegend({ - startSize: 17, - endSize: 17, - legendName: 'Single Point', + describe('empty layer state', () => { + it('shows checkbox and "no data" label instead of spinner for empty entry', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + expect(screen.getByText('Empty Layer')).toBeInTheDocument(); + expect(screen.getByText('Visible but Empty')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); - it('simple entry shows swatch and name when sizeEntry has equal sizes', () => { + it('does not show "no data" label for non-empty entry', () => { renderWithTheme( { { sliceId: '1', legendEntry: createSimpleLegendEntry({ - legendName: 'Simple Single', - sizeEntry: collapsedSizeLegend, + legendName: 'Normal Layer', }), }, ], @@ -618,117 +638,156 @@ describe('MultiLegend', () => { />, ); userEvent.click(screen.getByText('Legend')); - // Name appears in both the simple row and the single-value size swatch row - expect( - screen.getAllByText('Simple Single').length, - ).toBeGreaterThanOrEqual(1); - expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + expect(screen.getByText('Normal Layer')).toBeInTheDocument(); + expect(screen.queryByText('Visible but Empty')).not.toBeInTheDocument(); }); - it('combined metric+size shows swatch instead of graduated icons', () => { + it('group header shows checkbox (not spinner) when all entries are empty', () => { renderWithTheme( , ); userEvent.click(screen.getByText('Legend')); - expect(screen.getByText('Combined Single')).toBeInTheDocument(); - expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + // Group header should render a checkbox, not a loading spinner + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThanOrEqual(2); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); - it('standalone size legend shows swatch instead of graduated icons', () => { + it('group header checkbox is present when some entries have data', () => { renderWithTheme( , ); userEvent.click(screen.getByText('Legend')); - expect(screen.getByText('Standalone Single')).toBeInTheDocument(); - expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThanOrEqual(2); }); - it('uses singleValueColor for swatch when provided', () => { + it('shows spinner when loading, even if empty is also true', () => { renderWithTheme( , ); userEvent.click(screen.getByText('Legend')); - expect(screen.getByText('Colored Single')).toBeInTheDocument(); - expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + // Both group headers should show spinners, not checkboxes + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryByText('Visible but Empty')).not.toBeInTheDocument(); }); - it('renders graduated icons when sizeEntry has different start/end sizes', () => { - renderWithTheme( + it('transitions from loading to empty without spinner', () => { + const { rerender } = renderWithTheme( , ); userEvent.click(screen.getByText('Legend')); - expect(screen.getByTestId('graduated-icons')).toBeInTheDocument(); + expect(screen.queryByText('Visible but Empty')).not.toBeInTheDocument(); + + // Re-render with loading finished and empty result + rerender( + , + ); + expect(screen.getByText('Trans Layer')).toBeInTheDocument(); + expect(screen.getByText('Visible but Empty')).toBeInTheDocument(); }); }); });