From 4f444ae1d236d3c387f652706dbc1d7cf5027e64 Mon Sep 17 00:00:00 2001 From: amaannawab923 Date: Mon, 12 Jan 2026 19:25:07 +0530 Subject: [PATCH 01/16] feat(ag-grid): Server Side Filtering for Column Level Filters (#35683) --- .../AgGridTable/components/CustomHeader.tsx | 80 +- .../src/AgGridTable/index.tsx | 92 +- .../src/AgGridTableChart.tsx | 91 ++ .../src/buildQuery.ts | 158 +++- .../plugin-chart-ag-grid-table/src/consts.ts | 15 + .../src/renderers/NumericCellRenderer.tsx | 14 +- .../src/stateConversion.ts | 185 +++- .../plugin-chart-ag-grid-table/src/types.ts | 23 +- .../src/utils/agGridFilterConverter.ts | 726 +++++++++++++++ .../src/utils/filterStateManager.ts | 164 ++++ .../src/utils/getInitialFilterModel.ts | 42 + .../src/utils/useColDefs.ts | 89 +- .../test/buildQuery.test.ts | 591 ++++++++++++ .../test/utils/agGridFilterConverter.test.ts | 863 ++++++++++++++++++ .../test/utils/filterStateManager.test.ts | 658 +++++++++++++ .../test/utils/getInitialFilterModel.test.ts | 412 +++++++++ .../src/components/Chart/ChartRenderer.jsx | 12 +- .../useExploreAdditionalActionsMenu/index.jsx | 9 +- superset/models/helpers.py | 12 +- superset/utils/core.py | 1 + 20 files changed, 4142 insertions(+), 95 deletions(-) create mode 100644 superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts create mode 100644 superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/filterStateManager.ts create mode 100644 superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getInitialFilterModel.ts create mode 100644 superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/agGridFilterConverter.test.ts create mode 100644 superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts create mode 100644 superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/getInitialFilterModel.test.ts diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomHeader.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomHeader.tsx index b1ae8231fe39..61761c0f9279 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomHeader.tsx +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/CustomHeader.tsx @@ -19,9 +19,10 @@ * under the License. */ -import { useRef, useState } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { t } from '@apache-superset/core'; import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons'; +import { Column } from '@superset-ui/core/components/ThemedAgGridReact'; import FilterIcon from './Filter'; import KebabMenu from './KebabMenu'; import { @@ -29,6 +30,8 @@ import { CustomHeaderParams, SortState, UserProvidedColDef, + FilterInputPosition, + AGGridFilterInstance, } from '../../types'; import CustomPopover from './CustomPopover'; import { @@ -39,6 +42,13 @@ import { MenuContainer, SortIconWrapper, } from '../../styles'; +import { GridApi } from 'ag-grid-community'; +import { + FILTER_POPOVER_OPEN_DELAY, + FILTER_INPUT_POSITIONS, + FILTER_CONDITION_BODY_INDEX, + FILTER_INPUT_SELECTOR, +} from '../../consts'; const getSortIcon = (sortState: SortState[], colId: string | null) => { if (!sortState?.length || !colId) return null; @@ -53,6 +63,43 @@ const getSortIcon = (sortState: SortState[], colId: string | null) => { return null; }; +// Auto-opens filter popover and focuses the correct input after server-side filtering +const autoOpenFilterAndFocus = async ( + column: Column, + api: GridApi, + filterRef: React.RefObject, + setFilterVisible: (visible: boolean) => void, + lastFilteredInputPosition?: FilterInputPosition, +) => { + setFilterVisible(true); + + const filterInstance = (await api.getColumnFilterInstance( + column, + )) as AGGridFilterInstance | null; + const filterEl = filterInstance?.eGui; + + if (!filterEl || !filterRef.current) return; + + filterRef.current.innerHTML = ''; + filterRef.current.appendChild(filterEl); + + if (filterInstance?.eConditionBodies) { + const conditionBodies = filterInstance.eConditionBodies; + const targetIndex = + lastFilteredInputPosition === FILTER_INPUT_POSITIONS.SECOND + ? FILTER_CONDITION_BODY_INDEX.SECOND + : FILTER_CONDITION_BODY_INDEX.FIRST; + const targetBody = conditionBodies[targetIndex]; + + if (targetBody) { + const input = targetBody.querySelector( + FILTER_INPUT_SELECTOR, + ) as HTMLInputElement | null; + input?.focus(); + } + } +}; + const CustomHeader: React.FC = ({ displayName, enableSorting, @@ -61,7 +108,12 @@ const CustomHeader: React.FC = ({ column, api, }) => { - const { initialSortState, onColumnHeaderClicked } = context; + const { + initialSortState, + onColumnHeaderClicked, + lastFilteredColumn, + lastFilteredInputPosition, + } = context; const colId = column?.getColId(); const colDef = column?.getColDef() as CustomColDef; const userColDef = column.getUserProvidedColDef() as UserProvidedColDef; @@ -77,7 +129,6 @@ const CustomHeader: React.FC = ({ const isTimeComparison = !isMain && userColDef?.timeComparisonKey; const sortKey = isMain ? colId.replace('Main', '').trim() : colId; - // Sorting logic const clearSort = () => { onColumnHeaderClicked({ column: { colId: sortKey, sort: null } }); setSort(null, false); @@ -106,7 +157,9 @@ const CustomHeader: React.FC = ({ e.stopPropagation(); setFilterVisible(!isFilterVisible); - const filterInstance = await api.getColumnFilterInstance(column); + const filterInstance = (await api.getColumnFilterInstance( + column, + )) as AGGridFilterInstance | null; const filterEl = filterInstance?.eGui; if (filterEl && filterRef.current) { filterRef.current.innerHTML = ''; @@ -114,6 +167,25 @@ const CustomHeader: React.FC = ({ } }; + // Re-open filter popover after server refresh (delay allows AG Grid to finish rendering) + useEffect(() => { + if (lastFilteredColumn === colId && !isFilterVisible) { + const timeoutId = setTimeout( + () => + autoOpenFilterAndFocus( + column, + api, + filterRef, + setFilterVisible, + lastFilteredInputPosition, + ), + FILTER_POPOVER_OPEN_DELAY, + ); + return () => clearTimeout(timeoutId); + } + return undefined; + }, [lastFilteredColumn, colId, lastFilteredInputPosition]); + const handleMenuClick = (e: React.MouseEvent) => { e.stopPropagation(); setMenuVisible(!isMenuVisible); diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx index 23a029051265..c51f88ef0ef6 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx @@ -55,7 +55,9 @@ import Pagination from './components/Pagination'; import SearchSelectDropdown from './components/SearchSelectDropdown'; import { SearchOption, SortByItem } from '../types'; import getInitialSortState, { shouldSort } from '../utils/getInitialSortState'; +import getInitialFilterModel from '../utils/getInitialFilterModel'; import { PAGE_SIZE_OPTIONS } from '../consts'; +import { getCompleteFilterState } from '../utils/filterStateManager'; export interface AgGridState extends Partial { timestamp?: number; @@ -100,6 +102,8 @@ export interface AgGridTableProps { showTotals: boolean; width: number; onColumnStateChange?: (state: AgGridChartStateWithMetadata) => void; + onFilterChanged?: (filterModel: Record) => void; + metricColumns?: string[]; gridRef?: RefObject; chartState?: AgGridChartState; } @@ -137,6 +141,8 @@ const AgGridDataTable: FunctionComponent = memo( showTotals, width, onColumnStateChange, + onFilterChanged, + metricColumns = [], chartState, }) => { const gridRef = useRef(null); @@ -144,14 +150,27 @@ const AgGridDataTable: FunctionComponent = memo( const rowData = useMemo(() => data, [data]); const containerRef = useRef(null); const lastCapturedStateRef = useRef(null); + const filterOperationVersionRef = useRef(0); const searchId = `search-${id}`; + + const initialFilterModel = getInitialFilterModel( + chartState, + serverPaginationData, + serverPagination, + ); + const gridInitialState: GridState = { ...(serverPagination && { sort: { sortModel: getInitialSortState(serverPaginationData?.sortBy || []), }, }), + ...(initialFilterModel && { + filter: { + filterModel: initialFilterModel, + }, + }), }; const defaultColDef = useMemo( @@ -332,6 +351,56 @@ const AgGridDataTable: FunctionComponent = memo( [onColumnStateChange], ); + const handleFilterChanged = useCallback(async () => { + filterOperationVersionRef.current += 1; + const currentVersion = filterOperationVersionRef.current; + + const completeFilterState = await getCompleteFilterState( + gridRef, + metricColumns, + ); + + // Skip stale operations from rapid filter changes + if (currentVersion !== filterOperationVersionRef.current) { + return; + } + + // Reject invalid filter states (e.g., text filter on numeric column) + if (completeFilterState.originalFilterModel) { + const filterModel = completeFilterState.originalFilterModel; + const hasInvalidFilterType = Object.entries(filterModel).some( + ([colId, filter]: [string, any]) => { + if ( + filter?.filterType === 'text' && + metricColumns?.includes(colId) + ) { + return true; + } + return false; + }, + ); + + if (hasInvalidFilterType) { + return; + } + } + + if ( + !isEqual( + serverPaginationData?.agGridFilterModel, + completeFilterState.originalFilterModel, + ) + ) { + if (onFilterChanged) { + onFilterChanged(completeFilterState); + } + } + }, [ + onFilterChanged, + metricColumns, + serverPaginationData?.agGridFilterModel, + ]); + useEffect(() => { if ( hasServerPageLengthChanged && @@ -356,19 +425,14 @@ const AgGridDataTable: FunctionComponent = memo( // This will make columns fill the grid width params.api.sizeColumnsToFit(); - // Restore saved AG Grid state from permalink if available - if (chartState && params.api) { + // Restore saved column state from permalink if available + // Note: filterModel is now handled via gridInitialState for better performance + if (chartState?.columnState && params.api) { try { - if (chartState.columnState) { - params.api.applyColumnState?.({ - state: chartState.columnState as ColumnState[], - applyOrder: true, - }); - } - - if (chartState.filterModel) { - params.api.setFilterModel?.(chartState.filterModel); - } + params.api.applyColumnState?.({ + state: chartState.columnState as ColumnState[], + applyOrder: true, + }); } catch { // Silently fail if state restoration fails } @@ -429,6 +493,7 @@ const AgGridDataTable: FunctionComponent = memo( rowSelection="multiple" animateRows onCellClicked={handleCrossFilter} + onFilterChanged={handleFilterChanged} onStateUpdated={handleGridStateChange} initialState={gridInitialState} maintainColumnOrder @@ -520,6 +585,9 @@ const AgGridDataTable: FunctionComponent = memo( serverPaginationData?.sortBy || [], ), isActiveFilterValue, + lastFilteredColumn: serverPaginationData?.lastFilteredColumn, + lastFilteredInputPosition: + serverPaginationData?.lastFilteredInputPosition, }} /> {serverPagination && ( 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 add287c6f05e..2944477696ee 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 @@ -42,6 +42,7 @@ import TimeComparisonVisibility from './AgGridTable/components/TimeComparisonVis import { useColDefs } from './utils/useColDefs'; import { getCrossFilterDataMask } from './utils/getCrossFilterDataMask'; import { StyledChartContainer } from './styles'; +import type { FilterState } from './utils/filterStateManager'; const getGridHeight = (height: number, includeSearch: boolean | undefined) => { let calculatedGridHeight = height; @@ -88,6 +89,15 @@ export default function TableChart( const [searchOptions, setSearchOptions] = useState([]); + // Extract metric column names for SQL conversion + const metricColumns = useMemo( + () => + columns + .filter(col => col.isMetric || col.isPercentMetric) + .map(col => col.key), + [columns], + ); + useEffect(() => { const options = columns .filter(col => col?.dataType === GenericDataType.String) @@ -101,6 +111,29 @@ export default function TableChart( } }, [columns]); + useEffect(() => { + if (!serverPagination || !serverPaginationData || !rowCount) return; + + const currentPage = serverPaginationData.currentPage ?? 0; + const currentPageSize = serverPaginationData.pageSize ?? serverPageLength; + const totalPages = Math.ceil(rowCount / currentPageSize); + + if (currentPage >= totalPages && totalPages > 0) { + const validPage = Math.max(0, totalPages - 1); + const modifiedOwnState = { + ...serverPaginationData, + currentPage: validPage, + }; + updateTableOwnState(setDataMask, modifiedOwnState); + } + }, [ + rowCount, + serverPagination, + serverPaginationData, + serverPageLength, + setDataMask, + ]); + const comparisonColumns = [ { key: 'all', label: t('Display all') }, { key: '#', label: '#' }, @@ -121,6 +154,52 @@ export default function TableChart( [onChartStateChange], ); + const handleFilterChanged = useCallback( + (completeFilterState: FilterState) => { + if (!serverPagination) return; + // Sync chartState immediately with the new filter model to prevent stale state + // This ensures chartState and ownState are in sync + if (onChartStateChange && chartState) { + const filterModel = + completeFilterState.originalFilterModel && + Object.keys(completeFilterState.originalFilterModel).length > 0 + ? completeFilterState.originalFilterModel + : undefined; + const updatedChartState = { + ...chartState, + filterModel, + timestamp: Date.now(), + }; + onChartStateChange(updatedChartState); + } + + // Prepare modified own state for server pagination + const modifiedOwnState = { + ...serverPaginationData, + agGridFilterModel: + completeFilterState.originalFilterModel && + Object.keys(completeFilterState.originalFilterModel).length > 0 + ? completeFilterState.originalFilterModel + : undefined, + agGridSimpleFilters: completeFilterState.simpleFilters, + agGridComplexWhere: completeFilterState.complexWhere, + agGridHavingClause: completeFilterState.havingClause, + lastFilteredColumn: completeFilterState.lastFilteredColumn, + lastFilteredInputPosition: completeFilterState.inputPosition, + currentPage: 0, // Reset to first page when filtering + }; + + updateTableOwnState(setDataMask, modifiedOwnState); + }, + [ + setDataMask, + serverPagination, + serverPaginationData, + onChartStateChange, + chartState, + ], + ); + const filteredColumns = useMemo(() => { if (!isUsingTimeComparison) { return columns; @@ -206,6 +285,8 @@ export default function TableChart( ...serverPaginationData, currentPage: pageNumber, pageSize, + lastFilteredColumn: undefined, + lastFilteredInputPosition: undefined, }; updateTableOwnState(setDataMask, modifiedOwnState); }, @@ -218,6 +299,8 @@ export default function TableChart( ...serverPaginationData, currentPage: 0, pageSize, + lastFilteredColumn: undefined, + lastFilteredInputPosition: undefined, }; updateTableOwnState(setDataMask, modifiedOwnState); }, @@ -230,6 +313,8 @@ export default function TableChart( ...serverPaginationData, searchColumn: searchCol, searchText: '', + lastFilteredColumn: undefined, + lastFilteredInputPosition: undefined, }; updateTableOwnState(setDataMask, modifiedOwnState); } @@ -243,6 +328,8 @@ export default function TableChart( serverPaginationData?.searchColumn || searchOptions[0]?.value, searchText, currentPage: 0, // Reset to first page when searching + lastFilteredColumn: undefined, + lastFilteredInputPosition: undefined, }; updateTableOwnState(setDataMask, modifiedOwnState); }, @@ -255,6 +342,8 @@ export default function TableChart( const modifiedOwnState = { ...serverPaginationData, sortBy, + lastFilteredColumn: undefined, + lastFilteredInputPosition: undefined, }; updateTableOwnState(setDataMask, modifiedOwnState); }, @@ -288,6 +377,8 @@ export default function TableChart( onSearchColChange={handleChangeSearchCol} onSearchChange={handleSearch} onSortChange={handleSortByChange} + onFilterChanged={handleFilterChanged} + metricColumns={metricColumns} id={slice_id} handleCrossFilter={toggleFilter} percentMetrics={percentMetrics} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts index 2749071368b8..293509ff1aac 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts @@ -22,6 +22,7 @@ import { ensureIsArray, getColumnLabel, getMetricLabel, + isDefined, isPhysicalColumn, QueryFormColumn, QueryFormMetric, @@ -253,13 +254,23 @@ const buildQuery: BuildQuery = ( }; sortByFromOwnState = sortSource - .map((sortItem: any) => { - const colId = sortItem?.colId || sortItem?.key; - const sortKey = mapColIdToIdentifier(colId); - if (!sortKey) return null; - const isDesc = sortItem?.sort === 'desc' || sortItem?.desc; - return [sortKey, !isDesc] as QueryFormOrderBy; - }) + .map( + (sortItem: { + colId?: string | number; + key?: string | number; + sort?: string; + desc?: boolean; + }) => { + const colId = isDefined(sortItem?.colId) + ? sortItem.colId + : sortItem?.key; + if (!isDefined(colId)) return null; + const sortKey = mapColIdToIdentifier(String(colId)); + if (!sortKey) return null; + const isDesc = sortItem?.sort === 'desc' || sortItem?.desc; + return [sortKey, !isDesc] as QueryFormOrderBy; + }, + ) .filter((item): item is QueryFormOrderBy => item !== null); // Add secondary sort for stable ordering (matches AG Grid's stable sort behavior) @@ -361,6 +372,8 @@ const buildQuery: BuildQuery = ( ...options?.ownState, currentPage: 0, pageSize: queryObject.row_limit ?? 0, + lastFilteredColumn: undefined, + lastFilteredInputPosition: undefined, }; updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState); } @@ -370,21 +383,6 @@ const buildQuery: BuildQuery = ( }); const extraQueries: QueryObject[] = []; - if ( - metrics?.length && - formData.show_totals && - queryMode === QueryMode.Aggregate - ) { - extraQueries.push({ - ...queryObject, - columns: [], - row_limit: 0, - row_offset: 0, - post_processing: [], - order_desc: undefined, // we don't need orderby stuff here, - orderby: undefined, // because this query will be used for get total aggregation. - }); - } const interactiveGroupBy = formData.extra_form_data?.interactive_groupby; if (interactiveGroupBy && queryObject.columns) { @@ -445,6 +443,70 @@ const buildQuery: BuildQuery = ( ], }; } + // Add AG Grid column filters from ownState (non-metric filters only) + if ( + ownState.agGridSimpleFilters && + ownState.agGridSimpleFilters.length > 0 + ) { + // Get columns that have AG Grid filters + const agGridFilterColumns = new Set( + ownState.agGridSimpleFilters.map( + (filter: { col: string }) => filter.col, + ), + ); + + // Remove existing TEMPORAL_RANGE filters for columns that have new AG Grid filters + // This prevents duplicate filters like "No filter" and actual date ranges + const existingFilters = (queryObject.filters || []).filter(filter => { + // Keep filter if it doesn't have the expected structure + if (!filter || typeof filter !== 'object' || !filter.col) { + return true; + } + // Keep filter if it's not a temporal range filter + if (filter.op !== 'TEMPORAL_RANGE') { + return true; + } + // Remove if this column has an AG Grid filter + return !agGridFilterColumns.has(filter.col); + }); + + queryObject = { + ...queryObject, + filters: [...existingFilters, ...ownState.agGridSimpleFilters], + }; + } + + // Add AG Grid complex WHERE clause from ownState (non-metric filters) + if (ownState.agGridComplexWhere && ownState.agGridComplexWhere.trim()) { + const existingWhere = queryObject.extras?.where; + const combinedWhere = existingWhere + ? `${existingWhere} AND ${ownState.agGridComplexWhere}` + : ownState.agGridComplexWhere; + + queryObject = { + ...queryObject, + extras: { + ...queryObject.extras, + where: combinedWhere, + }, + }; + } + + // Add AG Grid HAVING clause from ownState (metric filters only) + if (ownState.agGridHavingClause && ownState.agGridHavingClause.trim()) { + const existingHaving = queryObject.extras?.having; + const combinedHaving = existingHaving + ? `${existingHaving} AND ${ownState.agGridHavingClause}` + : ownState.agGridHavingClause; + + queryObject = { + ...queryObject, + extras: { + ...queryObject.extras, + having: combinedHaving, + }, + }; + } } if (isDownloadQuery) { @@ -481,6 +543,54 @@ const buildQuery: BuildQuery = ( } } + // Create totals query AFTER all filters (including AG Grid filters) are applied + // This ensures we can properly exclude AG Grid WHERE filters from the totals + if ( + metrics?.length && + formData.show_totals && + queryMode === QueryMode.Aggregate + ) { + // Create a copy of extras without the AG Grid WHERE clause + // AG Grid filters in extras.where can reference calculated columns + // which aren't available in the totals subquery + const totalsExtras = { ...queryObject.extras }; + if (ownState.agGridComplexWhere) { + // Remove AG Grid WHERE clause from totals query + const whereClause = totalsExtras.where; + if (whereClause) { + // Remove the AG Grid filter part from the WHERE clause using string methods + const agGridWhere = ownState.agGridComplexWhere; + let newWhereClause = whereClause; + + // Try to remove with " AND " before + newWhereClause = newWhereClause.replace(` AND ${agGridWhere}`, ''); + // Try to remove with " AND " after + newWhereClause = newWhereClause.replace(`${agGridWhere} AND `, ''); + // If it's the only clause, remove it entirely + if (newWhereClause === agGridWhere) { + newWhereClause = ''; + } + + if (newWhereClause.trim()) { + totalsExtras.where = newWhereClause; + } else { + delete totalsExtras.where; + } + } + } + + extraQueries.push({ + ...queryObject, + columns: [], + extras: totalsExtras, // Use extras with AG Grid WHERE removed + row_limit: 0, + row_offset: 0, + post_processing: [], + order_desc: undefined, // we don't need orderby stuff here, + orderby: undefined, // because this query will be used for get total aggregation. + }); + } + // Now since row limit control is always visible even // in case of server pagination // we must use row limit from form data @@ -506,8 +616,8 @@ const buildQuery: BuildQuery = ( // Use this closure to cache changing of external filters, if we have server pagination we need reset page to 0, after // external filter changed export const cachedBuildQuery = (): BuildQuery => { - let cachedChanges: any = {}; - const setCachedChanges = (newChanges: any) => { + let cachedChanges: Record = {}; + const setCachedChanges = (newChanges: Record) => { cachedChanges = { ...cachedChanges, ...newChanges }; }; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts index 995e43ea6729..29d43a17aca4 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/consts.ts @@ -27,3 +27,18 @@ export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100, 200]; export const CUSTOM_AGG_FUNCS = { queryTotal: 'Metric total', }; + +export const FILTER_POPOVER_OPEN_DELAY = 200; +export const FILTER_INPUT_SELECTOR = 'input[data-ref="eInput"]'; +export const NOOP_FILTER_COMPARATOR = () => 0; + +export const FILTER_INPUT_POSITIONS = { + FIRST: 'first' as const, + SECOND: 'second' as const, + UNKNOWN: 'unknown' as const, +} as const; + +export const FILTER_CONDITION_BODY_INDEX = { + FIRST: 0, + SECOND: 1, +} as const; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/NumericCellRenderer.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/NumericCellRenderer.tsx index cd1f8564bfe3..a13502374af0 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/NumericCellRenderer.tsx +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/renderers/NumericCellRenderer.tsx @@ -16,9 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import { styled, useTheme } from '@apache-superset/core/ui'; +import { styled, useTheme, type SupersetTheme } from '@apache-superset/core/ui'; import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact'; -import { BasicColorFormatterType, InputColumn } from '../types'; +import { BasicColorFormatterType, InputColumn, ValueRange } from '../types'; import { useIsDark } from '../utils/useTableTheme'; const StyledTotalCell = styled.div` @@ -53,8 +53,6 @@ const Bar = styled.div<{ z-index: 1; `; -type ValueRange = [number, number]; - /** * Cell background width calculation for horizontal bar chart */ @@ -64,7 +62,7 @@ function cellWidth({ alignPositiveNegative, }: { value: number; - valueRange: ValueRange; + valueRange: [number, number]; alignPositiveNegative: boolean; }) { const [minValue, maxValue] = valueRange; @@ -89,7 +87,7 @@ function cellOffset({ alignPositiveNegative, }: { value: number; - valueRange: ValueRange; + valueRange: [number, number]; alignPositiveNegative: boolean; }) { if (alignPositiveNegative) { @@ -114,7 +112,7 @@ function cellBackground({ value: number; colorPositiveNegative: boolean; isDarkTheme: boolean; - theme: any; + theme: SupersetTheme | null; }) { if (!colorPositiveNegative) { return 'transparent'; // Use transparent background when colorPositiveNegative is false @@ -134,7 +132,7 @@ export const NumericCellRenderer = ( basicColorFormatters: { [Key: string]: BasicColorFormatterType; }[]; - valueRange: any; + valueRange: ValueRange; alignPositiveNegative: boolean; colorPositiveNegative: boolean; }, diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/stateConversion.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/stateConversion.ts index a8b8d10f4b91..bc04be642215 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/stateConversion.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/stateConversion.ts @@ -25,30 +25,46 @@ import { type AgGridFilterModel, type AgGridFilter, } from '@superset-ui/core'; +import { + getStartOfDay, + getEndOfDay, + FILTER_OPERATORS, + SQL_OPERATORS, + validateColumnName, +} from './utils/agGridFilterConverter'; /** - * AG Grid text filter type to backend operator mapping + * Maps custom server-side date filter operators to normalized operator names. + * Server-side operators (serverEquals, serverBefore, etc.) are custom operators + * used when server_pagination is enabled to bypass client-side filtering. */ -const TEXT_FILTER_OPERATORS: Record = { - equals: '==', - notEqual: '!=', - contains: 'ILIKE', - notContains: 'NOT ILIKE', - startsWith: 'ILIKE', - endsWith: 'ILIKE', +const DATE_FILTER_OPERATOR_MAP: Record = { + // Standard operators + [FILTER_OPERATORS.EQUALS]: FILTER_OPERATORS.EQUALS, + [FILTER_OPERATORS.NOT_EQUAL]: FILTER_OPERATORS.NOT_EQUAL, + [FILTER_OPERATORS.LESS_THAN]: FILTER_OPERATORS.LESS_THAN, + [FILTER_OPERATORS.LESS_THAN_OR_EQUAL]: FILTER_OPERATORS.LESS_THAN_OR_EQUAL, + [FILTER_OPERATORS.GREATER_THAN]: FILTER_OPERATORS.GREATER_THAN, + [FILTER_OPERATORS.GREATER_THAN_OR_EQUAL]: + FILTER_OPERATORS.GREATER_THAN_OR_EQUAL, + [FILTER_OPERATORS.IN_RANGE]: FILTER_OPERATORS.IN_RANGE, + // Custom server-side operators (map to standard equivalents) + [FILTER_OPERATORS.SERVER_EQUALS]: FILTER_OPERATORS.EQUALS, + [FILTER_OPERATORS.SERVER_NOT_EQUAL]: FILTER_OPERATORS.NOT_EQUAL, + [FILTER_OPERATORS.SERVER_BEFORE]: FILTER_OPERATORS.LESS_THAN, + [FILTER_OPERATORS.SERVER_AFTER]: FILTER_OPERATORS.GREATER_THAN, + [FILTER_OPERATORS.SERVER_IN_RANGE]: FILTER_OPERATORS.IN_RANGE, }; /** - * AG Grid number filter type to backend operator mapping + * Blank filter operator types */ -const NUMBER_FILTER_OPERATORS: Record = { - equals: '==', - notEqual: '!=', - lessThan: '<', - lessThanOrEqual: '<=', - greaterThan: '>', - greaterThanOrEqual: '>=', -}; +const BLANK_OPERATORS: Set = new Set([ + FILTER_OPERATORS.BLANK, + FILTER_OPERATORS.NOT_BLANK, + FILTER_OPERATORS.SERVER_BLANK, + FILTER_OPERATORS.SERVER_NOT_BLANK, +]); /** Escapes single quotes in SQL strings: O'Hara → O''Hara */ function escapeStringValue(value: string): string { @@ -56,18 +72,77 @@ function escapeStringValue(value: string): string { } function getTextComparator(type: string, value: string): string { - if (type === 'contains' || type === 'notContains') { + if ( + type === FILTER_OPERATORS.CONTAINS || + type === FILTER_OPERATORS.NOT_CONTAINS + ) { return `%${value}%`; } - if (type === 'startsWith') { + if (type === FILTER_OPERATORS.STARTS_WITH) { return `${value}%`; } - if (type === 'endsWith') { + if (type === FILTER_OPERATORS.ENDS_WITH) { return `%${value}`; } return value; } +/** + * Converts a date filter to SQL clause. + * Handles both standard operators (equals, lessThan, etc.) and + * custom server-side operators (serverEquals, serverBefore, etc.). + * + * @param colId - Column identifier + * @param filter - AG Grid date filter object + * @returns SQL clause string or null if conversion not possible + */ +function convertDateFilterToSQL( + colId: string, + filter: AgGridFilter, +): string | null { + const { type, dateFrom, dateTo } = filter; + + if (!type) return null; + + // Map custom server operators to standard ones + const normalizedType = DATE_FILTER_OPERATOR_MAP[type] || type; + + switch (normalizedType) { + case FILTER_OPERATORS.EQUALS: + if (!dateFrom) return null; + // Full day range for equals + return `(${colId} >= '${getStartOfDay(dateFrom)}' AND ${colId} <= '${getEndOfDay(dateFrom)}')`; + + case FILTER_OPERATORS.NOT_EQUAL: + if (!dateFrom) return null; + // Outside the full day range for not equals + return `(${colId} < '${getStartOfDay(dateFrom)}' OR ${colId} > '${getEndOfDay(dateFrom)}')`; + + case FILTER_OPERATORS.LESS_THAN: + if (!dateFrom) return null; + return `${colId} < '${getStartOfDay(dateFrom)}'`; + + case FILTER_OPERATORS.LESS_THAN_OR_EQUAL: + if (!dateFrom) return null; + return `${colId} <= '${getEndOfDay(dateFrom)}'`; + + case FILTER_OPERATORS.GREATER_THAN: + if (!dateFrom) return null; + return `${colId} > '${getEndOfDay(dateFrom)}'`; + + case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL: + if (!dateFrom) return null; + return `${colId} >= '${getStartOfDay(dateFrom)}'`; + + case FILTER_OPERATORS.IN_RANGE: + if (!dateFrom || !dateTo) return null; + return `${colId} BETWEEN '${getStartOfDay(dateFrom)}' AND '${getEndOfDay(dateTo)}'`; + + default: + return null; + } +} + /** * Converts AG Grid sortModel to backend sortBy format */ @@ -111,11 +186,18 @@ export function convertColumnState( * - Complex: {operator: 'AND', condition1: {type: 'greaterThan', filter: 1}, condition2: {type: 'lessThan', filter: 16}} * → "(column_name > 1 AND column_name < 16)" * - Set: {filterType: 'set', values: ['a', 'b']} → "column_name IN ('a', 'b')" + * - Blank: {filterType: 'text', type: 'blank'} → "column_name IS NULL" + * - Date: {filterType: 'date', type: 'serverBefore', dateFrom: '2024-01-01'} → "column_name < '2024-01-01T00:00:00'" */ function convertFilterToSQL( colId: string, filter: AgGridFilter, ): string | null { + // Validate column name to prevent SQL injection and malformed queries + if (!validateColumnName(colId)) { + return null; + } + // Complex filter: has operator and conditions if ( filter.operator && @@ -137,14 +219,43 @@ function convertFilterToSQL( return `(${conditions.join(` ${filter.operator} `)})`; } + // Handle blank/notBlank operators for all filter types + // These are special operators that check for NULL values + if (filter.type && BLANK_OPERATORS.has(filter.type)) { + if ( + filter.type === FILTER_OPERATORS.BLANK || + filter.type === FILTER_OPERATORS.SERVER_BLANK + ) { + return `${colId} ${SQL_OPERATORS.IS_NULL}`; + } + if ( + filter.type === FILTER_OPERATORS.NOT_BLANK || + filter.type === FILTER_OPERATORS.SERVER_NOT_BLANK + ) { + return `${colId} ${SQL_OPERATORS.IS_NOT_NULL}`; + } + } + if (filter.filterType === 'text' && filter.filter && filter.type) { - const op = TEXT_FILTER_OPERATORS[filter.type]; const escapedFilter = escapeStringValue(String(filter.filter)); const val = getTextComparator(filter.type, escapedFilter); - return op === 'ILIKE' || op === 'NOT ILIKE' - ? `${colId} ${op} '${val}'` - : `${colId} ${op} '${escapedFilter}'`; + // Map text filter types to SQL operators + switch (filter.type) { + case FILTER_OPERATORS.EQUALS: + return `${colId} ${SQL_OPERATORS.EQUALS} '${escapedFilter}'`; + case FILTER_OPERATORS.NOT_EQUAL: + return `${colId} ${SQL_OPERATORS.NOT_EQUALS} '${escapedFilter}'`; + case FILTER_OPERATORS.CONTAINS: + return `${colId} ${SQL_OPERATORS.ILIKE} '${val}'`; + case FILTER_OPERATORS.NOT_CONTAINS: + return `${colId} ${SQL_OPERATORS.NOT_ILIKE} '${val}'`; + case FILTER_OPERATORS.STARTS_WITH: + case FILTER_OPERATORS.ENDS_WITH: + return `${colId} ${SQL_OPERATORS.ILIKE} '${val}'`; + default: + return null; + } } if ( @@ -152,14 +263,28 @@ function convertFilterToSQL( filter.filter !== undefined && filter.type ) { - const op = NUMBER_FILTER_OPERATORS[filter.type]; - return `${colId} ${op} ${filter.filter}`; + // Map number filter types to SQL operators + switch (filter.type) { + case FILTER_OPERATORS.EQUALS: + return `${colId} ${SQL_OPERATORS.EQUALS} ${filter.filter}`; + case FILTER_OPERATORS.NOT_EQUAL: + return `${colId} ${SQL_OPERATORS.NOT_EQUALS} ${filter.filter}`; + case FILTER_OPERATORS.LESS_THAN: + return `${colId} ${SQL_OPERATORS.LESS_THAN} ${filter.filter}`; + case FILTER_OPERATORS.LESS_THAN_OR_EQUAL: + return `${colId} ${SQL_OPERATORS.LESS_THAN_OR_EQUAL} ${filter.filter}`; + case FILTER_OPERATORS.GREATER_THAN: + return `${colId} ${SQL_OPERATORS.GREATER_THAN} ${filter.filter}`; + case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL: + return `${colId} ${SQL_OPERATORS.GREATER_THAN_OR_EQUAL} ${filter.filter}`; + default: + return null; + } } - if (filter.filterType === 'date' && filter.dateFrom && filter.type) { - const op = NUMBER_FILTER_OPERATORS[filter.type]; - const escapedDate = escapeStringValue(filter.dateFrom); - return `${colId} ${op} '${escapedDate}'`; + // Handle date filters with proper date formatting and custom server operators + if (filter.filterType === 'date' && filter.type) { + return convertDateFilterToSQL(colId, filter); } if ( diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts index b4b89a0a9d21..d1a4cc143a10 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts @@ -191,9 +191,20 @@ export interface SortState { sort: 'asc' | 'desc' | null; } +export type FilterInputPosition = 'first' | 'second' | 'unknown'; + +export interface AGGridFilterInstance { + eGui?: HTMLElement; + eConditionBodies?: HTMLElement[]; + eJoinAnds?: Array<{ eGui?: HTMLElement }>; + eJoinOrs?: Array<{ eGui?: HTMLElement }>; +} + export interface CustomContext { initialSortState: SortState[]; onColumnHeaderClicked: (args: { column: SortState }) => void; + lastFilteredColumn?: string; + lastFilteredInputPosition?: FilterInputPosition; } export interface CustomHeaderParams extends IHeaderParams { @@ -226,19 +237,25 @@ export interface InputColumn { isNumeric: boolean; isMetric: boolean; isPercentMetric: boolean; - config: Record; - formatter?: Function; + config: TableColumnConfig; + formatter?: + | TimeFormatter + | NumberFormatter + | CustomFormatter + | CurrencyFormatter; originalLabel?: string; metricName?: string; } +export type ValueRange = [number, number] | null; + export type CellRendererProps = CustomCellRendererProps & { hasBasicColorFormatters: boolean | undefined; col: InputColumn; basicColorFormatters: { [Key: string]: BasicColorFormatterType; }[]; - valueRange: any; + valueRange: ValueRange; alignPositiveNegative: boolean; colorPositiveNegative: boolean; allowRenderHtml: boolean; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts new file mode 100644 index 000000000000..5fe226ef84d9 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/agGridFilterConverter.ts @@ -0,0 +1,726 @@ +/** + * 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. + */ + +export type AgGridFilterType = 'text' | 'number' | 'date' | 'set' | 'boolean'; + +export type AgGridFilterOperator = + | 'equals' + | 'notEqual' + | 'contains' + | 'notContains' + | 'startsWith' + | 'endsWith' + | 'lessThan' + | 'lessThanOrEqual' + | 'greaterThan' + | 'greaterThanOrEqual' + | 'inRange' + | 'blank' + | 'notBlank' + // Custom server-side date filter operators (always pass client-side filtering) + | 'serverEquals' + | 'serverNotEqual' + | 'serverBefore' + | 'serverAfter' + | 'serverInRange' + | 'serverBlank' + | 'serverNotBlank'; + +export type AgGridLogicalOperator = 'AND' | 'OR'; + +export const FILTER_OPERATORS = { + EQUALS: 'equals' as const, + NOT_EQUAL: 'notEqual' as const, + CONTAINS: 'contains' as const, + NOT_CONTAINS: 'notContains' as const, + STARTS_WITH: 'startsWith' as const, + ENDS_WITH: 'endsWith' as const, + LESS_THAN: 'lessThan' as const, + LESS_THAN_OR_EQUAL: 'lessThanOrEqual' as const, + GREATER_THAN: 'greaterThan' as const, + GREATER_THAN_OR_EQUAL: 'greaterThanOrEqual' as const, + IN_RANGE: 'inRange' as const, + BLANK: 'blank' as const, + NOT_BLANK: 'notBlank' as const, + // Custom server-side date filter operators + SERVER_EQUALS: 'serverEquals' as const, + SERVER_NOT_EQUAL: 'serverNotEqual' as const, + SERVER_BEFORE: 'serverBefore' as const, + SERVER_AFTER: 'serverAfter' as const, + SERVER_IN_RANGE: 'serverInRange' as const, + SERVER_BLANK: 'serverBlank' as const, + SERVER_NOT_BLANK: 'serverNotBlank' as const, +} as const; + +export const SQL_OPERATORS = { + EQUALS: '=', + NOT_EQUALS: '!=', + ILIKE: 'ILIKE', + NOT_ILIKE: 'NOT ILIKE', + LESS_THAN: '<', + LESS_THAN_OR_EQUAL: '<=', + GREATER_THAN: '>', + GREATER_THAN_OR_EQUAL: '>=', + BETWEEN: 'BETWEEN', + IS_NULL: 'IS NULL', + IS_NOT_NULL: 'IS NOT NULL', + IN: 'IN', + TEMPORAL_RANGE: 'TEMPORAL_RANGE', +} as const; + +export type FilterValue = string | number | boolean | Date | null; + +// Regex for validating column names. Allows: +// - Alphanumeric chars, underscores, dots, spaces (standard column names) +// - Parentheses for aggregate functions like COUNT(*) +// - % for LIKE patterns, * for wildcards, + - / for computed columns +const COLUMN_NAME_REGEX = /^[a-zA-Z0-9_. ()%*+\-/]+$/; + +export interface AgGridSimpleFilter { + filterType: AgGridFilterType; + type: AgGridFilterOperator; + filter?: FilterValue; + filterTo?: FilterValue; + // Date filter properties + dateFrom?: string | null; + dateTo?: string | null; +} + +export interface AgGridCompoundFilter { + filterType: AgGridFilterType; + operator: AgGridLogicalOperator; + condition1: AgGridSimpleFilter; + condition2: AgGridSimpleFilter; + conditions?: AgGridSimpleFilter[]; +} + +export interface AgGridSetFilter { + filterType: 'set'; + values: FilterValue[]; +} + +export type AgGridFilterModel = Record< + string, + AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter +>; + +export interface SQLAlchemyFilter { + col: string; + op: string; + val: FilterValue | FilterValue[]; +} + +export interface ConvertedFilter { + simpleFilters: SQLAlchemyFilter[]; + complexWhere?: string; + havingClause?: string; +} + +const AG_GRID_TO_SQLA_OPERATOR_MAP: Record = { + [FILTER_OPERATORS.EQUALS]: SQL_OPERATORS.EQUALS, + [FILTER_OPERATORS.NOT_EQUAL]: SQL_OPERATORS.NOT_EQUALS, + [FILTER_OPERATORS.CONTAINS]: SQL_OPERATORS.ILIKE, + [FILTER_OPERATORS.NOT_CONTAINS]: SQL_OPERATORS.NOT_ILIKE, + [FILTER_OPERATORS.STARTS_WITH]: SQL_OPERATORS.ILIKE, + [FILTER_OPERATORS.ENDS_WITH]: SQL_OPERATORS.ILIKE, + [FILTER_OPERATORS.LESS_THAN]: SQL_OPERATORS.LESS_THAN, + [FILTER_OPERATORS.LESS_THAN_OR_EQUAL]: SQL_OPERATORS.LESS_THAN_OR_EQUAL, + [FILTER_OPERATORS.GREATER_THAN]: SQL_OPERATORS.GREATER_THAN, + [FILTER_OPERATORS.GREATER_THAN_OR_EQUAL]: SQL_OPERATORS.GREATER_THAN_OR_EQUAL, + [FILTER_OPERATORS.IN_RANGE]: SQL_OPERATORS.BETWEEN, + [FILTER_OPERATORS.BLANK]: SQL_OPERATORS.IS_NULL, + [FILTER_OPERATORS.NOT_BLANK]: SQL_OPERATORS.IS_NOT_NULL, + // Server-side date filter operators (map to same SQL operators as standard ones) + [FILTER_OPERATORS.SERVER_EQUALS]: SQL_OPERATORS.EQUALS, + [FILTER_OPERATORS.SERVER_NOT_EQUAL]: SQL_OPERATORS.NOT_EQUALS, + [FILTER_OPERATORS.SERVER_BEFORE]: SQL_OPERATORS.LESS_THAN, + [FILTER_OPERATORS.SERVER_AFTER]: SQL_OPERATORS.GREATER_THAN, + [FILTER_OPERATORS.SERVER_IN_RANGE]: SQL_OPERATORS.BETWEEN, + [FILTER_OPERATORS.SERVER_BLANK]: SQL_OPERATORS.IS_NULL, + [FILTER_OPERATORS.SERVER_NOT_BLANK]: SQL_OPERATORS.IS_NOT_NULL, +}; + +/** + * Escapes single quotes in SQL strings to prevent SQL injection + * @param value - String value to escape + * @returns Escaped string safe for SQL queries + */ +function escapeSQLString(value: string): string { + return value.replace(/'/g, "''"); +} + +// Maximum column name length - conservative upper bound that exceeds all common +// database identifier limits (MySQL: 64, PostgreSQL: 63, SQL Server: 128, Oracle: 128) +const MAX_COLUMN_NAME_LENGTH = 255; + +/** + * Validates a column name to prevent SQL injection + * Checks for: non-empty string, length limit, allowed characters + */ +export function validateColumnName(columnName: string): boolean { + if (!columnName || typeof columnName !== 'string') { + return false; + } + + if (columnName.length > MAX_COLUMN_NAME_LENGTH) { + return false; + } + + if (!COLUMN_NAME_REGEX.test(columnName)) { + return false; + } + + return true; +} + +/** + * Validates a filter value for a given operator + * BLANK and NOT_BLANK operators don't require values + * @param value - Filter value to validate + * @param operator - AG Grid filter operator + * @returns True if the value is valid for the operator, false otherwise + */ +function validateFilterValue( + value: FilterValue | undefined, + operator: AgGridFilterOperator, +): boolean { + if ( + operator === FILTER_OPERATORS.BLANK || + operator === FILTER_OPERATORS.NOT_BLANK + ) { + return true; + } + + if (value === undefined) { + return false; + } + + const valueType = typeof value; + if ( + value !== null && + valueType !== 'string' && + valueType !== 'number' && + valueType !== 'boolean' && + !(value instanceof Date) + ) { + return false; + } + + return true; +} + +function formatValueForOperator( + operator: AgGridFilterOperator, + value: FilterValue, +): FilterValue { + if (typeof value === 'string') { + if ( + operator === FILTER_OPERATORS.CONTAINS || + operator === FILTER_OPERATORS.NOT_CONTAINS + ) { + return `%${value}%`; + } + if (operator === FILTER_OPERATORS.STARTS_WITH) { + return `${value}%`; + } + if (operator === FILTER_OPERATORS.ENDS_WITH) { + return `%${value}`; + } + } + return value; +} + +/** + * Convert a date filter to a WHERE clause + * @param columnName - Column name + * @param filter - AG Grid date filter + * @returns WHERE clause string for date filter + */ +function dateFilterToWhereClause( + columnName: string, + filter: AgGridSimpleFilter, +): string { + const { type, dateFrom, dateTo, filter: filterValue, filterTo } = filter; + + // Support both dateFrom/dateTo and filter/filterTo + const fromDate = dateFrom || (filterValue as string); + const toDate = dateTo || (filterTo as string); + + // Convert based on operator type + switch (type) { + case FILTER_OPERATORS.EQUALS: + if (!fromDate) return ''; + // For equals, check if date is within the full day range + return `(${columnName} >= '${getStartOfDay(fromDate)}' AND ${columnName} <= '${getEndOfDay(fromDate)}')`; + + case FILTER_OPERATORS.NOT_EQUAL: + if (!fromDate) return ''; + // For not equals, exclude the full day range + return `(${columnName} < '${getStartOfDay(fromDate)}' OR ${columnName} > '${getEndOfDay(fromDate)}')`; + + case FILTER_OPERATORS.LESS_THAN: + if (!fromDate) return ''; + return `${columnName} < '${getStartOfDay(fromDate)}'`; + + case FILTER_OPERATORS.LESS_THAN_OR_EQUAL: + if (!fromDate) return ''; + return `${columnName} <= '${getEndOfDay(fromDate)}'`; + + case FILTER_OPERATORS.GREATER_THAN: + if (!fromDate) return ''; + return `${columnName} > '${getEndOfDay(fromDate)}'`; + + case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL: + if (!fromDate) return ''; + return `${columnName} >= '${getStartOfDay(fromDate)}'`; + + case FILTER_OPERATORS.IN_RANGE: + if (!fromDate || !toDate) return ''; + return `${columnName} ${SQL_OPERATORS.BETWEEN} '${getStartOfDay(fromDate)}' AND '${getEndOfDay(toDate)}'`; + + case FILTER_OPERATORS.BLANK: + return `${columnName} ${SQL_OPERATORS.IS_NULL}`; + + case FILTER_OPERATORS.NOT_BLANK: + return `${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`; + + default: + return ''; + } +} + +function simpleFilterToWhereClause( + columnName: string, + filter: AgGridSimpleFilter, +): string { + // Check if this is a date filter and handle it specially + if (filter.filterType === 'date') { + return dateFilterToWhereClause(columnName, filter); + } + + const { type, filter: value, filterTo } = filter; + + const operator = AG_GRID_TO_SQLA_OPERATOR_MAP[type]; + if (!operator) { + return ''; + } + + if (!validateFilterValue(value, type)) { + return ''; + } + + if (type === FILTER_OPERATORS.BLANK) { + return `${columnName} ${SQL_OPERATORS.IS_NULL}`; + } + + if (type === FILTER_OPERATORS.NOT_BLANK) { + return `${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`; + } + + if (value === null || value === undefined) { + return ''; + } + + if (type === FILTER_OPERATORS.IN_RANGE && filterTo !== undefined) { + return `${columnName} ${SQL_OPERATORS.BETWEEN} ${value} AND ${filterTo}`; + } + + const formattedValue = formatValueForOperator(type, value!); + + if ( + operator === SQL_OPERATORS.ILIKE || + operator === SQL_OPERATORS.NOT_ILIKE + ) { + return `${columnName} ${operator} '${escapeSQLString(String(formattedValue))}'`; + } + + if (typeof formattedValue === 'string') { + return `${columnName} ${operator} '${escapeSQLString(formattedValue)}'`; + } + + return `${columnName} ${operator} ${formattedValue}`; +} + +function isCompoundFilter( + filter: AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter, +): filter is AgGridCompoundFilter { + return ( + 'operator' in filter && ('condition1' in filter || 'conditions' in filter) + ); +} + +function isSetFilter( + filter: AgGridSimpleFilter | AgGridCompoundFilter | AgGridSetFilter, +): filter is AgGridSetFilter { + return filter.filterType === 'set' && 'values' in filter; +} + +function compoundFilterToWhereClause( + columnName: string, + filter: AgGridCompoundFilter, +): string { + const { operator, condition1, condition2, conditions } = filter; + + if (conditions && conditions.length > 0) { + const clauses = conditions + .map(cond => { + const clause = simpleFilterToWhereClause(columnName, cond); + + return clause; + }) + .filter(clause => clause !== ''); + + if (clauses.length === 0) { + return ''; + } + + if (clauses.length === 1) { + return clauses[0]; + } + + const result = `(${clauses.join(` ${operator} `)})`; + + return result; + } + + const clause1 = simpleFilterToWhereClause(columnName, condition1); + const clause2 = simpleFilterToWhereClause(columnName, condition2); + + if (!clause1 && !clause2) { + return ''; + } + + if (!clause1) { + return clause2; + } + + if (!clause2) { + return clause1; + } + + const result = `(${clause1} ${operator} ${clause2})`; + return result; +} + +/** + * Format a date string to ISO format expected by Superset, preserving local timezone + */ +export function formatDateForSuperset(dateStr: string): string { + // AG Grid typically provides dates in format: "YYYY-MM-DD HH:MM:SS" + // Superset expects: "YYYY-MM-DDTHH:MM:SS" in local timezone (not UTC) + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) { + return dateStr; // Return as-is if invalid + } + + // Format date in local timezone, not UTC + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + + const formatted = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; + return formatted; +} + +/** + * Get the start of day (00:00:00) for a given date string + */ +export function getStartOfDay(dateStr: string): string { + const date = new Date(dateStr); + date.setHours(0, 0, 0, 0); + return formatDateForSuperset(date.toISOString()); +} + +/** + * Get the end of day (23:59:59) for a given date string + */ +export function getEndOfDay(dateStr: string): string { + const date = new Date(dateStr); + date.setHours(23, 59, 59, 999); + return formatDateForSuperset(date.toISOString()); +} + +// Converts date filters to TEMPORAL_RANGE format for Superset backend +function convertDateFilter( + columnName: string, + filter: AgGridSimpleFilter, +): SQLAlchemyFilter | null { + if (filter.filterType !== 'date') { + return null; + } + + const { type, dateFrom, dateTo } = filter; + + // Handle null/blank checks for date columns + if ( + type === FILTER_OPERATORS.BLANK || + type === FILTER_OPERATORS.SERVER_BLANK + ) { + return { + col: columnName, + op: SQL_OPERATORS.IS_NULL, + val: null, + }; + } + + if ( + type === FILTER_OPERATORS.NOT_BLANK || + type === FILTER_OPERATORS.SERVER_NOT_BLANK + ) { + return { + col: columnName, + op: SQL_OPERATORS.IS_NOT_NULL, + val: null, + }; + } + + // Validate we have at least one date + if (!dateFrom && !dateTo) { + return null; + } + + let temporalRangeValue: string; + + // Convert based on operator type + switch (type) { + case FILTER_OPERATORS.EQUALS: + case FILTER_OPERATORS.SERVER_EQUALS: + if (!dateFrom) { + return null; + } + // For equals, create a range for the entire day (00:00:00 to 23:59:59) + temporalRangeValue = `${getStartOfDay(dateFrom)} : ${getEndOfDay(dateFrom)}`; + break; + + case FILTER_OPERATORS.NOT_EQUAL: + case FILTER_OPERATORS.SERVER_NOT_EQUAL: + // NOT EQUAL for dates is complex, skip for now + return null; + + case FILTER_OPERATORS.LESS_THAN: + case FILTER_OPERATORS.SERVER_BEFORE: + if (!dateFrom) { + return null; + } + // Everything before the start of this date + temporalRangeValue = ` : ${getStartOfDay(dateFrom)}`; + break; + + case FILTER_OPERATORS.LESS_THAN_OR_EQUAL: + if (!dateFrom) { + return null; + } + // Everything up to and including the end of this date + temporalRangeValue = ` : ${getEndOfDay(dateFrom)}`; + break; + + case FILTER_OPERATORS.GREATER_THAN: + case FILTER_OPERATORS.SERVER_AFTER: + if (!dateFrom) { + return null; + } + // Everything after the end of this date + temporalRangeValue = `${getEndOfDay(dateFrom)} : `; + break; + + case FILTER_OPERATORS.GREATER_THAN_OR_EQUAL: + if (!dateFrom) { + return null; + } + // Everything from the start of this date onwards + temporalRangeValue = `${getStartOfDay(dateFrom)} : `; + break; + + case FILTER_OPERATORS.IN_RANGE: + case FILTER_OPERATORS.SERVER_IN_RANGE: + // Range between two dates + if (!dateFrom || !dateTo) { + return null; + } + // From start of first date to end of second date + temporalRangeValue = `${getStartOfDay(dateFrom)} : ${getEndOfDay(dateTo)}`; + break; + + default: + return null; + } + + const result = { + col: columnName, + op: SQL_OPERATORS.TEMPORAL_RANGE, + val: temporalRangeValue, + }; + + return result; +} + +// Converts AG Grid filters to SQLAlchemy format, separating dimension (WHERE) and metric (HAVING) filters +export function convertAgGridFiltersToSQL( + filterModel: AgGridFilterModel, + metricColumns: string[] = [], +): ConvertedFilter { + if (!filterModel || typeof filterModel !== 'object') { + return { + simpleFilters: [], + complexWhere: undefined, + havingClause: undefined, + }; + } + + const metricColumnsSet = new Set(metricColumns); + const simpleFilters: SQLAlchemyFilter[] = []; + const complexWhereClauses: string[] = []; + const complexHavingClauses: string[] = []; + + Object.entries(filterModel).forEach(([columnName, filter]) => { + if (!validateColumnName(columnName)) { + return; + } + + if (!filter || typeof filter !== 'object') { + return; + } + + const isMetric = metricColumnsSet.has(columnName); + + if (isSetFilter(filter)) { + if (!Array.isArray(filter.values) || filter.values.length === 0) { + return; + } + + if (isMetric) { + const values = filter.values + .map(v => (typeof v === 'string' ? `'${escapeSQLString(v)}'` : v)) + .join(', '); + complexHavingClauses.push(`${columnName} IN (${values})`); + } else { + simpleFilters.push({ + col: columnName, + op: SQL_OPERATORS.IN, + val: filter.values, + }); + } + return; + } + + if (isCompoundFilter(filter)) { + const whereClause = compoundFilterToWhereClause(columnName, filter); + if (whereClause) { + if (isMetric) { + complexHavingClauses.push(whereClause); + } else { + complexWhereClauses.push(whereClause); + } + } + return; + } + + const simpleFilter = filter as AgGridSimpleFilter; + + // Check if this is a date filter and handle it specially + if (simpleFilter.filterType === 'date') { + const dateFilter = convertDateFilter(columnName, simpleFilter); + if (dateFilter) { + simpleFilters.push(dateFilter); + return; + } + } + + const { type, filter: value } = simpleFilter; + + if (!type) { + return; + } + + const operator = AG_GRID_TO_SQLA_OPERATOR_MAP[type]; + if (!operator) { + return; + } + + if (type === FILTER_OPERATORS.BLANK) { + if (isMetric) { + complexHavingClauses.push(`${columnName} ${SQL_OPERATORS.IS_NULL}`); + } else { + simpleFilters.push({ + col: columnName, + op: SQL_OPERATORS.IS_NULL, + val: null, + }); + } + return; + } + + if (type === FILTER_OPERATORS.NOT_BLANK) { + if (isMetric) { + complexHavingClauses.push(`${columnName} ${SQL_OPERATORS.IS_NOT_NULL}`); + } else { + simpleFilters.push({ + col: columnName, + op: SQL_OPERATORS.IS_NOT_NULL, + val: null, + }); + } + return; + } + + if (!validateFilterValue(value, type)) { + return; + } + + const formattedValue = formatValueForOperator(type, value!); + + if (isMetric) { + const sqlClause = simpleFilterToWhereClause(columnName, simpleFilter); + if (sqlClause) { + complexHavingClauses.push(sqlClause); + } + } else { + simpleFilters.push({ + col: columnName, + op: operator, + val: formattedValue, + }); + } + }); + + let complexWhere; + if (complexWhereClauses.length === 1) { + [complexWhere] = complexWhereClauses; + } else if (complexWhereClauses.length > 1) { + complexWhere = `(${complexWhereClauses.join(' AND ')})`; + } + + let havingClause; + if (complexHavingClauses.length === 1) { + [havingClause] = complexHavingClauses; + } else if (complexHavingClauses.length > 1) { + havingClause = `(${complexHavingClauses.join(' AND ')})`; + } + + const result = { + simpleFilters, + complexWhere, + havingClause, + }; + + return result; +} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/filterStateManager.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/filterStateManager.ts new file mode 100644 index 000000000000..85304bb41194 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/filterStateManager.ts @@ -0,0 +1,164 @@ +/** + * 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 type { RefObject } from 'react'; +import { GridApi } from 'ag-grid-community'; +import { convertAgGridFiltersToSQL } from './agGridFilterConverter'; +import type { + AgGridFilterModel, + SQLAlchemyFilter, +} from './agGridFilterConverter'; +import type { AgGridReact } from '@superset-ui/core/components/ThemedAgGridReact'; +import type { FilterInputPosition, AGGridFilterInstance } from '../types'; +import { FILTER_INPUT_POSITIONS, FILTER_CONDITION_BODY_INDEX } from '../consts'; + +export interface FilterState { + originalFilterModel: AgGridFilterModel; + simpleFilters: SQLAlchemyFilter[]; + complexWhere?: string; + havingClause?: string; + lastFilteredColumn?: string; + inputPosition?: FilterInputPosition; +} + +/** + * Detects which input position (first or second) was last modified in a filter. + * Note: activeElement is captured before async operations and passed here to ensure + * we check against the element that was focused when the detection was initiated, + * not what might be focused after async operations complete. + */ +async function detectLastFilteredInput( + gridApi: GridApi, + filterModel: AgGridFilterModel, + activeElement: HTMLElement, +): Promise<{ + lastFilteredColumn?: string; + inputPosition: FilterInputPosition; +}> { + let inputPosition: FilterInputPosition = FILTER_INPUT_POSITIONS.UNKNOWN; + let lastFilteredColumn: string | undefined; + + // Loop through filtered columns to find which one contains the active element + for (const [colId] of Object.entries(filterModel)) { + const filterInstance = (await gridApi.getColumnFilterInstance( + colId, + )) as AGGridFilterInstance | null; + + if (!filterInstance) { + continue; + } + + if (filterInstance.eConditionBodies) { + const conditionBodies = filterInstance.eConditionBodies; + + // Check first condition body + if ( + conditionBodies[FILTER_CONDITION_BODY_INDEX.FIRST]?.contains( + activeElement, + ) + ) { + inputPosition = FILTER_INPUT_POSITIONS.FIRST; + lastFilteredColumn = colId; + break; + } + + // Check second condition body + if ( + conditionBodies[FILTER_CONDITION_BODY_INDEX.SECOND]?.contains( + activeElement, + ) + ) { + inputPosition = FILTER_INPUT_POSITIONS.SECOND; + lastFilteredColumn = colId; + break; + } + } + + if (filterInstance.eJoinAnds) { + for (const joinAnd of filterInstance.eJoinAnds) { + if (joinAnd.eGui?.contains(activeElement)) { + inputPosition = FILTER_INPUT_POSITIONS.FIRST; + lastFilteredColumn = colId; + break; + } + } + if (lastFilteredColumn) break; + } + + if (filterInstance.eJoinOrs) { + for (const joinOr of filterInstance.eJoinOrs) { + if (joinOr.eGui?.contains(activeElement)) { + inputPosition = FILTER_INPUT_POSITIONS.FIRST; + lastFilteredColumn = colId; + break; + } + } + if (lastFilteredColumn) break; + } + } + + return { lastFilteredColumn, inputPosition }; +} + +/** + * Gets complete filter state including SQL conversion and input position detection. + */ +export async function getCompleteFilterState( + gridRef: RefObject, + metricColumns: string[], +): Promise { + // Capture activeElement before any async operations to detect which input + // was focused when the user triggered the filter change + const activeElement = document.activeElement as HTMLElement; + + if (!gridRef.current?.api) { + return { + originalFilterModel: {}, + simpleFilters: [], + complexWhere: undefined, + havingClause: undefined, + lastFilteredColumn: undefined, + inputPosition: FILTER_INPUT_POSITIONS.UNKNOWN, + }; + } + + const filterModel = gridRef.current.api.getFilterModel(); + + // Convert filters to SQL + const convertedFilters = convertAgGridFiltersToSQL( + filterModel, + metricColumns, + ); + + // Detect which input was last modified + const { lastFilteredColumn, inputPosition } = await detectLastFilteredInput( + gridRef.current.api, + filterModel, + activeElement, + ); + + return { + originalFilterModel: filterModel, + simpleFilters: convertedFilters.simpleFilters, + complexWhere: convertedFilters.complexWhere, + havingClause: convertedFilters.havingClause, + lastFilteredColumn, + inputPosition, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getInitialFilterModel.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getInitialFilterModel.ts new file mode 100644 index 000000000000..bfe3c054be48 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getInitialFilterModel.ts @@ -0,0 +1,42 @@ +/** + * 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 { isEmpty } from 'lodash'; +import type { AgGridChartState } from '@superset-ui/core'; + +const getInitialFilterModel = ( + chartState?: Partial, + serverPaginationData?: Record, + serverPagination?: boolean, +): Record | undefined => { + const chartStateFilterModel = + chartState?.filterModel && !isEmpty(chartState.filterModel) + ? (chartState.filterModel as Record) + : undefined; + + const serverFilterModel = + serverPagination && + serverPaginationData?.agGridFilterModel && + !isEmpty(serverPaginationData.agGridFilterModel) + ? (serverPaginationData.agGridFilterModel as Record) + : undefined; + + return chartStateFilterModel ?? serverFilterModel; +}; + +export default getInitialFilterModel; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useColDefs.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useColDefs.ts index 89f652b1cf48..1879260d451c 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useColDefs.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/useColDefs.ts @@ -19,7 +19,7 @@ */ import { ColDef } from '@superset-ui/core/components/ThemedAgGridReact'; import { useCallback, useMemo } from 'react'; -import { DataRecord } from '@superset-ui/core'; +import { DataRecord, DataRecordValue } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/api/core'; import { ColorFormatters } from '@superset-ui/chart-controls'; import { extent as d3Extent, max as d3Max } from 'd3-array'; @@ -27,19 +27,22 @@ import { BasicColorFormatterType, CellRendererProps, InputColumn, + ValueRange, } from '../types'; import getCellClass from './getCellClass'; import filterValueGetter from './filterValueGetter'; import dateFilterComparator from './dateFilterComparator'; +import DateWithFormatter from './DateWithFormatter'; import { getAggFunc } from './getAggFunc'; import { TextCellRenderer } from '../renderers/TextCellRenderer'; import { NumericCellRenderer } from '../renderers/NumericCellRenderer'; import CustomHeader from '../AgGridTable/components/CustomHeader'; +import { NOOP_FILTER_COMPARATOR } from '../consts'; import { valueFormatter, valueGetter } from './formatValue'; import getCellStyle from './getCellStyle'; interface InputData { - [key: string]: any; + [key: string]: DataRecordValue; } type UseColDefsProps = { @@ -60,8 +63,6 @@ type UseColDefsProps = { slice_id: number; }; -type ValueRange = [number, number]; - function getValueRange( key: string, alignPositiveNegative: boolean, @@ -113,6 +114,73 @@ const getFilterType = (col: InputColumn) => { } }; +/** + * Filter value getter for temporal columns. + * Returns null for DateWithFormatter objects with null input, + * enabling AG Grid's blank filter to correctly identify null dates. + */ +const dateFilterValueGetter = (params: { + data: Record; + colDef: { field?: string }; +}) => { + const value = params.data?.[params.colDef.field as string]; + // Return null for DateWithFormatter with null input so AG Grid blank filter works + if (value instanceof DateWithFormatter && value.input === null) { + return null; + } + return value; +}; + +/** + * Custom date filter options for server-side pagination. + * Each option has a predicate that always returns true, allowing all rows to pass + * client-side filtering since the actual filtering is handled by the server. + */ +const SERVER_SIDE_DATE_FILTER_OPTIONS = [ + { + displayKey: 'serverEquals', + displayName: 'Equals', + predicate: () => true, + numberOfInputs: 1, + }, + { + displayKey: 'serverNotEqual', + displayName: 'Not Equal', + predicate: () => true, + numberOfInputs: 1, + }, + { + displayKey: 'serverBefore', + displayName: 'Before', + predicate: () => true, + numberOfInputs: 1, + }, + { + displayKey: 'serverAfter', + displayName: 'After', + predicate: () => true, + numberOfInputs: 1, + }, + { + displayKey: 'serverInRange', + displayName: 'In Range', + predicate: () => true, + numberOfInputs: 2, + }, + { + displayKey: 'serverBlank', + displayName: 'Blank', + predicate: () => true, + numberOfInputs: 0, + }, + { + displayKey: 'serverNotBlank', + displayName: 'Not blank', + predicate: () => true, + numberOfInputs: 0, + }, +]; + function getHeaderLabel(col: InputColumn) { let headerLabel: string | undefined; @@ -232,9 +300,16 @@ export const useColDefs = ({ filterValueGetter, }), ...(dataType === GenericDataType.Temporal && { - filterParams: { - comparator: dateFilterComparator, - }, + // Use dateFilterValueGetter so AG Grid correctly identifies null dates for blank filter + filterValueGetter: dateFilterValueGetter, + filterParams: serverPagination + ? { + filterOptions: SERVER_SIDE_DATE_FILTER_OPTIONS, + comparator: NOOP_FILTER_COMPARATOR, + } + : { + comparator: dateFilterComparator, + }, }), cellDataType: getCellDataType(col), defaultAggFunc: getAggFunc(col), diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts index 12647e99a5b0..bb9411ca98bc 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts @@ -476,6 +476,597 @@ describe('plugin-chart-ag-grid-table', () => { }); }); + describe('buildQuery - AG Grid server-side filters', () => { + describe('Simple filters', () => { + it('should apply agGridSimpleFilters to query.filters', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridSimpleFilters: [ + { col: 'state', op: '==', val: 'CA' }, + { col: 'city', op: 'ILIKE', val: '%San%' }, + ], + }, + }, + ).queries[0]; + + expect(query.filters).toContainEqual({ + col: 'state', + op: '==', + val: 'CA', + }); + expect(query.filters).toContainEqual({ + col: 'city', + op: 'ILIKE', + val: '%San%', + }); + }); + + it('should append simple filters to existing filters', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + adhoc_filters: [ + { + expressionType: 'SIMPLE', + subject: 'country', + operator: '==', + comparator: 'USA', + clause: 'WHERE', + }, + ], + }, + { + ownState: { + agGridSimpleFilters: [{ col: 'state', op: '==', val: 'CA' }], + }, + }, + ).queries[0]; + + expect(query.filters?.length).toBeGreaterThan(1); + expect(query.filters).toContainEqual({ + col: 'state', + op: '==', + val: 'CA', + }); + }); + + it('should handle empty agGridSimpleFilters array', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridSimpleFilters: [], + }, + }, + ).queries[0]; + + expect(query.filters).toBeDefined(); + }); + + it('should not apply simple filters when server pagination is disabled', () => { + const query = buildQuery(basicFormData, { + ownState: { + agGridSimpleFilters: [{ col: 'state', op: '==', val: 'CA' }], + }, + }).queries[0]; + + expect(query.filters).not.toContainEqual({ + col: 'state', + op: '==', + val: 'CA', + }); + }); + }); + + describe('Complex WHERE clause', () => { + it('should apply agGridComplexWhere to query.extras.where', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridComplexWhere: '(age > 18 AND age < 65)', + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe('(age > 18 AND age < 65)'); + }); + + it('should combine with existing WHERE clause using AND', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + adhoc_filters: [ + { + expressionType: 'SQL', + clause: 'WHERE', + sqlExpression: 'country = "USA"', + }, + ], + }, + { + ownState: { + agGridComplexWhere: '(age > 18 AND age < 65)', + }, + }, + ).queries[0]; + + expect(query.extras?.where).toContain('country = "USA"'); + expect(query.extras?.where).toContain('(age > 18 AND age < 65)'); + expect(query.extras?.where).toContain(' AND '); + }); + + it('should handle empty agGridComplexWhere', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridComplexWhere: '', + }, + }, + ).queries[0]; + + // Empty string should not set extras.where (undefined or empty string both acceptable) + expect(query.extras?.where || undefined).toBeUndefined(); + }); + + it('should not apply WHERE clause when server pagination is disabled', () => { + const query = buildQuery(basicFormData, { + ownState: { + agGridComplexWhere: '(age > 18)', + }, + }).queries[0]; + + // When server_pagination is disabled, AG Grid filters should not be applied + expect(query.extras?.where || undefined).toBeUndefined(); + }); + }); + + describe('HAVING clause', () => { + it('should apply agGridHavingClause to query.extras.having', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: ['SUM(revenue)'], + }, + { + ownState: { + agGridHavingClause: 'SUM(revenue) > 1000', + }, + }, + ).queries[0]; + + expect(query.extras?.having).toBe('SUM(revenue) > 1000'); + }); + + it('should combine with existing HAVING clause using AND', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: ['SUM(revenue)', 'COUNT(*)'], + adhoc_filters: [ + { + expressionType: 'SQL', + clause: 'HAVING', + sqlExpression: 'COUNT(*) > 10', + }, + ], + }, + { + ownState: { + agGridHavingClause: 'SUM(revenue) > 1000', + }, + }, + ).queries[0]; + + expect(query.extras?.having).toContain('COUNT(*) > 10'); + expect(query.extras?.having).toContain('SUM(revenue) > 1000'); + expect(query.extras?.having).toContain(' AND '); + }); + + it('should handle metric filters correctly', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: ['AVG(score)', 'MAX(points)'], + }, + { + ownState: { + agGridHavingClause: '(AVG(score) >= 90 AND MAX(points) < 100)', + }, + }, + ).queries[0]; + + expect(query.extras?.having).toBe( + '(AVG(score) >= 90 AND MAX(points) < 100)', + ); + }); + + it('should not apply HAVING clause when server pagination is disabled', () => { + const query = buildQuery(basicFormData, { + ownState: { + agGridHavingClause: 'SUM(revenue) > 1000', + }, + }).queries[0]; + + // When server_pagination is disabled, AG Grid filters should not be applied + expect(query.extras?.having || undefined).toBeUndefined(); + }); + }); + + describe('Totals query handling', () => { + it('should exclude AG Grid WHERE filters from totals query', () => { + const queries = buildQuery( + { + ...basicFormData, + server_pagination: true, + show_totals: true, + query_mode: QueryMode.Aggregate, + }, + { + ownState: { + agGridComplexWhere: 'age > 18', + }, + }, + ).queries; + + const mainQuery = queries[0]; + const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2] is totals + + expect(mainQuery.extras?.where).toBe('age > 18'); + expect(totalsQuery.extras?.where).toBeUndefined(); + }); + + it('should preserve non-AG Grid WHERE clauses in totals', () => { + const queries = buildQuery( + { + ...basicFormData, + server_pagination: true, + show_totals: true, + query_mode: QueryMode.Aggregate, + adhoc_filters: [ + { + expressionType: 'SQL', + clause: 'WHERE', + sqlExpression: 'country = "USA"', + }, + ], + }, + { + ownState: { + agGridComplexWhere: 'age > 18', + }, + }, + ).queries; + + const mainQuery = queries[0]; + const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2] is totals + + expect(mainQuery.extras?.where).toContain('country = "USA"'); + expect(mainQuery.extras?.where).toContain('age > 18'); + expect(totalsQuery.extras?.where).toContain('country = "USA"'); + expect(totalsQuery.extras?.where).not.toContain('age > 18'); + }); + + it('should handle totals when AG Grid WHERE is only clause', () => { + const queries = buildQuery( + { + ...basicFormData, + server_pagination: true, + show_totals: true, + query_mode: QueryMode.Aggregate, + }, + { + ownState: { + agGridComplexWhere: 'status = "active"', + }, + }, + ).queries; + + const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2] is totals + + expect(totalsQuery.extras?.where).toBeUndefined(); + }); + + it('should handle totals with empty WHERE clause after removal', () => { + const queries = buildQuery( + { + ...basicFormData, + server_pagination: true, + show_totals: true, + query_mode: QueryMode.Aggregate, + adhoc_filters: [ + { + expressionType: 'SQL', + clause: 'WHERE', + sqlExpression: 'country = "USA"', + }, + ], + }, + { + ownState: { + agGridComplexWhere: 'country = "USA"', + }, + }, + ).queries; + + const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2] is totals + + // After removing AG Grid WHERE, totals should still have the adhoc filter + expect(totalsQuery.extras).toBeDefined(); + }); + + it('should not modify totals query when no AG Grid filters applied', () => { + const queries = buildQuery( + { + ...basicFormData, + server_pagination: true, + show_totals: true, + query_mode: QueryMode.Aggregate, + }, + { + ownState: {}, + }, + ).queries; + + const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2] is totals + + expect(totalsQuery.columns).toEqual([]); + expect(totalsQuery.row_limit).toBe(0); + }); + }); + + describe('Integration - all filter types together', () => { + it('should apply simple, WHERE, and HAVING filters simultaneously', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: ['SUM(revenue)', 'COUNT(*)'], + }, + { + ownState: { + agGridSimpleFilters: [{ col: 'state', op: '==', val: 'CA' }], + agGridComplexWhere: '(age > 18 AND age < 65)', + agGridHavingClause: 'SUM(revenue) > 1000', + }, + }, + ).queries[0]; + + expect(query.filters).toContainEqual({ + col: 'state', + op: '==', + val: 'CA', + }); + expect(query.extras?.where).toBe('(age > 18 AND age < 65)'); + expect(query.extras?.having).toBe('SUM(revenue) > 1000'); + }); + + it('should combine AG Grid filters with adhoc filters', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + adhoc_filters: [ + { + expressionType: 'SIMPLE', + subject: 'country', + operator: '==', + comparator: 'USA', + clause: 'WHERE', + }, + { + expressionType: 'SQL', + clause: 'WHERE', + sqlExpression: 'region = "West"', + }, + ], + }, + { + ownState: { + agGridSimpleFilters: [ + { col: 'state', op: 'IN', val: ['CA', 'OR', 'WA'] }, + ], + agGridComplexWhere: "status = 'active'", + }, + }, + ).queries[0]; + + expect(query.filters).toContainEqual({ + col: 'state', + op: 'IN', + val: ['CA', 'OR', 'WA'], + }); + expect(query.extras?.where).toContain('region = "West"'); + expect(query.extras?.where).toContain("status = 'active'"); + }); + + it('should reset currentPage to 0 when filtering', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + currentPage: 5, + agGridSimpleFilters: [{ col: 'state', op: '==', val: 'CA' }], + }, + }, + ).queries[0]; + + // The query itself doesn't have page info, but ownState should be updated + expect(query.filters).toContainEqual({ + col: 'state', + op: '==', + val: 'CA', + }); + }); + + it('should include filter metadata in ownState', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridFilterModel: { + state: { filterType: 'text', type: 'equals', filter: 'CA' }, + }, + lastFilteredColumn: 'state', + lastFilteredInputPosition: 'first', + }, + }, + ).queries[0]; + + // Query should be generated with the filter model metadata + expect(query).toBeDefined(); + }); + + it('should handle complex real-world scenario', () => { + const queries = buildQuery( + { + ...basicFormData, + server_pagination: true, + show_totals: true, + query_mode: QueryMode.Aggregate, + groupby: ['state', 'city'], + metrics: ['SUM(revenue)', 'AVG(score)', 'COUNT(*)'], + adhoc_filters: [ + { + expressionType: 'SIMPLE', + subject: 'country', + operator: '==', + comparator: 'USA', + clause: 'WHERE', + }, + ], + }, + { + ownState: { + agGridSimpleFilters: [ + { col: 'state', op: 'IN', val: ['CA', 'NY', 'TX'] }, + ], + agGridComplexWhere: + '(population > 100000 AND growth_rate > 0.05)', + agGridHavingClause: + '(SUM(revenue) > 1000000 AND AVG(score) >= 4.5)', + currentPage: 0, + pageSize: 50, + }, + }, + ).queries; + + const mainQuery = queries[0]; + const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2] is totals + + // Main query should have all filters + expect(mainQuery.filters).toContainEqual({ + col: 'state', + op: 'IN', + val: ['CA', 'NY', 'TX'], + }); + expect(mainQuery.extras?.where).toContain('population > 100000'); + expect(mainQuery.extras?.having).toContain('SUM(revenue) > 1000000'); + + // Totals query should exclude AG Grid WHERE (since it's the only WHERE clause, it should be undefined) + expect(totalsQuery.extras?.where).toBeUndefined(); + }); + }); + + describe('Edge cases', () => { + it('should handle null ownState gracefully', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + {}, + ).queries[0]; + + expect(query).toBeDefined(); + }); + + it('should handle ownState without filter properties', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + currentPage: 0, + pageSize: 20, + }, + }, + ).queries[0]; + + expect(query).toBeDefined(); + }); + + it('should handle filters with special SQL characters', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridSimpleFilters: [{ col: 'name', op: '==', val: "O'Brien" }], + }, + }, + ).queries[0]; + + expect(query.filters).toContainEqual({ + col: 'name', + op: '==', + val: "O'Brien", + }); + }); + + it('should handle very long filter clauses', () => { + const longWhereClause = Array(50) + .fill(0) + .map((_, i) => `field${i} > ${i}`) + .join(' AND '); + + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridComplexWhere: longWhereClause, + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe(longWhereClause); + }); + }); + }); + describe('buildQuery - metrics handling in different query modes', () => { test('should not include metrics in raw records mode', () => { const query = buildQuery({ diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/agGridFilterConverter.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/agGridFilterConverter.test.ts new file mode 100644 index 000000000000..b00e3f2f1384 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/agGridFilterConverter.test.ts @@ -0,0 +1,863 @@ +/** + * 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 { convertAgGridFiltersToSQL } from '../../src/utils/agGridFilterConverter'; +import type { + AgGridFilterModel, + AgGridSimpleFilter, + AgGridCompoundFilter, + AgGridSetFilter, +} from '../../src/utils/agGridFilterConverter'; + +describe('agGridFilterConverter', () => { + describe('Empty and invalid inputs', () => { + it('should handle empty filter model', () => { + const result = convertAgGridFiltersToSQL({}); + + expect(result.simpleFilters).toEqual([]); + expect(result.complexWhere).toBeUndefined(); + expect(result.havingClause).toBeUndefined(); + }); + + it('should handle null filter model', () => { + const result = convertAgGridFiltersToSQL(null as any); + + expect(result.simpleFilters).toEqual([]); + expect(result.complexWhere).toBeUndefined(); + expect(result.havingClause).toBeUndefined(); + }); + + it('should skip invalid column names', () => { + const filterModel: AgGridFilterModel = { + valid_column: { + filterType: 'text', + type: 'equals', + filter: 'test', + }, + 'invalid; DROP TABLE users--': { + filterType: 'text', + type: 'equals', + filter: 'malicious', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters).toHaveLength(1); + expect(result.simpleFilters[0].col).toBe('valid_column'); + }); + + it('should skip filters with invalid objects', () => { + const filterModel = { + column1: null, + column2: 'invalid string', + column3: { + filterType: 'text', + type: 'equals', + filter: 'valid', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel as any); + + expect(result.simpleFilters).toHaveLength(1); + expect(result.simpleFilters[0].col).toBe('column3'); + }); + }); + + describe('Simple text filters', () => { + it('should convert equals filter', () => { + const filterModel: AgGridFilterModel = { + name: { + filterType: 'text', + type: 'equals', + filter: 'John', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters).toHaveLength(1); + expect(result.simpleFilters[0]).toEqual({ + col: 'name', + op: '=', + val: 'John', + }); + }); + + it('should convert notEqual filter', () => { + const filterModel: AgGridFilterModel = { + status: { + filterType: 'text', + type: 'notEqual', + filter: 'inactive', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'status', + op: '!=', + val: 'inactive', + }); + }); + + it('should convert contains filter with wildcard', () => { + const filterModel: AgGridFilterModel = { + description: { + filterType: 'text', + type: 'contains', + filter: 'urgent', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'description', + op: 'ILIKE', + val: '%urgent%', + }); + }); + + it('should convert notContains filter with wildcard', () => { + const filterModel: AgGridFilterModel = { + description: { + filterType: 'text', + type: 'notContains', + filter: 'spam', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'description', + op: 'NOT ILIKE', + val: '%spam%', + }); + }); + + it('should convert startsWith filter with trailing wildcard', () => { + const filterModel: AgGridFilterModel = { + email: { + filterType: 'text', + type: 'startsWith', + filter: 'admin', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'email', + op: 'ILIKE', + val: 'admin%', + }); + }); + + it('should convert endsWith filter with leading wildcard', () => { + const filterModel: AgGridFilterModel = { + email: { + filterType: 'text', + type: 'endsWith', + filter: '@example.com', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'email', + op: 'ILIKE', + val: '%@example.com', + }); + }); + }); + + describe('Numeric filters', () => { + it('should convert lessThan filter', () => { + const filterModel: AgGridFilterModel = { + age: { + filterType: 'number', + type: 'lessThan', + filter: 30, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'age', + op: '<', + val: 30, + }); + }); + + it('should convert lessThanOrEqual filter', () => { + const filterModel: AgGridFilterModel = { + price: { + filterType: 'number', + type: 'lessThanOrEqual', + filter: 100, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'price', + op: '<=', + val: 100, + }); + }); + + it('should convert greaterThan filter', () => { + const filterModel: AgGridFilterModel = { + score: { + filterType: 'number', + type: 'greaterThan', + filter: 50, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'score', + op: '>', + val: 50, + }); + }); + + it('should convert greaterThanOrEqual filter', () => { + const filterModel: AgGridFilterModel = { + rating: { + filterType: 'number', + type: 'greaterThanOrEqual', + filter: 4, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'rating', + op: '>=', + val: 4, + }); + }); + + it('should convert inRange filter to BETWEEN', () => { + const filterModel: AgGridFilterModel = { + age: { + filterType: 'number', + type: 'inRange', + filter: 18, + filterTo: 65, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + // inRange creates a simple filter with BETWEEN operator + expect(result.simpleFilters).toHaveLength(1); + expect(result.simpleFilters[0]).toEqual({ + col: 'age', + op: 'BETWEEN', + val: 18, + }); + }); + }); + + describe('Null/blank filters', () => { + it('should convert blank filter to IS NULL', () => { + const filterModel: AgGridFilterModel = { + optional_field: { + filterType: 'text', + type: 'blank', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'optional_field', + op: 'IS NULL', + val: null, + }); + }); + + it('should convert notBlank filter to IS NOT NULL', () => { + const filterModel: AgGridFilterModel = { + required_field: { + filterType: 'text', + type: 'notBlank', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'required_field', + op: 'IS NOT NULL', + val: null, + }); + }); + }); + + describe('Set filters', () => { + it('should convert set filter to IN operator', () => { + const filterModel: AgGridFilterModel = { + status: { + filterType: 'set', + values: ['active', 'pending', 'approved'], + } as AgGridSetFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'status', + op: 'IN', + val: ['active', 'pending', 'approved'], + }); + }); + + it('should handle set filter with numeric values', () => { + const filterModel: AgGridFilterModel = { + priority: { + filterType: 'set', + values: [1, 2, 3], + } as AgGridSetFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'priority', + op: 'IN', + val: [1, 2, 3], + }); + }); + + it('should skip empty set filters', () => { + const filterModel: AgGridFilterModel = { + status: { + filterType: 'set', + values: [], + } as AgGridSetFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters).toHaveLength(0); + }); + }); + + describe('Compound filters', () => { + it('should combine conditions with AND operator', () => { + const filterModel: AgGridFilterModel = { + age: { + filterType: 'number', + operator: 'AND', + condition1: { + filterType: 'number', + type: 'greaterThanOrEqual', + filter: 18, + }, + condition2: { + filterType: 'number', + type: 'lessThan', + filter: 65, + }, + } as AgGridCompoundFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.complexWhere).toBe('(age >= 18 AND age < 65)'); + }); + + it('should combine conditions with OR operator', () => { + const filterModel: AgGridFilterModel = { + status: { + filterType: 'text', + operator: 'OR', + condition1: { + filterType: 'text', + type: 'equals', + filter: 'urgent', + }, + condition2: { + filterType: 'text', + type: 'equals', + filter: 'critical', + }, + } as AgGridCompoundFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.complexWhere).toBe( + "(status = 'urgent' OR status = 'critical')", + ); + }); + + it('should handle compound filter with inRange', () => { + const filterModel: AgGridFilterModel = { + date: { + filterType: 'date', + operator: 'AND', + condition1: { + filterType: 'date', + type: 'inRange', + filter: '2024-01-01', + filterTo: '2024-12-31', + }, + condition2: { + filterType: 'date', + type: 'notBlank', + }, + } as AgGridCompoundFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.complexWhere).toContain('BETWEEN'); + expect(result.complexWhere).toContain('IS NOT NULL'); + }); + + it('should handle compound filter with invalid conditions gracefully', () => { + const filterModel: AgGridFilterModel = { + field: { + filterType: 'text', + operator: 'AND', + condition1: { + filterType: 'text', + type: 'equals', + filter: 'valid', + }, + condition2: { + filterType: 'text', + type: 'equals', + // Missing filter value - should be skipped + } as AgGridSimpleFilter, + } as AgGridCompoundFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + // Should only include valid condition + expect(result.complexWhere).toBe("field = 'valid'"); + }); + + it('should handle multi-condition filters (conditions array)', () => { + const filterModel: AgGridFilterModel = { + category: { + filterType: 'text', + operator: 'OR', + conditions: [ + { + filterType: 'text', + type: 'equals', + filter: 'A', + }, + { + filterType: 'text', + type: 'equals', + filter: 'B', + }, + { + filterType: 'text', + type: 'equals', + filter: 'C', + }, + ], + } as AgGridCompoundFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.complexWhere).toBe( + "(category = 'A' OR category = 'B' OR category = 'C')", + ); + }); + }); + + describe('Metric vs Dimension separation', () => { + it('should put dimension filters in simpleFilters/complexWhere', () => { + const filterModel: AgGridFilterModel = { + state: { + filterType: 'text', + type: 'equals', + filter: 'CA', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel, []); + + expect(result.simpleFilters).toHaveLength(1); + expect(result.havingClause).toBeUndefined(); + }); + + it('should put metric filters in havingClause', () => { + const filterModel: AgGridFilterModel = { + 'SUM(revenue)': { + filterType: 'number', + type: 'greaterThan', + filter: 1000, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel, ['SUM(revenue)']); + + expect(result.simpleFilters).toHaveLength(0); + expect(result.havingClause).toBe('SUM(revenue) > 1000'); + }); + + it('should separate mixed metric and dimension filters', () => { + const filterModel: AgGridFilterModel = { + state: { + filterType: 'text', + type: 'equals', + filter: 'CA', + }, + 'SUM(revenue)': { + filterType: 'number', + type: 'greaterThan', + filter: 1000, + }, + city: { + filterType: 'text', + type: 'startsWith', + filter: 'San', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel, ['SUM(revenue)']); + + expect(result.simpleFilters).toHaveLength(2); + expect(result.simpleFilters[0].col).toBe('state'); + expect(result.simpleFilters[1].col).toBe('city'); + expect(result.havingClause).toBe('SUM(revenue) > 1000'); + }); + + it('should handle metric set filters in HAVING clause', () => { + const filterModel: AgGridFilterModel = { + 'AVG(score)': { + filterType: 'set', + values: [90, 95, 100], + } as AgGridSetFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel, ['AVG(score)']); + + expect(result.simpleFilters).toHaveLength(0); + expect(result.havingClause).toBe('AVG(score) IN (90, 95, 100)'); + }); + + it('should handle metric blank filters in HAVING clause', () => { + const filterModel: AgGridFilterModel = { + 'COUNT(*)': { + filterType: 'number', + type: 'blank', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel, ['COUNT(*)']); + + expect(result.havingClause).toBe('COUNT(*) IS NULL'); + }); + }); + + describe('Multiple filters combination', () => { + it('should handle both simple and compound filters', () => { + const filterModel: AgGridFilterModel = { + status: { + filterType: 'text', + type: 'equals', + filter: 'active', + }, + age: { + filterType: 'number', + operator: 'AND', + condition1: { + filterType: 'number', + type: 'greaterThan', + filter: 18, + }, + condition2: { + filterType: 'number', + type: 'lessThan', + filter: 65, + }, + } as AgGridCompoundFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + // Simple filter goes to simpleFilters + expect(result.simpleFilters).toHaveLength(1); + expect(result.simpleFilters[0]).toEqual({ + col: 'status', + op: '=', + val: 'active', + }); + + // Compound filter goes to complexWhere + expect(result.complexWhere).toBe('(age > 18 AND age < 65)'); + }); + + it('should combine multiple HAVING filters with AND', () => { + const filterModel: AgGridFilterModel = { + 'SUM(revenue)': { + filterType: 'number', + type: 'greaterThan', + filter: 1000, + }, + 'AVG(score)': { + filterType: 'number', + type: 'greaterThanOrEqual', + filter: 90, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel, [ + 'SUM(revenue)', + 'AVG(score)', + ]); + + expect(result.havingClause).toBe( + '(SUM(revenue) > 1000 AND AVG(score) >= 90)', + ); + }); + + it('should handle single WHERE filter without parentheses', () => { + const filterModel: AgGridFilterModel = { + age: { + filterType: 'number', + operator: 'AND', + condition1: { + filterType: 'number', + type: 'greaterThan', + filter: 18, + }, + condition2: { + filterType: 'number', + type: 'lessThan', + filter: 65, + }, + } as AgGridCompoundFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.complexWhere).toBe('(age > 18 AND age < 65)'); + }); + }); + + describe('SQL injection prevention', () => { + it('should escape single quotes in filter values', () => { + const filterModel: AgGridFilterModel = { + name: { + filterType: 'text', + type: 'equals', + filter: "O'Brien", + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0].val).toBe("O'Brien"); + // The actual escaping happens in SQL generation, but value is preserved + }); + + it('should escape single quotes in complex filters', () => { + const filterModel: AgGridFilterModel = { + description: { + filterType: 'text', + type: 'contains', + filter: "It's working", + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + // For ILIKE filters, wildcards are added but value preserved + expect(result.simpleFilters[0].val).toBe("%It's working%"); + }); + + it('should reject column names with SQL injection attempts', () => { + const filterModel: AgGridFilterModel = { + "name'; DROP TABLE users--": { + filterType: 'text', + type: 'equals', + filter: 'test', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters).toHaveLength(0); + }); + + it('should reject column names with special characters', () => { + const filterModel: AgGridFilterModel = { + 'column': { + filterType: 'text', + type: 'equals', + filter: 'test', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters).toHaveLength(0); + }); + + it('should accept valid column names with allowed special characters', () => { + const filterModel: AgGridFilterModel = { + valid_column_123: { + filterType: 'text', + type: 'equals', + filter: 'test', + }, + 'Column Name With Spaces': { + filterType: 'text', + type: 'equals', + filter: 'test2', + }, + 'SUM(revenue)': { + filterType: 'number', + type: 'greaterThan', + filter: 100, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters).toHaveLength(3); + }); + + it('should handle very long column names', () => { + const longColumnName = 'a'.repeat(300); + const filterModel: AgGridFilterModel = { + [longColumnName]: { + filterType: 'text', + type: 'equals', + filter: 'test', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + // Should reject column names longer than 255 characters + expect(result.simpleFilters).toHaveLength(0); + }); + }); + + describe('Edge cases', () => { + it('should skip filters with missing type', () => { + const filterModel: AgGridFilterModel = { + column: { + filterType: 'text', + filter: 'value', + } as any, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters).toHaveLength(0); + }); + + it('should skip filters with unknown operator type', () => { + const filterModel: AgGridFilterModel = { + column: { + filterType: 'text', + type: 'unknownOperator' as any, + filter: 'value', + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters).toHaveLength(0); + }); + + it('should skip filters with invalid value types', () => { + const filterModel: AgGridFilterModel = { + column: { + filterType: 'text', + type: 'equals', + filter: { invalid: 'object' } as any, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters).toHaveLength(0); + }); + + it('should handle boolean filter values', () => { + const filterModel: AgGridFilterModel = { + is_active: { + filterType: 'boolean', + type: 'equals', + filter: true, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0]).toEqual({ + col: 'is_active', + op: '=', + val: true, + }); + }); + + it('should handle null filter values for blank operators', () => { + const filterModel: AgGridFilterModel = { + field: { + filterType: 'text', + type: 'blank', + filter: null, + }, + }; + + const result = convertAgGridFiltersToSQL(filterModel); + + expect(result.simpleFilters[0].val).toBeNull(); + }); + + it('should handle metric filters with set filter', () => { + const filterModel: AgGridFilterModel = { + 'SUM(amount)': { + filterType: 'set', + values: ['100', '200', '300'], + } as AgGridSetFilter, + }; + + const result = convertAgGridFiltersToSQL(filterModel, ['SUM(amount)']); + + expect(result.havingClause).toBe("SUM(amount) IN ('100', '200', '300')"); + }); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts new file mode 100644 index 000000000000..a15e3f1e2f9a --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/filterStateManager.test.ts @@ -0,0 +1,658 @@ +/** + * 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 { getCompleteFilterState } from '../../src/utils/filterStateManager'; +import type { RefObject } from 'react'; +import type { AgGridReact } from '@superset-ui/core/components/ThemedAgGridReact'; +import { FILTER_INPUT_POSITIONS } from '../../src/consts'; + +describe('filterStateManager', () => { + describe('getCompleteFilterState', () => { + it('should return empty state when gridRef.current is null', async () => { + const gridRef = { current: null } as RefObject; + + const result = await getCompleteFilterState(gridRef, []); + + expect(result).toEqual({ + originalFilterModel: {}, + simpleFilters: [], + complexWhere: undefined, + havingClause: undefined, + lastFilteredColumn: undefined, + inputPosition: FILTER_INPUT_POSITIONS.UNKNOWN, + }); + }); + + it('should return empty state when gridRef.current.api is undefined', async () => { + const gridRef = { + current: { api: undefined } as any, + } as RefObject; + + const result = await getCompleteFilterState(gridRef, []); + + expect(result).toEqual({ + originalFilterModel: {}, + simpleFilters: [], + complexWhere: undefined, + havingClause: undefined, + lastFilteredColumn: undefined, + inputPosition: FILTER_INPUT_POSITIONS.UNKNOWN, + }); + }); + + it('should convert simple filters correctly', async () => { + const filterModel = { + name: { filterType: 'text', type: 'equals', filter: 'John' }, + age: { filterType: 'number', type: 'greaterThan', filter: 25 }, + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => Promise.resolve(null)), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.originalFilterModel).toEqual(filterModel); + expect(result.simpleFilters).toHaveLength(2); + expect(result.simpleFilters[0]).toEqual({ + col: 'name', + op: '=', + val: 'John', + }); + expect(result.simpleFilters[1]).toEqual({ + col: 'age', + op: '>', + val: 25, + }); + }); + + it('should separate dimension and metric filters', async () => { + const filterModel = { + state: { filterType: 'text', type: 'equals', filter: 'CA' }, + 'SUM(revenue)': { + filterType: 'number', + type: 'greaterThan', + filter: 1000, + }, + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => Promise.resolve(null)), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + const result = await getCompleteFilterState(gridRef, ['SUM(revenue)']); + + // Dimension filter goes to simpleFilters + expect(result.simpleFilters).toHaveLength(1); + expect(result.simpleFilters[0].col).toBe('state'); + + // Metric filter goes to havingClause + expect(result.havingClause).toBe('SUM(revenue) > 1000'); + }); + + it('should detect first input position when active element is in first condition body', async () => { + const filterModel = { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }; + + const mockInput = document.createElement('input'); + const mockConditionBody1 = document.createElement('div'); + mockConditionBody1.appendChild(mockInput); + + const mockFilterInstance = { + eGui: document.createElement('div'), + eConditionBodies: [mockConditionBody1], + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => + Promise.resolve(mockFilterInstance), + ), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + // Mock activeElement + Object.defineProperty(document, 'activeElement', { + writable: true, + configurable: true, + value: mockInput, + }); + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.lastFilteredColumn).toBe('name'); + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST); + }); + + it('should detect second input position when active element is in second condition body', async () => { + const filterModel = { + age: { + filterType: 'number', + operator: 'AND', + condition1: { filterType: 'number', type: 'greaterThan', filter: 18 }, + condition2: { filterType: 'number', type: 'lessThan', filter: 65 }, + }, + }; + + const mockInput = document.createElement('input'); + const mockConditionBody1 = document.createElement('div'); + const mockConditionBody2 = document.createElement('div'); + mockConditionBody2.appendChild(mockInput); + + const mockFilterInstance = { + eGui: document.createElement('div'), + eConditionBodies: [mockConditionBody1, mockConditionBody2], + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => + Promise.resolve(mockFilterInstance), + ), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + // Mock activeElement + Object.defineProperty(document, 'activeElement', { + writable: true, + configurable: true, + value: mockInput, + }); + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.lastFilteredColumn).toBe('age'); + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.SECOND); + }); + + it('should return unknown position when active element is not in any condition body', async () => { + const filterModel = { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }; + + const mockConditionBody = document.createElement('div'); + const mockFilterInstance = { + eGui: document.createElement('div'), + eConditionBodies: [mockConditionBody], + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => + Promise.resolve(mockFilterInstance), + ), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + // Mock activeElement as something outside the filter + const outsideElement = document.createElement('div'); + Object.defineProperty(document, 'activeElement', { + writable: true, + configurable: true, + value: outsideElement, + }); + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.UNKNOWN); + expect(result.lastFilteredColumn).toBeUndefined(); + }); + + it('should handle multiple filtered columns and detect the correct one', async () => { + const filterModel = { + name: { filterType: 'text', type: 'equals', filter: 'John' }, + age: { filterType: 'number', type: 'greaterThan', filter: 25 }, + status: { filterType: 'text', type: 'equals', filter: 'active' }, + }; + + const mockInput = document.createElement('input'); + const mockConditionBodyAge = document.createElement('div'); + mockConditionBodyAge.appendChild(mockInput); + + const mockFilterInstanceName = { + eGui: document.createElement('div'), + eConditionBodies: [document.createElement('div')], + }; + + const mockFilterInstanceAge = { + eGui: document.createElement('div'), + eConditionBodies: [mockConditionBodyAge], + }; + + const mockFilterInstanceStatus = { + eGui: document.createElement('div'), + eConditionBodies: [document.createElement('div')], + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn((colId: string) => { + if (colId === 'name') return Promise.resolve(mockFilterInstanceName); + if (colId === 'age') return Promise.resolve(mockFilterInstanceAge); + if (colId === 'status') + return Promise.resolve(mockFilterInstanceStatus); + return Promise.resolve(null); + }), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + // Mock activeElement in age filter + Object.defineProperty(document, 'activeElement', { + writable: true, + configurable: true, + value: mockInput, + }); + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.lastFilteredColumn).toBe('age'); + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST); + }); + + it('should handle filter instance without eConditionBodies', async () => { + const filterModel = { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }; + + const mockFilterInstance = { + eGui: document.createElement('div'), + // No eConditionBodies property + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => + Promise.resolve(mockFilterInstance), + ), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.UNKNOWN); + expect(result.lastFilteredColumn).toBeUndefined(); + }); + + it('should handle empty filter model', async () => { + const filterModel = {}; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => Promise.resolve(null)), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.originalFilterModel).toEqual({}); + expect(result.simpleFilters).toEqual([]); + expect(result.complexWhere).toBeUndefined(); + expect(result.havingClause).toBeUndefined(); + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.UNKNOWN); + }); + + it('should handle compound filters correctly', async () => { + const filterModel = { + age: { + filterType: 'number', + operator: 'AND', + condition1: { + filterType: 'number', + type: 'greaterThanOrEqual', + filter: 18, + }, + condition2: { filterType: 'number', type: 'lessThan', filter: 65 }, + }, + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => Promise.resolve(null)), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.complexWhere).toBe('(age >= 18 AND age < 65)'); + }); + + it('should handle set filters correctly', async () => { + const filterModel = { + status: { + filterType: 'set', + values: ['active', 'pending', 'approved'], + }, + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => Promise.resolve(null)), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.simpleFilters).toHaveLength(1); + expect(result.simpleFilters[0]).toEqual({ + col: 'status', + op: 'IN', + val: ['active', 'pending', 'approved'], + }); + }); + + it('should break detection loop after finding active element', async () => { + const filterModel = { + col1: { filterType: 'text', type: 'equals', filter: 'a' }, + col2: { filterType: 'text', type: 'equals', filter: 'b' }, + col3: { filterType: 'text', type: 'equals', filter: 'c' }, + }; + + const mockInput = document.createElement('input'); + const mockConditionBody = document.createElement('div'); + mockConditionBody.appendChild(mockInput); + + let callCount = 0; + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn((colId: string) => { + callCount++; + // Return match on col2 + if (colId === 'col2') { + return Promise.resolve({ + eGui: document.createElement('div'), + eConditionBodies: [mockConditionBody], + }); + } + return Promise.resolve({ + eGui: document.createElement('div'), + eConditionBodies: [document.createElement('div')], + }); + }), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + Object.defineProperty(document, 'activeElement', { + writable: true, + configurable: true, + value: mockInput, + }); + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.lastFilteredColumn).toBe('col2'); + // Should not call getColumnFilterInstance for col3 after finding match + expect(callCount).toBeLessThanOrEqual(2); + }); + + it('should handle null filter instance gracefully', async () => { + const filterModel = { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => Promise.resolve(null)), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.UNKNOWN); + expect(result.originalFilterModel).toEqual(filterModel); + }); + + it('should maintain filter model reference integrity', async () => { + const originalFilterModel = { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }; + + const mockApi = { + getFilterModel: jest.fn(() => originalFilterModel), + getColumnFilterInstance: jest.fn(() => Promise.resolve(null)), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + const result = await getCompleteFilterState(gridRef, []); + + // Should return the same reference + expect(result.originalFilterModel).toBe(originalFilterModel); + }); + + it('should detect active element in eJoinAnds array', async () => { + const filterModel = { + age: { + filterType: 'number', + operator: 'AND', + condition1: { filterType: 'number', type: 'greaterThan', filter: 18 }, + condition2: { filterType: 'number', type: 'lessThan', filter: 65 }, + }, + }; + + const mockInput = document.createElement('input'); + const mockJoinAndGui = document.createElement('div'); + mockJoinAndGui.appendChild(mockInput); + + const mockFilterInstance = { + eGui: document.createElement('div'), + eConditionBodies: [ + document.createElement('div'), + document.createElement('div'), + ], + eJoinAnds: [{ eGui: mockJoinAndGui }], + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => + Promise.resolve(mockFilterInstance), + ), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + // Mock activeElement in join AND operator + Object.defineProperty(document, 'activeElement', { + writable: true, + configurable: true, + value: mockInput, + }); + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.lastFilteredColumn).toBe('age'); + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST); + }); + + it('should detect active element in eJoinOrs array', async () => { + const filterModel = { + status: { + filterType: 'text', + operator: 'OR', + condition1: { filterType: 'text', type: 'equals', filter: 'active' }, + condition2: { filterType: 'text', type: 'equals', filter: 'pending' }, + }, + }; + + const mockInput = document.createElement('input'); + const mockJoinOrGui = document.createElement('div'); + mockJoinOrGui.appendChild(mockInput); + + const mockFilterInstance = { + eGui: document.createElement('div'), + eConditionBodies: [ + document.createElement('div'), + document.createElement('div'), + ], + eJoinOrs: [{ eGui: mockJoinOrGui }], + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => + Promise.resolve(mockFilterInstance), + ), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + // Mock activeElement in join OR operator + Object.defineProperty(document, 'activeElement', { + writable: true, + configurable: true, + value: mockInput, + }); + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.lastFilteredColumn).toBe('status'); + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST); + }); + + it('should check condition bodies before join operators', async () => { + const filterModel = { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }; + + const mockInput = document.createElement('input'); + const mockConditionBody2 = document.createElement('div'); + mockConditionBody2.appendChild(mockInput); + + const mockJoinAndGui = document.createElement('div'); + // Input is NOT in join operator, only in condition body + + const mockFilterInstance = { + eGui: document.createElement('div'), + eConditionBodies: [document.createElement('div'), mockConditionBody2], + eJoinAnds: [{ eGui: mockJoinAndGui }], + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => + Promise.resolve(mockFilterInstance), + ), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + Object.defineProperty(document, 'activeElement', { + writable: true, + configurable: true, + value: mockInput, + }); + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.lastFilteredColumn).toBe('name'); + // Should detect SECOND input position, not from join operator + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.SECOND); + }); + + it('should handle multiple eJoinAnds elements', async () => { + const filterModel = { + score: { filterType: 'number', type: 'greaterThan', filter: 90 }, + }; + + const mockInput = document.createElement('input'); + const mockJoinAndGui2 = document.createElement('div'); + mockJoinAndGui2.appendChild(mockInput); + + const mockFilterInstance = { + eGui: document.createElement('div'), + eConditionBodies: [document.createElement('div')], + eJoinAnds: [ + { eGui: document.createElement('div') }, + { eGui: mockJoinAndGui2 }, + { eGui: document.createElement('div') }, + ], + }; + + const mockApi = { + getFilterModel: jest.fn(() => filterModel), + getColumnFilterInstance: jest.fn(() => + Promise.resolve(mockFilterInstance), + ), + }; + + const gridRef = { + current: { api: mockApi } as any, + } as RefObject; + + Object.defineProperty(document, 'activeElement', { + writable: true, + configurable: true, + value: mockInput, + }); + + const result = await getCompleteFilterState(gridRef, []); + + expect(result.lastFilteredColumn).toBe('score'); + expect(result.inputPosition).toBe(FILTER_INPUT_POSITIONS.FIRST); + }); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/getInitialFilterModel.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/getInitialFilterModel.test.ts new file mode 100644 index 000000000000..5a8822c2bed3 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/getInitialFilterModel.test.ts @@ -0,0 +1,412 @@ +/** + * 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 getInitialFilterModel from '../../src/utils/getInitialFilterModel'; +import type { AgGridChartState } from '@superset-ui/core'; + +describe('getInitialFilterModel', () => { + describe('Priority: chartState > serverPaginationData', () => { + it('should prioritize chartState.filterModel over serverPaginationData', () => { + const chartState: Partial = { + filterModel: { + name: { + filterType: 'text', + type: 'equals', + filter: 'from-chart-state', + }, + }, + }; + + const serverPaginationData = { + agGridFilterModel: { + name: { filterType: 'text', type: 'equals', filter: 'from-server' }, + }, + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + true, + ); + + expect(result).toEqual(chartState.filterModel); + }); + + it('should use serverPaginationData when chartState.filterModel is unavailable', () => { + const chartState: Partial = {}; + + const serverPaginationData = { + agGridFilterModel: { + name: { filterType: 'text', type: 'equals', filter: 'from-server' }, + }, + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + true, + ); + + expect(result).toEqual(serverPaginationData.agGridFilterModel); + }); + + it('should use serverPaginationData when chartState is undefined', () => { + const serverPaginationData = { + agGridFilterModel: { + status: { filterType: 'text', type: 'equals', filter: 'active' }, + }, + }; + + const result = getInitialFilterModel( + undefined, + serverPaginationData, + true, + ); + + expect(result).toEqual(serverPaginationData.agGridFilterModel); + }); + }); + + describe('Empty object handling', () => { + it('should return undefined when chartState.filterModel is empty object', () => { + const chartState: Partial = { + filterModel: {}, + }; + + const serverPaginationData = { + agGridFilterModel: { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }, + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + true, + ); + + // Empty filterModel should be ignored, fall back to server + expect(result).toEqual(serverPaginationData.agGridFilterModel); + }); + + it('should return undefined when serverPaginationData.agGridFilterModel is empty object', () => { + const chartState: Partial = {}; + + const serverPaginationData = { + agGridFilterModel: {}, + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + true, + ); + + expect(result).toBeUndefined(); + }); + + it('should handle both being empty objects', () => { + const chartState: Partial = { + filterModel: {}, + }; + + const serverPaginationData = { + agGridFilterModel: {}, + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + true, + ); + + expect(result).toBeUndefined(); + }); + }); + + describe('Undefined/null handling', () => { + it('should return undefined when all inputs are undefined', () => { + const result = getInitialFilterModel(undefined, undefined, true); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when chartState and serverPaginationData are undefined', () => { + const result = getInitialFilterModel(undefined, undefined, false); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when serverPagination is disabled', () => { + const chartState: Partial = {}; + + const serverPaginationData = { + agGridFilterModel: { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }, + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + false, + ); + + expect(result).toBeUndefined(); + }); + + it('should use chartState even when serverPagination is disabled', () => { + const chartState: Partial = { + filterModel: { + name: { + filterType: 'text', + type: 'equals', + filter: 'from-chart-state', + }, + }, + }; + + const serverPaginationData = { + agGridFilterModel: { + name: { filterType: 'text', type: 'equals', filter: 'from-server' }, + }, + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + false, + ); + + // chartState takes priority regardless of serverPagination flag + expect(result).toEqual(chartState.filterModel); + }); + }); + + describe('Complex filter models', () => { + it('should handle complex chartState filter model', () => { + const chartState: Partial = { + filterModel: { + name: { filterType: 'text', type: 'equals', filter: 'John' }, + age: { + filterType: 'number', + operator: 'AND', + condition1: { + filterType: 'number', + type: 'greaterThan', + filter: 18, + }, + condition2: { filterType: 'number', type: 'lessThan', filter: 65 }, + }, + status: { filterType: 'set', values: ['active', 'pending'] }, + }, + }; + + const result = getInitialFilterModel(chartState, undefined, true); + + expect(result).toEqual(chartState.filterModel); + }); + + it('should handle complex serverPaginationData filter model', () => { + const serverPaginationData = { + agGridFilterModel: { + category: { filterType: 'text', type: 'contains', filter: 'tech' }, + revenue: { filterType: 'number', type: 'greaterThan', filter: 1000 }, + }, + }; + + const result = getInitialFilterModel( + undefined, + serverPaginationData, + true, + ); + + expect(result).toEqual(serverPaginationData.agGridFilterModel); + }); + }); + + describe('Real-world scenarios', () => { + it('should handle permalink scenario with chartState', () => { + // User shares a permalink with saved filter state + const chartState: Partial = { + filterModel: { + state: { filterType: 'set', values: ['CA', 'NY', 'TX'] }, + revenue: { filterType: 'number', type: 'greaterThan', filter: 50000 }, + }, + columnState: [], + }; + + const result = getInitialFilterModel(chartState, undefined, true); + + expect(result).toEqual(chartState.filterModel); + expect(result?.state).toBeDefined(); + expect(result?.revenue).toBeDefined(); + }); + + it('should handle fresh page load with server state', () => { + // Fresh page load - no chartState, but has serverPaginationData from ownState + const serverPaginationData = { + agGridFilterModel: { + created_date: { + filterType: 'date', + type: 'greaterThan', + filter: '2024-01-01', + }, + }, + currentPage: 0, + pageSize: 50, + }; + + const result = getInitialFilterModel( + undefined, + serverPaginationData, + true, + ); + + expect(result).toEqual(serverPaginationData.agGridFilterModel); + }); + + it('should handle chart without any filters applied', () => { + // No filters applied anywhere + const chartState: Partial = { + columnState: [], + }; + + const serverPaginationData = { + currentPage: 0, + pageSize: 20, + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + true, + ); + + expect(result).toBeUndefined(); + }); + + it('should handle transition from no filters to filters via permalink', () => { + // User applies filters, creates permalink, then loads it + const chartState: Partial = { + filterModel: { + name: { filterType: 'text', type: 'startsWith', filter: 'Admin' }, + }, + }; + + const serverPaginationData = { + agGridFilterModel: undefined, // No server state yet + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + true, + ); + + expect(result).toEqual(chartState.filterModel); + }); + }); + + describe('Edge cases', () => { + it('should handle null values in serverPaginationData', () => { + const serverPaginationData = { + agGridFilterModel: null as any, + }; + + const result = getInitialFilterModel( + undefined, + serverPaginationData, + true, + ); + + expect(result).toBeUndefined(); + }); + + it('should handle serverPaginationData without agGridFilterModel key', () => { + const serverPaginationData = { + currentPage: 0, + pageSize: 20, + }; + + const result = getInitialFilterModel( + undefined, + serverPaginationData as any, + true, + ); + + expect(result).toBeUndefined(); + }); + + it('should handle chartState with null filterModel', () => { + const chartState: Partial = { + filterModel: null as any, + }; + + const serverPaginationData = { + agGridFilterModel: { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }, + }; + + const result = getInitialFilterModel( + chartState, + serverPaginationData, + true, + ); + + expect(result).toEqual(serverPaginationData.agGridFilterModel); + }); + + it('should handle serverPagination undefined (defaults to false)', () => { + const serverPaginationData = { + agGridFilterModel: { + name: { filterType: 'text', type: 'equals', filter: 'test' }, + }, + }; + + const result = getInitialFilterModel( + undefined, + serverPaginationData, + undefined, + ); + + expect(result).toBeUndefined(); + }); + + it('should preserve filter model structure without modification', () => { + const originalFilterModel = { + complexFilter: { + filterType: 'number', + operator: 'OR' as const, + condition1: { filterType: 'number', type: 'equals', filter: 100 }, + condition2: { filterType: 'number', type: 'equals', filter: 200 }, + }, + }; + + const chartState: Partial = { + filterModel: originalFilterModel, + }; + + const result = getInitialFilterModel(chartState, undefined, true); + + // Should return exact same reference, not a copy + expect(result).toBe(originalFilterModel); + }); + }); +}); diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index bad557f8852a..decee4d3b492 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -357,9 +357,17 @@ class ChartRenderer extends Component { ?.behaviors.find(behavior => behavior === Behavior.DrillToDetail) ? { inContextMenu: this.state.inContextMenu } : {}; - // By pass no result component when server pagination is enabled & the table has a backend search query + // By pass no result component when server pagination is enabled & the table has: + // - a backend search query, OR + // - non-empty AG Grid filter model + const hasSearchText = (ownState?.searchText?.length || 0) > 0; + const hasAgGridFilters = + ownState?.agGridFilterModel && + Object.keys(ownState.agGridFilterModel).length > 0; + const bypassNoResult = !( - formData?.server_pagination && (ownState?.searchText?.length || 0) > 0 + formData?.server_pagination && + (hasSearchText || hasAgGridFilters) ); return ( diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index 9e7425dc97cc..05c9e1997295 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -284,11 +284,12 @@ export const useExploreAdditionalActionsMenu = ( canDownloadCSV ? exportChart({ formData: latestQueryFormData, + ownState, resultType: 'post_processed', resultFormat: 'csv', }) : null, - [canDownloadCSV, latestQueryFormData], + [canDownloadCSV, latestQueryFormData, ownState], ); const exportJson = useCallback( @@ -296,11 +297,12 @@ export const useExploreAdditionalActionsMenu = ( canDownloadCSV ? exportChart({ formData: latestQueryFormData, + ownState, resultType: 'results', resultFormat: 'json', }) : null, - [canDownloadCSV, latestQueryFormData], + [canDownloadCSV, latestQueryFormData, ownState], ); const exportExcel = useCallback( @@ -308,11 +310,12 @@ export const useExploreAdditionalActionsMenu = ( canDownloadCSV ? exportChart({ formData: latestQueryFormData, + ownState, resultType: 'results', resultFormat: 'xlsx', }) : null, - [canDownloadCSV, latestQueryFormData], + [canDownloadCSV, latestQueryFormData, ownState], ); const copyLink = useCallback(async () => { diff --git a/superset/models/helpers.py b/superset/models/helpers.py index a4fb9e3fea15..c180420a91cc 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -2146,6 +2146,8 @@ def handle_single_value(value: Optional[FilterValue]) -> Optional[FilterValue]: not in { utils.FilterOperator.ILIKE, utils.FilterOperator.LIKE, + utils.FilterOperator.NOT_ILIKE, + utils.FilterOperator.NOT_LIKE, } ): # For backwards compatibility and edge cases @@ -3144,11 +3146,17 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma target_clause_list.append(sqla_col.like(eq)) else: target_clause_list.append(sqla_col.ilike(eq)) - elif op in {utils.FilterOperator.NOT_LIKE}: + elif op in { + utils.FilterOperator.NOT_LIKE, + utils.FilterOperator.NOT_ILIKE, + }: if target_generic_type != GenericDataType.STRING: sqla_col = sa.cast(sqla_col, sa.String) - target_clause_list.append(sqla_col.not_like(eq)) + if op == utils.FilterOperator.NOT_LIKE: + target_clause_list.append(sqla_col.not_like(eq)) + else: + target_clause_list.append(sqla_col.not_ilike(eq)) elif ( op == utils.FilterOperator.TEMPORAL_RANGE and isinstance(eq, str) diff --git a/superset/utils/core.py b/superset/utils/core.py index f089aadf70ed..a5c69554559f 100644 --- a/superset/utils/core.py +++ b/superset/utils/core.py @@ -269,6 +269,7 @@ class FilterOperator(StrEnum): LIKE = "LIKE" NOT_LIKE = "NOT LIKE" ILIKE = "ILIKE" + NOT_ILIKE = "NOT ILIKE" IS_NULL = "IS NULL" IS_NOT_NULL = "IS NOT NULL" IN = "IN" From 62c7b48b5c2028be3baba4060c09f925cc12f51f Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Mon, 12 Jan 2026 06:52:09 -0800 Subject: [PATCH 02/16] fix(extensions-cli): fix dev mode error (#37024) --- .../src/superset_extensions_cli/cli.py | 5 ---- superset-extensions-cli/tests/test_cli_dev.py | 24 +++++++------------ 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py b/superset-extensions-cli/src/superset_extensions_cli/cli.py index 0f75f4cf14c1..8f15df6da64a 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/cli.py +++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py @@ -363,11 +363,6 @@ def frontend_watcher() -> None: def backend_watcher() -> None: if backend_dir.exists(): rebuild_backend(cwd) - dist_dir = cwd / "dist" - manifest_path = dist_dir / "manifest.json" - if manifest_path.exists(): - manifest = json.loads(manifest_path.read_text()) - write_manifest(cwd, manifest) # Build watch message based on existing directories watch_dirs = [] diff --git a/superset-extensions-cli/tests/test_cli_dev.py b/superset-extensions-cli/tests/test_cli_dev.py index f75de6b2802a..8d4d4f42a8b9 100644 --- a/superset-extensions-cli/tests/test_cli_dev.py +++ b/superset-extensions-cli/tests/test_cli_dev.py @@ -216,23 +216,15 @@ def test_frontend_watcher_function_coverage(isolated_filesystem): @pytest.mark.unit def test_backend_watcher_function_coverage(isolated_filesystem): - """Test backend watcher function for coverage.""" - # Create dist directory with manifest - dist_dir = isolated_filesystem / "dist" - dist_dir.mkdir() - - manifest_data = {"name": "test", "version": "1.0.0"} - (dist_dir / "manifest.json").write_text(json.dumps(manifest_data)) + """Test backend watcher function only rebuilds backend files.""" + # Create backend directory + backend_dir = isolated_filesystem / "backend" + backend_dir.mkdir() with patch("superset_extensions_cli.cli.rebuild_backend") as mock_rebuild: - with patch("superset_extensions_cli.cli.write_manifest") as mock_write: - # Simulate backend watcher function + # Simulate backend watcher function - it only rebuilds backend + if backend_dir.exists(): mock_rebuild(isolated_filesystem) - manifest_path = dist_dir / "manifest.json" - if manifest_path.exists(): - manifest = json.loads(manifest_path.read_text()) - mock_write(isolated_filesystem, manifest) - - mock_rebuild.assert_called_once_with(isolated_filesystem) - mock_write.assert_called_once() + # Backend watcher should only call rebuild_backend + mock_rebuild.assert_called_once_with(isolated_filesystem) From 169d27c9e974d045c36767ef30bfbc0af30648ac Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Mon, 12 Jan 2026 06:53:04 -0800 Subject: [PATCH 03/16] fix(extensions): gracefully handle dist directory rebuilding (#37025) --- .../extensions/local_extensions_watcher.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/superset/extensions/local_extensions_watcher.py b/superset/extensions/local_extensions_watcher.py index 5ce529687a85..6d79a3298afb 100644 --- a/superset/extensions/local_extensions_watcher.py +++ b/superset/extensions/local_extensions_watcher.py @@ -46,6 +46,11 @@ def on_any_event(self, event: Any) -> None: if event.is_directory: return + # Only trigger on changes to files in `dist` directory + src = getattr(event, "src_path", None) + if not isinstance(src, str) or "dist" not in Path(src).parts: + return + logger.info( "File change detected in LOCAL_EXTENSIONS: %s", event.src_path ) @@ -80,8 +85,11 @@ def setup_local_extensions_watcher(app: Flask) -> None: # noqa: C901 if not handler_class: return - # Collect dist directories to watch - watch_dirs = [] + # Collect extension directories to watch + # We watch the parent extension directory instead of just dist/ + # to avoid the observer stopping when dist/ is deleted/recreated + # Use a set to avoid duplicate entries + watch_dirs: set[str] = set() for ext_path in local_extensions: if not ext_path: continue @@ -91,9 +99,24 @@ def setup_local_extensions_watcher(app: Flask) -> None: # noqa: C901 logger.warning("LOCAL_EXTENSIONS path does not exist: %s", ext_path) continue - dist_path = ext_path / "dist" - watch_dirs.append(str(dist_path)) - logger.info("Watching LOCAL_EXTENSIONS dist directory: %s", dist_path) + # Ensure we're watching a directory, not a file + if ext_path.is_file(): + logger.warning( + "LOCAL_EXTENSIONS path is a file, not a directory: %s. " + "Provide the extension directory path instead.", + ext_path, + ) + continue + + if not ext_path.is_dir(): + logger.warning("LOCAL_EXTENSIONS path is not a directory: %s", ext_path) + continue + + # Add to set (automatically handles duplicates) + watch_dir_str = str(ext_path) + if watch_dir_str not in watch_dirs: + watch_dirs.add(watch_dir_str) + logger.info("Watching LOCAL_EXTENSIONS directory: %s", ext_path) if not watch_dirs: return From 911d72c95783966e7503ac6fd7bcdd64c8400a84 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Mon, 12 Jan 2026 11:53:04 -0500 Subject: [PATCH 04/16] fix(models): prevent SQLAlchemy and_() deprecation warning (#37020) Co-authored-by: Claude Opus 4.5 --- superset/models/helpers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/superset/models/helpers.py b/superset/models/helpers.py index c180420a91cc..9be9efb43d1d 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -3214,10 +3214,12 @@ def get_sqla_query( # pylint: disable=too-many-arguments,too-many-locals,too-ma ) if granularity: - qry = qry.where(and_(*(time_filters + where_clause_and))) - else: + if time_filters or where_clause_and: + qry = qry.where(and_(*(time_filters + where_clause_and))) + elif where_clause_and: qry = qry.where(and_(*where_clause_and)) - qry = qry.having(and_(*having_clause_and)) + if having_clause_and: + qry = qry.having(and_(*having_clause_and)) self.make_orderby_compatible(select_exprs, orderby_exprs) From d8f7ae83ee60a2bf23e23b3dedac022cd4023290 Mon Sep 17 00:00:00 2001 From: ankitajhanwar2001 <85672910+ankitajhanwar2001@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:36:46 +0530 Subject: [PATCH 05/16] fix(sqlglot): use Athena dialect for awsathena parsing (#36747) --- superset/sql/parse.py | 2 +- tests/unit_tests/sql/parse_tests.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/superset/sql/parse.py b/superset/sql/parse.py index c750e0551b01..af72f72e9528 100644 --- a/superset/sql/parse.py +++ b/superset/sql/parse.py @@ -58,7 +58,7 @@ SQLGLOT_DIALECTS = { "base": Dialects.DIALECT, "ascend": Dialects.HIVE, - "awsathena": Dialects.PRESTO, + "awsathena": Dialects.ATHENA, "bigquery": Dialects.BIGQUERY, "clickhouse": Dialects.CLICKHOUSE, "clickhousedb": Dialects.CLICKHOUSE, diff --git a/tests/unit_tests/sql/parse_tests.py b/tests/unit_tests/sql/parse_tests.py index 95d1f3641939..f8e7251d8082 100644 --- a/tests/unit_tests/sql/parse_tests.py +++ b/tests/unit_tests/sql/parse_tests.py @@ -2891,6 +2891,20 @@ def test_singlestore_engine_mapping(): assert "COUNT(*)" in formatted +def test_awsathena_engine_mapping(): + """ + Test the `awsathena` dialect is properly mapped to ATHENA instead of PRESTO. + """ + sql = ( + "USING EXTERNAL FUNCTION my_func(x INT) RETURNS INT LAMBDA 'lambda_name' " + "SELECT my_func(id) FROM my_table" + ) + statement = SQLStatement(sql, engine="awsathena") + + # Should parse without errors using Athena dialect + statement.format() + + def test_remove_quotes() -> None: """ Test the `remove_quotes` helper function. From dcdcf889697fb6e087cb87170fac58c685460561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20S=C3=A1nchez?= Date: Mon, 12 Jan 2026 15:30:34 -0300 Subject: [PATCH 06/16] chore(chart): rollback legend top alignment to the right (#36994) --- .../plugins/plugin-chart-echarts/src/utils/series.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index 0660c77dcb65..5befd6ab542b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -495,9 +495,6 @@ export function getLegendProps( case LegendOrientation.Top: legend.top = 0; legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0; - if (padding?.left) { - legend.left = padding.left; - } break; default: legend.top = 0; From f4772a93838ccc227ae5b446ebbb42063f5bf394 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:35:55 -0800 Subject: [PATCH 07/16] chore(deps-dev): bump webpack-bundle-analyzer from 5.1.0 to 5.1.1 in /superset-frontend (#37006) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 8 ++++---- superset-frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 349917339ef2..1c4e7895bd8d 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -279,7 +279,7 @@ "vm-browserify": "^1.1.2", "wait-on": "^9.0.3", "webpack": "^5.103.0", - "webpack-bundle-analyzer": "^5.1.0", + "webpack-bundle-analyzer": "^5.1.1", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.2", "webpack-manifest-plugin": "^5.0.1", @@ -58640,9 +58640,9 @@ } }, "node_modules/webpack-bundle-analyzer": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.1.0.tgz", - "integrity": "sha512-WAWwIoIUx4yC2AEBqXbDkcmh/LzAaenv0+nISBflP5l+XIXO9/x6poWarGA3RTrfavk9H3oWQ64Wm0z26/UGKA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.1.1.tgz", + "integrity": "sha512-UzoaIA0Aigo5lUvoUkIkSoHtUK5rBJh9e2vW3Eqct0jc/L8hcruBCz/jsXEvB1hDU1G3V94jo2EJqPcFKeSSeQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index f1f1fc1a2dfe..67b335944b3d 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -360,7 +360,7 @@ "vm-browserify": "^1.1.2", "wait-on": "^9.0.3", "webpack": "^5.103.0", - "webpack-bundle-analyzer": "^5.1.0", + "webpack-bundle-analyzer": "^5.1.1", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.2", "webpack-manifest-plugin": "^5.0.1", From 2a38ce001e055f6824a041202b718a63af9490f1 Mon Sep 17 00:00:00 2001 From: Damian Pendrak Date: Mon, 12 Jan 2026 19:44:19 +0100 Subject: [PATCH 08/16] fix(deckgl): remove visibility condition in deckgl stroke color (#37029) --- .../src/layers/Polygon/controlPanel.ts | 2 +- .../legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/controlPanel.ts index 67a398c8a9c4..56bcdcb93f5b 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/controlPanel.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/controlPanel.ts @@ -118,10 +118,10 @@ const config: ControlPanelConfig = { }, }, fillColorPicker, - strokeColorPicker, deckGLLinearColorSchemeSelect, breakpointsDefaultColor, deckGLColorBreakpointsSelect, + strokeColorPicker, ], [filled, stroked], [extruded], diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx index 55c1cb83668b..7c6680da5dea 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/Shared_DeckGL.tsx @@ -286,8 +286,6 @@ export const strokeColorPicker: CustomControlItem = { type: 'ColorPickerControl', default: PRIMARY_COLOR, renderTrigger: true, - visibility: ({ controls }) => - isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.fixed_color), }, }; From 005b2af9853e5aa8409a006e489b6982ec3ff0b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:51:01 -0800 Subject: [PATCH 09/16] chore(deps-dev): bump @types/lodash from 4.17.21 to 4.17.23 in /superset-websocket (#37045) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-websocket/package-lock.json | 14 +++++++------- superset-websocket/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/superset-websocket/package-lock.json b/superset-websocket/package-lock.json index 6d654a9d22c7..a4a3f6c50f6e 100644 --- a/superset-websocket/package-lock.json +++ b/superset-websocket/package-lock.json @@ -24,7 +24,7 @@ "@types/ioredis": "^5.0.0", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.10", - "@types/lodash": "^4.17.21", + "@types/lodash": "^4.17.23", "@types/node": "^25.0.3", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", @@ -1809,9 +1809,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", "dev": true, "license": "MIT" }, @@ -7931,9 +7931,9 @@ } }, "@types/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", "dev": true }, "@types/ms": { diff --git a/superset-websocket/package.json b/superset-websocket/package.json index aa5eea910876..320761bd160e 100644 --- a/superset-websocket/package.json +++ b/superset-websocket/package.json @@ -32,7 +32,7 @@ "@types/ioredis": "^5.0.0", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.10", - "@types/lodash": "^4.17.21", + "@types/lodash": "^4.17.23", "@types/node": "^25.0.3", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", From 4fe20855961d921530c24adedf86c297d8225c07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:52:13 -0800 Subject: [PATCH 10/16] chore(deps): bump caniuse-lite from 1.0.30001763 to 1.0.30001764 in /docs (#37049) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- docs/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package.json b/docs/package.json index fa77b5390d11..e06c446cfb2f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -52,7 +52,7 @@ "@storybook/theming": "^8.6.11", "@superset-ui/core": "^0.20.4", "antd": "^6.1.2", - "caniuse-lite": "^1.0.30001763", + "caniuse-lite": "^1.0.30001764", "docusaurus-plugin-less": "^2.0.2", "js-yaml": "^4.1.1", "js-yaml-loader": "^1.2.2", diff --git a/docs/yarn.lock b/docs/yarn.lock index 318e9c3c017d..c492908b5d17 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -5506,10 +5506,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001763: - version "1.0.30001763" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz#9397446dd110b1aeadb0df249c41b2ece7f90f09" - integrity sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001764: + version "1.0.30001764" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz#03206c56469f236103b90f9ae10bcb8b9e1f6005" + integrity sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g== ccount@^2.0.0: version "2.0.1" From fac5d2bcb6502d2bbc0af6c0a3b427fba460f869 Mon Sep 17 00:00:00 2001 From: Igor Shmulyan Date: Mon, 12 Jan 2026 20:58:12 +0200 Subject: [PATCH 11/16] feat(db): add dynamic schema support for athena (#36003) --- superset/db_engine_specs/athena.py | 40 +++++++++++ .../unit_tests/db_engine_specs/test_athena.py | 70 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/superset/db_engine_specs/athena.py b/superset/db_engine_specs/athena.py index a3abfdf10971..002790da0ef6 100644 --- a/superset/db_engine_specs/athena.py +++ b/superset/db_engine_specs/athena.py @@ -21,6 +21,7 @@ from flask_babel import gettext as __ from sqlalchemy import types +from sqlalchemy.engine.url import URL from superset.constants import TimeGrain from superset.db_engine_specs.base import BaseEngineSpec @@ -38,6 +39,7 @@ class AthenaEngineSpec(BaseEngineSpec): disable_ssh_tunneling = True # Athena doesn't support IS true/false syntax, use = true/false instead use_equality_for_boolean_filters = True + supports_dynamic_schema = True _time_grain_expressions = { None: "{col}", @@ -92,3 +94,41 @@ def _mutate_label(label: str) -> str: :return: Conditionally mutated label """ return label.lower() + + @classmethod + def adjust_engine_params( + cls, + uri: URL, + connect_args: dict[str, Any], + catalog: str | None = None, + schema: str | None = None, + ) -> tuple[URL, dict[str, Any]]: + """ + Adjust the SQLAlchemy URI for Athena with a provided catalog and schema. + + For AWS Athena the SQLAlchemy URI looks like this: + + awsathena+rest://athena.{region_name}.amazonaws.com:443/{schema_name}?catalog_name={catalog_name}&s3_staging_dir={s3_staging_dir} + """ + if catalog: + uri = uri.update_query_dict({"catalog_name": catalog}) + + if schema: + uri = uri.set(database=schema) + + return uri, connect_args + + @classmethod + def get_schema_from_engine_params( + cls, + sqlalchemy_uri: URL, + connect_args: dict[str, Any], + ) -> str | None: + """ + Return the configured schema. + + For AWS Athena the SQLAlchemy URI looks like this: + + awsathena+rest://athena.{region_name}.amazonaws.com:443/{schema_name}?catalog_name={catalog_name}&s3_staging_dir={s3_staging_dir} + """ + return sqlalchemy_uri.database diff --git a/tests/unit_tests/db_engine_specs/test_athena.py b/tests/unit_tests/db_engine_specs/test_athena.py index 9e571eceb216..d205572d2cb3 100644 --- a/tests/unit_tests/db_engine_specs/test_athena.py +++ b/tests/unit_tests/db_engine_specs/test_athena.py @@ -20,6 +20,7 @@ from typing import Optional import pytest +from sqlalchemy.engine.url import make_url from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm @@ -120,3 +121,72 @@ def test_handle_boolean_filter() -> None: str(result_false.compile(compile_kwargs={"literal_binds": True})) == "test_col = false" ) + + +def test_adjust_engine_params() -> None: + """ + Test `adjust_engine_params`. + + The method can be used to adjust the schema dynamically. + """ + from superset.db_engine_specs.athena import AthenaEngineSpec + + url = make_url("awsathena+rest://athena.us-east-1.amazonaws.com:443/default") + + uri = AthenaEngineSpec.adjust_engine_params(url, {})[0] + assert str(uri) == "awsathena+rest://athena.us-east-1.amazonaws.com:443/default" + + uri = AthenaEngineSpec.adjust_engine_params( + url, + {}, + schema="new_schema", + )[0] + assert str(uri) == "awsathena+rest://athena.us-east-1.amazonaws.com:443/new_schema" + + uri = AthenaEngineSpec.adjust_engine_params( + url, + {}, + catalog="new_catalog", + )[0] + assert ( + str(uri) + == "awsathena+rest://athena.us-east-1.amazonaws.com:443/default?catalog_name=new_catalog" + ) + + uri = AthenaEngineSpec.adjust_engine_params( + url, + {}, + catalog="new_catalog", + schema="new_schema", + )[0] + assert ( + str(uri) + == "awsathena+rest://athena.us-east-1.amazonaws.com:443/new_schema?catalog_name=new_catalog" + ) + + +def test_get_schema_from_engine_params() -> None: + """ + Test the ``get_schema_from_engine_params`` method. + """ + from superset.db_engine_specs.athena import AthenaEngineSpec + + assert ( + AthenaEngineSpec.get_schema_from_engine_params( + make_url( + "awsathena+rest://athena.us-east-1.amazonaws.com:443/default?s3_staging_dir=s3%3A%2F%2Fathena-staging" + ), + {}, + ) + == "default" + ) + + assert ( + AthenaEngineSpec.get_schema_from_engine_params( + make_url( + "awsathena+rest://athena.us-east-1.amazonaws.com:443?s3_staging_dir=s3%3A%2F%2Fathena-staging" + ), + {}, + ) + is None + ) From 22cfc4536b17120227778081d6ef9ba264c2215b Mon Sep 17 00:00:00 2001 From: Joe Li Date: Mon, 12 Jan 2026 10:58:59 -0800 Subject: [PATCH 12/16] fix(export): URL prefix handling for subdirectory deployments (#36771) Co-authored-by: Claude Opus 4.5 --- .../components/ResultSet/ResultSet.test.tsx | 141 +++ .../src/SqlLab/components/ResultSet/index.tsx | 2 +- .../useStreamingExport.test.ts | 959 ++++++++++++++++++ .../useStreamingExport.ts | 57 +- .../explore/exploreUtils/exportChart.test.ts | 173 ++++ superset-frontend/src/utils/export.test.ts | 51 + superset-frontend/src/utils/export.ts | 5 +- 7 files changed, 1381 insertions(+), 7 deletions(-) create mode 100644 superset-frontend/src/explore/exploreUtils/exportChart.test.ts diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx index 29e2fd162f55..a45e38bf0c7b 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx @@ -47,6 +47,19 @@ jest.mock('src/components/ErrorMessage', () => ({ ErrorMessageWithStackTrace: () =>
Error
, })); +// Mock useStreamingExport to capture startExport calls +const mockStartExport = jest.fn(); +const mockResetExport = jest.fn(); +const mockCancelExport = jest.fn(); +jest.mock('src/components/StreamingExportModal/useStreamingExport', () => ({ + useStreamingExport: () => ({ + startExport: mockStartExport, + resetExport: mockResetExport, + cancelExport: mockCancelExport, + progress: { status: 'streaming', rowsProcessed: 0 }, + }), +})); + jest.mock( 'react-virtualized-auto-sizer', () => @@ -160,6 +173,7 @@ describe('ResultSet', () => { beforeEach(() => { applicationRootMock.mockReturnValue(''); + mockStartExport.mockClear(); }); // Add cleanup after each test @@ -657,4 +671,131 @@ describe('ResultSet', () => { const resultsCalls = fetchMock.calls('glob:*/api/v1/sqllab/results/*'); expect(resultsCalls).toHaveLength(1); }); + + test('should use non-streaming export (href) when rows below threshold', async () => { + // This test validates that when rows < CSV_STREAMING_ROW_THRESHOLD, + // the component uses the direct download href instead of streaming export. + const appRoot = '/superset'; + applicationRootMock.mockReturnValue(appRoot); + + // Create a query with rows BELOW the threshold + const smallQuery = { + ...queries[0], + rows: 500, // Below the 1000 threshold + limitingFactor: 'NOT_LIMITED', + }; + + const { getByTestId } = setup( + mockedProps, + mockStore({ + ...initialState, + user: { + ...user, + roles: { + sql_lab: [['can_export_csv', 'SQLLab']], + }, + }, + sqlLab: { + ...initialState.sqlLab, + queries: { + [smallQuery.id]: smallQuery, + }, + }, + common: { + conf: { + CSV_STREAMING_ROW_THRESHOLD: 1000, + }, + }, + }), + ); + + await waitFor(() => { + expect(getByTestId('export-csv-button')).toBeInTheDocument(); + }); + + const exportButton = getByTestId('export-csv-button'); + + // Non-streaming export should have href attribute with prefixed URL + expect(exportButton).toHaveAttribute( + 'href', + expect.stringMatching(new RegExp(`^${appRoot}/api/v1/sqllab/export/`)), + ); + + // Click should NOT trigger startExport for non-streaming + fireEvent.click(exportButton); + expect(mockStartExport).not.toHaveBeenCalled(); + }); + + test.each([ + { + name: 'no prefix (default deployment)', + appRoot: '', + expectedUrl: '/api/v1/sqllab/export_streaming/', + }, + { + name: 'with subdirectory prefix', + appRoot: '/superset', + expectedUrl: '/superset/api/v1/sqllab/export_streaming/', + }, + { + name: 'with nested subdirectory prefix', + appRoot: '/my-app/superset', + expectedUrl: '/my-app/superset/api/v1/sqllab/export_streaming/', + }, + ])( + 'streaming export URL respects app root configuration: $name', + async ({ appRoot, expectedUrl }) => { + // This test validates that streaming export startExport receives the correct URL + // based on the applicationRoot configuration. + applicationRootMock.mockReturnValue(appRoot); + + // Create a query with enough rows to trigger streaming export (>= threshold) + const largeQuery = { + ...queries[0], + rows: 5000, // Above the default 1000 threshold + limitingFactor: 'NOT_LIMITED', + }; + + const { getByTestId } = setup( + mockedProps, + mockStore({ + ...initialState, + user: { + ...user, + roles: { + sql_lab: [['can_export_csv', 'SQLLab']], + }, + }, + sqlLab: { + ...initialState.sqlLab, + queries: { + [largeQuery.id]: largeQuery, + }, + }, + common: { + conf: { + CSV_STREAMING_ROW_THRESHOLD: 1000, + }, + }, + }), + ); + + await waitFor(() => { + expect(getByTestId('export-csv-button')).toBeInTheDocument(); + }); + + const exportButton = getByTestId('export-csv-button'); + fireEvent.click(exportButton); + + // Verify startExport was called exactly once + expect(mockStartExport).toHaveBeenCalledTimes(1); + + // The URL should match the expected prefixed URL + expect(mockStartExport).toHaveBeenCalledWith( + expect.objectContaining({ + url: expectedUrl, + }), + ); + }, + ); }); diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx index def05372e70f..439bf37ca202 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx @@ -424,7 +424,7 @@ const ResultSet = ({ setShowStreamingModal(true); startExport({ - url: '/api/v1/sqllab/export_streaming/', + url: makeUrl('/api/v1/sqllab/export_streaming/'), payload: { client_id: query.id }, exportType: 'csv', expectedRows: rows, diff --git a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts index 03241082dd1e..8f114c6e462f 100644 --- a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts +++ b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts @@ -16,10 +16,16 @@ * specific language governing permissions and limitations * under the License. */ +import { TextEncoder, TextDecoder } from 'util'; import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; import { useStreamingExport } from './useStreamingExport'; import { ExportStatus } from './StreamingExportModal'; +// Polyfill TextEncoder/TextDecoder for Jest environment +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder as typeof global.TextDecoder; + // Mock SupersetClient jest.mock('@superset-ui/core', () => ({ ...jest.requireActual('@superset-ui/core'), @@ -28,6 +34,15 @@ jest.mock('@superset-ui/core', () => ({ }, })); +// Mock pathUtils and getBootstrapData for URL prefix guard tests +jest.mock('src/utils/pathUtils', () => ({ + makeUrl: jest.fn((path: string) => path), +})); + +jest.mock('src/utils/getBootstrapData', () => ({ + applicationRoot: jest.fn(() => ''), +})); + global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); global.URL.revokeObjectURL = jest.fn(); @@ -35,6 +50,7 @@ global.fetch = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + global.fetch = jest.fn(); }); test('useStreamingExport initializes with default progress state', () => { @@ -124,3 +140,946 @@ test('useStreamingExport cleans up on unmount', () => { // Cleanup should not throw errors expect(true).toBe(true); }); + +test('retryExport reuses the same URL from the original startExport call', async () => { + // This test ensures that retryExport uses the exact same URL that was passed to startExport, + // which is important for subdirectory deployments where the URL is already prefixed. + const originalUrl = '/superset/api/v1/sqllab/export_streaming/'; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockResolvedValue({ done: true, value: undefined }), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + // First call with startExport + act(() => { + result.current.startExport({ + url: originalUrl, + payload: { client_id: 'test-id' }, + exportType: 'csv', + expectedRows: 100, + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + expect(mockFetch).toHaveBeenCalledWith(originalUrl, expect.any(Object)); + + // Reset mock to track retry call + mockFetch.mockClear(); + + // Reset the export state so we can retry + act(() => { + result.current.resetExport(); + }); + + // Call retryExport - should reuse the same URL + act(() => { + result.current.retryExport(); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + // Retry should use the exact same URL that was passed to startExport + expect(mockFetch).toHaveBeenCalledWith(originalUrl, expect.any(Object)); +}); + +test('sets ERROR status and calls onError when fetch rejects', async () => { + const errorMessage = 'Network error'; + const mockFetch = jest.fn().mockRejectedValue(new Error(errorMessage)); + global.fetch = mockFetch; + + const onError = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onError })); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + expectedRows: 100, + }); + }); + + // Wait for fetch to be called and error to be processed + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.ERROR); + }); + + // Verify onError was called exactly once with the error message + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(errorMessage); +}); + +// URL prefix guard tests - prevent regression of missing app root prefix +const { applicationRoot } = jest.requireMock('src/utils/getBootstrapData'); +const { makeUrl } = jest.requireMock('src/utils/pathUtils'); + +const createPrefixTestMockFetch = () => + jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockResolvedValue({ done: true, value: undefined }), + }), + }, + }); + +test('URL prefix guard applies prefix to unprefixed relative URL when app root is configured', async () => { + const appRoot = '/superset'; + applicationRoot.mockReturnValue(appRoot); + makeUrl.mockImplementation((path: string) => `${appRoot}${path}`); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + expect(mockFetch).toHaveBeenCalledWith( + '/superset/api/v1/sqllab/export_streaming/', + expect.any(Object), + ); +}); + +test('URL prefix guard does not double-prefix URL that already has app root', async () => { + const appRoot = '/superset'; + applicationRoot.mockReturnValue(appRoot); + makeUrl.mockImplementation((path: string) => `${appRoot}${path}`); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/superset/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + expect(mockFetch).toHaveBeenCalledWith( + '/superset/api/v1/sqllab/export_streaming/', + expect.any(Object), + ); +}); + +test('URL prefix guard leaves URL unchanged when no app root is configured', async () => { + applicationRoot.mockReturnValue(''); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/v1/sqllab/export_streaming/', + expect.any(Object), + ); +}); + +test('URL prefix guard normalizes relative URL without leading slash and applies prefix', async () => { + const appRoot = '/superset'; + applicationRoot.mockReturnValue(appRoot); + makeUrl.mockImplementation((path: string) => `${appRoot}${path}`); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: 'api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // Should add leading slash and apply prefix + expect(mockFetch).toHaveBeenCalledWith( + '/superset/api/v1/sqllab/export_streaming/', + expect.any(Object), + ); +}); + +test('URL prefix guard normalizes non-slash URL to leading slash when no app root configured', async () => { + applicationRoot.mockReturnValue(''); + makeUrl.mockImplementation((path: string) => path); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: 'api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // Should normalize to leading slash even without app root + expect(mockFetch).toHaveBeenCalledWith( + '/api/v1/sqllab/export_streaming/', + expect.any(Object), + ); +}); + +test('URL prefix guard leaves absolute URLs (https) unchanged', async () => { + const appRoot = '/superset'; + applicationRoot.mockReturnValue(appRoot); + makeUrl.mockImplementation((path: string) => `${appRoot}${path}`); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: 'https://external.example.com/api/export/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://external.example.com/api/export/', + expect.any(Object), + ); +}); + +test('URL prefix guard leaves protocol-relative URLs (//host) unchanged', async () => { + const appRoot = '/superset'; + applicationRoot.mockReturnValue(appRoot); + makeUrl.mockImplementation((path: string) => `${appRoot}${path}`); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '//external.example.com/api/export/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + expect(mockFetch).toHaveBeenCalledWith( + '//external.example.com/api/export/', + expect.any(Object), + ); +}); + +test('URL prefix guard correctly handles sibling paths (prefixes /app2 when appRoot is /app)', async () => { + const appRoot = '/app'; + applicationRoot.mockReturnValue(appRoot); + makeUrl.mockImplementation((path: string) => `${appRoot}${path}`); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/app2/api/v1/export/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // /app2 should be prefixed because it's not under /app/ - it's a sibling path + expect(mockFetch).toHaveBeenCalledWith( + '/app/app2/api/v1/export/', + expect.any(Object), + ); +}); + +test('URL prefix guard does not double-prefix URL with query string at app root', async () => { + const appRoot = '/superset'; + applicationRoot.mockReturnValue(appRoot); + makeUrl.mockImplementation((path: string) => `${appRoot}${path}`); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/superset?foo=1&bar=2', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // Should NOT double-prefix to /superset/superset?foo=1&bar=2 + expect(mockFetch).toHaveBeenCalledWith( + '/superset?foo=1&bar=2', + expect.any(Object), + ); +}); + +test('URL prefix guard does not double-prefix URL with hash at app root', async () => { + const appRoot = '/superset'; + applicationRoot.mockReturnValue(appRoot); + makeUrl.mockImplementation((path: string) => `${appRoot}${path}`); + + const mockFetch = createPrefixTestMockFetch(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/superset#section', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // Should NOT double-prefix to /superset/superset#section + expect(mockFetch).toHaveBeenCalledWith( + '/superset#section', + expect.any(Object), + ); +}); + +// Streaming export behavior tests + +test('sets ERROR status and calls onError when stream contains __STREAM_ERROR__ marker', async () => { + const errorMessage = 'Database connection failed'; + const errorChunk = new TextEncoder().encode( + `__STREAM_ERROR__:${errorMessage}`, + ); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: errorChunk }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const onError = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onError })); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.ERROR); + }); + + expect(result.current.progress.error).toBe(errorMessage); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(errorMessage); +}); + +test('completes CSV export successfully with correct status and downloadUrl', async () => { + applicationRoot.mockReturnValue(''); + const csvData = new TextEncoder().encode('id,name\n1,Alice\n2,Bob\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="results.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: csvData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const onComplete = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onComplete })); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + expect(result.current.progress.downloadUrl).toBe('blob:mock-url'); + expect(result.current.progress.filename).toBe('results.csv'); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'results.csv'); +}); + +test('completes XLSX export successfully with correct filename', async () => { + applicationRoot.mockReturnValue(''); + const xlsxData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // XLSX magic bytes + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="report.xlsx"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: xlsxData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const onComplete = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onComplete })); + + act(() => { + result.current.startExport({ + url: '/api/v1/chart/data', + payload: { datasource: '1__table', viz_type: 'table' }, + exportType: 'xlsx', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + expect(result.current.progress.filename).toBe('report.xlsx'); + expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'report.xlsx'); +}); + +test('sets ERROR status when response is not ok (4xx/5xx)', async () => { + applicationRoot.mockReturnValue(''); + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: new Headers({}), + }); + global.fetch = mockFetch; + + const onError = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onError })); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.ERROR); + }); + + expect(result.current.progress.error).toBe( + 'Export failed: 500 Internal Server Error', + ); + expect(onError).toHaveBeenCalledWith( + 'Export failed: 500 Internal Server Error', + ); +}); + +test('sets ERROR status when response body is missing', async () => { + applicationRoot.mockReturnValue(''); + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({}), + body: null, + }); + global.fetch = mockFetch; + + const onError = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onError })); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.ERROR); + }); + + expect(result.current.progress.error).toBe( + 'Response body is not available for streaming', + ); + expect(onError).toHaveBeenCalledWith( + 'Response body is not available for streaming', + ); +}); + +test('cancelExport sets CANCELLED status and aborts the request', async () => { + applicationRoot.mockReturnValue(''); + let abortSignal: AbortSignal | undefined; + + // Create a reader that will hang until aborted + const mockFetch = jest.fn().mockImplementation((_url, options) => { + abortSignal = options?.signal; + return Promise.resolve({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation( + () => + new Promise((resolve, reject) => { + // Simulate slow stream that can be aborted + const timeout = setTimeout(() => { + resolve({ + done: false, + value: new TextEncoder().encode('data'), + }); + }, 10000); + abortSignal?.addEventListener('abort', () => { + clearTimeout(timeout); + reject(new Error('Export cancelled by user')); + }); + }), + ), + }), + }, + }); + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + // Wait for fetch to be called + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // Cancel the export + act(() => { + result.current.cancelExport(); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.CANCELLED); + }); +}); + +test('parses filename from Content-Disposition header with quotes', async () => { + applicationRoot.mockReturnValue(''); + const csvData = new TextEncoder().encode('data\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="my export file.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: csvData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + expect(result.current.progress.filename).toBe('my export file.csv'); +}); + +test('uses default filename when Content-Disposition header is missing', async () => { + applicationRoot.mockReturnValue(''); + const csvData = new TextEncoder().encode('data\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({}), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: csvData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + expect(result.current.progress.filename).toBe('export.csv'); +}); + +test('updates progress with rowsProcessed and totalSize during streaming', async () => { + applicationRoot.mockReturnValue(''); + const chunk1 = new TextEncoder().encode('id,name\n1,Alice\n'); + const chunk2 = new TextEncoder().encode('2,Bob\n3,Charlie\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: chunk1 }); + } + if (readCount === 2) { + return Promise.resolve({ done: false, value: chunk2 }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + expectedRows: 100, + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + // Verify final progress reflects data received + expect(result.current.progress.totalSize).toBe(chunk1.length + chunk2.length); + // 4 newlines total (2 in chunk1, 2 in chunk2) + expect(result.current.progress.rowsProcessed).toBe(4); +}); + +test('prevents double startExport calls while export is in progress', async () => { + applicationRoot.mockReturnValue(''); + + // Create a slow reader that takes time to complete + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation( + () => + new Promise(resolve => { + readCount += 1; + if (readCount === 1) { + // Delay first chunk to simulate in-progress export + setTimeout(() => { + resolve({ + done: false, + value: new TextEncoder().encode('data\n'), + }); + }, 50); + } else { + resolve({ done: true, value: undefined }); + } + }), + ), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + // Start first export + act(() => { + result.current.startExport({ + url: '/api/v1/first/', + payload: { client_id: 'first' }, + exportType: 'csv', + }); + }); + + // Immediately try to start second export + act(() => { + result.current.startExport({ + url: '/api/v1/second/', + payload: { client_id: 'second' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + // Only one fetch call should have been made (first export) + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith('/api/v1/first/', expect.any(Object)); +}); + +test('retryExport does nothing when no prior export exists', async () => { + applicationRoot.mockReturnValue(''); + const mockFetch = jest.fn(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + // Call retry without ever calling startExport + act(() => { + result.current.retryExport(); + }); + + // Give it time to potentially make a call + await new Promise(resolve => { + setTimeout(resolve, 50); + }); + + // No fetch should have been made + expect(mockFetch).not.toHaveBeenCalled(); +}); + +test('state resets correctly after successful export and resetExport call', async () => { + applicationRoot.mockReturnValue(''); + const csvData = new TextEncoder().encode('data\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: csvData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + // Verify completed state + expect(result.current.progress.downloadUrl).toBe('blob:mock-url'); + + // Reset the export + act(() => { + result.current.resetExport(); + }); + + // Verify state is reset + expect(result.current.progress.status).toBe(ExportStatus.STREAMING); + expect(result.current.progress.rowsProcessed).toBe(0); + expect(result.current.progress.totalSize).toBe(0); + expect(result.current.progress.downloadUrl).toBeUndefined(); + expect(result.current.progress.error).toBeUndefined(); + expect(global.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); +}); + +test('state resets correctly after failed export and resetExport call', async () => { + applicationRoot.mockReturnValue(''); + const mockFetch = jest.fn().mockRejectedValue(new Error('Network error')); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.ERROR); + }); + + // Verify error state + expect(result.current.progress.error).toBe('Network error'); + + // Reset the export + act(() => { + result.current.resetExport(); + }); + + // Verify state is reset + expect(result.current.progress.status).toBe(ExportStatus.STREAMING); + expect(result.current.progress.error).toBeUndefined(); + expect(result.current.progress.rowsProcessed).toBe(0); +}); diff --git a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts index fd0bf34cb2e3..05a7e5d5b789 100644 --- a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts +++ b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts @@ -19,6 +19,8 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { SupersetClient } from '@superset-ui/core'; import { ExportStatus, StreamingProgress } from './StreamingExportModal'; +import { makeUrl } from 'src/utils/pathUtils'; +import { applicationRoot } from 'src/utils/getBootstrapData'; interface UseStreamingExportOptions { onComplete?: (downloadUrl: string, filename: string) => void; @@ -30,6 +32,16 @@ interface StreamingExportPayload { } interface StreamingExportParams { + /** + * The API endpoint URL for the export request. + * + * URLs should be prefixed with the application root at the call site using + * `makeUrl()` from 'src/utils/pathUtils'. This ensures proper handling for + * subdirectory deployments (e.g., /superset/api/v1/...). + * + * A defensive guard (`ensureUrlPrefix`) will apply the prefix if missing, + * but callers should not rely on this fallback behavior. + */ url: string; payload: StreamingExportPayload; filename?: string; @@ -39,6 +51,45 @@ interface StreamingExportParams { const NEWLINE_BYTE = 10; // '\n' character code +/** + * Ensures URL has the application root prefix for subdirectory deployments. + * Applies makeUrl to relative paths that don't already include the app root. + * This guards against callers forgetting to prefix URLs when using native fetch. + */ +const ensureUrlPrefix = (url: string): string => { + const appRoot = applicationRoot(); + // Protocol-relative URLs (//example.com/...) should pass through unchanged + if (url.startsWith('//')) { + return url; + } + // Absolute URLs (http:// or https://) should pass through unchanged + if (url.match(/^https?:\/\//)) { + return url; + } + // Relative URLs without leading slash (e.g., "api/v1/...") need normalization + // Add leading slash and apply prefix + if (!url.startsWith('/')) { + return makeUrl(`/${url}`); + } + // If no app root configured, return as-is + if (!appRoot) { + return url; + } + // If URL already has the app root prefix, return as-is + // Use strict check to avoid false positives with sibling paths (e.g., /app2 when appRoot is /app) + // Also handle query strings and hashes (e.g., /superset?foo=1 or /superset#hash) + if ( + url === appRoot || + url.startsWith(`${appRoot}/`) || + url.startsWith(`${appRoot}?`) || + url.startsWith(`${appRoot}#`) + ) { + return url; + } + // Apply prefix via makeUrl + return makeUrl(url); +}; + const createFetchRequest = async ( _url: string, payload: StreamingExportPayload, @@ -63,7 +114,7 @@ const createFetchRequest = async ( formParams.filename = filename; } - if (expectedRows) { + if (expectedRows !== undefined) { formParams.expected_rows = expectedRows.toString(); } @@ -157,7 +208,9 @@ export const useStreamingExport = (options: UseStreamingExportOptions = {}) => { expectedRows, abortControllerRef.current.signal, ); - const response = await fetch(url, fetchOptions); + // Guard: ensure URL has app root prefix for subdirectory deployments + const prefixedUrl = ensureUrlPrefix(url); + const response = await fetch(prefixedUrl, fetchOptions); if (!response.ok) { throw new Error( diff --git a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts new file mode 100644 index 000000000000..661e1248b3e6 --- /dev/null +++ b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts @@ -0,0 +1,173 @@ +/** + * 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 { exportChart } from '.'; + +// Mock pathUtils to control app root prefix +jest.mock('src/utils/pathUtils', () => ({ + ensureAppRoot: jest.fn((path: string) => path), +})); + +// Mock SupersetClient +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + SupersetClient: { + postForm: jest.fn(), + get: jest.fn().mockResolvedValue({ json: {} }), + post: jest.fn().mockResolvedValue({ json: {} }), + }, + getChartBuildQueryRegistry: jest.fn().mockReturnValue({ + get: jest.fn().mockReturnValue(() => () => ({})), + }), + getChartMetadataRegistry: jest.fn().mockReturnValue({ + get: jest.fn().mockReturnValue({ parseMethod: 'json' }), + }), +})); + +const { ensureAppRoot } = jest.requireMock('src/utils/pathUtils'); +const { getChartMetadataRegistry } = jest.requireMock('@superset-ui/core'); + +// Minimal formData that won't trigger legacy API (useLegacyApi = false) +const baseFormData = { + datasource: '1__table', + viz_type: 'table', +}; + +beforeEach(() => { + jest.clearAllMocks(); + // Default: no prefix + ensureAppRoot.mockImplementation((path: string) => path); + // Default: v1 API (not legacy) + getChartMetadataRegistry.mockReturnValue({ + get: jest.fn().mockReturnValue({ parseMethod: 'json' }), + }); +}); + +// Tests for exportChart URL prefix handling in streaming export +test('exportChart v1 API passes prefixed URL to onStartStreamingExport when app root is configured', async () => { + const appRoot = '/superset'; + ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`); + + const onStartStreamingExport = jest.fn(); + + await exportChart({ + formData: baseFormData, + resultFormat: 'csv', + onStartStreamingExport: onStartStreamingExport as unknown as null, + }); + + expect(onStartStreamingExport).toHaveBeenCalledTimes(1); + const callArgs = onStartStreamingExport.mock.calls[0][0]; + expect(callArgs.url).toBe('/superset/api/v1/chart/data'); + expect(callArgs.exportType).toBe('csv'); +}); + +test('exportChart v1 API passes unprefixed URL when no app root is configured', async () => { + ensureAppRoot.mockImplementation((path: string) => path); + + const onStartStreamingExport = jest.fn(); + + await exportChart({ + formData: baseFormData, + resultFormat: 'csv', + onStartStreamingExport: onStartStreamingExport as unknown as null, + }); + + expect(onStartStreamingExport).toHaveBeenCalledTimes(1); + const callArgs = onStartStreamingExport.mock.calls[0][0]; + expect(callArgs.url).toBe('/api/v1/chart/data'); +}); + +test('exportChart v1 API passes nested prefix for deeply nested deployments', async () => { + const appRoot = '/my-company/analytics/superset'; + ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`); + + const onStartStreamingExport = jest.fn(); + + await exportChart({ + formData: baseFormData, + resultFormat: 'xlsx', + onStartStreamingExport: onStartStreamingExport as unknown as null, + }); + + expect(onStartStreamingExport).toHaveBeenCalledTimes(1); + const callArgs = onStartStreamingExport.mock.calls[0][0]; + expect(callArgs.url).toBe('/my-company/analytics/superset/api/v1/chart/data'); + expect(callArgs.exportType).toBe('xlsx'); +}); + +test('exportChart passes csv exportType for CSV exports', async () => { + const onStartStreamingExport = jest.fn(); + + await exportChart({ + formData: baseFormData, + resultFormat: 'csv', + onStartStreamingExport: onStartStreamingExport as unknown as null, + }); + + expect(onStartStreamingExport).toHaveBeenCalledWith( + expect.objectContaining({ + exportType: 'csv', + }), + ); +}); + +test('exportChart passes xlsx exportType for Excel exports', async () => { + const onStartStreamingExport = jest.fn(); + + await exportChart({ + formData: baseFormData, + resultFormat: 'xlsx', + onStartStreamingExport: onStartStreamingExport as unknown as null, + }); + + expect(onStartStreamingExport).toHaveBeenCalledWith( + expect.objectContaining({ + exportType: 'xlsx', + }), + ); +}); + +test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app root configured', async () => { + // Legacy API uses getExploreUrl() -> getURIDirectory() -> ensureAppRoot() + const appRoot = '/superset'; + ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`); + + // Configure mock to return useLegacyApi: true + getChartMetadataRegistry.mockReturnValue({ + get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }), + }); + + const onStartStreamingExport = jest.fn(); + const legacyFormData = { + datasource: '1__table', + viz_type: 'legacy_viz', + }; + + await exportChart({ + formData: legacyFormData, + resultFormat: 'csv', + onStartStreamingExport: onStartStreamingExport as unknown as null, + }); + + expect(onStartStreamingExport).toHaveBeenCalledTimes(1); + const callArgs = onStartStreamingExport.mock.calls[0][0]; + // Legacy path uses getURIDirectory which calls ensureAppRoot + expect(callArgs.url).toContain(appRoot); + expect(callArgs.exportType).toBe('csv'); +}); diff --git a/superset-frontend/src/utils/export.test.ts b/superset-frontend/src/utils/export.test.ts index 9f590005bf37..1a0f96be716f 100644 --- a/superset-frontend/src/utils/export.test.ts +++ b/superset-frontend/src/utils/export.test.ts @@ -37,6 +37,7 @@ jest.mock('@apache-superset/core', () => ({ jest.mock('content-disposition'); +// Default no-op mock for pathUtils; specific tests customize ensureAppRoot to simulate app root prefixing jest.mock('./pathUtils', () => ({ ensureAppRoot: jest.fn((path: string) => path), })); @@ -400,3 +401,53 @@ test('handles export with empty IDs array', async () => { }), ); }); + +const { ensureAppRoot } = jest.requireMock('./pathUtils'); + +const doublePrefixTestCases = [ + { + name: 'subdirectory prefix', + appRoot: '/superset', + resource: 'dashboard', + ids: [1], + }, + { + name: 'subdirectory prefix (dataset)', + appRoot: '/superset', + resource: 'dataset', + ids: [1], + }, + { + name: 'nested prefix', + appRoot: '/my-app/superset', + resource: 'dataset', + ids: [1, 2], + }, +]; + +test.each(doublePrefixTestCases)( + 'handleResourceExport endpoint should not include app prefix: $name', + async ({ appRoot, resource, ids }) => { + // Simulate real ensureAppRoot behavior: prepend the appRoot + (ensureAppRoot as jest.Mock).mockImplementation( + (path: string) => `${appRoot}${path}`, + ); + + const doneMock = jest.fn(); + await handleResourceExport(resource, ids, doneMock); + + // The endpoint passed to SupersetClient.get should NOT have the appRoot prefix + // because SupersetClient.getUrl() adds it when building the full URL. + const expectedEndpoint = `/api/v1/${resource}/export/?q=!(${ids.join(',')})`; + + // Explicitly verify no prefix in endpoint - this will fail if ensureAppRoot is used + const callArgs = (SupersetClient.get as jest.Mock).mock.calls.slice( + -1, + )[0][0]; + expect(callArgs.endpoint).not.toContain(appRoot); + expect(callArgs.endpoint).toBe(expectedEndpoint); + + // Reset mock for next test + (ensureAppRoot as jest.Mock).mockImplementation((path: string) => path); + }, +); diff --git a/superset-frontend/src/utils/export.ts b/superset-frontend/src/utils/export.ts index e26ab299215c..cabbc4937235 100644 --- a/superset-frontend/src/utils/export.ts +++ b/superset-frontend/src/utils/export.ts @@ -20,7 +20,6 @@ import { SupersetClient } from '@superset-ui/core'; import { logging } from '@apache-superset/core'; import rison from 'rison'; import contentDisposition from 'content-disposition'; -import { ensureAppRoot } from './pathUtils'; // Maximum blob size for in-memory downloads (100MB) const MAX_BLOB_SIZE = 100 * 1024 * 1024; @@ -50,9 +49,7 @@ export default async function handleResourceExport( ids: number[], done: () => void, ): Promise { - const endpoint = ensureAppRoot( - `/api/v1/${resource}/export/?q=${rison.encode(ids)}`, - ); + const endpoint = `/api/v1/${resource}/export/?q=${rison.encode(ids)}`; try { // Use fetch with blob response instead of iframe to avoid CSP frame-src violations From 72c69e2ca661dd3fe6880944d70ebe37312adcea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:59:09 -0800 Subject: [PATCH 13/16] chore(deps): bump fs-extra from 11.3.0 to 11.3.3 in /superset-frontend (#37001) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 10 +++++----- superset-frontend/package.json | 2 +- .../packages/generator-superset/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 1c4e7895bd8d..4c59075b93a1 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -71,7 +71,7 @@ "echarts": "^5.6.0", "eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", "fast-glob": "^3.3.2", - "fs-extra": "^11.2.0", + "fs-extra": "^11.3.3", "fuse.js": "^7.1.0", "geolib": "^2.0.24", "geostyler": "^14.1.3", @@ -32090,9 +32090,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -60392,7 +60392,7 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "fs-extra": "^11.3.2", + "fs-extra": "^11.3.3", "jest": "^30.2.0", "yeoman-test": "^11.2.0" }, diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 67b335944b3d..b0e1fa4940ee 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -152,7 +152,7 @@ "echarts": "^5.6.0", "eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings", "fast-glob": "^3.3.2", - "fs-extra": "^11.2.0", + "fs-extra": "^11.3.3", "fuse.js": "^7.1.0", "geolib": "^2.0.24", "geostyler": "^14.1.3", diff --git a/superset-frontend/packages/generator-superset/package.json b/superset-frontend/packages/generator-superset/package.json index b1f2436e3679..cb7162735ab8 100644 --- a/superset-frontend/packages/generator-superset/package.json +++ b/superset-frontend/packages/generator-superset/package.json @@ -35,7 +35,7 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "fs-extra": "^11.3.2", + "fs-extra": "^11.3.3", "jest": "^30.2.0", "yeoman-test": "^11.2.0" }, From d56bc5826fede42a9ded27c4ec0ba999aa272838 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:59:37 -0800 Subject: [PATCH 14/16] chore(deps-dev): bump @applitools/eyes-storybook from 3.60.0 to 3.63.4 in /superset-frontend (#37003) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 382 ++++++++++++++-------------- superset-frontend/package.json | 2 +- 2 files changed, 192 insertions(+), 192 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 4c59075b93a1..6e6d55102e65 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -138,7 +138,7 @@ "yargs": "^17.7.2" }, "devDependencies": { - "@applitools/eyes-storybook": "^3.60.0", + "@applitools/eyes-storybook": "^3.63.4", "@babel/cli": "^7.28.3", "@babel/compat-data": "^7.28.4", "@babel/core": "^7.28.3", @@ -473,26 +473,26 @@ "link": true }, "node_modules/@applitools/core": { - "version": "4.48.0", - "resolved": "https://registry.npmjs.org/@applitools/core/-/core-4.48.0.tgz", - "integrity": "sha512-J2QC+S8MtPCb3fGVyYUwUpwTr1LQ0mMH+/fvP/Ms6W/mpPcvomrkQfcNw7h9+Yu9DQWyWBWlkcAa3XDQ4R9+6w==", + "version": "4.54.1", + "resolved": "https://registry.npmjs.org/@applitools/core/-/core-4.54.1.tgz", + "integrity": "sha512-A6GDeTc9UOLc/1RYMntmRS39gaX4RhltDHcS0hVEjm9QyJirVV+l4TQDkhfUhOBfBCezFZzOLX75iZwGJAqjzA==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/core-base": "1.28.0", - "@applitools/dom-capture": "11.6.5", - "@applitools/dom-snapshot": "4.13.7", - "@applitools/driver": "1.23.5", - "@applitools/ec-client": "1.12.8", - "@applitools/logger": "2.2.4", - "@applitools/nml-client": "1.11.6", - "@applitools/req": "1.8.4", - "@applitools/screenshoter": "3.12.5", + "@applitools/core-base": "1.31.0", + "@applitools/dom-capture": "11.6.7", + "@applitools/dom-snapshot": "4.15.4", + "@applitools/driver": "1.24.3", + "@applitools/ec-client": "1.12.15", + "@applitools/logger": "2.2.7", + "@applitools/nml-client": "1.11.13", + "@applitools/req": "1.8.7", + "@applitools/screenshoter": "3.12.10", "@applitools/snippets": "2.7.0", - "@applitools/socket": "1.3.5", - "@applitools/spec-driver-webdriver": "1.4.5", - "@applitools/ufg-client": "1.17.4", - "@applitools/utils": "1.12.0", + "@applitools/socket": "1.3.8", + "@applitools/spec-driver-webdriver": "1.5.3", + "@applitools/ufg-client": "1.18.3", + "@applitools/utils": "1.14.1", "@types/ws": "8.5.5", "abort-controller": "3.0.0", "chalk": "4.1.2", @@ -512,16 +512,16 @@ } }, "node_modules/@applitools/core-base": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@applitools/core-base/-/core-base-1.28.0.tgz", - "integrity": "sha512-UTRP2fPhngBSrVEzGy2sOomCnGSWPT5Hn/sLlYZRPK/W2RkvGimNpka4rs4OmFT/7DETOD0AbLLu/8fTCiPe8Q==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@applitools/core-base/-/core-base-1.31.0.tgz", + "integrity": "sha512-7KBiOqstj8+CUTEhtc9sxUmxUtVdwgHiTeRZEO8Yi1L/0Y6/lriOMO6tEg/boKrLPrVaiIglbTEAMQbUe5vw0w==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/image": "1.2.3", - "@applitools/logger": "2.2.4", - "@applitools/req": "1.8.4", - "@applitools/utils": "1.12.0", + "@applitools/image": "1.2.6", + "@applitools/logger": "2.2.7", + "@applitools/req": "1.8.7", + "@applitools/utils": "1.14.1", "abort-controller": "3.0.0", "throat": "6.0.2" }, @@ -546,24 +546,10 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@applitools/css-tree": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@applitools/css-tree/-/css-tree-1.2.0.tgz", - "integrity": "sha512-/8hwwk8uScEh7xAJSbYzjLalS3Vkt2rPhoqMFc4Q++fGMLx/THq3TwgnPcoOtdT1pHBEwpnkQpxjxSn+HXoWig==", - "dev": true, - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "mdn-data": "2.1.0", - "source-map-js": "1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/@applitools/dom-capture": { - "version": "11.6.5", - "resolved": "https://registry.npmjs.org/@applitools/dom-capture/-/dom-capture-11.6.5.tgz", - "integrity": "sha512-NorPlPtiTJse9+SzAr49DuktqIAcgVx9vs24NZlWkSVb6o6hy2CJt20RhIMHwf6wOPToFbU3H8mlGjzJRtIxiA==", + "version": "11.6.7", + "resolved": "https://registry.npmjs.org/@applitools/dom-capture/-/dom-capture-11.6.7.tgz", + "integrity": "sha512-AeUWYKJmlmGSlWrH3fSFKmXSOpUz1gDfm25eFvBfxDLs4GjQaZ9bHph7t+85T5sX56dRIxi6s5Vhfyp9cuz6dQ==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -585,31 +571,45 @@ } }, "node_modules/@applitools/dom-snapshot": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/@applitools/dom-snapshot/-/dom-snapshot-4.13.7.tgz", - "integrity": "sha512-LIKbv0FGDUFkFYfqwQ8NJAf6xbJ1D9HEinWxsBUedMgPjaKzwuGOZHNZMl4j+Lm2BuCSaY3M/inQwmYlFdKhkA==", + "version": "4.15.4", + "resolved": "https://registry.npmjs.org/@applitools/dom-snapshot/-/dom-snapshot-4.15.4.tgz", + "integrity": "sha512-n2IILyrUomrkZCELKCgRIJ6MsFoGH6W/0hCusPc3CM12uD3XwaEBA5XYQP2mt3B0IX1AXsjSk5QQtZ0DxjFLwg==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/css-tree": "1.2.0", "@applitools/dom-shared": "1.1.1", "@applitools/functional-commons": "1.6.0", + "css-tree": "^3.1.0", "pako": "1.0.11" }, "engines": { "node": ">=12.13.0" } }, + "node_modules/@applitools/dom-snapshot/node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/@applitools/driver": { - "version": "1.23.5", - "resolved": "https://registry.npmjs.org/@applitools/driver/-/driver-1.23.5.tgz", - "integrity": "sha512-Pkrq0U/wGZQmrwPXI8sdOQZ7dRQpisncQ1AnUpx6DGavLQq2FiieG68kfwLDQ6Qbl0avZspmMN7ODaCgQ3VOGg==", + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/@applitools/driver/-/driver-1.24.3.tgz", + "integrity": "sha512-Xqx4imO7qW5Lr9iw6OwxMzFsdWaCKFfa0I+sTm6QRhpjkzfjWmGo58pheXn6rbw+IrmODt7aqS6HQ6mc8svijQ==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/logger": "2.2.4", + "@applitools/logger": "2.2.7", "@applitools/snippets": "2.7.0", - "@applitools/utils": "1.12.0", + "@applitools/utils": "1.14.1", "semver": "7.6.2" }, "engines": { @@ -617,20 +617,20 @@ } }, "node_modules/@applitools/ec-client": { - "version": "1.12.8", - "resolved": "https://registry.npmjs.org/@applitools/ec-client/-/ec-client-1.12.8.tgz", - "integrity": "sha512-zUvkZioQaBc4SovyW3aN3UM/ASjhu0thIoPJk8CKLAEoeMHBPJUIojNeWns7LE867tpXO2xGdeOgDQYWcB3fIA==", + "version": "1.12.15", + "resolved": "https://registry.npmjs.org/@applitools/ec-client/-/ec-client-1.12.15.tgz", + "integrity": "sha512-jiY8z+LCx1Iw0f5SI47g1xXH3Cg5wvdRL3dIekodqQqVLctccL01xB9hk/HvHlxHHaxgsAw3QTJKu/TTl3yCVA==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/core-base": "1.28.0", - "@applitools/driver": "1.23.5", - "@applitools/logger": "2.2.4", - "@applitools/req": "1.8.4", - "@applitools/socket": "1.3.5", - "@applitools/spec-driver-webdriver": "1.4.5", - "@applitools/tunnel-client": "1.11.2", - "@applitools/utils": "1.12.0", + "@applitools/core-base": "1.31.0", + "@applitools/driver": "1.24.3", + "@applitools/logger": "2.2.7", + "@applitools/req": "1.8.7", + "@applitools/socket": "1.3.8", + "@applitools/spec-driver-webdriver": "1.5.3", + "@applitools/tunnel-client": "1.11.5", + "@applitools/utils": "1.14.1", "abort-controller": "3.0.0", "webdriver": "7.31.1", "yargs": "^17.7.2" @@ -697,15 +697,15 @@ } }, "node_modules/@applitools/eyes": { - "version": "1.36.8", - "resolved": "https://registry.npmjs.org/@applitools/eyes/-/eyes-1.36.8.tgz", - "integrity": "sha512-2434CcEZDUcEc8DOcLEOcLjkGXJleLf3Ty5wACGAm/5IPCbwbg83Qkv4WuogXBzqM9ecQson8cTFIqDeq89ITA==", + "version": "1.36.19", + "resolved": "https://registry.npmjs.org/@applitools/eyes/-/eyes-1.36.19.tgz", + "integrity": "sha512-DLYTvOhF+5+LRiU8Lv8nPIrFabG8mCnYS9E68VdC5mERlMF/JI881E6etlo2rtNMEPK967BJIj9qpU1SzZUlGw==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/core": "4.48.0", - "@applitools/logger": "2.2.4", - "@applitools/utils": "1.12.0", + "@applitools/core": "4.54.1", + "@applitools/logger": "2.2.7", + "@applitools/utils": "1.14.1", "chalk": "4.1.2", "yargs": "17.7.2" }, @@ -717,21 +717,21 @@ } }, "node_modules/@applitools/eyes-storybook": { - "version": "3.60.0", - "resolved": "https://registry.npmjs.org/@applitools/eyes-storybook/-/eyes-storybook-3.60.0.tgz", - "integrity": "sha512-eFKLqR4XkGAy0g5FKxdUvrR5gEM5nQoNaaxDKYbIK+L0J1QB9C45ew3QXn4cwn5hfHT3iQTsn+yh92awrQh9bw==", + "version": "3.63.4", + "resolved": "https://registry.npmjs.org/@applitools/eyes-storybook/-/eyes-storybook-3.63.4.tgz", + "integrity": "sha512-z0hyIp0YU0VTjgQ8HBHt7jfwI1kIjpZF3DDLbr172qTkhvyibp9OJD0ZV2D0ES1KYTztGIeBs7ec/lGUTztZYA==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/core": "4.48.0", - "@applitools/driver": "1.23.5", - "@applitools/eyes": "1.36.8", + "@applitools/core": "4.54.1", + "@applitools/driver": "1.24.3", + "@applitools/eyes": "1.36.19", "@applitools/functional-commons": "1.6.0", - "@applitools/logger": "2.2.4", + "@applitools/logger": "2.2.7", "@applitools/monitoring-commons": "1.0.19", - "@applitools/spec-driver-puppeteer": "1.6.5", - "@applitools/ufg-client": "1.17.4", - "@applitools/utils": "1.12.0", + "@applitools/spec-driver-puppeteer": "1.6.9", + "@applitools/ufg-client": "1.18.3", + "@applitools/utils": "1.14.1", "@inquirer/prompts": "7.0.1", "boxen": "4.2.0", "chalk": "3.0.0", @@ -780,13 +780,13 @@ } }, "node_modules/@applitools/image": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@applitools/image/-/image-1.2.3.tgz", - "integrity": "sha512-TMdBoXhdSUm7OLAUZ/iZdDbRJ4wBeOVpUFIm1KO4kRHWfJHBh/pro21Y5H1ctMSVzGxuk5SSkJ+U/giq6Wl8dg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@applitools/image/-/image-1.2.6.tgz", + "integrity": "sha512-T6cQ+h/g+PVuc8g7UFp+kZS/vVt2/ZfdeZmpZcTkDwBmjHQvu7zX5xwX602NwP3F+kYc3jC/oaooPLAsmDzuFg==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/utils": "1.12.0", + "@applitools/utils": "1.14.1", "bmpimagejs": "1.0.4", "jpeg-js": "0.4.4", "omggif": "1.0.10", @@ -797,13 +797,13 @@ } }, "node_modules/@applitools/logger": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@applitools/logger/-/logger-2.2.4.tgz", - "integrity": "sha512-1UJDYRPIEG7NNNcJFfTd7xqclDQALH8ecBDVtQS3yjf/yPCB0ogof+R+40im2VSx4h26AAb3bJGAACRsUgrOxQ==", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@applitools/logger/-/logger-2.2.7.tgz", + "integrity": "sha512-m4YER684cDnR8PeBM5q8gYtLQm3K81+jF1Zh8NkaC/6wX9/TGTZOSygsmWniu+A7AqbL3fMvX+R4nNfjozNoSg==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/utils": "1.12.0", + "@applitools/utils": "1.14.1", "chalk": "4.1.2", "debug": "4.3.4" }, @@ -842,31 +842,31 @@ } }, "node_modules/@applitools/nml-client": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@applitools/nml-client/-/nml-client-1.11.6.tgz", - "integrity": "sha512-QGMfZjGbiMXKh2GGBZsqfimymESKHmb/yPq+A6tuxUXUNMlETyWCA4uirGpGGgfhwrYC2VNNC0EuZRnT7R2kGA==", + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/@applitools/nml-client/-/nml-client-1.11.13.tgz", + "integrity": "sha512-gjBHN845crqfKYSs2F5KEi+v4tGRx6nQvC7nSws2IhO4EqCVqS+p1B+39wDDOwRITwZMek9Nn+uc5F9MB+pVVQ==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/logger": "2.2.4", - "@applitools/req": "1.8.4", - "@applitools/utils": "1.12.0" + "@applitools/logger": "2.2.7", + "@applitools/req": "1.8.7", + "@applitools/utils": "1.14.1" }, "engines": { "node": ">=12.13.0" }, "peerDependencies": { - "@applitools/core-base": "1.28.0" + "@applitools/core-base": "1.31.0" } }, "node_modules/@applitools/req": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/@applitools/req/-/req-1.8.4.tgz", - "integrity": "sha512-g8hc0ieHlUUGFjTDBGSQMr060nsleU2tKn2XcupzQSi1XGi5yq0xFxQfEG6I65ACzMdAljUSLAA3CkORPcbA+g==", + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/@applitools/req/-/req-1.8.7.tgz", + "integrity": "sha512-PlW1t8EyTFoLGTYkDxkT558VVU8nHC9OhwM2Q/NgtrD+w1fATZXM/2sEbdSwlNsUnxNwqVEcLMIlifPUJsyJSw==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/utils": "1.12.0", + "@applitools/utils": "1.14.1", "abort-controller": "3.0.0", "http-proxy-agent": "5.0.0", "https-proxy-agent": "5.0.1", @@ -906,16 +906,16 @@ } }, "node_modules/@applitools/screenshoter": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/@applitools/screenshoter/-/screenshoter-3.12.5.tgz", - "integrity": "sha512-8Di1L2eC+fggLTDbV7llhuvJploTq9hgVZ4WF/5GJxFocPfk3ImRWHcAuAYR85dNXiNzPWB7C+I+J/7pM24GhA==", + "version": "3.12.10", + "resolved": "https://registry.npmjs.org/@applitools/screenshoter/-/screenshoter-3.12.10.tgz", + "integrity": "sha512-49kqoiunGB7nQiEgp/iu3fEz5xcah28fDcudu74dhUhjEkmWP8qh8+Y26Ea76UjmaDb0R7UNi4LvWlTrwa90Sw==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/image": "1.2.3", - "@applitools/logger": "2.2.4", + "@applitools/image": "1.2.6", + "@applitools/logger": "2.2.7", "@applitools/snippets": "2.7.0", - "@applitools/utils": "1.12.0" + "@applitools/utils": "1.14.1" }, "engines": { "node": ">=12.13.0" @@ -932,28 +932,28 @@ } }, "node_modules/@applitools/socket": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@applitools/socket/-/socket-1.3.5.tgz", - "integrity": "sha512-U1pJiVi/31ZfelWQFJBxnwcxNOpwHs7Uc5hDFT5ecQ/byF2vAYKQiEagWMD3MK0IALUAuq5LH0iYd7GOOQIoLw==", + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@applitools/socket/-/socket-1.3.8.tgz", + "integrity": "sha512-pnKdBSm6cRKenBaR0chqPhYLhUwbUZFCxw7UG78bu/5Y02GKH8WTeiMKEuQV1yrVRUltdISgGPMY8K8nVrFqxQ==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/logger": "2.2.4", - "@applitools/utils": "1.12.0" + "@applitools/logger": "2.2.7", + "@applitools/utils": "1.14.1" }, "engines": { "node": ">=12.13.0" } }, "node_modules/@applitools/spec-driver-puppeteer": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@applitools/spec-driver-puppeteer/-/spec-driver-puppeteer-1.6.5.tgz", - "integrity": "sha512-BqXk3Bng4GeMydIj0c2/ih01AaRfn7KQAjh9MheqRy0ddqr0nU/7D0nO5HZbswMr4ORs6cI2UC1BiGWv5/3njQ==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@applitools/spec-driver-puppeteer/-/spec-driver-puppeteer-1.6.9.tgz", + "integrity": "sha512-DNJr6DEl+6uKN+9fKwclZ6QcdlNFjn6bCH4KbGy9b3CXfzHS/QicXaGMeVENa4ItosDOe3eSE8nrEq+w/p1rgQ==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/driver": "1.23.5", - "@applitools/utils": "1.12.0" + "@applitools/driver": "1.24.3", + "@applitools/utils": "1.14.1" }, "engines": { "node": ">=12.13.0" @@ -963,14 +963,14 @@ } }, "node_modules/@applitools/spec-driver-webdriver": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@applitools/spec-driver-webdriver/-/spec-driver-webdriver-1.4.5.tgz", - "integrity": "sha512-weAY1+T2yv66bUZf+zapsMJ7act+K1LCwJwc4biuaNBN5J5psFE15p9UhjFY6M4M4pZMHwsOJMDzl5jJSF+hEg==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@applitools/spec-driver-webdriver/-/spec-driver-webdriver-1.5.3.tgz", + "integrity": "sha512-Fi4YfdP6mjZE1UdVlNA8f5yZNxHrklZ1TKlogcsRR7QA0cxKz6hd32+f0/trFIgkv/eJcsTSudpNPftg6WgV1w==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/driver": "1.23.5", - "@applitools/utils": "1.12.0", + "@applitools/driver": "1.24.3", + "@applitools/utils": "1.14.1", "http-proxy-agent": "5.0.0", "https-proxy-agent": "5.0.1" }, @@ -982,17 +982,17 @@ } }, "node_modules/@applitools/tunnel-client": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@applitools/tunnel-client/-/tunnel-client-1.11.2.tgz", - "integrity": "sha512-mKFTwY9MVBtsiBfjzA4NesdB9PcIJzPq13A46OCkpAYD4nppnS+aCeAVUu1M0wGqj6vRW9Uv4BS7ZmHXG+7PBQ==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@applitools/tunnel-client/-/tunnel-client-1.11.5.tgz", + "integrity": "sha512-gDBjASZ+elusM05TIZy63EI9KshpwgfKDE33MacRDp1XajSOqwR2Ei2a1oSZpUBWmINgLbnmqtlbx9pCfstWhA==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { "@applitools/execution-grid-tunnel": "3.1.3", - "@applitools/logger": "2.2.4", - "@applitools/req": "1.8.4", - "@applitools/socket": "1.3.5", - "@applitools/utils": "1.12.0", + "@applitools/logger": "2.2.7", + "@applitools/req": "1.8.7", + "@applitools/socket": "1.3.8", + "@applitools/utils": "1.14.1", "abort-controller": "3.0.0", "yargs": "17.7.2" }, @@ -1004,29 +1004,43 @@ } }, "node_modules/@applitools/ufg-client": { - "version": "1.17.4", - "resolved": "https://registry.npmjs.org/@applitools/ufg-client/-/ufg-client-1.17.4.tgz", - "integrity": "sha512-2bNzhPP2rzdAEXqLMf98Lca6epIwgp5C4cuUo48ELcGcw7WBqbzzLnRzTASmMq7grY3fVrXykx8kLt24CVTUFQ==", + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/@applitools/ufg-client/-/ufg-client-1.18.3.tgz", + "integrity": "sha512-8MtsfRnd/f/O5ONJasUQ1FT3UvYdoGFDgPnspr3TCx4hLIDTE+EV5SE20bmfYCPnFx/inle3vOm+9JI5kwLSAw==", "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@applitools/css-tree": "1.2.0", - "@applitools/image": "1.2.3", - "@applitools/logger": "2.2.4", - "@applitools/req": "1.8.4", - "@applitools/utils": "1.12.0", + "@applitools/image": "1.2.6", + "@applitools/logger": "2.2.7", + "@applitools/req": "1.8.7", + "@applitools/utils": "1.14.1", "@xmldom/xmldom": "0.8.10", "abort-controller": "3.0.0", + "css-tree": "^3.1.0", "throat": "6.0.2" }, "engines": { "node": ">=12.13.0" } }, + "node_modules/@applitools/ufg-client/node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/@applitools/utils": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@applitools/utils/-/utils-1.12.0.tgz", - "integrity": "sha512-8mBaNNJ0zUBlb09ycc8aFTKajoqEu+E7M7kdV1IENIwuVOI3ecM6x9vr4ptWQz0LTnel7M+L3NPqAGJqoQ3AKA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@applitools/utils/-/utils-1.14.1.tgz", + "integrity": "sha512-cQS+3S/YW2k4Eq1rRgnPPkNwaHwoYlr23rRFbc7f7wyrmf1sospROJ/9ORQCzw0LatH3tKHcp5SK2/ZBQi27Ew==", "dev": true, "license": "SEE LICENSE IN LICENSE", "engines": { @@ -1095,13 +1109,6 @@ "node": "20 || >=22" } }, - "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -10535,6 +10542,13 @@ "integrity": "sha512-j+ejhYwY6PeB+v1kn7lZFACUIG97u90WxMuGosILFsl9d4Ovi0sjk0GlPfoEcx+FzvXZDAfioD+NGnnPamXgMA==", "license": "MIT" }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "dev": true, + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -10741,9 +10755,9 @@ "license": "MIT" }, "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -21036,9 +21050,9 @@ } }, "node_modules/@wdio/config/node_modules/@types/node": { - "version": "18.19.129", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz", - "integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==", + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", "dependencies": { @@ -21193,9 +21207,9 @@ } }, "node_modules/@wdio/utils/node_modules/@types/node": { - "version": "18.19.129", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz", - "integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==", + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", "dependencies": { @@ -23027,9 +23041,9 @@ "optional": true }, "node_modules/bare-fs": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.5.tgz", - "integrity": "sha512-TCtu93KGLu6/aiGWzMr12TmSRS6nKdfhAnzTQRbXoSWxkbb9eRd53jQ51jG7g1gYjjtto3hbBrrhzg6djcgiKg==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -23098,9 +23112,9 @@ } }, "node_modules/bare-url": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.2.2.tgz", - "integrity": "sha512-g+ueNGKkrjMazDG3elZO1pNs3HY5+mMmOet1jtKyhOaCnkLzitxf26z7hoAEkDNgdNmnc1KIlt/dw6Po6xZMpA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -23169,9 +23183,9 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", "dev": true, "license": "MIT", "engines": { @@ -26827,13 +26841,6 @@ "node": "20 || >=22" } }, - "node_modules/cssstyle/node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -42740,9 +42747,9 @@ } }, "node_modules/mdn-data": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.1.0.tgz", - "integrity": "sha512-dbAWH6A+2NGuVJlQFrTKHJc07Vqn5frnhyTOGz+7BsK7V2hHdoBcwoiyV3QVhLHYpM/zqe2OSUn5ZWbVXLBB8A==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", "dev": true, "license": "CC0-1.0" }, @@ -46912,12 +46919,13 @@ } }, "node_modules/pino": { - "version": "9.12.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.12.0.tgz", - "integrity": "sha512-0Gd0OezGvqtqMwgYxpL7P0pSHHzTJ0Lx992h+mNlMtRVfNnqweWmf0JmRWk5gJzHalyd2mxTzKjhiNbGS2Ztfw==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", "dev": true, "license": "MIT", "dependencies": { + "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", @@ -46926,7 +46934,6 @@ "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", - "slow-redact": "^0.3.0", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, @@ -48440,7 +48447,7 @@ "version": "22.15.0", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", - "deprecated": "< 24.10.2 is no longer supported", + "deprecated": "< 24.15.0 is no longer supported", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -48500,9 +48507,9 @@ "license": "MIT" }, "node_modules/puppeteer-core/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", "engines": { @@ -53209,9 +53216,9 @@ "license": "ISC" }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "dev": true, "license": "MIT" }, @@ -53720,13 +53727,6 @@ "node": ">=8" } }, - "node_modules/slow-redact": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.0.tgz", - "integrity": "sha512-cf723wn9JeRIYP9tdtd86GuqoR5937u64Io+CYjlm2i7jvu7g0H+Cp0l0ShAf/4ZL+ISUTVT+8Qzz7RZmp9FjA==", - "dev": true, - "license": "MIT" - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -58526,9 +58526,9 @@ } }, "node_modules/webdriver/node_modules/@types/node": { - "version": "18.19.129", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz", - "integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==", + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index b0e1fa4940ee..9cef01cf8a1c 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -219,7 +219,7 @@ "yargs": "^17.7.2" }, "devDependencies": { - "@applitools/eyes-storybook": "^3.60.0", + "@applitools/eyes-storybook": "^3.63.4", "@babel/cli": "^7.28.3", "@babel/compat-data": "^7.28.4", "@babel/core": "^7.28.3", From adb575be2f102541c627f506f19bd3c0b851488c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:00:31 -0800 Subject: [PATCH 15/16] chore(deps-dev): bump typescript-eslint from 8.50.1 to 8.52.0 in /docs (#36913) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- docs/yarn.lock | 130 ++++++---------------------------------------- 2 files changed, 18 insertions(+), 114 deletions(-) diff --git a/docs/package.json b/docs/package.json index e06c446cfb2f..3ae70ae1b912 100644 --- a/docs/package.json +++ b/docs/package.json @@ -87,7 +87,7 @@ "globals": "^17.0.0", "prettier": "^3.7.4", "typescript": "~5.9.3", - "typescript-eslint": "^8.50.1", + "typescript-eslint": "^8.52.0", "webpack": "^5.104.1" }, "browserslist": { diff --git a/docs/yarn.lock b/docs/yarn.lock index c492908b5d17..d4cbc6794e6c 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2337,7 +2337,7 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== -"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": +"@eslint-community/eslint-utils@^4.8.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== @@ -2351,7 +2351,7 @@ dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": +"@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== @@ -4447,21 +4447,7 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz#b56e422fb82eb40fae04905f1444aef0298b634b" - integrity sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw== - dependencies: - "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.50.1" - "@typescript-eslint/type-utils" "8.50.1" - "@typescript-eslint/utils" "8.50.1" - "@typescript-eslint/visitor-keys" "8.50.1" - ignore "^7.0.0" - natural-compare "^1.4.0" - ts-api-utils "^2.1.0" - -"@typescript-eslint/eslint-plugin@^8.52.0": +"@typescript-eslint/eslint-plugin@8.52.0", "@typescript-eslint/eslint-plugin@^8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz#9a9f1d2ee974ed77a8b1bda94e77123f697ee8b4" integrity sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q== @@ -4475,18 +4461,7 @@ natural-compare "^1.4.0" ts-api-utils "^2.4.0" -"@typescript-eslint/parser@8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.50.1.tgz#9772760c0c4090ba3e8b43c796128ff88aff345c" - integrity sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg== - dependencies: - "@typescript-eslint/scope-manager" "8.50.1" - "@typescript-eslint/types" "8.50.1" - "@typescript-eslint/typescript-estree" "8.50.1" - "@typescript-eslint/visitor-keys" "8.50.1" - debug "^4.3.4" - -"@typescript-eslint/parser@^8.52.0": +"@typescript-eslint/parser@8.52.0", "@typescript-eslint/parser@^8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.52.0.tgz#9fae9f5f13ebb1c8f31a50c34381bfd6bf96a05f" integrity sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg== @@ -4497,15 +4472,6 @@ "@typescript-eslint/visitor-keys" "8.52.0" debug "^4.4.3" -"@typescript-eslint/project-service@8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.50.1.tgz#3176e55ac2907638f4b8d43da486c864934adc8d" - integrity sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg== - dependencies: - "@typescript-eslint/tsconfig-utils" "^8.50.1" - "@typescript-eslint/types" "^8.50.1" - debug "^4.3.4" - "@typescript-eslint/project-service@8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.52.0.tgz#5fb4c16af4eda6d74c70cbc62f5d3f77b96e4cbe" @@ -4515,14 +4481,6 @@ "@typescript-eslint/types" "^8.52.0" debug "^4.4.3" -"@typescript-eslint/scope-manager@8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.50.1.tgz#4a7cd64bcd45990865bdb2bedcacbfeccbd08193" - integrity sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw== - dependencies: - "@typescript-eslint/types" "8.50.1" - "@typescript-eslint/visitor-keys" "8.50.1" - "@typescript-eslint/scope-manager@8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.52.0.tgz#9884ff690fad30380ccabfb08af1ac200af6b4e5" @@ -4531,27 +4489,11 @@ "@typescript-eslint/types" "8.52.0" "@typescript-eslint/visitor-keys" "8.52.0" -"@typescript-eslint/tsconfig-utils@8.50.1", "@typescript-eslint/tsconfig-utils@^8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.1.tgz#ee4894bec14ef13db305d0323b14b109d996f116" - integrity sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw== - "@typescript-eslint/tsconfig-utils@8.52.0", "@typescript-eslint/tsconfig-utils@^8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.52.0.tgz#0296751c22ed05c83787a6eaec65ae221bd8b8ed" integrity sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg== -"@typescript-eslint/type-utils@8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.50.1.tgz#7bbc79baa03aee6e3b3faf14bb0b8a78badb2370" - integrity sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg== - dependencies: - "@typescript-eslint/types" "8.50.1" - "@typescript-eslint/typescript-estree" "8.50.1" - "@typescript-eslint/utils" "8.50.1" - debug "^4.3.4" - ts-api-utils "^2.1.0" - "@typescript-eslint/type-utils@8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.52.0.tgz#6e554113f8a074cf9b2faa818d2ebfccb867d6c5" @@ -4563,31 +4505,11 @@ debug "^4.4.3" ts-api-utils "^2.4.0" -"@typescript-eslint/types@8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.50.1.tgz#43d19e99613788e0715f799a29f139981bcd8385" - integrity sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA== - -"@typescript-eslint/types@8.52.0", "@typescript-eslint/types@^8.50.1", "@typescript-eslint/types@^8.52.0": +"@typescript-eslint/types@8.52.0", "@typescript-eslint/types@^8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.52.0.tgz#1eb0a16b324824bc23b89d109a267c38c9213c4a" integrity sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg== -"@typescript-eslint/typescript-estree@8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.1.tgz#ce273e584694fa5bd34514fcfbea51fe1d79e271" - integrity sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ== - dependencies: - "@typescript-eslint/project-service" "8.50.1" - "@typescript-eslint/tsconfig-utils" "8.50.1" - "@typescript-eslint/types" "8.50.1" - "@typescript-eslint/visitor-keys" "8.50.1" - debug "^4.3.4" - minimatch "^9.0.4" - semver "^7.6.0" - tinyglobby "^0.2.15" - ts-api-utils "^2.1.0" - "@typescript-eslint/typescript-estree@8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.52.0.tgz#2ad7721c671be2127951286cb7f44c4ce55b0591" @@ -4603,16 +4525,6 @@ tinyglobby "^0.2.15" ts-api-utils "^2.4.0" -"@typescript-eslint/utils@8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.50.1.tgz#054db870952e7526c3cf2162a2ff6e9434e544d0" - integrity sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ== - dependencies: - "@eslint-community/eslint-utils" "^4.7.0" - "@typescript-eslint/scope-manager" "8.50.1" - "@typescript-eslint/types" "8.50.1" - "@typescript-eslint/typescript-estree" "8.50.1" - "@typescript-eslint/utils@8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.52.0.tgz#b249be8264899b80d996fa353b4b84da4662f962" @@ -4623,14 +4535,6 @@ "@typescript-eslint/types" "8.52.0" "@typescript-eslint/typescript-estree" "8.52.0" -"@typescript-eslint/visitor-keys@8.50.1": - version "8.50.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.1.tgz#13b9d43b7567862faca69527580b9adda1a5c9fd" - integrity sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ== - dependencies: - "@typescript-eslint/types" "8.50.1" - eslint-visitor-keys "^4.2.1" - "@typescript-eslint/visitor-keys@8.52.0": version "8.52.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.52.0.tgz#50361c48a6302676230fe498f80f6decce4bf673" @@ -8298,7 +8202,7 @@ ignore@^5.2.0, ignore@^5.2.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== -ignore@^7.0.0, ignore@^7.0.5: +ignore@^7.0.5: version "7.0.5" resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== @@ -10296,7 +10200,7 @@ minimatch@^7.4.3: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.4, minimatch@^9.0.5: +minimatch@^9.0.5: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== @@ -12539,7 +12443,7 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2: +semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.4, semver@^7.6.2: version "7.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== @@ -13425,7 +13329,7 @@ trough@^2.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== -ts-api-utils@^2.1.0, ts-api-utils@^2.4.0: +ts-api-utils@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8" integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== @@ -13560,15 +13464,15 @@ types-ramda@^0.30.1: dependencies: ts-toolbelt "^9.6.0" -typescript-eslint@^8.50.1: - version "8.50.1" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.50.1.tgz#047df900e568757bc791b6b1ab6fa5fbed9b2393" - integrity sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ== +typescript-eslint@^8.52.0: + version "8.52.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.52.0.tgz#b8c156b6f2b4dee202a85712ff6a37f614476413" + integrity sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA== dependencies: - "@typescript-eslint/eslint-plugin" "8.50.1" - "@typescript-eslint/parser" "8.50.1" - "@typescript-eslint/typescript-estree" "8.50.1" - "@typescript-eslint/utils" "8.50.1" + "@typescript-eslint/eslint-plugin" "8.52.0" + "@typescript-eslint/parser" "8.52.0" + "@typescript-eslint/typescript-estree" "8.52.0" + "@typescript-eslint/utils" "8.52.0" typescript@~5.9.3: version "5.9.3" From fa3d4a75caf25f47351e8022c55d7da4e4bbb758 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:00:47 -0800 Subject: [PATCH 16/16] chore(deps): bump actions/download-artifact from 6 to 7 (#36699) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/superset-frontend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/superset-frontend.yml b/.github/workflows/superset-frontend.yml index 99ffbdfdf712..651d7519f7be 100644 --- a/.github/workflows/superset-frontend.yml +++ b/.github/workflows/superset-frontend.yml @@ -174,7 +174,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Download Docker Image Artifact - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: docker-image