diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx index 2944477696ee..79eee9a91ff0 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx @@ -251,8 +251,13 @@ export default function TableChart( ); const timestampFormatter = useCallback( - value => getTimeFormatterForGranularity(timeGrain)(value), - [timeGrain], + (value: DataRecordValue) => + isRawRecords + ? String(value ?? '') + : getTimeFormatterForGranularity(timeGrain)( + value as number | Date | null | undefined, + ), + [timeGrain, isRawRecords], ); const toggleFilter = useCallback( @@ -276,7 +281,14 @@ export default function TableChart( setDataMask(getCrossFilterDataMask(crossFilterProps).dataMask); } }, - [emitCrossFilters, setDataMask, filters, timeGrain], + [ + emitCrossFilters, + setDataMask, + filters, + timeGrain, + isActiveFilterValue, + timestampFormatter, + ], ); const handleServerPaginationChange = useCallback( diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts index 2925632468e0..d793f2c27a61 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts @@ -343,6 +343,7 @@ const processColumns = memoizeOne(function processColumns( metrics: metrics_, percent_metrics: percentMetrics_, column_config: columnConfig = {}, + query_mode: queryMode, }, queriesData, } = props; @@ -393,7 +394,7 @@ const processColumns = memoizeOne(function processColumns( const timeFormat = customFormat || tableTimestampFormat; // When format is "Adaptive Formatting" (smart_date) if (timeFormat === SMART_DATE_ID) { - if (granularity) { + if (granularity && queryMode !== QueryMode.Raw) { // time column use formats based on granularity formatter = getTimeFormatterForGranularity(granularity); } else if (customFormat) { diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/AgGridTableChart.test.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/AgGridTableChart.test.tsx new file mode 100644 index 000000000000..875f49331a3b --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/AgGridTableChart.test.tsx @@ -0,0 +1,359 @@ +/** + * 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 { setupAGGridModules } 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); +}); + +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(); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index 499ae52e9521..e95387049d94 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -356,8 +356,13 @@ export default function TableChart( ); const timestampFormatter = useCallback( - value => getTimeFormatterForGranularity(timeGrain)(value), - [timeGrain], + (value: DataRecordValue) => + isRawRecords + ? String(value ?? '') + : getTimeFormatterForGranularity(timeGrain)( + value as number | Date | null | undefined, + ), + [timeGrain, isRawRecords], ); const [tableSize, setTableSize] = useState({ width: 0, diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts index 48849e3dd20e..358181e46dee 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts @@ -212,6 +212,7 @@ const processColumns = memoizeOne(function processColumns( metrics: metrics_, percent_metrics: percentMetrics_, column_config: columnConfig = {}, + query_mode: queryMode, }, rawDatasource, queriesData, @@ -274,7 +275,7 @@ const processColumns = memoizeOne(function processColumns( const timeFormat = customFormat || tableTimestampFormat; // When format is "Adaptive Formatting" (smart_date) if (timeFormat === SMART_DATE_ID) { - if (granularity) { + if (granularity && queryMode !== QueryMode.Raw) { // time column use formats based on granularity formatter = getTimeFormatterForGranularity(granularity); } else if (customFormat) { diff --git a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx index 5183e5ab5432..e6a955b511b3 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx +++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx @@ -26,6 +26,12 @@ import { within, } from '@superset-ui/core/spec'; import { cloneDeep } from 'lodash'; +import { + QueryMode, + TimeGranularity, + SMART_DATE_ID, + getTimeFormatterForGranularity, +} from '@superset-ui/core'; import TableChart, { sanitizeHeaderId } from '../src/TableChart'; import { GenericDataType } from '@apache-superset/core/api/core'; import transformProps from '../src/transformProps'; @@ -377,6 +383,52 @@ describe('plugin-chart-table', () => { 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( @@ -386,7 +438,8 @@ describe('plugin-chart-table', () => { const firstDataRow = screen.getAllByRole('rowgroup')[1]; const cells = firstDataRow.querySelectorAll('td'); expect(cells).toHaveLength(12); - expect(cells[0]).toHaveTextContent('2020-01-01 12:34:56'); + // 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) @@ -1422,7 +1475,6 @@ describe('plugin-chart-table', () => { column: 'sum__num', operator: '>', targetValue: 2467, - // useGradient is undefined }, ], },