Compare commits

..

4 Commits

Author SHA1 Message Date
sadpandajoe
daeca84105 chore(ci): format dashboard controls spec
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 14:10:19 +00:00
Joe Li
6e1102b4f9 test(dashboard): assert force-refresh re-queries every distinct chart
The force-refresh test previously asserted only that the number of
force=true chart-data responses was at least the chart count, so a
regression that refreshed one chart twice while skipping another could
still pass. Extract slice_id from each forced request URL, poll until the
distinct set covers every chart, and assert the refreshed set matches the
dashboard's charts exactly. Also swap the local findDatasetIdByName(page:
any) for the typed getDatasetByName helper. Addresses review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:10:19 +00:00
Joe Li
6526d3ab04 test(dashboard): migrate dashboard force-refresh control to Playwright
Migrates the genuine end-to-end case from the legacy Cypress "Dashboard
top-level controls" suite. Builds a multi-chart dashboard via the API,
warms its cache on initial load, then triggers a dashboard-level force
refresh and asserts every chart re-queries the backend with force=true
and returns a freshly computed (uncached) result.

The other case ("should allow chart level refresh") only asserted a menu
item's disabled class with no backend round-trip — and the original itself
flagged it as flaky — so it belongs in component/RTL coverage and is not
migrated here.

Adds DashboardPage.forceRefresh(), a reusable page-object method that
triggers the force refresh via the header actions menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:10:19 +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 817 additions and 240 deletions

View File

@@ -108,6 +108,18 @@ export class DashboardPage {
);
}
/**
* Trigger a dashboard-level force refresh via the header actions menu.
* Re-runs every chart's query with `force=true`, bypassing the cache.
*/
async forceRefresh(): Promise<void> {
await this.openHeaderActionsMenu();
await this.page
.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU)
.getByRole('menuitem', { name: 'Refresh dashboard' })
.click();
}
/**
* Selects an option from the Download submenu.
* Opens the header actions menu, navigates to Download submenu,

View File

@@ -0,0 +1,215 @@
/**
* 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 top-level controls" suite
* (dashboard/controls.test.ts).
*
* The genuine end-to-end behaviour is the dashboard-level force refresh: every
* chart must re-query the backend with `force=true` (bypassing the cache), and
* each response must report `is_cached: false`. This can only be verified
* against a real backend, so it is migrated here.
*
* The other case ("should allow chart level refresh") only asserted the menu
* item's `ant-dropdown-menu-item-disabled` class — a DOM/state assertion with no
* backend round-trip, and one the original itself flagged as flaky. It belongs
* in component/RTL coverage and is intentionally not migrated.
*
* CI green => force refresh re-queries every chart with force=true and the
* backend serves fresh (uncached) results.
* CI red => force refresh did not bypass the cache or did not re-query charts.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPost, apiPut } from '../../helpers/api/requests';
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-level force refresh re-queries every chart with force=true and uncached results',
async ({ page, testAssets }) => {
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`;
const chartSpecs = [
{ datasource, viz_type: 'big_number_total', metric: 'count' },
{
datasource,
viz_type: 'table',
query_mode: 'aggregate',
groupby: ['name'],
metrics: ['count'],
row_limit: 100,
},
];
const chartIds: number[] = [];
for (const params of chartSpecs) {
const resp = await apiPost(page, 'api/v1/chart/', {
slice_name: `controls_${params.viz_type}_${Date.now()}`,
viz_type: params.viz_type,
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(params),
});
expect(resp.ok()).toBe(true);
const body = await resp.json();
const chartId: number = body.id ?? body.result?.id;
testAssets.trackChart(chartId);
chartIds.push(chartId);
}
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: 6,
height: 50,
sliceName: `controls_${index}`,
},
};
});
const dashResp = await apiPostDashboard(page, {
dashboard_title: `controls_force_refresh_${Date.now()}`,
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);
for (const chartId of chartIds) {
await apiPut(page, `api/v1/chart/${chartId}`, {
dashboards: [dashboardId],
});
}
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
// Initial load warms the cache; the force refresh must then bypass it.
await dashboard.waitForChartsToLoad();
// Capture the force-refresh chart-data round-trips. The chart id is carried
// in the request URL's form_data param (`{"slice_id":N}`), so we can tie
// each forced request back to a specific chart and assert that every chart
// — not just "enough requests" — was re-queried.
const sliceIdFromForceUrl = (url: string): number | undefined => {
const formData = new URL(url).searchParams.get('form_data');
if (!formData) return undefined;
try {
return JSON.parse(formData).slice_id as number;
} catch {
return undefined;
}
};
const forcedSliceIds = new Set<number>();
const forceResponses: Promise<{
sliceId: number | undefined;
status: number;
isCached: unknown;
}>[] = [];
page.on('response', response => {
const request = response.request();
if (
request.method() === 'POST' &&
response.url().includes('/api/v1/chart/data') &&
response.url().includes('force=true')
) {
const sliceId = sliceIdFromForceUrl(response.url());
if (sliceId !== undefined) forcedSliceIds.add(sliceId);
forceResponses.push(
(async () => {
const body = await response.json();
const result = Array.isArray(body.result) ? body.result[0] : body;
return {
sliceId,
status: response.status(),
isCached: result?.is_cached,
};
})(),
);
}
});
await dashboard.forceRefresh();
// Every chart — identified by slice_id, not merely by request count — must
// fire its own forced re-query. Polling on the distinct set (rather than the
// raw length) rejects a regression that refreshes one chart twice while
// skipping another.
await expect
.poll(() => chartIds.every(id => forcedSliceIds.has(id)), {
timeout: TIMEOUT.API_RESPONSE,
})
.toBe(true);
const resolved = await Promise.all(forceResponses);
for (const { sliceId, status, isCached } of resolved) {
// Each forced request targeted one of this dashboard's charts...
expect(chartIds).toContain(sliceId);
// ...and the backend served a real result...
expect(status).toBe(200);
// ...that was freshly computed, not served from cache. The backend reports
// an uncached result as a falsy is_cached (null or false depending on
// version); a cached result would report true.
expect(
isCached,
`force-refreshed result should not be cached, got is_cached=${isCached}`,
).toBeFalsy();
}
// The set of refreshed charts matches exactly the charts on the dashboard:
// none skipped, none foreign.
expect([...forcedSliceIds].sort()).toEqual([...chartIds].sort());
},
);

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;
}
}