mirror of
https://github.com/apache/superset.git
synced 2026-07-04 05:45:32 +00:00
Compare commits
8 Commits
fix/105973
...
fix/106389
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03bf1c4e81 | ||
|
|
b78f4fbab6 | ||
|
|
a15070a5cc | ||
|
|
1c892ae463 | ||
|
|
0db6f4b162 | ||
|
|
2d3641dac7 | ||
|
|
8c09439099 | ||
|
|
bae3a1cb90 |
@@ -211,6 +211,7 @@ export type {
|
||||
GridState,
|
||||
GridReadyEvent,
|
||||
CellClickedEvent,
|
||||
CellKeyDownEvent,
|
||||
CellClassParams,
|
||||
IMenuActionParams,
|
||||
IHeaderParams,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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__';
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
Reference in New Issue
Block a user