Compare commits

..

4 Commits

Author SHA1 Message Date
Joe Li
c4d54affd1 test(dashboard): address review feedback on load spec
- Use typed apiPostChart/apiPutChart instead of raw apiPost/apiPut
- Collapse the special-cased chart-association loop into one loop
- Guard chart id (result?.id ?? id) and the data-test-chart-id
  attribute against undefined for a clear failure instead of a
  #chart-id-undefined timeout
- Match meta.sliceName to each chart's real slice_name
- Parallel-safe chart/dashboard names via Date.now()_parallelIndex

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:10:21 +00:00
Joe Li
984d67ff28 test(dashboard): use typed getDatasetByName helper in load spec
Replace the local findDatasetIdByName(page: any) with the existing typed
getDatasetByName(page: Page) helper, which routes through the retry-wrapped
apiGet path and avoids a new any. Addresses review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:10:21 +00:00
Joe Li
f055e5bcab test(dashboard): migrate dashboard load smoke test to Playwright
Migrates the genuine end-to-end case from the legacy Cypress "Dashboard
load" suite. Builds a multi-chart dashboard via the API, loads it, and
asserts every chart renders by issuing real /api/v1/chart/data queries
that all return 200.

The remaining legacy cases (edit/standalone URL-param rendering,
send-log-data) only assert DOM/URL state with no backend round-trip and
are better served by component/RTL coverage, so they are not migrated here.

Adds DashboardPage.waitForAllChartsRendered(), which derives the chart set
from the dashboard and waits on each chart's render marker (the same signal
the legacy Cypress waitForChartLoad relied on).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:10:21 +00:00
Evan Rusackas
0e6c5838e4 fix(deck.gl): apply categorical scatterplot colors in Multiple Layers chart (#41490)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
2026-07-03 14:23:29 +02:00
14 changed files with 815 additions and 240 deletions

View File

@@ -94,6 +94,39 @@ export class DashboardPage {
);
}
/**
* Wait for every chart on the dashboard to finish rendering and return how
* many charts rendered.
*
* A chart's `#chart-id-<id>` element only becomes visible once the chart has
* fetched its data and rendered (the same signal the legacy Cypress
* `waitForChartLoad` helper relied on). The chart set is derived from the
* dashboard itself via the `[data-test="chart-grid-component"]` holders, so
* this adapts to any dashboard without hard-coding chart names or counts.
*/
async waitForAllChartsRendered(options?: {
timeout?: number;
}): Promise<number> {
// Charts issue real backend queries; allow generous time for slow viz types.
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE * 2;
const holders = this.page.locator('[data-test="chart-grid-component"]');
await holders.first().waitFor({ state: 'attached', timeout });
const count = await holders.count();
for (let i = 0; i < count; i += 1) {
const chartId = await holders.nth(i).getAttribute('data-test-chart-id');
if (!chartId) {
throw new Error(
`Chart holder ${i} is missing its data-test-chart-id attribute`,
);
}
await this.page
.locator(`#chart-id-${chartId}`)
.waitFor({ state: 'visible', timeout });
}
return count;
}
/**
* Open the dashboard header actions menu (three-dot menu)
*/

View File

@@ -0,0 +1,192 @@
/**
* 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.
*/
/**
* E2E migration of the Cypress "Dashboard load" suite (dashboard/load.test.ts).
*
* Only the "should load dashboard" case is a genuine end-to-end test: it loads a
* multi-chart dashboard and proves every chart renders by issuing real backend
* queries. The remaining legacy cases (edit/standalone URL-param rendering,
* send-log-data) only assert DOM/URL state with no backend round-trip and belong
* in component/RTL coverage instead.
*
* The dashboard is built from scratch via the API (rather than relying on a
* seeded example) so the test is hermetic, self-cleaning, and deterministic.
*
* CI green => the dashboard route mounts, every chart POSTs /api/v1/chart/data
* successfully, and each chart's render marker becomes visible.
* CI red => the dashboard failed to load or a chart never rendered.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { getDatasetByName } from '../../helpers/api/dataset';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
const DATASET_NAME = 'birth_names';
testWithAssets(
'dashboard loads and every chart renders via real queries',
async ({ page, testAssets }) => {
// Building + loading a multi-chart dashboard chains several slow queries.
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dataset = await getDatasetByName(page, DATASET_NAME);
if (!dataset) {
throw new Error(`Dataset ${DATASET_NAME} not found`);
}
const datasetId = dataset.id;
const datasource = `${datasetId}__table`;
// A spread of viz types that all render cleanly from the birth_names dataset.
const chartSpecs = [
{
viz_type: 'big_number_total',
params: { datasource, viz_type: 'big_number_total', metric: 'count' },
},
{
viz_type: 'table',
params: {
datasource,
viz_type: 'table',
query_mode: 'aggregate',
groupby: ['name'],
metrics: ['count'],
row_limit: 100,
},
},
{
viz_type: 'echarts_timeseries_line',
params: {
datasource,
viz_type: 'echarts_timeseries_line',
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: [],
row_limit: 100,
},
},
];
// Parallel-safe suffix so chart/dashboard names never collide across workers.
const uniqueSuffix = `${Date.now()}_${testWithAssets.info().parallelIndex}`;
// Create each chart via the API.
const charts: { id: number; sliceName: string }[] = [];
for (const spec of chartSpecs) {
const sliceName = `load_smoke_${spec.viz_type}_${uniqueSuffix}`;
const resp = await apiPostChart(page, {
slice_name: sliceName,
viz_type: spec.viz_type,
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(spec.params),
});
expect(resp.ok()).toBe(true);
const body = await resp.json();
const chartId: number = body.result?.id ?? body.id;
if (!chartId) {
throw new Error(
`Chart creation for ${spec.viz_type} returned no id: ${JSON.stringify(body)}`,
);
}
testAssets.trackChart(chartId);
charts.push({ id: chartId, sliceName });
}
const chartIds = charts.map(chart => chart.id);
// Lay all charts out in a single row.
const chartKeys = chartIds.map(id => `CHART-${id}`);
const positionJson: Record<string, unknown> = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: chartKeys,
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
};
chartIds.forEach((chartId, index) => {
positionJson[chartKeys[index]] = {
type: 'CHART',
id: chartKeys[index],
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: {
chartId,
width: 4,
height: 50,
sliceName: charts[index].sliceName,
},
};
});
const dashResp = await apiPostDashboard(page, {
dashboard_title: `load_smoke_${uniqueSuffix}`,
published: true,
position_json: JSON.stringify(positionJson),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
testAssets.trackDashboard(dashboardId);
// Associate every chart with the dashboard so they actually render.
for (const chartId of chartIds) {
await apiPutChart(page, chartId, { dashboards: [dashboardId] });
}
// Record the real chart-data round-trips the dashboard makes on load.
const chartDataStatuses: number[] = [];
page.on('response', response => {
const request = response.request();
if (
request.method() === 'POST' &&
response.url().includes('/api/v1/chart/data')
) {
chartDataStatuses.push(response.status());
}
});
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
// Every chart grid component must reach its rendered state.
const renderedCount = await dashboard.waitForAllChartsRendered();
expect(renderedCount).toBe(chartIds.length);
// The render came from real backend queries, and all of them succeeded.
expect(chartDataStatuses.length).toBeGreaterThan(0);
expect(
chartDataStatuses.every(status => status === 200),
`all /api/v1/chart/data responses should be 200, got [${chartDataStatuses}]`,
).toBe(true);
},
);

View File

@@ -44,8 +44,3 @@ export const FILTER_CONDITION_BODY_INDEX = {
} as const;
export const ROW_NUMBER_COL_ID = '__row_number__';
// Non-enumerable key used to attach a row's basic (increase/decrease) color
// formatter to the row data object so it travels with the row through AG Grid
// client-side sorting (#105973).
export const BASIC_COLOR_FORMATTERS_ROW_KEY = '__basicColorFormatters__';

View File

@@ -24,7 +24,6 @@ import {
import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact';
import { BasicColorFormatterType, InputColumn, ValueRange } from '../types';
import { useIsDark } from '../utils/useTableTheme';
import getRowBasicColorFormatter from '../utils/getRowBasicColorFormatter';
const StyledTotalCell = styled.div`
${() => `
@@ -164,13 +163,13 @@ export const NumericCellRenderer = (
let arrow = '';
let arrowColor = '';
if (hasBasicColorFormatters && col?.metricName) {
const rowFormatter = getRowBasicColorFormatter(
node,
node?.rowIndex,
basicColorFormatters,
)?.[col.metricName];
arrow = rowFormatter?.mainArrow;
arrowColor = rowFormatter?.arrowColor?.toLowerCase();
arrow =
basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName]
?.mainArrow;
arrowColor =
basicColorFormatters?.[node?.rowIndex as number]?.[
col.metricName
]?.arrowColor?.toLowerCase();
}
const alignment =

View File

@@ -46,7 +46,6 @@ import {
} from '@superset-ui/chart-controls';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from './consts';
import {
DataColumnMeta,
TableChartProps,
@@ -704,23 +703,6 @@ const transformProps = (
const basicColorFormatters =
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
// Attach each row's basic (increase/decrease) color formatter to the row data
// object so it travels with the row through AG Grid client-side sorting.
// basicColorFormatters is built in the original query order and was previously
// read positionally by the displayed rowIndex, which applied colors to the
// wrong rows once the table was sorted (#105973). The property is
// non-enumerable so it never leaks into exports, cross-filters or spreads.
if (basicColorFormatters) {
passedData.forEach((row, index) => {
Object.defineProperty(row, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: basicColorFormatters[index],
enumerable: false,
configurable: true,
writable: true,
});
});
}
const columnColorFormatters =
getColorFormatters(conditionalFormatting, passedData, theme) ?? [];

View File

@@ -24,7 +24,6 @@ import {
} from '@superset-ui/chart-controls';
import { CellClassParams } from '@superset-ui/core/components/ThemedAgGridReact';
import { BasicColorFormatterType, InputColumn } from '../types';
import getRowBasicColorFormatter from './getRowBasicColorFormatter';
type CellStyleParams = CellClassParams & {
hasColumnColorFormatters: boolean | undefined;
@@ -85,11 +84,8 @@ const getCellStyle = (params: CellStyleParams) => {
col?.metricName &&
node?.rowPinned !== 'bottom'
) {
backgroundColor = getRowBasicColorFormatter(
node,
rowIndex,
basicColorFormatters,
)?.[col.metricName]?.backgroundColor;
backgroundColor =
basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor;
}
const textAlign =

View File

@@ -1,51 +0,0 @@
/**
* 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 { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../consts';
import { BasicColorFormatterType } from '../types';
type RowFormatters = { [key: string]: BasicColorFormatterType };
/**
* Resolves the basic (increase/decrease) color formatters for a given AG Grid
* row node.
*
* The formatter is attached to the row data object itself (see transformProps),
* so it follows the row through client-side sorting. Looking it up positionally
* by the displayed `rowIndex` was wrong once the user sorted the table, because
* the displayed index no longer matched the original data order (#105973).
*
* Falls back to the positional array for safety when no attached formatter is
* present.
*/
export default function getRowBasicColorFormatter(
node: { data?: Record<string, unknown> } | undefined,
rowIndex: number | null | undefined,
basicColorFormatters: RowFormatters[] | undefined,
): RowFormatters | undefined {
const attached = node?.data?.[BASIC_COLOR_FORMATTERS_ROW_KEY] as
| RowFormatters
| undefined;
if (attached) {
return attached;
}
if (rowIndex == null) {
return undefined;
}
return basicColorFormatters?.[rowIndex];
}

View File

@@ -1,65 +0,0 @@
/**
* 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 getRowBasicColorFormatter from '../../src/utils/getRowBasicColorFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../../src/consts';
const red = { sales: { backgroundColor: 'red', mainArrow: '↓', arrowColor: 'red' } };
const green = {
sales: { backgroundColor: 'green', mainArrow: '↑', arrowColor: 'green' },
};
// Positional array in the original (unsorted) query order: row 0 -> green, row 1 -> red.
const positional = [green, red] as any;
test('uses the formatter attached to the row, not the displayed rowIndex (#105973)', () => {
// After sorting, the row whose original formatter is `red` is displayed first
// (rowIndex 0). The positional lookup would wrongly return `green`.
const rowData: Record<string, unknown> = { sales: 5 };
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: red,
enumerable: false,
});
const node = { data: rowData };
expect(getRowBasicColorFormatter(node, 0, positional)).toBe(red);
expect(
getRowBasicColorFormatter(node, 0, positional)?.sales.backgroundColor,
).toBe('red');
});
test('falls back to positional lookup when no formatter is attached', () => {
const node = { data: { sales: 5 } };
expect(getRowBasicColorFormatter(node, 1, positional)).toBe(red);
});
test('returns undefined when nothing matches', () => {
expect(getRowBasicColorFormatter(undefined, null, positional)).toBeUndefined();
expect(
getRowBasicColorFormatter({ data: {} }, null, positional),
).toBeUndefined();
});
test('attached formatter is non-enumerable so it does not leak into the row', () => {
const rowData: Record<string, unknown> = { sales: 5 };
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: green,
enumerable: false,
});
expect(Object.keys(rowData)).toEqual(['sales']);
});

View File

@@ -0,0 +1,68 @@
/**
* 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 { QueryFormData } from '@superset-ui/core';
import { getCategories } from './CategoricalDeckGLContainer';
import { addColorToFeatures } from './utils/addColor';
import { COLOR_SCHEME_TYPES } from './utilities/utils';
// Record every (label, sliceId) pair the categorical color scale is asked to
// resolve, so we can assert the legend and point-color paths key the scale on
// the same slice id.
const scaleCalls: [string, number | undefined][] = [];
jest.mock('@superset-ui/core', () => {
const actual = jest.requireActual('@superset-ui/core');
return {
...actual,
CategoricalColorNamespace: {
...actual.CategoricalColorNamespace,
getScale: () => (value: string, sliceId?: number) => {
scaleCalls.push([value, sliceId]);
return value === 'A' ? '#ff0000' : '#00ff00';
},
},
};
});
test('legend and point colors resolve from the same slice_id', () => {
const fd = {
datasource: '1__table',
viz_type: 'deck_scatter',
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
color_scheme: 'supersetColors',
dimension: 'category',
slice_id: 42,
color_picker: { r: 0, g: 0, b: 0, a: 1 },
} as unknown as QueryFormData;
const data = [{ cat_color: 'A' }, { cat_color: 'B' }];
const categories = getCategories(fd, data);
const features = addColorToFeatures(data, fd);
// Both the legend path (getCategories) and the point-color path
// (addColorToFeatures) key the color scale on the same slice id.
expect(scaleCalls.length).toBeGreaterThan(0);
scaleCalls.forEach(([, sliceId]) => {
expect(sliceId).toBe(42);
});
// The legend swatch for each category matches the resolved point color.
expect(categories.A.color).toEqual(features[0].color);
expect(categories.B.color).toEqual(features[1].color);
expect(features[0].color).not.toEqual(features[1].color);
});

View File

@@ -47,15 +47,15 @@ import {
DeckGLContainerStyledWrapper,
} from './DeckGLContainer';
import { GetLayerType } from './factory';
import { ColorBreakpointType, ColorType, Point } from './types';
import { Point } from './types';
import { TooltipProps } from './components/Tooltip';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from './utilities/utils';
import { getColorBreakpointsBuckets } from './utils';
import { DEFAULT_DECKGL_COLOR } from './utilities/Shared_DeckGL';
import { addColorToFeatures } from './utils/addColor';
const { getScale } = CategoricalColorNamespace;
function getCategories(fd: QueryFormData, data: JsonObject[]) {
export function getCategories(fd: QueryFormData, data: JsonObject[]) {
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
const appliedScheme = fd.color_scheme;
@@ -70,7 +70,7 @@ function getCategories(fd: QueryFormData, data: JsonObject[]) {
if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) {
let color;
if (fd.dimension) {
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255);
color = hexToRGB(colorFn(d.cat_color, fd.slice_id), c.a * 255);
} else {
color = fixedColor;
}
@@ -150,80 +150,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
data: JsonObject[],
fd: QueryFormData,
selectedColorScheme: ColorSchemeType,
) => {
const appliedScheme = fd.color_scheme;
const colorFn = getScale(appliedScheme);
let color: ColorType;
switch (selectedColorScheme) {
case COLOR_SCHEME_TYPES.fixed_color: {
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const colorArray = [color.r, color.g, color.b, color.a * 255];
return data.map(d => ({ ...d, color: colorArray }));
}
case COLOR_SCHEME_TYPES.categorical_palette: {
if (!fd.dimension) {
const fallbackColor = fd.color_picker || {
r: 0,
g: 0,
b: 0,
a: 1,
};
const colorArray = [
fallbackColor.r,
fallbackColor.g,
fallbackColor.b,
fallbackColor.a * 255,
];
return data.map(d => ({ ...d, color: colorArray }));
}
return data.map(d => ({
...d,
color: hexToRGB(colorFn(d.cat_color, fd.slice_id)),
}));
}
case COLOR_SCHEME_TYPES.color_breakpoints: {
const defaultBreakpointColor = fd.default_breakpoint_color
? [
fd.default_breakpoint_color.r,
fd.default_breakpoint_color.g,
fd.default_breakpoint_color.b,
fd.default_breakpoint_color.a * 255,
]
: [
DEFAULT_DECKGL_COLOR.r,
DEFAULT_DECKGL_COLOR.g,
DEFAULT_DECKGL_COLOR.b,
DEFAULT_DECKGL_COLOR.a * 255,
];
return data.map(d => {
const breakpointForPoint: ColorBreakpointType =
fd.color_breakpoints?.find(
(breakpoint: ColorBreakpointType) =>
d.metric >= breakpoint.minValue &&
d.metric <= breakpoint.maxValue,
);
if (breakpointForPoint) {
const pointColor = [
breakpointForPoint.color.r,
breakpointForPoint.color.g,
breakpointForPoint.color.b,
breakpointForPoint.color.a * 255,
];
return { ...d, color: pointColor };
}
return { ...d, color: defaultBreakpointColor };
});
}
default: {
return [];
}
}
},
) => addColorToFeatures(data, fd, selectedColorScheme),
[],
);

View File

@@ -0,0 +1,245 @@
/**
* 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 { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { DatasourceType, SupersetClient } from '@superset-ui/core';
import DeckMulti from './Multi';
// Capture the layers handed to the DeckGL container so we can inspect the
// per-feature colors that were resolved for each sublayer.
interface CapturedDataPoint {
color: number[];
}
interface CapturedLayer {
id?: string;
props: {
data: CapturedDataPoint[];
getSourceColor?: (d: Record<string, unknown>) => number[];
getTargetColor?: (d: Record<string, unknown>) => number[];
};
}
const mockLayerCapture: { layers: CapturedLayer[] } = { layers: [] };
jest.mock('../DeckGLContainer', () => ({
DeckGLContainerStyledWrapper: ({ layers }: { layers?: CapturedLayer[] }) => {
mockLayerCapture.layers = layers || [];
return <div data-test="deckgl-container">DeckGL Container Mock</div>;
},
}));
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
get: jest.fn(),
},
}));
const mockStore = configureStore({
reducer: {
dataMask: () => ({}),
},
});
const renderWithProviders = (component: React.ReactElement) =>
render(
<Provider store={mockStore}>
<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>
</Provider>,
);
const SCATTER_SLICE_ID = 1;
const props = {
formData: {
datasource: '1__table',
viz_type: 'deck_multi',
deck_slices: [SCATTER_SLICE_ID],
autozoom: false,
map_style: 'mapbox://styles/mapbox/light-v9',
},
payload: {
data: {
slices: [
{
slice_id: SCATTER_SLICE_ID,
form_data: {
viz_type: 'deck_scatter',
datasource: '1__table',
slice_id: SCATTER_SLICE_ID,
// categorical color configuration coming from the saved scatter chart
color_scheme_type: 'categorical_palette',
color_scheme: 'supersetColors',
dimension: 'category',
},
},
],
features: {
deck_scatter: [],
},
mapboxApiKey: 'test-key',
},
},
setControlValue: jest.fn(),
viewport: { longitude: 0, latitude: 0, zoom: 1 },
onAddFilter: jest.fn(),
height: 600,
width: 800,
datasource: {
id: 1,
type: DatasourceType.Table,
name: 'test_datasource',
columns: [],
metrics: [],
columnFormats: {},
currencyFormats: {},
verboseMap: {},
},
onSelect: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockLayerCapture.layers = [];
// The scatter sublayer query returns features tagged with a category column.
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: {
data: {
features: [
{ position: [0, 0], radius: 1, cat_color: 'A' },
{ position: [1, 1], radius: 1, cat_color: 'B' },
],
},
},
});
});
const expectDistinctCategoricalColors = async () => {
await waitFor(() => {
expect(mockLayerCapture.layers.length).toBeGreaterThan(0);
});
const scatterLayer = mockLayerCapture.layers.find((layer: CapturedLayer) =>
layer?.id?.startsWith('scatter-layer-'),
);
expect(scatterLayer).toBeDefined();
const { data } = (scatterLayer as CapturedLayer).props;
expect(data).toHaveLength(2);
// Both points must carry a resolved RGBA color...
data.forEach((d: CapturedDataPoint) => {
expect(Array.isArray(d.color)).toBe(true);
expect(d.color).toHaveLength(4);
});
// ...and the two distinct categories must NOT share the same color. Before
// the fix, categorical colors were dropped in the Multiple Layers chart and
// every point fell back to the same default color.
expect(data[0].color).not.toEqual(data[1].color);
};
test('applies categorical scatterplot colors to sublayers in the multi chart', async () => {
renderWithProviders(<DeckMulti {...props} />);
await expectDistinctCategoricalColors();
});
test('applies categorical colors to scatter subslices saved before the color_scheme_type control existed', async () => {
// Charts saved before the color_scheme_type control existed lack the key in
// stored params; the scatter default (categorical_palette) must be resolved
// so they keep per-category colors.
const legacyProps = {
...props,
payload: {
...props.payload,
data: {
...props.payload.data,
slices: [
{
slice_id: SCATTER_SLICE_ID,
form_data: {
viz_type: 'deck_scatter',
datasource: '1__table',
slice_id: SCATTER_SLICE_ID,
color_scheme: 'supersetColors',
dimension: 'category',
},
},
],
},
},
};
renderWithProviders(<DeckMulti {...legacyProps} />);
await expectDistinctCategoricalColors();
});
test('keeps fixed source and target colors for arc subslices saved before the color_scheme_type control existed', async () => {
// Legacy arcs default to fixed_color, where the layer reads the source and
// target pickers directly; resolving the default must not stamp a single
// per-feature color over the target color.
const ARC_SLICE_ID = 2;
const arcProps = {
...props,
formData: { ...props.formData, deck_slices: [ARC_SLICE_ID] },
payload: {
...props.payload,
data: {
...props.payload.data,
slices: [
{
slice_id: ARC_SLICE_ID,
form_data: {
viz_type: 'deck_arc',
datasource: '1__table',
slice_id: ARC_SLICE_ID,
color_picker: { r: 10, g: 20, b: 30, a: 1 },
target_color_picker: { r: 40, g: 50, b: 60, a: 1 },
},
},
],
features: { deck_arc: [] },
},
},
};
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: {
data: {
features: [{ sourcePosition: [0, 0], targetPosition: [1, 1] }],
},
},
});
renderWithProviders(<DeckMulti {...arcProps} />);
await waitFor(() => {
expect(mockLayerCapture.layers.length).toBeGreaterThan(0);
});
const arcLayer = mockLayerCapture.layers.find(
(layer: CapturedLayer) => layer?.id === `path-layer-${ARC_SLICE_ID}`,
);
expect(arcLayer).toBeDefined();
expect(arcLayer?.props.getSourceColor?.({})).toEqual([10, 20, 30, 255]);
expect(arcLayer?.props.getTargetColor?.({})).toEqual([40, 50, 60, 255]);
});

View File

@@ -49,6 +49,8 @@ import {
DeckGLContainerStyledWrapper,
} from '../DeckGLContainer';
import { getExploreLongUrl } from '../utils/explore';
import { addColorToFeatures } from '../utils/addColor';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils';
import layerGenerators from '../layers';
import fitViewport, { Viewport } from '../utils/fitViewport';
import { getMapboxApiKey } from '../utils/mapbox';
@@ -98,6 +100,16 @@ const MultiWrapper = styled.div<{ height: number; width: number }>`
width: ${({ width }) => width}px;
`;
// Default color_scheme_type per color-aware layer type, matching each control
// panel. Sub-slices arrive as raw saved form data without control-default
// hydration, so charts saved before this control existed need the default
// resolved here to keep their configured colors.
const COLOR_AWARE_LAYER_DEFAULTS: Record<string, ColorSchemeType> = {
deck_scatter: COLOR_SCHEME_TYPES.categorical_palette,
deck_path: COLOR_SCHEME_TYPES.fixed_color,
deck_arc: COLOR_SCHEME_TYPES.fixed_color,
};
const selectDataMask = createSelector(
(state: { dataMask?: DataMaskState }) => state.dataMask,
dataMask => dataMask || {},
@@ -225,15 +237,43 @@ const DeckMulti = (props: DeckMultiProps) => {
);
const createLayerFromData = useCallback(
(subslice: JsonObject, json: JsonObject): Layer =>
// @ts-expect-error TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators.
layerGenerators[subslice.form_data.viz_type]({
formData: subslice.form_data,
payload: json,
setTooltip,
datasource: props.datasource,
onSelect: props.onSelect,
}),
(subslice: JsonObject, json: JsonObject): Layer => {
const { form_data: subsliceFormData } = subslice;
const defaultColorSchemeType =
COLOR_AWARE_LAYER_DEFAULTS[subsliceFormData.viz_type];
let layerFormData = subsliceFormData;
let payload = json;
// Resolve per-feature colors as CategoricalDeckGLContainer does when
// the layer renders standalone.
if (defaultColorSchemeType) {
layerFormData = {
...subsliceFormData,
color_scheme_type:
subsliceFormData.color_scheme_type ?? defaultColorSchemeType,
};
if (Array.isArray(json?.data?.features)) {
payload = {
...json,
data: {
...json.data,
features: addColorToFeatures(json.data.features, layerFormData),
},
};
}
}
return (
// @ts-expect-error TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators.
layerGenerators[layerFormData.viz_type]({
formData: layerFormData,
payload,
setTooltip,
datasource: props.datasource,
onSelect: props.onSelect,
})
);
},
[props.onSelect, props.datasource, setTooltip],
);

View File

@@ -0,0 +1,103 @@
/**
* 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 { QueryFormData } from '@superset-ui/core';
import { addColorToFeatures } from './addColor';
import { COLOR_SCHEME_TYPES } from '../utilities/utils';
const baseFormData = {
datasource: '1__table',
viz_type: 'deck_scatter',
} as unknown as QueryFormData;
test('assigns distinct colors per category for a categorical palette', () => {
const features = [{ cat_color: 'A' }, { cat_color: 'B' }, { cat_color: 'A' }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
color_scheme: 'supersetColors',
dimension: 'category',
slice_id: 1,
} as unknown as QueryFormData);
// Each feature gets a resolved RGBA color
result.forEach(d => {
expect(Array.isArray(d.color)).toBe(true);
expect(d.color).toHaveLength(4);
});
// Same category resolves to the same color, different categories differ
expect(result[0].color).toEqual(result[2].color);
expect(result[0].color).not.toEqual(result[1].color);
});
test('falls back to the fixed color picker when no dimension is set', () => {
const features = [{ cat_color: 'A' }, { cat_color: 'B' }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
color_picker: { r: 10, g: 20, b: 30, a: 1 },
} as unknown as QueryFormData);
result.forEach(d => {
expect(d.color).toEqual([10, 20, 30, 255]);
});
});
test('applies the fixed color scheme to every feature', () => {
const features = [{ cat_color: 'A' }, { cat_color: 'B' }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: COLOR_SCHEME_TYPES.fixed_color,
color_picker: { r: 1, g: 2, b: 3, a: 0.5 },
} as unknown as QueryFormData);
result.forEach(d => {
expect(d.color).toEqual([1, 2, 3, 127.5]);
});
});
test('assigns breakpoint colors by metric and falls back to the default', () => {
const features = [{ metric: 5 }, { metric: 50 }, { metric: 500 }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
color_breakpoints: [
{ minValue: 0, maxValue: 10, color: { r: 1, g: 2, b: 3, a: 1 } },
{ minValue: 11, maxValue: 100, color: { r: 4, g: 5, b: 6, a: 0.5 } },
],
default_breakpoint_color: { r: 7, g: 8, b: 9, a: 1 },
} as unknown as QueryFormData);
// Metric inside the first breakpoint range
expect(result[0].color).toEqual([1, 2, 3, 255]);
// Metric inside the second breakpoint range (alpha scaled to 0-255)
expect(result[1].color).toEqual([4, 5, 6, 127.5]);
// Metric outside every range falls back to the default breakpoint color
expect(result[2].color).toEqual([7, 8, 9, 255]);
});
test('returns features unchanged for an unrecognized color scheme', () => {
const features = [{ cat_color: 'A' }];
const result = addColorToFeatures(features, {
...baseFormData,
color_scheme_type: 'something_else',
} as unknown as QueryFormData);
expect(result).toEqual(features);
expect(result[0].color).toBeUndefined();
});

View File

@@ -0,0 +1,111 @@
/**
* 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 {
CategoricalColorNamespace,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { hexToRGB } from './colors';
import { ColorBreakpointType } from '../types';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils';
import { DEFAULT_DECKGL_COLOR } from '../utilities/Shared_DeckGL';
const { getScale } = CategoricalColorNamespace;
/**
* Resolve the per-feature color for a deck.gl layer based on the form data's
* color scheme configuration. This mirrors the categorical/fixed/breakpoint
* color logic that `CategoricalDeckGLContainer` applies when a layer is
* rendered on its own, so that it can be reused when layers are composed
* inside the deck.gl Multiple Layers chart.
*
* Features whose color scheme is not recognized are returned unchanged so the
* layer's own fallback color logic can take over.
*/
export function addColorToFeatures(
data: JsonObject[],
fd: QueryFormData,
selectedColorScheme: ColorSchemeType = fd.color_scheme_type,
): JsonObject[] {
const appliedScheme = fd.color_scheme;
const colorFn = getScale(appliedScheme);
switch (selectedColorScheme) {
case COLOR_SCHEME_TYPES.fixed_color: {
const color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const colorArray = [color.r, color.g, color.b, color.a * 255];
return data.map(d => ({ ...d, color: colorArray }));
}
case COLOR_SCHEME_TYPES.categorical_palette: {
if (!fd.dimension) {
const fallbackColor = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const colorArray = [
fallbackColor.r,
fallbackColor.g,
fallbackColor.b,
fallbackColor.a * 255,
];
return data.map(d => ({ ...d, color: colorArray }));
}
return data.map(d => ({
...d,
color: hexToRGB(colorFn(d.cat_color, fd.slice_id)),
}));
}
case COLOR_SCHEME_TYPES.color_breakpoints: {
const defaultBreakpointColor = fd.default_breakpoint_color
? [
fd.default_breakpoint_color.r,
fd.default_breakpoint_color.g,
fd.default_breakpoint_color.b,
fd.default_breakpoint_color.a * 255,
]
: [
DEFAULT_DECKGL_COLOR.r,
DEFAULT_DECKGL_COLOR.g,
DEFAULT_DECKGL_COLOR.b,
DEFAULT_DECKGL_COLOR.a * 255,
];
return data.map(d => {
const breakpointForPoint: ColorBreakpointType =
fd.color_breakpoints?.find(
(breakpoint: ColorBreakpointType) =>
d.metric >= breakpoint.minValue &&
d.metric <= breakpoint.maxValue,
);
if (breakpointForPoint) {
const pointColor = [
breakpointForPoint.color.r,
breakpointForPoint.color.g,
breakpointForPoint.color.b,
breakpointForPoint.color.a * 255,
];
return { ...d, color: pointColor };
}
return { ...d, color: defaultBreakpointColor };
});
}
default:
return data;
}
}