diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 29cf32d131fb..07c6d67e2230 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -16,6 +16,7 @@ const config: Config.InitialOptions = { 'ts-jest': { useESM: true, isolatedModules: true, + tsconfig: '/tsconfig.jest.json', }, }, testMatch: ['/src/**/*?(*.)(test).(ts|js)?(x)'], diff --git a/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx b/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx index 4e37ddb4591c..b9c6827345af 100644 --- a/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx +++ b/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx @@ -1,14 +1,16 @@ import { render, screen } from '@testing-library/react'; +import getLocal from '../../../api/browser/localstorage/get'; import AppLoading from '../AppLoading'; -// Mock the localStorage API -const mockGet = jest.fn(); -jest.mock('api/browser/localstorage/get', () => ({ +jest.mock('../../../api/browser/localstorage/get', () => ({ __esModule: true, - default: mockGet, + default: jest.fn(), })); +// Access the mocked function +const mockGet = (getLocal as unknown) as jest.Mock; + describe('AppLoading', () => { const SIGNOZ_TEXT = 'SigNoz'; const TAGLINE_TEXT = diff --git a/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts index 6e30c69ab338..08e256f015bd 100644 --- a/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts +++ b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts @@ -1,9 +1,19 @@ /* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable import/no-unresolved */ +import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { extractQueryPairs } from 'utils/queryContextUtils'; -import { convertFiltersToExpression } from '../utils'; +import { + convertFiltersToExpression, + convertFiltersToExpressionWithExistingQuery, +} from '../utils'; describe('convertFiltersToExpression', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should handle empty, null, and undefined inputs', () => { // Test null and undefined expect(convertFiltersToExpression(null as any)).toEqual({ expression: '' }); @@ -533,4 +543,229 @@ describe('convertFiltersToExpression', () => { "user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])", }); }); + + it('should return filters with new expression when no existing query', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: OPERATORS['='], + value: 'test-service', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + undefined, + ); + + expect(result.filters).toEqual(filters); + expect(result.filter.expression).toBe("service.name = 'test-service'"); + }); + + it('should handle empty filters', () => { + const filters = { + items: [], + op: 'AND', + }; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + undefined, + ); + + expect(result.filters).toEqual(filters); + expect(result.filter.expression).toBe(''); + }); + + it('should handle existing query with matching filters', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: OPERATORS['='], + value: 'updated-service', + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name = 'old-service'"; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters).toBeDefined(); + expect(result.filter).toBeDefined(); + expect(result.filter.expression).toBe("service.name = 'updated-service'"); + // Ensure parser can parse the existing query + expect(extractQueryPairs(existingQuery)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'service.name', + operator: '=', + value: "'old-service'", + }), + ]), + ); + }); + + it('should handle IN operator with existing query', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: OPERATORS.IN, + value: ['service1', 'service2'], + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name IN ['old-service']"; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters).toBeDefined(); + expect(result.filter).toBeDefined(); + expect(result.filter.expression).toBe( + "service.name IN ['service1', 'service2']", + ); + }); + + it('should handle IN operator conversion from equals', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: OPERATORS.IN, + value: ['service1', 'service2'], + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name = 'old-service'"; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(1); + expect(result.filter.expression).toBe( + "service.name IN ['service1', 'service2'] ", + ); + }); + + it('should handle NOT IN operator conversion from not equals', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: negateOperator(OPERATORS.IN), + value: ['service1', 'service2'], + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name != 'old-service'"; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(1); + expect(result.filter.expression).toBe( + "service.name NOT IN ['service1', 'service2'] ", + ); + }); + + it('should add new filters when they do not exist in existing query', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'new.key', key: 'new.key', type: 'string' }, + op: OPERATORS['='], + value: 'new-value', + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name = 'old-service'"; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(2); // Original + new filter + expect(result.filter.expression).toBe( + "service.name = 'old-service' new.key = 'new-value'", + ); + }); + + it('should handle simple value replacement', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'status', key: 'status', type: 'string' }, + op: OPERATORS['='], + value: 'error', + }, + ], + op: 'AND', + }; + + const existingQuery = "status = 'success'"; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(1); + expect(result.filter.expression).toBe("status = 'error'"); + }); + + it('should handle filters with no key gracefully', () => { + const filters = { + items: [ + { + id: '1', + key: undefined, + op: OPERATORS['='], + value: 'test-value', + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name = 'old-service'"; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(2); + expect(result.filter.expression).toBe("service.name = 'old-service'"); + }); }); diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index 49f6b8988de1..2abca6d239b6 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -6,7 +6,7 @@ import { QUERY_BUILDER_FUNCTIONS, } from 'constants/antlrQueryConstants'; import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { cloneDeep } from 'lodash-es'; +import { cloneDeep, isEqual, sortBy } from 'lodash-es'; import { IQueryPair } from 'types/antlrQueryTypes'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { @@ -163,6 +163,19 @@ export const convertExpressionToFilters = ( return filters; }; +const getQueryPairsMap = (query: string): Map => { + const queryPairs = extractQueryPairs(query); + const queryPairsMap: Map = new Map(); + + queryPairs.forEach((pair) => { + const key = pair.hasNegation + ? `${pair.key}-not ${pair.operator}`.trim().toLowerCase() + : `${pair.key}-${pair.operator}`.trim().toLowerCase(); + queryPairsMap.set(key, pair); + }); + + return queryPairsMap; +}; export const convertFiltersToExpressionWithExistingQuery = ( filters: TagFilter, @@ -195,24 +208,12 @@ export const convertFiltersToExpressionWithExistingQuery = ( }; } - // Extract query pairs from the existing query - const queryPairs = extractQueryPairs(existingQuery.trim()); - let queryPairsMap: Map = new Map(); const nonExistingFilters: TagFilterItem[] = []; let modifiedQuery = existingQuery; // We'll modify this query as we proceed const visitedPairs: Set = new Set(); // Set to track visited query pairs // Map extracted query pairs to key-specific pair information for faster access - if (queryPairs.length > 0) { - queryPairsMap = new Map( - queryPairs.map((pair) => { - const key = pair.hasNegation - ? `${pair.key}-not ${pair.operator}`.trim().toLowerCase() - : `${pair.key}-${pair.operator}`.trim().toLowerCase(); - return [key, pair]; - }), - ); - } + let queryPairsMap = getQueryPairsMap(existingQuery.trim()); filters?.items?.forEach((filter) => { const { key, op, value } = filter; @@ -241,10 +242,37 @@ export const convertFiltersToExpressionWithExistingQuery = ( existingPair.position?.valueEnd ) { visitedPairs.add(`${key.key}-${op}`.trim().toLowerCase()); + + // Check if existing values match current filter values (for array-based operators) + if (existingPair.valueList && filter.value && Array.isArray(filter.value)) { + // Clean quotes from string values for comparison + const cleanValues = (values: any[]): any[] => + values.map((val) => (typeof val === 'string' ? unquote(val) : val)); + + const cleanExistingValues = cleanValues(existingPair.valueList); + const cleanFilterValues = cleanValues(filter.value); + + // Compare arrays (order-independent) - if identical, keep existing value + const isSameValues = + cleanExistingValues.length === cleanFilterValues.length && + isEqual(sortBy(cleanExistingValues), sortBy(cleanFilterValues)); + + if (isSameValues) { + // Values are identical, preserve existing formatting + modifiedQuery = + modifiedQuery.slice(0, existingPair.position.valueStart) + + existingPair.value + + modifiedQuery.slice(existingPair.position.valueEnd + 1); + return; + } + } + modifiedQuery = modifiedQuery.slice(0, existingPair.position.valueStart) + formattedValue + modifiedQuery.slice(existingPair.position.valueEnd + 1); + + queryPairsMap = getQueryPairsMap(modifiedQuery); return; } @@ -270,6 +298,7 @@ export const convertFiltersToExpressionWithExistingQuery = ( )}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice( notInPair.position.valueEnd + 1, )}`; + queryPairsMap = getQueryPairsMap(modifiedQuery.trim()); } shouldAddToNonExisting = false; // Don't add this to non-existing filters } else if ( @@ -286,6 +315,7 @@ export const convertFiltersToExpressionWithExistingQuery = ( )}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice( equalsPair.position.valueEnd + 1, )}`; + queryPairsMap = getQueryPairsMap(modifiedQuery); } shouldAddToNonExisting = false; // Don't add this to non-existing filters } else if ( @@ -302,6 +332,7 @@ export const convertFiltersToExpressionWithExistingQuery = ( )}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice( notEqualsPair.position.valueEnd + 1, )}`; + queryPairsMap = getQueryPairsMap(modifiedQuery); } shouldAddToNonExisting = false; // Don't add this to non-existing filters } @@ -323,6 +354,7 @@ export const convertFiltersToExpressionWithExistingQuery = ( } ${formattedValue} ${modifiedQuery.slice( notEqualsPair.position.valueEnd + 1, )}`; + queryPairsMap = getQueryPairsMap(modifiedQuery); } shouldAddToNonExisting = false; // Don't add this to non-existing filters } @@ -335,6 +367,23 @@ export const convertFiltersToExpressionWithExistingQuery = ( if ( queryPairsMap.has(`${filter.key?.key}-${filter.op}`.trim().toLowerCase()) ) { + const existingPair = queryPairsMap.get( + `${filter.key?.key}-${filter.op}`.trim().toLowerCase(), + ); + if ( + existingPair && + existingPair.position?.valueStart && + existingPair.position?.valueEnd + ) { + const formattedValue = formatValueForExpression(value, op); + // replace the value with the new value + modifiedQuery = + modifiedQuery.slice(0, existingPair.position.valueStart) + + formattedValue + + modifiedQuery.slice(existingPair.position.valueEnd + 1); + queryPairsMap = getQueryPairsMap(modifiedQuery); + } + visitedPairs.add(`${filter.key?.key}-${filter.op}`.trim().toLowerCase()); } diff --git a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx index 653fbbb5ddf6..aaeba0f654a4 100644 --- a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx +++ b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx @@ -151,7 +151,7 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({ jest.mock('hooks/logs/useCopyLogLink', () => ({ useCopyLogLink: jest.fn().mockReturnValue({ - activeLogId: ACTIVE_LOG_ID, + activeLogId: undefined, }), })); diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx index c5a48b14022a..ef1ff875f87b 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx @@ -34,7 +34,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useQueryClient } from 'react-query'; import { connect, useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { useNavigationType } from 'react-router-dom-v5-compat'; +import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat'; import { useCopyToClipboard } from 'react-use'; import { bindActionCreators, Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; @@ -117,6 +117,8 @@ function DateTimeSelection({ ); const [modalEndTime, setModalEndTime] = useState(initialModalEndTime); + const [searchParams] = useSearchParams(); + // Effect to update modal time state when props change useEffect(() => { if (modalInitialStartTime !== undefined) { @@ -410,8 +412,10 @@ function DateTimeSelection({ // Remove Hidden Filters from URL query parameters on time change urlQuery.delete(QueryParams.activeLogId); - const updatedCompositeQuery = getUpdatedCompositeQuery(); - urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery); + if (searchParams.has(QueryParams.compositeQuery)) { + const updatedCompositeQuery = getUpdatedCompositeQuery(); + urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery); + } const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; safeNavigate(generatedUrl); @@ -428,6 +432,7 @@ function DateTimeSelection({ updateLocalStorageForRoutes, updateTimeInterval, urlQuery, + searchParams, ], ); @@ -488,8 +493,10 @@ function DateTimeSelection({ urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString()); urlQuery.delete(QueryParams.relativeTime); - const updatedCompositeQuery = getUpdatedCompositeQuery(); - urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery); + if (searchParams.has(QueryParams.compositeQuery)) { + const updatedCompositeQuery = getUpdatedCompositeQuery(); + urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery); + } const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; safeNavigate(generatedUrl); diff --git a/frontend/src/utils/__tests__/queryContextUtils.test.ts b/frontend/src/utils/__tests__/queryContextUtils.test.ts new file mode 100644 index 000000000000..81ddc8f2112d --- /dev/null +++ b/frontend/src/utils/__tests__/queryContextUtils.test.ts @@ -0,0 +1,501 @@ +/* eslint-disable */ + +import { + createContext, + extractQueryPairs, + getCurrentQueryPair, + getCurrentValueIndexAtCursor, +} from '../queryContextUtils'; + +describe('extractQueryPairs', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should extract NOT EXISTS and NOT LIKE correctly', () => { + const input = "active NOT EXISTS AND name NOT LIKE '%tmp%'"; + + const result = extractQueryPairs(input); + + expect(result).toEqual([ + { + key: 'active', + operator: 'EXISTS', + value: undefined, + valueList: [], + valuesPosition: [], + hasNegation: true, + isMultiValue: false, + position: expect.any(Object), + isComplete: false, + }, + { + key: 'name', + operator: 'LIKE', + value: "'%tmp%'", + valueList: [], + valuesPosition: [], + hasNegation: true, + isMultiValue: false, + position: expect.any(Object), + isComplete: true, + }, + ]); + }); + + test('should extract IN with numeric list inside parentheses', () => { + const input = 'id IN (1, 2, 3)'; + const result = extractQueryPairs(input); + expect(result).toEqual([ + expect.objectContaining({ + key: 'id', + operator: 'IN', + isMultiValue: true, + isComplete: true, + value: expect.stringMatching(/^\(.*\)$/), + valueList: ['1', '2', '3'], + valuesPosition: expect.arrayContaining([ + expect.objectContaining({ + start: expect.any(Number), + end: expect.any(Number), + }), + ]), + }), + ]); + }); + + test('should handle extra whitespace and separators in IN lists', () => { + const input = "label IN [ 'a' , 'b' , 'c' ]"; + const result = extractQueryPairs(input); + expect(result).toEqual([ + expect.objectContaining({ + key: 'label', + operator: 'IN', + isMultiValue: true, + isComplete: true, + value: expect.stringMatching(/^\[.*\]$/), + valueList: ["'a'", "'b'", "'c'"], + }), + ]); + }); + + test('should return incomplete pair when value is missing', () => { + const input = 'a ='; + const result = extractQueryPairs(input); + expect(result).toEqual([ + expect.objectContaining({ + key: 'a', + operator: '=', + value: undefined, + isComplete: false, + }), + ]); + }); + + test('should parse pairs within grouping parentheses with conjunctions', () => { + const input = "(name = 'x' AND age > 10) OR active EXISTS"; + const result = extractQueryPairs(input); + expect(result).toEqual([ + expect.objectContaining({ + key: 'name', + operator: '=', + value: "'x'", + isComplete: true, + }), + expect.objectContaining({ + key: 'age', + operator: '>', + value: '10', + isComplete: true, + }), + expect.objectContaining({ + key: 'active', + operator: 'EXISTS', + value: undefined, + isComplete: false, + }), + ]); + }); + + test('should extract query pairs from complex query with IN operator and multiple conditions', () => { + const input = + "service.name IN ['adservice', 'consumer-svc-1'] AND cloud.account.id = 'signoz-staging' code.lineno < 172"; + + const result = extractQueryPairs(input); + + expect(result).toEqual([ + { + key: 'service.name', + operator: 'IN', + value: "['adservice', 'consumer-svc-1']", + valueList: ["'adservice'", "'consumer-svc-1'"], + valuesPosition: [ + { + start: 17, + end: 27, + }, + { + start: 30, + end: 45, + }, + ], + hasNegation: false, + isMultiValue: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 14, + valueStart: 16, + valueEnd: 46, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + { + key: 'cloud.account.id', + operator: '=', + value: "'signoz-staging'", + valueList: [], + valuesPosition: [], + hasNegation: false, + isMultiValue: false, + position: { + keyStart: 52, + keyEnd: 67, + operatorStart: 69, + operatorEnd: 69, + valueStart: 71, + valueEnd: 86, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + { + key: 'code.lineno', + operator: '<', + value: '172', + valueList: [], + valuesPosition: [], + hasNegation: false, + isMultiValue: false, + position: { + keyStart: 88, + keyEnd: 98, + operatorStart: 100, + operatorEnd: 100, + valueStart: 102, + valueEnd: 104, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + ]); + }); + + test('should extract query pairs from complex query with IN operator without brackets', () => { + const input = + "service.name IN 'adservice' AND cloud.account.id = 'signoz-staging' code.lineno < 172"; + + const result = extractQueryPairs(input); + expect(result).toEqual([ + { + key: 'service.name', + operator: 'IN', + value: "'adservice'", + valueList: ["'adservice'"], + valuesPosition: [ + { + start: 16, + end: 26, + }, + ], + hasNegation: false, + isMultiValue: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 14, + valueStart: 16, + valueEnd: 26, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + { + key: 'cloud.account.id', + operator: '=', + value: "'signoz-staging'", + valueList: [], + valuesPosition: [], + hasNegation: false, + isMultiValue: false, + position: { + keyStart: 32, + keyEnd: 47, + operatorStart: 49, + operatorEnd: 49, + valueStart: 51, + valueEnd: 66, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + { + key: 'code.lineno', + operator: '<', + value: '172', + valueList: [], + valuesPosition: [], + hasNegation: false, + isMultiValue: false, + position: { + keyStart: 68, + keyEnd: 78, + operatorStart: 80, + operatorEnd: 80, + valueStart: 82, + valueEnd: 84, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + ]); + }); + + test('should handle recursion guard', () => { + // This test verifies the recursion protection in the function + // We'll mock the function to simulate recursion + + // Mock console.warn to capture the warning + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // Call the function multiple times to trigger recursion guard + // Note: This is a simplified test since we can't easily trigger the actual recursion + const result = extractQueryPairs('test'); + + // The function should still work normally + expect(Array.isArray(result)).toBe(true); + + consoleSpy.mockRestore(); + }); +}); + +describe('createContext', () => { + test('should create a context object with all parameters', () => { + const mockToken = { + type: 29, + text: 'test', + start: 0, + stop: 3, + }; + + const result = createContext( + mockToken as any, + true, // isInKey + false, // isInNegation + false, // isInOperator + false, // isInValue + 'testKey', // keyToken + '=', // operatorToken + 'testValue', // valueToken + [], // queryPairs + null, // currentPair + ); + + expect(result).toEqual({ + tokenType: 29, + text: 'test', + start: 0, + stop: 3, + currentToken: 'test', + isInKey: true, + isInNegation: false, + isInOperator: false, + isInValue: false, + isInFunction: false, + isInConjunction: false, + isInParenthesis: false, + keyToken: 'testKey', + operatorToken: '=', + valueToken: 'testValue', + queryPairs: [], + currentPair: null, + }); + }); + + test('should create a context object with minimal parameters', () => { + const mockToken = { + type: 29, + text: 'test', + start: 0, + stop: 3, + }; + + const result = createContext(mockToken as any, false, false, false, false); + + expect(result).toEqual({ + tokenType: 29, + text: 'test', + start: 0, + stop: 3, + currentToken: 'test', + isInKey: false, + isInNegation: false, + isInOperator: false, + isInValue: false, + isInFunction: false, + isInConjunction: false, + isInParenthesis: false, + keyToken: undefined, + operatorToken: undefined, + valueToken: undefined, + queryPairs: [], + currentPair: undefined, + }); + }); +}); + +describe('getCurrentValueIndexAtCursor', () => { + test('should return correct value index when cursor is within a value range', () => { + const valuesPosition = [ + { start: 0, end: 10 }, + { start: 15, end: 25 }, + { start: 30, end: 40 }, + ]; + + const result = getCurrentValueIndexAtCursor(valuesPosition, 20); + + expect(result).toBe(1); + }); + + test('should return null when cursor is not within any value range', () => { + const valuesPosition = [ + { start: 0, end: 10 }, + { start: 15, end: 25 }, + ]; + + const result = getCurrentValueIndexAtCursor(valuesPosition, 12); + + expect(result).toBeNull(); + }); + + test('should return correct index when cursor is at the boundary', () => { + const valuesPosition = [ + { start: 0, end: 10 }, + { start: 15, end: 25 }, + ]; + + const result = getCurrentValueIndexAtCursor(valuesPosition, 10); + + expect(result).toBe(0); + }); + + test('should return null for empty valuesPosition array', () => { + const result = getCurrentValueIndexAtCursor([], 5); + + expect(result).toBeNull(); + }); +}); + +describe('getCurrentQueryPair', () => { + test('should return the correct query pair at cursor position', () => { + const queryPairs = [ + { + key: 'a', + operator: '=', + value: '1', + position: { + keyStart: 0, + keyEnd: 0, + operatorStart: 2, + operatorEnd: 2, + valueStart: 4, + valueEnd: 4, + }, + isComplete: true, + } as any, + { + key: 'b', + operator: '=', + value: '2', + position: { + keyStart: 10, + keyEnd: 10, + operatorStart: 12, + operatorEnd: 12, + valueStart: 14, + valueEnd: 14, + }, + isComplete: true, + } as any, + ]; + + const query = 'a = 1 AND b = 2'; + const result = getCurrentQueryPair(queryPairs, query, 15); + + expect(result).toEqual(queryPairs[1]); + }); + + test('should return null when no pairs match cursor position', () => { + const queryPairs = [ + { + key: 'a', + operator: '=', + value: '1', + position: { + keyStart: 0, + keyEnd: 0, + operatorStart: 2, + operatorEnd: 2, + valueStart: 4, + valueEnd: 4, + }, + isComplete: true, + } as any, + ]; + + const query = 'a = 1'; + // Test with cursor position that's before any pair starts + const result = getCurrentQueryPair(queryPairs, query, -1); + + expect(result).toBeNull(); + }); + + test('should return null for empty queryPairs array', () => { + const result = getCurrentQueryPair([], 'test query', 5); + + expect(result).toBeNull(); + }); + + test('should return last pair when cursor is at the end', () => { + const queryPairs = [ + { + key: 'a', + operator: '=', + value: '1', + position: { + keyStart: 0, + keyEnd: 0, + operatorStart: 2, + operatorEnd: 2, + valueStart: 4, + valueEnd: 4, + }, + isComplete: true, + } as any, + ]; + + const query = 'a = 1'; + const result = getCurrentQueryPair(queryPairs, query, 5); + + expect(result).toEqual(queryPairs[0]); + }); +}); diff --git a/frontend/src/utils/queryContextUtils.ts b/frontend/src/utils/queryContextUtils.ts index 330a083f2cc5..afc2fa400b55 100644 --- a/frontend/src/utils/queryContextUtils.ts +++ b/frontend/src/utils/queryContextUtils.ts @@ -1255,10 +1255,12 @@ export function extractQueryPairs(query: string): IQueryPair[] { allTokens[iterator].type, ) ) { + // Capture opening token type before advancing + const openingTokenType = allTokens[iterator].type; multiValueStart = allTokens[iterator].start; iterator += 1; const closingToken = - allTokens[iterator].type === FilterQueryLexer.LPAREN + openingTokenType === FilterQueryLexer.LPAREN ? FilterQueryLexer.RPAREN : FilterQueryLexer.RBRACK; @@ -1279,6 +1281,15 @@ export function extractQueryPairs(query: string): IQueryPair[] { if (allTokens[iterator].type === closingToken) { multiValueEnd = allTokens[iterator].stop; } + } else if (isValueToken(allTokens[iterator].type)) { + valueList.push(allTokens[iterator].text); + valuesPosition.push({ + start: allTokens[iterator].start, + end: allTokens[iterator].stop, + }); + multiValueStart = allTokens[iterator].start; + multiValueEnd = allTokens[iterator].stop; + iterator += 1; } currentPair.valuesPosition = valuesPosition; diff --git a/frontend/tsconfig.jest.json b/frontend/tsconfig.jest.json new file mode 100644 index 000000000000..f2dc6787e1a6 --- /dev/null +++ b/frontend/tsconfig.jest.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es6", + "noEmit": false + } +} \ No newline at end of file