Compare commits

..

8 Commits

Author SHA1 Message Date
amaannawab923
03bf1c4e81 test(ag-grid): assert cell-selection wiring on the interactive table
Verify the grid is rendered with enableCellTextSelection disabled and an
onCellKeyDown handler that copies the focused cell value on Ctrl/Cmd+C, so the
#106389 decision cannot be silently reverted.
2026-07-03 19:56:17 +05:30
amaannawab923
b78f4fbab6 test(ag-grid): cover copyCellValue clipboard error paths
Add cases for the Clipboard API rejecting (falls back to execCommand) and for
both clipboard paths failing (returns false), taking the util to full function
coverage.
2026-07-03 19:56:17 +05:30
amaannawab923
a15070a5cc test(ag-grid): cover copyCellValue clipboard handler
Shortcut detection, formatted-vs-raw value resolution, null handling, the
Clipboard API path and execCommand fallback, and the onCellKeyDown handler.
2026-07-03 19:10:01 +05:30
amaannawab923
1c892ae463 fix(ag-grid): restore copy-a-value when selecting cell on click (#106389)
Selecting the cell (not its text) removed the only way to copy a value in the
Community build, where the Enterprise clipboard module is inert. Wire an
onCellKeyDown handler so Ctrl/Cmd+C copies the focused cell, keeping both cell
selection and copy.
2026-07-03 19:10:01 +05:30
amaannawab923
0db6f4b162 feat(ag-grid): add util to copy a focused cell value on Ctrl/Cmd+C
Small, framework-agnostic helpers: detect the copy shortcut, resolve the cell's
display text (formatted value, falling back to raw), and write to the clipboard
via the async Clipboard API with an execCommand fallback for non-secure
contexts.
2026-07-03 19:10:01 +05:30
amaannawab923
2d3641dac7 chore(ThemedAgGridReact): re-export CellKeyDownEvent type
Expose the ag-grid-community CellKeyDownEvent type from the themed wrapper so
consumers can type onCellKeyDown handlers without importing ag-grid directly.
2026-07-03 19:10:01 +05:30
Enzo Martellucci
8c09439099 Merge branch 'master' into fix/106389-cell-selection 2026-07-03 13:21:08 +02:00
amaannawab923
bae3a1cb90 fix(plugin-chart-ag-grid-table): select cell on click instead of its text (#106389)
enableCellTextSelection forced browser text selection when clicking a cell,
which suppressed AG Grid's cell-focus (cell selection) behavior. Disable it so a
click selects/focuses the cell as users expect.

Note: full multi-cell range selection requires AG Grid Enterprise and is not
available in the Community build, so the multi-cell part of the acceptance
criteria cannot be satisfied without an Enterprise license.
2026-06-24 18:16:50 +05:30
11 changed files with 408 additions and 154 deletions

View File

@@ -211,6 +211,7 @@ export type {
GridState,
GridReadyEvent,
CellClickedEvent,
CellKeyDownEvent,
CellClassParams,
IMenuActionParams,
IHeaderParams,

View File

@@ -40,6 +40,7 @@ import {
GridReadyEvent,
GridState,
CellClickedEvent,
CellKeyDownEvent,
SelectionChangedEvent,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { t } from '@apache-superset/core/translation';
@@ -59,6 +60,7 @@ import getInitialFilterModel from '../utils/getInitialFilterModel';
import reconcileColumnState from '../utils/reconcileColumnState';
import { PAGE_SIZE_OPTIONS } from '../consts';
import { getCompleteFilterState } from '../utils/filterStateManager';
import { copyCellValueOnKeyDown } from '../utils/copyCellValue';
export interface AgGridState extends Partial<GridState> {
timestamp?: number;
@@ -235,6 +237,13 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
isSearchFocused.set(searchId, false);
}, [searchId]);
// Copy the focused cell's value on Ctrl/Cmd+C. Needed because cell text is
// no longer natively selectable (see enableCellTextSelection below) and the
// Enterprise clipboard module is not registered (#106389).
const handleCellKeyDown = useCallback((event: CellKeyDownEvent) => {
copyCellValueOnKeyDown(event);
}, []);
const onFilterTextBoxChanged = useCallback(
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
if (serverPagination) {
@@ -514,13 +523,22 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
rowSelection="multiple"
animateRows
onCellClicked={handleCellClicked}
onCellKeyDown={handleCellKeyDown}
onSelectionChanged={handleSelectionChanged}
onFilterChanged={handleFilterChanged}
onStateUpdated={handleGridStateChange}
initialState={gridInitialState}
maintainColumnOrder
suppressAggFuncInHeader
enableCellTextSelection
// Clicking a cell should select (focus) the cell rather than select
// its text content (#106389). enableCellTextSelection forces browser
// text selection on click, which suppresses the cell-focus behavior.
// Because the Enterprise clipboard module isn't registered, native
// text selection was the only way to copy a value, so onCellKeyDown
// (above) restores Ctrl/Cmd+C copy for the focused cell. Full
// multi-cell range selection still requires AG Grid Enterprise, which
// is not available in the Community build used here.
enableCellTextSelection={false}
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}
pagination={pagination}

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

@@ -0,0 +1,123 @@
/**
* 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.
*/
/**
* The Interactive Table selects the cell (not its text) on click (#106389).
* Because only AG Grid Community modules are registered, the Enterprise
* clipboard module is unavailable, so the browser's native text-selection was
* previously the *only* way to copy a cell value. To keep copy-a-value working,
* we provide a small Ctrl/Cmd+C handler that copies the focused cell's value.
*/
/** Minimal shape of a keyboard event needed to detect a copy shortcut. */
export interface CopyKeyboardEvent {
key?: string;
ctrlKey?: boolean;
metaKey?: boolean;
}
/** Minimal shape of the AG Grid cell key-down params we rely on. */
export interface CopyableCellParams {
event?: CopyKeyboardEvent | null;
value?: unknown;
valueFormatted?: unknown;
}
/** True when the event is Ctrl+C (Win/Linux) or Cmd+C (macOS). */
export function isCopyShortcut(event?: CopyKeyboardEvent | null): boolean {
if (!event) {
return false;
}
const key = (event.key ?? '').toLowerCase();
return (event.ctrlKey === true || event.metaKey === true) && key === 'c';
}
/**
* Resolves the text to copy for a cell. Prefers the formatted (displayed) value
* so the copied text matches what the user sees; falls back to the raw value.
* Null/undefined values copy as an empty string.
*/
export function getCellCopyText(params?: CopyableCellParams): string {
if (!params) {
return '';
}
const raw =
params.valueFormatted !== null && params.valueFormatted !== undefined
? params.valueFormatted
: params.value;
if (raw === null || raw === undefined) {
return '';
}
return String(raw);
}
/**
* Writes text to the clipboard, using the async Clipboard API when available
* and falling back to a hidden textarea + execCommand for non-secure contexts.
* Returns whether the write is believed to have succeeded.
*/
export async function writeTextToClipboard(text: string): Promise<boolean> {
try {
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === 'function'
) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// Fall through to the legacy fallback below.
}
if (typeof document === 'undefined') {
return false;
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const succeeded = document.execCommand('copy');
document.body.removeChild(textarea);
return succeeded;
} catch {
return false;
}
}
/**
* AG Grid `onCellKeyDown` handler: copies the focused cell's value on Ctrl/Cmd+C.
* Returns true when the copy shortcut was handled, false otherwise (so the event
* is left untouched for any other key).
*/
export function copyCellValueOnKeyDown(params?: CopyableCellParams): boolean {
if (!isCopyShortcut(params?.event)) {
return false;
}
const text = getCellCopyText(params);
// Fire and forget — the clipboard write is async but the handler is sync.
void writeTextToClipboard(text);
return true;
}

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

@@ -0,0 +1,97 @@
/**
* 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 '@superset-ui/core/spec';
import { ProviderWrapper } from '../../plugin-chart-table/test/testHelpers';
import testData from '../../plugin-chart-table/test/testData';
// Capture the props the grid is rendered with, so we can assert the
// cell-selection wiring without depending on AG Grid's DOM rendering.
const captured: { props?: Record<string, any> } = {};
// Mock the narrow ThemedAgGridReact module (which the components barrel
// re-exports) rather than the whole barrel, to avoid its circular-init.
jest.mock('@superset-ui/core/components/ThemedAgGridReact', () => ({
__esModule: true,
ThemedAgGridReact: (props: Record<string, any>) => {
captured.props = props;
return null;
},
AgGridReact: function AgGridReact() {
return null;
},
AllCommunityModule: {},
ClientSideRowModelModule: {},
ModuleRegistry: { registerModules: () => undefined },
setupAGGridModules: () => undefined,
defaultModules: [],
themeQuartz: {},
colorSchemeDark: {},
colorSchemeLight: {},
}));
// Imported after the mock is declared (jest.mock is hoisted above imports).
// eslint-disable-next-line import/first
import AgGridTableChart from '../src/AgGridTableChart';
// eslint-disable-next-line import/first
import transformProps from '../src/transformProps';
function renderChart() {
captured.props = undefined;
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...transformProps(testData.basic)}
setDataMask={jest.fn()}
slice_id={1}
/>
),
}),
);
}
test('interactive table selects the cell on click (text selection disabled) and wires a copy handler', async () => {
renderChart();
await waitFor(() => expect(captured.props).toBeDefined());
// #106389: clicking selects the cell, not its text.
expect(captured.props?.enableCellTextSelection).toBe(false);
// A key-down handler must be wired to restore copy-a-value.
expect(typeof captured.props?.onCellKeyDown).toBe('function');
});
test('the wired onCellKeyDown copies the focused cell value on Ctrl/Cmd+C', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
writable: true,
});
renderChart();
await waitFor(() => expect(captured.props?.onCellKeyDown).toBeDefined());
captured.props?.onCellKeyDown({
event: { key: 'c', metaKey: true },
value: 2871,
valueFormatted: '2,871',
});
expect(writeText).toHaveBeenCalledWith('2,871');
});

View File

@@ -0,0 +1,159 @@
/**
* 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 {
isCopyShortcut,
getCellCopyText,
writeTextToClipboard,
copyCellValueOnKeyDown,
} from '../../src/utils/copyCellValue';
const originalClipboard = navigator.clipboard;
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
configurable: true,
writable: true,
});
jest.restoreAllMocks();
});
function mockClipboard(): jest.Mock {
const writeText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
writable: true,
});
return writeText;
}
// --- isCopyShortcut -------------------------------------------------------
test('isCopyShortcut detects Ctrl+C and Cmd+C', () => {
expect(isCopyShortcut({ key: 'c', ctrlKey: true })).toBe(true);
expect(isCopyShortcut({ key: 'c', metaKey: true })).toBe(true);
expect(isCopyShortcut({ key: 'C', metaKey: true })).toBe(true); // capitalized
});
test('isCopyShortcut ignores other keys and bare C', () => {
expect(isCopyShortcut({ key: 'c' })).toBe(false); // no modifier
expect(isCopyShortcut({ key: 'v', ctrlKey: true })).toBe(false); // paste
expect(isCopyShortcut({ key: 'a', metaKey: true })).toBe(false); // select all
expect(isCopyShortcut(null)).toBe(false);
expect(isCopyShortcut(undefined)).toBe(false);
});
// --- getCellCopyText ------------------------------------------------------
test('getCellCopyText prefers the formatted value, falls back to raw', () => {
expect(getCellCopyText({ value: 2871, valueFormatted: '2.87k' })).toBe(
'2.87k',
);
expect(getCellCopyText({ value: 2871 })).toBe('2871');
expect(getCellCopyText({ value: 'Paris' })).toBe('Paris');
});
test('getCellCopyText copies empty string for null/undefined values', () => {
expect(getCellCopyText({ value: null })).toBe('');
expect(getCellCopyText({ value: undefined })).toBe('');
expect(getCellCopyText({})).toBe('');
expect(getCellCopyText(undefined)).toBe('');
// A formatted value of 0 / empty string must still be respected.
expect(getCellCopyText({ value: 5, valueFormatted: 0 })).toBe('0');
});
// --- writeTextToClipboard -------------------------------------------------
test('writeTextToClipboard uses the async Clipboard API when available', async () => {
const writeText = mockClipboard();
await expect(writeTextToClipboard('hello')).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith('hello');
});
test('writeTextToClipboard falls back to execCommand when Clipboard API is missing', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
writable: true,
});
const execCommand = jest.fn().mockReturnValue(true);
// jsdom does not implement execCommand.
(document as unknown as { execCommand: unknown }).execCommand = execCommand;
await expect(writeTextToClipboard('fallback')).resolves.toBe(true);
expect(execCommand).toHaveBeenCalledWith('copy');
});
test('writeTextToClipboard falls back to execCommand when the Clipboard API rejects', async () => {
// e.g. clipboard permission denied — must still copy via the legacy path.
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: jest.fn().mockRejectedValue(new Error('denied')) },
configurable: true,
writable: true,
});
const execCommand = jest.fn().mockReturnValue(true);
(document as unknown as { execCommand: unknown }).execCommand = execCommand;
await expect(writeTextToClipboard('rejected')).resolves.toBe(true);
expect(execCommand).toHaveBeenCalledWith('copy');
});
test('writeTextToClipboard returns false when both clipboard paths fail', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
writable: true,
});
(document as unknown as { execCommand: unknown }).execCommand = jest.fn(() => {
throw new Error('execCommand unsupported');
});
await expect(writeTextToClipboard('nope')).resolves.toBe(false);
});
// --- copyCellValueOnKeyDown (the onCellKeyDown handler) -------------------
test('copyCellValueOnKeyDown copies the cell value on Ctrl/Cmd+C', () => {
const writeText = mockClipboard();
const handled = copyCellValueOnKeyDown({
event: { key: 'c', metaKey: true },
value: 2871,
valueFormatted: '2,871',
});
expect(handled).toBe(true);
expect(writeText).toHaveBeenCalledWith('2,871');
});
test('copyCellValueOnKeyDown ignores non-copy keystrokes', () => {
const writeText = mockClipboard();
const handled = copyCellValueOnKeyDown({
event: { key: 'v', ctrlKey: true },
value: 'Paris',
});
expect(handled).toBe(false);
expect(writeText).not.toHaveBeenCalled();
});
test('copyCellValueOnKeyDown handles a missing event without throwing', () => {
const writeText = mockClipboard();
expect(copyCellValueOnKeyDown(undefined)).toBe(false);
expect(copyCellValueOnKeyDown({ value: 1 })).toBe(false);
expect(writeText).not.toHaveBeenCalled();
});

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']);
});