/** * 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, waitFor } from '@superset-ui/core/spec'; import { QueryMode, TimeGranularity, SMART_DATE_ID } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; import { setupAGGridModules, type ColumnState, } from '@superset-ui/core/components/ThemedAgGridReact'; import AgGridTableChart from '../src/AgGridTableChart'; import transformProps from '../src/transformProps'; import { ProviderWrapper } from '../../plugin-chart-table/test/testHelpers'; import testData from '../../plugin-chart-table/test/testData'; const mockSetDataMask = jest.fn(); beforeAll(() => { setupAGGridModules(); }); beforeEach(() => { jest.clearAllMocks(); }); test('transformProps parses 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('transformProps does 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); expect(transformedProps.isRawRecords).toBe(true); expect(transformedProps.timeGrain).toBe(TimeGranularity.MONTH); }); test('transformProps handles 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); }); test('AgGridTableChart renders basic data', async () => { const props = transformProps(testData.basic); render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { const grid = document.querySelector('.ag-container'); expect(grid).toBeInTheDocument(); }); const headerCells = document.querySelectorAll('.ag-header-cell-text'); const headerTexts = Array.from(headerCells).map(el => el.textContent); expect(headerTexts).toContain('name'); expect(headerTexts).toContain('sum__num'); const dataRows = document.querySelectorAll('.ag-row:not(.ag-row-pinned)'); expect(dataRows.length).toBe(3); expect(screen.getByText('Michael')).toBeInTheDocument(); expect(screen.getByText('Joe')).toBeInTheDocument(); expect(screen.getByText('Maria')).toBeInTheDocument(); }); test('AgGridTableChart renders with server pagination', async () => { const props = transformProps({ ...testData.basic, rawFormData: { ...testData.basic.rawFormData, server_pagination: true, }, }); props.serverPagination = true; props.rowCount = 100; props.serverPaginationData = { currentPage: 0, pageSize: 20, }; render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { const grid = document.querySelector('.ag-container'); expect(grid).toBeInTheDocument(); }); expect(screen.getByText('Page Size:')).toBeInTheDocument(); expect(screen.getByText('Page')).toBeInTheDocument(); const paginationEl = screen.getByText('Page Size:').closest('div')!; const paginationText = paginationEl.textContent; expect(paginationText).toContain('1'); expect(paginationText).toContain('20'); expect(paginationText).toContain('100'); expect(paginationText).toContain('5'); }); test('AgGridTableChart renders with search enabled', async () => { const props = transformProps({ ...testData.basic, rawFormData: { ...testData.basic.rawFormData, include_search: true, }, }); props.includeSearch = true; render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { const grid = document.querySelector('.ag-container'); expect(grid).toBeInTheDocument(); }); const searchContainer = document.querySelector('.search-container'); expect(searchContainer).toBeInTheDocument(); const searchInput = screen.getByPlaceholderText('Search'); expect(searchInput).toBeInTheDocument(); expect(searchInput).toHaveAttribute('type', 'text'); expect(searchInput).toHaveAttribute('id', 'filter-text-box'); }); test('AgGridTableChart renders with totals', async () => { const props = transformProps({ ...testData.basic, rawFormData: { ...testData.basic.rawFormData, show_totals: true, }, }); props.showTotals = true; props.totals = { sum__num: 1000 }; render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { const grid = document.querySelector('.ag-container'); expect(grid).toBeInTheDocument(); }); const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row'); expect(pinnedRows.length).toBeGreaterThan(0); const dataRows = document.querySelectorAll( '.ag-body-viewport .ag-row:not(.ag-row-pinned)', ); expect(dataRows.length).toBe(3); }); test('AgGridTableChart handles empty data', async () => { const props = transformProps(testData.empty); render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { const grid = document.querySelector('.ag-container'); expect(grid).toBeInTheDocument(); }); const dataRows = document.querySelectorAll( '.ag-center-cols-container .ag-row', ); expect(dataRows.length).toBe(0); const headerCells = document.querySelectorAll('.ag-header-cell'); expect(headerCells.length).toBeGreaterThan(0); }); test('AgGridTableChart renders with time comparison', async () => { const props = transformProps(testData.comparison); props.isUsingTimeComparison = true; render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { const grid = document.querySelector('.ag-container'); expect(grid).toBeInTheDocument(); }); const comparisonDropdown = document.querySelector( '.time-comparison-dropdown', ); expect(comparisonDropdown).toBeInTheDocument(); const headerCells = document.querySelectorAll('.ag-header-cell-text'); const headerTexts = Array.from(headerCells).map(el => el.textContent); expect(headerTexts).toContain('#'); expect(headerTexts).toContain('△'); expect(headerTexts).toContain('%'); }); test('AgGridTableChart handles raw records mode', async () => { const rawRecordsProps = { ...testData.basic, rawFormData: { ...testData.basic.rawFormData, query_mode: QueryMode.Raw, }, }; const props = transformProps(rawRecordsProps); expect(props.isRawRecords).toBe(true); render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { const grid = document.querySelector('.ag-container'); expect(grid).toBeInTheDocument(); }); const dataRows = document.querySelectorAll('.ag-row:not(.ag-row-pinned)'); expect(dataRows.length).toBe(3); const headerCells = document.querySelectorAll('.ag-header-cell'); expect(headerCells.length).toBeGreaterThan(0); }); const rawSummaryProps = { ...testData.basic, rawFormData: { ...testData.basic.rawFormData, query_mode: QueryMode.Raw, show_totals: true, }, datasource: { ...testData.basic.datasource, columns: [{ column_name: 'name' }, { column_name: 'sum__num' }], }, queriesData: [ { ...testData.basic.queriesData[0], colnames: [...testData.basic.queriesData[0].colnames, 'num_free'], coltypes: [ ...testData.basic.queriesData[0].coltypes, GenericDataType.Numeric, ], data: testData.basic.queriesData[0].data, }, { ...testData.basic.queriesData[0], data: [{ sum__num: 12345 }] }, ], }; test('transformProps derives numeric dataset columns as rawSummaryColumns in raw mode', () => { const transformed = transformProps(rawSummaryProps); // 'sum__num' is the only numeric column of the selection that is both // numeric and backed by a dataset column: 'num_free' is Numeric but not // dataset-backed, and 'name' is dataset-backed but not numeric. expect(transformed.rawSummaryColumns).toEqual(['sum__num']); }); test('transformProps surfaces raw records totals when the summary is on', () => { const transformed = transformProps(rawSummaryProps); expect(transformed.totals).toEqual({ sum__num: 12345 }); }); test('transformProps leaves totals undefined in raw mode when the summary is off', () => { const transformed = transformProps({ ...rawSummaryProps, rawFormData: { ...rawSummaryProps.rawFormData, show_totals: false, }, }); expect(transformed.totals).toBeUndefined(); expect(transformed.rawSummaryColumns).toEqual([]); }); test('transformProps returns empty rawSummaryColumns in aggregate mode', () => { const transformed = transformProps(testData.basic); expect(transformed.rawSummaryColumns).toEqual([]); }); test('AgGridTableChart corrects invalid page number when currentPage >= totalPages', async () => { const props = transformProps({ ...testData.basic, rawFormData: { ...testData.basic.rawFormData, server_pagination: true, }, }); props.serverPagination = true; props.rowCount = 50; props.serverPaginationData = { currentPage: 5, pageSize: 20, }; render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(mockSetDataMask).toHaveBeenCalled(); }); }); test('AgGridTableChart primes raw summary columns into own state', async () => { const props = transformProps(rawSummaryProps); render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(mockSetDataMask).toHaveBeenCalledWith( expect.objectContaining({ ownState: expect.objectContaining({ rawSummaryColumns: ['sum__num'], }), }), ); }); }); test('AgGridTableChart does not prime summary columns when the summary is off', async () => { const props = transformProps({ ...rawSummaryProps, rawFormData: { ...rawSummaryProps.rawFormData, show_totals: false, }, }); render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(document.querySelector('.ag-container')).toBeInTheDocument(); }); const primedCalls = mockSetDataMask.mock.calls.filter( ([arg]) => arg?.ownState?.rawSummaryColumns, ); expect(primedCalls).toHaveLength(0); }); test('AgGridTableChart clears a stale summary prime when no numeric columns remain', async () => { const props = transformProps({ ...rawSummaryProps, datasource: { ...testData.basic.datasource, columns: [{ column_name: 'name' }], }, }); props.serverPaginationData = { rawSummaryColumns: ['sum__num'] }; render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(mockSetDataMask).toHaveBeenCalledWith( expect.objectContaining({ ownState: expect.objectContaining({ rawSummaryColumns: [] }), }), ); }); }); test('AgGridTableChart requests aggregate totals when the summary toggles on without data', async () => { const props = transformProps({ ...testData.basic, rawFormData: { ...testData.basic.rawFormData, show_totals: true, }, }); render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(mockSetDataMask).toHaveBeenCalledWith( expect.objectContaining({ ownState: expect.objectContaining({ totalsRequested: true }), }), ); }); }); test('AgGridTableChart clears the aggregate totals request when the summary is off', async () => { const props = transformProps(testData.basic); props.serverPaginationData = { totalsRequested: true }; render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(mockSetDataMask).toHaveBeenCalledWith( expect.objectContaining({ ownState: expect.objectContaining({ totalsRequested: false, }), }), ); }); }); test('AgGridTableChart clamps the page to zero when the result set is empty', async () => { const props = transformProps({ ...rawSummaryProps, queriesData: [rawSummaryProps.queriesData[0]], }); props.serverPagination = true; props.rowCount = 0; props.serverPaginationData = { currentPage: 5, pageSize: 20 }; render( ProviderWrapper({ children: ( ), }), ); // Zero rows means zero pages; a stale page index must reset so row_offset // does not keep pointing past the (now empty) result set. Asserting the // totals keys alongside pins the write to the unified effect rather than // the pagination footer's own page reset. await waitFor(() => { expect(mockSetDataMask).toHaveBeenCalledWith( expect.objectContaining({ ownState: expect.objectContaining({ currentPage: 0, rawSummaryColumns: ['sum__num'], totalsRequested: true, }), }), ); }); }); test('AgGridTableChart merges the page clamp and totals request into one own-state write', async () => { const props = transformProps({ ...rawSummaryProps, queriesData: [rawSummaryProps.queriesData[0]], }); props.serverPagination = true; props.rowCount = 41; props.serverPaginationData = { currentPage: 5, pageSize: 20 }; render( ProviderWrapper({ children: ( ), }), ); // 41 rows at page size 20 leave 3 pages, so page 5 must clamp to 2 in the // same write that primes the summary columns and requests totals; separate // writes would overwrite each other's keys. await waitFor(() => { expect(mockSetDataMask).toHaveBeenCalledWith( expect.objectContaining({ ownState: expect.objectContaining({ currentPage: 2, rawSummaryColumns: ['sum__num'], totalsRequested: true, }), }), ); }); }); test('AgGridTableChart re-requests raw totals when toggled back on with a matching prime', async () => { const props = transformProps({ ...rawSummaryProps, queriesData: [rawSummaryProps.queriesData[0]], }); render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(mockSetDataMask).toHaveBeenCalledWith( expect.objectContaining({ ownState: expect.objectContaining({ rawSummaryColumns: ['sum__num'], totalsRequested: true, }), }), ); }); }); test('AgGridTableChart pins no summary row when totals come back empty', async () => { const props = transformProps({ ...testData.basic, rawFormData: { ...testData.basic.rawFormData, show_totals: true, }, }); props.showTotals = true; // An empty totals object (e.g. a time-comparison totals result with no // rows) carries nothing to display and must not pin a blank row. props.totals = {}; render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(document.querySelector('.ag-container')).toBeInTheDocument(); }); const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row'); expect(pinnedRows.length).toBe(0); }); test('AgGridTableChart pins no summary row when totals are absent', async () => { const props = transformProps({ ...rawSummaryProps, datasource: { ...testData.basic.datasource, columns: [{ column_name: 'name' }], }, queriesData: [rawSummaryProps.queriesData[0]], }); render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(document.querySelector('.ag-container')).toBeInTheDocument(); }); const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row'); expect(pinnedRows.length).toBe(0); }); test('AgGridTableChart emits column state with aggFunc through the debounced save path', async () => { const props = transformProps(testData.basic); const onChartStateChange = jest.fn(); render( ProviderWrapper({ children: ( ), }), ); await waitFor(() => { expect(document.querySelector('.ag-container')).toBeInTheDocument(); }); // The save path is debounced (SLOW_DEBOUNCE = 500ms); wait for a capture. await waitFor(() => expect(onChartStateChange).toHaveBeenCalled(), { timeout: 3000, }); const savedState = onChartStateChange.mock.calls[onChartStateChange.mock.calls.length - 1][0]; const savedColumn = (savedState.columnState as ColumnState[]).find( col => col.colId === 'sum__num', ); // Entries must carry an aggFunc key so "None" survives reload. The value // itself is not assertable here: aggregation state needs an enterprise // (SharedAggregation) module; the community modules always report null. expect(savedColumn).toMatchObject({ aggFunc: null }); });