diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.test.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.test.ts new file mode 100644 index 00000000000..3bca793ef41 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.test.ts @@ -0,0 +1,456 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import buildQuery, { DeckPolygonFormData } from './buildQuery'; + +describe('Polygon buildQuery', () => { + const baseFormData: DeckPolygonFormData = { + datasource: '1__table', + viz_type: 'deck_polygon', + line_column: 'polygon_geom', + }; + + test('should require line_column', () => { + const formDataWithoutLineColumn = { + ...baseFormData, + line_column: undefined, + }; + + expect(() => buildQuery(formDataWithoutLineColumn)).toThrow( + 'Polygon column is required for Polygon charts', + ); + }); + + test('should build basic query with minimal data', () => { + const queryContext = buildQuery(baseFormData); + const [query] = queryContext.queries; + + expect(query.columns).toEqual(['polygon_geom']); + expect(query.metrics).toEqual([]); + expect(query.is_timeseries).toBe(false); + expect(query.filters).toEqual([ + { + col: 'polygon_geom', + op: 'IS NOT NULL', + }, + ]); + }); + + test('should include metric in query when provided', () => { + const formDataWithMetric = { + ...baseFormData, + metric: 'population', + }; + + const queryContext = buildQuery(formDataWithMetric); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual(['population']); + expect(query.filters).toContainEqual({ + col: 'population', + op: 'IS NOT NULL', + }); + }); + + describe('point_radius_fixed legacy structure', () => { + test('should not add metrics to query when value is simple string', () => { + const formDataWithFixValue = { + ...baseFormData, + point_radius_fixed: { + value: '1000', + }, + }; + + const queryContext = buildQuery(formDataWithFixValue); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should not add metrics to query when point_radius_fixed.value is undefined', () => { + const formDataWithEmptyValue = { + ...baseFormData, + point_radius_fixed: { + value: undefined, + }, + }; + + const queryContext = buildQuery(formDataWithEmptyValue); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should not add metrics to query when point_radius_fixed is undefined', () => { + const formDataWithoutFixedRadius = { + ...baseFormData, + point_radius_fixed: undefined, + }; + + const queryContext = buildQuery(formDataWithoutFixedRadius); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + }); + + describe('point_radius_fixed "fix" type', () => { + test('should not add metrics to query when point_radius_fixed type is "fix"', () => { + const formDataWithFixType = { + ...baseFormData, + point_radius_fixed: { + type: 'fix', + value: '1000', + }, + } as any; + + const queryContext = buildQuery(formDataWithFixType); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should not add metrics to query when point_radius_fixed type is "fix" with zero value', () => { + const formDataWithZeroFix = { + ...baseFormData, + point_radius_fixed: { + type: 'fix', + value: '0', + }, + } as any; + + const queryContext = buildQuery(formDataWithZeroFix); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should not add metrics to query when point_radius_fixed type is "fix" with decimal value', () => { + const formDataWithDecimalFix = { + ...baseFormData, + point_radius_fixed: { + type: 'fix', + value: '500.5', + }, + } as any; + + const queryContext = buildQuery(formDataWithDecimalFix); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + }); + + describe('point_radius_fixed "metric" type', () => { + test('should add metric object to query when point_radius_fixed type is "metric"', () => { + const metricObject = { + expressionType: 'SQL', + sqlExpression: 'SUM(population)/SUM(area)', + column: null, + aggregate: null, + datasourceWarning: false, + hasCustomLabel: false, + label: 'SUM(population)/SUM(area)', + optionName: 'metric_c5rvwrzoo86_293h6yrv2ic', + }; + + const formDataWithMetricType = { + ...baseFormData, + point_radius_fixed: { + type: 'metric', + value: metricObject, + }, + } as any; + + const queryContext = buildQuery(formDataWithMetricType); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([metricObject]); + }); + + test('should add simple column metric to query when point_radius_fixed type is "metric"', () => { + const simpleMetricObject = { + expressionType: 'simple', + column: { + column_name: 'avg_elevation', + type: 'NUMERIC', + }, + aggregate: 'avg', + label: 'AVG(avg_elevation)', + }; + + const formDataWithSimpleMetric = { + ...baseFormData, + point_radius_fixed: { + type: 'metric', + value: simpleMetricObject, + }, + } as any; + + const queryContext = buildQuery(formDataWithSimpleMetric); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([simpleMetricObject]); + }); + + test('should include both regular metric and point_radius_fixed metric in query when both are specified', () => { + const metricObject = { + expressionType: 'simple', + column: { column_name: 'elevation' }, + aggregate: 'sum', + label: 'SUM(elevation)', + }; + + const formDataWithBothMetrics = { + ...baseFormData, + metric: 'population', + point_radius_fixed: { + type: 'metric', + value: metricObject, + }, + } as any; + + const queryContext = buildQuery(formDataWithBothMetrics); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual(['population', metricObject]); + }); + }); + + describe('Edge cases and error handling', () => { + test('should not add metrics to query when point_radius_fixed is null', () => { + const formDataWithNull = { + ...baseFormData, + point_radius_fixed: null, + } as any; + + const queryContext = buildQuery(formDataWithNull); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should handle null metric values gracefully', () => { + const formDataWithNullMetric = { + ...baseFormData, + point_radius_fixed: { + type: 'metric', + value: null, + }, + } as any; + + const queryContext = buildQuery(formDataWithNullMetric); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should handle undefined metric values gracefully', () => { + const formDataWithUndefinedMetric = { + ...baseFormData, + point_radius_fixed: { + type: 'metric', + value: undefined, + }, + } as any; + + const queryContext = buildQuery(formDataWithUndefinedMetric); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should not add metrics to query when point_radius_fixed is empty object', () => { + const formDataWithEmptyObject = { + ...baseFormData, + point_radius_fixed: {}, + }; + + const queryContext = buildQuery(formDataWithEmptyObject); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should not add metrics to query when point_radius_fixed has unsupported type', () => { + const formDataWithUnsupportedType = { + ...baseFormData, + point_radius_fixed: { + type: 'unsupported_type', + value: 'some_value', + }, + } as any; + + const queryContext = buildQuery(formDataWithUnsupportedType); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should not add metrics to query when point_radius_fixed has missing type field', () => { + const formDataWithMissingType = { + ...baseFormData, + point_radius_fixed: { + value: 'some_value', + }, + }; + + const queryContext = buildQuery(formDataWithMissingType); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + }); + + describe('Integration with other form data fields', () => { + test('should include js_columns in query columns', () => { + const formDataWithJsColumns = { + ...baseFormData, + js_columns: ['custom_col1', 'custom_col2'], + }; + + const queryContext = buildQuery(formDataWithJsColumns); + const [query] = queryContext.queries; + + expect(query.columns).toEqual([ + 'polygon_geom', + 'custom_col1', + 'custom_col2', + ]); + }); + + test('should include tooltip_contents columns in query', () => { + const formDataWithTooltips = { + ...baseFormData, + tooltip_contents: [ + { item_type: 'column', column_name: 'tooltip_col' }, + 'another_tooltip_col', + ], + }; + + const queryContext = buildQuery(formDataWithTooltips); + const [query] = queryContext.queries; + + expect(query.columns).toContain('tooltip_col'); + expect(query.columns).toContain('another_tooltip_col'); + }); + + test('should not add null filters when filter_nulls is false', () => { + const formDataWithoutNullFilters = { + ...baseFormData, + filter_nulls: false, + metric: 'population', + }; + + const queryContext = buildQuery(formDataWithoutNullFilters); + const [query] = queryContext.queries; + + expect(query.filters).toEqual([]); + }); + + test('should build comprehensive query when multiple form data fields are specified', () => { + const complexFormData = { + ...baseFormData, + metric: 'population', + point_radius_fixed: { + type: 'metric', + value: { + expressionType: 'simple', + column: { column_name: 'elevation' }, + aggregate: 'avg', + label: 'AVG(elevation)', + }, + }, + js_columns: ['custom_prop'], + tooltip_contents: [ + { item_type: 'column', column_name: 'tooltip_info' }, + ], + filter_nulls: true, + } as any; + + const queryContext = buildQuery(complexFormData); + const [query] = queryContext.queries; + + expect(query.columns).toContain('polygon_geom'); + expect(query.columns).toContain('custom_prop'); + expect(query.columns).toContain('tooltip_info'); + expect(query.metrics).toContain('population'); + expect(query.metrics).toContain(complexFormData.point_radius_fixed.value); + expect(query.filters).toContainEqual({ + col: 'polygon_geom', + op: 'IS NOT NULL', + }); + expect(query.filters).toContainEqual({ + col: 'population', + op: 'IS NOT NULL', + }); + }); + }); + + describe('Current implementation behavior', () => { + test('should not add fixed values to metrics for legacy point_radius_fixed structure', () => { + const formDataWithFix = { + ...baseFormData, + point_radius_fixed: { + value: '1000', + }, + }; + + const queryContext = buildQuery(formDataWithFix); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + + test('should add metric objects to query when point_radius_fixed type is "metric"', () => { + const metricObject = { + expressionType: 'SQL', + sqlExpression: 'AVG(elevation)', + label: 'AVG(elevation)', + }; + + const formDataWithMetricObject = { + ...baseFormData, + point_radius_fixed: { + type: 'metric', + value: metricObject, + }, + } as any; + + const queryContext = buildQuery(formDataWithMetricObject); + const [query] = queryContext.queries; + + expect(query.metrics).toContain(metricObject); + }); + + test('should respect type information when processing point_radius_fixed', () => { + const formDataWithTypeInfo = { + ...baseFormData, + point_radius_fixed: { + type: 'fix', + value: '500', + }, + } as any; + + const queryContext = buildQuery(formDataWithTypeInfo); + const [query] = queryContext.queries; + + expect(query.metrics).toEqual([]); + }); + }); +}); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.ts index 257096525c0..ec319a9ae2c 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/buildQuery.ts @@ -24,6 +24,7 @@ import { QueryObjectFilterClause, QueryObject, QueryFormColumn, + QueryFormMetric, } from '@superset-ui/core'; import { addTooltipColumnsToQuery } from '../buildQueryUtils'; @@ -31,9 +32,18 @@ export interface DeckPolygonFormData extends SqlaFormData { line_column?: string; line_type?: string; metric?: string; - point_radius_fixed?: { - value?: string; - }; + point_radius_fixed?: + | { + value?: string; + } + | { + type: 'fix'; + value: string; + } + | { + type: 'metric'; + value: QueryFormMetric; + }; reverse_long_lat?: boolean; filter_nulls?: boolean; js_columns?: string[]; @@ -74,8 +84,16 @@ export default function buildQuery(formData: DeckPolygonFormData) { if (metric) { metrics.push(metric); } - if (point_radius_fixed?.value) { - metrics.push(point_radius_fixed.value); + + if (point_radius_fixed) { + if ('type' in point_radius_fixed) { + if ( + point_radius_fixed.type === 'metric' && + point_radius_fixed.value != null + ) { + metrics.push(point_radius_fixed.value); + } + } } const filters = ensureIsArray(baseQueryObject.filters || []); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.test.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.test.ts new file mode 100644 index 00000000000..f15f3eca9b5 --- /dev/null +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.test.ts @@ -0,0 +1,254 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ChartProps } from '@superset-ui/core'; +import transformProps from './transformProps'; + +interface PolygonFeature { + polygon?: number[][]; + elevation?: number; + extraProps?: Record; + metrics?: Record; +} + +jest.mock('../spatialUtils', () => ({ + ...jest.requireActual('../spatialUtils'), + getMapboxApiKey: jest.fn(() => 'mock-mapbox-key'), +})); + +describe('Polygon transformProps', () => { + const mockChartProps: Partial = { + rawFormData: { + line_column: 'geom', + line_type: 'json', + viewport: {}, + }, + queriesData: [ + { + data: [ + { + geom: JSON.stringify([ + [-122.4, 37.8], + [-122.3, 37.8], + [-122.3, 37.9], + [-122.4, 37.9], + ]), + 'AVG(elevation)': 150.5, + population: 50000, + }, + ], + }, + ], + datasource: { type: 'table' as const, id: 1 }, + height: 400, + width: 600, + hooks: {}, + filterState: {}, + emitCrossFilters: false, + }; + + test('should use constant elevation value when point_radius_fixed type is "fix"', () => { + const fixProps = { + ...mockChartProps, + rawFormData: { + ...mockChartProps.rawFormData, + point_radius_fixed: { + type: 'fix', + value: '1000', + }, + }, + }; + + const result = transformProps(fixProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.elevation).toBe(1000); + }); + + test('should use database metric value for elevation when point_radius_fixed type is "metric"', () => { + const metricProps = { + ...mockChartProps, + rawFormData: { + ...mockChartProps.rawFormData, + point_radius_fixed: { + type: 'metric', + value: { + expressionType: 'SQL', + sqlExpression: 'AVG(elevation)', + label: 'AVG(elevation)', + }, + }, + }, + }; + + const result = transformProps(metricProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.elevation).toBe(150.5); + }); + + test('should use constant elevation value when point_radius_fixed has legacy structure', () => { + const legacyProps = { + ...mockChartProps, + rawFormData: { + ...mockChartProps.rawFormData, + point_radius_fixed: { + value: '750', + }, + }, + }; + + const result = transformProps(legacyProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.elevation).toBe(750); + }); + + test('should not set elevation when point_radius_fixed is not specified', () => { + const noElevationProps = { + ...mockChartProps, + rawFormData: { + ...mockChartProps.rawFormData, + }, + }; + + const result = transformProps(noElevationProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.elevation).toBeUndefined(); + }); + + test('should use decimal constant elevation value when point_radius_fixed type is "fix"', () => { + const decimalFixProps = { + ...mockChartProps, + rawFormData: { + ...mockChartProps.rawFormData, + point_radius_fixed: { + type: 'fix', + value: '500.75', + }, + }, + }; + + const result = transformProps(decimalFixProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.elevation).toBe(500.75); + }); + + test('should handle invalid numeric strings gracefully', () => { + const invalidNumericProps = { + ...mockChartProps, + rawFormData: { + ...mockChartProps.rawFormData, + point_radius_fixed: { + type: 'fix', + value: 'not-a-number', + }, + }, + }; + + const result = transformProps(invalidNumericProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.elevation).toBeUndefined(); + }); + + test('should handle empty string elevation values gracefully', () => { + const emptyStringProps = { + ...mockChartProps, + rawFormData: { + ...mockChartProps.rawFormData, + point_radius_fixed: { + type: 'fix', + value: '', + }, + }, + }; + + const result = transformProps(emptyStringProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.elevation).toBeUndefined(); + }); + + test('should handle null metric elevation values gracefully', () => { + const nullMetricProps = { + ...mockChartProps, + rawFormData: { + ...mockChartProps.rawFormData, + point_radius_fixed: { + type: 'metric', + value: null, + }, + }, + }; + + const result = transformProps(nullMetricProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.elevation).toBeUndefined(); + }); + + test('should handle invalid JSON in polygon data gracefully', () => { + const invalidJsonProps = { + ...mockChartProps, + queriesData: [ + { + data: [ + { + geom: 'invalid-json-string', + }, + ], + }, + ], + }; + + const result = transformProps(invalidJsonProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(0); + }); + + test('should handle legacy point_radius_fixed with invalid value gracefully', () => { + const legacyInvalidProps = { + ...mockChartProps, + rawFormData: { + ...mockChartProps.rawFormData, + point_radius_fixed: { + value: 'invalid-number', + }, + }, + }; + + const result = transformProps(legacyInvalidProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.elevation).toBeUndefined(); + }); +}); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts index b1c73b72d51..3c55142d39e 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { ChartProps } from '@superset-ui/core'; +import { ChartProps, getMetricLabel } from '@superset-ui/core'; import { addJsColumnsToExtraProps, DataRecord } from '../spatialUtils'; import { createBaseTransformResult, @@ -27,6 +27,11 @@ import { } from '../transformUtils'; import { DeckPolygonFormData } from './buildQuery'; +function parseElevationValue(value: string): number | undefined { + const parsed = parseFloat(value); + return Number.isNaN(parsed) ? undefined : parsed; +} + interface PolygonFeature { polygon?: number[][]; name?: string; @@ -53,7 +58,28 @@ function processPolygonData( } const metricLabel = getMetricLabelFromFormData(metric); - const elevationLabel = getMetricLabelFromFormData(point_radius_fixed); + + let elevationLabel: string | undefined; + let fixedElevationValue: number | undefined; + + if (point_radius_fixed) { + if ('type' in point_radius_fixed) { + if ( + point_radius_fixed.type === 'metric' && + point_radius_fixed.value != null + ) { + elevationLabel = getMetricLabel(point_radius_fixed.value); + } else if ( + point_radius_fixed.type === 'fix' && + point_radius_fixed.value + ) { + fixedElevationValue = parseElevationValue(point_radius_fixed.value); + } + } else if (point_radius_fixed.value) { + fixedElevationValue = parseElevationValue(point_radius_fixed.value); + } + } + const excludeKeys = new Set([line_column, ...(js_columns || [])]); return records @@ -109,7 +135,9 @@ function processPolygonData( feature.polygon = polygonCoords; - if (elevationLabel && record[elevationLabel] != null) { + if (fixedElevationValue !== undefined) { + feature.elevation = fixedElevationValue; + } else if (elevationLabel && record[elevationLabel] != null) { const elevationValue = parseMetricValue(record[elevationLabel]); if (elevationValue !== undefined) { feature.elevation = elevationValue;