mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-18 07:56:56 +00:00
Merge branch 'main' into enhancement/cmd-click-across-routes
This commit is contained in:
commit
b48ac31f0d
@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
*.typegen.ts
|
*.typegen.ts
|
||||||
i18-generate-hash.js
|
i18-generate-hash.js
|
||||||
|
src/parser/TraceOperatorParser/**
|
||||||
@ -10,4 +10,6 @@ public/
|
|||||||
**/*.json
|
**/*.json
|
||||||
|
|
||||||
# Ignore all files in parser folder:
|
# Ignore all files in parser folder:
|
||||||
src/parser/**
|
src/parser/**
|
||||||
|
|
||||||
|
src/TraceOperator/parser/**
|
||||||
@ -45,6 +45,7 @@
|
|||||||
"@sentry/webpack-plugin": "2.22.6",
|
"@sentry/webpack-plugin": "2.22.6",
|
||||||
"@signozhq/badge": "0.0.2",
|
"@signozhq/badge": "0.0.2",
|
||||||
"@signozhq/calendar": "0.0.0",
|
"@signozhq/calendar": "0.0.0",
|
||||||
|
"@signozhq/callout": "0.0.2",
|
||||||
"@signozhq/design-tokens": "1.1.4",
|
"@signozhq/design-tokens": "1.1.4",
|
||||||
"@signozhq/input": "0.0.2",
|
"@signozhq/input": "0.0.2",
|
||||||
"@signozhq/popover": "0.0.0",
|
"@signozhq/popover": "0.0.0",
|
||||||
|
|||||||
115
frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts
Normal file
115
frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
import { ApiBaseInstance } from 'api';
|
||||||
|
|
||||||
|
import { getFieldKeys } from '../getFieldKeys';
|
||||||
|
|
||||||
|
// Mock the API instance
|
||||||
|
jest.mock('api', () => ({
|
||||||
|
ApiBaseInstance: {
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('getFieldKeys API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSuccessResponse = {
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
keys: {
|
||||||
|
'service.name': [],
|
||||||
|
'http.status_code': [],
|
||||||
|
},
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should call API with correct parameters when no args provided', async () => {
|
||||||
|
// Mock successful API response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||||
|
|
||||||
|
// Call function with no parameters
|
||||||
|
await getFieldKeys();
|
||||||
|
|
||||||
|
// Verify API was called correctly with empty params object
|
||||||
|
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call API with signal parameter when provided', async () => {
|
||||||
|
// Mock successful API response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||||
|
|
||||||
|
// Call function with signal parameter
|
||||||
|
await getFieldKeys('traces');
|
||||||
|
|
||||||
|
// Verify API was called with signal parameter
|
||||||
|
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
||||||
|
params: { signal: 'traces' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call API with name parameter when provided', async () => {
|
||||||
|
// Mock successful API response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
keys: { service: [] },
|
||||||
|
complete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call function with name parameter
|
||||||
|
await getFieldKeys(undefined, 'service');
|
||||||
|
|
||||||
|
// Verify API was called with name parameter
|
||||||
|
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
||||||
|
params: { name: 'service' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call API with both signal and name when provided', async () => {
|
||||||
|
// Mock successful API response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
keys: { service: [] },
|
||||||
|
complete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call function with both parameters
|
||||||
|
await getFieldKeys('logs', 'service');
|
||||||
|
|
||||||
|
// Verify API was called with both parameters
|
||||||
|
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
||||||
|
params: { signal: 'logs', name: 'service' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return properly formatted response', async () => {
|
||||||
|
// Mock API to return our response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||||
|
|
||||||
|
// Call the function
|
||||||
|
const result = await getFieldKeys('traces');
|
||||||
|
|
||||||
|
// Verify the returned structure matches SuccessResponseV2 format
|
||||||
|
expect(result).toEqual({
|
||||||
|
httpStatusCode: 200,
|
||||||
|
data: mockSuccessResponse.data.data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,214 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
import { ApiBaseInstance } from 'api';
|
||||||
|
|
||||||
|
import { getFieldValues } from '../getFieldValues';
|
||||||
|
|
||||||
|
// Mock the API instance
|
||||||
|
jest.mock('api', () => ({
|
||||||
|
ApiBaseInstance: {
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('getFieldValues API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the API with correct parameters (no options)', async () => {
|
||||||
|
// Mock API response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
values: {
|
||||||
|
stringValues: ['frontend', 'backend'],
|
||||||
|
},
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call function without parameters
|
||||||
|
await getFieldValues();
|
||||||
|
|
||||||
|
// Verify API was called correctly with empty params
|
||||||
|
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the API with signal parameter', async () => {
|
||||||
|
// Mock API response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
values: {
|
||||||
|
stringValues: ['frontend', 'backend'],
|
||||||
|
},
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call function with signal parameter
|
||||||
|
await getFieldValues('traces');
|
||||||
|
|
||||||
|
// Verify API was called with signal parameter
|
||||||
|
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||||
|
params: { signal: 'traces' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the API with name parameter', async () => {
|
||||||
|
// Mock API response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
values: {
|
||||||
|
stringValues: ['frontend', 'backend'],
|
||||||
|
},
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call function with name parameter
|
||||||
|
await getFieldValues(undefined, 'service.name');
|
||||||
|
|
||||||
|
// Verify API was called with name parameter
|
||||||
|
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||||
|
params: { name: 'service.name' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the API with value parameter', async () => {
|
||||||
|
// Mock API response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
values: {
|
||||||
|
stringValues: ['frontend'],
|
||||||
|
},
|
||||||
|
complete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call function with value parameter
|
||||||
|
await getFieldValues(undefined, 'service.name', 'front');
|
||||||
|
|
||||||
|
// Verify API was called with value parameter
|
||||||
|
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||||
|
params: { name: 'service.name', searchText: 'front' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the API with time range parameters', async () => {
|
||||||
|
// Mock API response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
values: {
|
||||||
|
stringValues: ['frontend', 'backend'],
|
||||||
|
},
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call function with time range parameters
|
||||||
|
const startUnixMilli = 1625097600000000; // Note: nanoseconds
|
||||||
|
const endUnixMilli = 1625184000000000;
|
||||||
|
await getFieldValues(
|
||||||
|
'logs',
|
||||||
|
'service.name',
|
||||||
|
undefined,
|
||||||
|
startUnixMilli,
|
||||||
|
endUnixMilli,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify API was called with time range parameters (converted to milliseconds)
|
||||||
|
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||||
|
params: {
|
||||||
|
signal: 'logs',
|
||||||
|
name: 'service.name',
|
||||||
|
startUnixMilli: '1625097600', // Should be converted to seconds (divided by 1000000)
|
||||||
|
endUnixMilli: '1625184000', // Should be converted to seconds (divided by 1000000)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize the response values', async () => {
|
||||||
|
// Mock API response with multiple value types
|
||||||
|
const mockResponse = {
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
values: {
|
||||||
|
stringValues: ['frontend', 'backend'],
|
||||||
|
numberValues: [200, 404],
|
||||||
|
boolValues: [true, false],
|
||||||
|
},
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
// Call the function
|
||||||
|
const result = await getFieldValues('traces', 'mixed.values');
|
||||||
|
|
||||||
|
// Verify the response has normalized values array
|
||||||
|
expect(result.data?.normalizedValues).toContain('frontend');
|
||||||
|
expect(result.data?.normalizedValues).toContain('backend');
|
||||||
|
expect(result.data?.normalizedValues).toContain('200');
|
||||||
|
expect(result.data?.normalizedValues).toContain('404');
|
||||||
|
expect(result.data?.normalizedValues).toContain('true');
|
||||||
|
expect(result.data?.normalizedValues).toContain('false');
|
||||||
|
expect(result.data?.normalizedValues?.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a properly formatted success response', async () => {
|
||||||
|
// Create mock response
|
||||||
|
const mockApiResponse = {
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
values: {
|
||||||
|
stringValues: ['frontend', 'backend'],
|
||||||
|
},
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock API to return our response
|
||||||
|
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
|
||||||
|
|
||||||
|
// Call the function
|
||||||
|
const result = await getFieldValues('traces', 'service.name');
|
||||||
|
|
||||||
|
// Verify the returned structure matches SuccessResponseV2 format
|
||||||
|
expect(result).toEqual({
|
||||||
|
httpStatusCode: 200,
|
||||||
|
data: expect.objectContaining({
|
||||||
|
values: expect.any(Object),
|
||||||
|
normalizedValues: expect.any(Array),
|
||||||
|
complete: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
38
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal file
38
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { ApiBaseInstance } from 'api';
|
||||||
|
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||||
|
import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field keys for a given signal type
|
||||||
|
* @param signal Type of signal (traces, logs, metrics)
|
||||||
|
* @param name Optional search text
|
||||||
|
*/
|
||||||
|
export const getFieldKeys = async (
|
||||||
|
signal?: 'traces' | 'logs' | 'metrics',
|
||||||
|
name?: string,
|
||||||
|
): Promise<SuccessResponseV2<FieldKeyResponse>> => {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
params.signal = encodeURIComponent(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
params.name = encodeURIComponent(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ApiBaseInstance.get('/fields/keys', { params });
|
||||||
|
|
||||||
|
return {
|
||||||
|
httpStatusCode: response.status,
|
||||||
|
data: response.data.data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getFieldKeys;
|
||||||
87
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
87
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
import { ApiBaseInstance } from 'api';
|
||||||
|
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||||
|
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field values for a given signal type and field name
|
||||||
|
* @param signal Type of signal (traces, logs, metrics)
|
||||||
|
* @param name Name of the attribute for which values are being fetched
|
||||||
|
* @param value Optional search text
|
||||||
|
* @param existingQuery Optional existing query - across all present dynamic variables
|
||||||
|
*/
|
||||||
|
export const getFieldValues = async (
|
||||||
|
signal?: 'traces' | 'logs' | 'metrics',
|
||||||
|
name?: string,
|
||||||
|
searchText?: string,
|
||||||
|
startUnixMilli?: number,
|
||||||
|
endUnixMilli?: number,
|
||||||
|
existingQuery?: string,
|
||||||
|
): Promise<SuccessResponseV2<FieldValueResponse>> => {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
params.signal = encodeURIComponent(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
params.name = encodeURIComponent(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchText) {
|
||||||
|
params.searchText = encodeURIComponent(searchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startUnixMilli) {
|
||||||
|
params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endUnixMilli) {
|
||||||
|
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingQuery) {
|
||||||
|
params.existingQuery = existingQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ApiBaseInstance.get('/fields/values', { params });
|
||||||
|
|
||||||
|
// Normalize values from different types (stringValues, boolValues, etc.)
|
||||||
|
if (response.data?.data?.values) {
|
||||||
|
const allValues: string[] = [];
|
||||||
|
Object.entries(response.data?.data?.values).forEach(
|
||||||
|
([key, valueArray]: [string, any]) => {
|
||||||
|
// Skip RelatedValues as they should be kept separate
|
||||||
|
if (key === 'relatedValues') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(valueArray)) {
|
||||||
|
allValues.push(...valueArray.map(String));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a normalized values array to the response
|
||||||
|
response.data.data.normalizedValues = allValues;
|
||||||
|
|
||||||
|
// Add relatedValues to the response as per FieldValueResponse
|
||||||
|
if (response.data?.data?.values?.relatedValues) {
|
||||||
|
response.data.data.relatedValues =
|
||||||
|
response.data?.data?.values?.relatedValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
httpStatusCode: response.status,
|
||||||
|
data: response.data.data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getFieldValues;
|
||||||
@ -92,6 +92,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
|||||||
builder: {
|
builder: {
|
||||||
queryData: [baseBuilderQuery()],
|
queryData: [baseBuilderQuery()],
|
||||||
queryFormulas: [baseFormula()],
|
queryFormulas: [baseFormula()],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
graphType: PANEL_TYPES.TIME_SERIES,
|
graphType: PANEL_TYPES.TIME_SERIES,
|
||||||
@ -215,7 +216,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
clickhouse_sql: [],
|
clickhouse_sql: [],
|
||||||
builder: { queryData: [], queryFormulas: [] },
|
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
|
||||||
},
|
},
|
||||||
graphType: PANEL_TYPES.TIME_SERIES,
|
graphType: PANEL_TYPES.TIME_SERIES,
|
||||||
originalGraphType: PANEL_TYPES.TABLE,
|
originalGraphType: PANEL_TYPES.TABLE,
|
||||||
@ -286,7 +287,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
|||||||
legend: 'LC',
|
legend: 'LC',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
builder: { queryData: [], queryFormulas: [] },
|
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
|
||||||
},
|
},
|
||||||
graphType: PANEL_TYPES.TABLE,
|
graphType: PANEL_TYPES.TABLE,
|
||||||
selectedTime: 'GLOBAL_TIME',
|
selectedTime: 'GLOBAL_TIME',
|
||||||
@ -345,7 +346,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
|||||||
unit: undefined,
|
unit: undefined,
|
||||||
promql: [],
|
promql: [],
|
||||||
clickhouse_sql: [],
|
clickhouse_sql: [],
|
||||||
builder: { queryData: [], queryFormulas: [] },
|
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
|
||||||
},
|
},
|
||||||
graphType: PANEL_TYPES.TIME_SERIES,
|
graphType: PANEL_TYPES.TIME_SERIES,
|
||||||
selectedTime: 'GLOBAL_TIME',
|
selectedTime: 'GLOBAL_TIME',
|
||||||
@ -386,6 +387,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
|||||||
builder: {
|
builder: {
|
||||||
queryData: [baseBuilderQuery()],
|
queryData: [baseBuilderQuery()],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
graphType: PANEL_TYPES.TABLE,
|
graphType: PANEL_TYPES.TABLE,
|
||||||
@ -459,6 +461,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
|||||||
builder: {
|
builder: {
|
||||||
queryData: [logsQuery],
|
queryData: [logsQuery],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
graphType: PANEL_TYPES.LIST,
|
graphType: PANEL_TYPES.LIST,
|
||||||
@ -572,6 +575,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
graphType: PANEL_TYPES.TIME_SERIES,
|
graphType: PANEL_TYPES.TIME_SERIES,
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
/* eslint-disable sonarjs/cognitive-complexity */
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
/* eslint-disable sonarjs/no-identical-functions */
|
||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||||
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
||||||
import { isEmpty } from 'lodash-es';
|
import { isEmpty } from 'lodash-es';
|
||||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
import {
|
||||||
|
IBuilderQuery,
|
||||||
|
IBuilderTraceOperator,
|
||||||
|
} from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import {
|
import {
|
||||||
BaseBuilderQuery,
|
BaseBuilderQuery,
|
||||||
FieldContext,
|
FieldContext,
|
||||||
@ -24,6 +28,7 @@ import {
|
|||||||
TelemetryFieldKey,
|
TelemetryFieldKey,
|
||||||
TraceAggregation,
|
TraceAggregation,
|
||||||
VariableItem,
|
VariableItem,
|
||||||
|
VariableType,
|
||||||
} from 'types/api/v5/queryRange';
|
} from 'types/api/v5/queryRange';
|
||||||
import { EQueryType } from 'types/common/dashboard';
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
import { DataSource } from 'types/common/queryBuilder';
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
@ -332,6 +337,109 @@ export function convertBuilderQueriesToV5(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTraceOperatorBaseSpec(
|
||||||
|
queryData: IBuilderTraceOperator,
|
||||||
|
requestType: RequestType,
|
||||||
|
panelType?: PANEL_TYPES,
|
||||||
|
): BaseBuilderQuery {
|
||||||
|
const nonEmptySelectColumns = (queryData.selectColumns as (
|
||||||
|
| BaseAutocompleteData
|
||||||
|
| TelemetryFieldKey
|
||||||
|
)[])?.filter((c) => ('key' in c ? c?.key : c?.name));
|
||||||
|
|
||||||
|
const {
|
||||||
|
stepInterval,
|
||||||
|
groupBy,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
legend,
|
||||||
|
having,
|
||||||
|
orderBy,
|
||||||
|
pageSize,
|
||||||
|
} = queryData;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stepInterval: stepInterval || undefined,
|
||||||
|
groupBy:
|
||||||
|
groupBy?.length > 0
|
||||||
|
? groupBy.map(
|
||||||
|
(item: any): GroupByKey => ({
|
||||||
|
name: item.key,
|
||||||
|
fieldDataType: item?.dataType,
|
||||||
|
fieldContext: item?.type,
|
||||||
|
description: item?.description,
|
||||||
|
unit: item?.unit,
|
||||||
|
signal: item?.signal,
|
||||||
|
materialized: item?.materialized,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
limit:
|
||||||
|
panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.LIST
|
||||||
|
? limit || pageSize || undefined
|
||||||
|
: limit || undefined,
|
||||||
|
offset: requestType === 'raw' || requestType === 'trace' ? offset : undefined,
|
||||||
|
order:
|
||||||
|
orderBy?.length > 0
|
||||||
|
? orderBy.map(
|
||||||
|
(order: any): OrderBy => ({
|
||||||
|
key: {
|
||||||
|
name: order.columnName,
|
||||||
|
},
|
||||||
|
direction: order.order,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
legend: isEmpty(legend) ? undefined : legend,
|
||||||
|
having: isEmpty(having) ? undefined : (having as Having),
|
||||||
|
selectFields: isEmpty(nonEmptySelectColumns)
|
||||||
|
? undefined
|
||||||
|
: nonEmptySelectColumns?.map(
|
||||||
|
(column: any): TelemetryFieldKey => ({
|
||||||
|
name: column.name ?? column.key,
|
||||||
|
fieldDataType:
|
||||||
|
column?.fieldDataType ?? (column?.dataType as FieldDataType),
|
||||||
|
fieldContext: column?.fieldContext ?? (column?.type as FieldContext),
|
||||||
|
signal: column?.signal ?? undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertTraceOperatorToV5(
|
||||||
|
traceOperator: Record<string, IBuilderTraceOperator>,
|
||||||
|
requestType: RequestType,
|
||||||
|
panelType?: PANEL_TYPES,
|
||||||
|
): QueryEnvelope[] {
|
||||||
|
return Object.entries(traceOperator).map(
|
||||||
|
([queryName, traceOperatorData]): QueryEnvelope => {
|
||||||
|
const baseSpec = createTraceOperatorBaseSpec(
|
||||||
|
traceOperatorData,
|
||||||
|
requestType,
|
||||||
|
panelType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skip aggregation for raw request type
|
||||||
|
const aggregations =
|
||||||
|
requestType === 'raw'
|
||||||
|
? undefined
|
||||||
|
: createAggregation(traceOperatorData, panelType);
|
||||||
|
|
||||||
|
const spec: QueryEnvelope['spec'] = {
|
||||||
|
name: queryName,
|
||||||
|
...baseSpec,
|
||||||
|
expression: traceOperatorData.expression || '',
|
||||||
|
aggregations: aggregations as TraceAggregation[],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'builder_trace_operator' as QueryType,
|
||||||
|
spec,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts PromQL queries to V5 format
|
* Converts PromQL queries to V5 format
|
||||||
*/
|
*/
|
||||||
@ -406,6 +514,7 @@ export const prepareQueryRangePayloadV5 = ({
|
|||||||
formatForWeb,
|
formatForWeb,
|
||||||
originalGraphType,
|
originalGraphType,
|
||||||
fillGaps,
|
fillGaps,
|
||||||
|
dynamicVariables,
|
||||||
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
|
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
|
||||||
let legendMap: Record<string, string> = {};
|
let legendMap: Record<string, string> = {};
|
||||||
const requestType = mapPanelTypeToRequestType(graphType);
|
const requestType = mapPanelTypeToRequestType(graphType);
|
||||||
@ -413,14 +522,28 @@ export const prepareQueryRangePayloadV5 = ({
|
|||||||
|
|
||||||
switch (query.queryType) {
|
switch (query.queryType) {
|
||||||
case EQueryType.QUERY_BUILDER: {
|
case EQueryType.QUERY_BUILDER: {
|
||||||
const { queryData: data, queryFormulas } = query.builder;
|
const { queryData: data, queryFormulas, queryTraceOperator } = query.builder;
|
||||||
const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams);
|
const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams);
|
||||||
const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName');
|
const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName');
|
||||||
|
|
||||||
|
const filteredTraceOperator =
|
||||||
|
queryTraceOperator && queryTraceOperator.length > 0
|
||||||
|
? queryTraceOperator.filter((traceOperator) =>
|
||||||
|
Boolean(traceOperator.expression.trim()),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const currentTraceOperator = mapQueryDataToApi(
|
||||||
|
filteredTraceOperator,
|
||||||
|
'queryName',
|
||||||
|
tableParams,
|
||||||
|
);
|
||||||
|
|
||||||
// Combine legend maps
|
// Combine legend maps
|
||||||
legendMap = {
|
legendMap = {
|
||||||
...currentQueryData.newLegendMap,
|
...currentQueryData.newLegendMap,
|
||||||
...currentFormulas.newLegendMap,
|
...currentFormulas.newLegendMap,
|
||||||
|
...currentTraceOperator.newLegendMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert builder queries
|
// Convert builder queries
|
||||||
@ -453,8 +576,14 @@ export const prepareQueryRangePayloadV5 = ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combine both types
|
const traceOperatorQueries = convertTraceOperatorToV5(
|
||||||
queries = [...builderQueries, ...formulaQueries];
|
currentTraceOperator.data,
|
||||||
|
requestType,
|
||||||
|
graphType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine all query types
|
||||||
|
queries = [...builderQueries, ...formulaQueries, ...traceOperatorQueries];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EQueryType.PROM: {
|
case EQueryType.PROM: {
|
||||||
@ -497,7 +626,12 @@ export const prepareQueryRangePayloadV5 = ({
|
|||||||
fillGaps: fillGaps || false,
|
fillGaps: fillGaps || false,
|
||||||
},
|
},
|
||||||
variables: Object.entries(variables).reduce((acc, [key, value]) => {
|
variables: Object.entries(variables).reduce((acc, [key, value]) => {
|
||||||
acc[key] = { value };
|
acc[key] = {
|
||||||
|
value,
|
||||||
|
type: dynamicVariables
|
||||||
|
?.find((v) => v.name === key)
|
||||||
|
?.type?.toLowerCase() as VariableType,
|
||||||
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, VariableItem>),
|
}, {} as Record<string, VariableItem>),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -125,6 +125,7 @@ export const getHostTracesQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
id: '572f1d91-6ac0-46c0-b726-c21488b34434',
|
id: '572f1d91-6ac0-46c0-b726-c21488b34434',
|
||||||
queryType: EQueryType.QUERY_BUILDER,
|
queryType: EQueryType.QUERY_BUILDER,
|
||||||
|
|||||||
@ -169,6 +169,7 @@
|
|||||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-drawer-close {
|
.ant-drawer-close {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export const getHostLogsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
queryType: EQueryType.QUERY_BUILDER,
|
queryType: EQueryType.QUERY_BUILDER,
|
||||||
|
|||||||
@ -78,7 +78,7 @@ function Metrics({
|
|||||||
signal,
|
signal,
|
||||||
}: QueryFunctionContext): Promise<
|
}: QueryFunctionContext): Promise<
|
||||||
SuccessResponse<MetricRangePayloadProps>
|
SuccessResponse<MetricRangePayloadProps>
|
||||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, signal),
|
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
|
||||||
enabled: !!payload && visibilities[index],
|
enabled: !!payload && visibilities[index],
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||||
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
/* eslint-disable sonarjs/cognitive-complexity */
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
/* eslint-disable react/jsx-props-no-spreading */
|
/* eslint-disable react/jsx-props-no-spreading */
|
||||||
/* eslint-disable no-nested-ternary */
|
/* eslint-disable no-nested-ternary */
|
||||||
@ -12,9 +14,11 @@ import {
|
|||||||
import { Color } from '@signozhq/design-tokens';
|
import { Color } from '@signozhq/design-tokens';
|
||||||
import { Button, Checkbox, Select, Typography } from 'antd';
|
import { Button, Checkbox, Select, Typography } from 'antd';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
|
import TextToolTip from 'components/TextToolTip/TextToolTip';
|
||||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { capitalize, isEmpty } from 'lodash-es';
|
import { capitalize, isEmpty } from 'lodash-es';
|
||||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp } from 'lucide-react';
|
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Info } from 'lucide-react';
|
||||||
import type { BaseSelectRef } from 'rc-select';
|
import type { BaseSelectRef } from 'rc-select';
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -23,11 +27,13 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
import { popupContainer } from 'utils/selectPopupContainer';
|
import { popupContainer } from 'utils/selectPopupContainer';
|
||||||
|
|
||||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||||
import {
|
import {
|
||||||
filterOptionsBySearch,
|
filterOptionsBySearch,
|
||||||
|
handleScrollToBottom,
|
||||||
prioritizeOrAddOptionForMultiSelect,
|
prioritizeOrAddOptionForMultiSelect,
|
||||||
SPACEKEY,
|
SPACEKEY,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
@ -37,7 +43,7 @@ enum ToggleTagValue {
|
|||||||
All = 'All',
|
All = 'All',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value
|
const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
|
||||||
|
|
||||||
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||||
placeholder = 'Search...',
|
placeholder = 'Search...',
|
||||||
@ -62,6 +68,12 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
allowClear = false,
|
allowClear = false,
|
||||||
onRetry,
|
onRetry,
|
||||||
maxTagTextLength,
|
maxTagTextLength,
|
||||||
|
onDropdownVisibleChange,
|
||||||
|
showIncompleteDataMessage = false,
|
||||||
|
showLabels = false,
|
||||||
|
enableRegexOption = false,
|
||||||
|
isDynamicVariable = false,
|
||||||
|
showRetryButton = true,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
// ===== State & Refs =====
|
// ===== State & Refs =====
|
||||||
@ -78,6 +90,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
|
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
|
||||||
const isClickInsideDropdownRef = useRef(false);
|
const isClickInsideDropdownRef = useRef(false);
|
||||||
|
const justOpenedRef = useRef<boolean>(false);
|
||||||
|
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||||
|
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
|
||||||
// Convert single string value to array for consistency
|
// Convert single string value to array for consistency
|
||||||
const selectedValues = useMemo(
|
const selectedValues = useMemo(
|
||||||
@ -124,6 +140,12 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
return allAvailableValues.every((val) => selectedValues.includes(val));
|
return allAvailableValues.every((val) => selectedValues.includes(val));
|
||||||
}, [selectedValues, allAvailableValues, enableAllSelection]);
|
}, [selectedValues, allAvailableValues, enableAllSelection]);
|
||||||
|
|
||||||
|
// Define allOptionShown earlier in the code
|
||||||
|
const allOptionShown = useMemo(
|
||||||
|
() => value === ALL_SELECTED_VALUE || value === 'ALL',
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
// Value passed to the underlying Ant Select component
|
// Value passed to the underlying Ant Select component
|
||||||
const displayValue = useMemo(
|
const displayValue = useMemo(
|
||||||
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
|
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
|
||||||
@ -132,10 +154,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
|
|
||||||
// ===== Internal onChange Handler =====
|
// ===== Internal onChange Handler =====
|
||||||
const handleInternalChange = useCallback(
|
const handleInternalChange = useCallback(
|
||||||
(newValue: string | string[]): void => {
|
(newValue: string | string[], directCaller?: boolean): void => {
|
||||||
// Ensure newValue is an array
|
// Ensure newValue is an array
|
||||||
const currentNewValue = Array.isArray(newValue) ? newValue : [];
|
const currentNewValue = Array.isArray(newValue) ? newValue : [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
(allOptionShown || isAllSelected) &&
|
||||||
|
!directCaller &&
|
||||||
|
currentNewValue.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!onChange) return;
|
if (!onChange) return;
|
||||||
|
|
||||||
// Case 1: Cleared (empty array or undefined)
|
// Case 1: Cleared (empty array or undefined)
|
||||||
@ -144,7 +174,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: "__all__" is selected (means select all actual values)
|
// Case 2: "__ALL__" is selected (means select all actual values)
|
||||||
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
|
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
|
||||||
const allActualOptions = allAvailableValues.map(
|
const allActualOptions = allAvailableValues.map(
|
||||||
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
|
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
|
||||||
@ -175,7 +205,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange, allAvailableValues, options, enableAllSelection],
|
[
|
||||||
|
allOptionShown,
|
||||||
|
isAllSelected,
|
||||||
|
onChange,
|
||||||
|
allAvailableValues,
|
||||||
|
options,
|
||||||
|
enableAllSelection,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== Existing Callbacks (potentially needing adjustment later) =====
|
// ===== Existing Callbacks (potentially needing adjustment later) =====
|
||||||
@ -272,7 +309,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
: filteredOptions,
|
: filteredOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [filteredOptions, searchText, options, selectedValues]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filteredOptions, searchText, options]);
|
||||||
|
|
||||||
// ===== Text Selection Utilities =====
|
// ===== Text Selection Utilities =====
|
||||||
|
|
||||||
@ -510,13 +548,46 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normal single value handling
|
// Normal single value handling
|
||||||
setSearchText(value.trim());
|
const trimmedValue = value.trim();
|
||||||
|
setSearchText(trimmedValue);
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
|
justOpenedRef.current = true;
|
||||||
}
|
}
|
||||||
if (onSearch) onSearch(value.trim());
|
|
||||||
|
// Reset active index when search changes if dropdown is open
|
||||||
|
if (isOpen && trimmedValue) {
|
||||||
|
setActiveIndex(-1);
|
||||||
|
// see if the trimmed value matched any option and set that active index
|
||||||
|
const matchedOption = filteredOptions.find(
|
||||||
|
(option) =>
|
||||||
|
option.label.toLowerCase() === trimmedValue.toLowerCase() ||
|
||||||
|
option.value?.toLowerCase() === trimmedValue.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (matchedOption) {
|
||||||
|
setActiveIndex(1);
|
||||||
|
} else {
|
||||||
|
// check if the trimmed value is a regex pattern and set that active index
|
||||||
|
const isRegex =
|
||||||
|
trimmedValue.startsWith('.*') && trimmedValue.endsWith('.*');
|
||||||
|
if (isRegex && enableRegexOption) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
} else {
|
||||||
|
setActiveIndex(enableRegexOption ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSearch) onSearch(trimmedValue);
|
||||||
},
|
},
|
||||||
[onSearch, isOpen, selectedValues, onChange],
|
[
|
||||||
|
onSearch,
|
||||||
|
isOpen,
|
||||||
|
selectedValues,
|
||||||
|
onChange,
|
||||||
|
filteredOptions,
|
||||||
|
enableRegexOption,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== UI & Rendering Functions =====
|
// ===== UI & Rendering Functions =====
|
||||||
@ -528,28 +599,34 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
(text: string, searchQuery: string): React.ReactNode => {
|
(text: string, searchQuery: string): React.ReactNode => {
|
||||||
if (!searchQuery || !highlightSearch) return text;
|
if (!searchQuery || !highlightSearch) return text;
|
||||||
|
|
||||||
const parts = text.split(
|
try {
|
||||||
new RegExp(
|
const parts = text.split(
|
||||||
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
|
new RegExp(
|
||||||
'gi',
|
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||||
),
|
'gi',
|
||||||
);
|
),
|
||||||
return (
|
);
|
||||||
<>
|
return (
|
||||||
{parts.map((part, i) => {
|
<>
|
||||||
// Create a unique key that doesn't rely on array index
|
{parts.map((part, i) => {
|
||||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
// Create a unique key that doesn't rely on array index
|
||||||
|
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||||
|
|
||||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||||
<span key={uniqueKey} className="highlight-text">
|
<span key={uniqueKey} className="highlight-text">
|
||||||
{part}
|
{part}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
part
|
part
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// If regex fails, return the original text without highlighting
|
||||||
|
console.error('Error in text highlighting:', error);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[highlightSearch],
|
[highlightSearch],
|
||||||
);
|
);
|
||||||
@ -560,10 +637,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
|
|
||||||
if (isAllSelected) {
|
if (isAllSelected) {
|
||||||
// If all are selected, deselect all
|
// If all are selected, deselect all
|
||||||
handleInternalChange([]);
|
handleInternalChange([], true);
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, select all
|
// Otherwise, select all
|
||||||
handleInternalChange([ALL_SELECTED_VALUE]);
|
handleInternalChange([ALL_SELECTED_VALUE], true);
|
||||||
}
|
}
|
||||||
}, [options, isAllSelected, handleInternalChange]);
|
}, [options, isAllSelected, handleInternalChange]);
|
||||||
|
|
||||||
@ -738,6 +815,26 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
// Enhanced keyboard navigation with support for maxTagCount
|
// Enhanced keyboard navigation with support for maxTagCount
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLElement>): void => {
|
(e: React.KeyboardEvent<HTMLElement>): void => {
|
||||||
|
// Simple early return if ALL is selected - block all possible keyboard interactions
|
||||||
|
// that could remove the ALL tag, but still allow dropdown navigation and search
|
||||||
|
if (
|
||||||
|
(allOptionShown || isAllSelected) &&
|
||||||
|
(e.key === 'Backspace' || e.key === 'Delete')
|
||||||
|
) {
|
||||||
|
// Only prevent default if the input is empty or cursor is at start position
|
||||||
|
const activeElement = document.activeElement as HTMLInputElement;
|
||||||
|
const isInputActive = activeElement?.tagName === 'INPUT';
|
||||||
|
const isInputEmpty = isInputActive && !activeElement?.value;
|
||||||
|
const isCursorAtStart =
|
||||||
|
isInputActive && activeElement?.selectionStart === 0;
|
||||||
|
|
||||||
|
if (isInputEmpty || isCursorAtStart) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get flattened list of all selectable options
|
// Get flattened list of all selectable options
|
||||||
const getFlatOptions = (): OptionData[] => {
|
const getFlatOptions = (): OptionData[] => {
|
||||||
if (!visibleOptions) return [];
|
if (!visibleOptions) return [];
|
||||||
@ -752,13 +849,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
if (hasAll) {
|
if (hasAll) {
|
||||||
flatList.push({
|
flatList.push({
|
||||||
label: 'ALL',
|
label: 'ALL',
|
||||||
value: '__all__', // Special value for the ALL option
|
value: ALL_SELECTED_VALUE, // Special value for the ALL option
|
||||||
type: 'defined',
|
type: 'defined',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Regex to flat list
|
// Add Regex to flat list
|
||||||
if (!isEmpty(searchText)) {
|
if (!isEmpty(searchText) && enableRegexOption) {
|
||||||
// Only add regex wrapper if it doesn't already look like a regex pattern
|
// Only add regex wrapper if it doesn't already look like a regex pattern
|
||||||
const isAlreadyRegex =
|
const isAlreadyRegex =
|
||||||
searchText.startsWith('.*') && searchText.endsWith('.*');
|
searchText.startsWith('.*') && searchText.endsWith('.*');
|
||||||
@ -784,6 +881,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
|
|
||||||
const flatOptions = getFlatOptions();
|
const flatOptions = getFlatOptions();
|
||||||
|
|
||||||
|
// If we just opened the dropdown and have options, set first option as active
|
||||||
|
if (justOpenedRef.current && flatOptions.length > 0) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
justOpenedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no option is active but we have options and dropdown is open, activate the first one
|
||||||
|
if (isOpen && activeIndex === -1 && flatOptions.length > 0) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
// Get the active input element to check cursor position
|
// Get the active input element to check cursor position
|
||||||
const activeElement = document.activeElement as HTMLInputElement;
|
const activeElement = document.activeElement as HTMLInputElement;
|
||||||
const isInputActive = activeElement?.tagName === 'INPUT';
|
const isInputActive = activeElement?.tagName === 'INPUT';
|
||||||
@ -1129,7 +1237,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
// If there's an active option in the dropdown, prioritize selecting it
|
// If there's an active option in the dropdown, prioritize selecting it
|
||||||
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
|
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
|
||||||
const selectedOption = flatOptions[activeIndex];
|
const selectedOption = flatOptions[activeIndex];
|
||||||
if (selectedOption.value === '__all__') {
|
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||||
handleSelectAll();
|
handleSelectAll();
|
||||||
} else if (selectedOption.value && onChange) {
|
} else if (selectedOption.value && onChange) {
|
||||||
const newValues = selectedValues.includes(selectedOption.value)
|
const newValues = selectedValues.includes(selectedOption.value)
|
||||||
@ -1159,6 +1267,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveIndex(-1);
|
setActiveIndex(-1);
|
||||||
|
// Call onDropdownVisibleChange when Escape is pressed to close dropdown
|
||||||
|
if (onDropdownVisibleChange) {
|
||||||
|
onDropdownVisibleChange(false);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SPACEKEY:
|
case SPACEKEY:
|
||||||
@ -1168,7 +1280,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
const selectedOption = flatOptions[activeIndex];
|
const selectedOption = flatOptions[activeIndex];
|
||||||
|
|
||||||
// Check if it's the ALL option
|
// Check if it's the ALL option
|
||||||
if (selectedOption.value === '__all__') {
|
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||||
handleSelectAll();
|
handleSelectAll();
|
||||||
} else if (selectedOption.value && onChange) {
|
} else if (selectedOption.value && onChange) {
|
||||||
const newValues = selectedValues.includes(selectedOption.value)
|
const newValues = selectedValues.includes(selectedOption.value)
|
||||||
@ -1214,7 +1326,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setActiveIndex(0);
|
justOpenedRef.current = true; // Set flag to initialize active option on next render
|
||||||
setActiveChipIndex(-1);
|
setActiveChipIndex(-1);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -1260,9 +1372,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
allOptionShown,
|
||||||
|
isAllSelected,
|
||||||
|
isOpen,
|
||||||
|
activeIndex,
|
||||||
|
getVisibleChipIndices,
|
||||||
|
getLastVisibleChipIndex,
|
||||||
selectedChips,
|
selectedChips,
|
||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
isOpen,
|
|
||||||
activeChipIndex,
|
activeChipIndex,
|
||||||
selectedValues,
|
selectedValues,
|
||||||
visibleOptions,
|
visibleOptions,
|
||||||
@ -1278,10 +1395,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
startSelection,
|
startSelection,
|
||||||
selectionEnd,
|
selectionEnd,
|
||||||
extendSelection,
|
extendSelection,
|
||||||
activeIndex,
|
onDropdownVisibleChange,
|
||||||
handleSelectAll,
|
handleSelectAll,
|
||||||
getVisibleChipIndices,
|
enableRegexOption,
|
||||||
getLastVisibleChipIndex,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1306,6 +1422,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Add a scroll handler for the dropdown
|
||||||
|
const handleDropdownScroll = useCallback(
|
||||||
|
(e: React.UIEvent<HTMLDivElement>): void => {
|
||||||
|
setIsScrolledToBottom(handleScrollToBottom(e));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Custom dropdown render with sections support
|
// Custom dropdown render with sections support
|
||||||
const customDropdownRender = useCallback((): React.ReactElement => {
|
const customDropdownRender = useCallback((): React.ReactElement => {
|
||||||
// Process options based on current search
|
// Process options based on current search
|
||||||
@ -1324,7 +1448,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
const customOptions: OptionData[] = [];
|
const customOptions: OptionData[] = [];
|
||||||
|
|
||||||
// add regex options first since they appear first in the UI
|
// add regex options first since they appear first in the UI
|
||||||
if (!isEmpty(searchText)) {
|
if (!isEmpty(searchText) && enableRegexOption) {
|
||||||
// Only add regex wrapper if it doesn't already look like a regex pattern
|
// Only add regex wrapper if it doesn't already look like a regex pattern
|
||||||
const isAlreadyRegex =
|
const isAlreadyRegex =
|
||||||
searchText.startsWith('.*') && searchText.endsWith('.*');
|
searchText.startsWith('.*') && searchText.endsWith('.*');
|
||||||
@ -1347,8 +1471,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now add all custom options at the beginning
|
// Now add all custom options at the beginning, removing duplicates based on value
|
||||||
const enhancedNonSectionOptions = [...customOptions, ...nonSectionOptions];
|
const allOptions = [...customOptions, ...nonSectionOptions];
|
||||||
|
const seenValues = new Set<string>();
|
||||||
|
const enhancedNonSectionOptions = allOptions.filter((option) => {
|
||||||
|
const value = option.value || '';
|
||||||
|
if (seenValues.has(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seenValues.add(value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const allOptionValues = getAllAvailableValues(processedOptions);
|
const allOptionValues = getAllAvailableValues(processedOptions);
|
||||||
const allOptionsSelected =
|
const allOptionsSelected =
|
||||||
@ -1382,6 +1515,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
onMouseDown={handleDropdownMouseDown}
|
onMouseDown={handleDropdownMouseDown}
|
||||||
onClick={handleDropdownClick}
|
onClick={handleDropdownClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onScroll={handleDropdownScroll}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-multiselectable="true"
|
aria-multiselectable="true"
|
||||||
@ -1423,14 +1557,39 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||||
checked={allOptionsSelected}
|
<Checkbox checked={allOptionsSelected} className="option-checkbox">
|
||||||
style={{ width: '100%', height: '100%' }}
|
<div className="option-content">
|
||||||
>
|
<div className="all-option-text">ALL</div>
|
||||||
<div className="option-content">
|
</div>
|
||||||
<div>ALL</div>
|
</Checkbox>
|
||||||
|
<div
|
||||||
|
onClick={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDynamicVariable && (
|
||||||
|
<TextToolTip
|
||||||
|
text="ALL in dynamic variable = No filter applied (unlike other variable types where ALL sends all selected values). Learn more"
|
||||||
|
url="https://signoz.io/docs/userguide/manage-variables/#note-about-all"
|
||||||
|
urlText="here"
|
||||||
|
useFilledIcon={false}
|
||||||
|
outlinedIcon={
|
||||||
|
<Info
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||||
|
marginLeft: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Checkbox>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
</>
|
</>
|
||||||
@ -1439,7 +1598,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
{/* Non-section options when not searching */}
|
{/* Non-section options when not searching */}
|
||||||
{enhancedNonSectionOptions.length > 0 && (
|
{enhancedNonSectionOptions.length > 0 && (
|
||||||
<div className="no-section-options">
|
<div className="no-section-options">
|
||||||
{mapOptions(enhancedNonSectionOptions)}
|
<Virtuoso
|
||||||
|
style={{
|
||||||
|
minHeight: Math.min(300, enhancedNonSectionOptions.length * 40),
|
||||||
|
maxHeight: enhancedNonSectionOptions.length * 40,
|
||||||
|
}}
|
||||||
|
data={enhancedNonSectionOptions}
|
||||||
|
itemContent={(index, item): React.ReactNode =>
|
||||||
|
(mapOptions([item]) as unknown) as React.ReactElement
|
||||||
|
}
|
||||||
|
totalCount={enhancedNonSectionOptions.length}
|
||||||
|
itemSize={(): number => 40}
|
||||||
|
overscan={5}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1450,31 +1621,65 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
<div className="select-group" key={section.label}>
|
<div className="select-group" key={section.label}>
|
||||||
<div className="group-label" role="heading" aria-level={2}>
|
<div className="group-label" role="heading" aria-level={2}>
|
||||||
{section.label}
|
{section.label}
|
||||||
|
{isDynamicVariable && (
|
||||||
|
<TextToolTip
|
||||||
|
text="Related values: Filtered by other variable selections. All values: Unfiltered complete list. Learn more"
|
||||||
|
url="https://signoz.io/docs/userguide/manage-variables/#dynamic-variable-dropdowns-display-values-in-two-sections"
|
||||||
|
urlText="here"
|
||||||
|
useFilledIcon={false}
|
||||||
|
outlinedIcon={
|
||||||
|
<Info
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div role="group" aria-label={`${section.label} options`}>
|
<div role="group" aria-label={`${section.label} options`}>
|
||||||
{section.options && mapOptions(section.options)}
|
<Virtuoso
|
||||||
|
style={{
|
||||||
|
minHeight: Math.min(300, (section.options?.length || 0) * 40),
|
||||||
|
maxHeight: (section.options?.length || 0) * 40,
|
||||||
|
}}
|
||||||
|
data={section.options || []}
|
||||||
|
itemContent={(index, item): React.ReactNode =>
|
||||||
|
(mapOptions([item]) as unknown) as React.ReactElement
|
||||||
|
}
|
||||||
|
totalCount={section.options?.length || 0}
|
||||||
|
itemSize={(): number => 40}
|
||||||
|
overscan={5}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null,
|
) : (
|
||||||
|
<div key={section.label} />
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation help footer */}
|
{/* Navigation help footer */}
|
||||||
<div className="navigation-footer" role="note">
|
<div className="navigation-footer" role="note">
|
||||||
{!loading && !errorMessage && !noDataMessage && (
|
{!loading &&
|
||||||
<section className="navigate">
|
!errorMessage &&
|
||||||
<ArrowDown size={8} className="icons" />
|
!noDataMessage &&
|
||||||
<ArrowUp size={8} className="icons" />
|
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||||
<ArrowLeft size={8} className="icons" />
|
<section className="navigate">
|
||||||
<ArrowRight size={8} className="icons" />
|
<ArrowDown size={8} className="icons" />
|
||||||
<span className="keyboard-text">to navigate</span>
|
<ArrowUp size={8} className="icons" />
|
||||||
</section>
|
<ArrowLeft size={8} className="icons" />
|
||||||
)}
|
<ArrowRight size={8} className="icons" />
|
||||||
|
<span className="keyboard-text">to navigate</span>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="navigation-loading">
|
<div className="navigation-loading">
|
||||||
<div className="navigation-icons">
|
<div className="navigation-icons">
|
||||||
<LoadingOutlined />
|
<LoadingOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div className="navigation-text">We are updating the values...</div>
|
<div className="navigation-text">Refreshing values...</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{errorMessage && !loading && (
|
{errorMessage && !loading && (
|
||||||
@ -1482,21 +1687,33 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
<div className="navigation-text">
|
<div className="navigation-text">
|
||||||
{errorMessage || SOMETHING_WENT_WRONG}
|
{errorMessage || SOMETHING_WENT_WRONG}
|
||||||
</div>
|
</div>
|
||||||
<div className="navigation-icons">
|
{onRetry && showRetryButton && (
|
||||||
<ReloadOutlined
|
<div className="navigation-icons">
|
||||||
twoToneColor={Color.BG_CHERRY_400}
|
<ReloadOutlined
|
||||||
onClick={(e): void => {
|
twoToneColor={Color.BG_CHERRY_400}
|
||||||
e.stopPropagation();
|
onClick={(e): void => {
|
||||||
if (onRetry) onRetry();
|
e.stopPropagation();
|
||||||
}}
|
onRetry();
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{noDataMessage && !loading && (
|
{showIncompleteDataMessage &&
|
||||||
<div className="navigation-text">{noDataMessage}</div>
|
isScrolledToBottom &&
|
||||||
)}
|
!loading &&
|
||||||
|
!errorMessage && (
|
||||||
|
<div className="navigation-text-incomplete">
|
||||||
|
Don't see the value? Use search
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{noDataMessage &&
|
||||||
|
!loading &&
|
||||||
|
!(showIncompleteDataMessage && isScrolledToBottom) &&
|
||||||
|
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1513,6 +1730,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
handleDropdownMouseDown,
|
handleDropdownMouseDown,
|
||||||
handleDropdownClick,
|
handleDropdownClick,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
|
handleDropdownScroll,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
activeIndex,
|
activeIndex,
|
||||||
loading,
|
loading,
|
||||||
@ -1522,8 +1740,35 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
renderOptionWithIndex,
|
renderOptionWithIndex,
|
||||||
handleSelectAll,
|
handleSelectAll,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
showIncompleteDataMessage,
|
||||||
|
isScrolledToBottom,
|
||||||
|
enableRegexOption,
|
||||||
|
isDarkMode,
|
||||||
|
isDynamicVariable,
|
||||||
|
showRetryButton,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Custom handler for dropdown visibility changes
|
||||||
|
const handleDropdownVisibleChange = useCallback(
|
||||||
|
(visible: boolean): void => {
|
||||||
|
setIsOpen(visible);
|
||||||
|
if (visible) {
|
||||||
|
justOpenedRef.current = true;
|
||||||
|
setActiveIndex(0);
|
||||||
|
setActiveChipIndex(-1);
|
||||||
|
} else {
|
||||||
|
setSearchText('');
|
||||||
|
setActiveIndex(-1);
|
||||||
|
// Don't clear activeChipIndex when dropdown closes to maintain tag focus
|
||||||
|
}
|
||||||
|
// Pass through to the parent component's handler if provided
|
||||||
|
if (onDropdownVisibleChange) {
|
||||||
|
onDropdownVisibleChange(visible);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDropdownVisibleChange],
|
||||||
|
);
|
||||||
|
|
||||||
// ===== Side Effects =====
|
// ===== Side Effects =====
|
||||||
|
|
||||||
// Clear search when dropdown closes
|
// Clear search when dropdown closes
|
||||||
@ -1585,55 +1830,16 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
// Custom Tag Render (needs significant updates)
|
// Custom Tag Render (needs significant updates)
|
||||||
const tagRender = useCallback(
|
const tagRender = useCallback(
|
||||||
(props: CustomTagProps): React.ReactElement => {
|
(props: CustomTagProps): React.ReactElement => {
|
||||||
const { label, value, closable, onClose } = props;
|
const { label: labelProp, value, closable, onClose } = props;
|
||||||
|
|
||||||
|
const label = showLabels
|
||||||
|
? options.find((option) => option.value === value)?.label || labelProp
|
||||||
|
: labelProp;
|
||||||
|
|
||||||
// If the display value is the special ALL value, render the ALL tag
|
// If the display value is the special ALL value, render the ALL tag
|
||||||
if (value === ALL_SELECTED_VALUE && isAllSelected) {
|
if (allOptionShown) {
|
||||||
const handleAllTagClose = (
|
// Don't render a visible tag - will be shown as placeholder
|
||||||
e: React.MouseEvent | React.KeyboardEvent,
|
return <div style={{ display: 'none' }} />;
|
||||||
): void => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
handleInternalChange([]); // Clear selection when ALL tag is closed
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAllTagKeyDown = (e: React.KeyboardEvent): void => {
|
|
||||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
|
||||||
handleAllTagClose(e);
|
|
||||||
}
|
|
||||||
// Prevent Backspace/Delete propagation if needed, handle in main keydown handler
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx('ant-select-selection-item', {
|
|
||||||
'ant-select-selection-item-active': activeChipIndex === 0, // Treat ALL tag as index 0 when active
|
|
||||||
'ant-select-selection-item-selected': selectedChips.includes(0),
|
|
||||||
})}
|
|
||||||
style={
|
|
||||||
activeChipIndex === 0 || selectedChips.includes(0)
|
|
||||||
? {
|
|
||||||
borderColor: Color.BG_ROBIN_500,
|
|
||||||
backgroundColor: Color.BG_SLATE_400,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="ant-select-selection-item-content">ALL</span>
|
|
||||||
{closable && (
|
|
||||||
<span
|
|
||||||
className="ant-select-selection-item-remove"
|
|
||||||
onClick={handleAllTagClose}
|
|
||||||
onKeyDown={handleAllTagKeyDown}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Remove ALL tag (deselect all)"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not isAllSelected, render individual tags using previous logic
|
// If not isAllSelected, render individual tags using previous logic
|
||||||
@ -1713,52 +1919,69 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
// Fallback for safety, should not be reached
|
// Fallback for safety, should not be reached
|
||||||
return <div />;
|
return <div />;
|
||||||
},
|
},
|
||||||
[
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
isAllSelected,
|
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
|
||||||
handleInternalChange,
|
|
||||||
activeChipIndex,
|
|
||||||
selectedChips,
|
|
||||||
selectedValues,
|
|
||||||
maxTagCount,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Simple onClear handler to prevent clearing ALL
|
||||||
|
const onClearHandler = useCallback((): void => {
|
||||||
|
// Skip clearing if ALL is selected
|
||||||
|
if (allOptionShown || isAllSelected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal clear behavior
|
||||||
|
handleInternalChange([], true);
|
||||||
|
if (onClear) onClear();
|
||||||
|
}, [onClear, handleInternalChange, allOptionShown, isAllSelected]);
|
||||||
|
|
||||||
// ===== Component Rendering =====
|
// ===== Component Rendering =====
|
||||||
return (
|
return (
|
||||||
<Select
|
<div
|
||||||
ref={selectRef}
|
className={cx('custom-multiselect-wrapper', {
|
||||||
className={cx('custom-multiselect', className, {
|
'all-selected': allOptionShown || isAllSelected,
|
||||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
|
||||||
'is-all-selected': isAllSelected,
|
|
||||||
})}
|
})}
|
||||||
placeholder={placeholder}
|
>
|
||||||
mode="multiple"
|
{(allOptionShown || isAllSelected) && !searchText && (
|
||||||
showSearch
|
<div className="all-text">ALL</div>
|
||||||
filterOption={false}
|
)}
|
||||||
onSearch={handleSearch}
|
<Select
|
||||||
value={displayValue}
|
ref={selectRef}
|
||||||
onChange={handleInternalChange}
|
className={cx('custom-multiselect', className, {
|
||||||
onClear={(): void => handleInternalChange([])}
|
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||||
onDropdownVisibleChange={setIsOpen}
|
'is-all-selected': isAllSelected,
|
||||||
open={isOpen}
|
})}
|
||||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
placeholder={placeholder}
|
||||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
mode="multiple"
|
||||||
allowClear={allowClear}
|
showSearch
|
||||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
filterOption={false}
|
||||||
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
|
onSearch={handleSearch}
|
||||||
dropdownRender={customDropdownRender}
|
value={displayValue}
|
||||||
menuItemSelectedIcon={null}
|
onChange={(newValue): void => {
|
||||||
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
|
handleInternalChange(newValue, false);
|
||||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onClear={onClearHandler}
|
||||||
tagRender={tagRender as any}
|
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||||
placement={placement}
|
open={isOpen}
|
||||||
listHeight={300}
|
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||||
searchValue={searchText}
|
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||||
maxTagTextLength={maxTagTextLength}
|
allowClear={allowClear}
|
||||||
maxTagCount={isAllSelected ? 1 : maxTagCount}
|
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||||
{...rest}
|
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
|
||||||
/>
|
dropdownRender={customDropdownRender}
|
||||||
|
menuItemSelectedIcon={null}
|
||||||
|
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
|
||||||
|
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
tagRender={tagRender as any}
|
||||||
|
placement={placement}
|
||||||
|
listHeight={300}
|
||||||
|
searchValue={searchText}
|
||||||
|
maxTagTextLength={maxTagTextLength}
|
||||||
|
maxTagCount={isAllSelected ? undefined : maxTagCount}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import {
|
|||||||
import { Color } from '@signozhq/design-tokens';
|
import { Color } from '@signozhq/design-tokens';
|
||||||
import { Select } from 'antd';
|
import { Select } from 'antd';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
|
import TextToolTip from 'components/TextToolTip';
|
||||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { capitalize, isEmpty } from 'lodash-es';
|
import { capitalize, isEmpty } from 'lodash-es';
|
||||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Info } from 'lucide-react';
|
||||||
import type { BaseSelectRef } from 'rc-select';
|
import type { BaseSelectRef } from 'rc-select';
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -29,6 +31,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
|||||||
import { CustomSelectProps, OptionData } from './types';
|
import { CustomSelectProps, OptionData } from './types';
|
||||||
import {
|
import {
|
||||||
filterOptionsBySearch,
|
filterOptionsBySearch,
|
||||||
|
handleScrollToBottom,
|
||||||
prioritizeOrAddOptionForSingleSelect,
|
prioritizeOrAddOptionForSingleSelect,
|
||||||
SPACEKEY,
|
SPACEKEY,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
@ -57,17 +60,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
allowClear = false,
|
allowClear = false,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
showIncompleteDataMessage = false,
|
||||||
|
showRetryButton = true,
|
||||||
|
isDynamicVariable = false,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
// ===== State & Refs =====
|
// ===== State & Refs =====
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
|
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
|
||||||
|
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||||
|
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
|
||||||
// Refs for element access and scroll behavior
|
// Refs for element access and scroll behavior
|
||||||
const selectRef = useRef<BaseSelectRef>(null);
|
const selectRef = useRef<BaseSelectRef>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
|
// Flag to track if dropdown just opened
|
||||||
|
const justOpenedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
// Add a scroll handler for the dropdown
|
||||||
|
const handleDropdownScroll = useCallback(
|
||||||
|
(e: React.UIEvent<HTMLDivElement>): void => {
|
||||||
|
setIsScrolledToBottom(handleScrollToBottom(e));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// ===== Option Filtering & Processing Utilities =====
|
// ===== Option Filtering & Processing Utilities =====
|
||||||
|
|
||||||
@ -130,23 +149,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
(text: string, searchQuery: string): React.ReactNode => {
|
(text: string, searchQuery: string): React.ReactNode => {
|
||||||
if (!searchQuery || !highlightSearch) return text;
|
if (!searchQuery || !highlightSearch) return text;
|
||||||
|
|
||||||
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
|
try {
|
||||||
return (
|
const parts = text.split(
|
||||||
<>
|
new RegExp(
|
||||||
{parts.map((part, i) => {
|
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||||
// Create a deterministic but unique key
|
'gi',
|
||||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
// Create a deterministic but unique key
|
||||||
|
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||||
|
|
||||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||||
<span key={uniqueKey} className="highlight-text">
|
<span key={uniqueKey} className="highlight-text">
|
||||||
{part}
|
{part}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
part
|
part
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in text highlighting:', error);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[highlightSearch],
|
[highlightSearch],
|
||||||
);
|
);
|
||||||
@ -246,9 +275,14 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
const trimmedValue = value.trim();
|
const trimmedValue = value.trim();
|
||||||
setSearchText(trimmedValue);
|
setSearchText(trimmedValue);
|
||||||
|
|
||||||
|
// Reset active option index when search changes
|
||||||
|
if (isOpen) {
|
||||||
|
setActiveOptionIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
if (onSearch) onSearch(trimmedValue);
|
if (onSearch) onSearch(trimmedValue);
|
||||||
},
|
},
|
||||||
[onSearch],
|
[onSearch, isOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -272,14 +306,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
const flatList: OptionData[] = [];
|
const flatList: OptionData[] = [];
|
||||||
|
|
||||||
// Process options
|
// Process options
|
||||||
|
let processedOptions = isEmpty(value)
|
||||||
|
? filteredOptions
|
||||||
|
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
|
||||||
|
|
||||||
|
if (!isEmpty(searchText)) {
|
||||||
|
processedOptions = filterOptionsBySearch(processedOptions, searchText);
|
||||||
|
}
|
||||||
|
|
||||||
const { sectionOptions, nonSectionOptions } = splitOptions(
|
const { sectionOptions, nonSectionOptions } = splitOptions(
|
||||||
isEmpty(value)
|
processedOptions,
|
||||||
? filteredOptions
|
|
||||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add custom option if needed
|
// Add custom option if needed
|
||||||
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
|
if (
|
||||||
|
!isEmpty(searchText) &&
|
||||||
|
!isLabelPresent(processedOptions, searchText)
|
||||||
|
) {
|
||||||
flatList.push({
|
flatList.push({
|
||||||
label: searchText,
|
label: searchText,
|
||||||
value: searchText,
|
value: searchText,
|
||||||
@ -300,33 +343,52 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
|
|
||||||
const options = getFlatOptions();
|
const options = getFlatOptions();
|
||||||
|
|
||||||
|
// If we just opened the dropdown and have options, set first option as active
|
||||||
|
if (justOpenedRef.current && options.length > 0) {
|
||||||
|
setActiveOptionIndex(0);
|
||||||
|
justOpenedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no option is active but we have options, activate the first one
|
||||||
|
if (activeOptionIndex === -1 && options.length > 0) {
|
||||||
|
setActiveOptionIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveOptionIndex((prev) =>
|
if (options.length > 0) {
|
||||||
prev < options.length - 1 ? prev + 1 : 0,
|
setActiveOptionIndex((prev) =>
|
||||||
);
|
prev < options.length - 1 ? prev + 1 : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveOptionIndex((prev) =>
|
if (options.length > 0) {
|
||||||
prev > 0 ? prev - 1 : options.length - 1,
|
setActiveOptionIndex((prev) =>
|
||||||
);
|
prev > 0 ? prev - 1 : options.length - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
// Tab navigation with Shift key support
|
// Tab navigation with Shift key support
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveOptionIndex((prev) =>
|
if (options.length > 0) {
|
||||||
prev > 0 ? prev - 1 : options.length - 1,
|
setActiveOptionIndex((prev) =>
|
||||||
);
|
prev > 0 ? prev - 1 : options.length - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveOptionIndex((prev) =>
|
if (options.length > 0) {
|
||||||
prev < options.length - 1 ? prev + 1 : 0,
|
setActiveOptionIndex((prev) =>
|
||||||
);
|
prev < options.length - 1 ? prev + 1 : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -339,6 +401,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
onChange(selectedOption.value, selectedOption);
|
onChange(selectedOption.value, selectedOption);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveOptionIndex(-1);
|
setActiveOptionIndex(-1);
|
||||||
|
setSearchText('');
|
||||||
}
|
}
|
||||||
} else if (!isEmpty(searchText)) {
|
} else if (!isEmpty(searchText)) {
|
||||||
// Add custom value when no option is focused
|
// Add custom value when no option is focused
|
||||||
@ -351,6 +414,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
onChange(customOption.value, customOption);
|
onChange(customOption.value, customOption);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveOptionIndex(-1);
|
setActiveOptionIndex(-1);
|
||||||
|
setSearchText('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -359,6 +423,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveOptionIndex(-1);
|
setActiveOptionIndex(-1);
|
||||||
|
setSearchText('');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ' ': // Space key
|
case ' ': // Space key
|
||||||
@ -369,6 +434,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
onChange(selectedOption.value, selectedOption);
|
onChange(selectedOption.value, selectedOption);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveOptionIndex(-1);
|
setActiveOptionIndex(-1);
|
||||||
|
setSearchText('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -379,7 +445,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
// Open dropdown when Down or Tab is pressed while closed
|
// Open dropdown when Down or Tab is pressed while closed
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setActiveOptionIndex(0);
|
justOpenedRef.current = true; // Set flag to initialize active option on next render
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@ -444,6 +510,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
className="custom-select-dropdown"
|
className="custom-select-dropdown"
|
||||||
onClick={handleDropdownClick}
|
onClick={handleDropdownClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onScroll={handleDropdownScroll}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-activedescendant={
|
aria-activedescendant={
|
||||||
@ -454,7 +521,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
<div className="no-section-options">
|
<div className="no-section-options">
|
||||||
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
|
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section options */}
|
{/* Section options */}
|
||||||
{sectionOptions.length > 0 &&
|
{sectionOptions.length > 0 &&
|
||||||
sectionOptions.map((section) =>
|
sectionOptions.map((section) =>
|
||||||
@ -462,6 +528,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
<div className="select-group" key={section.label}>
|
<div className="select-group" key={section.label}>
|
||||||
<div className="group-label" role="heading" aria-level={2}>
|
<div className="group-label" role="heading" aria-level={2}>
|
||||||
{section.label}
|
{section.label}
|
||||||
|
{isDynamicVariable && (
|
||||||
|
<TextToolTip
|
||||||
|
text="Related values: Filtered by other variable selections. All values: Unfiltered complete list. Learn more"
|
||||||
|
url="https://signoz.io/docs/userguide/manage-variables/#dynamic-variable-dropdowns-display-values-in-two-sections"
|
||||||
|
urlText="here"
|
||||||
|
useFilledIcon={false}
|
||||||
|
outlinedIcon={
|
||||||
|
<Info
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div role="group" aria-label={`${section.label} options`}>
|
<div role="group" aria-label={`${section.label} options`}>
|
||||||
{section.options && mapOptions(section.options)}
|
{section.options && mapOptions(section.options)}
|
||||||
@ -472,19 +555,22 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
|
|
||||||
{/* Navigation help footer */}
|
{/* Navigation help footer */}
|
||||||
<div className="navigation-footer" role="note">
|
<div className="navigation-footer" role="note">
|
||||||
{!loading && !errorMessage && !noDataMessage && (
|
{!loading &&
|
||||||
<section className="navigate">
|
!errorMessage &&
|
||||||
<ArrowDown size={8} className="icons" />
|
!noDataMessage &&
|
||||||
<ArrowUp size={8} className="icons" />
|
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||||
<span className="keyboard-text">to navigate</span>
|
<section className="navigate">
|
||||||
</section>
|
<ArrowDown size={8} className="icons" />
|
||||||
)}
|
<ArrowUp size={8} className="icons" />
|
||||||
|
<span className="keyboard-text">to navigate</span>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="navigation-loading">
|
<div className="navigation-loading">
|
||||||
<div className="navigation-icons">
|
<div className="navigation-icons">
|
||||||
<LoadingOutlined />
|
<LoadingOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div className="navigation-text">We are updating the values...</div>
|
<div className="navigation-text">Refreshing values...</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{errorMessage && !loading && (
|
{errorMessage && !loading && (
|
||||||
@ -492,21 +578,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
<div className="navigation-text">
|
<div className="navigation-text">
|
||||||
{errorMessage || SOMETHING_WENT_WRONG}
|
{errorMessage || SOMETHING_WENT_WRONG}
|
||||||
</div>
|
</div>
|
||||||
<div className="navigation-icons">
|
{onRetry && showRetryButton && (
|
||||||
<ReloadOutlined
|
<div className="navigation-icons">
|
||||||
twoToneColor={Color.BG_CHERRY_400}
|
<ReloadOutlined
|
||||||
onClick={(e): void => {
|
twoToneColor={Color.BG_CHERRY_400}
|
||||||
e.stopPropagation();
|
onClick={(e): void => {
|
||||||
if (onRetry) onRetry();
|
e.stopPropagation();
|
||||||
}}
|
onRetry();
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{noDataMessage && !loading && (
|
{showIncompleteDataMessage &&
|
||||||
<div className="navigation-text">{noDataMessage}</div>
|
isScrolledToBottom &&
|
||||||
)}
|
!loading &&
|
||||||
|
!errorMessage && (
|
||||||
|
<div className="navigation-text-incomplete">
|
||||||
|
Don't see the value? Use search
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{noDataMessage &&
|
||||||
|
!loading &&
|
||||||
|
!(showIncompleteDataMessage && isScrolledToBottom) &&
|
||||||
|
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -520,6 +618,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
isLabelPresent,
|
isLabelPresent,
|
||||||
handleDropdownClick,
|
handleDropdownClick,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
|
handleDropdownScroll,
|
||||||
activeOptionIndex,
|
activeOptionIndex,
|
||||||
loading,
|
loading,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@ -527,8 +626,25 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
dropdownRender,
|
dropdownRender,
|
||||||
renderOptionWithIndex,
|
renderOptionWithIndex,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
showIncompleteDataMessage,
|
||||||
|
isScrolledToBottom,
|
||||||
|
showRetryButton,
|
||||||
|
isDarkMode,
|
||||||
|
isDynamicVariable,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Handle dropdown visibility changes
|
||||||
|
const handleDropdownVisibleChange = useCallback((visible: boolean): void => {
|
||||||
|
setIsOpen(visible);
|
||||||
|
if (visible) {
|
||||||
|
justOpenedRef.current = true;
|
||||||
|
setActiveOptionIndex(0);
|
||||||
|
} else {
|
||||||
|
setSearchText('');
|
||||||
|
setActiveOptionIndex(-1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ===== Side Effects =====
|
// ===== Side Effects =====
|
||||||
|
|
||||||
// Clear search text when dropdown closes
|
// Clear search text when dropdown closes
|
||||||
@ -582,7 +698,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onDropdownVisibleChange={setIsOpen}
|
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
options={optionsWithHighlight}
|
options={optionsWithHighlight}
|
||||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
RenderResult,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import CustomMultiSelect from '../CustomMultiSelect';
|
||||||
|
|
||||||
|
// Mock scrollIntoView which isn't available in JSDOM
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||||
|
|
||||||
|
// Helper function to render with VirtuosoMockContext
|
||||||
|
const renderWithVirtuoso = (component: React.ReactElement): RenderResult =>
|
||||||
|
render(
|
||||||
|
<VirtuosoMockContext.Provider
|
||||||
|
value={{ viewportHeight: 300, itemHeight: 100 }}
|
||||||
|
>
|
||||||
|
{component}
|
||||||
|
</VirtuosoMockContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock options data
|
||||||
|
const mockOptions = [
|
||||||
|
{ label: 'Option 1', value: 'option1' },
|
||||||
|
{ label: 'Option 2', value: 'option2' },
|
||||||
|
{ label: 'Option 3', value: 'option3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// CSS selector for retry button
|
||||||
|
const RETRY_BUTTON_SELECTOR = '.navigation-icons .anticon-reload';
|
||||||
|
|
||||||
|
describe('CustomMultiSelect - Retry Functionality', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show retry button when 5xx error occurs and error message is displayed', async () => {
|
||||||
|
const mockOnRetry = jest.fn();
|
||||||
|
const errorMessage = 'Internal Server Error (500)';
|
||||||
|
|
||||||
|
renderWithVirtuoso(
|
||||||
|
<CustomMultiSelect
|
||||||
|
options={mockOptions}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
onRetry={mockOnRetry}
|
||||||
|
showRetryButton
|
||||||
|
loading={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown to see error state
|
||||||
|
const selectElement = screen.getByRole('combobox');
|
||||||
|
fireEvent.mouseDown(selectElement);
|
||||||
|
|
||||||
|
// Wait for dropdown to appear with error message
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that retry button (ReloadOutlined icon) is present
|
||||||
|
const retryButton = document.querySelector(RETRY_BUTTON_SELECTOR);
|
||||||
|
expect(retryButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show retry button when 4xx error occurs and error message is displayed (current behavior)', async () => {
|
||||||
|
const mockOnRetry = jest.fn();
|
||||||
|
const errorMessage = 'Bad Request (400)';
|
||||||
|
|
||||||
|
renderWithVirtuoso(
|
||||||
|
<CustomMultiSelect
|
||||||
|
options={mockOptions}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
onRetry={mockOnRetry}
|
||||||
|
showRetryButton={false}
|
||||||
|
loading={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const selectElement = screen.getByRole('combobox');
|
||||||
|
fireEvent.mouseDown(selectElement);
|
||||||
|
|
||||||
|
// Wait for dropdown to appear with error message
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryButton = document.querySelector(RETRY_BUTTON_SELECTOR);
|
||||||
|
expect(retryButton).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onRetry function when retry button is clicked', async () => {
|
||||||
|
const mockOnRetry = jest.fn();
|
||||||
|
const errorMessage = 'Internal Server Error (500)';
|
||||||
|
|
||||||
|
renderWithVirtuoso(
|
||||||
|
<CustomMultiSelect
|
||||||
|
options={mockOptions}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
onRetry={mockOnRetry}
|
||||||
|
showRetryButton
|
||||||
|
loading={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const selectElement = screen.getByRole('combobox');
|
||||||
|
fireEvent.mouseDown(selectElement);
|
||||||
|
|
||||||
|
// Wait for dropdown to appear
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find and click the retry button
|
||||||
|
const retryButton = document.querySelector(RETRY_BUTTON_SELECTOR);
|
||||||
|
expect(retryButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(retryButton as Element);
|
||||||
|
|
||||||
|
// Verify onRetry was called
|
||||||
|
expect(mockOnRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,10 +1,27 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
RenderResult,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||||
|
|
||||||
import CustomMultiSelect from '../CustomMultiSelect';
|
import CustomMultiSelect from '../CustomMultiSelect';
|
||||||
|
|
||||||
// Mock scrollIntoView which isn't available in JSDOM
|
// Mock scrollIntoView which isn't available in JSDOM
|
||||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||||
|
|
||||||
|
// Helper function to render with VirtuosoMockContext
|
||||||
|
const renderWithVirtuoso = (component: React.ReactElement): RenderResult =>
|
||||||
|
render(
|
||||||
|
<VirtuosoMockContext.Provider
|
||||||
|
value={{ viewportHeight: 300, itemHeight: 100 }}
|
||||||
|
>
|
||||||
|
{component}
|
||||||
|
</VirtuosoMockContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
// Mock options data
|
// Mock options data
|
||||||
const mockOptions = [
|
const mockOptions = [
|
||||||
{ label: 'Option 1', value: 'option1' },
|
{ label: 'Option 1', value: 'option1' },
|
||||||
@ -32,7 +49,7 @@ const mockGroupedOptions = [
|
|||||||
describe('CustomMultiSelect Component', () => {
|
describe('CustomMultiSelect Component', () => {
|
||||||
it('renders with placeholder', () => {
|
it('renders with placeholder', () => {
|
||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
render(
|
renderWithVirtuoso(
|
||||||
<CustomMultiSelect
|
<CustomMultiSelect
|
||||||
placeholder="Select multiple options"
|
placeholder="Select multiple options"
|
||||||
options={mockOptions}
|
options={mockOptions}
|
||||||
@ -47,7 +64,9 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
|
|
||||||
it('opens dropdown when clicked', async () => {
|
it('opens dropdown when clicked', async () => {
|
||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
render(<CustomMultiSelect options={mockOptions} onChange={handleChange} />);
|
renderWithVirtuoso(
|
||||||
|
<CustomMultiSelect options={mockOptions} onChange={handleChange} />,
|
||||||
|
);
|
||||||
|
|
||||||
// Click to open the dropdown
|
// Click to open the dropdown
|
||||||
const selectElement = screen.getByRole('combobox');
|
const selectElement = screen.getByRole('combobox');
|
||||||
@ -66,7 +85,7 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
|
|
||||||
// Start with option1 already selected
|
// Start with option1 already selected
|
||||||
render(
|
renderWithVirtuoso(
|
||||||
<CustomMultiSelect
|
<CustomMultiSelect
|
||||||
options={mockOptions}
|
options={mockOptions}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@ -93,7 +112,7 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
|
|
||||||
it('selects ALL options when ALL is clicked', async () => {
|
it('selects ALL options when ALL is clicked', async () => {
|
||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
render(
|
renderWithVirtuoso(
|
||||||
<CustomMultiSelect
|
<CustomMultiSelect
|
||||||
options={mockOptions}
|
options={mockOptions}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@ -126,7 +145,7 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays selected options as tags', async () => {
|
it('displays selected options as tags', async () => {
|
||||||
render(
|
renderWithVirtuoso(
|
||||||
<CustomMultiSelect options={mockOptions} value={['option1', 'option2']} />,
|
<CustomMultiSelect options={mockOptions} value={['option1', 'option2']} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -137,7 +156,7 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
|
|
||||||
it('removes a tag when clicked', async () => {
|
it('removes a tag when clicked', async () => {
|
||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
render(
|
renderWithVirtuoso(
|
||||||
<CustomMultiSelect
|
<CustomMultiSelect
|
||||||
options={mockOptions}
|
options={mockOptions}
|
||||||
value={['option1', 'option2']}
|
value={['option1', 'option2']}
|
||||||
@ -159,7 +178,7 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('filters options when searching', async () => {
|
it('filters options when searching', async () => {
|
||||||
render(<CustomMultiSelect options={mockOptions} />);
|
renderWithVirtuoso(<CustomMultiSelect options={mockOptions} />);
|
||||||
|
|
||||||
// Open dropdown
|
// Open dropdown
|
||||||
const selectElement = screen.getByRole('combobox');
|
const selectElement = screen.getByRole('combobox');
|
||||||
@ -193,7 +212,7 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders grouped options correctly', async () => {
|
it('renders grouped options correctly', async () => {
|
||||||
render(<CustomMultiSelect options={mockGroupedOptions} />);
|
renderWithVirtuoso(<CustomMultiSelect options={mockGroupedOptions} />);
|
||||||
|
|
||||||
// Open dropdown
|
// Open dropdown
|
||||||
const selectElement = screen.getByRole('combobox');
|
const selectElement = screen.getByRole('combobox');
|
||||||
@ -211,18 +230,18 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows loading state', () => {
|
it('shows loading state', () => {
|
||||||
render(<CustomMultiSelect options={mockOptions} loading />);
|
renderWithVirtuoso(<CustomMultiSelect options={mockOptions} loading />);
|
||||||
|
|
||||||
// Open dropdown
|
// Open dropdown
|
||||||
const selectElement = screen.getByRole('combobox');
|
const selectElement = screen.getByRole('combobox');
|
||||||
fireEvent.mouseDown(selectElement);
|
fireEvent.mouseDown(selectElement);
|
||||||
|
|
||||||
// Check loading text is displayed
|
// Check loading text is displayed
|
||||||
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
|
expect(screen.getByText('Refreshing values...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error message', () => {
|
it('shows error message', () => {
|
||||||
render(
|
renderWithVirtuoso(
|
||||||
<CustomMultiSelect
|
<CustomMultiSelect
|
||||||
options={mockOptions}
|
options={mockOptions}
|
||||||
errorMessage="Test error message"
|
errorMessage="Test error message"
|
||||||
@ -238,7 +257,9 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows no data message', () => {
|
it('shows no data message', () => {
|
||||||
render(<CustomMultiSelect options={[]} noDataMessage="No data available" />);
|
renderWithVirtuoso(
|
||||||
|
<CustomMultiSelect options={[]} noDataMessage="No data available" />,
|
||||||
|
);
|
||||||
|
|
||||||
// Open dropdown
|
// Open dropdown
|
||||||
const selectElement = screen.getByRole('combobox');
|
const selectElement = screen.getByRole('combobox');
|
||||||
@ -249,7 +270,7 @@ describe('CustomMultiSelect Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows "ALL" tag when all options are selected', () => {
|
it('shows "ALL" tag when all options are selected', () => {
|
||||||
render(
|
renderWithVirtuoso(
|
||||||
<CustomMultiSelect
|
<CustomMultiSelect
|
||||||
options={mockOptions}
|
options={mockOptions}
|
||||||
value={['option1', 'option2', 'option3']}
|
value={['option1', 'option2', 'option3']}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -140,7 +140,7 @@ describe('CustomSelect Component', () => {
|
|||||||
fireEvent.mouseDown(selectElement);
|
fireEvent.mouseDown(selectElement);
|
||||||
|
|
||||||
// Check loading text is displayed
|
// Check loading text is displayed
|
||||||
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
|
expect(screen.getByText('Refreshing values...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error message', () => {
|
it('shows error message', () => {
|
||||||
|
|||||||
@ -0,0 +1,624 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||||
|
import configureStore from 'redux-mock-store';
|
||||||
|
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||||
|
|
||||||
|
import VariableItem from '../../../container/NewDashboard/DashboardVariablesSelection/VariableItem';
|
||||||
|
|
||||||
|
// Mock the dashboard variables query
|
||||||
|
jest.mock('api/dashboard/variables/dashboardVariablesQuery', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
payload: {
|
||||||
|
variableValues: ['option1', 'option2', 'option3', 'option4'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock scrollIntoView which isn't available in JSDOM
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const TEST_VARIABLE_NAME = 'test_variable';
|
||||||
|
const TEST_VARIABLE_ID = 'test-var-id';
|
||||||
|
|
||||||
|
// Create a mock store
|
||||||
|
const mockStore = configureStore([])({
|
||||||
|
globalTime: {
|
||||||
|
minTime: Date.now() - 3600000, // 1 hour ago
|
||||||
|
maxTime: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const createMockVariable = (
|
||||||
|
overrides: Partial<IDashboardVariable> = {},
|
||||||
|
): IDashboardVariable => ({
|
||||||
|
id: TEST_VARIABLE_ID,
|
||||||
|
name: TEST_VARIABLE_NAME,
|
||||||
|
description: 'Test variable description',
|
||||||
|
type: 'QUERY',
|
||||||
|
queryValue: 'SELECT DISTINCT value FROM table',
|
||||||
|
customValue: '',
|
||||||
|
sort: 'ASC',
|
||||||
|
multiSelect: false,
|
||||||
|
showALLOption: true,
|
||||||
|
selectedValue: [],
|
||||||
|
allSelected: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Provider store={mockStore}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<VirtuosoMockContext.Provider
|
||||||
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
||||||
|
value={{ viewportHeight: 300, itemHeight: 40 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</VirtuosoMockContext.Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VariableItem Integration Tests', () => {
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
let mockOnValueUpdate: jest.Mock;
|
||||||
|
let mockSetVariablesToGetUpdated: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
user = userEvent.setup();
|
||||||
|
mockOnValueUpdate = jest.fn();
|
||||||
|
mockSetVariablesToGetUpdated = jest.fn();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 1. INTEGRATION WITH CUSTOMSELECT =====
|
||||||
|
describe('CustomSelect Integration (VI)', () => {
|
||||||
|
test('VI-01: Single select variable integration', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
multiSelect: false,
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'option1,option2,option3',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render with CustomSelect
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
expect(combobox).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(combobox);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('option1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('option2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('option3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select an option
|
||||||
|
const option1 = screen.getByText('option1');
|
||||||
|
await user.click(option1);
|
||||||
|
|
||||||
|
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||||
|
TEST_VARIABLE_NAME,
|
||||||
|
TEST_VARIABLE_ID,
|
||||||
|
'option1',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 2. INTEGRATION WITH CUSTOMMULTISELECT =====
|
||||||
|
describe('CustomMultiSelect Integration (VI)', () => {
|
||||||
|
test('VI-02: Multi select variable integration', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
multiSelect: true,
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'option1,option2,option3,option4',
|
||||||
|
showALLOption: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render with CustomMultiSelect
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
expect(combobox).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(combobox);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should show ALL option
|
||||||
|
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for Virtuoso to render the custom options
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByText('option1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('option2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('option3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('option4')).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 3. TEXTBOX VARIABLE TYPE =====
|
||||||
|
describe('Textbox Variable Integration', () => {
|
||||||
|
test('VI-03: Textbox variable handling', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
type: 'TEXTBOX',
|
||||||
|
selectedValue: 'initial-value',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render a regular input
|
||||||
|
const textInput = screen.getByDisplayValue('initial-value');
|
||||||
|
expect(textInput).toBeInTheDocument();
|
||||||
|
expect(textInput.tagName).toBe('INPUT');
|
||||||
|
|
||||||
|
// Clear and type new value
|
||||||
|
await user.clear(textInput);
|
||||||
|
await user.type(textInput, 'new-text-value');
|
||||||
|
|
||||||
|
// Should call onValueUpdate after debounce
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||||
|
TEST_VARIABLE_NAME,
|
||||||
|
TEST_VARIABLE_ID,
|
||||||
|
'new-text-value',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 1000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 4. VALUE PERSISTENCE AND STATE MANAGEMENT =====
|
||||||
|
describe('Value Persistence and State Management', () => {
|
||||||
|
test('VI-04: All selected state handling', () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
multiSelect: true,
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'service1,service2,service3',
|
||||||
|
selectedValue: ['service1', 'service2', 'service3'],
|
||||||
|
allSelected: true,
|
||||||
|
showALLOption: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show "ALL" instead of individual values
|
||||||
|
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VI-05: Dropdown behavior with temporary selections', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
multiSelect: true,
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'item1,item2,item3',
|
||||||
|
selectedValue: ['item1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
await user.click(combobox);
|
||||||
|
|
||||||
|
// Wait for dropdown to open
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('.ant-select-dropdown');
|
||||||
|
expect(dropdown).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the component renders without crashing
|
||||||
|
expect(combobox).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 6. ACCESSIBILITY AND USER EXPERIENCE =====
|
||||||
|
describe('Accessibility and User Experience', () => {
|
||||||
|
test('VI-06: Variable description tooltip', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
description: 'This variable controls the service selection',
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'service1,service2',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show info icon
|
||||||
|
const infoIcon = document.querySelector('.info-icon');
|
||||||
|
expect(infoIcon).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Hover to show tooltip
|
||||||
|
if (infoIcon) {
|
||||||
|
await user.hover(infoIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('This variable controls the service selection'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VI-07: Variable name display', () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
name: 'service_name',
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'service1,service2',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show variable name with $ prefix
|
||||||
|
expect(screen.getByText('$service_name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VI-08: Max tag count behavior', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
multiSelect: true,
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'tag1,tag2,tag3,tag4,tag5,tag6,tag7',
|
||||||
|
selectedValue: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for component to render
|
||||||
|
await waitFor(() => {
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
expect(combobox).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show limited number of tags with "+ X more"
|
||||||
|
const tags = document.querySelectorAll('.ant-select-selection-item');
|
||||||
|
|
||||||
|
// The component should render without crashing
|
||||||
|
expect(tags.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 8. SEARCH INTERACTION TESTS =====
|
||||||
|
describe('Search Interaction Tests', () => {
|
||||||
|
test('VI-14: Search persistence across dropdown open/close', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'option1,option2,option3',
|
||||||
|
multiSelect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
await user.click(combobox);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = document.querySelector(
|
||||||
|
'.ant-select-selection-search-input',
|
||||||
|
);
|
||||||
|
expect(searchInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
await user.type(searchInput, 'search-text');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify search text is in input
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(searchInput).toHaveValue('search-text');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Press Escape to close dropdown
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
|
||||||
|
// Dropdown should close and search text should be cleared
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('.ant-select-dropdown');
|
||||||
|
expect(dropdown).toHaveClass('ant-select-dropdown-hidden');
|
||||||
|
expect(searchInput).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 9. ADVANCED KEYBOARD NAVIGATION =====
|
||||||
|
describe('Advanced Keyboard Navigation (VI)', () => {
|
||||||
|
test('VI-15: Shift + Arrow + Del chip deletion in multiselect', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'option1,option2,option3',
|
||||||
|
multiSelect: true,
|
||||||
|
selectedValue: ['option1', 'option2', 'option3'],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
await user.click(combobox);
|
||||||
|
|
||||||
|
// Navigate to chips using arrow keys
|
||||||
|
await user.keyboard('{ArrowLeft}');
|
||||||
|
|
||||||
|
// Use Shift + Arrow to navigate between chips
|
||||||
|
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');
|
||||||
|
|
||||||
|
// Use Del to delete the active chip
|
||||||
|
await user.keyboard('{Delete}');
|
||||||
|
|
||||||
|
// Note: The component may not immediately call onValueUpdate
|
||||||
|
// This test verifies the chip deletion behavior
|
||||||
|
await waitFor(() => {
|
||||||
|
// Check if a chip was removed from the selection
|
||||||
|
const selectionItems = document.querySelectorAll(
|
||||||
|
'.ant-select-selection-item',
|
||||||
|
);
|
||||||
|
expect(selectionItems.length).toBeLessThan(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 11. ADVANCED UI STATES =====
|
||||||
|
describe('Advanced UI States (VI)', () => {
|
||||||
|
test('VI-19: No data with previous value selected in variable', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: '',
|
||||||
|
multiSelect: true,
|
||||||
|
selectedValue: ['previous-value'],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for component to initialize
|
||||||
|
await waitFor(() => {
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
expect(combobox).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
await user.click(combobox);
|
||||||
|
|
||||||
|
// Should show no data message (the component may not show this exact text)
|
||||||
|
await waitFor(() => {
|
||||||
|
// Check if dropdown is empty or shows no data indication
|
||||||
|
const dropdown = document.querySelector('.ant-select-dropdown');
|
||||||
|
expect(dropdown).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the component renders without crashing
|
||||||
|
expect(combobox).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VI-20: Always editable accessibility in variable', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'option1,option2',
|
||||||
|
multiSelect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
|
||||||
|
// Should be editable
|
||||||
|
expect(combobox).not.toBeDisabled();
|
||||||
|
await user.click(combobox);
|
||||||
|
expect(combobox).toHaveFocus();
|
||||||
|
|
||||||
|
// Should still be interactive
|
||||||
|
expect(combobox).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 13. DROPDOWN PERSISTENCE =====
|
||||||
|
describe('Dropdown Persistence (VI)', () => {
|
||||||
|
test('VI-24: Dropdown stays open for non-save actions in variable', async () => {
|
||||||
|
const variable = createMockVariable({
|
||||||
|
type: 'CUSTOM',
|
||||||
|
customValue: 'option1,option2,option3',
|
||||||
|
multiSelect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<VariableItem
|
||||||
|
variableData={variable}
|
||||||
|
existingVariables={{}}
|
||||||
|
onValueUpdate={mockOnValueUpdate}
|
||||||
|
variablesToGetUpdated={[]}
|
||||||
|
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||||
|
dependencyData={null}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for component to initialize
|
||||||
|
await waitFor(() => {
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
expect(combobox).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const combobox = screen.getByRole('combobox');
|
||||||
|
await user.click(combobox);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate with arrow keys (non-save action)
|
||||||
|
await user.keyboard('{ArrowDown}');
|
||||||
|
await user.keyboard('{ArrowDown}');
|
||||||
|
|
||||||
|
// Dropdown should still be open
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('.ant-select-dropdown');
|
||||||
|
expect(dropdown).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the component renders without crashing
|
||||||
|
expect(combobox).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Only ESC should close the dropdown
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('.ant-select-dropdown');
|
||||||
|
expect(dropdown).toHaveClass('ant-select-dropdown-hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -35,12 +35,50 @@ $custom-border-color: #2c3044;
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
&.is-all-selected {
|
||||||
|
.ant-select-selection-search-input {
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-selection-placeholder {
|
||||||
|
opacity: 1 !important;
|
||||||
|
color: var(--bg-vanilla-400) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
visibility: visible !important;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.lightMode & {
|
||||||
|
color: rgba(0, 0, 0, 0.85) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-select-focused .ant-select-selection-placeholder {
|
||||||
|
opacity: 0.45 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-selected-text {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.lightMode & {
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ant-select-selector {
|
.ant-select-selector {
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
background-color: var(--bg-ink-400);
|
background-color: var(--bg-ink-400);
|
||||||
border-color: var(--bg-slate-400);
|
border-color: var(--bg-slate-400);
|
||||||
|
cursor: text;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@ -56,6 +94,16 @@ $custom-border-color: #2c3044;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure adequate space for input area
|
||||||
|
.ant-select-selection-search {
|
||||||
|
min-width: 60px !important;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
.ant-select-selection-search-input {
|
||||||
|
min-width: 60px !important;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.ant-select-focused {
|
&.ant-select-focused {
|
||||||
.ant-select-selector {
|
.ant-select-selector {
|
||||||
border-color: var(--bg-robin-500);
|
border-color: var(--bg-robin-500);
|
||||||
@ -158,7 +206,7 @@ $custom-border-color: #2c3044;
|
|||||||
// Custom dropdown styles for single select
|
// Custom dropdown styles for single select
|
||||||
.custom-select-dropdown {
|
.custom-select-dropdown {
|
||||||
padding: 8px 0 0 0;
|
padding: 8px 0 0 0;
|
||||||
max-height: 500px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@ -276,6 +324,10 @@ $custom-border-color: #2c3044;
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navigation-text-incomplete {
|
||||||
|
color: var(--bg-amber-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.navigation-error {
|
.navigation-error {
|
||||||
.navigation-text,
|
.navigation-text,
|
||||||
.navigation-icons {
|
.navigation-icons {
|
||||||
@ -322,7 +374,7 @@ $custom-border-color: #2c3044;
|
|||||||
// Custom dropdown styles for multi-select
|
// Custom dropdown styles for multi-select
|
||||||
.custom-multiselect-dropdown {
|
.custom-multiselect-dropdown {
|
||||||
padding: 8px 0 0 0;
|
padding: 8px 0 0 0;
|
||||||
max-height: 500px;
|
max-height: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@ -355,8 +407,13 @@ $custom-border-color: #2c3044;
|
|||||||
.select-group {
|
.select-group {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
.group-label {
|
.group-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -404,6 +461,13 @@ $custom-border-color: #2c3044;
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.all-option-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.option-content {
|
.option-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -637,6 +701,7 @@ $custom-border-color: #2c3044;
|
|||||||
.ant-select-selector {
|
.ant-select-selector {
|
||||||
background-color: var(--bg-vanilla-100);
|
background-color: var(--bg-vanilla-100);
|
||||||
border-color: #e9e9e9;
|
border-color: #e9e9e9;
|
||||||
|
cursor: text; // Make entire selector clickable for input focus
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background-color: #ccc;
|
background-color: #ccc;
|
||||||
@ -647,6 +712,20 @@ $custom-border-color: #2c3044;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-select-selection-search {
|
||||||
|
min-width: 60px !important;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
.ant-select-selection-search-input {
|
||||||
|
min-width: 60px !important;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-selector {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
.ant-select-selection-placeholder {
|
.ant-select-selection-placeholder {
|
||||||
color: rgba(0, 0, 0, 0.45);
|
color: rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
@ -656,6 +735,10 @@ $custom-border-color: #2c3044;
|
|||||||
border: 1px solid #e8e8e8;
|
border: 1px solid #e8e8e8;
|
||||||
color: rgba(0, 0, 0, 0.85);
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
|
||||||
|
font-size: 12px !important;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 18px;
|
||||||
|
|
||||||
.ant-select-selection-item-content {
|
.ant-select-selection-item-content {
|
||||||
color: rgba(0, 0, 0, 0.85);
|
color: rgba(0, 0, 0, 0.85);
|
||||||
}
|
}
|
||||||
@ -718,6 +801,10 @@ $custom-border-color: #2c3044;
|
|||||||
|
|
||||||
.select-group {
|
.select-group {
|
||||||
.group-label {
|
.group-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
color: rgba(0, 0, 0, 0.85);
|
color: rgba(0, 0, 0, 0.85);
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
@ -836,3 +923,38 @@ $custom-border-color: #2c3044;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-multiselect-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.all-selected {
|
||||||
|
.all-text {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||||
|
|
||||||
|
.lightMode & {
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within .all-text {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-selection-search-input {
|
||||||
|
caret-color: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-selection-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -24,9 +24,12 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
|
|||||||
highlightSearch?: boolean;
|
highlightSearch?: boolean;
|
||||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||||
popupMatchSelectWidth?: boolean;
|
popupMatchSelectWidth?: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string | null;
|
||||||
allowClear?: SelectProps['allowClear'];
|
allowClear?: SelectProps['allowClear'];
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
|
showIncompleteDataMessage?: boolean;
|
||||||
|
showRetryButton?: boolean;
|
||||||
|
isDynamicVariable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomTagProps {
|
export interface CustomTagProps {
|
||||||
@ -51,10 +54,16 @@ export interface CustomMultiSelectProps
|
|||||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||||
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
||||||
highlightSearch?: boolean;
|
highlightSearch?: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string | null;
|
||||||
popupClassName?: string;
|
popupClassName?: string;
|
||||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||||
maxTagCount?: number;
|
maxTagCount?: number;
|
||||||
allowClear?: SelectProps['allowClear'];
|
allowClear?: SelectProps['allowClear'];
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
|
maxTagTextLength?: number;
|
||||||
|
showIncompleteDataMessage?: boolean;
|
||||||
|
showLabels?: boolean;
|
||||||
|
enableRegexOption?: boolean;
|
||||||
|
isDynamicVariable?: boolean;
|
||||||
|
showRetryButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
/* eslint-disable sonarjs/cognitive-complexity */
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
import { uniqueOptions } from 'container/NewDashboard/DashboardVariablesSelection/util';
|
||||||
|
|
||||||
import { OptionData } from './types';
|
import { OptionData } from './types';
|
||||||
|
|
||||||
export const SPACEKEY = ' ';
|
export const SPACEKEY = ' ';
|
||||||
@ -98,8 +100,10 @@ export const prioritizeOrAddOptionForMultiSelect = (
|
|||||||
label: labels?.[value] ?? value, // Use provided label or default to value
|
label: labels?.[value] ?? value, // Use provided label or default to value
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const flatOutSelectedOptions = uniqueOptions([...newOptions, ...foundOptions]);
|
||||||
|
|
||||||
// Add found & new options to the top
|
// Add found & new options to the top
|
||||||
return [...newOptions, ...foundOptions, ...filteredOptions];
|
return [...flatOutSelectedOptions, ...filteredOptions];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -133,3 +137,15 @@ export const filterOptionsBySearch = (
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as OptionData[];
|
.filter(Boolean) as OptionData[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to handle dropdown scroll and detect when scrolled to bottom
|
||||||
|
* Returns true when scrolled to within 20px of the bottom
|
||||||
|
*/
|
||||||
|
export const handleScrollToBottom = (
|
||||||
|
e: React.UIEvent<HTMLDivElement>,
|
||||||
|
): boolean => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||||
|
// Consider "scrolled to bottom" when within 20px of the bottom or at the bottom
|
||||||
|
return scrollHeight - scrollTop - clientHeight < 20;
|
||||||
|
};
|
||||||
|
|||||||
@ -102,7 +102,7 @@ function ListViewOrderBy({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
notFoundContent={<Loader isLoading={isLoading} />}
|
notFoundContent={<Loader isLoading={isLoading} />}
|
||||||
placeholder="Select an attribute"
|
placeholder="Select a field"
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
filterOption={(input, option): boolean =>
|
filterOption={(input, option): boolean =>
|
||||||
|
|||||||
@ -22,6 +22,10 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
.qb-trace-view-selector-container {
|
||||||
|
padding: 12px 8px 8px 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.qb-content-section {
|
.qb-content-section {
|
||||||
@ -179,7 +183,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
margin-left: 32px;
|
margin-left: 26px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
|
||||||
@ -195,8 +199,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.formula-container {
|
.formula-container {
|
||||||
margin-left: 82px;
|
padding: 8px;
|
||||||
padding: 4px 0px;
|
margin-left: 74px;
|
||||||
|
|
||||||
.ant-col {
|
.ant-col {
|
||||||
&::before {
|
&::before {
|
||||||
@ -291,6 +295,13 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.qb-trace-operator-button-container {
|
||||||
|
&-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,6 +342,12 @@
|
|||||||
);
|
);
|
||||||
left: 15px;
|
left: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.has-trace-operator {
|
||||||
|
&::before {
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.formula-name {
|
.formula-name {
|
||||||
@ -347,7 +364,7 @@
|
|||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
height: 65px;
|
height: 128px;
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -387,6 +404,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.qb-search-filter-container {
|
.qb-search-filter-container {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import { Formula } from 'container/QueryBuilder/components/Formula';
|
|||||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { DataSource } from 'types/common/queryBuilder';
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
import { QueryBuilderV2Provider } from './QueryBuilderV2Context';
|
import { QueryBuilderV2Provider } from './QueryBuilderV2Context';
|
||||||
import QueryFooter from './QueryV2/QueryFooter/QueryFooter';
|
import QueryFooter from './QueryV2/QueryFooter/QueryFooter';
|
||||||
import { QueryV2 } from './QueryV2/QueryV2';
|
import { QueryV2 } from './QueryV2/QueryV2';
|
||||||
|
import TraceOperator from './QueryV2/TraceOperator/TraceOperator';
|
||||||
|
|
||||||
export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||||
config,
|
config,
|
||||||
@ -18,6 +20,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
|||||||
queryComponents,
|
queryComponents,
|
||||||
isListViewPanel = false,
|
isListViewPanel = false,
|
||||||
showOnlyWhereClause = false,
|
showOnlyWhereClause = false,
|
||||||
|
showTraceOperator = false,
|
||||||
version,
|
version,
|
||||||
}: QueryBuilderProps): JSX.Element {
|
}: QueryBuilderProps): JSX.Element {
|
||||||
const {
|
const {
|
||||||
@ -25,6 +28,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
|||||||
addNewBuilderQuery,
|
addNewBuilderQuery,
|
||||||
addNewFormula,
|
addNewFormula,
|
||||||
handleSetConfig,
|
handleSetConfig,
|
||||||
|
addTraceOperator,
|
||||||
panelType,
|
panelType,
|
||||||
initialDataSource,
|
initialDataSource,
|
||||||
} = useQueryBuilder();
|
} = useQueryBuilder();
|
||||||
@ -54,6 +58,11 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
|||||||
newPanelType,
|
newPanelType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const isMultiQueryAllowed = useMemo(
|
||||||
|
() => !isListViewPanel || showTraceOperator,
|
||||||
|
[showTraceOperator, isListViewPanel],
|
||||||
|
);
|
||||||
|
|
||||||
const listViewLogFilterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
const listViewLogFilterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||||
const config: QueryBuilderProps['filterConfigs'] = {
|
const config: QueryBuilderProps['filterConfigs'] = {
|
||||||
stepInterval: { isHidden: true, isDisabled: true },
|
stepInterval: { isHidden: true, isDisabled: true },
|
||||||
@ -97,11 +106,60 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
|||||||
listViewTracesFilterConfigs,
|
listViewTracesFilterConfigs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const traceOperator = useMemo((): IBuilderTraceOperator | undefined => {
|
||||||
|
if (
|
||||||
|
currentQuery.builder.queryTraceOperator &&
|
||||||
|
currentQuery.builder.queryTraceOperator.length > 0
|
||||||
|
) {
|
||||||
|
return currentQuery.builder.queryTraceOperator[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [currentQuery.builder.queryTraceOperator]);
|
||||||
|
|
||||||
|
const hasAtLeastOneTraceQuery = useMemo(
|
||||||
|
() =>
|
||||||
|
currentQuery.builder.queryData.some(
|
||||||
|
(query) => query.dataSource === DataSource.TRACES,
|
||||||
|
),
|
||||||
|
[currentQuery.builder.queryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasTraceOperator = useMemo(
|
||||||
|
() => showTraceOperator && hasAtLeastOneTraceQuery && Boolean(traceOperator),
|
||||||
|
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldShowFooter = useMemo(
|
||||||
|
() =>
|
||||||
|
(!showOnlyWhereClause && !isListViewPanel) ||
|
||||||
|
(currentDataSource === DataSource.TRACES && showTraceOperator),
|
||||||
|
[isListViewPanel, showTraceOperator, showOnlyWhereClause, currentDataSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showQueryList = useMemo(
|
||||||
|
() => (!showOnlyWhereClause && !isListViewPanel) || showTraceOperator,
|
||||||
|
[isListViewPanel, showOnlyWhereClause, showTraceOperator],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showFormula = useMemo(() => {
|
||||||
|
if (currentDataSource === DataSource.TRACES) {
|
||||||
|
return !isListViewPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [isListViewPanel, currentDataSource]);
|
||||||
|
|
||||||
|
const showAddTraceOperator = useMemo(
|
||||||
|
() => showTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
|
||||||
|
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryBuilderV2Provider>
|
<QueryBuilderV2Provider>
|
||||||
<div className="query-builder-v2">
|
<div className="query-builder-v2">
|
||||||
<div className="qb-content-container">
|
<div className="qb-content-container">
|
||||||
{isListViewPanel && (
|
{!isMultiQueryAllowed ? (
|
||||||
<QueryV2
|
<QueryV2
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
key={currentQuery.builder.queryData[0].queryName}
|
key={currentQuery.builder.queryData[0].queryName}
|
||||||
@ -109,15 +167,16 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
|||||||
query={currentQuery.builder.queryData[0]}
|
query={currentQuery.builder.queryData[0]}
|
||||||
filterConfigs={queryFilterConfigs}
|
filterConfigs={queryFilterConfigs}
|
||||||
queryComponents={queryComponents}
|
queryComponents={queryComponents}
|
||||||
|
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||||
|
showTraceOperator={showTraceOperator}
|
||||||
|
hasTraceOperator={hasTraceOperator}
|
||||||
version={version}
|
version={version}
|
||||||
isAvailableToDisable={false}
|
isAvailableToDisable={false}
|
||||||
queryVariant={config?.queryVariant || 'dropdown'}
|
queryVariant={config?.queryVariant || 'dropdown'}
|
||||||
showOnlyWhereClause={showOnlyWhereClause}
|
showOnlyWhereClause={showOnlyWhereClause}
|
||||||
isListViewPanel={isListViewPanel}
|
isListViewPanel={isListViewPanel}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{!isListViewPanel &&
|
|
||||||
currentQuery.builder.queryData.map((query, index) => (
|
currentQuery.builder.queryData.map((query, index) => (
|
||||||
<QueryV2
|
<QueryV2
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@ -127,13 +186,17 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
|||||||
filterConfigs={queryFilterConfigs}
|
filterConfigs={queryFilterConfigs}
|
||||||
queryComponents={queryComponents}
|
queryComponents={queryComponents}
|
||||||
version={version}
|
version={version}
|
||||||
|
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||||
isAvailableToDisable={false}
|
isAvailableToDisable={false}
|
||||||
|
showTraceOperator={showTraceOperator}
|
||||||
|
hasTraceOperator={hasTraceOperator}
|
||||||
queryVariant={config?.queryVariant || 'dropdown'}
|
queryVariant={config?.queryVariant || 'dropdown'}
|
||||||
showOnlyWhereClause={showOnlyWhereClause}
|
showOnlyWhereClause={showOnlyWhereClause}
|
||||||
isListViewPanel={isListViewPanel}
|
isListViewPanel={isListViewPanel}
|
||||||
signalSource={config?.signalSource || ''}
|
signalSource={config?.signalSource || ''}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
|
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
|
||||||
<div className="qb-formulas-container">
|
<div className="qb-formulas-container">
|
||||||
@ -158,15 +221,25 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!showOnlyWhereClause && !isListViewPanel && (
|
{shouldShowFooter && (
|
||||||
<QueryFooter
|
<QueryFooter
|
||||||
|
showAddFormula={showFormula}
|
||||||
addNewBuilderQuery={addNewBuilderQuery}
|
addNewBuilderQuery={addNewBuilderQuery}
|
||||||
addNewFormula={addNewFormula}
|
addNewFormula={addNewFormula}
|
||||||
|
addTraceOperator={addTraceOperator}
|
||||||
|
showAddTraceOperator={showAddTraceOperator}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasTraceOperator && (
|
||||||
|
<TraceOperator
|
||||||
|
isListViewPanel={isListViewPanel}
|
||||||
|
traceOperator={traceOperator as IBuilderTraceOperator}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!showOnlyWhereClause && !isListViewPanel && (
|
{showQueryList && (
|
||||||
<div className="query-names-section">
|
<div className="query-names-section">
|
||||||
{currentQuery.builder.queryData.map((query) => (
|
{currentQuery.builder.queryData.map((query) => (
|
||||||
<div key={query.queryName} className="query-name">
|
<div key={query.queryName} className="query-name">
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
|
.query-add-ons {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.add-ons-list {
|
.add-ons-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
.add-ons-tabs {
|
.add-ons-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -144,6 +144,7 @@ function QueryAddOns({
|
|||||||
showReduceTo,
|
showReduceTo,
|
||||||
panelType,
|
panelType,
|
||||||
index,
|
index,
|
||||||
|
isForTraceOperator = false,
|
||||||
}: {
|
}: {
|
||||||
query: IBuilderQuery;
|
query: IBuilderQuery;
|
||||||
version: string;
|
version: string;
|
||||||
@ -151,6 +152,7 @@ function QueryAddOns({
|
|||||||
showReduceTo: boolean;
|
showReduceTo: boolean;
|
||||||
panelType: PANEL_TYPES | null;
|
panelType: PANEL_TYPES | null;
|
||||||
index: number;
|
index: number;
|
||||||
|
isForTraceOperator?: boolean;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const [addOns, setAddOns] = useState<AddOn[]>(ADD_ONS);
|
const [addOns, setAddOns] = useState<AddOn[]>(ADD_ONS);
|
||||||
|
|
||||||
@ -160,6 +162,7 @@ function QueryAddOns({
|
|||||||
index,
|
index,
|
||||||
query,
|
query,
|
||||||
entityVersion: '',
|
entityVersion: '',
|
||||||
|
isForTraceOperator,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSetQueryData } = useQueryBuilder();
|
const { handleSetQueryData } = useQueryBuilder();
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import { Tooltip } from 'antd';
|
|||||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
import {
|
||||||
|
IBuilderQuery,
|
||||||
|
IBuilderTraceOperator,
|
||||||
|
} from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { DataSource } from 'types/common/queryBuilder';
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
import QueryAggregationSelect from './QueryAggregationSelect';
|
import QueryAggregationSelect from './QueryAggregationSelect';
|
||||||
@ -20,7 +23,7 @@ function QueryAggregationOptions({
|
|||||||
panelType?: string;
|
panelType?: string;
|
||||||
onAggregationIntervalChange: (value: number) => void;
|
onAggregationIntervalChange: (value: number) => void;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
queryData: IBuilderQuery;
|
queryData: IBuilderQuery | IBuilderTraceOperator;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const showAggregationInterval = useMemo(() => {
|
const showAggregationInterval = useMemo(() => {
|
||||||
// eslint-disable-next-line sonarjs/prefer-single-boolean-return
|
// eslint-disable-next-line sonarjs/prefer-single-boolean-return
|
||||||
|
|||||||
@ -686,7 +686,10 @@ function QueryAggregationSelect({
|
|||||||
>
|
>
|
||||||
<Info
|
<Info
|
||||||
size={14}
|
size={14}
|
||||||
style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }}
|
style={{
|
||||||
|
opacity: 0.9,
|
||||||
|
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -1,12 +1,20 @@
|
|||||||
|
/* eslint-disable react/require-default-props */
|
||||||
import { Button, Tooltip, Typography } from 'antd';
|
import { Button, Tooltip, Typography } from 'antd';
|
||||||
import { Plus, Sigma } from 'lucide-react';
|
import { DraftingCompass, Plus, Sigma } from 'lucide-react';
|
||||||
|
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||||
|
|
||||||
export default function QueryFooter({
|
export default function QueryFooter({
|
||||||
addNewBuilderQuery,
|
addNewBuilderQuery,
|
||||||
addNewFormula,
|
addNewFormula,
|
||||||
|
addTraceOperator,
|
||||||
|
showAddFormula = true,
|
||||||
|
showAddTraceOperator = false,
|
||||||
}: {
|
}: {
|
||||||
addNewBuilderQuery: () => void;
|
addNewBuilderQuery: () => void;
|
||||||
addNewFormula: () => void;
|
addNewFormula: () => void;
|
||||||
|
addTraceOperator?: () => void;
|
||||||
|
showAddTraceOperator: boolean;
|
||||||
|
showAddFormula?: boolean;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="qb-footer">
|
<div className="qb-footer">
|
||||||
@ -22,32 +30,65 @@ export default function QueryFooter({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="qb-add-formula">
|
{showAddFormula && (
|
||||||
<Tooltip
|
<div className="qb-add-formula">
|
||||||
title={
|
<Tooltip
|
||||||
<div style={{ textAlign: 'center' }}>
|
title={
|
||||||
Add New Formula
|
<div style={{ textAlign: 'center' }}>
|
||||||
<Typography.Link
|
Add New Formula
|
||||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
|
<Typography.Link
|
||||||
target="_blank"
|
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
|
||||||
style={{ textDecoration: 'underline' }}
|
target="_blank"
|
||||||
>
|
style={{ textDecoration: 'underline' }}
|
||||||
{' '}
|
>
|
||||||
<br />
|
{' '}
|
||||||
Learn more
|
<br />
|
||||||
</Typography.Link>
|
Learn more
|
||||||
</div>
|
</Typography.Link>
|
||||||
}
|
</div>
|
||||||
>
|
}
|
||||||
<Button
|
|
||||||
className="add-formula-button periscope-btn secondary"
|
|
||||||
icon={<Sigma size={16} />}
|
|
||||||
onClick={addNewFormula}
|
|
||||||
>
|
>
|
||||||
Add Formula
|
<Button
|
||||||
</Button>
|
className="add-formula-button periscope-btn secondary"
|
||||||
</Tooltip>
|
icon={<Sigma size={16} />}
|
||||||
</div>
|
onClick={addNewFormula}
|
||||||
|
>
|
||||||
|
Add Formula
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showAddTraceOperator && (
|
||||||
|
<div className="qb-trace-operator-button-container">
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
Add Trace Matching
|
||||||
|
<Typography.Link
|
||||||
|
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
|
||||||
|
target="_blank"
|
||||||
|
style={{ textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
{' '}
|
||||||
|
<br />
|
||||||
|
Learn more
|
||||||
|
</Typography.Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="add-trace-operator-button periscope-btn secondary"
|
||||||
|
icon={<DraftingCompass size={16} />}
|
||||||
|
onClick={(): void => addTraceOperator?.()}
|
||||||
|
>
|
||||||
|
<div className="qb-trace-operator-button-container-text">
|
||||||
|
Add Trace Matching
|
||||||
|
<BetaTag />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
'Helvetica Neue', sans-serif;
|
'Helvetica Neue', sans-serif;
|
||||||
|
|
||||||
.query-where-clause-editor-container {
|
.query-where-clause-editor-container {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
|
|||||||
@ -32,12 +32,14 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
|||||||
import useDebounce from 'hooks/useDebounce';
|
import useDebounce from 'hooks/useDebounce';
|
||||||
import { debounce, isNull } from 'lodash-es';
|
import { debounce, isNull } from 'lodash-es';
|
||||||
import { Info, TriangleAlert } from 'lucide-react';
|
import { Info, TriangleAlert } from 'lucide-react';
|
||||||
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
IDetailedError,
|
IDetailedError,
|
||||||
IQueryContext,
|
IQueryContext,
|
||||||
IValidationResult,
|
IValidationResult,
|
||||||
} from 'types/antlrQueryTypes';
|
} from 'types/antlrQueryTypes';
|
||||||
|
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||||
import { DataSource } from 'types/common/queryBuilder';
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
@ -161,13 +163,15 @@ function QuerySearch({
|
|||||||
|
|
||||||
const { handleRunQuery } = useQueryBuilder();
|
const { handleRunQuery } = useQueryBuilder();
|
||||||
|
|
||||||
// const {
|
const { selectedDashboard } = useDashboard();
|
||||||
// data: queryKeySuggestions,
|
|
||||||
// refetch: refetchQueryKeySuggestions,
|
const dynamicVariables = useMemo(
|
||||||
// } = useGetQueryKeySuggestions({
|
() =>
|
||||||
// signal: dataSource,
|
Object.values(selectedDashboard?.data?.variables || {})?.filter(
|
||||||
// name: searchText || '',
|
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
|
||||||
// });
|
),
|
||||||
|
[selectedDashboard],
|
||||||
|
);
|
||||||
|
|
||||||
// Add back the generateOptions function and useEffect
|
// Add back the generateOptions function and useEffect
|
||||||
const generateOptions = (keys: {
|
const generateOptions = (keys: {
|
||||||
@ -982,6 +986,25 @@ function QuerySearch({
|
|||||||
option.label.toLowerCase().includes(searchText),
|
option.label.toLowerCase().includes(searchText),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add dynamic variables suggestions for the current key
|
||||||
|
const variableName = dynamicVariables?.find(
|
||||||
|
(variable) => variable?.dynamicVariablesAttribute === keyName,
|
||||||
|
)?.name;
|
||||||
|
|
||||||
|
if (variableName) {
|
||||||
|
const variableValue = `$${variableName}`;
|
||||||
|
const variableOption = {
|
||||||
|
label: variableValue,
|
||||||
|
type: 'variable',
|
||||||
|
apply: variableValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add variable suggestion at the beginning if it matches the search text
|
||||||
|
if (variableValue.toLowerCase().includes(searchText.toLowerCase())) {
|
||||||
|
options = [variableOption, ...options];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger fetch only if needed
|
// Trigger fetch only if needed
|
||||||
const shouldFetch =
|
const shouldFetch =
|
||||||
// Fetch only if key is available
|
// Fetch only if key is available
|
||||||
@ -1034,6 +1057,9 @@ function QuerySearch({
|
|||||||
} else if (option.type === 'array') {
|
} else if (option.type === 'array') {
|
||||||
// Arrays are already formatted as arrays
|
// Arrays are already formatted as arrays
|
||||||
processedOption.apply = option.label;
|
processedOption.apply = option.label;
|
||||||
|
} else if (option.type === 'variable') {
|
||||||
|
// Variables should be used as-is (they already have the $ prefix)
|
||||||
|
processedOption.apply = option.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
return processedOption;
|
return processedOption;
|
||||||
@ -1243,7 +1269,10 @@ function QuerySearch({
|
|||||||
>
|
>
|
||||||
<Info
|
<Info
|
||||||
size={14}
|
size={14}
|
||||||
style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }}
|
style={{
|
||||||
|
opacity: 0.9,
|
||||||
|
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
import { Dropdown } from 'antd';
|
import { Dropdown } from 'antd';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||||
@ -26,9 +27,12 @@ export const QueryV2 = memo(function QueryV2({
|
|||||||
query,
|
query,
|
||||||
filterConfigs,
|
filterConfigs,
|
||||||
isListViewPanel = false,
|
isListViewPanel = false,
|
||||||
|
showTraceOperator = false,
|
||||||
|
hasTraceOperator = false,
|
||||||
version,
|
version,
|
||||||
showOnlyWhereClause = false,
|
showOnlyWhereClause = false,
|
||||||
signalSource = '',
|
signalSource = '',
|
||||||
|
isMultiQueryAllowed = false,
|
||||||
}: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
|
}: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
|
||||||
const { cloneQuery, panelType } = useQueryBuilder();
|
const { cloneQuery, panelType } = useQueryBuilder();
|
||||||
|
|
||||||
@ -75,6 +79,15 @@ export const QueryV2 = memo(function QueryV2({
|
|||||||
dataSource,
|
dataSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const showInlineQuerySearch = useMemo(() => {
|
||||||
|
if (!showTraceOperator) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
dataSource === DataSource.TRACES && (hasTraceOperator || isListViewPanel)
|
||||||
|
);
|
||||||
|
}, [hasTraceOperator, isListViewPanel, showTraceOperator, dataSource]);
|
||||||
|
|
||||||
const handleChangeAggregateEvery = useCallback(
|
const handleChangeAggregateEvery = useCallback(
|
||||||
(value: IBuilderQuery['stepInterval']) => {
|
(value: IBuilderQuery['stepInterval']) => {
|
||||||
handleChangeQueryData('stepInterval', value);
|
handleChangeQueryData('stepInterval', value);
|
||||||
@ -108,11 +121,12 @@ export const QueryV2 = memo(function QueryV2({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<div className="qb-content-section">
|
<div className="qb-content-section">
|
||||||
{!showOnlyWhereClause && (
|
{(!showOnlyWhereClause || showTraceOperator) && (
|
||||||
<div className="qb-header-container">
|
<div className="qb-header-container">
|
||||||
<div className="query-actions-container">
|
<div className="query-actions-container">
|
||||||
<div className="query-actions-left-container">
|
<div className="query-actions-left-container">
|
||||||
<QBEntityOptions
|
<QBEntityOptions
|
||||||
|
hasTraceOperator={hasTraceOperator}
|
||||||
isMetricsDataSource={dataSource === DataSource.METRICS}
|
isMetricsDataSource={dataSource === DataSource.METRICS}
|
||||||
showFunctions={
|
showFunctions={
|
||||||
(version && version === ENTITY_VERSION_V4) ||
|
(version && version === ENTITY_VERSION_V4) ||
|
||||||
@ -122,6 +136,7 @@ export const QueryV2 = memo(function QueryV2({
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
showTraceOperator={showTraceOperator}
|
||||||
entityType="query"
|
entityType="query"
|
||||||
entityData={query}
|
entityData={query}
|
||||||
onToggleVisibility={handleToggleDisableQuery}
|
onToggleVisibility={handleToggleDisableQuery}
|
||||||
@ -139,7 +154,28 @@ export const QueryV2 = memo(function QueryV2({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isListViewPanel && (
|
{!isCollapsed && showInlineQuerySearch && (
|
||||||
|
<div className="qb-search-filter-container">
|
||||||
|
<div className="query-search-container">
|
||||||
|
<QuerySearch
|
||||||
|
key={`query-search-${query.queryName}-${query.dataSource}`}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
queryData={query}
|
||||||
|
dataSource={dataSource}
|
||||||
|
signalSource={signalSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSpanScopeSelector && (
|
||||||
|
<div className="traces-search-filter-container">
|
||||||
|
<div className="traces-search-filter-in">in</div>
|
||||||
|
<SpanScopeSelector query={query} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMultiQueryAllowed && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
className="query-actions-dropdown"
|
className="query-actions-dropdown"
|
||||||
menu={{
|
menu={{
|
||||||
@ -181,28 +217,31 @@ export const QueryV2 = memo(function QueryV2({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="qb-search-filter-container">
|
{!showInlineQuerySearch && (
|
||||||
<div className="query-search-container">
|
<div className="qb-search-filter-container">
|
||||||
<QuerySearch
|
<div className="query-search-container">
|
||||||
key={`query-search-${query.queryName}-${query.dataSource}`}
|
<QuerySearch
|
||||||
onChange={handleSearchChange}
|
key={`query-search-${query.queryName}-${query.dataSource}`}
|
||||||
queryData={query}
|
onChange={handleSearchChange}
|
||||||
dataSource={dataSource}
|
queryData={query}
|
||||||
signalSource={signalSource}
|
dataSource={dataSource}
|
||||||
/>
|
signalSource={signalSource}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{showSpanScopeSelector && (
|
|
||||||
<div className="traces-search-filter-container">
|
|
||||||
<div className="traces-search-filter-in">in</div>
|
|
||||||
<SpanScopeSelector query={query} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{showSpanScopeSelector && (
|
||||||
|
<div className="traces-search-filter-container">
|
||||||
|
<div className="traces-search-filter-in">in</div>
|
||||||
|
<SpanScopeSelector query={query} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!showOnlyWhereClause &&
|
{!showOnlyWhereClause &&
|
||||||
!isListViewPanel &&
|
!isListViewPanel &&
|
||||||
|
!(hasTraceOperator && dataSource === DataSource.TRACES) &&
|
||||||
dataSource !== DataSource.METRICS && (
|
dataSource !== DataSource.METRICS && (
|
||||||
<QueryAggregation
|
<QueryAggregation
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
@ -225,16 +264,17 @@ export const QueryV2 = memo(function QueryV2({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!showOnlyWhereClause && (
|
{!showOnlyWhereClause &&
|
||||||
<QueryAddOns
|
!(hasTraceOperator && query.dataSource === DataSource.TRACES) && (
|
||||||
index={index}
|
<QueryAddOns
|
||||||
query={query}
|
index={index}
|
||||||
version="v3"
|
query={query}
|
||||||
isListViewPanel={isListViewPanel}
|
version="v3"
|
||||||
showReduceTo={showReduceTo}
|
isListViewPanel={isListViewPanel}
|
||||||
panelType={panelType}
|
showReduceTo={showReduceTo}
|
||||||
/>
|
panelType={panelType}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,159 @@
|
|||||||
|
.qb-trace-operator {
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&.non-list-view {
|
||||||
|
padding-left: 40px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 24px;
|
||||||
|
left: 12px;
|
||||||
|
height: 88px;
|
||||||
|
width: 1px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
var(--bg-slate-400),
|
||||||
|
var(--bg-slate-400) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-arrow {
|
||||||
|
position: relative;
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
left: -26px;
|
||||||
|
height: 1px;
|
||||||
|
width: 20px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--bg-slate-400),
|
||||||
|
var(--bg-slate-400) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: -10px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 4px;
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-aggregation-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-add-ons-container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-label-with-input {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
background: var(--bg-ink-300);
|
||||||
|
|
||||||
|
.qb-trace-operator-editor-container {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.arrow-left {
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -16px;
|
||||||
|
top: 50%;
|
||||||
|
height: 1px;
|
||||||
|
width: 16px;
|
||||||
|
background-color: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 0px 8px;
|
||||||
|
border-right: 1px solid var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.qb-trace-operator {
|
||||||
|
&-arrow {
|
||||||
|
&::before {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--bg-vanilla-300),
|
||||||
|
var(--bg-vanilla-300) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
background-color: var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.non-list-view {
|
||||||
|
&::before {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
var(--bg-vanilla-300),
|
||||||
|
var(--bg-vanilla-300) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-label-with-input {
|
||||||
|
border: 1px solid var(--bg-vanilla-300) !important;
|
||||||
|
background: var(--bg-vanilla-100) !important;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--bg-ink-500) !important;
|
||||||
|
border-right: 1px solid var(--bg-vanilla-300) !important;
|
||||||
|
background: var(--bg-vanilla-100) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
/* eslint-disable react/require-default-props */
|
||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
|
||||||
|
import './TraceOperator.styles.scss';
|
||||||
|
|
||||||
|
import { Button, Tooltip, Typography } from 'antd';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
IBuilderQuery,
|
||||||
|
IBuilderTraceOperator,
|
||||||
|
} from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import QueryAddOns from '../QueryAddOns/QueryAddOns';
|
||||||
|
import QueryAggregation from '../QueryAggregation/QueryAggregation';
|
||||||
|
import TraceOperatorEditor from './TraceOperatorEditor';
|
||||||
|
|
||||||
|
export default function TraceOperator({
|
||||||
|
traceOperator,
|
||||||
|
isListViewPanel = false,
|
||||||
|
}: {
|
||||||
|
traceOperator: IBuilderTraceOperator;
|
||||||
|
isListViewPanel?: boolean;
|
||||||
|
}): JSX.Element {
|
||||||
|
const { panelType, removeTraceOperator } = useQueryBuilder();
|
||||||
|
const { handleChangeQueryData } = useQueryOperations({
|
||||||
|
index: 0,
|
||||||
|
query: traceOperator,
|
||||||
|
entityVersion: '',
|
||||||
|
isForTraceOperator: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTraceOperatorChange = useCallback(
|
||||||
|
(traceOperatorExpression: string) => {
|
||||||
|
handleChangeQueryData('expression', traceOperatorExpression);
|
||||||
|
},
|
||||||
|
[handleChangeQueryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeAggregateEvery = useCallback(
|
||||||
|
(value: IBuilderQuery['stepInterval']) => {
|
||||||
|
handleChangeQueryData('stepInterval', value);
|
||||||
|
},
|
||||||
|
[handleChangeQueryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeAggregation = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
handleChangeQueryData('aggregations', [
|
||||||
|
{
|
||||||
|
expression: value,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[handleChangeQueryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx('qb-trace-operator', !isListViewPanel && 'non-list-view')}>
|
||||||
|
<div className="qb-trace-operator-container">
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'qb-trace-operator-label-with-input',
|
||||||
|
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Typography.Text className="label">TRACE OPERATOR</Typography.Text>
|
||||||
|
<div className="qb-trace-operator-editor-container">
|
||||||
|
<TraceOperatorEditor
|
||||||
|
value={traceOperator?.expression || ''}
|
||||||
|
traceOperator={traceOperator}
|
||||||
|
onChange={handleTraceOperatorChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isListViewPanel && (
|
||||||
|
<div className="qb-trace-operator-aggregation-container">
|
||||||
|
<div className={cx(!isListViewPanel && 'qb-trace-operator-arrow')}>
|
||||||
|
<QueryAggregation
|
||||||
|
dataSource={DataSource.TRACES}
|
||||||
|
key={`query-search-${traceOperator.queryName}`}
|
||||||
|
panelType={panelType || undefined}
|
||||||
|
onAggregationIntervalChange={handleChangeAggregateEvery}
|
||||||
|
onChange={handleChangeAggregation}
|
||||||
|
queryData={traceOperator}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'qb-trace-operator-add-ons-container',
|
||||||
|
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<QueryAddOns
|
||||||
|
index={0}
|
||||||
|
query={traceOperator}
|
||||||
|
version="v3"
|
||||||
|
isForTraceOperator
|
||||||
|
isListViewPanel={false}
|
||||||
|
showReduceTo={false}
|
||||||
|
panelType={panelType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tooltip title="Remove Trace Operator" placement="topLeft">
|
||||||
|
<Button className="periscope-btn ghost" onClick={removeTraceOperator}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,491 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
/* eslint-disable sonarjs/no-identical-functions */
|
||||||
|
|
||||||
|
import '../QuerySearch/QuerySearch.styles.scss';
|
||||||
|
|
||||||
|
import { CheckCircleFilled } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
autocompletion,
|
||||||
|
closeCompletion,
|
||||||
|
CompletionContext,
|
||||||
|
completionKeymap,
|
||||||
|
CompletionResult,
|
||||||
|
startCompletion,
|
||||||
|
} from '@codemirror/autocomplete';
|
||||||
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
|
import { Color } from '@signozhq/design-tokens';
|
||||||
|
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||||
|
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||||
|
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
|
||||||
|
import { Button, Popover } from 'antd';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import {
|
||||||
|
TRACE_OPERATOR_OPERATORS,
|
||||||
|
TRACE_OPERATOR_OPERATORS_LABELS,
|
||||||
|
TRACE_OPERATOR_OPERATORS_WITH_PRIORITY,
|
||||||
|
} from 'constants/antlrQueryConstants';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
|
import { TriangleAlert } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { IDetailedError, IValidationResult } from 'types/antlrQueryTypes';
|
||||||
|
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
import { validateTraceOperatorQuery } from 'utils/queryValidationUtils';
|
||||||
|
|
||||||
|
import { getTraceOperatorContextAtCursor } from './utils/traceOperatorContextUtils';
|
||||||
|
import { getInvolvedQueriesInTraceOperator } from './utils/utils';
|
||||||
|
|
||||||
|
// Custom extension to stop events
|
||||||
|
const stopEventsExtension = EditorView.domEventHandlers({
|
||||||
|
keydown: (event) => {
|
||||||
|
// Stop all keyboard events from propagating to global shortcuts
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
return false; // Important for CM to know you handled it
|
||||||
|
},
|
||||||
|
input: (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
focus: (event) => {
|
||||||
|
// Ensure focus events don't interfere with global shortcuts
|
||||||
|
event.stopPropagation();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
blur: (event) => {
|
||||||
|
// Ensure blur events don't interfere with global shortcuts
|
||||||
|
event.stopPropagation();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TraceOperatorEditorProps {
|
||||||
|
value: string;
|
||||||
|
traceOperator: IBuilderTraceOperator;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
onRun?: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceOperatorEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
traceOperator,
|
||||||
|
placeholder = 'Enter your trace operator query',
|
||||||
|
onRun,
|
||||||
|
}: TraceOperatorEditorProps): JSX.Element {
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
|
||||||
|
const editorRef = useRef<EditorView | null>(null);
|
||||||
|
const [validation, setValidation] = useState<IValidationResult>({
|
||||||
|
isValid: false,
|
||||||
|
message: '',
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
// Track if the query was changed externally (from props) vs internally (user input)
|
||||||
|
const [isExternalQueryChange, setIsExternalQueryChange] = useState(false);
|
||||||
|
const [lastExternalValue, setLastExternalValue] = useState<string>('');
|
||||||
|
const { currentQuery, handleRunQuery } = useQueryBuilder();
|
||||||
|
|
||||||
|
const queryOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
currentQuery.builder.queryData
|
||||||
|
.filter((query) => query.dataSource === DataSource.TRACES) // Only show trace queries
|
||||||
|
.map((query) => ({
|
||||||
|
label: query.queryName,
|
||||||
|
type: 'atom',
|
||||||
|
apply: query.queryName,
|
||||||
|
})),
|
||||||
|
[currentQuery.builder.queryData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSuggestions = useCallback(
|
||||||
|
(timeout?: number) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
if (isFocused) {
|
||||||
|
startCompletion(editorRef.current);
|
||||||
|
} else {
|
||||||
|
closeCompletion(editorRef.current);
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
return (): void => clearTimeout(timeoutId);
|
||||||
|
},
|
||||||
|
[isFocused],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleQueryValidation = (newQuery: string): void => {
|
||||||
|
try {
|
||||||
|
const validationResponse = validateTraceOperatorQuery(newQuery);
|
||||||
|
setValidation(validationResponse);
|
||||||
|
} catch (error) {
|
||||||
|
setValidation({
|
||||||
|
isValid: false,
|
||||||
|
message: 'Failed to process trace operator',
|
||||||
|
errors: [error as IDetailedError],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect external value changes and mark for validation
|
||||||
|
useEffect(() => {
|
||||||
|
const newValue = value || '';
|
||||||
|
if (newValue !== lastExternalValue) {
|
||||||
|
setIsExternalQueryChange(true);
|
||||||
|
setLastExternalValue(newValue);
|
||||||
|
}
|
||||||
|
}, [value, lastExternalValue]);
|
||||||
|
|
||||||
|
// Validate when the value changes externally (including on mount)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExternalQueryChange && value) {
|
||||||
|
handleQueryValidation(value);
|
||||||
|
setIsExternalQueryChange(false);
|
||||||
|
}
|
||||||
|
}, [isExternalQueryChange, value]);
|
||||||
|
|
||||||
|
// Enhanced autosuggestion function with context awareness
|
||||||
|
function autoSuggestions(context: CompletionContext): CompletionResult | null {
|
||||||
|
// This matches words before the cursor position
|
||||||
|
// eslint-disable-next-line no-useless-escape
|
||||||
|
const word = context.matchBefore(/[a-zA-Z0-9_.:/?&=#%\-\[\]]*/);
|
||||||
|
if (word?.from === word?.to && !context.explicit) return null;
|
||||||
|
|
||||||
|
// Get the trace operator context at the cursor position
|
||||||
|
const queryContext = getTraceOperatorContextAtCursor(value, cursorPos.ch);
|
||||||
|
|
||||||
|
// Define autocomplete options based on the context
|
||||||
|
let options: {
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
info?: string;
|
||||||
|
apply:
|
||||||
|
| string
|
||||||
|
| ((view: EditorView, completion: any, from: number, to: number) => void);
|
||||||
|
detail?: string;
|
||||||
|
boost?: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// Helper function to add space after selection
|
||||||
|
const addSpaceAfterSelection = (
|
||||||
|
view: EditorView,
|
||||||
|
completion: any,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
shouldAddSpace = true,
|
||||||
|
): void => {
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
insert: shouldAddSpace ? `${completion.apply} ` : `${completion.apply}`,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
anchor:
|
||||||
|
from +
|
||||||
|
(shouldAddSpace ? completion.apply.length + 1 : completion.apply.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Do not reopen here; onUpdate will handle reopening via toggleSuggestions
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to add space after selection to options
|
||||||
|
const addSpaceToOptions = (opts: typeof options): typeof options =>
|
||||||
|
opts.map((option) => {
|
||||||
|
const originalApply = option.apply || option.label;
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
apply: (
|
||||||
|
view: EditorView,
|
||||||
|
completion: any,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
): void => {
|
||||||
|
addSpaceAfterSelection(view, { apply: originalApply }, from, to);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (queryContext.isInAtom) {
|
||||||
|
// Suggest atoms (identifiers) for trace operators
|
||||||
|
|
||||||
|
const involvedQueries = getInvolvedQueriesInTraceOperator([traceOperator]);
|
||||||
|
|
||||||
|
options = queryOptions.map((option) => ({
|
||||||
|
...option,
|
||||||
|
boost: !involvedQueries.includes(option.apply as string) ? 100 : -99,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filter options based on what user is typing
|
||||||
|
const searchText = word?.text.toLowerCase().trim() ?? '';
|
||||||
|
options = options.filter((option) =>
|
||||||
|
option.label.toLowerCase().includes(searchText),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add space after selection for atoms
|
||||||
|
const optionsWithSpace = addSpaceToOptions(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: word?.from ?? 0,
|
||||||
|
to: word?.to ?? cursorPos.ch,
|
||||||
|
options: optionsWithSpace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryContext.isInOperator) {
|
||||||
|
// Suggest operators for trace operators
|
||||||
|
const operators = Object.values(TRACE_OPERATOR_OPERATORS);
|
||||||
|
options = operators.map((operator) => ({
|
||||||
|
label: TRACE_OPERATOR_OPERATORS_LABELS[operator]
|
||||||
|
? `${operator} (${TRACE_OPERATOR_OPERATORS_LABELS[operator]})`
|
||||||
|
: operator,
|
||||||
|
type: 'operator',
|
||||||
|
apply: operator,
|
||||||
|
boost: TRACE_OPERATOR_OPERATORS_WITH_PRIORITY[operator] * -10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add space after selection for operators
|
||||||
|
const optionsWithSpace = addSpaceToOptions(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: word?.from ?? 0,
|
||||||
|
to: word?.to ?? cursorPos.ch,
|
||||||
|
options: optionsWithSpace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryContext.isInParenthesis) {
|
||||||
|
// Different suggestions based on the context within parenthesis
|
||||||
|
const curChar = value.charAt(cursorPos.ch - 1) || '';
|
||||||
|
|
||||||
|
if (curChar === '(') {
|
||||||
|
// Right after opening parenthesis, suggest atoms or nested expressions
|
||||||
|
options = [
|
||||||
|
{ label: '(', type: 'parenthesis', apply: '(' },
|
||||||
|
...queryOptions,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add space after selection for opening parenthesis context
|
||||||
|
const optionsWithSpace = addSpaceToOptions(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: word?.from ?? 0,
|
||||||
|
options: optionsWithSpace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curChar === ')') {
|
||||||
|
// After closing parenthesis, suggest operators
|
||||||
|
const operators = Object.values(TRACE_OPERATOR_OPERATORS);
|
||||||
|
options = operators.map((operator) => ({
|
||||||
|
label: TRACE_OPERATOR_OPERATORS_LABELS[operator]
|
||||||
|
? `${operator} (${TRACE_OPERATOR_OPERATORS_LABELS[operator]})`
|
||||||
|
: operator,
|
||||||
|
type: 'operator',
|
||||||
|
apply: operator,
|
||||||
|
boost: TRACE_OPERATOR_OPERATORS_WITH_PRIORITY[operator] * -10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add space after selection for closing parenthesis context
|
||||||
|
const optionsWithSpace = addSpaceToOptions(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: word?.from ?? 0,
|
||||||
|
options: optionsWithSpace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: suggest atoms if no specific context
|
||||||
|
options = [
|
||||||
|
...queryOptions,
|
||||||
|
{
|
||||||
|
label: '(',
|
||||||
|
type: 'parenthesis',
|
||||||
|
apply: '(',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter options based on what user is typing
|
||||||
|
const searchText = word?.text.toLowerCase().trim() ?? '';
|
||||||
|
options = options.filter((option) =>
|
||||||
|
option.label.toLowerCase().includes(searchText),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add space after selection
|
||||||
|
const optionsWithSpace = addSpaceToOptions(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: word?.from ?? 0,
|
||||||
|
to: word?.to ?? context.pos,
|
||||||
|
options: optionsWithSpace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(viewUpdate: { view: EditorView }): void => {
|
||||||
|
if (!editorRef.current) {
|
||||||
|
editorRef.current = viewUpdate.view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = viewUpdate.view.state.selection.main;
|
||||||
|
const pos = selection.head;
|
||||||
|
|
||||||
|
const lineInfo = viewUpdate.view.state.doc.lineAt(pos);
|
||||||
|
const newPos = {
|
||||||
|
line: lineInfo.number,
|
||||||
|
ch: pos - lineInfo.from,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newPos.line !== cursorPos.line || newPos.ch !== cursorPos.ch) {
|
||||||
|
setCursorPos(newPos);
|
||||||
|
// Trigger suggestions on context update
|
||||||
|
toggleSuggestions(10);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cursorPos, toggleSuggestions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (newValue: string): void => {
|
||||||
|
// Mark as internal change to avoid triggering external validation
|
||||||
|
setIsExternalQueryChange(false);
|
||||||
|
setLastExternalValue(newValue);
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (): void => {
|
||||||
|
handleQueryValidation(value);
|
||||||
|
setIsFocused(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effect to handle focus state and trigger suggestions on focus
|
||||||
|
useEffect(() => {
|
||||||
|
const clearTimeout = toggleSuggestions(10);
|
||||||
|
return (): void => clearTimeout();
|
||||||
|
}, [isFocused, toggleSuggestions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="code-mirror-where-clause">
|
||||||
|
<div className="query-where-clause-editor-container">
|
||||||
|
<CodeMirror
|
||||||
|
value={value}
|
||||||
|
theme={isDarkMode ? copilot : githubLight}
|
||||||
|
onChange={handleChange}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
className={cx('query-where-clause-editor', {
|
||||||
|
isValid: validation.isValid === true,
|
||||||
|
hasErrors: validation.errors.length > 0,
|
||||||
|
})}
|
||||||
|
extensions={[
|
||||||
|
autocompletion({
|
||||||
|
override: [autoSuggestions],
|
||||||
|
defaultKeymap: true,
|
||||||
|
closeOnBlur: true,
|
||||||
|
activateOnTyping: true,
|
||||||
|
maxRenderedOptions: 50,
|
||||||
|
}),
|
||||||
|
javascript({ jsx: false, typescript: false }),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
stopEventsExtension,
|
||||||
|
Prec.highest(
|
||||||
|
keymap.of([
|
||||||
|
...completionKeymap,
|
||||||
|
{
|
||||||
|
key: 'Escape',
|
||||||
|
run: closeCompletion,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Enter',
|
||||||
|
preventDefault: true,
|
||||||
|
// Prevent default behavior of Enter to add new line
|
||||||
|
// and instead run a custom action
|
||||||
|
run: (): boolean => true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Mod-Enter',
|
||||||
|
preventDefault: true,
|
||||||
|
run: (): boolean => {
|
||||||
|
if (onRun && typeof onRun === 'function') {
|
||||||
|
onRun(value);
|
||||||
|
} else {
|
||||||
|
handleRunQuery();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Shift-Enter',
|
||||||
|
preventDefault: true,
|
||||||
|
// Prevent default behavior of Shift-Enter to add new line
|
||||||
|
run: (): boolean => true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: false,
|
||||||
|
}}
|
||||||
|
onFocus={(): void => {
|
||||||
|
setIsFocused(true);
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
{value && validation.isValid === false && !isFocused && (
|
||||||
|
<div
|
||||||
|
className={cx('query-status-container', {
|
||||||
|
hasErrors: validation.errors.length > 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Popover
|
||||||
|
placement="bottomRight"
|
||||||
|
showArrow={false}
|
||||||
|
content={
|
||||||
|
<div className="query-status-content">
|
||||||
|
<div className="query-status-content-header">
|
||||||
|
<div className="query-validation">
|
||||||
|
<div className="query-validation-errors">
|
||||||
|
{validation.errors.map((error) => (
|
||||||
|
<div key={error.message} className="query-validation-error">
|
||||||
|
<div className="query-validation-error">
|
||||||
|
{error.line}:{error.column} - {error.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
overlayClassName="query-status-popover"
|
||||||
|
>
|
||||||
|
{validation.isValid ? (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<CheckCircleFilled />}
|
||||||
|
className="periscope-btn ghost"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
|
||||||
|
className="periscope-btn ghost"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TraceOperatorEditor.defaultProps = {
|
||||||
|
onRun: undefined,
|
||||||
|
placeholder: 'Enter your trace operator query',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TraceOperatorEditor;
|
||||||
@ -0,0 +1,425 @@
|
|||||||
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
|
||||||
|
import { Token } from 'antlr4';
|
||||||
|
import TraceOperatorGrammarLexer from 'parser/TraceOperatorParser/TraceOperatorGrammarLexer';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTraceOperatorContext,
|
||||||
|
extractTraceExpressionPairs,
|
||||||
|
getTraceOperatorContextAtCursor,
|
||||||
|
} from '../utils/traceOperatorContextUtils';
|
||||||
|
|
||||||
|
describe('traceOperatorContextUtils', () => {
|
||||||
|
describe('createTraceOperatorContext', () => {
|
||||||
|
it('should create a context object with all required properties', () => {
|
||||||
|
const mockToken = {
|
||||||
|
type: TraceOperatorGrammarLexer.IDENTIFIER,
|
||||||
|
text: 'test',
|
||||||
|
start: 0,
|
||||||
|
stop: 3,
|
||||||
|
} as Token;
|
||||||
|
|
||||||
|
const context = createTraceOperatorContext(
|
||||||
|
mockToken,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
'atom',
|
||||||
|
'operator',
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(context).toEqual({
|
||||||
|
tokenType: TraceOperatorGrammarLexer.IDENTIFIER,
|
||||||
|
text: 'test',
|
||||||
|
start: 0,
|
||||||
|
stop: 3,
|
||||||
|
currentToken: 'test',
|
||||||
|
isInAtom: true,
|
||||||
|
isInOperator: false,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
atomToken: 'atom',
|
||||||
|
operatorToken: 'operator',
|
||||||
|
expressionPairs: [],
|
||||||
|
currentPair: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a context object with default values', () => {
|
||||||
|
const mockToken = {
|
||||||
|
type: TraceOperatorGrammarLexer.IDENTIFIER,
|
||||||
|
text: 'test',
|
||||||
|
start: 0,
|
||||||
|
stop: 3,
|
||||||
|
} as Token;
|
||||||
|
|
||||||
|
const context = createTraceOperatorContext(
|
||||||
|
mockToken,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(context).toEqual({
|
||||||
|
tokenType: TraceOperatorGrammarLexer.IDENTIFIER,
|
||||||
|
text: 'test',
|
||||||
|
start: 0,
|
||||||
|
stop: 3,
|
||||||
|
currentToken: 'test',
|
||||||
|
isInAtom: false,
|
||||||
|
isInOperator: true,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
atomToken: undefined,
|
||||||
|
operatorToken: undefined,
|
||||||
|
expressionPairs: [],
|
||||||
|
currentPair: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractTraceExpressionPairs', () => {
|
||||||
|
it('should extract simple expression pair', () => {
|
||||||
|
const query = 'A => B';
|
||||||
|
const result = extractTraceExpressionPairs(query);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].leftAtom).toBe('A');
|
||||||
|
expect(result[0].position.leftStart).toBe(0);
|
||||||
|
expect(result[0].position.leftEnd).toBe(0);
|
||||||
|
expect(result[0].operator).toBe('=>');
|
||||||
|
expect(result[0].position.operatorStart).toBe(2);
|
||||||
|
expect(result[0].position.operatorEnd).toBe(3);
|
||||||
|
expect(result[0].rightAtom).toBe('B');
|
||||||
|
expect(result[0].position.rightStart).toBe(5);
|
||||||
|
expect(result[0].position.rightEnd).toBe(5);
|
||||||
|
expect(result[0].isComplete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract multiple expression pairs', () => {
|
||||||
|
const query = 'A => B && C => D';
|
||||||
|
const result = extractTraceExpressionPairs(query);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
|
||||||
|
// First pair: A => B
|
||||||
|
expect(result[0].leftAtom).toBe('A');
|
||||||
|
expect(result[0].operator).toBe('=>');
|
||||||
|
expect(result[0].rightAtom).toBe('B');
|
||||||
|
|
||||||
|
// Second pair: C => D
|
||||||
|
expect(result[1].leftAtom).toBe('C');
|
||||||
|
expect(result[1].operator).toBe('=>');
|
||||||
|
expect(result[1].rightAtom).toBe('D');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NOT operator', () => {
|
||||||
|
const query = 'NOT A => B';
|
||||||
|
const result = extractTraceExpressionPairs(query);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].leftAtom).toBe('A');
|
||||||
|
expect(result[0].operator).toBe('=>');
|
||||||
|
expect(result[0].rightAtom).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle parentheses', () => {
|
||||||
|
const query = '(A => B) && (C => D)';
|
||||||
|
const result = extractTraceExpressionPairs(query);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].leftAtom).toBe('A');
|
||||||
|
expect(result[0].rightAtom).toBe('B');
|
||||||
|
expect(result[1].leftAtom).toBe('C');
|
||||||
|
expect(result[1].rightAtom).toBe('D');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle incomplete expressions', () => {
|
||||||
|
const query = 'A =>';
|
||||||
|
const result = extractTraceExpressionPairs(query);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].leftAtom).toBe('A');
|
||||||
|
expect(result[0].operator).toBe('=>');
|
||||||
|
expect(result[0].rightAtom).toBeUndefined();
|
||||||
|
expect(result[0].isComplete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex nested expressions', () => {
|
||||||
|
const query = 'A => B && (C => D || E => F)';
|
||||||
|
const result = extractTraceExpressionPairs(query);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].leftAtom).toBe('A');
|
||||||
|
expect(result[0].rightAtom).toBe('B');
|
||||||
|
expect(result[1].leftAtom).toBe('C');
|
||||||
|
expect(result[1].rightAtom).toBe('D');
|
||||||
|
expect(result[2].leftAtom).toBe('E');
|
||||||
|
expect(result[2].rightAtom).toBe('F');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle whitespace variations', () => {
|
||||||
|
const query = 'A=>B';
|
||||||
|
const result = extractTraceExpressionPairs(query);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].leftAtom).toBe('A');
|
||||||
|
expect(result[0].operator).toBe('=>');
|
||||||
|
expect(result[0].rightAtom).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error cases gracefully', () => {
|
||||||
|
const query = 'invalid syntax @#$%';
|
||||||
|
const result = extractTraceExpressionPairs(query);
|
||||||
|
|
||||||
|
// Should return an array (even if empty or with partial results)
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTraceOperatorContextAtCursor', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset console.error mock
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default context for empty query', () => {
|
||||||
|
const result = getTraceOperatorContextAtCursor('', 0);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
tokenType: -1,
|
||||||
|
text: '',
|
||||||
|
start: 0,
|
||||||
|
stop: 0,
|
||||||
|
currentToken: '',
|
||||||
|
isInAtom: true,
|
||||||
|
isInOperator: false,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
expressionPairs: [],
|
||||||
|
currentPair: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default context for null query', () => {
|
||||||
|
const result = getTraceOperatorContextAtCursor(null as any, 0);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
tokenType: -1,
|
||||||
|
text: '',
|
||||||
|
start: 0,
|
||||||
|
stop: 0,
|
||||||
|
currentToken: '',
|
||||||
|
isInAtom: true,
|
||||||
|
isInOperator: false,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
expressionPairs: [],
|
||||||
|
currentPair: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default context for undefined query', () => {
|
||||||
|
const result = getTraceOperatorContextAtCursor(undefined as any, 0);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
tokenType: -1,
|
||||||
|
text: '',
|
||||||
|
start: 0,
|
||||||
|
stop: 0,
|
||||||
|
currentToken: '',
|
||||||
|
isInAtom: true,
|
||||||
|
isInOperator: false,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
expressionPairs: [],
|
||||||
|
currentPair: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify atom context', () => {
|
||||||
|
const query = 'A => B';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 0); // cursor at 'A'
|
||||||
|
|
||||||
|
expect(result.atomToken).toBe('A');
|
||||||
|
expect(result.operatorToken).toBe('=>');
|
||||||
|
expect(result.isInAtom).toBe(true);
|
||||||
|
expect(result.isInOperator).toBe(false);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
expect(result.start).toBe(0);
|
||||||
|
expect(result.stop).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify operator context', () => {
|
||||||
|
const query = 'A => B';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 2); // cursor at '='
|
||||||
|
|
||||||
|
expect(result.atomToken).toBe('A');
|
||||||
|
expect(result.operatorToken).toBeUndefined();
|
||||||
|
expect(result.isInAtom).toBe(false);
|
||||||
|
expect(result.isInOperator).toBe(true);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
expect(result.start).toBe(2);
|
||||||
|
expect(result.stop).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify parenthesis context', () => {
|
||||||
|
const query = '(A => B)';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 0); // cursor at '('
|
||||||
|
|
||||||
|
expect(result.atomToken).toBeUndefined();
|
||||||
|
expect(result.operatorToken).toBeUndefined();
|
||||||
|
expect(result.isInAtom).toBe(false);
|
||||||
|
expect(result.isInOperator).toBe(false);
|
||||||
|
expect(result.isInParenthesis).toBe(true);
|
||||||
|
expect(result.start).toBe(0);
|
||||||
|
expect(result.stop).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cursor at space', () => {
|
||||||
|
const query = 'A => B';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 1); // cursor at space
|
||||||
|
|
||||||
|
expect(result.atomToken).toBe('A');
|
||||||
|
expect(result.operatorToken).toBeUndefined();
|
||||||
|
expect(result.isInAtom).toBe(false);
|
||||||
|
expect(result.isInOperator).toBe(true);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cursor at end of query', () => {
|
||||||
|
const query = 'A => B';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 5); // cursor at end
|
||||||
|
|
||||||
|
expect(result.atomToken).toBe('A');
|
||||||
|
expect(result.operatorToken).toBe('=>');
|
||||||
|
expect(result.isInAtom).toBe(true);
|
||||||
|
expect(result.isInOperator).toBe(false);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
expect(result.start).toBe(5);
|
||||||
|
expect(result.stop).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex query', () => {
|
||||||
|
const query = 'A => B && C => D';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 8); // cursor at '&'
|
||||||
|
|
||||||
|
expect(result.atomToken).toBeUndefined();
|
||||||
|
expect(result.operatorToken).toBe('&&');
|
||||||
|
expect(result.isInAtom).toBe(false);
|
||||||
|
expect(result.isInOperator).toBe(true);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
expect(result.start).toBe(7);
|
||||||
|
expect(result.stop).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify operator position in complex query', () => {
|
||||||
|
const query = 'A => B && C => D';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 10); // cursor at 'C'
|
||||||
|
|
||||||
|
expect(result.atomToken).toBe('C');
|
||||||
|
expect(result.operatorToken).toBe('&&');
|
||||||
|
expect(result.isInAtom).toBe(true);
|
||||||
|
expect(result.isInOperator).toBe(false);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
expect(result.start).toBe(10);
|
||||||
|
expect(result.stop).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify atom position in complex query', () => {
|
||||||
|
const query = 'A => B && C => D';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 13); // cursor at '>'
|
||||||
|
|
||||||
|
expect(result.atomToken).toBe('C');
|
||||||
|
expect(result.operatorToken).toBe('=>');
|
||||||
|
expect(result.isInAtom).toBe(false);
|
||||||
|
expect(result.isInOperator).toBe(true);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
expect(result.start).toBe(12);
|
||||||
|
expect(result.stop).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle transition points', () => {
|
||||||
|
const query = 'A => B';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 4); // cursor at 'B'
|
||||||
|
|
||||||
|
expect(result.atomToken).toBe('A');
|
||||||
|
expect(result.operatorToken).toBe('=>');
|
||||||
|
expect(result.isInAtom).toBe(true);
|
||||||
|
expect(result.isInOperator).toBe(false);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
expect(result.start).toBe(4);
|
||||||
|
expect(result.stop).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle whitespace in complex queries', () => {
|
||||||
|
const query = 'A=>B && C=>D';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 6); // cursor at '&'
|
||||||
|
|
||||||
|
expect(result.atomToken).toBeUndefined();
|
||||||
|
expect(result.operatorToken).toBe('&&');
|
||||||
|
expect(result.isInAtom).toBe(false);
|
||||||
|
expect(result.isInOperator).toBe(true);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
expect(result.start).toBe(5);
|
||||||
|
expect(result.stop).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NOT operator context', () => {
|
||||||
|
const query = 'NOT A => B';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 0); // cursor at 'N'
|
||||||
|
|
||||||
|
expect(result.atomToken).toBeUndefined();
|
||||||
|
expect(result.operatorToken).toBeUndefined();
|
||||||
|
expect(result.isInAtom).toBe(false);
|
||||||
|
expect(result.isInOperator).toBe(false);
|
||||||
|
expect(result.isInParenthesis).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle parentheses context', () => {
|
||||||
|
const query = '(A => B)';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 1); // cursor at 'A'
|
||||||
|
|
||||||
|
expect(result.atomToken).toBe('A');
|
||||||
|
expect(result.operatorToken).toBe('=>');
|
||||||
|
expect(result.isInAtom).toBe(false);
|
||||||
|
expect(result.isInOperator).toBe(false);
|
||||||
|
expect(result.isInParenthesis).toBe(true);
|
||||||
|
expect(result.start).toBe(0);
|
||||||
|
expect(result.stop).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle expression pairs context', () => {
|
||||||
|
const query = 'A => B && C => D';
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, 5); // cursor at 'A' in "&&"
|
||||||
|
|
||||||
|
expect(result.atomToken).toBe('A');
|
||||||
|
expect(result.operatorToken).toBe('=>');
|
||||||
|
expect(result.isInAtom).toBe(true);
|
||||||
|
expect(result.isInOperator).toBe(false);
|
||||||
|
expect(result.isInParenthesis).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle various cursor positions', () => {
|
||||||
|
const query = 'A => B';
|
||||||
|
|
||||||
|
// Test cursor at each position
|
||||||
|
for (let i = 0; i < query.length; i++) {
|
||||||
|
const result = getTraceOperatorContextAtCursor(query, i);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(typeof result.start).toBe('number');
|
||||||
|
expect(typeof result.stop).toBe('number');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
import { getInvolvedQueriesInTraceOperator } from '../utils/utils';
|
||||||
|
|
||||||
|
const makeTraceOperator = (expression: string): IBuilderTraceOperator =>
|
||||||
|
(({ expression } as unknown) as IBuilderTraceOperator);
|
||||||
|
|
||||||
|
describe('getInvolvedQueriesInTraceOperator', () => {
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
const result = getInvolvedQueriesInTraceOperator([]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts identifiers from expression', () => {
|
||||||
|
const result = getInvolvedQueriesInTraceOperator([
|
||||||
|
makeTraceOperator('A => B'),
|
||||||
|
]);
|
||||||
|
expect(result).toEqual(['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts identifiers from complex expression', () => {
|
||||||
|
const result = getInvolvedQueriesInTraceOperator([
|
||||||
|
makeTraceOperator('A => (NOT B || C)'),
|
||||||
|
]);
|
||||||
|
expect(result).toEqual(['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out querynames from complex expression', () => {
|
||||||
|
const result = getInvolvedQueriesInTraceOperator([
|
||||||
|
makeTraceOperator(
|
||||||
|
'(A1 && (NOT B2 || (C3 -> (D4 && E5)))) => ((F6 || G7) && (NOT (H8 -> I9)))',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
expect(result).toEqual([
|
||||||
|
'A1',
|
||||||
|
'B2',
|
||||||
|
'C3',
|
||||||
|
'D4',
|
||||||
|
'E5',
|
||||||
|
'F6',
|
||||||
|
'G7',
|
||||||
|
'H8',
|
||||||
|
'I9',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,562 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
/* eslint-disable no-continue */
|
||||||
|
|
||||||
|
import { CharStreams, CommonTokenStream, Token } from 'antlr4';
|
||||||
|
import TraceOperatorGrammarLexer from 'parser/TraceOperatorParser/TraceOperatorGrammarLexer';
|
||||||
|
import { IToken } from 'types/antlrQueryTypes';
|
||||||
|
|
||||||
|
// Trace Operator Context Interface
|
||||||
|
export interface ITraceOperatorContext {
|
||||||
|
tokenType: number;
|
||||||
|
text: string;
|
||||||
|
start: number;
|
||||||
|
stop: number;
|
||||||
|
currentToken: string;
|
||||||
|
isInAtom: boolean;
|
||||||
|
isInOperator: boolean;
|
||||||
|
isInParenthesis: boolean;
|
||||||
|
isInExpression: boolean;
|
||||||
|
atomToken?: string;
|
||||||
|
operatorToken?: string;
|
||||||
|
expressionPairs: ITraceExpressionPair[];
|
||||||
|
currentPair?: ITraceExpressionPair | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace Expression Pair Interface
|
||||||
|
export interface ITraceExpressionPair {
|
||||||
|
leftAtom: string;
|
||||||
|
operator: string;
|
||||||
|
rightAtom?: string;
|
||||||
|
rightExpression?: string;
|
||||||
|
position: {
|
||||||
|
leftStart: number;
|
||||||
|
leftEnd: number;
|
||||||
|
operatorStart: number;
|
||||||
|
operatorEnd: number;
|
||||||
|
rightStart?: number;
|
||||||
|
rightEnd?: number;
|
||||||
|
};
|
||||||
|
isComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions to determine token types
|
||||||
|
function isAtomToken(tokenType: number): boolean {
|
||||||
|
return tokenType === TraceOperatorGrammarLexer.IDENTIFIER;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOperatorToken(tokenType: number): boolean {
|
||||||
|
return [
|
||||||
|
TraceOperatorGrammarLexer.T__2, // '=>'
|
||||||
|
TraceOperatorGrammarLexer.T__3, // '&&'
|
||||||
|
TraceOperatorGrammarLexer.T__4, // '||'
|
||||||
|
TraceOperatorGrammarLexer.T__5, // 'NOT'
|
||||||
|
TraceOperatorGrammarLexer.T__6, // '->'
|
||||||
|
].includes(tokenType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParenthesisToken(tokenType: number): boolean {
|
||||||
|
return (
|
||||||
|
tokenType === TraceOperatorGrammarLexer.T__0 ||
|
||||||
|
tokenType === TraceOperatorGrammarLexer.T__1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpeningParenthesis(tokenType: number): boolean {
|
||||||
|
return tokenType === TraceOperatorGrammarLexer.T__0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClosingParenthesis(tokenType: number): boolean {
|
||||||
|
return tokenType === TraceOperatorGrammarLexer.T__1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to create a context object
|
||||||
|
export function createTraceOperatorContext(
|
||||||
|
token: Token,
|
||||||
|
isInAtom: boolean,
|
||||||
|
isInOperator: boolean,
|
||||||
|
isInParenthesis: boolean,
|
||||||
|
isInExpression: boolean,
|
||||||
|
atomToken?: string,
|
||||||
|
operatorToken?: string,
|
||||||
|
expressionPairs?: ITraceExpressionPair[],
|
||||||
|
currentPair?: ITraceExpressionPair | null,
|
||||||
|
): ITraceOperatorContext {
|
||||||
|
return {
|
||||||
|
tokenType: token.type,
|
||||||
|
text: token.text || '',
|
||||||
|
start: token.start,
|
||||||
|
stop: token.stop,
|
||||||
|
currentToken: token.text || '',
|
||||||
|
isInAtom,
|
||||||
|
isInOperator,
|
||||||
|
isInParenthesis,
|
||||||
|
isInExpression,
|
||||||
|
atomToken,
|
||||||
|
operatorToken,
|
||||||
|
expressionPairs: expressionPairs || [],
|
||||||
|
currentPair,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to determine token context
|
||||||
|
function determineTraceTokenContext(
|
||||||
|
token: IToken,
|
||||||
|
): {
|
||||||
|
isInAtom: boolean;
|
||||||
|
isInOperator: boolean;
|
||||||
|
isInParenthesis: boolean;
|
||||||
|
isInExpression: boolean;
|
||||||
|
} {
|
||||||
|
const tokenType = token.type;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isInAtom: isAtomToken(tokenType),
|
||||||
|
isInOperator: isOperatorToken(tokenType),
|
||||||
|
isInParenthesis: isParenthesisToken(tokenType),
|
||||||
|
isInExpression: false, // Will be determined by broader context
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all expression pairs from a trace operator query string
|
||||||
|
* This parses the query according to the TraceOperatorGrammar.g4 grammar
|
||||||
|
*
|
||||||
|
* @param query The trace operator query string to parse
|
||||||
|
* @returns An array of ITraceExpressionPair objects representing the expression pairs
|
||||||
|
*/
|
||||||
|
export function extractTraceExpressionPairs(
|
||||||
|
query: string,
|
||||||
|
): ITraceExpressionPair[] {
|
||||||
|
try {
|
||||||
|
const input = query || '';
|
||||||
|
const chars = CharStreams.fromString(input);
|
||||||
|
const lexer = new TraceOperatorGrammarLexer(chars);
|
||||||
|
|
||||||
|
const tokenStream = new CommonTokenStream(lexer);
|
||||||
|
tokenStream.fill();
|
||||||
|
|
||||||
|
const allTokens = tokenStream.tokens as IToken[];
|
||||||
|
const expressionPairs: ITraceExpressionPair[] = [];
|
||||||
|
let currentPair: Partial<ITraceExpressionPair> | null = null;
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < allTokens.length) {
|
||||||
|
const token = allTokens[i];
|
||||||
|
i++;
|
||||||
|
|
||||||
|
// Skip EOF and whitespace tokens
|
||||||
|
if (token.type === TraceOperatorGrammarLexer.EOF || token.channel !== 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token is an IDENTIFIER (atom), start or continue a pair
|
||||||
|
if (isAtomToken(token.type)) {
|
||||||
|
// If we don't have a current pair, start one
|
||||||
|
if (!currentPair) {
|
||||||
|
currentPair = {
|
||||||
|
leftAtom: token.text,
|
||||||
|
position: {
|
||||||
|
leftStart: token.start,
|
||||||
|
leftEnd: token.stop,
|
||||||
|
operatorStart: 0,
|
||||||
|
operatorEnd: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// If we have a current pair but no operator yet, this is still the left atom
|
||||||
|
else if (!currentPair.operator && currentPair.position) {
|
||||||
|
currentPair.leftAtom = token.text;
|
||||||
|
currentPair.position.leftStart = token.start;
|
||||||
|
currentPair.position.leftEnd = token.stop;
|
||||||
|
}
|
||||||
|
// If we have an operator, this is the right atom
|
||||||
|
else if (
|
||||||
|
currentPair.operator &&
|
||||||
|
!currentPair.rightAtom &&
|
||||||
|
currentPair.position
|
||||||
|
) {
|
||||||
|
currentPair.rightAtom = token.text;
|
||||||
|
currentPair.position.rightStart = token.start;
|
||||||
|
currentPair.position.rightEnd = token.stop;
|
||||||
|
currentPair.isComplete = true;
|
||||||
|
|
||||||
|
// Add the completed pair to the result
|
||||||
|
expressionPairs.push(currentPair as ITraceExpressionPair);
|
||||||
|
currentPair = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If token is an operator and we have a left atom
|
||||||
|
else if (
|
||||||
|
isOperatorToken(token.type) &&
|
||||||
|
currentPair &&
|
||||||
|
currentPair.leftAtom &&
|
||||||
|
currentPair.position
|
||||||
|
) {
|
||||||
|
currentPair.operator = token.text;
|
||||||
|
currentPair.position.operatorStart = token.start;
|
||||||
|
currentPair.position.operatorEnd = token.stop;
|
||||||
|
|
||||||
|
// If this is a NOT operator, it might be followed by another operator
|
||||||
|
if (token.type === TraceOperatorGrammarLexer.T__5 && i < allTokens.length) {
|
||||||
|
// Look ahead for the next operator
|
||||||
|
const nextToken = allTokens[i];
|
||||||
|
if (isOperatorToken(nextToken.type) && nextToken.channel === 0) {
|
||||||
|
currentPair.operator = `${token.text} ${nextToken.text}`;
|
||||||
|
currentPair.position.operatorEnd = nextToken.stop;
|
||||||
|
i++; // Skip the next token since we've consumed it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If token is an opening parenthesis after an operator, this is a right expression
|
||||||
|
else if (
|
||||||
|
isOpeningParenthesis(token.type) &&
|
||||||
|
currentPair &&
|
||||||
|
currentPair.operator &&
|
||||||
|
!currentPair.rightAtom &&
|
||||||
|
currentPair.position
|
||||||
|
) {
|
||||||
|
// Find the matching closing parenthesis
|
||||||
|
let parenCount = 1;
|
||||||
|
let j = i;
|
||||||
|
let rightExpression = '';
|
||||||
|
const rightStart = token.start;
|
||||||
|
let rightEnd = token.stop;
|
||||||
|
|
||||||
|
while (j < allTokens.length && parenCount > 0) {
|
||||||
|
const parenToken = allTokens[j];
|
||||||
|
if (parenToken.channel === 0) {
|
||||||
|
if (isOpeningParenthesis(parenToken.type)) {
|
||||||
|
parenCount++;
|
||||||
|
} else if (isClosingParenthesis(parenToken.type)) {
|
||||||
|
parenCount--;
|
||||||
|
if (parenCount === 0) {
|
||||||
|
rightEnd = parenToken.stop;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rightExpression += parenToken.text;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parenCount === 0) {
|
||||||
|
currentPair.rightExpression = rightExpression;
|
||||||
|
currentPair.position.rightStart = rightStart;
|
||||||
|
currentPair.position.rightEnd = rightEnd;
|
||||||
|
currentPair.isComplete = true;
|
||||||
|
|
||||||
|
// Add the completed pair to the result
|
||||||
|
expressionPairs.push(currentPair as ITraceExpressionPair);
|
||||||
|
currentPair = null;
|
||||||
|
|
||||||
|
// Skip to the end of the expression
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining incomplete pair
|
||||||
|
if (currentPair && currentPair.leftAtom && currentPair.position) {
|
||||||
|
expressionPairs.push({
|
||||||
|
...currentPair,
|
||||||
|
isComplete: !!(currentPair.leftAtom && currentPair.operator),
|
||||||
|
} as ITraceExpressionPair);
|
||||||
|
}
|
||||||
|
|
||||||
|
return expressionPairs;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in extractTraceExpressionPairs:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current expression pair at the cursor position
|
||||||
|
*
|
||||||
|
* @param expressionPairs An array of ITraceExpressionPair objects
|
||||||
|
* @param query The full query string
|
||||||
|
* @param cursorIndex The position of the cursor in the query
|
||||||
|
* @returns The expression pair at the cursor position, or null if not found
|
||||||
|
*/
|
||||||
|
export function getCurrentTraceExpressionPair(
|
||||||
|
expressionPairs: ITraceExpressionPair[],
|
||||||
|
cursorIndex: number,
|
||||||
|
): ITraceExpressionPair | null {
|
||||||
|
try {
|
||||||
|
if (expressionPairs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the rightmost pair whose end position is before or at the cursor
|
||||||
|
let bestMatch: ITraceExpressionPair | null = null;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const pair of expressionPairs) {
|
||||||
|
const { position } = pair;
|
||||||
|
const pairEnd =
|
||||||
|
position.rightEnd || position.operatorEnd || position.leftEnd;
|
||||||
|
const pairStart = position.leftStart;
|
||||||
|
|
||||||
|
// If this pair ends at or before the cursor, and it's further right than our previous best match
|
||||||
|
if (
|
||||||
|
pairStart <= cursorIndex &&
|
||||||
|
cursorIndex <= pairEnd + 1 &&
|
||||||
|
(!bestMatch ||
|
||||||
|
pairEnd >
|
||||||
|
(bestMatch.position.rightEnd ||
|
||||||
|
bestMatch.position.operatorEnd ||
|
||||||
|
bestMatch.position.leftEnd))
|
||||||
|
) {
|
||||||
|
bestMatch = pair;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getCurrentTraceExpressionPair:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current trace operator context at the cursor position
|
||||||
|
* This is useful for determining what kind of suggestions to show
|
||||||
|
*
|
||||||
|
* @param query The trace operator query string
|
||||||
|
* @param cursorIndex The position of the cursor in the query
|
||||||
|
* @returns The trace operator context at the cursor position
|
||||||
|
*/
|
||||||
|
export function getTraceOperatorContextAtCursor(
|
||||||
|
query: string,
|
||||||
|
cursorIndex: number,
|
||||||
|
): ITraceOperatorContext {
|
||||||
|
try {
|
||||||
|
// Guard against infinite recursion
|
||||||
|
const stackTrace = new Error().stack || '';
|
||||||
|
const callCount = (stackTrace.match(/getTraceOperatorContextAtCursor/g) || [])
|
||||||
|
.length;
|
||||||
|
if (callCount > 3) {
|
||||||
|
console.warn(
|
||||||
|
'Potential infinite recursion detected in getTraceOperatorContextAtCursor',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
tokenType: -1,
|
||||||
|
text: '',
|
||||||
|
start: cursorIndex,
|
||||||
|
stop: cursorIndex,
|
||||||
|
currentToken: '',
|
||||||
|
isInAtom: true,
|
||||||
|
isInOperator: false,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
expressionPairs: [],
|
||||||
|
currentPair: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create input stream and lexer
|
||||||
|
const input = query || '';
|
||||||
|
const chars = CharStreams.fromString(input);
|
||||||
|
const lexer = new TraceOperatorGrammarLexer(chars);
|
||||||
|
|
||||||
|
const tokenStream = new CommonTokenStream(lexer);
|
||||||
|
tokenStream.fill();
|
||||||
|
|
||||||
|
const allTokens = tokenStream.tokens as IToken[];
|
||||||
|
|
||||||
|
// Get expression pairs information
|
||||||
|
const expressionPairs = extractTraceExpressionPairs(query);
|
||||||
|
const currentPair = getCurrentTraceExpressionPair(
|
||||||
|
expressionPairs,
|
||||||
|
cursorIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the token at or just before the cursor
|
||||||
|
let lastTokenBeforeCursor: IToken | null = null;
|
||||||
|
for (let i = 0; i < allTokens.length; i++) {
|
||||||
|
const token = allTokens[i];
|
||||||
|
if (token.type === TraceOperatorGrammarLexer.EOF) continue;
|
||||||
|
|
||||||
|
if (token.stop < cursorIndex || token.stop + 1 === cursorIndex) {
|
||||||
|
lastTokenBeforeCursor = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.start > cursorIndex) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find exact token at cursor
|
||||||
|
let exactToken: IToken | null = null;
|
||||||
|
for (let i = 0; i < allTokens.length; i++) {
|
||||||
|
const token = allTokens[i];
|
||||||
|
if (token.type === TraceOperatorGrammarLexer.EOF) continue;
|
||||||
|
|
||||||
|
if (token.start <= cursorIndex && cursorIndex <= token.stop + 1) {
|
||||||
|
exactToken = token;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't have any tokens, return default context
|
||||||
|
if (!lastTokenBeforeCursor && !exactToken) {
|
||||||
|
return {
|
||||||
|
tokenType: -1,
|
||||||
|
text: '',
|
||||||
|
start: cursorIndex,
|
||||||
|
stop: cursorIndex,
|
||||||
|
currentToken: '',
|
||||||
|
isInAtom: true, // Default to atom context when input is empty
|
||||||
|
isInOperator: false,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
expressionPairs,
|
||||||
|
currentPair: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cursor is at a space after a token (transition point)
|
||||||
|
const isAtSpace = cursorIndex < query.length && query[cursorIndex] === ' ';
|
||||||
|
const isAfterSpace = cursorIndex > 0 && query[cursorIndex - 1] === ' ';
|
||||||
|
const isAfterToken = cursorIndex > 0 && query[cursorIndex - 1] !== ' ';
|
||||||
|
const isTransitionPoint =
|
||||||
|
(isAtSpace && isAfterToken) ||
|
||||||
|
(cursorIndex === query.length && isAfterToken);
|
||||||
|
|
||||||
|
// If we're at a transition point after a token, progress the context
|
||||||
|
if (
|
||||||
|
lastTokenBeforeCursor &&
|
||||||
|
(isAtSpace || isAfterSpace || isTransitionPoint)
|
||||||
|
) {
|
||||||
|
const lastTokenContext = determineTraceTokenContext(lastTokenBeforeCursor);
|
||||||
|
|
||||||
|
// Apply context progression: atom → operator → atom/expression → operator → atom
|
||||||
|
if (lastTokenContext.isInAtom) {
|
||||||
|
// After atom + space, move to operator context
|
||||||
|
return {
|
||||||
|
tokenType: lastTokenBeforeCursor.type,
|
||||||
|
text: lastTokenBeforeCursor.text,
|
||||||
|
start: cursorIndex,
|
||||||
|
stop: cursorIndex,
|
||||||
|
currentToken: lastTokenBeforeCursor.text,
|
||||||
|
isInAtom: false,
|
||||||
|
isInOperator: true,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
atomToken: lastTokenBeforeCursor.text,
|
||||||
|
expressionPairs,
|
||||||
|
currentPair,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastTokenContext.isInOperator) {
|
||||||
|
// After operator + space, move to atom/expression context
|
||||||
|
return {
|
||||||
|
tokenType: lastTokenBeforeCursor.type,
|
||||||
|
text: lastTokenBeforeCursor.text,
|
||||||
|
start: cursorIndex,
|
||||||
|
stop: cursorIndex,
|
||||||
|
currentToken: lastTokenBeforeCursor.text,
|
||||||
|
isInAtom: true, // Expecting an atom or expression after operator
|
||||||
|
isInOperator: false,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
operatorToken: lastTokenBeforeCursor.text,
|
||||||
|
atomToken: currentPair?.leftAtom,
|
||||||
|
expressionPairs,
|
||||||
|
currentPair,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastTokenContext.isInParenthesis &&
|
||||||
|
isClosingParenthesis(lastTokenBeforeCursor.type)
|
||||||
|
) {
|
||||||
|
// After closing parenthesis, move to operator context
|
||||||
|
return {
|
||||||
|
tokenType: lastTokenBeforeCursor.type,
|
||||||
|
text: lastTokenBeforeCursor.text,
|
||||||
|
start: cursorIndex,
|
||||||
|
stop: cursorIndex,
|
||||||
|
currentToken: lastTokenBeforeCursor.text,
|
||||||
|
isInAtom: false,
|
||||||
|
isInOperator: true,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
expressionPairs,
|
||||||
|
currentPair,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If cursor is at the end of a token, return the current token context
|
||||||
|
if (exactToken && cursorIndex === exactToken.stop + 1) {
|
||||||
|
const tokenContext = determineTraceTokenContext(exactToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenType: exactToken.type,
|
||||||
|
text: exactToken.text,
|
||||||
|
start: exactToken.start,
|
||||||
|
stop: exactToken.stop,
|
||||||
|
currentToken: exactToken.text,
|
||||||
|
...tokenContext,
|
||||||
|
atomToken: tokenContext.isInAtom ? exactToken.text : currentPair?.leftAtom,
|
||||||
|
operatorToken: tokenContext.isInOperator
|
||||||
|
? exactToken.text
|
||||||
|
: currentPair?.operator,
|
||||||
|
expressionPairs,
|
||||||
|
currentPair,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular token-based context detection
|
||||||
|
if (exactToken?.channel === 0) {
|
||||||
|
const tokenContext = determineTraceTokenContext(exactToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenType: exactToken.type,
|
||||||
|
text: exactToken.text,
|
||||||
|
start: exactToken.start,
|
||||||
|
stop: exactToken.stop,
|
||||||
|
currentToken: exactToken.text,
|
||||||
|
...tokenContext,
|
||||||
|
atomToken: tokenContext.isInAtom ? exactToken.text : currentPair?.leftAtom,
|
||||||
|
operatorToken: tokenContext.isInOperator
|
||||||
|
? exactToken.text
|
||||||
|
: currentPair?.operator,
|
||||||
|
expressionPairs,
|
||||||
|
currentPair,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback to atom context
|
||||||
|
return {
|
||||||
|
tokenType: -1,
|
||||||
|
text: '',
|
||||||
|
start: cursorIndex,
|
||||||
|
stop: cursorIndex,
|
||||||
|
currentToken: '',
|
||||||
|
isInAtom: true,
|
||||||
|
isInOperator: false,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
expressionPairs,
|
||||||
|
currentPair,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getTraceOperatorContextAtCursor:', error);
|
||||||
|
return {
|
||||||
|
tokenType: -1,
|
||||||
|
text: '',
|
||||||
|
start: cursorIndex,
|
||||||
|
stop: cursorIndex,
|
||||||
|
currentToken: '',
|
||||||
|
isInAtom: true,
|
||||||
|
isInOperator: false,
|
||||||
|
isInParenthesis: false,
|
||||||
|
isInExpression: false,
|
||||||
|
expressionPairs: [],
|
||||||
|
currentPair: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
export const getInvolvedQueriesInTraceOperator = (
|
||||||
|
traceOperators: IBuilderTraceOperator[],
|
||||||
|
): string[] => {
|
||||||
|
if (
|
||||||
|
!traceOperators ||
|
||||||
|
traceOperators.length === 0 ||
|
||||||
|
traceOperators.length > 1
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const currentTraceOperator = traceOperators[0];
|
||||||
|
|
||||||
|
// Match any word starting with letter or underscore
|
||||||
|
const tokens =
|
||||||
|
currentTraceOperator.expression.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) || [];
|
||||||
|
|
||||||
|
// Filter out operator keywords
|
||||||
|
const operators = new Set(['NOT']);
|
||||||
|
return tokens.filter((t) => !operators.has(t));
|
||||||
|
};
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
convertAggregationToExpression,
|
convertAggregationToExpression,
|
||||||
convertFiltersToExpression,
|
convertFiltersToExpression,
|
||||||
convertFiltersToExpressionWithExistingQuery,
|
convertFiltersToExpressionWithExistingQuery,
|
||||||
|
removeKeysFromExpression,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
describe('convertFiltersToExpression', () => {
|
describe('convertFiltersToExpression', () => {
|
||||||
@ -972,3 +973,223 @@ describe('convertAggregationToExpression', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('removeKeysFromExpression', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backward compatibility (removeOnlyVariableExpressions = false)', () => {
|
||||||
|
it('should remove simple key-value pair from expression', () => {
|
||||||
|
const expression = "service.name = 'api-gateway' AND status = 'success'";
|
||||||
|
const result = removeKeysFromExpression(expression, ['service.name']);
|
||||||
|
|
||||||
|
expect(result).toBe("status = 'success'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove multiple keys from expression', () => {
|
||||||
|
const expression =
|
||||||
|
"service.name = 'api-gateway' AND status = 'success' AND region = 'us-east-1'";
|
||||||
|
const result = removeKeysFromExpression(expression, [
|
||||||
|
'service.name',
|
||||||
|
'status',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBe("region = 'us-east-1'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty expression', () => {
|
||||||
|
const result = removeKeysFromExpression('', ['service.name']);
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty keys array', () => {
|
||||||
|
const expression = "service.name = 'api-gateway'";
|
||||||
|
const result = removeKeysFromExpression(expression, []);
|
||||||
|
expect(result).toBe(expression);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle key not found in expression', () => {
|
||||||
|
const expression = "service.name = 'api-gateway'";
|
||||||
|
const result = removeKeysFromExpression(expression, ['nonexistent.key']);
|
||||||
|
expect(result).toBe(expression);
|
||||||
|
});
|
||||||
|
|
||||||
|
// todo: Sagar check this - this is expected or not
|
||||||
|
// it('should remove last occurrence when multiple occurrences exist', () => {
|
||||||
|
// // This tests the original behavior - should remove the last occurrence
|
||||||
|
// const expression =
|
||||||
|
// "deployment.environment = $deployment.environment deployment.environment = 'default'";
|
||||||
|
// const result = removeKeysFromExpression(
|
||||||
|
// expression,
|
||||||
|
// ['deployment.environment'],
|
||||||
|
// false,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // Should remove the literal value (last occurrence), leaving the variable
|
||||||
|
// expect(result).toBe('deployment.environment = $deployment.environment');
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Variable expression targeting (removeOnlyVariableExpressions = true)', () => {
|
||||||
|
it('should remove only variable expressions (values starting with $)', () => {
|
||||||
|
const expression =
|
||||||
|
"deployment.environment = $deployment.environment deployment.environment = 'default'";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should remove the variable expression, leaving the literal value
|
||||||
|
expect(result).toBe("deployment.environment = 'default'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not remove literal values when targeting variable expressions', () => {
|
||||||
|
const expression = "service.name = 'api-gateway' AND status = 'success'";
|
||||||
|
const result = removeKeysFromExpression(expression, ['service.name'], true);
|
||||||
|
|
||||||
|
// Should not remove anything since no variable expressions exist
|
||||||
|
expect(result).toBe("service.name = 'api-gateway' AND status = 'success'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove multiple variable expressions', () => {
|
||||||
|
const expression =
|
||||||
|
"deployment.environment = $deployment.environment service.name = $service.name status = 'success'";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment', 'service.name'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("status = 'success'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed variable and literal expressions correctly', () => {
|
||||||
|
const expression =
|
||||||
|
"deployment.environment = $deployment.environment service.name = 'api-gateway' region = $region";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment', 'region'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only remove variable expressions, leaving literal value
|
||||||
|
expect(result).toBe("service.name = 'api-gateway'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex expressions with operators', () => {
|
||||||
|
const expression =
|
||||||
|
"deployment.environment IN [$env1, $env2] AND service.name = 'api-gateway'";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("service.name = 'api-gateway'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases and robustness', () => {
|
||||||
|
it('should handle case insensitive key matching', () => {
|
||||||
|
const expression = 'Service.Name = $Service.Name';
|
||||||
|
const result = removeKeysFromExpression(expression, ['service.name'], true);
|
||||||
|
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up trailing AND/OR operators', () => {
|
||||||
|
const expression =
|
||||||
|
"deployment.environment = $deployment.environment AND service.name = 'api-gateway'";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("service.name = 'api-gateway'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up leading AND/OR operators', () => {
|
||||||
|
const expression =
|
||||||
|
"service.name = 'api-gateway' AND deployment.environment = $deployment.environment";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("service.name = 'api-gateway'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle expressions with only variable assignments', () => {
|
||||||
|
const expression = 'deployment.environment = $deployment.environment';
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle whitespace around operators', () => {
|
||||||
|
const expression =
|
||||||
|
"deployment.environment = $deployment.environment AND service.name = 'api-gateway'";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.trim()).toBe("service.name = 'api-gateway'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real-world scenarios', () => {
|
||||||
|
it('should handle multiple variable instances of same key', () => {
|
||||||
|
const expression =
|
||||||
|
"deployment.environment = $env1 deployment.environment = $env2 deployment.environment = 'default'";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should remove one occurence as this case in itself is invalid to have multiple variable expressions for the same key
|
||||||
|
expect(result).toBe(
|
||||||
|
"deployment.environment = $env1 deployment.environment = 'default'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle OR operators in expressions', () => {
|
||||||
|
const expression =
|
||||||
|
"deployment.environment = $deployment.environment OR service.name = 'api-gateway'";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("service.name = 'api-gateway'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain expression validity after removal', () => {
|
||||||
|
const expression =
|
||||||
|
"deployment.environment = $deployment.environment AND service.name = 'api-gateway' AND status = 'success'";
|
||||||
|
const result = removeKeysFromExpression(
|
||||||
|
expression,
|
||||||
|
['deployment.environment'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should maintain valid AND structure
|
||||||
|
expect(result).toBe("service.name = 'api-gateway' AND status = 'success'");
|
||||||
|
|
||||||
|
// Verify the result can be parsed by extractQueryPairs
|
||||||
|
const pairs = extractQueryPairs(result);
|
||||||
|
expect(pairs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -38,6 +38,13 @@ const isArrayOperator = (operator: string): boolean => {
|
|||||||
return arrayOperators.includes(operator);
|
return arrayOperators.includes(operator);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isVariable = (value: string | string[] | number | boolean): boolean => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.some((v) => typeof v === 'string' && v.trim().startsWith('$'));
|
||||||
|
}
|
||||||
|
return typeof value === 'string' && value.trim().startsWith('$');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a value for the expression string
|
* Format a value for the expression string
|
||||||
* @param value - The value to format
|
* @param value - The value to format
|
||||||
@ -48,6 +55,10 @@ const formatValueForExpression = (
|
|||||||
value: string[] | string | number | boolean,
|
value: string[] | string | number | boolean,
|
||||||
operator?: string,
|
operator?: string,
|
||||||
): string => {
|
): string => {
|
||||||
|
if (isVariable(value)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
// For IN operators, ensure value is always an array
|
// For IN operators, ensure value is always an array
|
||||||
if (isArrayOperator(operator || '')) {
|
if (isArrayOperator(operator || '')) {
|
||||||
const arrayValue = Array.isArray(value) ? value : [value];
|
const arrayValue = Array.isArray(value) ? value : [value];
|
||||||
@ -466,11 +477,13 @@ export const convertFiltersToExpressionWithExistingQuery = (
|
|||||||
*
|
*
|
||||||
* @param expression - The full query string.
|
* @param expression - The full query string.
|
||||||
* @param keysToRemove - An array of keys (case-insensitive) that should be removed from the expression.
|
* @param keysToRemove - An array of keys (case-insensitive) that should be removed from the expression.
|
||||||
|
* @param removeOnlyVariableExpressions - When true, only removes key-value pairs where the value is a variable (starts with $). When false, uses the original behavior.
|
||||||
* @returns A new expression string with the specified keys and their associated clauses removed.
|
* @returns A new expression string with the specified keys and their associated clauses removed.
|
||||||
*/
|
*/
|
||||||
export const removeKeysFromExpression = (
|
export const removeKeysFromExpression = (
|
||||||
expression: string,
|
expression: string,
|
||||||
keysToRemove: string[],
|
keysToRemove: string[],
|
||||||
|
removeOnlyVariableExpressions = false,
|
||||||
): string => {
|
): string => {
|
||||||
if (!keysToRemove || keysToRemove.length === 0) {
|
if (!keysToRemove || keysToRemove.length === 0) {
|
||||||
return expression;
|
return expression;
|
||||||
@ -486,9 +499,20 @@ export const removeKeysFromExpression = (
|
|||||||
let queryPairsMap: Map<string, IQueryPair>;
|
let queryPairsMap: Map<string, IQueryPair>;
|
||||||
|
|
||||||
if (existingQueryPairs.length > 0) {
|
if (existingQueryPairs.length > 0) {
|
||||||
|
// Filter query pairs based on the removeOnlyVariableExpressions flag
|
||||||
|
const filteredQueryPairs = removeOnlyVariableExpressions
|
||||||
|
? existingQueryPairs.filter((pair) => {
|
||||||
|
const pairKey = pair.key?.trim().toLowerCase();
|
||||||
|
const matchesKey = pairKey === `${key}`.trim().toLowerCase();
|
||||||
|
if (!matchesKey) return false;
|
||||||
|
const value = pair.value?.toString().trim();
|
||||||
|
return value && value.includes('$');
|
||||||
|
})
|
||||||
|
: existingQueryPairs;
|
||||||
|
|
||||||
// Build a map for quick lookup of query pairs by their lowercase trimmed keys
|
// Build a map for quick lookup of query pairs by their lowercase trimmed keys
|
||||||
queryPairsMap = new Map(
|
queryPairsMap = new Map(
|
||||||
existingQueryPairs.map((pair) => {
|
filteredQueryPairs.map((pair) => {
|
||||||
const key = pair.key.trim().toLowerCase();
|
const key = pair.key.trim().toLowerCase();
|
||||||
return [key, pair];
|
return [key, pair];
|
||||||
}),
|
}),
|
||||||
@ -524,6 +548,12 @@ export const removeKeysFromExpression = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up any remaining trailing AND/OR operators and extra whitespace
|
||||||
|
updatedExpression = updatedExpression
|
||||||
|
.replace(/\s+(AND|OR)\s*$/i, '') // Remove trailing AND/OR
|
||||||
|
.replace(/^(AND|OR)\s+/i, '') // Remove leading AND/OR
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedExpression;
|
return updatedExpression;
|
||||||
|
|||||||
@ -17,6 +17,19 @@
|
|||||||
font-weight: var(--font-weight-normal);
|
font-weight: var(--font-weight-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-title-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.icon-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
border: 1px solid var(--bg-slate-400);
|
border: 1px solid var(--bg-slate-400);
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { RadioChangeEvent } from 'antd/es/radio';
|
|||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SignozRadioGroupProps {
|
interface SignozRadioGroupProps {
|
||||||
@ -37,7 +38,10 @@ function SignozRadioGroup({
|
|||||||
value={option.value}
|
value={option.value}
|
||||||
className={value === option.value ? 'selected_view tab' : 'tab'}
|
className={value === option.value ? 'selected_view tab' : 'tab'}
|
||||||
>
|
>
|
||||||
{option.label}
|
<div className="view-title-container">
|
||||||
|
{option.icon && <div className="icon-container">{option.icon}</div>}
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
</Radio.Button>
|
</Radio.Button>
|
||||||
))}
|
))}
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
import { Tooltip } from 'antd';
|
import { Tooltip } from 'antd';
|
||||||
import { themeColors } from 'constants/theme';
|
import { themeColors } from 'constants/theme';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useMemo } from 'react';
|
import { ReactNode, useMemo } from 'react';
|
||||||
|
|
||||||
import { style } from './constant';
|
import { style } from './constant';
|
||||||
|
|
||||||
@ -17,6 +17,8 @@ function TextToolTip({
|
|||||||
url,
|
url,
|
||||||
useFilledIcon = true,
|
useFilledIcon = true,
|
||||||
urlText,
|
urlText,
|
||||||
|
filledIcon,
|
||||||
|
outlinedIcon,
|
||||||
}: TextToolTipProps): JSX.Element {
|
}: TextToolTipProps): JSX.Element {
|
||||||
const isDarkMode = useIsDarkMode();
|
const isDarkMode = useIsDarkMode();
|
||||||
|
|
||||||
@ -62,27 +64,44 @@ function TextToolTip({
|
|||||||
[isDarkMode],
|
[isDarkMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
// Use provided icons or fallback to default icons
|
||||||
<Tooltip overlay={overlay}>
|
const defaultFilledIcon = <QuestionCircleFilled style={iconStyle} />;
|
||||||
{useFilledIcon ? (
|
const defaultOutlinedIcon = (
|
||||||
<QuestionCircleFilled style={iconStyle} />
|
<QuestionCircleOutlined style={iconOutlinedStyle} />
|
||||||
) : (
|
|
||||||
<QuestionCircleOutlined style={iconOutlinedStyle} />
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderIcon = (): ReactNode => {
|
||||||
|
if (useFilledIcon) {
|
||||||
|
return filledIcon ? (
|
||||||
|
<div style={{ color: iconStyle.color }}>{filledIcon}</div>
|
||||||
|
) : (
|
||||||
|
defaultFilledIcon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return outlinedIcon ? (
|
||||||
|
<div style={{ color: iconOutlinedStyle.color }}>{outlinedIcon}</div>
|
||||||
|
) : (
|
||||||
|
defaultOutlinedIcon
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Tooltip overlay={overlay}>{renderIcon()}</Tooltip>;
|
||||||
}
|
}
|
||||||
|
|
||||||
TextToolTip.defaultProps = {
|
TextToolTip.defaultProps = {
|
||||||
url: '',
|
url: '',
|
||||||
urlText: '',
|
urlText: '',
|
||||||
useFilledIcon: true,
|
useFilledIcon: true,
|
||||||
|
filledIcon: undefined,
|
||||||
|
outlinedIcon: undefined,
|
||||||
};
|
};
|
||||||
interface TextToolTipProps {
|
interface TextToolTipProps {
|
||||||
url?: string;
|
url?: string;
|
||||||
text: string;
|
text: string;
|
||||||
useFilledIcon?: boolean;
|
useFilledIcon?: boolean;
|
||||||
urlText?: string;
|
urlText?: string;
|
||||||
|
filledIcon?: ReactNode;
|
||||||
|
outlinedIcon?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TextToolTip;
|
export default TextToolTip;
|
||||||
|
|||||||
@ -17,6 +17,27 @@ export const OPERATORS = {
|
|||||||
'<': '<',
|
'<': '<',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TRACE_OPERATOR_OPERATORS = {
|
||||||
|
AND: '&&',
|
||||||
|
OR: '||',
|
||||||
|
NOT: 'NOT',
|
||||||
|
DIRECT_DESCENDENT: '=>',
|
||||||
|
INDIRECT_DESCENDENT: '->',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRACE_OPERATOR_OPERATORS_WITH_PRIORITY = {
|
||||||
|
[TRACE_OPERATOR_OPERATORS.DIRECT_DESCENDENT]: 1,
|
||||||
|
[TRACE_OPERATOR_OPERATORS.AND]: 2,
|
||||||
|
[TRACE_OPERATOR_OPERATORS.OR]: 3,
|
||||||
|
[TRACE_OPERATOR_OPERATORS.NOT]: 4,
|
||||||
|
[TRACE_OPERATOR_OPERATORS.INDIRECT_DESCENDENT]: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRACE_OPERATOR_OPERATORS_LABELS = {
|
||||||
|
[TRACE_OPERATOR_OPERATORS.DIRECT_DESCENDENT]: 'Direct Descendant',
|
||||||
|
[TRACE_OPERATOR_OPERATORS.INDIRECT_DESCENDENT]: 'Indirect Descendant',
|
||||||
|
};
|
||||||
|
|
||||||
export const QUERY_BUILDER_FUNCTIONS = {
|
export const QUERY_BUILDER_FUNCTIONS = {
|
||||||
HAS: 'has',
|
HAS: 'has',
|
||||||
HASANY: 'hasAny',
|
HASANY: 'hasAny',
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export enum QueryParams {
|
|||||||
msgSystem = 'msgSystem',
|
msgSystem = 'msgSystem',
|
||||||
destination = 'destination',
|
destination = 'destination',
|
||||||
kindString = 'kindString',
|
kindString = 'kindString',
|
||||||
|
summaryFilters = 'summaryFilters',
|
||||||
tab = 'tab',
|
tab = 'tab',
|
||||||
thresholds = 'thresholds',
|
thresholds = 'thresholds',
|
||||||
selectedExplorerView = 'selectedExplorerView',
|
selectedExplorerView = 'selectedExplorerView',
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
HavingForm,
|
HavingForm,
|
||||||
IBuilderFormula,
|
IBuilderFormula,
|
||||||
IBuilderQuery,
|
IBuilderQuery,
|
||||||
|
IBuilderTraceOperator,
|
||||||
IClickHouseQuery,
|
IClickHouseQuery,
|
||||||
IPromQLQuery,
|
IPromQLQuery,
|
||||||
Query,
|
Query,
|
||||||
@ -50,6 +51,8 @@ import {
|
|||||||
export const MAX_FORMULAS = 20;
|
export const MAX_FORMULAS = 20;
|
||||||
export const MAX_QUERIES = 26;
|
export const MAX_QUERIES = 26;
|
||||||
|
|
||||||
|
export const TRACE_OPERATOR_QUERY_NAME = 'Trace Operator';
|
||||||
|
|
||||||
export const idDivider = '--';
|
export const idDivider = '--';
|
||||||
export const selectValueDivider = '__';
|
export const selectValueDivider = '__';
|
||||||
|
|
||||||
@ -263,6 +266,11 @@ export const initialFormulaBuilderFormValues: IBuilderFormula = {
|
|||||||
legend: '',
|
legend: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const initialQueryBuilderFormTraceOperatorValues: IBuilderTraceOperator = {
|
||||||
|
...initialQueryBuilderFormTracesValues,
|
||||||
|
queryName: TRACE_OPERATOR_QUERY_NAME,
|
||||||
|
};
|
||||||
|
|
||||||
export const initialQueryPromQLData: IPromQLQuery = {
|
export const initialQueryPromQLData: IPromQLQuery = {
|
||||||
name: createNewBuilderItemName({ existNames: [], sourceNames: alphabet }),
|
name: createNewBuilderItemName({ existNames: [], sourceNames: alphabet }),
|
||||||
query: '',
|
query: '',
|
||||||
@ -280,6 +288,7 @@ export const initialClickHouseData: IClickHouseQuery = {
|
|||||||
export const initialQueryBuilderData: QueryBuilderData = {
|
export const initialQueryBuilderData: QueryBuilderData = {
|
||||||
queryData: [initialQueryBuilderFormValues],
|
queryData: [initialQueryBuilderFormValues],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialSingleQueryMap: Record<
|
export const initialSingleQueryMap: Record<
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { TRACE_OPERATOR_QUERY_NAME } from './queryBuilder';
|
||||||
|
|
||||||
export const FORMULA_REGEXP = /F\d+/;
|
export const FORMULA_REGEXP = /F\d+/;
|
||||||
|
|
||||||
export const HAVING_FILTER_REGEXP = /^[-\d.,\s]+$/;
|
export const HAVING_FILTER_REGEXP = /^[-\d.,\s]+$/;
|
||||||
@ -5,3 +7,5 @@ export const HAVING_FILTER_REGEXP = /^[-\d.,\s]+$/;
|
|||||||
export const TYPE_ADDON_REGEXP = /_(.+)/;
|
export const TYPE_ADDON_REGEXP = /_(.+)/;
|
||||||
|
|
||||||
export const SPLIT_FIRST_UNDERSCORE = /(?<!^)_/;
|
export const SPLIT_FIRST_UNDERSCORE = /(?<!^)_/;
|
||||||
|
|
||||||
|
export const TRACE_OPERATOR_REGEXP = new RegExp(TRACE_OPERATOR_QUERY_NAME);
|
||||||
|
|||||||
@ -507,6 +507,7 @@ export const getDomainMetricsQueryPayload = (
|
|||||||
legend: '',
|
legend: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -816,6 +817,7 @@ export const getEndPointsQueryPayload = (
|
|||||||
legend: 'error percentage',
|
legend: 'error percentage',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -965,6 +967,7 @@ export const getTopErrorsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1729,6 +1732,7 @@ export const getEndPointDetailsQueryPayload = (
|
|||||||
legend: 'error percentage',
|
legend: 'error percentage',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1928,6 +1932,7 @@ export const getEndPointDetailsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -2016,6 +2021,7 @@ export const getEndPointDetailsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -2287,6 +2293,7 @@ export const getEndPointDetailsQueryPayload = (
|
|||||||
legend: 'error percentage',
|
legend: 'error percentage',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -2376,6 +2383,7 @@ export const getEndPointDetailsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -2464,6 +2472,7 @@ export const getEndPointDetailsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -2558,6 +2567,7 @@ export const getEndPointZeroStateQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -3135,6 +3145,7 @@ export const getStatusCodeBarChartWidgetData = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -54,6 +54,7 @@ function QuerySection({
|
|||||||
queryVariant: 'static',
|
queryVariant: 'static',
|
||||||
initialDataSource: ALERTS_DATA_SOURCE_MAP[alertType],
|
initialDataSource: ALERTS_DATA_SOURCE_MAP[alertType],
|
||||||
}}
|
}}
|
||||||
|
showTraceOperator={alertType === AlertTypes.TRACES_BASED_ALERT}
|
||||||
showFunctions={
|
showFunctions={
|
||||||
(alertType === AlertTypes.METRICS_BASED_ALERT &&
|
(alertType === AlertTypes.METRICS_BASED_ALERT &&
|
||||||
alertDef.version === ENTITY_VERSION_V4) ||
|
alertDef.version === ENTITY_VERSION_V4) ||
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Button, FormInstance, Modal, SelectProps, Typography } from 'antd';
|
|||||||
import saveAlertApi from 'api/alerts/save';
|
import saveAlertApi from 'api/alerts/save';
|
||||||
import testAlertApi from 'api/alerts/testAlert';
|
import testAlertApi from 'api/alerts/testAlert';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
|
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
|
||||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||||
import { FeatureKeys } from 'constants/features';
|
import { FeatureKeys } from 'constants/features';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
@ -149,10 +150,17 @@ function FormAlertRules({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const queryOptions = useMemo(() => {
|
const queryOptions = useMemo(() => {
|
||||||
|
const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator(
|
||||||
|
currentQuery.builder.queryTraceOperator,
|
||||||
|
);
|
||||||
const queryConfig: Record<EQueryType, () => SelectProps['options']> = {
|
const queryConfig: Record<EQueryType, () => SelectProps['options']> = {
|
||||||
[EQueryType.QUERY_BUILDER]: () => [
|
[EQueryType.QUERY_BUILDER]: () => [
|
||||||
...(getSelectedQueryOptions(currentQuery.builder.queryData) || []),
|
...(getSelectedQueryOptions(currentQuery.builder.queryData)?.filter(
|
||||||
|
(option) =>
|
||||||
|
!involvedQueriesInTraceOperator.includes(option.value as string),
|
||||||
|
) || []),
|
||||||
...(getSelectedQueryOptions(currentQuery.builder.queryFormulas) || []),
|
...(getSelectedQueryOptions(currentQuery.builder.queryFormulas) || []),
|
||||||
|
...(getSelectedQueryOptions(currentQuery.builder.queryTraceOperator) || []),
|
||||||
],
|
],
|
||||||
[EQueryType.PROM]: () => getSelectedQueryOptions(currentQuery.promql),
|
[EQueryType.PROM]: () => getSelectedQueryOptions(currentQuery.promql),
|
||||||
[EQueryType.CLICKHOUSE]: () =>
|
[EQueryType.CLICKHOUSE]: () =>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import getStep from 'lib/getStep';
|
|||||||
import {
|
import {
|
||||||
IBuilderFormula,
|
IBuilderFormula,
|
||||||
IBuilderQuery,
|
IBuilderQuery,
|
||||||
|
IBuilderTraceOperator,
|
||||||
IClickHouseQuery,
|
IClickHouseQuery,
|
||||||
IPromQLQuery,
|
IPromQLQuery,
|
||||||
} from 'types/api/queryBuilder/queryBuilderData';
|
} from 'types/api/queryBuilder/queryBuilderData';
|
||||||
@ -53,7 +54,11 @@ export const getUpdatedStepInterval = (evalWindow?: string): number => {
|
|||||||
|
|
||||||
export const getSelectedQueryOptions = (
|
export const getSelectedQueryOptions = (
|
||||||
queries: Array<
|
queries: Array<
|
||||||
IBuilderQuery | IBuilderFormula | IClickHouseQuery | IPromQLQuery
|
| IBuilderQuery
|
||||||
|
| IBuilderTraceOperator
|
||||||
|
| IBuilderFormula
|
||||||
|
| IClickHouseQuery
|
||||||
|
| IPromQLQuery
|
||||||
>,
|
>,
|
||||||
): SelectProps['options'] =>
|
): SelectProps['options'] =>
|
||||||
queries
|
queries
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
.panel-type-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 16px;
|
||||||
|
min-width: 180px;
|
||||||
|
|
||||||
|
.typography {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--bg-slate-600);
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-type-select {
|
||||||
|
.view-panel-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select-item-option-content {
|
||||||
|
.view-panel-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import './PanelTypeSelector.scss';
|
||||||
|
|
||||||
|
import { Select, Typography } from 'antd';
|
||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import GraphTypes from 'container/NewDashboard/ComponentsSlider/menuItems';
|
||||||
|
import { handleQueryChange } from 'container/NewWidget/utils';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
interface PanelTypeSelectorProps {
|
||||||
|
selectedPanelType: PANEL_TYPES;
|
||||||
|
disabled?: boolean;
|
||||||
|
query: Query;
|
||||||
|
widgetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelTypeSelector({
|
||||||
|
selectedPanelType,
|
||||||
|
disabled = false,
|
||||||
|
query,
|
||||||
|
widgetId,
|
||||||
|
}: PanelTypeSelectorProps): JSX.Element {
|
||||||
|
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(newPanelType: PANEL_TYPES): void => {
|
||||||
|
// Transform the query for the new panel type using handleQueryChange
|
||||||
|
const transformedQuery = handleQueryChange(
|
||||||
|
newPanelType as any,
|
||||||
|
query,
|
||||||
|
selectedPanelType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use redirectWithQueryBuilderData to update URL with transformed query and new panel type
|
||||||
|
redirectWithQueryBuilderData(
|
||||||
|
transformedQuery,
|
||||||
|
{
|
||||||
|
[QueryParams.expandedWidgetId]: widgetId,
|
||||||
|
[QueryParams.graphType]: newPanelType,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[redirectWithQueryBuilderData, query, selectedPanelType, widgetId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel-type-selector">
|
||||||
|
<Select
|
||||||
|
onChange={handleChange}
|
||||||
|
value={selectedPanelType}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
className="panel-type-select"
|
||||||
|
data-testid="panel-change-select"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{GraphTypes.map((item) => (
|
||||||
|
<Option key={item.name} value={item.name}>
|
||||||
|
<div className="view-panel-select-option">
|
||||||
|
<div className="icon">{item.icon}</div>
|
||||||
|
<Typography.Text className="display">{item.display}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelTypeSelector.defaultProps = {
|
||||||
|
disabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PanelTypeSelector;
|
||||||
@ -4,7 +4,9 @@
|
|||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
|
||||||
.full-view-header-container {
|
.full-view-header-container {
|
||||||
height: 40px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-container {
|
.graph-container {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
import './WidgetFullView.styles.scss';
|
import './WidgetFullView.styles.scss';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -8,36 +9,47 @@ import {
|
|||||||
import { Button, Input, Spin } from 'antd';
|
import { Button, Input, Spin } from 'antd';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { ToggleGraphProps } from 'components/Graph/types';
|
import { ToggleGraphProps } from 'components/Graph/types';
|
||||||
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
|
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||||
import Spinner from 'components/Spinner';
|
import Spinner from 'components/Spinner';
|
||||||
import TimePreference from 'components/TimePreferenceDropDown';
|
import TimePreference from 'components/TimePreferenceDropDown';
|
||||||
|
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import useDrilldown from 'container/GridCardLayout/GridCard/FullView/useDrilldown';
|
||||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||||
import {
|
import {
|
||||||
timeItems,
|
timeItems,
|
||||||
timePreferance,
|
timePreferance,
|
||||||
} from 'container/NewWidget/RightContainer/timeItems';
|
} from 'container/NewWidget/RightContainer/timeItems';
|
||||||
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
||||||
|
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useChartMutable } from 'hooks/useChartMutable';
|
import { useChartMutable } from 'hooks/useChartMutable';
|
||||||
|
import useComponentPermission from 'hooks/useComponentPermission';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||||
import GetMinMax from 'lib/getMinMax';
|
import GetMinMax from 'lib/getMinMax';
|
||||||
|
import { isEmpty } from 'lodash-es';
|
||||||
|
import { useAppContext } from 'providers/App/App';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { UpdateTimeInterval } from 'store/actions';
|
import { UpdateTimeInterval } from 'store/actions';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
import { Warning } from 'types/api';
|
||||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
import { getGraphType } from 'utils/getGraphType';
|
import { getGraphType } from 'utils/getGraphType';
|
||||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||||
|
|
||||||
import { getLocalStorageGraphVisibilityState } from '../utils';
|
import { getLocalStorageGraphVisibilityState } from '../utils';
|
||||||
import { PANEL_TYPES_VS_FULL_VIEW_TABLE } from './contants';
|
import { PANEL_TYPES_VS_FULL_VIEW_TABLE } from './contants';
|
||||||
|
import PanelTypeSelector from './PanelTypeSelector';
|
||||||
import { GraphContainer, TimeContainer } from './styles';
|
import { GraphContainer, TimeContainer } from './styles';
|
||||||
import { FullViewProps } from './types';
|
import { FullViewProps } from './types';
|
||||||
|
|
||||||
@ -52,6 +64,7 @@ function FullView({
|
|||||||
onClickHandler,
|
onClickHandler,
|
||||||
customOnDragSelect,
|
customOnDragSelect,
|
||||||
setCurrentGraphRef,
|
setCurrentGraphRef,
|
||||||
|
enableDrillDown = false,
|
||||||
}: FullViewProps): JSX.Element {
|
}: FullViewProps): JSX.Element {
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const { selectedTime: globalSelectedTime } = useSelector<
|
const { selectedTime: globalSelectedTime } = useSelector<
|
||||||
@ -63,12 +76,16 @@ function FullView({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const fullViewRef = useRef<HTMLDivElement>(null);
|
const fullViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { handleRunQuery } = useQueryBuilder();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentGraphRef(fullViewRef);
|
setCurrentGraphRef(fullViewRef);
|
||||||
}, [setCurrentGraphRef]);
|
}, [setCurrentGraphRef]);
|
||||||
|
|
||||||
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
||||||
|
const { user } = useAppContext();
|
||||||
|
|
||||||
|
const [editWidget] = useComponentPermission(['edit_widget'], user.role);
|
||||||
|
|
||||||
const getSelectedTime = useCallback(
|
const getSelectedTime = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@ -85,17 +102,26 @@ function FullView({
|
|||||||
|
|
||||||
const updatedQuery = widget?.query;
|
const updatedQuery = widget?.query;
|
||||||
|
|
||||||
|
// Panel type derived from URL with fallback to widget setting
|
||||||
|
const selectedPanelType = useMemo(() => {
|
||||||
|
const urlPanelType = urlQuery.get(QueryParams.graphType) as PANEL_TYPES;
|
||||||
|
if (urlPanelType && Object.values(PANEL_TYPES).includes(urlPanelType)) {
|
||||||
|
return urlPanelType;
|
||||||
|
}
|
||||||
|
return widget?.panelTypes || PANEL_TYPES.TIME_SERIES;
|
||||||
|
}, [urlQuery, widget?.panelTypes]);
|
||||||
|
|
||||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
||||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
if (selectedPanelType !== PANEL_TYPES.LIST) {
|
||||||
return {
|
return {
|
||||||
selectedTime: selectedTime.enum,
|
selectedTime: selectedTime.enum,
|
||||||
graphType: getGraphType(widget.panelTypes),
|
graphType: getGraphType(selectedPanelType),
|
||||||
query: updatedQuery,
|
query: updatedQuery,
|
||||||
globalSelectedInterval: globalSelectedTime,
|
globalSelectedInterval: globalSelectedTime,
|
||||||
variables: getDashboardVariables(selectedDashboard?.data.variables),
|
variables: getDashboardVariables(selectedDashboard?.data.variables),
|
||||||
fillGaps: widget.fillSpans,
|
fillGaps: widget.fillSpans,
|
||||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
formatForWeb: selectedPanelType === PANEL_TYPES.TABLE,
|
||||||
originalGraphType: widget?.panelTypes,
|
originalGraphType: selectedPanelType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
updatedQuery.builder.queryData[0].pageSize = 10;
|
updatedQuery.builder.queryData[0].pageSize = 10;
|
||||||
@ -114,6 +140,19 @@ function FullView({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
drilldownQuery,
|
||||||
|
dashboardEditView,
|
||||||
|
handleResetQuery,
|
||||||
|
showResetQuery,
|
||||||
|
} = useDrilldown({
|
||||||
|
enableDrillDown,
|
||||||
|
widget,
|
||||||
|
setRequestData,
|
||||||
|
selectedDashboard,
|
||||||
|
selectedPanelType,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRequestData((prev) => ({
|
setRequestData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -121,12 +160,33 @@ function FullView({
|
|||||||
}));
|
}));
|
||||||
}, [selectedTime]);
|
}, [selectedTime]);
|
||||||
|
|
||||||
|
// Update requestData when panel type changes
|
||||||
|
useEffect(() => {
|
||||||
|
setRequestData((prev) => {
|
||||||
|
if (selectedPanelType !== PANEL_TYPES.LIST) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
graphType: getGraphType(selectedPanelType),
|
||||||
|
formatForWeb: selectedPanelType === PANEL_TYPES.TABLE,
|
||||||
|
originalGraphType: selectedPanelType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// For LIST panels, ensure proper configuration
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
graphType: PANEL_TYPES.LIST,
|
||||||
|
formatForWeb: false,
|
||||||
|
originalGraphType: selectedPanelType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [selectedPanelType]);
|
||||||
|
|
||||||
const response = useGetQueryRange(
|
const response = useGetQueryRange(
|
||||||
requestData,
|
requestData,
|
||||||
// selectedDashboard?.data?.version || version || DEFAULT_ENTITY_VERSION,
|
// selectedDashboard?.data?.version || version || DEFAULT_ENTITY_VERSION,
|
||||||
ENTITY_VERSION_V5,
|
ENTITY_VERSION_V5,
|
||||||
{
|
{
|
||||||
queryKey: [widget?.query, widget?.panelTypes, requestData, version],
|
queryKey: [widget?.query, selectedPanelType, requestData, version],
|
||||||
enabled: !isDependedDataLoaded,
|
enabled: !isDependedDataLoaded,
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
},
|
},
|
||||||
@ -169,18 +229,18 @@ function FullView({
|
|||||||
}, [originalName, response.data?.payload.data.result]);
|
}, [originalName, response.data?.payload.data.result]);
|
||||||
|
|
||||||
const canModifyChart = useChartMutable({
|
const canModifyChart = useChartMutable({
|
||||||
panelType: widget.panelTypes,
|
panelType: selectedPanelType,
|
||||||
panelTypeAndGraphManagerVisibility: PANEL_TYPES_VS_FULL_VIEW_TABLE,
|
panelTypeAndGraphManagerVisibility: PANEL_TYPES_VS_FULL_VIEW_TABLE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && widget.panelTypes === PANEL_TYPES.BAR) {
|
if (response.data && selectedPanelType === PANEL_TYPES.BAR) {
|
||||||
const sortedSeriesData = getSortedSeriesData(
|
const sortedSeriesData = getSortedSeriesData(
|
||||||
response.data?.payload.data.result,
|
response.data?.payload.data.result,
|
||||||
);
|
);
|
||||||
response.data.payload.data.result = sortedSeriesData;
|
response.data.payload.data.result = sortedSeriesData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.data && widget.panelTypes === PANEL_TYPES.PIE) {
|
if (response.data && selectedPanelType === PANEL_TYPES.PIE) {
|
||||||
const transformedData = populateMultipleResults(response?.data);
|
const transformedData = populateMultipleResults(response?.data);
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
response.data = transformedData;
|
response.data = transformedData;
|
||||||
@ -192,83 +252,139 @@ function FullView({
|
|||||||
});
|
});
|
||||||
}, [graphsVisibilityStates]);
|
}, [graphsVisibilityStates]);
|
||||||
|
|
||||||
const isListView = widget.panelTypes === PANEL_TYPES.LIST;
|
const isListView = selectedPanelType === PANEL_TYPES.LIST;
|
||||||
|
|
||||||
const isTablePanel = widget.panelTypes === PANEL_TYPES.TABLE;
|
const isTablePanel = selectedPanelType === PANEL_TYPES.TABLE;
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
|
|
||||||
if (response.isLoading && widget.panelTypes !== PANEL_TYPES.LIST) {
|
if (response.isLoading && selectedPanelType !== PANEL_TYPES.LIST) {
|
||||||
return <Spinner height="100%" size="large" tip="Loading..." />;
|
return <Spinner height="100%" size="large" tip="Loading..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="full-view-container">
|
<div className="full-view-container">
|
||||||
<div className="full-view-header-container">
|
<OverlayScrollbar>
|
||||||
{fullViewOptions && (
|
<>
|
||||||
<TimeContainer $panelType={widget.panelTypes}>
|
<div className="full-view-header-container">
|
||||||
{response.isFetching && (
|
{fullViewOptions && (
|
||||||
<Spin spinning indicator={<LoadingOutlined spin />} />
|
<TimeContainer $panelType={selectedPanelType}>
|
||||||
|
{enableDrillDown && (
|
||||||
|
<div className="drildown-options-container">
|
||||||
|
{showResetQuery && (
|
||||||
|
<Button type="link" onClick={handleResetQuery}>
|
||||||
|
Reset Query
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{editWidget && (
|
||||||
|
<Button
|
||||||
|
className="switch-edit-btn"
|
||||||
|
disabled={response.isFetching || response.isLoading}
|
||||||
|
onClick={(): void => {
|
||||||
|
if (dashboardEditView) {
|
||||||
|
safeNavigate(dashboardEditView);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Switch to Edit Mode
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<PanelTypeSelector
|
||||||
|
selectedPanelType={selectedPanelType}
|
||||||
|
disabled={response.isFetching || response.isLoading}
|
||||||
|
query={drilldownQuery}
|
||||||
|
widgetId={widget?.id || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isEmpty(response.data?.warning) && (
|
||||||
|
<WarningPopover warningData={response.data?.warning as Warning} />
|
||||||
|
)}
|
||||||
|
<div className="time-container">
|
||||||
|
{response.isFetching && (
|
||||||
|
<Spin spinning indicator={<LoadingOutlined spin />} />
|
||||||
|
)}
|
||||||
|
<TimePreference
|
||||||
|
selectedTime={selectedTime}
|
||||||
|
setSelectedTime={setSelectedTime}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
style={{
|
||||||
|
marginLeft: '4px',
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
response.refetch();
|
||||||
|
}}
|
||||||
|
type="primary"
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TimeContainer>
|
||||||
)}
|
)}
|
||||||
<TimePreference
|
{enableDrillDown && (
|
||||||
selectedTime={selectedTime}
|
<>
|
||||||
setSelectedTime={setSelectedTime}
|
<QueryBuilderV2
|
||||||
/>
|
panelType={selectedPanelType}
|
||||||
<Button
|
version={selectedDashboard?.data?.version || 'v3'}
|
||||||
style={{
|
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
|
||||||
marginLeft: '4px',
|
// filterConfigs={filterConfigs}
|
||||||
}}
|
// queryComponents={queryComponents}
|
||||||
onClick={(): void => {
|
/>
|
||||||
response.refetch();
|
<RightToolbarActions
|
||||||
}}
|
onStageRunQuery={(): void => {
|
||||||
type="primary"
|
handleRunQuery();
|
||||||
icon={<SyncOutlined />}
|
}}
|
||||||
/>
|
/>
|
||||||
</TimeContainer>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cx('graph-container', {
|
className={cx('graph-container', {
|
||||||
disabled: isDashboardLocked,
|
disabled: isDashboardLocked,
|
||||||
'height-widget': widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
'height-widget':
|
||||||
'full-view-graph-container': isListView || isTablePanel,
|
widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
||||||
})}
|
'full-view-graph-container': isListView,
|
||||||
ref={fullViewRef}
|
})}
|
||||||
>
|
ref={fullViewRef}
|
||||||
<GraphContainer
|
>
|
||||||
style={{
|
<GraphContainer
|
||||||
height: isListView ? '100%' : '90%',
|
style={{
|
||||||
}}
|
height: isListView ? '100%' : '90%',
|
||||||
isGraphLegendToggleAvailable={canModifyChart}
|
|
||||||
>
|
|
||||||
{isTablePanel && (
|
|
||||||
<Input
|
|
||||||
addonBefore={<SearchOutlined size={14} />}
|
|
||||||
className="global-search"
|
|
||||||
placeholder="Search..."
|
|
||||||
allowClear
|
|
||||||
key={widget.id}
|
|
||||||
onChange={(e): void => {
|
|
||||||
setSearchTerm(e.target.value || '');
|
|
||||||
}}
|
}}
|
||||||
/>
|
isGraphLegendToggleAvailable={canModifyChart}
|
||||||
)}
|
>
|
||||||
<PanelWrapper
|
{isTablePanel && (
|
||||||
queryResponse={response}
|
<Input
|
||||||
widget={widget}
|
addonBefore={<SearchOutlined size={14} />}
|
||||||
setRequestData={setRequestData}
|
className="global-search"
|
||||||
isFullViewMode
|
placeholder="Search..."
|
||||||
onToggleModelHandler={onToggleModelHandler}
|
allowClear
|
||||||
setGraphVisibility={setGraphsVisibilityStates}
|
key={widget.id}
|
||||||
graphVisibility={graphsVisibilityStates}
|
onChange={(e): void => {
|
||||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
setSearchTerm(e.target.value || '');
|
||||||
tableProcessedDataRef={tableProcessedDataRef}
|
}}
|
||||||
searchTerm={searchTerm}
|
/>
|
||||||
onClickHandler={onClickHandler}
|
)}
|
||||||
/>
|
<PanelWrapper
|
||||||
</GraphContainer>
|
queryResponse={response}
|
||||||
</div>
|
widget={widget}
|
||||||
|
setRequestData={setRequestData}
|
||||||
|
isFullViewMode
|
||||||
|
onToggleModelHandler={onToggleModelHandler}
|
||||||
|
setGraphVisibility={setGraphsVisibilityStates}
|
||||||
|
graphVisibility={graphsVisibilityStates}
|
||||||
|
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||||
|
tableProcessedDataRef={tableProcessedDataRef}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onClickHandler={onClickHandler}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
|
selectedGraph={selectedPanelType}
|
||||||
|
/>
|
||||||
|
</GraphContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</OverlayScrollbar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export const NotFoundContainer = styled.div`
|
|||||||
export const TimeContainer = styled.div<Props>`
|
export const TimeContainer = styled.div<Props>`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
${({ $panelType }): FlattenSimpleInterpolation =>
|
${({ $panelType }): FlattenSimpleInterpolation =>
|
||||||
$panelType === PANEL_TYPES.TABLE
|
$panelType === PANEL_TYPES.TABLE
|
||||||
@ -25,6 +26,14 @@ export const TimeContainer = styled.div<Props>`
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
`
|
`
|
||||||
: css``}
|
: css``}
|
||||||
|
|
||||||
|
.time-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.drildown-options-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GraphContainer = styled.div<GraphContainerProps>`
|
export const GraphContainer = styled.div<GraphContainerProps>`
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export interface FullViewProps {
|
|||||||
isDependedDataLoaded?: boolean;
|
isDependedDataLoaded?: boolean;
|
||||||
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
|
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
|
||||||
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
|
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphManagerProps extends UplotProps {
|
export interface GraphManagerProps extends UplotProps {
|
||||||
|
|||||||
@ -0,0 +1,99 @@
|
|||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||||
|
import {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||||
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||||
|
|
||||||
|
export interface DrilldownQueryProps {
|
||||||
|
widget: Widgets;
|
||||||
|
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||||
|
enableDrillDown: boolean;
|
||||||
|
selectedDashboard: Dashboard | undefined;
|
||||||
|
selectedPanelType: PANEL_TYPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDrilldownReturn {
|
||||||
|
drilldownQuery: Query;
|
||||||
|
dashboardEditView: string;
|
||||||
|
handleResetQuery: () => void;
|
||||||
|
showResetQuery: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDrilldown = ({
|
||||||
|
enableDrillDown,
|
||||||
|
widget,
|
||||||
|
setRequestData,
|
||||||
|
selectedDashboard,
|
||||||
|
selectedPanelType,
|
||||||
|
}: DrilldownQueryProps): UseDrilldownReturn => {
|
||||||
|
const isMounted = useRef(false);
|
||||||
|
const { redirectWithQueryBuilderData, currentQuery } = useQueryBuilder();
|
||||||
|
const compositeQuery = useGetCompositeQueryParam();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enableDrillDown && !!compositeQuery) {
|
||||||
|
setRequestData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
query: compositeQuery,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentQuery, compositeQuery]);
|
||||||
|
|
||||||
|
// update composite query with widget query if composite query is not present in url.
|
||||||
|
// Composite query should be in the url if switch to edit mode is clicked or drilldown happens from dashboard.
|
||||||
|
useEffect(() => {
|
||||||
|
if (enableDrillDown && !isMounted.current) {
|
||||||
|
redirectWithQueryBuilderData(compositeQuery || widget.query);
|
||||||
|
}
|
||||||
|
isMounted.current = true;
|
||||||
|
}, [widget, enableDrillDown, compositeQuery, redirectWithQueryBuilderData]);
|
||||||
|
|
||||||
|
const dashboardEditView = selectedDashboard?.id
|
||||||
|
? generateExportToDashboardLink({
|
||||||
|
query: currentQuery,
|
||||||
|
panelType: selectedPanelType,
|
||||||
|
dashboardId: selectedDashboard?.id || '',
|
||||||
|
widgetId: widget.id,
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const showResetQuery = useMemo(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(widget.query?.builder) !==
|
||||||
|
JSON.stringify(compositeQuery?.builder),
|
||||||
|
[widget.query, compositeQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResetQuery = useCallback((): void => {
|
||||||
|
redirectWithQueryBuilderData(
|
||||||
|
widget.query,
|
||||||
|
{
|
||||||
|
[QueryParams.expandedWidgetId]: widget.id,
|
||||||
|
[QueryParams.graphType]: widget.panelTypes,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}, [redirectWithQueryBuilderData, widget.query, widget.id, widget.panelTypes]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
drilldownQuery: compositeQuery || widget.query,
|
||||||
|
dashboardEditView,
|
||||||
|
handleResetQuery,
|
||||||
|
showResetQuery,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDrilldown;
|
||||||
@ -90,6 +90,7 @@ const mockProps: WidgetGraphComponentProps = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import {
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Widgets } from 'types/api/dashboard/getAll';
|
import { Widgets } from 'types/api/dashboard/getAll';
|
||||||
import { Props } from 'types/api/dashboard/update';
|
import { Props } from 'types/api/dashboard/update';
|
||||||
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
import { DataSource } from 'types/common/queryBuilder';
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
@ -62,6 +63,7 @@ function WidgetGraphComponent({
|
|||||||
customErrorMessage,
|
customErrorMessage,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
customTimeRangeWindowForCoRelation,
|
customTimeRangeWindowForCoRelation,
|
||||||
|
enableDrillDown,
|
||||||
}: WidgetGraphComponentProps): JSX.Element {
|
}: WidgetGraphComponentProps): JSX.Element {
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const [deleteModal, setDeleteModal] = useState(false);
|
const [deleteModal, setDeleteModal] = useState(false);
|
||||||
@ -236,6 +238,8 @@ function WidgetGraphComponent({
|
|||||||
const onToggleModelHandler = (): void => {
|
const onToggleModelHandler = (): void => {
|
||||||
const existingSearchParams = new URLSearchParams(search);
|
const existingSearchParams = new URLSearchParams(search);
|
||||||
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
||||||
|
existingSearchParams.delete(QueryParams.compositeQuery);
|
||||||
|
existingSearchParams.delete(QueryParams.graphType);
|
||||||
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
||||||
if (queryResponse.data?.payload) {
|
if (queryResponse.data?.payload) {
|
||||||
const {
|
const {
|
||||||
@ -365,6 +369,9 @@ function WidgetGraphComponent({
|
|||||||
onClickHandler={onClickHandler ?? graphClickHandler}
|
onClickHandler={onClickHandler ?? graphClickHandler}
|
||||||
customOnDragSelect={customOnDragSelect}
|
customOnDragSelect={customOnDragSelect}
|
||||||
setCurrentGraphRef={setCurrentGraphRef}
|
setCurrentGraphRef={setCurrentGraphRef}
|
||||||
|
enableDrillDown={
|
||||||
|
enableDrillDown && widget?.query?.queryType === EQueryType.QUERY_BUILDER
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@ -418,6 +425,7 @@ function WidgetGraphComponent({
|
|||||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||||
customSeries={customSeries}
|
customSeries={customSeries}
|
||||||
customOnRowClick={customOnRowClick}
|
customOnRowClick={customOnRowClick}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -430,6 +438,7 @@ WidgetGraphComponent.defaultProps = {
|
|||||||
setLayout: undefined,
|
setLayout: undefined,
|
||||||
onClickHandler: undefined,
|
onClickHandler: undefined,
|
||||||
customTimeRangeWindowForCoRelation: undefined,
|
customTimeRangeWindowForCoRelation: undefined,
|
||||||
|
enableDrillDown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WidgetGraphComponent;
|
export default WidgetGraphComponent;
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import { isEqual } from 'lodash-es';
|
|||||||
import isEmpty from 'lodash-es/isEmpty';
|
import isEmpty from 'lodash-es/isEmpty';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useQueryClient } from 'react-query';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { UpdateTimeInterval } from 'store/actions';
|
import { UpdateTimeInterval } from 'store/actions';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
@ -53,6 +52,8 @@ function GridCardGraph({
|
|||||||
customTimeRange,
|
customTimeRange,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
customTimeRangeWindowForCoRelation,
|
customTimeRangeWindowForCoRelation,
|
||||||
|
enableDrillDown,
|
||||||
|
widgetsHavingDynamicVariables,
|
||||||
}: GridCardGraphProps): JSX.Element {
|
}: GridCardGraphProps): JSX.Element {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [errorMessage, setErrorMessage] = useState<string>();
|
const [errorMessage, setErrorMessage] = useState<string>();
|
||||||
@ -62,14 +63,13 @@ function GridCardGraph({
|
|||||||
const {
|
const {
|
||||||
toScrollWidgetId,
|
toScrollWidgetId,
|
||||||
setToScrollWidgetId,
|
setToScrollWidgetId,
|
||||||
variablesToGetUpdated,
|
|
||||||
setDashboardQueryRangeCalled,
|
setDashboardQueryRangeCalled,
|
||||||
|
variablesToGetUpdated,
|
||||||
} = useDashboard();
|
} = useDashboard();
|
||||||
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
||||||
AppState,
|
AppState,
|
||||||
GlobalReducer
|
GlobalReducer
|
||||||
>((state) => state.globalTime);
|
>((state) => state.globalTime);
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handleBackNavigation = (): void => {
|
const handleBackNavigation = (): void => {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
@ -120,11 +120,7 @@ function GridCardGraph({
|
|||||||
const isEmptyWidget =
|
const isEmptyWidget =
|
||||||
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
|
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
|
||||||
|
|
||||||
const queryEnabledCondition =
|
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
|
||||||
isVisible &&
|
|
||||||
!isEmptyWidget &&
|
|
||||||
isQueryEnabled &&
|
|
||||||
isEmpty(variablesToGetUpdated);
|
|
||||||
|
|
||||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
||||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||||
@ -163,23 +159,6 @@ function GridCardGraph({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (variablesToGetUpdated.length > 0) {
|
|
||||||
queryClient.cancelQueries([
|
|
||||||
maxTime,
|
|
||||||
minTime,
|
|
||||||
globalSelectedInterval,
|
|
||||||
variables,
|
|
||||||
widget?.query,
|
|
||||||
widget?.panelTypes,
|
|
||||||
widget.timePreferance,
|
|
||||||
widget.fillSpans,
|
|
||||||
requestData,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [variablesToGetUpdated]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEqual(updatedQuery, requestData.query)) {
|
if (!isEqual(updatedQuery, requestData.query)) {
|
||||||
setRequestData((prev) => ({
|
setRequestData((prev) => ({
|
||||||
@ -199,6 +178,27 @@ function GridCardGraph({
|
|||||||
[requestData.query],
|
[requestData.query],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bring back dependency on variable chaining for panels to refetch,
|
||||||
|
// but only for non-dynamic variables. We derive a stable token from
|
||||||
|
// the head of the variablesToGetUpdated queue when it's non-dynamic.
|
||||||
|
const nonDynamicVariableChainToken = useMemo(() => {
|
||||||
|
if (!variablesToGetUpdated || variablesToGetUpdated.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!variables) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const headName = variablesToGetUpdated[0];
|
||||||
|
const variableObj = Object.values(variables).find(
|
||||||
|
(variable) => variable?.name === headName,
|
||||||
|
);
|
||||||
|
if (variableObj && variableObj.type !== 'DYNAMIC') {
|
||||||
|
return headName;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [variablesToGetUpdated, variables]);
|
||||||
|
|
||||||
const queryResponse = useGetQueryRange(
|
const queryResponse = useGetQueryRange(
|
||||||
{
|
{
|
||||||
...requestData,
|
...requestData,
|
||||||
@ -218,15 +218,29 @@ function GridCardGraph({
|
|||||||
maxTime,
|
maxTime,
|
||||||
minTime,
|
minTime,
|
||||||
globalSelectedInterval,
|
globalSelectedInterval,
|
||||||
variables,
|
|
||||||
widget?.query,
|
widget?.query,
|
||||||
widget?.panelTypes,
|
widget?.panelTypes,
|
||||||
widget.timePreferance,
|
widget.timePreferance,
|
||||||
widget.fillSpans,
|
widget.fillSpans,
|
||||||
requestData,
|
requestData,
|
||||||
|
variables
|
||||||
|
? Object.entries(variables).reduce((acc, [id, variable]) => {
|
||||||
|
if (
|
||||||
|
variable.type !== 'DYNAMIC' ||
|
||||||
|
(widgetsHavingDynamicVariables?.[variable.id] &&
|
||||||
|
widgetsHavingDynamicVariables?.[variable.id].includes(widget.id))
|
||||||
|
) {
|
||||||
|
return { ...acc, [id]: variable.selectedValue };
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
: {},
|
||||||
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
|
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
|
||||||
? [customTimeRange.startTime, customTimeRange.endTime]
|
? [customTimeRange.startTime, customTimeRange.endTime]
|
||||||
: []),
|
: []),
|
||||||
|
// Include non-dynamic variable chaining token to drive refetches
|
||||||
|
// only when a non-dynamic variable is at the head of the queue
|
||||||
|
...(nonDynamicVariableChainToken ? [nonDynamicVariableChainToken] : []),
|
||||||
],
|
],
|
||||||
retry(failureCount, error): boolean {
|
retry(failureCount, error): boolean {
|
||||||
if (
|
if (
|
||||||
@ -239,7 +253,7 @@ function GridCardGraph({
|
|||||||
return failureCount < 2;
|
return failureCount < 2;
|
||||||
},
|
},
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
enabled: queryEnabledCondition,
|
enabled: queryEnabledCondition && !nonDynamicVariableChainToken,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@ -317,6 +331,7 @@ function GridCardGraph({
|
|||||||
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
|
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
|
||||||
customOnRowClick={customOnRowClick}
|
customOnRowClick={customOnRowClick}
|
||||||
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
|
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -332,6 +347,7 @@ GridCardGraph.defaultProps = {
|
|||||||
version: 'v3',
|
version: 'v3',
|
||||||
analyticsEvent: undefined,
|
analyticsEvent: undefined,
|
||||||
customTimeRangeWindowForCoRelation: undefined,
|
customTimeRangeWindowForCoRelation: undefined,
|
||||||
|
enableDrillDown: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(GridCardGraph);
|
export default memo(GridCardGraph);
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export interface WidgetGraphComponentProps {
|
|||||||
customErrorMessage?: string;
|
customErrorMessage?: string;
|
||||||
customOnRowClick?: (record: RowData) => void;
|
customOnRowClick?: (record: RowData) => void;
|
||||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridCardGraphProps {
|
export interface GridCardGraphProps {
|
||||||
@ -69,6 +70,8 @@ export interface GridCardGraphProps {
|
|||||||
};
|
};
|
||||||
customOnRowClick?: (record: RowData) => void;
|
customOnRowClick?: (record: RowData) => void;
|
||||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
|
widgetsHavingDynamicVariables?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetGraphVisibilityStateOnLegendClickProps {
|
export interface GetGraphVisibilityStateOnLegendClickProps {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
|||||||
import { themeColors } from 'constants/theme';
|
import { themeColors } from 'constants/theme';
|
||||||
import { DEFAULT_ROW_NAME } from 'container/NewDashboard/DashboardDescription/utils';
|
import { DEFAULT_ROW_NAME } from 'container/NewDashboard/DashboardDescription/utils';
|
||||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||||
|
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
|
||||||
import useComponentPermission from 'hooks/useComponentPermission';
|
import useComponentPermission from 'hooks/useComponentPermission';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
@ -35,7 +36,7 @@ import { ItemCallback, Layout } from 'react-grid-layout';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { UpdateTimeInterval } from 'store/actions';
|
import { UpdateTimeInterval } from 'store/actions';
|
||||||
import { Widgets } from 'types/api/dashboard/getAll';
|
import { IDashboardVariable, Widgets } from 'types/api/dashboard/getAll';
|
||||||
import { Props } from 'types/api/dashboard/update';
|
import { Props } from 'types/api/dashboard/update';
|
||||||
import { ROLES, USER_ROLES } from 'types/roles';
|
import { ROLES, USER_ROLES } from 'types/roles';
|
||||||
import { ComponentTypes } from 'utils/permission';
|
import { ComponentTypes } from 'utils/permission';
|
||||||
@ -53,11 +54,12 @@ import { WidgetRowHeader } from './WidgetRow';
|
|||||||
|
|
||||||
interface GraphLayoutProps {
|
interface GraphLayoutProps {
|
||||||
handle: FullScreenHandle;
|
handle: FullScreenHandle;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||||
const { handle } = props;
|
const { handle, enableDrillDown = false } = props;
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
const {
|
const {
|
||||||
selectedDashboard,
|
selectedDashboard,
|
||||||
@ -97,6 +99,22 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
|||||||
Record<string, { widgets: Layout[]; collapsed: boolean }>
|
Record<string, { widgets: Layout[]; collapsed: boolean }>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
|
const widgetsHavingDynamicVariables = useMemo(() => {
|
||||||
|
const dynamicVariables = Object.values(
|
||||||
|
selectedDashboard?.data?.variables || {},
|
||||||
|
)?.filter((variable: IDashboardVariable) => variable.type === 'DYNAMIC');
|
||||||
|
|
||||||
|
const widgets =
|
||||||
|
selectedDashboard?.data?.widgets?.filter(
|
||||||
|
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
return createDynamicVariableToWidgetsMap(
|
||||||
|
dynamicVariables,
|
||||||
|
widgets as Widgets[],
|
||||||
|
);
|
||||||
|
}, [selectedDashboard]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPanelMap(panelMap);
|
setCurrentPanelMap(panelMap);
|
||||||
}, [panelMap]);
|
}, [panelMap]);
|
||||||
@ -584,6 +602,8 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
|||||||
version={ENTITY_VERSION_V5}
|
version={ENTITY_VERSION_V5}
|
||||||
onDragSelect={onDragSelect}
|
onDragSelect={onDragSelect}
|
||||||
dataAvailable={checkIfDataExists}
|
dataAvailable={checkIfDataExists}
|
||||||
|
enableDrillDown={enableDrillDown}
|
||||||
|
widgetsHavingDynamicVariables={widgetsHavingDynamicVariables}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</CardContainer>
|
</CardContainer>
|
||||||
@ -670,3 +690,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default GraphLayout;
|
export default GraphLayout;
|
||||||
|
|
||||||
|
GraphLayout.defaultProps = {
|
||||||
|
enableDrillDown: false,
|
||||||
|
};
|
||||||
|
|||||||
@ -131,6 +131,7 @@ describe('GridCardLayout Utils', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [],
|
clickhouse_sql: [],
|
||||||
promql: [],
|
promql: [],
|
||||||
@ -171,6 +172,7 @@ describe('GridCardLayout Utils', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -195,6 +197,7 @@ describe('GridCardLayout Utils', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -240,6 +243,7 @@ describe('GridCardLayout Utils', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -268,6 +272,7 @@ describe('GridCardLayout Utils', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,10 +4,17 @@ import GraphLayoutContainer from './GridCardLayout';
|
|||||||
|
|
||||||
interface GridGraphProps {
|
interface GridGraphProps {
|
||||||
handle: FullScreenHandle;
|
handle: FullScreenHandle;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
}
|
}
|
||||||
function GridGraph(props: GridGraphProps): JSX.Element {
|
function GridGraph(props: GridGraphProps): JSX.Element {
|
||||||
const { handle } = props;
|
const { handle, enableDrillDown = false } = props;
|
||||||
return <GraphLayoutContainer handle={handle} />;
|
return (
|
||||||
|
<GraphLayoutContainer handle={handle} enableDrillDown={enableDrillDown} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GridGraph;
|
export default GridGraph;
|
||||||
|
|
||||||
|
GridGraph.defaultProps = {
|
||||||
|
enableDrillDown: false,
|
||||||
|
};
|
||||||
|
|||||||
@ -4,10 +4,12 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
|||||||
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
|
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
|
||||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||||
import { useCallback } from 'react';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useMutation } from 'react-query';
|
import { useMutation } from 'react-query';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
import { getGraphType } from 'utils/getGraphType';
|
import { getGraphType } from 'utils/getGraphType';
|
||||||
@ -34,6 +36,16 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
|||||||
|
|
||||||
const queryRangeMutation = useMutation(getSubstituteVars);
|
const queryRangeMutation = useMutation(getSubstituteVars);
|
||||||
|
|
||||||
|
const { selectedDashboard } = useDashboard();
|
||||||
|
|
||||||
|
const dynamicVariables = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(selectedDashboard?.data?.variables || {})?.filter(
|
||||||
|
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
|
||||||
|
),
|
||||||
|
[selectedDashboard],
|
||||||
|
);
|
||||||
|
|
||||||
const getUpdatedQuery = useCallback(
|
const getUpdatedQuery = useCallback(
|
||||||
async ({
|
async ({
|
||||||
widgetConfig,
|
widgetConfig,
|
||||||
@ -47,6 +59,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
|||||||
globalSelectedInterval,
|
globalSelectedInterval,
|
||||||
variables: getDashboardVariables(selectedDashboard?.data?.variables),
|
variables: getDashboardVariables(selectedDashboard?.data?.variables),
|
||||||
originalGraphType: widgetConfig.panelTypes,
|
originalGraphType: widgetConfig.panelTypes,
|
||||||
|
dynamicVariables,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Execute query and process results
|
// Execute query and process results
|
||||||
@ -55,7 +68,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
|||||||
// Map query data from API response
|
// Map query data from API response
|
||||||
return mapQueryDataFromApi(queryResult.data.compositeQuery);
|
return mapQueryDataFromApi(queryResult.data.compositeQuery);
|
||||||
},
|
},
|
||||||
[globalSelectedInterval, queryRangeMutation],
|
[dynamicVariables, globalSelectedInterval, queryRangeMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -162,6 +162,7 @@ export const widgetQueryWithLegend = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
id: '48ad5a67-9a3c-49d4-a886-d7a34f8b875d',
|
id: '48ad5a67-9a3c-49d4-a886-d7a34f8b875d',
|
||||||
queryType: 'builder',
|
queryType: 'builder',
|
||||||
@ -457,6 +458,7 @@ export const widgetQueryQBv5MultiAggregations = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
id: 'qb-v5-multi-aggregations-test',
|
id: 'qb-v5-multi-aggregations-test',
|
||||||
queryType: 'builder',
|
queryType: 'builder',
|
||||||
|
|||||||
@ -46,6 +46,8 @@ function GridTableComponent({
|
|||||||
onOpenTraceBtnClick,
|
onOpenTraceBtnClick,
|
||||||
customOnRowClick,
|
customOnRowClick,
|
||||||
widgetId,
|
widgetId,
|
||||||
|
panelType,
|
||||||
|
queryRangeRequest,
|
||||||
...props
|
...props
|
||||||
}: GridTableComponentProps): JSX.Element {
|
}: GridTableComponentProps): JSX.Element {
|
||||||
const { t } = useTranslation(['valueGraph']);
|
const { t } = useTranslation(['valueGraph']);
|
||||||
@ -266,6 +268,8 @@ function GridTableComponent({
|
|||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
sticky={sticky}
|
sticky={sticky}
|
||||||
widgetId={widgetId}
|
widgetId={widgetId}
|
||||||
|
panelType={panelType}
|
||||||
|
queryRangeRequest={queryRangeRequest}
|
||||||
onRow={
|
onRow={
|
||||||
openTracesButton || customOnRowClick
|
openTracesButton || customOnRowClick
|
||||||
? (record): React.HTMLAttributes<HTMLElement> => ({
|
? (record): React.HTMLAttributes<HTMLElement> => ({
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { TableProps } from 'antd';
|
import { TableProps } from 'antd';
|
||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
|
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
|
||||||
import {
|
import {
|
||||||
ThresholdOperators,
|
ThresholdOperators,
|
||||||
@ -6,8 +7,9 @@ import {
|
|||||||
} from 'container/NewWidget/RightContainer/Threshold/types';
|
} from 'container/NewWidget/RightContainer/Threshold/types';
|
||||||
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
|
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
|
||||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||||
import { ColumnUnit } from 'types/api/dashboard/getAll';
|
import { ColumnUnit, ContextLinksData } from 'types/api/dashboard/getAll';
|
||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||||
|
|
||||||
export type GridTableComponentProps = {
|
export type GridTableComponentProps = {
|
||||||
query: Query;
|
query: Query;
|
||||||
@ -22,6 +24,10 @@ export type GridTableComponentProps = {
|
|||||||
widgetId?: string;
|
widgetId?: string;
|
||||||
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
||||||
customColTitles?: Record<string, string>;
|
customColTitles?: Record<string, string>;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
|
panelType?: PANEL_TYPES;
|
||||||
|
queryRangeRequest?: QueryRangeRequestV5;
|
||||||
} & Pick<LogsExplorerTableProps, 'data'> &
|
} & Pick<LogsExplorerTableProps, 'data'> &
|
||||||
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable sonarjs/cognitive-complexity */
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
import { ColumnsType, ColumnType } from 'antd/es/table';
|
import { ColumnType } from 'antd/es/table';
|
||||||
import { convertUnit } from 'container/NewWidget/RightContainer/dataFormatCategories';
|
import { convertUnit } from 'container/NewWidget/RightContainer/dataFormatCategories';
|
||||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||||
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
|
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
|
||||||
@ -9,6 +9,12 @@ import { isEmpty, isNaN } from 'lodash-es';
|
|||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { EQueryType } from 'types/common/dashboard';
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
|
|
||||||
|
// Custom column type that extends ColumnType to include isValueColumn
|
||||||
|
export interface CustomDataColumnType<T> extends ColumnType<T> {
|
||||||
|
isValueColumn?: boolean;
|
||||||
|
queryName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to evaluate the condition based on the operator
|
// Helper function to evaluate the condition based on the operator
|
||||||
function evaluateCondition(
|
function evaluateCondition(
|
||||||
operator: string | undefined,
|
operator: string | undefined,
|
||||||
@ -184,9 +190,9 @@ export function createColumnsAndDataSource(
|
|||||||
data: TableData,
|
data: TableData,
|
||||||
currentQuery: Query,
|
currentQuery: Query,
|
||||||
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
||||||
): { columns: ColumnsType<RowData>; dataSource: RowData[] } {
|
): { columns: CustomDataColumnType<RowData>[]; dataSource: RowData[] } {
|
||||||
const columns: ColumnsType<RowData> =
|
const columns: CustomDataColumnType<RowData>[] =
|
||||||
data.columns?.reduce<ColumnsType<RowData>>((acc, item) => {
|
data.columns?.reduce<CustomDataColumnType<RowData>[]>((acc, item) => {
|
||||||
// is the column is the value column then we need to check for the available legend
|
// is the column is the value column then we need to check for the available legend
|
||||||
const legend = item.isValueColumn
|
const legend = item.isValueColumn
|
||||||
? getQueryLegend(currentQuery, item.queryName)
|
? getQueryLegend(currentQuery, item.queryName)
|
||||||
@ -197,11 +203,13 @@ export function createColumnsAndDataSource(
|
|||||||
(query) => query.queryName === item.queryName,
|
(query) => query.queryName === item.queryName,
|
||||||
)?.aggregations?.length || 0;
|
)?.aggregations?.length || 0;
|
||||||
|
|
||||||
const column: ColumnType<RowData> = {
|
const column: CustomDataColumnType<RowData> = {
|
||||||
dataIndex: item.id || item.name,
|
dataIndex: item.id || item.name,
|
||||||
// if no legend present then rely on the column name value
|
// if no legend present then rely on the column name value
|
||||||
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
|
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
|
||||||
width: QUERY_TABLE_CONFIG.width,
|
width: QUERY_TABLE_CONFIG.width,
|
||||||
|
isValueColumn: item.isValueColumn,
|
||||||
|
queryName: item.queryName,
|
||||||
render: renderColumnCell && renderColumnCell[item.id],
|
render: renderColumnCell && renderColumnCell[item.id],
|
||||||
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
|
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import { Typography } from 'antd';
|
|||||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||||
import ValueGraph from 'components/ValueGraph';
|
import ValueGraph from 'components/ValueGraph';
|
||||||
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
|
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
|
||||||
|
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||||
|
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
|
|
||||||
import { TitleContainer, ValueContainer } from './styles';
|
import { TitleContainer, ValueContainer } from './styles';
|
||||||
import { GridValueComponentProps } from './types';
|
import { GridValueComponentProps } from './types';
|
||||||
@ -13,6 +16,10 @@ function GridValueComponent({
|
|||||||
title,
|
title,
|
||||||
yAxisUnit,
|
yAxisUnit,
|
||||||
thresholds,
|
thresholds,
|
||||||
|
widget,
|
||||||
|
queryResponse,
|
||||||
|
contextLinks,
|
||||||
|
enableDrillDown = false,
|
||||||
}: GridValueComponentProps): JSX.Element {
|
}: GridValueComponentProps): JSX.Element {
|
||||||
const value = ((data[1] || [])[0] || 0) as number;
|
const value = ((data[1] || [])[0] || 0) as number;
|
||||||
|
|
||||||
@ -21,6 +28,39 @@ function GridValueComponent({
|
|||||||
|
|
||||||
const isDashboardPage = location.pathname.split('/').length === 3;
|
const isDashboardPage = location.pathname.split('/').length === 3;
|
||||||
|
|
||||||
|
const {
|
||||||
|
coordinates,
|
||||||
|
popoverPosition,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
clickedData,
|
||||||
|
} = useCoordinates();
|
||||||
|
|
||||||
|
const { menuItemsConfig } = useGraphContextMenu({
|
||||||
|
widgetId: widget?.id || '',
|
||||||
|
query: widget?.query || {
|
||||||
|
queryType: EQueryType.QUERY_BUILDER,
|
||||||
|
promql: [],
|
||||||
|
builder: {
|
||||||
|
queryFormulas: [],
|
||||||
|
queryData: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
|
},
|
||||||
|
clickhouse_sql: [],
|
||||||
|
id: '',
|
||||||
|
},
|
||||||
|
graphData: clickedData,
|
||||||
|
onClose,
|
||||||
|
coordinates,
|
||||||
|
subMenu,
|
||||||
|
setSubMenu,
|
||||||
|
contextLinks: contextLinks || { linksData: [] },
|
||||||
|
panelType: widget?.panelTypes,
|
||||||
|
queryRange: queryResponse,
|
||||||
|
});
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<ValueContainer>
|
<ValueContainer>
|
||||||
@ -29,12 +69,31 @@ function GridValueComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isQueryTypeBuilder =
|
||||||
|
widget?.query?.queryType === EQueryType.QUERY_BUILDER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TitleContainer isDashboardPage={isDashboardPage}>
|
<TitleContainer isDashboardPage={isDashboardPage}>
|
||||||
<Typography>{gridTitle}</Typography>
|
<Typography>{gridTitle}</Typography>
|
||||||
</TitleContainer>
|
</TitleContainer>
|
||||||
<ValueContainer>
|
<ValueContainer
|
||||||
|
showClickable={enableDrillDown && isQueryTypeBuilder}
|
||||||
|
onClick={(e): void => {
|
||||||
|
const queryName = (queryResponse?.data?.params as any)?.compositeQuery
|
||||||
|
?.queries[0]?.spec?.name;
|
||||||
|
|
||||||
|
if (!enableDrillDown || !queryName || !isQueryTypeBuilder) return;
|
||||||
|
|
||||||
|
// when multiple queries are present, we need to get the query name from the queryResponse
|
||||||
|
// since value panel shows result for the first query
|
||||||
|
const clickedData = {
|
||||||
|
queryName,
|
||||||
|
filters: [],
|
||||||
|
};
|
||||||
|
onClick({ x: e.clientX, y: e.clientY }, clickedData);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ValueGraph
|
<ValueGraph
|
||||||
thresholds={thresholds || []}
|
thresholds={thresholds || []}
|
||||||
rawValue={value}
|
rawValue={value}
|
||||||
@ -45,6 +104,13 @@ function GridValueComponent({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ValueContainer>
|
</ValueContainer>
|
||||||
|
<ContextMenu
|
||||||
|
coordinates={coordinates}
|
||||||
|
popoverPosition={popoverPosition}
|
||||||
|
title={menuItemsConfig.header as string}
|
||||||
|
items={menuItemsConfig.items}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,19 @@ interface Props {
|
|||||||
isDashboardPage: boolean;
|
isDashboardPage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ValueContainer = styled.div`
|
interface ValueContainerProps {
|
||||||
|
showClickable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValueContainer = styled.div<ValueContainerProps>`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
user-select: none;
|
||||||
|
cursor: ${({ showClickable = false }): string =>
|
||||||
|
showClickable ? 'pointer' : 'default'};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const TitleContainer = styled.div<Props>`
|
export const TitleContainer = styled.div<Props>`
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||||
|
import { UseQueryResult } from 'react-query';
|
||||||
|
import { SuccessResponse } from 'types/api';
|
||||||
|
import { ContextLinksData, Widgets } from 'types/api/dashboard/getAll';
|
||||||
|
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
|
|
||||||
export type GridValueComponentProps = {
|
export type GridValueComponentProps = {
|
||||||
@ -7,4 +11,12 @@ export type GridValueComponentProps = {
|
|||||||
title?: React.ReactNode;
|
title?: React.ReactNode;
|
||||||
yAxisUnit?: string;
|
yAxisUnit?: string;
|
||||||
thresholds?: ThresholdProps[];
|
thresholds?: ThresholdProps[];
|
||||||
|
// Context menu related props
|
||||||
|
widget?: Widgets;
|
||||||
|
queryResponse?: UseQueryResult<
|
||||||
|
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||||
|
Error
|
||||||
|
>;
|
||||||
|
contextLinks?: ContextLinksData;
|
||||||
|
enableDrillDown?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -301,6 +301,7 @@ export const getClusterMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -490,6 +491,7 @@ export const getClusterMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -575,6 +577,7 @@ export const getClusterMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -660,6 +663,7 @@ export const getClusterMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -797,6 +801,7 @@ export const getClusterMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1050,6 +1055,7 @@ export const getClusterMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1257,6 +1263,7 @@ export const getClusterMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1522,6 +1529,7 @@ export const getClusterMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -233,6 +233,7 @@ export const getDaemonSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -416,6 +417,7 @@ export const getDaemonSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -512,6 +514,7 @@ export const getDaemonSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -608,6 +611,7 @@ export const getDaemonSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -196,6 +196,7 @@ export const getDeploymentMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -346,6 +347,7 @@ export const getDeploymentMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -431,6 +433,7 @@ export const getDeploymentMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -516,6 +519,7 @@ export const getDeploymentMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -105,7 +105,7 @@ function EntityMetrics<T>({
|
|||||||
signal,
|
signal,
|
||||||
}: QueryFunctionContext): Promise<
|
}: QueryFunctionContext): Promise<
|
||||||
SuccessResponse<MetricRangePayloadProps>
|
SuccessResponse<MetricRangePayloadProps>
|
||||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, signal),
|
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
|
||||||
enabled: !!payload && visibilities[index],
|
enabled: !!payload && visibilities[index],
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@ -50,8 +50,10 @@ jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockUseQueries = jest.fn();
|
const mockUseQueries = jest.fn();
|
||||||
|
const mockUseQuery = jest.fn();
|
||||||
jest.mock('react-query', () => ({
|
jest.mock('react-query', () => ({
|
||||||
useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs),
|
useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs),
|
||||||
|
useQuery: (config: any): any => mockUseQuery(config),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('hooks/useDarkMode', () => ({
|
jest.mock('hooks/useDarkMode', () => ({
|
||||||
@ -302,6 +304,20 @@ describe('EntityMetrics', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
mockUseQueries.mockReturnValue(mockQueries);
|
mockUseQueries.mockReturnValue(mockQueries);
|
||||||
|
mockUseQuery.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
variables: {},
|
||||||
|
title: 'Test Dashboard',
|
||||||
|
},
|
||||||
|
id: 'test-dashboard-id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render metrics with data', () => {
|
it('should render metrics with data', () => {
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export const getEntityEventsOrLogsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
queryType: EQueryType.QUERY_BUILDER,
|
queryType: EQueryType.QUERY_BUILDER,
|
||||||
@ -226,6 +227,7 @@ export const getEntityTracesQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
id: '572f1d91-6ac0-46c0-b726-c21488b34434',
|
id: '572f1d91-6ac0-46c0-b726-c21488b34434',
|
||||||
queryType: EQueryType.QUERY_BUILDER,
|
queryType: EQueryType.QUERY_BUILDER,
|
||||||
|
|||||||
@ -108,6 +108,7 @@ export const getJobMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -191,6 +192,7 @@ export const getJobMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -287,6 +289,7 @@ export const getJobMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -383,6 +386,7 @@ export const getJobMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -309,6 +309,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -576,6 +577,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -655,6 +657,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -734,6 +737,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -819,6 +823,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -904,6 +909,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1075,6 +1081,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1212,6 +1219,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1429,6 +1437,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1561,6 +1570,7 @@ export const getNamespaceMetricsQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -341,6 +341,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -647,6 +648,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -810,6 +812,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
queryName: 'F2',
|
queryName: 'F2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -973,6 +976,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
queryName: 'F2',
|
queryName: 'F2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1052,6 +1056,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1131,6 +1136,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1216,6 +1222,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1301,6 +1308,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1451,6 +1459,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1569,6 +1578,7 @@ export const getNodeMetricsQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -335,6 +335,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -668,6 +669,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -851,6 +853,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1184,6 +1187,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1324,6 +1328,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1407,6 +1412,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1497,6 +1503,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1714,6 +1721,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
queryName: 'F2',
|
queryName: 'F2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -1918,6 +1926,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -2135,6 +2144,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
queryName: 'F2',
|
queryName: 'F2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -2231,6 +2241,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -2327,6 +2338,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
@ -2510,6 +2522,7 @@ export const getPodMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [
|
clickhouse_sql: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -246,6 +246,7 @@ export const getStatefulSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@ -365,6 +366,7 @@ export const getStatefulSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@ -534,6 +536,7 @@ export const getStatefulSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@ -653,6 +656,7 @@ export const getStatefulSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@ -735,6 +739,7 @@ export const getStatefulSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@ -817,6 +822,7 @@ export const getStatefulSetMetricsQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
|
|||||||
@ -148,6 +148,7 @@ export const getVolumeQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@ -239,6 +240,7 @@ export const getVolumeQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@ -330,6 +332,7 @@ export const getVolumeQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@ -421,6 +424,7 @@ export const getVolumeQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@ -512,6 +516,7 @@ export const getVolumeQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: v4(),
|
id: v4(),
|
||||||
|
|||||||
@ -58,6 +58,7 @@ export const mockQuery: Query = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [],
|
clickhouse_sql: [],
|
||||||
id: 'test-query-id',
|
id: 'test-query-id',
|
||||||
|
|||||||
@ -121,6 +121,7 @@ export const getPodQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '9b92756a-b445-45f8-90f4-d26f3ef28f8f',
|
id: '9b92756a-b445-45f8-90f4-d26f3ef28f8f',
|
||||||
@ -197,6 +198,7 @@ export const getPodQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: 'a22c1e03-4876-4b3e-9a96-a3c3a28f9c0f',
|
id: 'a22c1e03-4876-4b3e-9a96-a3c3a28f9c0f',
|
||||||
@ -337,6 +339,7 @@ export const getPodQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '7bb3a6f5-d1c6-4f2e-9cc9-7dcc46db398f',
|
id: '7bb3a6f5-d1c6-4f2e-9cc9-7dcc46db398f',
|
||||||
@ -477,6 +480,7 @@ export const getPodQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '6d5ccd81-0ea1-4fb9-a66b-7f0fe2f15165',
|
id: '6d5ccd81-0ea1-4fb9-a66b-7f0fe2f15165',
|
||||||
@ -624,6 +628,7 @@ export const getPodQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '4d03a0ff-4fa5-4b19-b397-97f80ba9e0ac',
|
id: '4d03a0ff-4fa5-4b19-b397-97f80ba9e0ac',
|
||||||
@ -772,6 +777,7 @@ export const getPodQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: 'ad491f19-0f83-4dd4-bb8f-bec295c18d1b',
|
id: 'ad491f19-0f83-4dd4-bb8f-bec295c18d1b',
|
||||||
@ -920,6 +926,7 @@ export const getPodQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '16908d4e-1565-4847-8d87-01ebb8fc494a',
|
id: '16908d4e-1565-4847-8d87-01ebb8fc494a',
|
||||||
@ -1001,6 +1008,7 @@ export const getPodQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '4b255d6d-4cde-474d-8866-f4418583c18b',
|
id: '4b255d6d-4cde-474d-8866-f4418583c18b',
|
||||||
@ -1177,6 +1185,7 @@ export const getNodeQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '259295b5-774d-4b2e-8a4f-e5dd63e6c38d',
|
id: '259295b5-774d-4b2e-8a4f-e5dd63e6c38d',
|
||||||
@ -1314,6 +1323,7 @@ export const getNodeQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '486af4da-2a1a-4b8f-992c-eba098d3a6f9',
|
id: '486af4da-2a1a-4b8f-992c-eba098d3a6f9',
|
||||||
@ -1409,6 +1419,7 @@ export const getNodeQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: 'b56143c0-7d2f-4425-97c5-65ad6fc87366',
|
id: 'b56143c0-7d2f-4425-97c5-65ad6fc87366',
|
||||||
@ -1557,6 +1568,7 @@ export const getNodeQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '57eeac15-615c-4a71-9c61-8e0c0c76b045',
|
id: '57eeac15-615c-4a71-9c61-8e0c0c76b045',
|
||||||
@ -1718,6 +1730,7 @@ export const getHostQueryPayload = (
|
|||||||
queryName: 'F1',
|
queryName: 'F1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||||
@ -1786,6 +1799,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '40218bfb-a9b7-4974-aead-5bf666e139bf',
|
id: '40218bfb-a9b7-4974-aead-5bf666e139bf',
|
||||||
@ -1928,6 +1942,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '8e6485ea-7018-43b0-ab27-b210f77b59ad',
|
id: '8e6485ea-7018-43b0-ab27-b210f77b59ad',
|
||||||
@ -2009,6 +2024,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '47173220-44df-4ef6-87f4-31e333c180c7',
|
id: '47173220-44df-4ef6-87f4-31e333c180c7',
|
||||||
@ -2084,6 +2100,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '62eedbc6-c8ad-4d13-80a8-129396e1d1dc',
|
id: '62eedbc6-c8ad-4d13-80a8-129396e1d1dc',
|
||||||
@ -2159,6 +2176,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '5ddb1b38-53bb-46f5-b4fe-fe832d6b9b24',
|
id: '5ddb1b38-53bb-46f5-b4fe-fe832d6b9b24',
|
||||||
@ -2234,6 +2252,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: 'a849bcce-7684-4852-9134-530b45419b8f',
|
id: 'a849bcce-7684-4852-9134-530b45419b8f',
|
||||||
@ -2309,6 +2328,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: 'ab685a3d-fa4c-4663-8d94-c452e59038f3',
|
id: 'ab685a3d-fa4c-4663-8d94-c452e59038f3',
|
||||||
@ -2369,6 +2389,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '9bd40b51-0790-4cdd-9718-551b2ded5926',
|
id: '9bd40b51-0790-4cdd-9718-551b2ded5926',
|
||||||
@ -2450,6 +2471,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: '9c6d18ad-89ff-4e38-a15a-440e72ed6ca8',
|
id: '9c6d18ad-89ff-4e38-a15a-440e72ed6ca8',
|
||||||
@ -2524,6 +2546,7 @@ export const getHostQueryPayload = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||||
id: 'f4cfc2a5-78fc-42cc-8f4a-194c8c916132',
|
id: 'f4cfc2a5-78fc-42cc-8f4a-194c8c916132',
|
||||||
|
|||||||
@ -50,7 +50,6 @@ function LogsExplorerList({
|
|||||||
isFilterApplied,
|
isFilterApplied,
|
||||||
}: LogsExplorerListProps): JSX.Element {
|
}: LogsExplorerListProps): JSX.Element {
|
||||||
const ref = useRef<VirtuosoHandle>(null);
|
const ref = useRef<VirtuosoHandle>(null);
|
||||||
|
|
||||||
const { activeLogId } = useCopyLogLink();
|
const { activeLogId } = useCopyLogLink();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -178,6 +178,10 @@ export const mockQueryBuilderContextValue = {
|
|||||||
panelType: PANEL_TYPES.TIME_SERIES,
|
panelType: PANEL_TYPES.TIME_SERIES,
|
||||||
isEnabledQuery: false,
|
isEnabledQuery: false,
|
||||||
lastUsedQuery: 0,
|
lastUsedQuery: 0,
|
||||||
|
handleSetTraceOperatorData: noop,
|
||||||
|
removeAllQueryBuilderEntities: noop,
|
||||||
|
removeTraceOperator: noop,
|
||||||
|
addTraceOperator: noop,
|
||||||
setLastUsedQuery: noop,
|
setLastUsedQuery: noop,
|
||||||
handleSetQueryData: noop,
|
handleSetQueryData: noop,
|
||||||
handleSetFormulaData: noop,
|
handleSetFormulaData: noop,
|
||||||
|
|||||||
@ -71,6 +71,7 @@ export function getWidgetQuery(
|
|||||||
builder: {
|
builder: {
|
||||||
queryData: props.queryData,
|
queryData: props.queryData,
|
||||||
queryFormulas: (props.queryFormulas as IBuilderFormula[]) || [],
|
queryFormulas: (props.queryFormulas as IBuilderFormula[]) || [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [],
|
clickhouse_sql: [],
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
|||||||
@ -64,6 +64,7 @@ export const getQueryBuilderQueries = ({
|
|||||||
|
|
||||||
return newQueryData;
|
return newQueryData;
|
||||||
}),
|
}),
|
||||||
|
queryTraceOperator: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getQueryBuilderQuerieswithFormula = ({
|
export const getQueryBuilderQuerieswithFormula = ({
|
||||||
@ -106,4 +107,5 @@ export const getQueryBuilderQuerieswithFormula = ({
|
|||||||
}),
|
}),
|
||||||
dataSource,
|
dataSource,
|
||||||
})),
|
})),
|
||||||
|
queryTraceOperator: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -71,6 +71,7 @@ export const useGetRelatedMetricsGraphs = ({
|
|||||||
builder: {
|
builder: {
|
||||||
queryData: [metric.query],
|
queryData: [metric.query],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
clickhouse_sql: [],
|
clickhouse_sql: [],
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
|
|||||||
@ -150,6 +150,7 @@ export function getMetricDetailsQuery(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFormulas: [],
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,12 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apply-to-all-variable-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bg-robin-400);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-variable-settings-table {
|
.dashboard-variable-settings-table {
|
||||||
.variable-name-drag {
|
.variable-name-drag {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -70,6 +76,17 @@
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apply-to-all-button {
|
||||||
|
width: min-content;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: flex;
|
||||||
|
padding: 0px 6px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
background: var(--bg-slate-400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,6 +129,10 @@
|
|||||||
.edit-variable-button {
|
.edit-variable-button {
|
||||||
background: var(--bg-vanilla-300);
|
background: var(--bg-vanilla-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apply-to-all-button {
|
||||||
|
background: var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
.dynamic-variable-container {
|
||||||
|
margin: 24px 0;
|
||||||
|
|
||||||
|
.dynamic-variable-config-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 32px 16px 200px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.ant-select {
|
||||||
|
.ant-select-selector {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-variable-from-text {
|
||||||
|
font-family: 'Space Mono';
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.dynamic-variable-container {
|
||||||
|
.dynamic-variable-config-container {
|
||||||
|
.ant-select {
|
||||||
|
.ant-select-selector {
|
||||||
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,229 @@
|
|||||||
|
import './DynamicVariable.styles.scss';
|
||||||
|
|
||||||
|
import { Color } from '@signozhq/design-tokens';
|
||||||
|
import { Select, Typography } from 'antd';
|
||||||
|
import CustomSelect from 'components/NewSelect/CustomSelect';
|
||||||
|
import TextToolTip from 'components/TextToolTip';
|
||||||
|
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||||
|
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
|
import useDebounce from 'hooks/useDebounce';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { FieldKey } from 'types/api/dynamicVariables/getFieldKeys';
|
||||||
|
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
|
||||||
|
|
||||||
|
enum AttributeSource {
|
||||||
|
ALL_TELEMETRY = 'All telemetry',
|
||||||
|
LOGS = 'Logs',
|
||||||
|
METRICS = 'Metrics',
|
||||||
|
TRACES = 'Traces',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DynamicVariable({
|
||||||
|
setDynamicVariablesSelectedValue,
|
||||||
|
dynamicVariablesSelectedValue,
|
||||||
|
errorAttributeKeyMessage,
|
||||||
|
}: {
|
||||||
|
setDynamicVariablesSelectedValue: Dispatch<
|
||||||
|
SetStateAction<
|
||||||
|
| {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
dynamicVariablesSelectedValue:
|
||||||
|
| {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
errorAttributeKeyMessage?: string;
|
||||||
|
}): JSX.Element {
|
||||||
|
const sources = [
|
||||||
|
AttributeSource.ALL_TELEMETRY,
|
||||||
|
AttributeSource.LOGS,
|
||||||
|
AttributeSource.TRACES,
|
||||||
|
AttributeSource.METRICS,
|
||||||
|
];
|
||||||
|
|
||||||
|
const [attributeSource, setAttributeSource] = useState<AttributeSource>();
|
||||||
|
const [attributes, setAttributes] = useState<Record<string, FieldKey[]>>({});
|
||||||
|
const [selectedAttribute, setSelectedAttribute] = useState<string>();
|
||||||
|
const [apiSearchText, setApiSearchText] = useState<string>('');
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string>();
|
||||||
|
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
|
||||||
|
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
|
||||||
|
|
||||||
|
const [filteredAttributes, setFilteredAttributes] = useState<
|
||||||
|
Record<string, FieldKey[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dynamicVariablesSelectedValue?.name) {
|
||||||
|
setSelectedAttribute(dynamicVariablesSelectedValue.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dynamicVariablesSelectedValue?.value) {
|
||||||
|
setAttributeSource(dynamicVariablesSelectedValue.value as AttributeSource);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
dynamicVariablesSelectedValue?.name,
|
||||||
|
dynamicVariablesSelectedValue?.value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data, error, isLoading, refetch } = useGetFieldKeys({
|
||||||
|
signal:
|
||||||
|
attributeSource === AttributeSource.ALL_TELEMETRY
|
||||||
|
? undefined
|
||||||
|
: (attributeSource?.toLowerCase() as 'traces' | 'logs' | 'metrics'),
|
||||||
|
name: debouncedApiSearchText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isComplete = useMemo(() => data?.data?.complete === true, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
const newAttributes = data.data?.keys ?? {};
|
||||||
|
setAttributes(newAttributes);
|
||||||
|
setFilteredAttributes(newAttributes);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Handle error from useGetFieldKeys
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
// Check if error is retryable (5xx) or not (4xx)
|
||||||
|
const isRetryable = checkIfRetryableError(error);
|
||||||
|
setIsRetryableError(isRetryable);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
// refetch when attributeSource changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (attributeSource) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}, [attributeSource, refetch, debouncedApiSearchText]);
|
||||||
|
|
||||||
|
// Handle search based on whether we have complete data or not
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (isComplete) {
|
||||||
|
// If complete is true, do client-side filtering
|
||||||
|
if (!text) {
|
||||||
|
setFilteredAttributes(attributes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered: Record<string, FieldKey[]> = {};
|
||||||
|
Object.keys(attributes).forEach((key) => {
|
||||||
|
if (key.toLowerCase().includes(text.toLowerCase())) {
|
||||||
|
filtered[key] = attributes[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFilteredAttributes(filtered);
|
||||||
|
} else {
|
||||||
|
// If complete is false, debounce the API call
|
||||||
|
setApiSearchText(text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attributes, isComplete],
|
||||||
|
);
|
||||||
|
|
||||||
|
// update setDynamicVariablesSelectedValue with debounce when attribute and source is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAttribute || attributeSource) {
|
||||||
|
setDynamicVariablesSelectedValue({
|
||||||
|
name: selectedAttribute || dynamicVariablesSelectedValue?.name || '',
|
||||||
|
value:
|
||||||
|
attributeSource ||
|
||||||
|
dynamicVariablesSelectedValue?.value ||
|
||||||
|
AttributeSource.ALL_TELEMETRY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
selectedAttribute,
|
||||||
|
attributeSource,
|
||||||
|
setDynamicVariablesSelectedValue,
|
||||||
|
dynamicVariablesSelectedValue?.name,
|
||||||
|
dynamicVariablesSelectedValue?.value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
const errorText = (error as any)?.message || errorMessage;
|
||||||
|
return (
|
||||||
|
<div className="dynamic-variable-container">
|
||||||
|
<div className="dynamic-variable-config-container">
|
||||||
|
<CustomSelect
|
||||||
|
placeholder="Select a field"
|
||||||
|
options={Object.keys(filteredAttributes).map((key) => ({
|
||||||
|
label: key,
|
||||||
|
value: key,
|
||||||
|
}))}
|
||||||
|
loading={isLoading}
|
||||||
|
status={errorText ? 'error' : undefined}
|
||||||
|
onChange={(value): void => {
|
||||||
|
setSelectedAttribute(value);
|
||||||
|
}}
|
||||||
|
showSearch
|
||||||
|
errorMessage={errorText as any}
|
||||||
|
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onRetry={(): void => {
|
||||||
|
// reset error message
|
||||||
|
setErrorMessage(undefined);
|
||||||
|
setIsRetryableError(true);
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
showRetryButton={isRetryableError}
|
||||||
|
/>
|
||||||
|
<Typography className="dynamic-variable-from-text">from</Typography>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||||
|
<TextToolTip
|
||||||
|
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||||
|
useFilledIcon={false}
|
||||||
|
outlinedIcon={
|
||||||
|
<Info
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
placeholder="Source"
|
||||||
|
defaultValue={AttributeSource.ALL_TELEMETRY}
|
||||||
|
options={sources.map((source) => ({ label: source, value: source }))}
|
||||||
|
onChange={(value): void => setAttributeSource(value as AttributeSource)}
|
||||||
|
value={attributeSource || dynamicVariablesSelectedValue?.value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errorAttributeKeyMessage && (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="warning">
|
||||||
|
{errorAttributeKeyMessage}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicVariable.defaultProps = {
|
||||||
|
errorAttributeKeyMessage: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DynamicVariable;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user