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 21767a70b5..d177e66073 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx +++ b/superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx @@ -342,10 +342,6 @@ const DeckMulti = (props: DeckMultiProps) => { handleFeatureClick(info, sliceFeatureInfoColumnNames), ); - if (!newLayer) { - return null; - } - const payloadData = payload?.data || []; const geometryType = getGeometryType(payloadData[0]?.geojson); let transformPropsGeojsonLayer = @@ -470,13 +466,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 f25d204bcd..17e0830974 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} /> ); }; @@ -236,6 +246,9 @@ const LegendEntryContent: React.FC<{ return (
+ {legendEntry.empty && ( + Visible but Empty + )} {/* SIMPLE - show icon and slice name (skip when sizeEntry handles the display) */} {legendEntry.type === 'simple' && legendEntry.simpleStyle && @@ -246,6 +259,7 @@ const LegendEntryContent: React.FC<{ type="checkbox" checked={isVisible} onChange={onToggleVisibility} + $empty={legendEntry.empty} /> )} onToggleCategory(sliceId, item.label)} + $empty={legendEntry.empty} /> )} {item.label} @@ -324,6 +339,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 ( @@ -505,6 +524,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 3a5ab44edd..9f6efc4d35 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/types.ts +++ b/superset-frontend/plugins/geoset-map-chart/src/types.ts @@ -104,6 +104,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 fa69fa9660..7219271c55 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 @@ -564,4 +564,215 @@ describe('MultiLegend', () => { expect(screen.getByText('500+')).toBeInTheDocument(); }); }); + + 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('does not show "no data" label for non-empty entry', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + expect(screen.getByText('Normal Layer')).toBeInTheDocument(); + expect( + screen.queryByText('Visible but Empty'), + ).not.toBeInTheDocument(); + }); + + it('group header shows checkbox (not spinner) when all entries are empty', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + // 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('group header checkbox is present when some entries have data', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThanOrEqual(2); + }); + + it('shows spinner when loading, even if empty is also true', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + // Both group headers should show spinners, not checkboxes + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect( + screen.queryByText('Visible but Empty'), + ).not.toBeInTheDocument(); + }); + + it('transitions from loading to empty without spinner', () => { + const { rerender } = renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + 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(); + }); + }); });