feat(ag-grid): Server Side Filtering for Column Level Filters (#35683)

This commit is contained in:
amaannawab923
2026-01-12 19:25:07 +05:30
committed by GitHub
parent 459b4cb23d
commit 4f444ae1d2
20 changed files with 4142 additions and 95 deletions

View File

@@ -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<script>alert(1)</script>': {
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')");
});
});
});

View File

@@ -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<AgGridReact>;
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<AgGridReact>;
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<AgGridReact>;
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<AgGridReact>;
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<AgGridReact>;
// 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<AgGridReact>;
// 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<AgGridReact>;
// 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<AgGridReact>;
// 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<AgGridReact>;
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<AgGridReact>;
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<AgGridReact>;
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<AgGridReact>;
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<AgGridReact>;
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<AgGridReact>;
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<AgGridReact>;
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<AgGridReact>;
// 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<AgGridReact>;
// 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<AgGridReact>;
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<AgGridReact>;
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);
});
});
});

View File

@@ -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<AgGridChartState> = {
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<AgGridChartState> = {};
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<AgGridChartState> = {
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<AgGridChartState> = {};
const serverPaginationData = {
agGridFilterModel: {},
};
const result = getInitialFilterModel(
chartState,
serverPaginationData,
true,
);
expect(result).toBeUndefined();
});
it('should handle both being empty objects', () => {
const chartState: Partial<AgGridChartState> = {
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<AgGridChartState> = {};
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<AgGridChartState> = {
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<AgGridChartState> = {
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<AgGridChartState> = {
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<AgGridChartState> = {
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<AgGridChartState> = {
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<AgGridChartState> = {
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<AgGridChartState> = {
filterModel: originalFilterModel,
};
const result = getInitialFilterModel(chartState, undefined, true);
// Should return exact same reference, not a copy
expect(result).toBe(originalFilterModel);
});
});
});