/** * 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 { render, screen } from '@testing-library/react'; import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme'; import { TableRenderer } from '../../src/react-pivottable/TableRenderers'; import { aggregatorTemplates } from '../../src/react-pivottable/utilities'; jest.mock( 'react-icons/fa', () => ({ FaSort: () => , FaSortDown: () => , FaSortUp: () => , }), { virtual: true }, ); /** * A minimal aggregatorsFactory that mirrors the production one. * PivotData's constructor calls `aggregatorsFactory(defaultFormatter)` * to obtain a map of aggregator constructors keyed by name. * The `formatter` argument is ignored here because the tests only * care about rendering output, not number formatting precision. */ const aggregatorsFactory = () => ({ Count: aggregatorTemplates.count(), Sum: aggregatorTemplates.sum(), }); const SAMPLE_DATA = [ { color: 'blue', shape: 'circle', value: 10 }, { color: 'blue', shape: 'square', value: 20 }, { color: 'red', shape: 'circle', value: 30 }, { color: 'red', shape: 'square', value: 40 }, ]; function renderWithTheme(ui: React.ReactElement) { return render({ui}); } function buildDefaultProps(overrides: Record = {}) { return { data: SAMPLE_DATA, rows: ['color'] as string[], cols: ['shape'] as string[], aggregatorName: 'Count', vals: [] as string[], aggregatorsFactory, tableOptions: {}, onContextMenu: jest.fn(), ...overrides, }; } test('TableRenderer renders a table element with the pvtTable class', () => { const props = buildDefaultProps(); renderWithTheme(); const table = screen.getByRole('grid'); expect(table).toBeInTheDocument(); expect(table).toHaveClass('pvtTable'); }); test('TableRenderer renders column headers from pivot data', () => { const props = buildDefaultProps(); renderWithTheme(); // The column attribute values ("circle" and "square") should appear as // column headers in the rendered table. expect(screen.getByText('circle')).toBeInTheDocument(); expect(screen.getByText('square')).toBeInTheDocument(); }); test('TableRenderer renders row headers from pivot data', () => { const props = buildDefaultProps(); renderWithTheme(); // The row attribute values ("blue" and "red") should appear as // row headers in the rendered table. expect(screen.getByText('blue')).toBeInTheDocument(); expect(screen.getByText('red')).toBeInTheDocument(); }); test('TableRenderer renders aggregated cell values', () => { const props = buildDefaultProps(); renderWithTheme(); // With "Count" aggregator, each cell (row x col intersection) should // contain "1" because each combination appears exactly once. const cells = screen.getAllByRole('gridcell'); const cellTexts = cells.map(cell => cell.textContent); // There should be cell values of "1" for each of the four intersections // (blue+circle, blue+square, red+circle, red+square). const onesCount = cellTexts.filter(text => text === '1').length; expect(onesCount).toBeGreaterThanOrEqual(4); }); test('TableRenderer renders row totals when rowTotals is enabled', () => { const props = buildDefaultProps({ tableOptions: { rowTotals: true, colTotals: true }, }); renderWithTheme(); // Row totals column should show "2" for each color (blue has 2 records, // red has 2 records). const totalCells = screen .getAllByRole('gridcell') .filter(cell => cell.classList.contains('pvtTotal')); expect(totalCells.length).toBeGreaterThan(0); const totalValues = totalCells.map(cell => cell.textContent); expect(totalValues).toContain('2'); }); test('TableRenderer renders col totals row when colTotals is enabled', () => { const props = buildDefaultProps({ tableOptions: { rowTotals: true, colTotals: true }, }); renderWithTheme(); // The totals row should have cells with class pvtRowTotal. const rowTotalCells = screen .getAllByRole('gridcell') .filter(cell => cell.classList.contains('pvtRowTotal')); expect(rowTotalCells.length).toBeGreaterThan(0); }); test('TableRenderer renders grand total when both totals are enabled', () => { const props = buildDefaultProps({ tableOptions: { rowTotals: true, colTotals: true }, }); renderWithTheme(); // The grand total cell should show "4" (total record count). const grandTotalCells = screen .getAllByRole('gridcell') .filter(cell => cell.classList.contains('pvtGrandTotal')); expect(grandTotalCells.length).toBe(1); expect(grandTotalCells[0]).toHaveTextContent('4'); }); test('TableRenderer handles empty data gracefully', () => { const props = buildDefaultProps({ data: [] }); renderWithTheme(); // The table should still render without crashing, just with no data rows. const table = screen.getByRole('grid'); expect(table).toBeInTheDocument(); // With empty data, there are no regular value cells (pvtVal). const valueCells = document.querySelectorAll('.pvtVal'); expect(valueCells).toHaveLength(0); // No row headers should be present. const rowLabels = document.querySelectorAll('.pvtRowLabel'); expect(rowLabels).toHaveLength(0); }); test('TableRenderer handles data with no rows dimension', () => { const props = buildDefaultProps({ rows: [], cols: ['color'], }); renderWithTheme(); const table = screen.getByRole('grid'); expect(table).toBeInTheDocument(); // Column headers should still render. expect(screen.getByText('blue')).toBeInTheDocument(); expect(screen.getByText('red')).toBeInTheDocument(); }); test('TableRenderer handles data with no cols dimension', () => { const props = buildDefaultProps({ rows: ['color'], cols: [], }); renderWithTheme(); const table = screen.getByRole('grid'); expect(table).toBeInTheDocument(); // Row headers should still render. expect(screen.getByText('blue')).toBeInTheDocument(); expect(screen.getByText('red')).toBeInTheDocument(); }); test('TableRenderer renders with Sum aggregator', () => { const props = buildDefaultProps({ aggregatorName: 'Sum', vals: ['value'], }); renderWithTheme(); const cells = screen.getAllByRole('gridcell'); const cellTexts = cells.map(cell => cell.textContent); // Sum of value for blue+circle=10, blue+square=20, red+circle=30, // red+square=40. Check that at least some of these appear. expect(cellTexts.some(text => text?.includes('10'))).toBe(true); expect(cellTexts.some(text => text?.includes('40'))).toBe(true); }); test('TableRenderer applies namesMapping to header labels', () => { const props = buildDefaultProps({ namesMapping: { blue: 'Blue Color', red: 'Red Color' }, }); renderWithTheme(); expect(screen.getByText('Blue Color')).toBeInTheDocument(); expect(screen.getByText('Red Color')).toBeInTheDocument(); }); test('TableRenderer renders the row attribute label in the header', () => { const props = buildDefaultProps(); renderWithTheme(); // The row attribute name "color" should appear as an axis label. const axisLabels = document.querySelectorAll('.pvtAxisLabel'); const axisLabelTexts = Array.from(axisLabels).map(el => el.textContent); expect(axisLabelTexts).toContain('color'); }); test('TableRenderer renders the column attribute label in the header', () => { const props = buildDefaultProps(); renderWithTheme(); // The column attribute name "shape" should appear as an axis label. const axisLabels = document.querySelectorAll('.pvtAxisLabel'); const axisLabelTexts = Array.from(axisLabels).map(el => el.textContent); expect(axisLabelTexts).toContain('shape'); }); test('TableRenderer calls onContextMenu callback', () => { const onContextMenu = jest.fn(); const props = buildDefaultProps({ onContextMenu }); renderWithTheme(); const cells = screen.getAllByRole('gridcell'); expect(cells.length).toBeGreaterThan(0); }); test('TableRenderer renders with multiple row dimensions', () => { const multiRowData = [ { country: 'US', city: 'NYC', value: 10 }, { country: 'US', city: 'LA', value: 20 }, { country: 'UK', city: 'London', value: 30 }, ]; const props = buildDefaultProps({ data: multiRowData, rows: ['country', 'city'], cols: [], }); renderWithTheme(); const table = screen.getByRole('grid'); expect(table).toBeInTheDocument(); expect(screen.getByText('US')).toBeInTheDocument(); expect(screen.getByText('UK')).toBeInTheDocument(); expect(screen.getByText('NYC')).toBeInTheDocument(); expect(screen.getByText('LA')).toBeInTheDocument(); expect(screen.getByText('London')).toBeInTheDocument(); }); test('TableRenderer renders with multiple column dimensions', () => { const multiColData = [ { year: '2023', quarter: 'Q1', metric: 5 }, { year: '2023', quarter: 'Q2', metric: 10 }, { year: '2024', quarter: 'Q1', metric: 15 }, ]; const props = buildDefaultProps({ data: multiColData, rows: [], cols: ['year', 'quarter'], }); renderWithTheme(); const table = screen.getByRole('grid'); expect(table).toBeInTheDocument(); expect(screen.getByText('2023')).toBeInTheDocument(); expect(screen.getByText('2024')).toBeInTheDocument(); // Q1 appears under both 2023 and 2024, so use getAllByText. expect(screen.getAllByText('Q1').length).toBeGreaterThanOrEqual(2); expect(screen.getByText('Q2')).toBeInTheDocument(); }); test('TableRenderer renders value cells with the pvtVal class', () => { const props = buildDefaultProps(); renderWithTheme(); const valueCells = document.querySelectorAll('.pvtVal'); // 2 rows x 2 cols = 4 value cells expect(valueCells.length).toBe(4); }); test('TableRenderer renders correct number of thead and tbody sections', () => { const props = buildDefaultProps(); renderWithTheme(); const table = screen.getByRole('grid'); // The table should have thead and tbody elements. const theadEl = table.querySelector('thead'); const tbodyEl = table.querySelector('tbody'); expect(theadEl).toBeInTheDocument(); expect(tbodyEl).toBeInTheDocument(); });