mirror of
https://github.com/apache/superset.git
synced 2026-07-04 05:45:32 +00:00
Compare commits
2 Commits
dashboard-
...
fix/105973
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
797153b74c | ||
|
|
348d924c92 |
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page, Download, Locator } from '@playwright/test';
|
||||
import { Page, Download } from '@playwright/test';
|
||||
import { Menu } from '../components/core';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
@@ -33,7 +33,6 @@ export class DashboardPage {
|
||||
DASHBOARD_MENU_TRIGGER: '[data-test="actions-trigger"]',
|
||||
// The header-actions-menu is the data-test for the dropdown menu content
|
||||
HEADER_ACTIONS_MENU: '[data-test="header-actions-menu"]',
|
||||
DASHBOARD_TABS: '[data-test="dashboard-component-tabs"]',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -95,34 +94,6 @@ export class DashboardPage {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for the individual tabs of the top-level tab bar.
|
||||
* A dashboard can contain several nested tab bars; the top-level one is the
|
||||
* first `dashboard-component-tabs` rendered in the DOM. The nav list is
|
||||
* scoped to the immediate child (`:scope >`) so that tabs belonging to nested
|
||||
* tab bars rendered inside this container's content are not counted.
|
||||
*/
|
||||
topLevelTabs(): Locator {
|
||||
return this.page
|
||||
.locator(DashboardPage.SELECTORS.DASHBOARD_TABS)
|
||||
.first()
|
||||
.locator(
|
||||
':scope > [data-test="nav-list"] .ant-tabs-nav-list > .ant-tabs-tab',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to the nth top-level tab (0-indexed) and wait for it to become active.
|
||||
*/
|
||||
async switchToTopLevelTab(index: number): Promise<void> {
|
||||
const tab = this.topLevelTabs().nth(index);
|
||||
await tab.click();
|
||||
await tab.and(this.page.locator('.ant-tabs-tab-active')).waitFor({
|
||||
state: 'attached',
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the dashboard header actions menu (three-dot menu)
|
||||
*/
|
||||
|
||||
@@ -1,276 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E migration of the Cypress "Dashboard tabs" suite (dashboard/tabs.test.ts).
|
||||
*
|
||||
* Only one of the three original cases is a genuine end-to-end behaviour:
|
||||
* "should update size when switch tab". A chart living in an inactive (hidden)
|
||||
* tab must re-measure and refit its container when the tab is revealed after the
|
||||
* available width has changed — a real browser layout-reflow that can only be
|
||||
* exercised against a rendered chart in a real dashboard. The other two cases
|
||||
* ("should switch tabs" asserted only the `ant-tabs-tab-active` CSS class, and
|
||||
* "should send new queries when tab becomes visible" was already skipped) are
|
||||
* DOM/state assertions with no backend invariant and belong in component/RTL
|
||||
* coverage, so they are intentionally not migrated here.
|
||||
*
|
||||
* The original relied on a seeded tabbed dashboard and expanded the native
|
||||
* filter bar to change the available width. This migration builds the dashboard
|
||||
* hermetically (two top-level tabs, a width-sensitive treemap in the first) and
|
||||
* shrinks the viewport while the treemap is hidden — the equivalent width change,
|
||||
* with no dependency on seeded data.
|
||||
*
|
||||
* CI green => the treemap reflowed to the narrower width and fit its container
|
||||
* (no horizontal overflow) after the tab switch.
|
||||
* CI red => the chart kept a stale size and overflowed its container, or the
|
||||
* width never changed (the reflow was never exercised).
|
||||
*/
|
||||
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';
|
||||
const WIDE_VIEWPORT = { width: 1400, height: 900 };
|
||||
const NARROW_VIEWPORT = { width: 700, height: 900 };
|
||||
|
||||
testWithAssets(
|
||||
'chart in a hidden tab refits its container after the tab is revealed at a new width',
|
||||
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`;
|
||||
|
||||
// Tab A holds a width-sensitive treemap (echarts sizes it to fill the
|
||||
// container); Tab B holds a table so the second tab has real content.
|
||||
const chartSpecs = [
|
||||
{
|
||||
slug: 'treemap',
|
||||
params: {
|
||||
datasource,
|
||||
viz_type: 'treemap_v2',
|
||||
metric: 'count',
|
||||
groupby: ['gender'],
|
||||
row_limit: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'table',
|
||||
params: {
|
||||
datasource,
|
||||
viz_type: 'table',
|
||||
query_mode: 'aggregate',
|
||||
groupby: ['name'],
|
||||
metrics: ['count'],
|
||||
row_limit: 100,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const chartIds: Record<string, number> = {};
|
||||
for (const { slug, params } of chartSpecs) {
|
||||
const resp = await apiPost(page, 'api/v1/chart/', {
|
||||
slice_name: `tabs_${slug}_${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[slug] = chartId;
|
||||
}
|
||||
|
||||
const treemapKey = `CHART-${chartIds.treemap}`;
|
||||
const tableKey = `CHART-${chartIds.table}`;
|
||||
|
||||
// Top-level tabs live inside GRID_ID: ROOT -> GRID -> TABS -> TAB -> ROW -> CHART.
|
||||
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: ['TABS-TOP'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
'TABS-TOP': {
|
||||
type: 'TABS',
|
||||
id: 'TABS-TOP',
|
||||
children: ['TAB-A', 'TAB-B'],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: {},
|
||||
},
|
||||
'TAB-A': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-A',
|
||||
children: ['ROW-A'],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP'],
|
||||
meta: {
|
||||
text: 'Tab A',
|
||||
defaultText: 'Tab title',
|
||||
placeholder: 'Tab title',
|
||||
},
|
||||
},
|
||||
'TAB-B': {
|
||||
type: 'TAB',
|
||||
id: 'TAB-B',
|
||||
children: ['ROW-B'],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP'],
|
||||
meta: {
|
||||
text: 'Tab B',
|
||||
defaultText: 'Tab title',
|
||||
placeholder: 'Tab title',
|
||||
},
|
||||
},
|
||||
'ROW-A': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-A',
|
||||
children: [treemapKey],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP', 'TAB-A'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
'ROW-B': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-B',
|
||||
children: [tableKey],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP', 'TAB-B'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
[treemapKey]: {
|
||||
type: 'CHART',
|
||||
id: treemapKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP', 'TAB-A', 'ROW-A'],
|
||||
meta: {
|
||||
chartId: chartIds.treemap,
|
||||
width: 12,
|
||||
height: 50,
|
||||
sliceName: 'treemap',
|
||||
},
|
||||
},
|
||||
[tableKey]: {
|
||||
type: 'CHART',
|
||||
id: tableKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'TABS-TOP', 'TAB-B', 'ROW-B'],
|
||||
meta: {
|
||||
chartId: chartIds.table,
|
||||
width: 12,
|
||||
height: 50,
|
||||
sliceName: 'table',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `tabs_resize_${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 Object.values(chartIds)) {
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
dashboards: [dashboardId],
|
||||
});
|
||||
}
|
||||
|
||||
// Render the treemap at the wide viewport first so its initial layout (which
|
||||
// we measure against) is computed at the wide width.
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
|
||||
// Tab A is active on load; wait for its treemap to finish rendering.
|
||||
const treemapContainer = page
|
||||
.locator('[data-test-viz-type="treemap_v2"]')
|
||||
.locator('[data-test="chart-container"]');
|
||||
await treemapContainer.waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await dashboard.waitForChartsToLoad();
|
||||
|
||||
const widthsAtWide = await treemapContainer.evaluate((el: HTMLElement) => ({
|
||||
offsetWidth: el.offsetWidth,
|
||||
scrollWidth: el.scrollWidth,
|
||||
}));
|
||||
|
||||
// Switch to Tab B (treemap becomes hidden), shrink the viewport so the
|
||||
// available width changes while the treemap is not visible, then return.
|
||||
await dashboard.switchToTopLevelTab(1);
|
||||
await page.setViewportSize(NARROW_VIEWPORT);
|
||||
await dashboard.switchToTopLevelTab(0);
|
||||
|
||||
// Let the reveal settle, mirroring the original's fixed wait.
|
||||
await treemapContainer.waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await dashboard.waitForChartsToLoad();
|
||||
|
||||
// 1) The container itself reflowed to the narrower viewport synchronously on
|
||||
// reveal. Without this the fit assertion below could pass trivially (a
|
||||
// chart that never resized still has scrollWidth === offsetWidth), so this
|
||||
// guards against a false green where the reflow was never exercised.
|
||||
const offsetWidthAtNarrow = await treemapContainer.evaluate(
|
||||
(el: HTMLElement) => el.offsetWidth,
|
||||
);
|
||||
expect(
|
||||
offsetWidthAtNarrow,
|
||||
`treemap container should narrow after the viewport shrank ` +
|
||||
`(wide=${widthsAtWide.offsetWidth}, narrow=${offsetWidthAtNarrow})`,
|
||||
).toBeLessThan(widthsAtWide.offsetWidth);
|
||||
|
||||
// 2) Once revealed, the treemap refits its container: its rendered content
|
||||
// fills exactly the available width with no horizontal overflow. The
|
||||
// echarts canvas re-measures a beat after the tab becomes visible, so we
|
||||
// poll until it fits. A genuine resize-on-reveal regression leaves the
|
||||
// chart permanently overflowing (scrollWidth > offsetWidth) and this poll
|
||||
// times out red; ordinary resize latency converges and it passes.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
treemapContainer.evaluate(
|
||||
(el: HTMLElement) => el.scrollWidth - el.offsetWidth,
|
||||
),
|
||||
{
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
message:
|
||||
'treemap should refit its container (no horizontal overflow) after the tab switch',
|
||||
},
|
||||
)
|
||||
.toBe(0);
|
||||
},
|
||||
);
|
||||
@@ -44,3 +44,8 @@ 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__';
|
||||
|
||||
@@ -24,6 +24,7 @@ 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`
|
||||
${() => `
|
||||
@@ -163,13 +164,13 @@ export const NumericCellRenderer = (
|
||||
let arrow = '';
|
||||
let arrowColor = '';
|
||||
if (hasBasicColorFormatters && col?.metricName) {
|
||||
arrow =
|
||||
basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName]
|
||||
?.mainArrow;
|
||||
arrowColor =
|
||||
basicColorFormatters?.[node?.rowIndex as number]?.[
|
||||
col.metricName
|
||||
]?.arrowColor?.toLowerCase();
|
||||
const rowFormatter = getRowBasicColorFormatter(
|
||||
node,
|
||||
node?.rowIndex,
|
||||
basicColorFormatters,
|
||||
)?.[col.metricName];
|
||||
arrow = rowFormatter?.mainArrow;
|
||||
arrowColor = rowFormatter?.arrowColor?.toLowerCase();
|
||||
}
|
||||
|
||||
const alignment =
|
||||
|
||||
@@ -46,6 +46,7 @@ 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,
|
||||
@@ -703,6 +704,23 @@ 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) ?? [];
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ 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;
|
||||
@@ -84,8 +85,11 @@ const getCellStyle = (params: CellStyleParams) => {
|
||||
col?.metricName &&
|
||||
node?.rowPinned !== 'bottom'
|
||||
) {
|
||||
backgroundColor =
|
||||
basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor;
|
||||
backgroundColor = getRowBasicColorFormatter(
|
||||
node,
|
||||
rowIndex,
|
||||
basicColorFormatters,
|
||||
)?.[col.metricName]?.backgroundColor;
|
||||
}
|
||||
|
||||
const textAlign =
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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']);
|
||||
});
|
||||
@@ -1,68 +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 { 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);
|
||||
});
|
||||
@@ -47,15 +47,15 @@ import {
|
||||
DeckGLContainerStyledWrapper,
|
||||
} from './DeckGLContainer';
|
||||
import { GetLayerType } from './factory';
|
||||
import { Point } from './types';
|
||||
import { ColorBreakpointType, ColorType, Point } from './types';
|
||||
import { TooltipProps } from './components/Tooltip';
|
||||
import { COLOR_SCHEME_TYPES, ColorSchemeType } from './utilities/utils';
|
||||
import { getColorBreakpointsBuckets } from './utils';
|
||||
import { addColorToFeatures } from './utils/addColor';
|
||||
import { DEFAULT_DECKGL_COLOR } from './utilities/Shared_DeckGL';
|
||||
|
||||
const { getScale } = CategoricalColorNamespace;
|
||||
|
||||
export function getCategories(fd: QueryFormData, data: JsonObject[]) {
|
||||
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 @@ export 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.slice_id), c.a * 255);
|
||||
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255);
|
||||
} else {
|
||||
color = fixedColor;
|
||||
}
|
||||
@@ -150,7 +150,80 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
data: JsonObject[],
|
||||
fd: QueryFormData,
|
||||
selectedColorScheme: ColorSchemeType,
|
||||
) => addColorToFeatures(data, fd, selectedColorScheme),
|
||||
) => {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,245 +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 { 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]);
|
||||
});
|
||||
@@ -49,8 +49,6 @@ 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';
|
||||
@@ -100,16 +98,6 @@ 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 || {},
|
||||
@@ -237,43 +225,15 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
);
|
||||
|
||||
const createLayerFromData = useCallback(
|
||||
(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,
|
||||
})
|
||||
);
|
||||
},
|
||||
(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,
|
||||
}),
|
||||
[props.onSelect, props.datasource, setTooltip],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,103 +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 { 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();
|
||||
});
|
||||
@@ -1,111 +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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user