| /** |
| * 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 '@testing-library/jest-dom'; |
| import { |
| getTextColorForBackground, |
| ObjectFormattingEnum, |
| } from '@superset-ui/chart-controls'; |
| import { supersetTheme } from '@apache-superset/core/theme'; |
| import { |
| render, |
| screen, |
| fireEvent, |
| waitFor, |
| within, |
| } from '@superset-ui/core/spec'; |
| import { cloneDeep } from 'lodash'; |
| import { |
| QueryMode, |
| TimeGranularity, |
| SMART_DATE_ID, |
| getTimeFormatterForGranularity, |
| } from '@superset-ui/core'; |
| import { CellProps, Column, HeaderProps } from 'react-table'; |
| import DataTable from '../src/DataTable/DataTable'; |
| import TableChart, { sanitizeHeaderId } from '../src/TableChart'; |
| import { GenericDataType } from '@apache-superset/core/common'; |
| import transformProps from '../src/transformProps'; |
| import DateWithFormatter from '../src/utils/DateWithFormatter'; |
| import testData from './testData'; |
| import { ProviderWrapper } from './testHelpers'; |
| |
| const expectValidAriaLabels = (container: HTMLElement) => { |
| const allCells = container.querySelectorAll('tbody td'); |
| const cellsWithLabels = container.querySelectorAll( |
| 'tbody td[aria-labelledby]', |
| ); |
| |
| // Table must render data cells (catch empty table regression) |
| expect(allCells.length).toBeGreaterThan(0); |
| |
| // ALL data cells must have aria-labelledby (no unlabeled cells) |
| expect(cellsWithLabels.length).toBe(allCells.length); |
| |
| // ALL aria-labelledby values should be valid |
| cellsWithLabels.forEach(cell => { |
| const labelledBy = cell.getAttribute('aria-labelledby'); |
| expect(labelledBy).not.toBeNull(); |
| expect(labelledBy).toEqual(expect.stringMatching(/\S/)); |
| const labelledByValue = labelledBy as string; |
| expect(labelledByValue).not.toMatch(/\s/); |
| expect(labelledByValue).not.toMatch(/[%#△]/); |
| const referencedHeader = container.querySelector( |
| `#${CSS.escape(labelledByValue)}`, |
| ); |
| expect(referencedHeader).toBeTruthy(); |
| }); |
| }; |
| |
| test('sanitizeHeaderId should sanitize percent sign', () => { |
| expect(sanitizeHeaderId('%pct_nice')).toBe('percentpct_nice'); |
| }); |
| |
| test('sanitizeHeaderId should sanitize hash/pound sign', () => { |
| expect(sanitizeHeaderId('# metric_1')).toBe('hash_metric_1'); |
| }); |
| |
| test('sanitizeHeaderId should sanitize delta symbol', () => { |
| expect(sanitizeHeaderId('△ delta')).toBe('delta_delta'); |
| }); |
| |
| test('sanitizeHeaderId should replace spaces with underscores', () => { |
| expect(sanitizeHeaderId('Main metric_1')).toBe('Main_metric_1'); |
| expect(sanitizeHeaderId('multiple spaces')).toBe('multiple_spaces'); |
| }); |
| |
| test('sanitizeHeaderId should handle multiple special characters', () => { |
| expect(sanitizeHeaderId('% #△ test')).toBe('percent_hashdelta_test'); |
| expect(sanitizeHeaderId('% # △ test')).toBe('percent_hash_delta_test'); |
| }); |
| |
| test('sanitizeHeaderId should preserve alphanumeric, underscore, and hyphen', () => { |
| expect(sanitizeHeaderId('valid-name_123')).toBe('valid-name_123'); |
| }); |
| |
| test('sanitizeHeaderId should replace other special characters with underscore', () => { |
| expect(sanitizeHeaderId('col@name!test')).toBe('col_name_test'); |
| expect(sanitizeHeaderId('test.column')).toBe('test_column'); |
| }); |
| |
| test('sanitizeHeaderId should handle edge cases', () => { |
| expect(sanitizeHeaderId('')).toBe(''); |
| expect(sanitizeHeaderId('simple')).toBe('simple'); |
| }); |
| |
| test('sanitizeHeaderId should collapse consecutive underscores', () => { |
| expect(sanitizeHeaderId('test @@ space')).toBe('test_space'); |
| expect(sanitizeHeaderId('col___name')).toBe('col_name'); |
| expect(sanitizeHeaderId('a b c')).toBe('a_b_c'); |
| expect(sanitizeHeaderId('test@@name')).toBe('test_name'); |
| }); |
| |
| test('sanitizeHeaderId should remove leading underscores', () => { |
| expect(sanitizeHeaderId('@col')).toBe('col'); |
| expect(sanitizeHeaderId('!revenue')).toBe('revenue'); |
| expect(sanitizeHeaderId('@@test')).toBe('test'); |
| expect(sanitizeHeaderId(' leading_spaces')).toBe('leading_spaces'); |
| }); |
| |
| test('sanitizeHeaderId should remove trailing underscores', () => { |
| expect(sanitizeHeaderId('col@')).toBe('col'); |
| expect(sanitizeHeaderId('revenue!')).toBe('revenue'); |
| expect(sanitizeHeaderId('test@@')).toBe('test'); |
| expect(sanitizeHeaderId('trailing_spaces ')).toBe('trailing_spaces'); |
| }); |
| |
| test('sanitizeHeaderId should remove leading and trailing underscores', () => { |
| expect(sanitizeHeaderId('@col@')).toBe('col'); |
| expect(sanitizeHeaderId('!test!')).toBe('test'); |
| expect(sanitizeHeaderId(' spaced ')).toBe('spaced'); |
| expect(sanitizeHeaderId('@@multiple@@')).toBe('multiple'); |
| }); |
| |
| test('sanitizeHeaderId should handle inputs with only special characters', () => { |
| expect(sanitizeHeaderId('@')).toBe(''); |
| expect(sanitizeHeaderId('@@')).toBe(''); |
| expect(sanitizeHeaderId(' ')).toBe(''); |
| expect(sanitizeHeaderId('!@$')).toBe(''); |
| expect(sanitizeHeaderId('!@#$')).toBe('hash'); // # is replaced with 'hash' (semantic replacement) |
| // Semantic replacements produce readable output even when alone |
| expect(sanitizeHeaderId('%')).toBe('percent'); |
| expect(sanitizeHeaderId('#')).toBe('hash'); |
| expect(sanitizeHeaderId('△')).toBe('delta'); |
| expect(sanitizeHeaderId('% # △')).toBe('percent_hash_delta'); |
| }); |
| |
| describe('plugin-chart-table', () => { |
| describe('transformProps', () => { |
| test('should parse pageLength to pageSize', () => { |
| expect(transformProps(testData.basic).pageSize).toBe(20); |
| expect( |
| transformProps({ |
| ...testData.basic, |
| rawFormData: { ...testData.basic.rawFormData, page_length: '20' }, |
| }).pageSize, |
| ).toBe(20); |
| expect( |
| transformProps({ |
| ...testData.basic, |
| rawFormData: { ...testData.basic.rawFormData, page_length: '' }, |
| }).pageSize, |
| ).toBe(0); |
| }); |
| |
| test('should memoize data records', () => { |
| expect(transformProps(testData.basic).data).toBe( |
| transformProps(testData.basic).data, |
| ); |
| }); |
| |
| test('should memoize columns meta', () => { |
| expect(transformProps(testData.basic).columns).toBe( |
| transformProps({ |
| ...testData.basic, |
| rawFormData: { ...testData.basic.rawFormData, pageLength: null }, |
| }).columns, |
| ); |
| }); |
| |
| test('should format timestamp', () => { |
| // eslint-disable-next-line no-underscore-dangle |
| const parsedDate = transformProps(testData.basic).data[0] |
| .__timestamp as DateWithFormatter; |
| expect(String(parsedDate)).toBe('2020-01-01 12:34:56'); |
| expect(parsedDate.getTime()).toBe(1577882096000); |
| }); |
| test('should process comparison columns when time_compare and comparison_type are set', () => { |
| const transformedProps = transformProps(testData.comparison); |
| const comparisonColumns = transformedProps.columns.filter( |
| col => |
| col.originalLabel === 'metric_1' || |
| col.originalLabel === 'metric_2' || |
| col.label === '#' || |
| col.label === '△' || |
| col.label === '%', |
| ); |
| expect(comparisonColumns.length).toBeGreaterThan(0); |
| expect( |
| comparisonColumns.some(col => col.originalLabel === 'metric_1'), |
| ).toBe(true); |
| expect( |
| comparisonColumns.some(col => col.originalLabel === 'metric_2'), |
| ).toBe(true); |
| expect(comparisonColumns.some(col => col.label === '#')).toBe(true); |
| expect(comparisonColumns.some(col => col.label === '△')).toBe(true); |
| expect(comparisonColumns.some(col => col.label === '%')).toBe(true); |
| }); |
| |
| test('should not process comparison columns when time_compare is empty', () => { |
| const propsWithoutTimeCompare = { |
| ...testData.comparison, |
| rawFormData: { |
| ...testData.comparison.rawFormData, |
| time_compare: [], |
| }, |
| }; |
| |
| const transformedProps = transformProps(propsWithoutTimeCompare); |
| |
| // Check if comparison columns are not processed |
| const comparisonColumns = transformedProps.columns.filter( |
| col => |
| col.label === 'Main' || |
| col.label === '#' || |
| col.label === '△' || |
| col.label === '%', |
| ); |
| |
| expect(comparisonColumns.length).toBe(0); |
| }); |
| |
| test('should correctly apply column configuration for comparison columns', () => { |
| const transformedProps = transformProps(testData.comparisonWithConfig); |
| |
| const comparisonColumns = transformedProps.columns.filter( |
| col => |
| col.key.startsWith('Main') || |
| col.key.startsWith('#') || |
| col.key.startsWith('△') || |
| col.key.startsWith('%'), |
| ); |
| |
| expect(comparisonColumns).toHaveLength(4); |
| |
| const mainMetricConfig = comparisonColumns.find( |
| col => col.key === 'Main metric_1', |
| ); |
| expect(mainMetricConfig).toBeDefined(); |
| expect(mainMetricConfig?.config).toEqual({ d3NumberFormat: '.2f' }); |
| |
| const hashMetricConfig = comparisonColumns.find( |
| col => col.key === '# metric_1', |
| ); |
| expect(hashMetricConfig).toBeDefined(); |
| expect(hashMetricConfig?.config).toEqual({ d3NumberFormat: '.1f' }); |
| |
| const deltaMetricConfig = comparisonColumns.find( |
| col => col.key === '△ metric_1', |
| ); |
| expect(deltaMetricConfig).toBeDefined(); |
| expect(deltaMetricConfig?.config).toEqual({ d3NumberFormat: '.0f' }); |
| |
| const percentMetricConfig = comparisonColumns.find( |
| col => col.key === '% metric_1', |
| ); |
| expect(percentMetricConfig).toBeDefined(); |
| expect(percentMetricConfig?.config).toEqual({ d3NumberFormat: '.3f' }); |
| }); |
| |
| test('should correctly format comparison columns using getComparisonColFormatter', () => { |
| const transformedProps = transformProps(testData.comparisonWithConfig); |
| const comparisonColumns = transformedProps.columns.filter( |
| col => |
| col.key.startsWith('Main') || |
| col.key.startsWith('#') || |
| col.key.startsWith('△') || |
| col.key.startsWith('%'), |
| ); |
| |
| const formattedMainMetric = comparisonColumns |
| .find(col => col.key === 'Main metric_1') |
| ?.formatter?.(12345.678); |
| expect(formattedMainMetric).toBe('12345.68'); |
| |
| const formattedHashMetric = comparisonColumns |
| .find(col => col.key === '# metric_1') |
| ?.formatter?.(12345.678); |
| expect(formattedHashMetric).toBe('12345.7'); |
| |
| const formattedDeltaMetric = comparisonColumns |
| .find(col => col.key === '△ metric_1') |
| ?.formatter?.(12345.678); |
| expect(formattedDeltaMetric).toBe('12346'); |
| |
| const formattedPercentMetric = comparisonColumns |
| .find(col => col.key === '% metric_1') |
| ?.formatter?.(0.123456); |
| expect(formattedPercentMetric).toBe('0.123'); |
| }); |
| |
| test('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => { |
| const transformedProps = transformProps(testData.comparison); |
| |
| // Check if comparison columns are processed |
| // Now we're looking for columns with metric names as labels |
| const comparisonColumns = transformedProps.columns.filter( |
| col => |
| col.originalLabel === 'metric_1' || |
| col.originalLabel === 'metric_2' || |
| col.label === '#' || |
| col.label === '△' || |
| col.label === '%', |
| ); |
| |
| expect(comparisonColumns.length).toBeGreaterThan(0); |
| expect( |
| comparisonColumns.some(col => col.originalLabel === 'metric_1'), |
| ).toBe(true); |
| expect( |
| comparisonColumns.some(col => col.originalLabel === 'metric_2'), |
| ).toBe(true); |
| expect(comparisonColumns.some(col => col.label === '#')).toBe(true); |
| expect(comparisonColumns.some(col => col.label === '△')).toBe(true); |
| expect(comparisonColumns.some(col => col.label === '%')).toBe(true); |
| // Verify originalLabel for metric_1 comparison columns |
| const metric1Column = transformedProps.columns.find( |
| col => |
| col.originalLabel === 'metric_1' && |
| !col.key.startsWith('#') && |
| !col.key.startsWith('△') && |
| !col.key.startsWith('%'), |
| ); |
| expect(metric1Column).toBeDefined(); |
| expect(metric1Column?.originalLabel).toBe('metric_1'); |
| expect(metric1Column?.label).toBe('Main'); |
| |
| const hashMetric1 = transformedProps.columns.find( |
| col => col.key === '# metric_1', |
| ); |
| expect(hashMetric1).toBeDefined(); |
| expect(hashMetric1?.originalLabel).toBe('metric_1'); |
| |
| const deltaMetric1 = transformedProps.columns.find( |
| col => col.key === '△ metric_1', |
| ); |
| expect(deltaMetric1).toBeDefined(); |
| expect(deltaMetric1?.originalLabel).toBe('metric_1'); |
| |
| const percentMetric1 = transformedProps.columns.find( |
| col => col.key === '% metric_1', |
| ); |
| expect(percentMetric1).toBeDefined(); |
| expect(percentMetric1?.originalLabel).toBe('metric_1'); |
| |
| // Verify originalLabel for metric_2 comparison columns |
| const metric2Column = transformedProps.columns.find( |
| col => |
| col.originalLabel === 'metric_2' && |
| !col.key.startsWith('#') && |
| !col.key.startsWith('△') && |
| !col.key.startsWith('%'), |
| ); |
| expect(metric2Column).toBeDefined(); |
| expect(metric2Column?.originalLabel).toBe('metric_2'); |
| |
| expect(metric2Column?.label).toBe('Main'); |
| |
| const hashMetric2 = transformedProps.columns.find( |
| col => col.key === '# metric_2', |
| ); |
| expect(hashMetric2).toBeDefined(); |
| expect(hashMetric2?.originalLabel).toBe('metric_2'); |
| |
| const deltaMetric2 = transformedProps.columns.find( |
| col => col.key === '△ metric_2', |
| ); |
| expect(deltaMetric2).toBeDefined(); |
| expect(deltaMetric2?.originalLabel).toBe('metric_2'); |
| |
| const percentMetric2 = transformedProps.columns.find( |
| col => col.key === '% metric_2', |
| ); |
| expect(percentMetric2).toBeDefined(); |
| expect(percentMetric2?.originalLabel).toBe('metric_2'); |
| }); |
| |
| test('should not apply time grain formatting in Raw Records mode', () => { |
| const rawRecordsProps = { |
| ...testData.basic, |
| rawFormData: { |
| ...testData.basic.rawFormData, |
| query_mode: QueryMode.Raw, |
| time_grain_sqla: TimeGranularity.MONTH, |
| table_timestamp_format: SMART_DATE_ID, |
| }, |
| }; |
| |
| const transformedProps = transformProps(rawRecordsProps); |
| const timestampColumn = transformedProps.columns.find( |
| col => col.key === '__timestamp', |
| ); |
| |
| expect(timestampColumn).toBeDefined(); |
| const testValue = new Date('2023-01-15T10:30:45'); |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| const formatted = (timestampColumn?.formatter as any)?.(testValue); |
| const granularityFormatted = getTimeFormatterForGranularity( |
| TimeGranularity.MONTH, |
| )(testValue as number | Date | null); |
| expect(formatted).not.toBe(granularityFormatted); |
| expect(typeof formatted).toBe('string'); |
| expect(formatted).toContain('2023'); |
| }); |
| |
| test('should handle null/undefined timestamp values correctly', () => { |
| const rawRecordsProps = { |
| ...testData.basic, |
| rawFormData: { |
| ...testData.basic.rawFormData, |
| query_mode: QueryMode.Raw, |
| }, |
| }; |
| |
| const transformedProps = transformProps(rawRecordsProps); |
| expect(transformedProps.isRawRecords).toBe(true); |
| |
| const timestampColumn = transformedProps.columns.find( |
| col => col.key === '__timestamp', |
| ); |
| expect(timestampColumn).toBeDefined(); |
| }); |
| |
| describe('TableChart', () => { |
| test('render basic data', () => { |
| render( |
| <TableChart {...transformProps(testData.basic)} sticky={false} />, |
| ); |
| |
| const firstDataRow = screen.getAllByRole('rowgroup')[1]; |
| const cells = firstDataRow.querySelectorAll('td'); |
| expect(cells).toHaveLength(12); |
| // Date is rendered as ISO string format |
| expect(cells[0]).toHaveTextContent('2020-01-01T12:34:56'); |
| expect(cells[1]).toHaveTextContent('Michael'); |
| // number is not in `metrics` list, so it should output raw value |
| // (in real world Superset, this would mean the column is used in GROUP BY) |
| expect(cells[2]).toHaveTextContent('2467063'); |
| // should not render column with `.` in name as `undefined` |
| expect(cells[3]).toHaveTextContent('foo'); |
| expect(cells[6]).toHaveTextContent('2467'); |
| expect(cells[8]).toHaveTextContent('N/A'); |
| }); |
| |
| test('render advanced data', () => { |
| render( |
| <TableChart {...transformProps(testData.advanced)} sticky={false} />, |
| ); |
| const secondColumnHeader = screen.getByText('Sum of Num'); |
| expect(secondColumnHeader).toBeInTheDocument(); |
| expect(secondColumnHeader?.getAttribute('data-column-name')).toEqual( |
| '1', |
| ); |
| |
| const firstDataRow = screen.getAllByRole('rowgroup')[1]; |
| const cells = firstDataRow.querySelectorAll('td'); |
| expect(cells[0]).toHaveTextContent('Michael'); |
| expect(cells[2]).toHaveTextContent('12.346%'); |
| expect(cells[4]).toHaveTextContent('2.47k'); |
| }); |
| |
| test('render advanced data with currencies', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps(testData.advancedWithCurrency)} |
| sticky={false} |
| /> |
| ), |
| }), |
| ); |
| const cells = document.querySelectorAll('td'); |
| expect(document.querySelectorAll('th')[1]).toHaveTextContent( |
| 'Sum of Num', |
| ); |
| expect(cells[0]).toHaveTextContent('Michael'); |
| expect(cells[2]).toHaveTextContent('12.346%'); |
| expect(cells[4]).toHaveTextContent('$ 2.47k'); |
| }); |
| |
| test('render data with a bigint value in a raw record mode', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps(testData.bigint)} |
| sticky={false} |
| isRawRecords |
| /> |
| ), |
| }), |
| ); |
| const cells = document.querySelectorAll('td'); |
| expect(document.querySelectorAll('th')[0]).toHaveTextContent('name'); |
| expect(document.querySelectorAll('th')[1]).toHaveTextContent('id'); |
| expect(cells[0]).toHaveTextContent('Michael'); |
| expect(cells[1]).toHaveTextContent('4312'); |
| expect(cells[2]).toHaveTextContent('John'); |
| expect(cells[3]).toHaveTextContent('1234567890123456789'); |
| }); |
| |
| test('render raw data', () => { |
| const props = transformProps({ |
| ...testData.raw, |
| rawFormData: { ...testData.raw.rawFormData }, |
| }); |
| render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| const cells = document.querySelectorAll('td'); |
| expect(document.querySelectorAll('th')[0]).toHaveTextContent('num'); |
| expect(cells[0]).toHaveTextContent('1234'); |
| expect(cells[1]).toHaveTextContent('10000'); |
| expect(cells[1]).toHaveTextContent('0'); |
| }); |
| |
| test('render raw data with currencies', () => { |
| const props = transformProps({ |
| ...testData.raw, |
| rawFormData: { |
| ...testData.raw.rawFormData, |
| column_config: { |
| num: { |
| currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, |
| }, |
| }, |
| }, |
| }); |
| render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| const cells = document.querySelectorAll('td'); |
| |
| expect(document.querySelectorAll('th')[0]).toHaveTextContent('num'); |
| expect(cells[0]).toHaveTextContent('$ 1.23k'); |
| expect(cells[1]).toHaveTextContent('$ 10k'); |
| expect(cells[2]).toHaveTextContent('$ 0'); |
| }); |
| |
| test('render small formatted data with currencies', () => { |
| const props = transformProps({ |
| ...testData.raw, |
| rawFormData: { |
| ...testData.raw.rawFormData, |
| column_config: { |
| num: { |
| d3SmallNumberFormat: '.2r', |
| currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, |
| }, |
| }, |
| }, |
| queriesData: [ |
| { |
| ...testData.raw.queriesData[0], |
| data: [ |
| { |
| num: 1234, |
| }, |
| { |
| num: 0.5, |
| }, |
| { |
| num: 0.61234, |
| }, |
| ], |
| }, |
| ], |
| }); |
| render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| const cells = document.querySelectorAll('td'); |
| |
| expect(document.querySelectorAll('th')[0]).toHaveTextContent('num'); |
| expect(cells[0]).toHaveTextContent('$ 1.23k'); |
| expect(cells[1]).toHaveTextContent('$ 0.50'); |
| expect(cells[2]).toHaveTextContent('$ 0.61'); |
| }); |
| |
| test('render empty data', () => { |
| render( |
| <TableChart {...transformProps(testData.empty)} sticky={false} />, |
| ); |
| expect(screen.getByText('No records found')).toBeInTheDocument(); |
| }); |
| |
| test('render color with column color formatter', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render cell without color', () => { |
| const dataWithEmptyCell = cloneDeep(testData.advanced.queriesData[0]); |
| dataWithEmptyCell.data.push({ |
| __timestamp: null, |
| name: 'Noah', |
| sum__num: null, |
| '%pct_nice': 0.643, |
| 'abc.com': 'bazzinga', |
| }); |
| |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| queriesData: [dataWithEmptyCell], |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '<', |
| targetValue: 12342, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467')).background).toBe( |
| 'rgba(172, 225, 196, 0.81)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| expect(getComputedStyle(screen.getByText('N/A')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('preserves muted null styling when no formatter resolves text color', () => { |
| const dataWithEmptyCell = cloneDeep(testData.advanced.queriesData[0]); |
| dataWithEmptyCell.data.push({ |
| __timestamp: null, |
| name: 'Noah', |
| sum__num: null, |
| '%pct_nice': 0.643, |
| 'abc.com': 'bazzinga', |
| }); |
| |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| queriesData: [dataWithEmptyCell], |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '<', |
| targetValue: 12342, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| const noahRow = screen.getByText('Noah').closest('tr'); |
| expect(noahRow).not.toBeNull(); |
| |
| const nullCell = noahRow?.querySelector('td.dt-is-null'); |
| expect(nullCell).not.toBeNull(); |
| expect((nullCell as HTMLElement).style.color).toBe(''); |
| expect(getComputedStyle(nullCell as Element).color).toBe( |
| 'rgba(0, 0, 0, 0.45)', |
| ); |
| }); |
| |
| test('should display original label in grouped headers', () => { |
| const props = transformProps(testData.comparison); |
| |
| render(<TableChart {...props} sticky={false} />); |
| const groupHeaders = screen.getAllByRole('columnheader'); |
| expect(groupHeaders.length).toBeGreaterThan(0); |
| const hasMetricHeaders = groupHeaders.some( |
| header => |
| header.textContent && |
| (header.textContent.includes('metric') || |
| header.textContent.includes('Metric')), |
| ); |
| expect(hasMetricHeaders).toBe(true); |
| }); |
| |
| test('should set meaningful header IDs for time-comparison columns', () => { |
| // Test time-comparison columns have proper IDs |
| // Uses originalLabel (e.g., "metric_1") which is sanitized for CSS safety |
| const props = transformProps(testData.comparison); |
| |
| render(<TableChart {...props} sticky={false} />); |
| |
| const headers = screen.getAllByRole('columnheader'); |
| |
| // All headers should have IDs |
| const headersWithIds = headers.filter(header => header.id); |
| expect(headersWithIds.length).toBeGreaterThan(0); |
| |
| // None should have "header-undefined" |
| const undefinedHeaders = headersWithIds.filter(header => |
| header.id.includes('undefined'), |
| ); |
| expect(undefinedHeaders).toHaveLength(0); |
| |
| // Should have IDs based on sanitized originalLabel (e.g., "metric_1") |
| const hasMetricHeaders = headersWithIds.some( |
| header => |
| header.id.includes('metric_1') || header.id.includes('metric_2'), |
| ); |
| expect(hasMetricHeaders).toBe(true); |
| |
| // CRITICAL: Verify sanitization - no spaces or special chars in any header ID |
| headersWithIds.forEach(header => { |
| // IDs must not contain spaces (would break CSS selectors and ARIA) |
| expect(header.id).not.toMatch(/\s/); |
| // IDs must not contain special chars like %, #, △ |
| expect(header.id).not.toMatch(/[%#△]/); |
| // IDs should only contain valid characters: alphanumeric, underscore, hyphen |
| expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/); |
| }); |
| }); |
| |
| test('should validate ARIA references for time-comparison table cells', () => { |
| // Test that ALL cells with aria-labelledby have valid references |
| // This is critical for screen reader accessibility |
| const props = transformProps(testData.comparison); |
| |
| const { container } = render(<TableChart {...props} sticky={false} />); |
| |
| expectValidAriaLabels(container); |
| }); |
| |
| test('should align group headers correctly when some comparison columns are hidden (#37074)', () => { |
| // Test that group headers align correctly when columns have visible: false |
| // This reproduces issue #37074 where headers became misaligned |
| const props = transformProps(testData.comparisonWithHiddenColumns); |
| |
| const { container } = render(<TableChart {...props} sticky={false} />); |
| |
| // Get all header rows - first row contains group headers, second row contains column headers |
| const headerRows = container.querySelectorAll('thead tr'); |
| expect(headerRows.length).toBe(2); |
| |
| // Get group headers from the first row (th elements with colSpan > 1 or group headers) |
| const groupHeaderRow = headerRows[0]; |
| const groupHeaders = groupHeaderRow.querySelectorAll('th'); |
| |
| // Extract group header text content (filter out empty placeholder headers) |
| const groupHeaderTexts = Array.from(groupHeaders) |
| .map(th => th.textContent?.trim()) |
| .filter(text => text && text.length > 0); |
| |
| // Verify metric_1 group header appears before metric_2 |
| // With hidden columns: metric_1 has 2 visible columns (△, %), metric_2 has 4 (Main, #, △, %) |
| const metric1Index = groupHeaderTexts.findIndex( |
| text => text?.includes('metric_1') || text?.includes('Metric 1'), |
| ); |
| const metric2Index = groupHeaderTexts.findIndex( |
| text => text?.includes('metric_2') || text?.includes('Metric 2'), |
| ); |
| |
| // Both headers should exist and metric_1 should come before metric_2 |
| expect(metric1Index).toBeGreaterThanOrEqual(0); |
| expect(metric2Index).toBeGreaterThanOrEqual(0); |
| expect(metric1Index).toBeLessThan(metric2Index); |
| |
| // Verify colSpan values match the number of visible columns |
| const metric1Header = Array.from(groupHeaders).find( |
| th => |
| th.textContent?.includes('metric_1') || |
| th.textContent?.includes('Metric 1'), |
| ); |
| const metric2Header = Array.from(groupHeaders).find( |
| th => |
| th.textContent?.includes('metric_2') || |
| th.textContent?.includes('Metric 2'), |
| ); |
| |
| // metric_1 should span 2 columns (△ and % are visible, Main and # are hidden) |
| expect(metric1Header?.getAttribute('colspan')).toBe('2'); |
| // metric_2 should span 4 columns (all visible) |
| expect(metric2Header?.getAttribute('colspan')).toBe('4'); |
| |
| // Verify ARIA labels are still valid after filtering |
| expectValidAriaLabels(container); |
| }); |
| |
| test('should set meaningful header IDs for regular table columns', () => { |
| // Test regular (non-time-comparison) columns have proper IDs |
| // Uses fallback to column.key since originalLabel is undefined |
| const props = transformProps(testData.advanced); |
| |
| const { container } = render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| |
| const headers = screen.getAllByRole('columnheader'); |
| |
| // Test 1: "name" column (regular string column) |
| const nameHeader = headers.find(header => |
| header.textContent?.includes('name'), |
| ); |
| expect(nameHeader).toBeDefined(); |
| expect(nameHeader?.id).toBe('header-name'); // Falls back to column.key |
| |
| // Verify cells reference this header correctly |
| const nameCells = container.querySelectorAll( |
| 'td[aria-labelledby="header-name"]', |
| ); |
| expect(nameCells.length).toBeGreaterThan(0); |
| |
| // Test 2: "sum__num" column (metric with verbose map "Sum of Num") |
| const sumHeader = headers.find(header => |
| header.textContent?.includes('Sum of Num'), |
| ); |
| expect(sumHeader).toBeDefined(); |
| expect(sumHeader?.id).toBe('header-sum_num'); // Falls back to column.key, consecutive underscores collapsed |
| |
| // Verify cells reference this header correctly |
| const sumCells = container.querySelectorAll( |
| 'td[aria-labelledby="header-sum_num"]', |
| ); |
| expect(sumCells.length).toBeGreaterThan(0); |
| |
| // Test 3: Verify NO headers have "undefined" in their ID |
| const undefinedHeaders = headers.filter(header => |
| header.id?.includes('undefined'), |
| ); |
| expect(undefinedHeaders).toHaveLength(0); |
| |
| // Test 4: Verify ALL headers have proper IDs (no missing IDs) |
| const headersWithIds = headers.filter(header => header.id); |
| expect(headersWithIds.length).toBe(headers.length); |
| |
| // Test 5: Verify ALL header IDs are properly sanitized |
| headersWithIds.forEach(header => { |
| // IDs must not contain spaces |
| expect(header.id).not.toMatch(/\s/); |
| // IDs must not contain special chars like % (from %pct_nice column) |
| expect(header.id).not.toMatch(/[%#△]/); |
| // IDs should only contain valid CSS selector characters |
| expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/); |
| }); |
| }); |
| |
| test('should validate ARIA references for regular table cells', () => { |
| // Test that ALL cells with aria-labelledby have valid references |
| // This is critical for screen reader accessibility |
| const props = transformProps(testData.advanced); |
| |
| const { container } = render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| |
| expectValidAriaLabels(container); |
| }); |
| |
| test('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => { |
| const props = transformProps({ |
| ...testData.raw, |
| rawFormData: { ...testData.raw.rawFormData }, |
| }); |
| |
| props.columns[0].isMetric = true; |
| |
| render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| let cells = document.querySelectorAll('div.cell-bar'); |
| cells.forEach(cell => { |
| expect(cell).toHaveClass('positive'); |
| }); |
| props.columns[0].isMetric = false; |
| props.columns[0].isPercentMetric = true; |
| |
| render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| cells = document.querySelectorAll('div.cell-bar'); |
| cells.forEach(cell => { |
| expect(cell).toHaveClass('positive'); |
| }); |
| |
| props.showCellBars = false; |
| |
| render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| cells = document.querySelectorAll('td'); |
| |
| props.columns[0].isPercentMetric = false; |
| props.columns[0].isMetric = true; |
| |
| render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| cells = document.querySelectorAll('td'); |
| }); |
| |
| test('render cell bars even when column contains NULL values', () => { |
| const props = transformProps({ |
| ...testData.raw, |
| queriesData: [ |
| { |
| ...testData.raw.queriesData[0], |
| colnames: ['category', 'value1', 'value2', 'value3', 'value4'], |
| coltypes: [ |
| GenericDataType.String, |
| GenericDataType.Numeric, |
| GenericDataType.Numeric, |
| GenericDataType.Numeric, |
| GenericDataType.Numeric, |
| ], |
| data: [ |
| { |
| category: 'Category A', |
| value1: 10, |
| value2: 20, |
| value3: 30, |
| value4: null, |
| }, |
| { |
| category: 'Category B', |
| value1: 15, |
| value2: 25, |
| value3: 35, |
| value4: 100, |
| }, |
| { |
| category: 'Category C', |
| value1: 18, |
| value2: 28, |
| value3: 38, |
| value4: null, |
| }, |
| ], |
| }, |
| ], |
| rawFormData: { |
| ...testData.raw.rawFormData, |
| show_cell_bars: true, |
| metrics: ['value1', 'value2', 'value3', 'value4'], |
| }, |
| }); |
| |
| const { container } = render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| |
| // Get all cell bars - should exist for both columns with and without NULL values |
| const cellBars = container.querySelectorAll('div.cell-bar'); |
| |
| // Should have cell bars in all numeric columns, even those with NULL values |
| // value1, value2, value3 all have 3 values, value4 has 1 non-NULL value |
| // Total: 3 + 3 + 3 + 1 = 10 cell bars |
| expect(cellBars.length).toBeGreaterThan(0); |
| |
| // Specifically check that value4 column (which has NULLs) still renders bars for non-NULL cells |
| const rows = container.querySelectorAll('tbody tr'); |
| expect(rows.length).toBe(3); |
| |
| // Row 2 should have a cell bar in value4 column (value: 100) |
| const row2Cells = rows[1].querySelectorAll('td'); |
| const value4Cell = row2Cells[4]; // 5th column (0-indexed) |
| const value4Bar = value4Cell.querySelector('div.cell-bar'); |
| expect(value4Bar).toBeTruthy(); |
| }); |
| |
| test('render color with string column color formatter(operator begins with)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'name', |
| operator: 'begins with', |
| targetValue: 'J', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| expect(getComputedStyle(screen.getByText('Joe')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('Michael')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with string column color formatter (operator ends with)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'name', |
| operator: 'ends with', |
| targetValue: 'ia', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| expect(getComputedStyle(screen.getByText('Maria')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('Joe')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with string column color formatter (operator containing)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'name', |
| operator: 'containing', |
| targetValue: 'c', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| expect(getComputedStyle(screen.getByText('Michael')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('Joe')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with string column color formatter (operator not containing)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'name', |
| operator: 'not containing', |
| targetValue: 'i', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| expect(getComputedStyle(screen.getByText('Joe')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('Michael')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with string column color formatter (operator =)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'name', |
| operator: '=', |
| targetValue: 'Joe', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| expect(getComputedStyle(screen.getByText('Joe')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('Michael')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with string column color formatter (operator None)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'name', |
| operator: 'None', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| expect(getComputedStyle(screen.getByText('Joe')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('Michael')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('Maria')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| }); |
| |
| test('render color with boolean column color formatter (operator is true)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.nameAndBoolean, |
| rawFormData: { |
| ...testData.nameAndBoolean.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'is_adult', |
| operator: 'is true', |
| targetValue: '', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| expect(getComputedStyle(screen.getByText('true')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('false')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with boolean column color formatter (operator is false)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.nameAndBoolean, |
| rawFormData: { |
| ...testData.nameAndBoolean.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'is_adult', |
| operator: 'is false', |
| targetValue: '', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| expect(getComputedStyle(screen.getByText('false')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('true')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with boolean column color formatter (operator is null)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.nameAndBoolean, |
| rawFormData: { |
| ...testData.nameAndBoolean.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'is_adult', |
| operator: 'is null', |
| targetValue: '', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| expect(getComputedStyle(screen.getByText('N/A')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('true')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| expect(getComputedStyle(screen.getByText('false')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with boolean column color formatter (operator is not null)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.nameAndBoolean, |
| rawFormData: { |
| ...testData.nameAndBoolean.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'is_adult', |
| operator: 'is not null', |
| targetValue: '', |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| const trueElements = screen.getAllByText('true'); |
| const falseElements = screen.getAllByText('false'); |
| expect(getComputedStyle(trueElements[0]).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(falseElements[0]).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByText('N/A')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with column color formatter to entire row', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| columnFormatting: ObjectFormattingEnum.ENTIRE_ROW, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| expect(getComputedStyle(screen.getByText('Michael')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('0.123456')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| }); |
| |
| test('display text color using column color formatter', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| objectFormatting: ObjectFormattingEnum.TEXT_COLOR, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect((screen.getByTitle('2467') as HTMLElement).style.color).toBe(''); |
| }); |
| |
| test('display text color using column color formatter for entire row', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| columnFormatting: ObjectFormattingEnum.ENTIRE_ROW, |
| objectFormatting: ObjectFormattingEnum.TEXT_COLOR, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| expect(getComputedStyle(screen.getByText('Michael')).color).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('0.123456')).color).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| }); |
| |
| test('derive readable text color from dark background formatting', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#111111', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| useGradient: false, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgb(17, 17, 17)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe( |
| 'rgb(255, 255, 255)', |
| ); |
| }); |
| |
| test('keep explicit text color over adaptive contrast', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#111111', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| useGradient: false, |
| }, |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| objectFormatting: ObjectFormattingEnum.TEXT_COLOR, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgb(17, 17, 17)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| }); |
| |
| test('support legacy toTextColor formatters', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#111111', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| useGradient: false, |
| }, |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| toTextColor: true, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgb(17, 17, 17)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| }); |
| |
| test('use striped row surface when deriving adaptive text color', () => { |
| const backgroundColor = Array.from( |
| { length: 0xff }, |
| (_, index) => `#000000${(index + 1).toString(16).padStart(2, '0')}`, |
| ).find(candidate => { |
| const baseColor = getTextColorForBackground( |
| { backgroundColor: candidate }, |
| supersetTheme.colorBgBase, |
| ); |
| const layoutColor = getTextColorForBackground( |
| { backgroundColor: candidate }, |
| supersetTheme.colorBgLayout, |
| ); |
| return baseColor !== layoutColor; |
| }); |
| |
| expect(backgroundColor).toBeDefined(); |
| |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: backgroundColor, |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2000, |
| useGradient: false, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe( |
| getTextColorForBackground( |
| { backgroundColor }, |
| supersetTheme.colorBgLayout, |
| ), |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467')).color).toBe( |
| getTextColorForBackground( |
| { backgroundColor }, |
| supersetTheme.colorBgBase, |
| ), |
| ); |
| }); |
| |
| test('render color with useGradient false returns solid color', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| useGradient: false, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| // When useGradient is false, should return solid color (no opacity variation) |
| // The color should be the same for all matching values |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with useGradient true returns gradient color', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| useGradient: true, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| // When useGradient is true, should return gradient color with opacity |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with useGradient undefined defaults to gradient (backward compatibility)', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: '>', |
| targetValue: 2467, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| // When useGradient is undefined, should default to gradient for backward compatibility |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467')).background).toBe( |
| 'rgba(0, 0, 0, 0)', |
| ); |
| }); |
| |
| test('render color with useGradient false and None operator returns solid color', () => { |
| render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart |
| {...transformProps({ |
| ...testData.advanced, |
| rawFormData: { |
| ...testData.advanced.rawFormData, |
| conditional_formatting: [ |
| { |
| colorScheme: '#ACE1C4', |
| column: 'sum__num', |
| operator: 'None', |
| useGradient: false, |
| }, |
| ], |
| }, |
| })} |
| /> |
| ), |
| }), |
| ); |
| |
| // When useGradient is false with None operator, all values should have solid color |
| expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| expect(getComputedStyle(screen.getByTitle('2467')).background).toBe( |
| 'rgb(172, 225, 196)', |
| ); |
| }); |
| |
| test('clicking a cell emits cross-filter, clicking again clears it', () => { |
| const setDataMask = jest.fn(); |
| const props = transformProps({ |
| ...testData.basic, |
| hooks: { setDataMask }, |
| emitCrossFilters: true, |
| }); |
| const { rerender } = render( |
| <ProviderWrapper> |
| <TableChart |
| {...props} |
| emitCrossFilters |
| setDataMask={setDataMask} |
| sticky={false} |
| /> |
| </ProviderWrapper>, |
| ); |
| |
| // Click a string cell to apply cross-filter |
| const nameCell = screen.getByText('Michael'); |
| fireEvent.click(nameCell); |
| |
| // Find the cross-filter call (not the ownState call) |
| const crossFilterCall = setDataMask.mock.calls.find( |
| (call: any[]) => call[0]?.filterState?.filters, |
| ); |
| expect(crossFilterCall).toBeDefined(); |
| const firstCallArg = crossFilterCall![0]; |
| // Should set the filter |
| expect(firstCallArg.filterState.filters).toEqual({ |
| name: ['Michael'], |
| }); |
| expect(firstCallArg.extraFormData.filters).toEqual( |
| expect.arrayContaining([ |
| expect.objectContaining({ |
| col: 'name', |
| op: 'IN', |
| val: ['Michael'], |
| }), |
| ]), |
| ); |
| |
| // Now simulate Redux updating the filters prop (as would happen in dashboard) |
| setDataMask.mockClear(); |
| rerender( |
| <ProviderWrapper> |
| <TableChart |
| {...props} |
| emitCrossFilters |
| setDataMask={setDataMask} |
| filters={{ name: ['Michael'] }} |
| sticky={false} |
| /> |
| </ProviderWrapper>, |
| ); |
| |
| // The cell should now have the active filter class |
| const activeCells = document.querySelectorAll('.dt-is-active-filter'); |
| expect(activeCells.length).toBeGreaterThan(0); |
| |
| // Click same cell again to clear cross-filter |
| setDataMask.mockClear(); |
| const sameCellAgain = screen.getByText('Michael'); |
| fireEvent.click(sameCellAgain); |
| |
| // Find the cross-filter clearing call |
| const clearCall = setDataMask.mock.calls.find( |
| (call: any[]) => call[0]?.filterState !== undefined, |
| ); |
| expect(clearCall).toBeDefined(); |
| const secondCallArg = clearCall![0]; |
| // Should clear the filter |
| expect(secondCallArg.filterState.filters).toBeNull(); |
| expect(secondCallArg.extraFormData.filters).toEqual([]); |
| }); |
| |
| test('cross-filter toggle works with DateWithFormatter values', () => { |
| const setDataMask = jest.fn(); |
| const props = transformProps({ |
| ...testData.basic, |
| hooks: { setDataMask }, |
| emitCrossFilters: true, |
| }); |
| |
| // The data has a __timestamp column with DateWithFormatter values |
| // after processDataRecords. Let's verify this. |
| const timestampVal = props.data[0].__timestamp; |
| expect(timestampVal).toBeInstanceOf(DateWithFormatter); |
| |
| const { rerender } = render( |
| <ProviderWrapper> |
| <TableChart |
| {...props} |
| emitCrossFilters |
| setDataMask={setDataMask} |
| sticky={false} |
| /> |
| </ProviderWrapper>, |
| ); |
| |
| // Click a timestamp cell - find it by text content |
| const timestampCell = screen.getByText('2020-01-01 12:34:56'); |
| fireEvent.click(timestampCell); |
| |
| const crossFilterCall = setDataMask.mock.calls.find( |
| (call: any[]) => call[0]?.filterState?.filters, |
| ); |
| expect(crossFilterCall).toBeDefined(); |
| const firstCallArg = crossFilterCall![0]; |
| |
| // Now re-render with the filters from the first click |
| // This simulates what happens via Redux in the real app |
| setDataMask.mockClear(); |
| rerender( |
| <ProviderWrapper> |
| <TableChart |
| {...props} |
| emitCrossFilters |
| setDataMask={setDataMask} |
| filters={firstCallArg.filterState.filters} |
| sticky={false} |
| /> |
| </ProviderWrapper>, |
| ); |
| |
| // The timestamp cell should be active |
| const activeCells = document.querySelectorAll('.dt-is-active-filter'); |
| expect(activeCells.length).toBeGreaterThan(0); |
| |
| // Click the same timestamp cell again to clear |
| setDataMask.mockClear(); |
| const sameCell = screen.getByText('2020-01-01 12:34:56'); |
| fireEvent.click(sameCell); |
| |
| const clearCall = setDataMask.mock.calls.find( |
| (call: any[]) => call[0]?.filterState !== undefined, |
| ); |
| expect(clearCall).toBeDefined(); |
| // Should CLEAR the filter (not re-apply it) |
| expect(clearCall![0].filterState.filters).toBeNull(); |
| expect(clearCall![0].extraFormData.filters).toEqual([]); |
| }); |
| |
| test('cross-filter toggle clears when DateWithFormatter references differ', () => { |
| // Regression test: when memoizeOne cache misses between renders, |
| // new DateWithFormatter instances are created with different references. |
| // isActiveFilterValue must compare by time value, not reference. |
| const setDataMask = jest.fn(); |
| const props = transformProps({ |
| ...testData.basic, |
| hooks: { setDataMask }, |
| emitCrossFilters: true, |
| }); |
| |
| const timestampVal = props.data[0].__timestamp as DateWithFormatter; |
| expect(timestampVal).toBeInstanceOf(DateWithFormatter); |
| |
| // Build filters with a DIFFERENT DateWithFormatter instance (same time value) |
| const filterKey = '__timestamp'; |
| const differentRef = new DateWithFormatter(timestampVal.input, { |
| formatter: timestampVal.formatter, |
| }); |
| expect(differentRef).not.toBe(timestampVal); // different reference |
| expect(differentRef.getTime()).toBe(timestampVal.getTime()); // same time |
| |
| const { container } = render( |
| <ProviderWrapper> |
| <TableChart |
| {...props} |
| emitCrossFilters |
| setDataMask={setDataMask} |
| filters={{ [filterKey]: [differentRef] }} |
| sticky={false} |
| /> |
| </ProviderWrapper>, |
| ); |
| |
| // The cell should show active filter despite different reference |
| const activeCells = container.querySelectorAll('.dt-is-active-filter'); |
| expect(activeCells.length).toBeGreaterThan(0); |
| |
| // Clicking should CLEAR the filter, not re-apply it |
| setDataMask.mockClear(); |
| const timestampCell = screen.getByText('2020-01-01 12:34:56'); |
| fireEvent.click(timestampCell); |
| |
| const clearCall = setDataMask.mock.calls.find( |
| (call: any[]) => call[0]?.filterState !== undefined, |
| ); |
| expect(clearCall).toBeDefined(); |
| expect(clearCall![0].filterState.filters).toBeNull(); |
| expect(clearCall![0].extraFormData.filters).toEqual([]); |
| }); |
| |
| test('page size selector arrow stays above resize handles (#39305)', () => { |
| // .resize-handle elements in dashboard ResizableContainer sit at |
| // z-index: 10 — the page size arrow must stack above them or it |
| // gets covered on dashboard charts. |
| const { container } = render( |
| ProviderWrapper({ |
| children: ( |
| <TableChart {...transformProps(testData.basic)} sticky={false} /> |
| ), |
| }), |
| ); |
| |
| const arrow = container.querySelector( |
| '.dt-select-page-size .ant-select .ant-select-arrow', |
| ); |
| expect(arrow).not.toBeNull(); |
| expect(getComputedStyle(arrow as HTMLElement).zIndex).toBe('11'); |
| }); |
| |
| test('recalculates totals when user filters data', async () => { |
| const formDataWithTotals = { |
| ...testData.basic.formData, |
| show_totals: true, |
| include_search: true, |
| server_pagination: false, |
| metrics: ['sum__num'], |
| }; |
| |
| const { data } = testData.basic.queriesData[0]; |
| const totalBeforeFilter = data.reduce( |
| (sum, row) => sum + Number(row.sum__num || 0), |
| 0, |
| ); |
| const totalAfterFilter = |
| data.find(item => item.name === 'Michael')?.sum__num || 0; |
| |
| const props = transformProps({ |
| ...testData.basic, |
| formData: formDataWithTotals, |
| }); |
| props.totals = { sum__num: totalBeforeFilter }; |
| props.includeSearch = true; |
| render( |
| <ProviderWrapper> |
| <TableChart {...props} sticky={false} /> |
| </ProviderWrapper>, |
| ); |
| |
| const table = screen.getByRole('table'); |
| const totalCellBefore = within(table).getByText( |
| String(totalBeforeFilter), |
| ); |
| expect(totalCellBefore).toBeInTheDocument(); |
| |
| const searchInput = screen.getByRole('textbox'); |
| fireEvent.change(searchInput, { target: { value: 'Michael' } }); |
| |
| await waitFor(() => { |
| const totalCellAfter = within(table).getByText( |
| String(totalAfterFilter), |
| ); |
| expect(totalCellAfter).toBeInTheDocument(); |
| }); |
| }); |
| |
| test('preserves client-side search text across temporal table rerenders', async () => { |
| const formDataWithSearch = { |
| ...testData.basic.formData, |
| include_search: true, |
| server_pagination: false, |
| }; |
| |
| const renderChart = () => { |
| const props = transformProps({ |
| ...testData.basic, |
| formData: formDataWithSearch, |
| }); |
| props.includeSearch = true; |
| |
| return ( |
| <ProviderWrapper> |
| <TableChart {...props} sticky={false} /> |
| </ProviderWrapper> |
| ); |
| }; |
| |
| const { rerender } = render(renderChart()); |
| |
| const searchInput = screen.getByRole('textbox'); |
| fireEvent.change(searchInput, { target: { value: 'Michael' } }); |
| |
| await waitFor(() => { |
| expect(searchInput).toHaveValue('Michael'); |
| expect(screen.getByText('Michael')).toBeInTheDocument(); |
| }); |
| |
| rerender(renderChart()); |
| |
| await waitFor(() => { |
| expect(screen.getByRole('textbox')).toHaveValue('Michael'); |
| expect(screen.getByText('Michael')).toBeInTheDocument(); |
| }); |
| }); |
| |
| test('preserves client-side search text when rerendered with empty data', async () => { |
| const formDataWithSearch = { |
| ...testData.basic.formData, |
| include_search: true, |
| server_pagination: false, |
| }; |
| |
| const renderChart = (data = testData.basic.queriesData[0].data) => { |
| const props = transformProps({ |
| ...testData.basic, |
| formData: formDataWithSearch, |
| queriesData: [ |
| { |
| ...testData.basic.queriesData[0], |
| data, |
| }, |
| ], |
| }); |
| props.includeSearch = true; |
| |
| return ( |
| <ProviderWrapper> |
| <TableChart {...props} sticky={false} /> |
| </ProviderWrapper> |
| ); |
| }; |
| |
| const { rerender } = render(renderChart()); |
| |
| const searchInput = screen.getByRole('textbox'); |
| fireEvent.change(searchInput, { target: { value: 'Michael' } }); |
| |
| await waitFor(() => { |
| expect(searchInput).toHaveValue('Michael'); |
| expect(screen.getByText('Michael')).toBeInTheDocument(); |
| }); |
| |
| rerender(renderChart([])); |
| |
| await waitFor(() => { |
| expect(screen.getByRole('textbox')).toHaveValue('Michael'); |
| expect(screen.getByLabelText('Search 0 records')).toHaveValue( |
| 'Michael', |
| ); |
| }); |
| }); |
| |
| test('preserves client-side search text for function accessor columns', async () => { |
| type DataRow = { |
| city: string; |
| firstName: string; |
| }; |
| |
| const makeColumns = (): Column<DataRow>[] => [ |
| { |
| Header: ({ column }: HeaderProps<DataRow>) => ( |
| <th data-column-name={column.id}>First name</th> |
| ), |
| Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>, |
| id: 'firstName', |
| accessor: ((row: DataRow) => row.firstName) as never, |
| }, |
| { |
| Header: ({ column }: HeaderProps<DataRow>) => ( |
| <th data-column-name={column.id}>City</th> |
| ), |
| Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>, |
| id: 'city', |
| accessor: ((row: DataRow) => row.city) as never, |
| }, |
| ]; |
| |
| const data: DataRow[] = [ |
| { firstName: 'Michael', city: 'Paris' }, |
| { firstName: 'Jordan', city: 'London' }, |
| ]; |
| |
| const renderDataTable = () => ( |
| <ProviderWrapper> |
| <DataTable<DataRow> |
| columns={makeColumns()} |
| data={data} |
| rowCount={data.length} |
| serverPagination={false} |
| serverPaginationData={{}} |
| onServerPaginationChange={jest.fn()} |
| handleSortByChange={jest.fn()} |
| sortByFromParent={[]} |
| onSearchColChange={jest.fn()} |
| searchOptions={[]} |
| sticky={false} |
| /> |
| </ProviderWrapper> |
| ); |
| |
| const { rerender } = render(renderDataTable()); |
| |
| const searchInput = screen.getByRole('textbox'); |
| fireEvent.change(searchInput, { target: { value: 'Michael' } }); |
| |
| await waitFor(() => { |
| expect(searchInput).toHaveValue('Michael'); |
| expect(screen.getByText('Michael')).toBeInTheDocument(); |
| }); |
| |
| rerender(renderDataTable()); |
| |
| await waitFor(() => { |
| expect(screen.getByRole('textbox')).toHaveValue('Michael'); |
| expect(screen.getByText('Michael')).toBeInTheDocument(); |
| }); |
| }); |
| |
| test('preserves client-side search text for string accessor columns without ids', async () => { |
| type DataRow = { |
| city: string; |
| firstName: string; |
| }; |
| |
| const makeColumns = (): Column<DataRow>[] => [ |
| { |
| Header: ({ column }: HeaderProps<DataRow>) => ( |
| <th data-column-name={column.id}>First name</th> |
| ), |
| Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>, |
| accessor: 'firstName', |
| }, |
| { |
| Header: ({ column }: HeaderProps<DataRow>) => ( |
| <th data-column-name={column.id}>City</th> |
| ), |
| Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>, |
| accessor: 'city', |
| }, |
| ]; |
| |
| const data: DataRow[] = [ |
| { firstName: 'Michael', city: 'Paris' }, |
| { firstName: 'Jordan', city: 'London' }, |
| ]; |
| |
| const renderDataTable = () => ( |
| <ProviderWrapper> |
| <DataTable<DataRow> |
| columns={makeColumns()} |
| data={data} |
| rowCount={data.length} |
| serverPagination={false} |
| serverPaginationData={{}} |
| onServerPaginationChange={jest.fn()} |
| handleSortByChange={jest.fn()} |
| sortByFromParent={[]} |
| onSearchColChange={jest.fn()} |
| searchOptions={[]} |
| sticky={false} |
| /> |
| </ProviderWrapper> |
| ); |
| |
| const { rerender } = render(renderDataTable()); |
| |
| const searchInput = screen.getByRole('textbox'); |
| fireEvent.change(searchInput, { target: { value: 'Michael' } }); |
| |
| await waitFor(() => { |
| expect(searchInput).toHaveValue('Michael'); |
| expect(screen.getByText('Michael')).toBeInTheDocument(); |
| }); |
| |
| rerender(renderDataTable()); |
| |
| await waitFor(() => { |
| expect(screen.getByRole('textbox')).toHaveValue('Michael'); |
| expect(screen.getByText('Michael')).toBeInTheDocument(); |
| }); |
| }); |
| }); |
| |
| test('should build columnLabelToNameMap for adhoc columns with custom labels', () => { |
| const result = transformProps({ |
| ...testData.basic, |
| rawFormData: { |
| ...testData.basic.rawFormData, |
| query_mode: QueryMode.Aggregate, |
| groupby: [ |
| { |
| sqlExpression: 'name', |
| label: 'Name_Renamed', |
| expressionType: 'SQL', |
| }, |
| ], |
| metrics: ['sum__num'], |
| }, |
| emitCrossFilters: true, |
| queriesData: [ |
| { |
| ...testData.basic.queriesData[0], |
| colnames: ['Name_Renamed', 'sum__num'], |
| coltypes: [GenericDataType.String, GenericDataType.Numeric], |
| data: [{ Name_Renamed: 'Michael', sum__num: 2467063 }], |
| }, |
| ], |
| }); |
| expect(result.columnLabelToNameMap).toEqual({ |
| Name_Renamed: 'name', |
| }); |
| }); |
| |
| test('should not populate columnLabelToNameMap for physical columns', () => { |
| const result = transformProps({ |
| ...testData.basic, |
| rawFormData: { |
| ...testData.basic.rawFormData, |
| query_mode: QueryMode.Aggregate, |
| groupby: ['name'], |
| metrics: ['sum__num'], |
| }, |
| emitCrossFilters: true, |
| queriesData: [ |
| { |
| ...testData.basic.queriesData[0], |
| colnames: ['name', 'sum__num'], |
| coltypes: [GenericDataType.String, GenericDataType.Numeric], |
| data: [{ name: 'Michael', sum__num: 2467063 }], |
| }, |
| ], |
| }); |
| expect(result.columnLabelToNameMap).toEqual({}); |
| }); |
| |
| test('should not populate columnLabelToNameMap when adhoc label matches sqlExpression', () => { |
| const result = transformProps({ |
| ...testData.basic, |
| rawFormData: { |
| ...testData.basic.rawFormData, |
| query_mode: QueryMode.Aggregate, |
| groupby: [ |
| { |
| sqlExpression: 'name', |
| label: 'name', |
| expressionType: 'SQL', |
| }, |
| ], |
| metrics: ['sum__num'], |
| }, |
| emitCrossFilters: true, |
| queriesData: [ |
| { |
| ...testData.basic.queriesData[0], |
| colnames: ['name', 'sum__num'], |
| coltypes: [GenericDataType.String, GenericDataType.Numeric], |
| data: [{ name: 'Michael', sum__num: 2467063 }], |
| }, |
| ], |
| }); |
| expect(result.columnLabelToNameMap).toEqual({}); |
| }); |
| |
| test('cross-filter on adhoc column with custom label emits original column name', () => { |
| const setDataMask = jest.fn(); |
| const baseProps = transformProps({ |
| ...testData.basic, |
| rawFormData: { |
| ...testData.basic.rawFormData, |
| query_mode: QueryMode.Aggregate, |
| groupby: [ |
| { |
| sqlExpression: 'name', |
| label: 'Name_Renamed', |
| expressionType: 'SQL', |
| }, |
| ], |
| metrics: ['sum__num'], |
| }, |
| filterState: { filters: {} }, |
| ownState: {}, |
| hooks: { |
| onAddFilter: jest.fn(), |
| setDataMask, |
| onContextMenu: jest.fn(), |
| }, |
| emitCrossFilters: true, |
| queriesData: [ |
| { |
| ...testData.basic.queriesData[0], |
| colnames: ['Name_Renamed', 'sum__num'], |
| coltypes: [GenericDataType.String, GenericDataType.Numeric], |
| data: [ |
| { Name_Renamed: 'Michael', sum__num: 2467063 }, |
| { Name_Renamed: 'Joe', sum__num: 2467 }, |
| ], |
| }, |
| ], |
| }); |
| |
| render( |
| <ProviderWrapper> |
| <TableChart {...baseProps} emitCrossFilters sticky={false} /> |
| </ProviderWrapper>, |
| ); |
| |
| // Verify the table rendered with data |
| expect(screen.getByText('Michael')).toBeInTheDocument(); |
| |
| // Find the td cell containing "Michael" and click it |
| const cell = screen.getByText('Michael').closest('td')!; |
| fireEvent.click(cell); |
| |
| expect(setDataMask).toHaveBeenCalled(); |
| const lastCall = |
| setDataMask.mock.calls[setDataMask.mock.calls.length - 1][0]; |
| const { filters } = lastCall.extraFormData; |
| expect(filters).toHaveLength(1); |
| // Should emit the original column name, not the label |
| expect(filters[0].col).toBe('name'); |
| expect(filters[0].val).toEqual(['Michael']); |
| }); |
| }); |
| }); |
| |
| /** |
| * DRILL-TO-DETAIL FIX VERIFICATION (#23847) |
| */ |
| describe('Drill-to-Detail Temporal Range Logic', () => { |
| const renderChartAndOpenContextMenu = ( |
| timeGrain?: TimeGranularity, |
| timestampValue?: string | number | null, |
| ) => { |
| const onContextMenu = jest.fn(); |
| const data = cloneDeep(testData.basic); |
| |
| if (timestampValue !== undefined) { |
| data.queriesData[0].data[0].__timestamp = timestampValue; |
| } |
| |
| const props = transformProps({ |
| ...data, |
| rawFormData: { |
| ...data.rawFormData, |
| ...(timeGrain ? { time_grain_sqla: timeGrain } : {}), |
| }, |
| hooks: { onAddFilter: jest.fn(), onContextMenu, setDataMask: jest.fn() }, |
| }); |
| render(<TableChart {...props} sticky={false} />); |
| |
| const tbody = screen.getAllByRole('rowgroup')[1]; |
| fireEvent.contextMenu(tbody.querySelectorAll('td')[0]); |
| |
| const [, , { drillToDetail }] = onContextMenu.mock.calls[0]; |
| return drillToDetail.find((f: any) => f.col === '__timestamp'); |
| }; |
| |
| test('uses TEMPORAL_RANGE for monthly grain', () => { |
| const filter = renderChartAndOpenContextMenu(TimeGranularity.MONTH); |
| |
| expect(filter.op).toBe('TEMPORAL_RANGE'); |
| expect(filter.val).toContain( |
| '2020-01-01T12:34:56.000Z : 2020-02-01T00:00:00.000Z', |
| ); |
| }); |
| |
| test('uses the full bucket for week ending sunday grain', () => { |
| const filter = renderChartAndOpenContextMenu( |
| TimeGranularity.WEEK_ENDING_SUNDAY, |
| '2020-01-05T00:00:00', |
| ); |
| |
| expect(filter.op).toBe('TEMPORAL_RANGE'); |
| expect(filter.val).toBe( |
| '2019-12-30T00:00:00.000Z : 2020-01-06T00:00:00.000Z', |
| ); |
| }); |
| |
| test('uses the full bucket for week ending saturday grain', () => { |
| const filter = renderChartAndOpenContextMenu( |
| TimeGranularity.WEEK_ENDING_SATURDAY, |
| '2020-01-04T00:00:00', |
| ); |
| |
| expect(filter.op).toBe('TEMPORAL_RANGE'); |
| expect(filter.val).toBe( |
| '2019-12-29T00:00:00.000Z : 2020-01-05T00:00:00.000Z', |
| ); |
| }); |
| |
| test('correctly handles NULL values by emitting IS NULL instead of 1970 timestamp', () => { |
| const filter = renderChartAndOpenContextMenu(TimeGranularity.MONTH, null); |
| |
| expect(filter.op).toBe('IS NULL'); |
| expect(filter.val).toBeNull(); |
| }); |
| }); |
| |
| // Numeric values with String dataType (e.g. backend mis-reports type for computed columns) |
| // get 'alphanumeric' sort, which treats raw numbers as non-strings and produces unstable order. |
| // They should sort numerically regardless of display format. |
| test('sorts numeric-backed percentage column numerically when dataType is String', async () => { |
| const props = transformProps({ |
| ...testData.raw, |
| rawFormData: { |
| ...testData.raw.rawFormData, |
| order_desc: false, |
| metrics: ['pct'], |
| column_config: { |
| pct: { d3NumberFormat: '.1%' }, |
| }, |
| }, |
| queriesData: [ |
| { |
| ...testData.raw.queriesData[0], |
| colnames: ['pct'], |
| coltypes: [GenericDataType.String], |
| data: [ |
| { pct: 0.4 }, |
| { pct: 0.056 }, |
| { pct: 0.506 }, |
| { pct: 0.066 }, |
| { pct: 0.41 }, |
| ], |
| }, |
| ], |
| }); |
| |
| render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| |
| const header = screen.getByText('pct'); |
| fireEvent.click(header); |
| |
| const cells = document.querySelectorAll('tbody td'); |
| const values = Array.from(cells).map(td => td.textContent); |
| expect(values).toEqual(['5.6%', '6.6%', '40.0%', '41.0%', '50.6%']); |
| }); |
| |
| test('sorts genuinely string columns alphanumerically', () => { |
| const props = transformProps({ |
| ...testData.raw, |
| rawFormData: { |
| ...testData.raw.rawFormData, |
| order_desc: false, |
| metrics: [], |
| columns: ['label'], |
| }, |
| queriesData: [ |
| { |
| ...testData.raw.queriesData[0], |
| colnames: ['label'], |
| coltypes: [GenericDataType.String], |
| data: [{ label: 'banana' }, { label: 'apple' }, { label: 'cherry' }], |
| }, |
| ], |
| }); |
| |
| render( |
| ProviderWrapper({ |
| children: <TableChart {...props} sticky={false} />, |
| }), |
| ); |
| |
| const header = screen.getByText('label'); |
| fireEvent.click(header); |
| |
| const cells = document.querySelectorAll('tbody td'); |
| const values = Array.from(cells).map(td => td.textContent); |
| expect(values).toEqual(['apple', 'banana', 'cherry']); |
| }); |