Skip to content

Commit fb840b8

Browse files
sadpandajoeclaude
andauthored
fix(deck.gl): restore legend display for Polygon charts with linear palette and fixed color schemes (#35142)
Co-authored-by: Claude <[email protected]>
1 parent d0cc6f1 commit fb840b8

File tree

2 files changed

+359
-3
lines changed

2 files changed

+359
-3
lines changed
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
// eslint-disable-next-line import/no-extraneous-dependencies
20+
import { render, screen } from '@testing-library/react';
21+
// eslint-disable-next-line import/no-extraneous-dependencies
22+
import '@testing-library/jest-dom';
23+
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
24+
import DeckGLPolygon, { getPoints } from './Polygon';
25+
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
26+
import * as utils from '../../utils';
27+
28+
// Mock the utils functions
29+
const mockGetBuckets = jest.spyOn(utils, 'getBuckets');
30+
const mockGetColorBreakpointsBuckets = jest.spyOn(
31+
utils,
32+
'getColorBreakpointsBuckets',
33+
);
34+
35+
// Mock DeckGL container and Legend
36+
jest.mock('../../DeckGLContainer', () => ({
37+
DeckGLContainerStyledWrapper: ({ children }: any) => (
38+
<div data-testid="deckgl-container">{children}</div>
39+
),
40+
}));
41+
42+
jest.mock('../../components/Legend', () => ({ categories, position }: any) => (
43+
<div
44+
data-testid="legend"
45+
data-categories={JSON.stringify(categories)}
46+
data-position={position}
47+
>
48+
Legend Mock
49+
</div>
50+
));
51+
52+
const mockProps = {
53+
formData: {
54+
// Required QueryFormData properties
55+
datasource: 'test_datasource',
56+
viz_type: 'deck_polygon',
57+
// Polygon-specific properties
58+
metric: { label: 'population' },
59+
color_scheme_type: COLOR_SCHEME_TYPES.linear_palette,
60+
legend_position: 'tr',
61+
legend_format: '.2f',
62+
autozoom: false,
63+
mapbox_style: 'mapbox://styles/mapbox/light-v9',
64+
opacity: 80,
65+
filled: true,
66+
stroked: true,
67+
extruded: false,
68+
line_width: 1,
69+
line_width_unit: 'pixels',
70+
multiplier: 1,
71+
break_points: [],
72+
num_buckets: '5',
73+
linear_color_scheme: 'blue_white_yellow',
74+
},
75+
payload: {
76+
data: {
77+
features: [
78+
{
79+
population: 100000,
80+
polygon: [
81+
[0, 0],
82+
[1, 0],
83+
[1, 1],
84+
[0, 1],
85+
],
86+
},
87+
{
88+
population: 200000,
89+
polygon: [
90+
[2, 2],
91+
[3, 2],
92+
[3, 3],
93+
[2, 3],
94+
],
95+
},
96+
],
97+
mapboxApiKey: 'test-key',
98+
},
99+
form_data: {},
100+
},
101+
setControlValue: jest.fn(),
102+
viewport: { longitude: 0, latitude: 0, zoom: 1 },
103+
onAddFilter: jest.fn(),
104+
width: 800,
105+
height: 600,
106+
onContextMenu: jest.fn(),
107+
setDataMask: jest.fn(),
108+
filterState: undefined,
109+
emitCrossFilters: false,
110+
};
111+
112+
describe('DeckGLPolygon bucket generation logic', () => {
113+
beforeEach(() => {
114+
jest.clearAllMocks();
115+
mockGetBuckets.mockReturnValue({
116+
'100000 - 150000': { color: [0, 100, 200], enabled: true },
117+
'150000 - 200000': { color: [50, 150, 250], enabled: true },
118+
});
119+
mockGetColorBreakpointsBuckets.mockReturnValue({});
120+
});
121+
122+
const renderWithTheme = (component: React.ReactElement) =>
123+
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
124+
125+
test('should use getBuckets for linear_palette color scheme', () => {
126+
const propsWithLinearPalette = {
127+
...mockProps,
128+
formData: {
129+
...mockProps.formData,
130+
color_scheme_type: COLOR_SCHEME_TYPES.linear_palette,
131+
},
132+
};
133+
134+
renderWithTheme(<DeckGLPolygon {...propsWithLinearPalette} />);
135+
136+
// Should call getBuckets, not getColorBreakpointsBuckets
137+
expect(mockGetBuckets).toHaveBeenCalled();
138+
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
139+
});
140+
141+
test('should use getBuckets for fixed_color color scheme', () => {
142+
const propsWithFixedColor = {
143+
...mockProps,
144+
formData: {
145+
...mockProps.formData,
146+
color_scheme_type: COLOR_SCHEME_TYPES.fixed_color,
147+
},
148+
};
149+
150+
renderWithTheme(<DeckGLPolygon {...propsWithFixedColor} />);
151+
152+
// Should call getBuckets, not getColorBreakpointsBuckets
153+
expect(mockGetBuckets).toHaveBeenCalled();
154+
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
155+
});
156+
157+
test('should use getColorBreakpointsBuckets for color_breakpoints scheme', () => {
158+
const propsWithBreakpoints = {
159+
...mockProps,
160+
formData: {
161+
...mockProps.formData,
162+
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
163+
color_breakpoints: [
164+
{
165+
minValue: 0,
166+
maxValue: 100000,
167+
color: { r: 255, g: 0, b: 0, a: 100 },
168+
},
169+
{
170+
minValue: 100001,
171+
maxValue: 200000,
172+
color: { r: 0, g: 255, b: 0, a: 100 },
173+
},
174+
],
175+
},
176+
};
177+
178+
mockGetColorBreakpointsBuckets.mockReturnValue({
179+
'0 - 100000': { color: [255, 0, 0], enabled: true },
180+
'100001 - 200000': { color: [0, 255, 0], enabled: true },
181+
});
182+
183+
renderWithTheme(<DeckGLPolygon {...propsWithBreakpoints} />);
184+
185+
// Should call getColorBreakpointsBuckets, not getBuckets
186+
expect(mockGetColorBreakpointsBuckets).toHaveBeenCalled();
187+
expect(mockGetBuckets).not.toHaveBeenCalled();
188+
});
189+
190+
test('should use getBuckets when color_scheme_type is undefined (backward compatibility)', () => {
191+
const propsWithUndefinedScheme = {
192+
...mockProps,
193+
formData: {
194+
...mockProps.formData,
195+
color_scheme_type: undefined,
196+
},
197+
};
198+
199+
renderWithTheme(<DeckGLPolygon {...propsWithUndefinedScheme} />);
200+
201+
// Should call getBuckets for backward compatibility
202+
expect(mockGetBuckets).toHaveBeenCalled();
203+
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
204+
});
205+
206+
test('should use getBuckets for unsupported color schemes (categorical_palette)', () => {
207+
const propsWithUnsupportedScheme = {
208+
...mockProps,
209+
formData: {
210+
...mockProps.formData,
211+
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
212+
},
213+
};
214+
215+
renderWithTheme(<DeckGLPolygon {...propsWithUnsupportedScheme} />);
216+
217+
// Should fall back to getBuckets for unsupported color schemes
218+
expect(mockGetBuckets).toHaveBeenCalled();
219+
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
220+
});
221+
});
222+
223+
describe('DeckGLPolygon Error Handling and Edge Cases', () => {
224+
beforeEach(() => {
225+
jest.clearAllMocks();
226+
mockGetBuckets.mockReturnValue({});
227+
mockGetColorBreakpointsBuckets.mockReturnValue({});
228+
});
229+
230+
const renderWithTheme = (component: React.ReactElement) =>
231+
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
232+
233+
test('handles empty features data gracefully', () => {
234+
const propsWithEmptyData = {
235+
...mockProps,
236+
payload: {
237+
...mockProps.payload,
238+
data: {
239+
...mockProps.payload.data,
240+
features: [],
241+
},
242+
},
243+
};
244+
245+
renderWithTheme(<DeckGLPolygon {...propsWithEmptyData} />);
246+
247+
// Should still call getBuckets with empty data
248+
expect(mockGetBuckets).toHaveBeenCalled();
249+
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
250+
});
251+
252+
test('handles missing color_breakpoints for color_breakpoints scheme', () => {
253+
const propsWithMissingBreakpoints = {
254+
...mockProps,
255+
formData: {
256+
...mockProps.formData,
257+
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
258+
color_breakpoints: undefined,
259+
},
260+
};
261+
262+
renderWithTheme(<DeckGLPolygon {...propsWithMissingBreakpoints} />);
263+
264+
// Should call getColorBreakpointsBuckets even with undefined breakpoints
265+
expect(mockGetColorBreakpointsBuckets).toHaveBeenCalledWith(undefined);
266+
expect(mockGetBuckets).not.toHaveBeenCalled();
267+
});
268+
269+
test('handles null legend_position correctly', () => {
270+
const propsWithNullLegendPosition = {
271+
...mockProps,
272+
formData: {
273+
...mockProps.formData,
274+
legend_position: null,
275+
},
276+
};
277+
278+
renderWithTheme(<DeckGLPolygon {...propsWithNullLegendPosition} />);
279+
280+
// Legend should not be rendered when position is null
281+
expect(screen.queryByTestId('legend')).not.toBeInTheDocument();
282+
});
283+
});
284+
285+
describe('DeckGLPolygon Legend Integration', () => {
286+
beforeEach(() => {
287+
jest.clearAllMocks();
288+
mockGetBuckets.mockReturnValue({
289+
'100000 - 150000': { color: [0, 100, 200], enabled: true },
290+
'150000 - 200000': { color: [50, 150, 250], enabled: true },
291+
});
292+
});
293+
294+
const renderWithTheme = (component: React.ReactElement) =>
295+
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
296+
297+
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {
298+
const { container } = renderWithTheme(<DeckGLPolygon {...mockProps} />);
299+
300+
// Verify the component renders and calls the correct bucket function
301+
expect(mockGetBuckets).toHaveBeenCalled();
302+
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
303+
304+
// Verify the legend mock was rendered with non-empty categories
305+
const legendElement = container.querySelector('[data-testid="legend"]');
306+
expect(legendElement).toBeTruthy();
307+
const categoriesAttr = legendElement?.getAttribute('data-categories');
308+
const categoriesData = JSON.parse(categoriesAttr || '{}');
309+
expect(Object.keys(categoriesData)).toHaveLength(2);
310+
});
311+
312+
test('does not render legend when metric is null', () => {
313+
const propsWithoutMetric = {
314+
...mockProps,
315+
formData: {
316+
...mockProps.formData,
317+
metric: null,
318+
},
319+
};
320+
321+
renderWithTheme(<DeckGLPolygon {...propsWithoutMetric} />);
322+
323+
// Legend should not be rendered when no metric is defined
324+
expect(screen.queryByTestId('legend')).not.toBeInTheDocument();
325+
});
326+
});
327+
328+
describe('getPoints utility', () => {
329+
test('extracts points from polygon data', () => {
330+
const data = [
331+
{
332+
polygon: [
333+
[0, 0],
334+
[1, 0],
335+
[1, 1],
336+
[0, 1],
337+
],
338+
},
339+
{
340+
polygon: [
341+
[2, 2],
342+
[3, 2],
343+
[3, 3],
344+
[2, 3],
345+
],
346+
},
347+
];
348+
349+
const points = getPoints(data);
350+
351+
expect(points).toHaveLength(8); // 4 points per polygon * 2 polygons
352+
expect(points[0]).toEqual([0, 0]);
353+
expect(points[4]).toEqual([2, 2]);
354+
});
355+
});

superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,10 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
335335
const accessor = (d: JsonObject) => d[metricLabel];
336336

337337
const colorSchemeType = formData.color_scheme_type;
338-
const buckets = colorSchemeType
339-
? getColorBreakpointsBuckets(formData.color_breakpoints)
340-
: getBuckets(formData, payload.data.features, accessor);
338+
const buckets =
339+
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
340+
? getColorBreakpointsBuckets(formData.color_breakpoints)
341+
: getBuckets(formData, payload.data.features, accessor);
341342

342343
return (
343344
<div style={{ position: 'relative' }}>

0 commit comments

Comments
 (0)