Compare commits
309 Commits
feat/log-d
...
feat/cross
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001f14c017 | ||
|
|
aa847b71ad | ||
|
|
9ad6ab49f0 | ||
|
|
8f6b853bb9 | ||
|
|
4e4f7cc521 | ||
|
|
d794404f31 | ||
|
|
7d81f1e665 | ||
|
|
3b8033c7ec | ||
|
|
70e6b61660 | ||
|
|
2b4cd5d1fb | ||
|
|
73c279cac9 | ||
|
|
73ce96e3a6 | ||
|
|
8217e1e0cb | ||
|
|
e24da43559 | ||
|
|
6efbce3ea1 | ||
|
|
eb063f7ac0 | ||
|
|
b01f8ae170 | ||
|
|
10167f7cd1 | ||
|
|
a98b56d994 | ||
|
|
6eb2546398 | ||
|
|
47b447099d | ||
|
|
35a1875d45 | ||
|
|
d971224169 | ||
|
|
c72ef90209 | ||
|
|
6c41aa1420 | ||
|
|
d2a175db9c | ||
|
|
5a149a9a4f | ||
|
|
0319e1b816 | ||
|
|
215707304a | ||
|
|
cb22545031 | ||
|
|
9f40bd6a9f | ||
|
|
3a78b13e0c | ||
|
|
2323ce4aeb | ||
|
|
72272799ee | ||
|
|
73d635149f | ||
|
|
9bd55dfa6c | ||
|
|
822338ace8 | ||
|
|
37bb8e95a8 | ||
|
|
7eaab9cd21 | ||
|
|
7c97b8f880 | ||
|
|
6f1dd4d10a | ||
|
|
8a9f67b17c | ||
|
|
d16e26b5e4 | ||
|
|
36dd024f69 | ||
|
|
12f61bdccf | ||
|
|
46307ed4f4 | ||
|
|
8e20150e48 | ||
|
|
15f857bced | ||
|
|
e4b0388de5 | ||
|
|
bcd2ebed47 | ||
|
|
dae61cfa7a | ||
|
|
c6b6e84db6 | ||
|
|
bf02c6b500 | ||
|
|
27e3700e27 | ||
|
|
5c0ece454a | ||
|
|
4c86c0650c | ||
|
|
3c380353a3 | ||
|
|
917814e903 | ||
|
|
ff6f3a382d | ||
|
|
30e6a3b248 | ||
|
|
05c58a2b3b | ||
|
|
c638b3be39 | ||
|
|
e2df0ffc87 | ||
|
|
c222350f6e | ||
|
|
0ce9531a7a | ||
|
|
e23a569d53 | ||
|
|
5632f05d51 | ||
|
|
f9512dd37c | ||
|
|
5eb4e54913 | ||
|
|
2f53a2471d | ||
|
|
ff38ceaecf | ||
|
|
e28d9977be | ||
|
|
dd0a263008 | ||
|
|
0df85ae46b | ||
|
|
aadbf6c316 | ||
|
|
6cb1ffdbc2 | ||
|
|
83df91bba5 | ||
|
|
796497adfc | ||
|
|
049f1f396d | ||
|
|
4fb993bb6e | ||
|
|
6251fd42b2 | ||
|
|
0b36e17090 | ||
|
|
f150d320b8 | ||
|
|
1d08233ed4 | ||
|
|
0a3d40806a | ||
|
|
be7b3e7f9b | ||
|
|
3ca0fd8029 | ||
|
|
45015c1e9b | ||
|
|
4b95010f14 | ||
|
|
1e66ce6b63 | ||
|
|
4690e201d6 | ||
|
|
7780dc3248 | ||
|
|
65609c62cc | ||
|
|
a6790e2997 | ||
|
|
c0847285ab | ||
|
|
c4d2b70689 | ||
|
|
59702e16e0 | ||
|
|
eb3bb41d0a | ||
|
|
ac44c92ab6 | ||
|
|
58c8310634 | ||
|
|
90eebe207e | ||
|
|
c09eae6386 | ||
|
|
797f7e2487 | ||
|
|
ef4446cd35 | ||
|
|
0e0fa9ebea | ||
|
|
5f768fec48 | ||
|
|
274fd8b51f | ||
|
|
57c8381f68 | ||
|
|
067919cd7d | ||
|
|
13f2cc8115 | ||
|
|
22a5420340 | ||
|
|
07573e831e | ||
|
|
42e5aa2dd4 | ||
|
|
4e72753c24 | ||
|
|
6f9ac378e2 | ||
|
|
89135b4d90 | ||
|
|
f1f446b455 | ||
|
|
84b3ec0626 | ||
|
|
5445fe8e8c | ||
|
|
55f9bfbfa8 | ||
|
|
d70034fbc5 | ||
|
|
21fb5876c1 | ||
|
|
0902dc4b43 | ||
|
|
d0e668c6ce | ||
|
|
0a008cd6c7 | ||
|
|
e47d13a237 | ||
|
|
e3b0a2e33f | ||
|
|
7a319d926f | ||
|
|
4d7b54382d | ||
|
|
0950a74e96 | ||
|
|
b90ab7fe1b | ||
|
|
1915df8ad7 | ||
|
|
eb37dafcd1 | ||
|
|
c5682b98c5 | ||
|
|
7fbe7ab019 | ||
|
|
b14e77a120 | ||
|
|
75f30e6117 | ||
|
|
c53a599b2e | ||
|
|
8f4832de3e | ||
|
|
a257208254 | ||
|
|
55df468435 | ||
|
|
8334b5cb87 | ||
|
|
2cdcec9d07 | ||
|
|
b4a3645d1f | ||
|
|
f786576895 | ||
|
|
30d16a3f48 | ||
|
|
9745e9e3a2 | ||
|
|
a2deba11af | ||
|
|
d8afa24184 | ||
|
|
16165c3bd2 | ||
|
|
bcf3b8f1ac | ||
|
|
13b39d9b13 | ||
|
|
0be18b7e77 | ||
|
|
cd6105a6b9 | ||
|
|
9f23a39abe | ||
|
|
19216e107c | ||
|
|
2aa423de52 | ||
|
|
3f7175daa3 | ||
|
|
0d7a6794b4 | ||
|
|
312f02c318 | ||
|
|
0dd085c48e | ||
|
|
020bf76570 | ||
|
|
3191f81046 | ||
|
|
9d59fb8d05 | ||
|
|
dfe024e234 | ||
|
|
2f4ae5ad05 | ||
|
|
68714b14c1 | ||
|
|
531a0a12dd | ||
|
|
9a2c74ccbc | ||
|
|
031575cb27 | ||
|
|
c4eefc4935 | ||
|
|
db36f0c336 | ||
|
|
df50184f65 | ||
|
|
ddacc77100 | ||
|
|
f8b16e1034 | ||
|
|
749dff2200 | ||
|
|
de05394859 | ||
|
|
a6a9bf5bad | ||
|
|
e767c229aa | ||
|
|
b9cf516201 | ||
|
|
f87e80a0f5 | ||
|
|
f114d0249d | ||
|
|
b4fbd7c673 | ||
|
|
e25d625c4b | ||
|
|
9ca0cc90b0 | ||
|
|
d8d1c2ea7a | ||
|
|
bf1378f144 | ||
|
|
2207643e21 | ||
|
|
2af035d3cf | ||
|
|
acc4db2ce4 | ||
|
|
f9dd1d6b69 | ||
|
|
e9c6513328 | ||
|
|
fa047ba7db | ||
|
|
90758dbd32 | ||
|
|
c80f020145 | ||
|
|
3748b9d24b | ||
|
|
28370d219e | ||
|
|
a03d2ba961 | ||
|
|
e08045d413 | ||
|
|
fd073d9788 | ||
|
|
e57a21dd92 | ||
|
|
53e10602b6 | ||
|
|
8168d8bea0 | ||
|
|
b18f998d0e | ||
|
|
9b559d6251 | ||
|
|
bdfb712395 | ||
|
|
0d2a4b397a | ||
|
|
2c9a51c2ac | ||
|
|
fb43f12a76 | ||
|
|
60e0e84237 | ||
|
|
54d46a1d03 | ||
|
|
73a7246a11 | ||
|
|
163d59bf71 | ||
|
|
fb672eda11 | ||
|
|
43a432b22b | ||
|
|
8107946cb1 | ||
|
|
38ee4aae30 | ||
|
|
001d9ed9fb | ||
|
|
e1abae91a3 | ||
|
|
a9ac3b7e15 | ||
|
|
4a98c54e78 | ||
|
|
9ed4a09caf | ||
|
|
132a31852f | ||
|
|
5686697b6c | ||
|
|
5f4fc12031 | ||
|
|
fe2c42de90 | ||
|
|
d8f2cf1c0e | ||
|
|
a7e8f31561 | ||
|
|
d9d6e7b4f1 | ||
|
|
f8f1a26a43 | ||
|
|
79dfd6f17f | ||
|
|
f386662e00 | ||
|
|
b2de302262 | ||
|
|
6f63076b8e | ||
|
|
8007f954e5 | ||
|
|
b39b24c46f | ||
|
|
70472c587d | ||
|
|
06e89b7199 | ||
|
|
d60ac0d0e1 | ||
|
|
1e4c213df4 | ||
|
|
9bf112cfcf | ||
|
|
a611b8f429 | ||
|
|
872230169c | ||
|
|
4a28954074 | ||
|
|
0df2d9e6da | ||
|
|
67f412477c | ||
|
|
43dc060950 | ||
|
|
a21ae43a1f | ||
|
|
331a8b386f | ||
|
|
ca6c7afa5c | ||
|
|
dc8e5d6df9 | ||
|
|
c68f352aeb | ||
|
|
7863877a49 | ||
|
|
76384c2430 | ||
|
|
4e06d7757b | ||
|
|
5c06429ebe | ||
|
|
aefc7940a7 | ||
|
|
0deae0c73b | ||
|
|
a4c16e5847 | ||
|
|
efb741cf35 | ||
|
|
153f64067c | ||
|
|
c83ae1a485 | ||
|
|
bfd74fb906 | ||
|
|
5d56f05fab | ||
|
|
57ca53c74c | ||
|
|
bde078472b | ||
|
|
6deb75ff46 | ||
|
|
424fd0362d | ||
|
|
1bc51102f6 | ||
|
|
c1b70c05f1 | ||
|
|
8fce0ab1af | ||
|
|
df1923a7c6 | ||
|
|
1e37ae2fd0 | ||
|
|
7b3ea5cc45 | ||
|
|
167ddc6c56 | ||
|
|
dbc1e1fc45 | ||
|
|
01e798f3c1 | ||
|
|
d9010fb3fc | ||
|
|
06363f2e5b | ||
|
|
f1853a6bca | ||
|
|
97e9f5dc8d | ||
|
|
3b959bd2f6 | ||
|
|
9662e43418 | ||
|
|
736bb2ebfb | ||
|
|
879700ea7a | ||
|
|
438ffe45f2 | ||
|
|
723b6b6b79 | ||
|
|
d2df098bb3 | ||
|
|
196ae10f00 | ||
|
|
00eba89e20 | ||
|
|
1739a9e27b | ||
|
|
cfdf714ffa | ||
|
|
49e78b6998 | ||
|
|
762c658c10 | ||
|
|
48e7e33dea | ||
|
|
dc4996c127 | ||
|
|
d95f7b976c | ||
|
|
9a47883064 | ||
|
|
39a90fd33c | ||
|
|
722c3482d2 | ||
|
|
60e84e6681 | ||
|
|
8d1fa84e6a | ||
|
|
6c22197bf4 | ||
|
|
f6c426d0cc | ||
|
|
e21757b2bd | ||
|
|
a87fbabbe7 | ||
|
|
b2847cb05b | ||
|
|
0b575b41a1 | ||
|
|
0a3fd7a7dc |
114
frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts
Normal file
114
frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/* 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 = {
|
||||
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({
|
||||
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({
|
||||
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 our expected format
|
||||
expect(result).toEqual({
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'success',
|
||||
payload: mockSuccessResponse.data.data,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/* 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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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', value: 'front' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the API with time range parameters', async () => {
|
||||
// Mock API response
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||
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 = {
|
||||
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.payload?.normalizedValues).toContain('frontend');
|
||||
expect(result.payload?.normalizedValues).toContain('backend');
|
||||
expect(result.payload?.normalizedValues).toContain('200');
|
||||
expect(result.payload?.normalizedValues).toContain('404');
|
||||
expect(result.payload?.normalizedValues).toContain('true');
|
||||
expect(result.payload?.normalizedValues).toContain('false');
|
||||
expect(result.payload?.normalizedValues?.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should return a properly formatted success response', async () => {
|
||||
// Create mock response
|
||||
const mockApiResponse = {
|
||||
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
|
||||
expect(result).toEqual({
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'success',
|
||||
payload: expect.objectContaining({
|
||||
values: expect.any(Object),
|
||||
normalizedValues: expect.any(Array),
|
||||
complete: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
34
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal file
34
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponse, SuccessResponse } 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<SuccessResponse<FieldKeyResponse> | ErrorResponse> => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (signal) {
|
||||
params.signal = encodeURIComponent(signal);
|
||||
}
|
||||
|
||||
if (name) {
|
||||
params.name = encodeURIComponent(name);
|
||||
}
|
||||
|
||||
const response = await ApiBaseInstance.get('/fields/keys', { params });
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getFieldKeys;
|
||||
80
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
80
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponse, SuccessResponse } 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
|
||||
*/
|
||||
export const getFieldValues = async (
|
||||
signal?: 'traces' | 'logs' | 'metrics',
|
||||
name?: string,
|
||||
searchText?: string,
|
||||
startUnixMilli?: number,
|
||||
endUnixMilli?: number,
|
||||
existingQuery?: string,
|
||||
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getFieldValues;
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
TelemetryFieldKey,
|
||||
TraceAggregation,
|
||||
VariableItem,
|
||||
VariableType,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -406,6 +407,7 @@ export const prepareQueryRangePayloadV5 = ({
|
||||
formatForWeb,
|
||||
originalGraphType,
|
||||
fillGaps,
|
||||
dynamicVariables,
|
||||
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
|
||||
let legendMap: Record<string, string> = {};
|
||||
const requestType = mapPanelTypeToRequestType(graphType);
|
||||
@@ -497,7 +499,12 @@ export const prepareQueryRangePayloadV5 = ({
|
||||
fillGaps: fillGaps || false,
|
||||
},
|
||||
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;
|
||||
}, {} as Record<string, VariableItem>),
|
||||
};
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ function Metrics({
|
||||
signal,
|
||||
}: QueryFunctionContext): Promise<
|
||||
SuccessResponse<MetricRangePayloadProps>
|
||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, signal),
|
||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
|
||||
enabled: !!payload && visibilities[index],
|
||||
keepPreviousData: true,
|
||||
})),
|
||||
|
||||
@@ -23,11 +23,13 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForMultiSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
@@ -37,7 +39,7 @@ enum ToggleTagValue {
|
||||
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> = ({
|
||||
placeholder = 'Search...',
|
||||
@@ -62,6 +64,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
allowClear = false,
|
||||
onRetry,
|
||||
maxTagTextLength,
|
||||
onDropdownVisibleChange,
|
||||
showIncompleteDataMessage = false,
|
||||
showLabels = false,
|
||||
enableRegexOption = false,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
@@ -78,6 +84,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
|
||||
const isClickInsideDropdownRef = useRef(false);
|
||||
const justOpenedRef = useRef<boolean>(false);
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||
|
||||
// Convert single string value to array for consistency
|
||||
const selectedValues = useMemo(
|
||||
@@ -124,6 +132,12 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
return allAvailableValues.every((val) => selectedValues.includes(val));
|
||||
}, [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
|
||||
const displayValue = useMemo(
|
||||
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
|
||||
@@ -132,10 +146,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
// ===== Internal onChange Handler =====
|
||||
const handleInternalChange = useCallback(
|
||||
(newValue: string | string[]): void => {
|
||||
(newValue: string | string[], directCaller?: boolean): void => {
|
||||
// Ensure newValue is an array
|
||||
const currentNewValue = Array.isArray(newValue) ? newValue : [];
|
||||
|
||||
if (
|
||||
(allOptionShown || isAllSelected) &&
|
||||
!directCaller &&
|
||||
currentNewValue.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onChange) return;
|
||||
|
||||
// Case 1: Cleared (empty array or undefined)
|
||||
@@ -144,7 +166,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
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)) {
|
||||
const allActualOptions = allAvailableValues.map(
|
||||
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
|
||||
@@ -175,7 +197,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange, allAvailableValues, options, enableAllSelection],
|
||||
[
|
||||
allOptionShown,
|
||||
isAllSelected,
|
||||
onChange,
|
||||
allAvailableValues,
|
||||
options,
|
||||
enableAllSelection,
|
||||
],
|
||||
);
|
||||
|
||||
// ===== Existing Callbacks (potentially needing adjustment later) =====
|
||||
@@ -510,13 +539,46 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
|
||||
// Normal single value handling
|
||||
setSearchText(value.trim());
|
||||
const trimmedValue = value.trim();
|
||||
setSearchText(trimmedValue);
|
||||
if (!isOpen) {
|
||||
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 =====
|
||||
@@ -528,28 +590,34 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
(text: string, searchQuery: string): React.ReactNode => {
|
||||
if (!searchQuery || !highlightSearch) return text;
|
||||
|
||||
const parts = text.split(
|
||||
new RegExp(
|
||||
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
|
||||
'gi',
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a unique key that doesn't rely on array index
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
try {
|
||||
const parts = text.split(
|
||||
new RegExp(
|
||||
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||
'gi',
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, 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() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
// If regex fails, return the original text without highlighting
|
||||
console.error('Error in text highlighting:', error);
|
||||
return text;
|
||||
}
|
||||
},
|
||||
[highlightSearch],
|
||||
);
|
||||
@@ -560,10 +628,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
if (isAllSelected) {
|
||||
// If all are selected, deselect all
|
||||
handleInternalChange([]);
|
||||
handleInternalChange([], true);
|
||||
} else {
|
||||
// Otherwise, select all
|
||||
handleInternalChange([ALL_SELECTED_VALUE]);
|
||||
handleInternalChange([ALL_SELECTED_VALUE], true);
|
||||
}
|
||||
}, [options, isAllSelected, handleInternalChange]);
|
||||
|
||||
@@ -738,6 +806,26 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// Enhanced keyboard navigation with support for maxTagCount
|
||||
const handleKeyDown = useCallback(
|
||||
(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
|
||||
const getFlatOptions = (): OptionData[] => {
|
||||
if (!visibleOptions) return [];
|
||||
@@ -752,13 +840,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
if (hasAll) {
|
||||
flatList.push({
|
||||
label: 'ALL',
|
||||
value: '__all__', // Special value for the ALL option
|
||||
value: ALL_SELECTED_VALUE, // Special value for the ALL option
|
||||
type: 'defined',
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
const isAlreadyRegex =
|
||||
searchText.startsWith('.*') && searchText.endsWith('.*');
|
||||
@@ -784,6 +872,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
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
|
||||
const activeElement = document.activeElement as HTMLInputElement;
|
||||
const isInputActive = activeElement?.tagName === 'INPUT';
|
||||
@@ -1129,7 +1228,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// If there's an active option in the dropdown, prioritize selecting it
|
||||
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
|
||||
const selectedOption = flatOptions[activeIndex];
|
||||
if (selectedOption.value === '__all__') {
|
||||
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||
handleSelectAll();
|
||||
} else if (selectedOption.value && onChange) {
|
||||
const newValues = selectedValues.includes(selectedOption.value)
|
||||
@@ -1159,6 +1258,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
// Call onDropdownVisibleChange when Escape is pressed to close dropdown
|
||||
if (onDropdownVisibleChange) {
|
||||
onDropdownVisibleChange(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPACEKEY:
|
||||
@@ -1168,7 +1271,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const selectedOption = flatOptions[activeIndex];
|
||||
|
||||
// Check if it's the ALL option
|
||||
if (selectedOption.value === '__all__') {
|
||||
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||
handleSelectAll();
|
||||
} else if (selectedOption.value && onChange) {
|
||||
const newValues = selectedValues.includes(selectedOption.value)
|
||||
@@ -1214,7 +1317,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveIndex(0);
|
||||
justOpenedRef.current = true; // Set flag to initialize active option on next render
|
||||
setActiveChipIndex(-1);
|
||||
break;
|
||||
|
||||
@@ -1260,9 +1363,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
allOptionShown,
|
||||
isAllSelected,
|
||||
isOpen,
|
||||
activeIndex,
|
||||
getVisibleChipIndices,
|
||||
getLastVisibleChipIndex,
|
||||
selectedChips,
|
||||
isSelectionMode,
|
||||
isOpen,
|
||||
activeChipIndex,
|
||||
selectedValues,
|
||||
visibleOptions,
|
||||
@@ -1278,10 +1386,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
startSelection,
|
||||
selectionEnd,
|
||||
extendSelection,
|
||||
activeIndex,
|
||||
onDropdownVisibleChange,
|
||||
handleSelectAll,
|
||||
getVisibleChipIndices,
|
||||
getLastVisibleChipIndex,
|
||||
enableRegexOption,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1306,6 +1413,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
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
|
||||
const customDropdownRender = useCallback((): React.ReactElement => {
|
||||
// Process options based on current search
|
||||
@@ -1324,7 +1439,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const customOptions: OptionData[] = [];
|
||||
|
||||
// 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
|
||||
const isAlreadyRegex =
|
||||
searchText.startsWith('.*') && searchText.endsWith('.*');
|
||||
@@ -1347,8 +1462,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Now add all custom options at the beginning
|
||||
const enhancedNonSectionOptions = [...customOptions, ...nonSectionOptions];
|
||||
// Now add all custom options at the beginning, removing duplicates based on value
|
||||
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 allOptionsSelected =
|
||||
@@ -1382,6 +1506,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
onMouseDown={handleDropdownMouseDown}
|
||||
onClick={handleDropdownClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleDropdownScroll}
|
||||
onBlur={handleBlur}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
@@ -1439,7 +1564,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
{/* Non-section options when not searching */}
|
||||
{enhancedNonSectionOptions.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1452,23 +1589,40 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
{section.label}
|
||||
</div>
|
||||
<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>
|
||||
) : null,
|
||||
) : (
|
||||
<div key={section.label} />
|
||||
),
|
||||
)}
|
||||
|
||||
{/* Navigation help footer */}
|
||||
<div className="navigation-footer" role="note">
|
||||
{!loading && !errorMessage && !noDataMessage && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<ArrowLeft size={8} className="icons" />
|
||||
<ArrowRight size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<ArrowLeft size={8} className="icons" />
|
||||
<ArrowRight size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
@@ -1482,21 +1636,33 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
<div className="navigation-text">
|
||||
{errorMessage || SOMETHING_WENT_WRONG}
|
||||
</div>
|
||||
<div className="navigation-icons">
|
||||
<ReloadOutlined
|
||||
twoToneColor={Color.BG_CHERRY_400}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
if (onRetry) onRetry();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<div className="navigation-icons">
|
||||
<ReloadOutlined
|
||||
twoToneColor={Color.BG_CHERRY_400}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRetry();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage && !loading && (
|
||||
<div className="navigation-text">{noDataMessage}</div>
|
||||
)}
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Use search for more options
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage &&
|
||||
!loading &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) &&
|
||||
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1513,6 +1679,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
handleDropdownMouseDown,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
handleDropdownScroll,
|
||||
handleBlur,
|
||||
activeIndex,
|
||||
loading,
|
||||
@@ -1522,8 +1689,32 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
renderOptionWithIndex,
|
||||
handleSelectAll,
|
||||
onRetry,
|
||||
showIncompleteDataMessage,
|
||||
isScrolledToBottom,
|
||||
enableRegexOption,
|
||||
]);
|
||||
|
||||
// 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 =====
|
||||
|
||||
// Clear search when dropdown closes
|
||||
@@ -1585,55 +1776,16 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// Custom Tag Render (needs significant updates)
|
||||
const tagRender = useCallback(
|
||||
(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 (value === ALL_SELECTED_VALUE && isAllSelected) {
|
||||
const handleAllTagClose = (
|
||||
e: React.MouseEvent | React.KeyboardEvent,
|
||||
): 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 (allOptionShown) {
|
||||
// Don't render a visible tag - will be shown as placeholder
|
||||
return <div style={{ display: 'none' }} />;
|
||||
}
|
||||
|
||||
// If not isAllSelected, render individual tags using previous logic
|
||||
@@ -1713,52 +1865,69 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// Fallback for safety, should not be reached
|
||||
return <div />;
|
||||
},
|
||||
[
|
||||
isAllSelected,
|
||||
handleInternalChange,
|
||||
activeChipIndex,
|
||||
selectedChips,
|
||||
selectedValues,
|
||||
maxTagCount,
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isAllSelected, 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 =====
|
||||
return (
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
<div
|
||||
className={cx('custom-multiselect-wrapper', {
|
||||
'all-selected': allOptionShown || isAllSelected,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={handleInternalChange}
|
||||
onClear={(): void => handleInternalChange([])}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
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 ? 1 : maxTagCount}
|
||||
{...rest}
|
||||
/>
|
||||
>
|
||||
{(allOptionShown || isAllSelected) && !searchText && (
|
||||
<div className="all-text">ALL</div>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={(newValue): void => {
|
||||
handleInternalChange(newValue, false);
|
||||
}}
|
||||
onClear={onClearHandler}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { CustomSelectProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForSingleSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
@@ -57,17 +58,29 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
errorMessage,
|
||||
allowClear = false,
|
||||
onRetry,
|
||||
showIncompleteDataMessage = false,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||
|
||||
// Refs for element access and scroll behavior
|
||||
const selectRef = useRef<BaseSelectRef>(null);
|
||||
const dropdownRef = useRef<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 =====
|
||||
|
||||
@@ -130,23 +143,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
(text: string, searchQuery: string): React.ReactNode => {
|
||||
if (!searchQuery || !highlightSearch) return text;
|
||||
|
||||
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a deterministic but unique key
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
try {
|
||||
const parts = text.split(
|
||||
new RegExp(
|
||||
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||
'gi',
|
||||
),
|
||||
);
|
||||
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() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in text highlighting:', error);
|
||||
return text;
|
||||
}
|
||||
},
|
||||
[highlightSearch],
|
||||
);
|
||||
@@ -246,9 +269,14 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const trimmedValue = value.trim();
|
||||
setSearchText(trimmedValue);
|
||||
|
||||
// Reset active option index when search changes
|
||||
if (isOpen) {
|
||||
setActiveOptionIndex(0);
|
||||
}
|
||||
|
||||
if (onSearch) onSearch(trimmedValue);
|
||||
},
|
||||
[onSearch],
|
||||
[onSearch, isOpen],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -272,14 +300,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const flatList: OptionData[] = [];
|
||||
|
||||
// Process options
|
||||
let processedOptions = isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
|
||||
|
||||
if (!isEmpty(searchText)) {
|
||||
processedOptions = filterOptionsBySearch(processedOptions, searchText);
|
||||
}
|
||||
|
||||
const { sectionOptions, nonSectionOptions } = splitOptions(
|
||||
isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
|
||||
processedOptions,
|
||||
);
|
||||
|
||||
// Add custom option if needed
|
||||
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
|
||||
if (
|
||||
!isEmpty(searchText) &&
|
||||
!isLabelPresent(processedOptions, searchText)
|
||||
) {
|
||||
flatList.push({
|
||||
label: searchText,
|
||||
value: searchText,
|
||||
@@ -300,33 +337,52 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
|
||||
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) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
// Tab navigation with Shift key support
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -339,6 +395,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
}
|
||||
} else if (!isEmpty(searchText)) {
|
||||
// Add custom value when no option is focused
|
||||
@@ -351,6 +408,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onChange(customOption.value, customOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -359,6 +417,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
break;
|
||||
|
||||
case ' ': // Space key
|
||||
@@ -369,6 +428,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -379,7 +439,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
// Open dropdown when Down or Tab is pressed while closed
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveOptionIndex(0);
|
||||
justOpenedRef.current = true; // Set flag to initialize active option on next render
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -444,6 +504,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
className="custom-select-dropdown"
|
||||
onClick={handleDropdownClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleDropdownScroll}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-activedescendant={
|
||||
@@ -454,7 +515,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
<div className="no-section-options">
|
||||
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
|
||||
</div>
|
||||
|
||||
{/* Section options */}
|
||||
{sectionOptions.length > 0 &&
|
||||
sectionOptions.map((section) =>
|
||||
@@ -472,13 +532,16 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
|
||||
{/* Navigation help footer */}
|
||||
<div className="navigation-footer" role="note">
|
||||
{!loading && !errorMessage && !noDataMessage && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
@@ -492,21 +555,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
<div className="navigation-text">
|
||||
{errorMessage || SOMETHING_WENT_WRONG}
|
||||
</div>
|
||||
<div className="navigation-icons">
|
||||
<ReloadOutlined
|
||||
twoToneColor={Color.BG_CHERRY_400}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
if (onRetry) onRetry();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<div className="navigation-icons">
|
||||
<ReloadOutlined
|
||||
twoToneColor={Color.BG_CHERRY_400}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRetry();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage && !loading && (
|
||||
<div className="navigation-text">{noDataMessage}</div>
|
||||
)}
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Use search for more options
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage &&
|
||||
!loading &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) &&
|
||||
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -520,6 +595,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
isLabelPresent,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
handleDropdownScroll,
|
||||
activeOptionIndex,
|
||||
loading,
|
||||
errorMessage,
|
||||
@@ -527,8 +603,22 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
dropdownRender,
|
||||
renderOptionWithIndex,
|
||||
onRetry,
|
||||
showIncompleteDataMessage,
|
||||
isScrolledToBottom,
|
||||
]);
|
||||
|
||||
// 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 =====
|
||||
|
||||
// Clear search text when dropdown closes
|
||||
@@ -582,7 +672,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
|
||||
@@ -35,12 +35,50 @@ $custom-border-color: #2c3044;
|
||||
width: 100%;
|
||||
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 {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
background-color: var(--bg-ink-400);
|
||||
border-color: var(--bg-slate-400);
|
||||
cursor: text;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
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-selector {
|
||||
border-color: var(--bg-robin-500);
|
||||
@@ -158,7 +206,7 @@ $custom-border-color: #2c3044;
|
||||
// Custom dropdown styles for single select
|
||||
.custom-select-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
@@ -276,6 +324,10 @@ $custom-border-color: #2c3044;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.navigation-text-incomplete {
|
||||
color: var(--bg-amber-600) !important;
|
||||
}
|
||||
|
||||
.navigation-error {
|
||||
.navigation-text,
|
||||
.navigation-icons {
|
||||
@@ -322,7 +374,7 @@ $custom-border-color: #2c3044;
|
||||
// Custom dropdown styles for multi-select
|
||||
.custom-multiselect-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
@@ -355,6 +407,7 @@ $custom-border-color: #2c3044;
|
||||
.select-group {
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
|
||||
.group-label {
|
||||
font-weight: 500;
|
||||
@@ -637,6 +690,7 @@ $custom-border-color: #2c3044;
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border-color: #e9e9e9;
|
||||
cursor: text; // Make entire selector clickable for input focus
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #ccc;
|
||||
@@ -647,6 +701,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 {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
@@ -656,6 +724,10 @@ $custom-border-color: #2c3044;
|
||||
border: 1px solid #e8e8e8;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
font-size: 12px !important;
|
||||
height: 20px;
|
||||
line-height: 18px;
|
||||
|
||||
.ant-select-selection-item-content {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
@@ -836,3 +908,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,10 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
|
||||
highlightSearch?: boolean;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
popupMatchSelectWidth?: boolean;
|
||||
errorMessage?: string;
|
||||
errorMessage?: string | null;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomTagProps {
|
||||
@@ -51,10 +52,14 @@ export interface CustomMultiSelectProps
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
||||
highlightSearch?: boolean;
|
||||
errorMessage?: string;
|
||||
errorMessage?: string | null;
|
||||
popupClassName?: string;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
maxTagCount?: number;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
maxTagTextLength?: number;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
showLabels?: boolean;
|
||||
enableRegexOption?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { uniqueOptions } from 'container/NewDashboard/DashboardVariablesSelection/util';
|
||||
|
||||
import { OptionData } from './types';
|
||||
|
||||
export const SPACEKEY = ' ';
|
||||
@@ -98,8 +100,10 @@ export const prioritizeOrAddOptionForMultiSelect = (
|
||||
label: labels?.[value] ?? value, // Use provided label or default to value
|
||||
}));
|
||||
|
||||
const flatOutSelectedOptions = uniqueOptions([...newOptions, ...foundOptions]);
|
||||
|
||||
// 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[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
@@ -32,12 +32,14 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { debounce, isNull } from 'lodash-es';
|
||||
import { Info, TriangleAlert } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
IDetailedError,
|
||||
IQueryContext,
|
||||
IValidationResult,
|
||||
} from 'types/antlrQueryTypes';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -161,13 +163,15 @@ function QuerySearch({
|
||||
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// const {
|
||||
// data: queryKeySuggestions,
|
||||
// refetch: refetchQueryKeySuggestions,
|
||||
// } = useGetQueryKeySuggestions({
|
||||
// signal: dataSource,
|
||||
// name: searchText || '',
|
||||
// });
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
const dynamicVariables = useMemo(
|
||||
() =>
|
||||
Object.values(selectedDashboard?.data?.variables || {})?.filter(
|
||||
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
|
||||
),
|
||||
[selectedDashboard],
|
||||
);
|
||||
|
||||
// Add back the generateOptions function and useEffect
|
||||
const generateOptions = (keys: {
|
||||
@@ -982,6 +986,25 @@ function QuerySearch({
|
||||
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
|
||||
const shouldFetch =
|
||||
// Fetch only if key is available
|
||||
@@ -1034,6 +1057,9 @@ function QuerySearch({
|
||||
} else if (option.type === 'array') {
|
||||
// Arrays are already formatted as arrays
|
||||
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;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
removeKeysFromExpression,
|
||||
} from '../utils';
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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
|
||||
* @param value - The value to format
|
||||
@@ -48,6 +55,10 @@ const formatValueForExpression = (
|
||||
value: string[] | string | number | boolean,
|
||||
operator?: string,
|
||||
): string => {
|
||||
if (isVariable(value)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// For IN operators, ensure value is always an array
|
||||
if (isArrayOperator(operator || '')) {
|
||||
const arrayValue = Array.isArray(value) ? value : [value];
|
||||
@@ -466,11 +477,13 @@ export const convertFiltersToExpressionWithExistingQuery = (
|
||||
*
|
||||
* @param expression - The full query string.
|
||||
* @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.
|
||||
*/
|
||||
export const removeKeysFromExpression = (
|
||||
expression: string,
|
||||
keysToRemove: string[],
|
||||
removeOnlyVariableExpressions = false,
|
||||
): string => {
|
||||
if (!keysToRemove || keysToRemove.length === 0) {
|
||||
return expression;
|
||||
@@ -486,9 +499,20 @@ export const removeKeysFromExpression = (
|
||||
let queryPairsMap: Map<string, IQueryPair>;
|
||||
|
||||
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
|
||||
queryPairsMap = new Map(
|
||||
existingQueryPairs.map((pair) => {
|
||||
filteredQueryPairs.map((pair) => {
|
||||
const key = pair.key.trim().toLowerCase();
|
||||
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;
|
||||
|
||||
@@ -46,6 +46,7 @@ export enum QueryParams {
|
||||
msgSystem = 'msgSystem',
|
||||
destination = 'destination',
|
||||
kindString = 'kindString',
|
||||
summaryFilters = 'summaryFilters',
|
||||
tab = 'tab',
|
||||
thresholds = 'thresholds',
|
||||
selectedExplorerView = 'selectedExplorerView',
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
overflow-y: hidden;
|
||||
|
||||
.full-view-header-container {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import './WidgetFullView.styles.scss';
|
||||
|
||||
import {
|
||||
@@ -8,24 +9,31 @@ import {
|
||||
import { Button, Input, Spin } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import Spinner from 'components/Spinner';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useDrilldown from 'container/GridCardLayout/GridCard/FullView/useDrilldown';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import {
|
||||
timeItems,
|
||||
timePreferance,
|
||||
} from 'container/NewWidget/RightContainer/timeItems';
|
||||
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useChartMutable } from 'hooks/useChartMutable';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -52,6 +60,7 @@ function FullView({
|
||||
onClickHandler,
|
||||
customOnDragSelect,
|
||||
setCurrentGraphRef,
|
||||
enableDrillDown = false,
|
||||
}: FullViewProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { selectedTime: globalSelectedTime } = useSelector<
|
||||
@@ -63,12 +72,16 @@ function FullView({
|
||||
const location = useLocation();
|
||||
|
||||
const fullViewRef = useRef<HTMLDivElement>(null);
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentGraphRef(fullViewRef);
|
||||
}, [setCurrentGraphRef]);
|
||||
|
||||
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
||||
const { user } = useAppContext();
|
||||
|
||||
const [editWidget] = useComponentPermission(['edit_widget'], user.role);
|
||||
|
||||
const getSelectedTime = useCallback(
|
||||
() =>
|
||||
@@ -114,6 +127,13 @@ function FullView({
|
||||
};
|
||||
});
|
||||
|
||||
const { dashboardEditView, handleResetQuery, showResetQuery } = useDrilldown({
|
||||
enableDrillDown,
|
||||
widget,
|
||||
setRequestData,
|
||||
selectedDashboard,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
@@ -204,71 +224,117 @@ function FullView({
|
||||
|
||||
return (
|
||||
<div className="full-view-container">
|
||||
<div className="full-view-header-container">
|
||||
{fullViewOptions && (
|
||||
<TimeContainer $panelType={widget.panelTypes}>
|
||||
{response.isFetching && (
|
||||
<Spin spinning indicator={<LoadingOutlined spin />} />
|
||||
<OverlayScrollbar>
|
||||
<>
|
||||
<div className="full-view-header-container">
|
||||
{fullViewOptions && (
|
||||
<TimeContainer $panelType={widget.panelTypes}>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<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
|
||||
selectedTime={selectedTime}
|
||||
setSelectedTime={setSelectedTime}
|
||||
/>
|
||||
<Button
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
}}
|
||||
onClick={(): void => {
|
||||
response.refetch();
|
||||
}}
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
/>
|
||||
</TimeContainer>
|
||||
)}
|
||||
</div>
|
||||
{enableDrillDown && (
|
||||
<>
|
||||
<QueryBuilderV2
|
||||
panelType={widget.panelTypes}
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
isListViewPanel={widget.panelTypes === PANEL_TYPES.LIST}
|
||||
// filterConfigs={filterConfigs}
|
||||
// queryComponents={queryComponents}
|
||||
/>
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
handleRunQuery();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cx('graph-container', {
|
||||
disabled: isDashboardLocked,
|
||||
'height-widget': widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
||||
'full-view-graph-container': isListView || isTablePanel,
|
||||
})}
|
||||
ref={fullViewRef}
|
||||
>
|
||||
<GraphContainer
|
||||
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 || '');
|
||||
<div
|
||||
className={cx('graph-container', {
|
||||
disabled: isDashboardLocked,
|
||||
'height-widget':
|
||||
widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
||||
'full-view-graph-container': isListView,
|
||||
})}
|
||||
ref={fullViewRef}
|
||||
>
|
||||
<GraphContainer
|
||||
style={{
|
||||
height: isListView ? '100%' : '90%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PanelWrapper
|
||||
queryResponse={response}
|
||||
widget={widget}
|
||||
setRequestData={setRequestData}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphVisibility={setGraphsVisibilityStates}
|
||||
graphVisibility={graphsVisibilityStates}
|
||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
searchTerm={searchTerm}
|
||||
onClickHandler={onClickHandler}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</div>
|
||||
isGraphLegendToggleAvailable={canModifyChart}
|
||||
>
|
||||
{isTablePanel && (
|
||||
<Input
|
||||
addonBefore={<SearchOutlined size={14} />}
|
||||
className="global-search"
|
||||
placeholder="Search..."
|
||||
allowClear
|
||||
key={widget.id}
|
||||
onChange={(e): void => {
|
||||
setSearchTerm(e.target.value || '');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PanelWrapper
|
||||
queryResponse={response}
|
||||
widget={widget}
|
||||
setRequestData={setRequestData}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphVisibility={setGraphsVisibilityStates}
|
||||
graphVisibility={graphsVisibilityStates}
|
||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
searchTerm={searchTerm}
|
||||
onClickHandler={onClickHandler}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</div>
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const NotFoundContainer = styled.div`
|
||||
export const TimeContainer = styled.div<Props>`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
${({ $panelType }): FlattenSimpleInterpolation =>
|
||||
$panelType === PANEL_TYPES.TABLE
|
||||
@@ -25,6 +26,10 @@ export const TimeContainer = styled.div<Props>`
|
||||
margin-bottom: 1rem;
|
||||
`
|
||||
: css``}
|
||||
|
||||
.time-container {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
export const GraphContainer = styled.div<GraphContainerProps>`
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface FullViewProps {
|
||||
isDependedDataLoaded?: boolean;
|
||||
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
|
||||
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphManagerProps extends UplotProps {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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 { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
|
||||
export interface DrilldownQueryProps {
|
||||
widget: Widgets;
|
||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
enableDrillDown: boolean;
|
||||
selectedDashboard: Dashboard | undefined;
|
||||
}
|
||||
|
||||
export interface UseDrilldownReturn {
|
||||
dashboardEditView: string;
|
||||
handleResetQuery: () => void;
|
||||
showResetQuery: boolean;
|
||||
}
|
||||
|
||||
const useDrilldown = ({
|
||||
enableDrillDown,
|
||||
widget,
|
||||
setRequestData,
|
||||
selectedDashboard,
|
||||
}: 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: widget.panelTypes,
|
||||
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);
|
||||
}, [redirectWithQueryBuilderData, widget.query]);
|
||||
|
||||
return {
|
||||
dashboardEditView,
|
||||
handleResetQuery,
|
||||
showResetQuery,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDrilldown;
|
||||
@@ -62,6 +62,7 @@ function WidgetGraphComponent({
|
||||
customErrorMessage,
|
||||
customOnRowClick,
|
||||
customTimeRangeWindowForCoRelation,
|
||||
enableDrillDown,
|
||||
}: WidgetGraphComponentProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
@@ -236,6 +237,7 @@ function WidgetGraphComponent({
|
||||
const onToggleModelHandler = (): void => {
|
||||
const existingSearchParams = new URLSearchParams(search);
|
||||
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
||||
existingSearchParams.delete(QueryParams.compositeQuery);
|
||||
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
||||
if (queryResponse.data?.payload) {
|
||||
const {
|
||||
@@ -365,6 +367,7 @@ function WidgetGraphComponent({
|
||||
onClickHandler={onClickHandler ?? graphClickHandler}
|
||||
customOnDragSelect={customOnDragSelect}
|
||||
setCurrentGraphRef={setCurrentGraphRef}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -418,6 +421,7 @@ function WidgetGraphComponent({
|
||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||
customSeries={customSeries}
|
||||
customOnRowClick={customOnRowClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -430,6 +434,7 @@ WidgetGraphComponent.defaultProps = {
|
||||
setLayout: undefined,
|
||||
onClickHandler: undefined,
|
||||
customTimeRangeWindowForCoRelation: undefined,
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
export default WidgetGraphComponent;
|
||||
|
||||
@@ -13,7 +13,6 @@ import { isEqual } from 'lodash-es';
|
||||
import isEmpty from 'lodash-es/isEmpty';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -53,6 +52,8 @@ function GridCardGraph({
|
||||
customTimeRange,
|
||||
customOnRowClick,
|
||||
customTimeRangeWindowForCoRelation,
|
||||
enableDrillDown,
|
||||
widgetsHavingDynamicVariables,
|
||||
}: GridCardGraphProps): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
@@ -62,14 +63,13 @@ function GridCardGraph({
|
||||
const {
|
||||
toScrollWidgetId,
|
||||
setToScrollWidgetId,
|
||||
variablesToGetUpdated,
|
||||
setDashboardQueryRangeCalled,
|
||||
variablesToGetUpdated,
|
||||
} = useDashboard();
|
||||
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleBackNavigation = (): void => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
@@ -120,11 +120,7 @@ function GridCardGraph({
|
||||
const isEmptyWidget =
|
||||
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
|
||||
|
||||
const queryEnabledCondition =
|
||||
isVisible &&
|
||||
!isEmptyWidget &&
|
||||
isQueryEnabled &&
|
||||
isEmpty(variablesToGetUpdated);
|
||||
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
|
||||
|
||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
@@ -163,22 +159,24 @@ 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]);
|
||||
// TODO [vikrantgupta25] remove this useEffect with refactor as this is prone to race condition
|
||||
// this is added to tackle the case of async communication between VariableItem.tsx and GridCard.tsx
|
||||
// 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(() => {
|
||||
if (!isEqual(updatedQuery, requestData.query)) {
|
||||
@@ -199,6 +197,27 @@ function GridCardGraph({
|
||||
[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(
|
||||
{
|
||||
...requestData,
|
||||
@@ -218,15 +237,29 @@ function GridCardGraph({
|
||||
maxTime,
|
||||
minTime,
|
||||
globalSelectedInterval,
|
||||
variables,
|
||||
widget?.query,
|
||||
widget?.panelTypes,
|
||||
widget.timePreferance,
|
||||
widget.fillSpans,
|
||||
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.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 {
|
||||
if (
|
||||
@@ -239,7 +272,7 @@ function GridCardGraph({
|
||||
return failureCount < 2;
|
||||
},
|
||||
keepPreviousData: true,
|
||||
enabled: queryEnabledCondition,
|
||||
enabled: queryEnabledCondition && !nonDynamicVariableChainToken,
|
||||
refetchOnMount: false,
|
||||
onError: (error) => {
|
||||
const errorMessage =
|
||||
@@ -317,6 +350,7 @@ function GridCardGraph({
|
||||
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
|
||||
customOnRowClick={customOnRowClick}
|
||||
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -332,6 +366,7 @@ GridCardGraph.defaultProps = {
|
||||
version: 'v3',
|
||||
analyticsEvent: undefined,
|
||||
customTimeRangeWindowForCoRelation: undefined,
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
export default memo(GridCardGraph);
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface WidgetGraphComponentProps {
|
||||
customErrorMessage?: string;
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export interface GridCardGraphProps {
|
||||
@@ -69,6 +70,8 @@ export interface GridCardGraphProps {
|
||||
};
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
widgetsHavingDynamicVariables?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface GetGraphVisibilityStateOnLegendClickProps {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { DEFAULT_ROW_NAME } from 'container/NewDashboard/DashboardDescription/utils';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -35,7 +36,7 @@ import { ItemCallback, Layout } from 'react-grid-layout';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
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 { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
@@ -53,11 +54,12 @@ import { WidgetRowHeader } from './WidgetRow';
|
||||
|
||||
interface GraphLayoutProps {
|
||||
handle: FullScreenHandle;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
const { handle } = props;
|
||||
const { handle, enableDrillDown = false } = props;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const {
|
||||
selectedDashboard,
|
||||
@@ -97,6 +99,22 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
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(() => {
|
||||
setCurrentPanelMap(panelMap);
|
||||
}, [panelMap]);
|
||||
@@ -584,6 +602,8 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
version={ENTITY_VERSION_V5}
|
||||
onDragSelect={onDragSelect}
|
||||
dataAvailable={checkIfDataExists}
|
||||
enableDrillDown={enableDrillDown}
|
||||
widgetsHavingDynamicVariables={widgetsHavingDynamicVariables}
|
||||
/>
|
||||
</Card>
|
||||
</CardContainer>
|
||||
@@ -670,3 +690,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
}
|
||||
|
||||
export default GraphLayout;
|
||||
|
||||
GraphLayout.defaultProps = {
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
@@ -4,10 +4,17 @@ import GraphLayoutContainer from './GridCardLayout';
|
||||
|
||||
interface GridGraphProps {
|
||||
handle: FullScreenHandle;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
function GridGraph(props: GridGraphProps): JSX.Element {
|
||||
const { handle } = props;
|
||||
return <GraphLayoutContainer handle={handle} />;
|
||||
const { handle, enableDrillDown = false } = props;
|
||||
return (
|
||||
<GraphLayoutContainer handle={handle} enableDrillDown={enableDrillDown} />
|
||||
);
|
||||
}
|
||||
|
||||
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 { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||
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 { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
@@ -34,6 +36,16 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
|
||||
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(
|
||||
async ({
|
||||
widgetConfig,
|
||||
@@ -47,6 +59,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
globalSelectedInterval,
|
||||
variables: getDashboardVariables(selectedDashboard?.data?.variables),
|
||||
originalGraphType: widgetConfig.panelTypes,
|
||||
dynamicVariables,
|
||||
});
|
||||
|
||||
// Execute query and process results
|
||||
@@ -55,7 +68,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
// Map query data from API response
|
||||
return mapQueryDataFromApi(queryResult.data.compositeQuery);
|
||||
},
|
||||
[globalSelectedInterval, queryRangeMutation],
|
||||
[dynamicVariables, globalSelectedInterval, queryRangeMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -46,6 +46,8 @@ function GridTableComponent({
|
||||
onOpenTraceBtnClick,
|
||||
customOnRowClick,
|
||||
widgetId,
|
||||
panelType,
|
||||
queryRange,
|
||||
...props
|
||||
}: GridTableComponentProps): JSX.Element {
|
||||
const { t } = useTranslation(['valueGraph']);
|
||||
@@ -266,6 +268,8 @@ function GridTableComponent({
|
||||
dataSource={dataSource}
|
||||
sticky={sticky}
|
||||
widgetId={widgetId}
|
||||
panelType={panelType}
|
||||
queryRange={queryRange}
|
||||
onRow={
|
||||
openTracesButton || customOnRowClick
|
||||
? (record): React.HTMLAttributes<HTMLElement> => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TableProps } from 'antd';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
|
||||
import {
|
||||
ThresholdOperators,
|
||||
@@ -6,7 +7,10 @@ import {
|
||||
} from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { ColumnUnit } from 'types/api/dashboard/getAll';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { ColumnUnit, ContextLinksData } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type GridTableComponentProps = {
|
||||
@@ -22,6 +26,13 @@ export type GridTableComponentProps = {
|
||||
widgetId?: string;
|
||||
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
||||
customColTitles?: Record<string, string>;
|
||||
enableDrillDown?: boolean;
|
||||
contextLinks?: ContextLinksData;
|
||||
panelType?: PANEL_TYPES;
|
||||
queryRange?: UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||
Error
|
||||
>;
|
||||
} & Pick<LogsExplorerTableProps, 'data'> &
|
||||
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 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 { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
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 { 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
|
||||
function evaluateCondition(
|
||||
operator: string | undefined,
|
||||
@@ -184,9 +190,9 @@ export function createColumnsAndDataSource(
|
||||
data: TableData,
|
||||
currentQuery: Query,
|
||||
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
||||
): { columns: ColumnsType<RowData>; dataSource: RowData[] } {
|
||||
const columns: ColumnsType<RowData> =
|
||||
data.columns?.reduce<ColumnsType<RowData>>((acc, item) => {
|
||||
): { columns: CustomDataColumnType<RowData>[]; dataSource: RowData[] } {
|
||||
const columns: CustomDataColumnType<RowData>[] =
|
||||
data.columns?.reduce<CustomDataColumnType<RowData>[]>((acc, item) => {
|
||||
// is the column is the value column then we need to check for the available legend
|
||||
const legend = item.isValueColumn
|
||||
? getQueryLegend(currentQuery, item.queryName)
|
||||
@@ -197,11 +203,13 @@ export function createColumnsAndDataSource(
|
||||
(query) => query.queryName === item.queryName,
|
||||
)?.aggregations?.length || 0;
|
||||
|
||||
const column: ColumnType<RowData> = {
|
||||
const column: CustomDataColumnType<RowData> = {
|
||||
dataIndex: item.id || item.name,
|
||||
// if no legend present then rely on the column name value
|
||||
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
|
||||
width: QUERY_TABLE_CONFIG.width,
|
||||
isValueColumn: item.isValueColumn,
|
||||
queryName: item.queryName,
|
||||
render: renderColumnCell && renderColumnCell[item.id],
|
||||
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 ValueGraph from 'components/ValueGraph';
|
||||
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 { useLocation } from 'react-router-dom';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { TitleContainer, ValueContainer } from './styles';
|
||||
import { GridValueComponentProps } from './types';
|
||||
@@ -13,6 +16,10 @@ function GridValueComponent({
|
||||
title,
|
||||
yAxisUnit,
|
||||
thresholds,
|
||||
widget,
|
||||
queryResponse,
|
||||
contextLinks,
|
||||
enableDrillDown = false,
|
||||
}: GridValueComponentProps): JSX.Element {
|
||||
const value = ((data[1] || [])[0] || 0) as number;
|
||||
|
||||
@@ -21,6 +28,35 @@ function GridValueComponent({
|
||||
|
||||
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: [] },
|
||||
clickhouse_sql: [],
|
||||
id: '',
|
||||
},
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: contextLinks || { linksData: [] },
|
||||
panelType: widget?.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<ValueContainer>
|
||||
@@ -34,7 +70,22 @@ function GridValueComponent({
|
||||
<TitleContainer isDashboardPage={isDashboardPage}>
|
||||
<Typography>{gridTitle}</Typography>
|
||||
</TitleContainer>
|
||||
<ValueContainer>
|
||||
<ValueContainer
|
||||
onClick={(e): void => {
|
||||
const queryName = (queryResponse?.data?.params as any)?.compositeQuery
|
||||
?.queries[0]?.spec?.name;
|
||||
|
||||
if (!enableDrillDown || !queryName) 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
|
||||
thresholds={thresholds || []}
|
||||
rawValue={value}
|
||||
@@ -45,6 +96,13 @@ function GridValueComponent({
|
||||
}
|
||||
/>
|
||||
</ValueContainer>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ValueContainer = styled.div`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export const TitleContainer = styled.div<Props>`
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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';
|
||||
|
||||
export type GridValueComponentProps = {
|
||||
@@ -7,4 +11,12 @@ export type GridValueComponentProps = {
|
||||
title?: React.ReactNode;
|
||||
yAxisUnit?: string;
|
||||
thresholds?: ThresholdProps[];
|
||||
// Context menu related props
|
||||
widget?: Widgets;
|
||||
queryResponse?: UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||
Error
|
||||
>;
|
||||
contextLinks?: ContextLinksData;
|
||||
enableDrillDown?: boolean;
|
||||
};
|
||||
|
||||
@@ -105,7 +105,7 @@ function EntityMetrics<T>({
|
||||
signal,
|
||||
}: QueryFunctionContext): Promise<
|
||||
SuccessResponse<MetricRangePayloadProps>
|
||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, signal),
|
||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
|
||||
enabled: !!payload && visibilities[index],
|
||||
keepPreviousData: true,
|
||||
})),
|
||||
|
||||
@@ -50,8 +50,10 @@ jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({
|
||||
}));
|
||||
|
||||
const mockUseQueries = jest.fn();
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs),
|
||||
useQuery: (config: any): any => mockUseQuery(config),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
@@ -302,6 +304,20 @@ describe('EntityMetrics', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
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', () => {
|
||||
|
||||
@@ -50,7 +50,6 @@ function LogsExplorerList({
|
||||
isFilterApplied,
|
||||
}: LogsExplorerListProps): JSX.Element {
|
||||
const ref = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const { activeLogId } = useCopyLogLink();
|
||||
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.apply-to-all-variable-name {
|
||||
font-weight: 700;
|
||||
color: var(--bg-robin-400);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dashboard-variable-settings-table {
|
||||
.variable-name-drag {
|
||||
display: flex;
|
||||
@@ -70,6 +76,17 @@
|
||||
gap: 3px;
|
||||
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 {
|
||||
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 200px;
|
||||
gap: 32px;
|
||||
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,196 @@
|
||||
import './DynamicVariable.styles.scss';
|
||||
|
||||
import { Select, Typography } from 'antd';
|
||||
import CustomSelect from 'components/NewSelect/CustomSelect';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FieldKey } from 'types/api/dynamicVariables/getFieldKeys';
|
||||
|
||||
enum AttributeSource {
|
||||
ALL_SOURCES = 'All Sources',
|
||||
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_SOURCES,
|
||||
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 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_SOURCES
|
||||
? undefined
|
||||
: (attributeSource?.toLowerCase() as 'traces' | 'logs' | 'metrics'),
|
||||
name: debouncedApiSearchText,
|
||||
});
|
||||
|
||||
const isComplete = useMemo(() => data?.payload?.complete === true, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const newAttributes = data.payload?.keys ?? {};
|
||||
setAttributes(newAttributes);
|
||||
setFilteredAttributes(newAttributes);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// 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_SOURCES,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
selectedAttribute,
|
||||
attributeSource,
|
||||
setDynamicVariablesSelectedValue,
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
dynamicVariablesSelectedValue?.value,
|
||||
]);
|
||||
|
||||
const errorText = (error as any)?.message || errorMessage;
|
||||
return (
|
||||
<div className="dynamic-variable-container">
|
||||
<div className="dynamic-variable-config-container">
|
||||
<CustomSelect
|
||||
placeholder="Select an Attribute"
|
||||
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);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
<Typography className="dynamic-variable-from-text">from</Typography>
|
||||
<Select
|
||||
placeholder="Source"
|
||||
defaultValue={AttributeSource.ALL_SOURCES}
|
||||
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;
|
||||
@@ -0,0 +1,377 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
|
||||
import DynamicVariable from '../DynamicVariable';
|
||||
|
||||
// Mock scrollIntoView since it's not available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('hooks/dynamicVariables/useGetFieldKeys', () => ({
|
||||
useGetFieldKeys: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDebounce', () => ({
|
||||
__esModule: true,
|
||||
default: (value: any): any => value, // Return the same value without debouncing for testing
|
||||
}));
|
||||
|
||||
describe('DynamicVariable Component', () => {
|
||||
const mockSetDynamicVariablesSelectedValue = jest.fn();
|
||||
const ATTRIBUTE_PLACEHOLDER = 'Select an Attribute';
|
||||
const LOADING_TEXT = 'We are updating the values...';
|
||||
const DEFAULT_PROPS = {
|
||||
setDynamicVariablesSelectedValue: mockSetDynamicVariablesSelectedValue,
|
||||
dynamicVariablesSelectedValue: undefined,
|
||||
errorAttributeKeyMessage: '',
|
||||
};
|
||||
|
||||
const mockFieldKeysResponse = {
|
||||
payload: {
|
||||
keys: {
|
||||
'service.name': [],
|
||||
'http.status_code': [],
|
||||
duration: [],
|
||||
},
|
||||
complete: true,
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock implementation
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: mockFieldKeysResponse,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to get the attribute select element
|
||||
const getAttributeSelect = (): HTMLElement =>
|
||||
screen.getAllByRole('combobox')[0];
|
||||
|
||||
// Helper function to get the source select element
|
||||
const getSourceSelect = (): HTMLElement => screen.getAllByRole('combobox')[1];
|
||||
|
||||
it('renders with default state', () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Check for main components
|
||||
expect(screen.getByText(ATTRIBUTE_PLACEHOLDER)).toBeInTheDocument();
|
||||
expect(screen.getByText('All Sources')).toBeInTheDocument();
|
||||
expect(screen.getByText('from')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses existing values from dynamicVariablesSelectedValue prop', () => {
|
||||
const selectedValue = {
|
||||
name: 'service.name',
|
||||
value: 'Logs',
|
||||
};
|
||||
|
||||
render(
|
||||
<DynamicVariable
|
||||
setDynamicVariablesSelectedValue={mockSetDynamicVariablesSelectedValue}
|
||||
dynamicVariablesSelectedValue={selectedValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify values are set
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Logs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state when fetching data', () => {
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the CustomSelect dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Should show loading state
|
||||
expect(screen.getByText(LOADING_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message when API fails', () => {
|
||||
const errorMessage = 'Failed to fetch field keys';
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: { message: errorMessage },
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the CustomSelect dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates filteredAttributes when data is loaded', async () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the CustomSelect dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Wait for options to appear in the dropdown
|
||||
await waitFor(() => {
|
||||
// Looking for option-content elements inside the CustomSelect dropdown
|
||||
const options = document.querySelectorAll('.option-content');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
|
||||
// Check if all expected options are present
|
||||
let foundServiceName = false;
|
||||
let foundHttpStatusCode = false;
|
||||
let foundDuration = false;
|
||||
|
||||
options.forEach((option) => {
|
||||
const text = option.textContent?.trim();
|
||||
if (text === 'service.name') foundServiceName = true;
|
||||
if (text === 'http.status_code') foundHttpStatusCode = true;
|
||||
if (text === 'duration') foundDuration = true;
|
||||
});
|
||||
|
||||
expect(foundServiceName).toBe(true);
|
||||
expect(foundHttpStatusCode).toBe(true);
|
||||
expect(foundDuration).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setDynamicVariablesSelectedValue when attribute is selected', async () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the attribute dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Wait for options to appear, then click on service.name
|
||||
await waitFor(() => {
|
||||
// Need to find the option-item containing service.name
|
||||
const serviceNameOption = screen.getByText('service.name');
|
||||
expect(serviceNameOption).not.toBeNull();
|
||||
expect(serviceNameOption?.textContent).toBe('service.name');
|
||||
|
||||
// Click on the option-item that contains service.name
|
||||
const optionElement = serviceNameOption?.closest('.option-item');
|
||||
if (optionElement) {
|
||||
fireEvent.click(optionElement);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if the setter was called with the correct value
|
||||
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith({
|
||||
name: 'service.name',
|
||||
value: 'All Sources',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setDynamicVariablesSelectedValue when source is selected', () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: mockFieldKeysResponse,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Get the Select component
|
||||
const select = screen
|
||||
.getByText('All Sources')
|
||||
.closest('div[class*="ant-select"]');
|
||||
expect(select).toBeInTheDocument();
|
||||
|
||||
// Directly call the onChange handler by simulating the Select's onChange
|
||||
// Find the props.onChange of the Select component and call it directly
|
||||
fireEvent.mouseDown(select as HTMLElement);
|
||||
|
||||
// Use a more specific selector to find the "Logs" option
|
||||
const optionsContainer = document.querySelector(
|
||||
'.rc-virtual-list-holder-inner',
|
||||
);
|
||||
expect(optionsContainer).not.toBeNull();
|
||||
|
||||
// Find the option with Logs text content
|
||||
const logsOption = Array.from(
|
||||
optionsContainer?.querySelectorAll('.ant-select-item-option-content') || [],
|
||||
)
|
||||
.find((element) => element.textContent === 'Logs')
|
||||
?.closest('.ant-select-item-option');
|
||||
|
||||
expect(logsOption).not.toBeNull();
|
||||
|
||||
// Click on it
|
||||
if (logsOption) {
|
||||
fireEvent.click(logsOption);
|
||||
}
|
||||
|
||||
// Check if the setter was called with the correct value
|
||||
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: 'Logs',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters attributes locally when complete is true', async () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the attribute dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Mock the filter function behavior
|
||||
const attributeKeys = Object.keys(mockFieldKeysResponse.payload.keys);
|
||||
|
||||
// Only "http.status_code" should match the filter
|
||||
const expectedFilteredKeys = attributeKeys.filter((key) =>
|
||||
key.includes('http'),
|
||||
);
|
||||
|
||||
// Verify our expected filtering logic
|
||||
expect(expectedFilteredKeys).toContain('http.status_code');
|
||||
expect(expectedFilteredKeys).not.toContain('service.name');
|
||||
expect(expectedFilteredKeys).not.toContain('duration');
|
||||
|
||||
// Now verify the component's filtering ability by inputting the search text
|
||||
const inputElement = screen
|
||||
.getAllByRole('combobox')[0]
|
||||
.querySelector('input');
|
||||
if (inputElement) {
|
||||
fireEvent.change(inputElement, { target: { value: 'http' } });
|
||||
}
|
||||
});
|
||||
|
||||
it('triggers API call when complete is false and search text changes', async () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
// Set up the mock to indicate that data is not complete
|
||||
// and needs to be fetched from the server
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
keys: {
|
||||
'http.status_code': [],
|
||||
},
|
||||
complete: false, // This indicates server-side filtering is needed
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
// Render with Logs as the initial source
|
||||
render(
|
||||
<DynamicVariable
|
||||
{...DEFAULT_PROPS}
|
||||
dynamicVariablesSelectedValue={{
|
||||
name: '',
|
||||
value: 'Logs',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear any initial calls
|
||||
mockRefetch.mockClear();
|
||||
|
||||
// Now test the search functionality
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Find the input element and simulate typing
|
||||
const inputElement = document.querySelector(
|
||||
'.ant-select-selection-search-input',
|
||||
);
|
||||
|
||||
if (inputElement) {
|
||||
// Simulate typing in the search input
|
||||
fireEvent.change(inputElement, { target: { value: 'http' } });
|
||||
|
||||
// Verify that the input has the correct value
|
||||
expect((inputElement as HTMLInputElement).value).toBe('http');
|
||||
|
||||
// Wait for the effect to run and verify refetch was called
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
); // Increase timeout to give more time for the effect to run
|
||||
}
|
||||
});
|
||||
|
||||
it('triggers refetch when attributeSource changes', async () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: mockFieldKeysResponse,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Clear any initial calls
|
||||
mockRefetch.mockClear();
|
||||
|
||||
// Find and click on the source select to open dropdown
|
||||
const sourceSelectElement = getSourceSelect();
|
||||
fireEvent.mouseDown(sourceSelectElement);
|
||||
|
||||
// Find and click on the "Metrics" option
|
||||
const metricsOption = screen.getByText('Metrics');
|
||||
fireEvent.click(metricsOption);
|
||||
|
||||
// Wait for the effect to run
|
||||
await waitFor(() => {
|
||||
// Verify that refetch was called after source selection
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows retry button when error occurs', () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: { message: 'Failed to fetch field keys' },
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the attribute dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Find and click reload icon (retry button)
|
||||
const reloadIcon = screen.getByLabelText('reload');
|
||||
fireEvent.click(reloadIcon);
|
||||
|
||||
// Should trigger refetch
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -99,8 +99,8 @@
|
||||
}
|
||||
|
||||
.variable-type-btn-group {
|
||||
display: flex;
|
||||
width: 342px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content max-content max-content;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
@@ -113,12 +113,14 @@
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.variable-type-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.variable-type-btn + .variable-type-btn {
|
||||
@@ -199,6 +201,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.default-value-section {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
|
||||
.default-value-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
|
||||
.dynamic-variable-section {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
|
||||
.typography-variables {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
width: 339px;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-textbox-section {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
@@ -446,6 +479,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.default-value-section {
|
||||
.default-value-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.dynamic-variable-section {
|
||||
.typography-variables {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.variable-textbox-section {
|
||||
.typography-variables {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
IDashboardVariable,
|
||||
@@ -26,6 +27,7 @@ jest.mock('uuid', () => ({
|
||||
const onCancel = jest.fn();
|
||||
const onSave = jest.fn();
|
||||
const validateName = jest.fn(() => true);
|
||||
const validateAttributeKey = jest.fn(() => true);
|
||||
|
||||
// Mode constant
|
||||
const VARIABLE_MODE = 'ADD';
|
||||
@@ -35,6 +37,8 @@ const TEXT = {
|
||||
INCLUDE_ALL_VALUES: 'Include an option for ALL values',
|
||||
ENABLE_MULTI_VALUES: 'Enable multiple values to be checked',
|
||||
VARIABLE_EXISTS: 'Variable name already exists',
|
||||
VARIABLE_WHITESPACE: 'Variable name cannot contain whitespaces',
|
||||
ATTRIBUTE_KEY_EXISTS: 'A variable with this attribute key already exists',
|
||||
SORT_VALUES: 'Sort Values',
|
||||
DEFAULT_VALUE: 'Default Value',
|
||||
ALL_VARIABLES: 'All variables',
|
||||
@@ -43,6 +47,7 @@ const TEXT = {
|
||||
QUERY: 'Query',
|
||||
TEXTBOX: 'Textbox',
|
||||
CUSTOM: 'Custom',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
// Common test constants
|
||||
@@ -75,23 +80,6 @@ const TEST_VAR_DESCRIPTIONS = {
|
||||
const SAVE_BUTTON_TEXT = 'Save Variable';
|
||||
const UNIQUE_NAME_PLACEHOLDER = 'Unique name of the variable';
|
||||
|
||||
// Create QueryClient for wrapping the component
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wrapper component with QueryClientProvider
|
||||
const wrapper = ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
// Basic variable data for testing
|
||||
const basicVariableData: IDashboardVariable = {
|
||||
id: TEST_VAR_IDS.VAR1,
|
||||
@@ -108,6 +96,7 @@ const renderVariableItem = (
|
||||
variableData: IDashboardVariable = basicVariableData,
|
||||
existingVariables: Record<string, IDashboardVariable> = {},
|
||||
validateNameFn = validateName,
|
||||
validateAttributeKeyFn = validateAttributeKey,
|
||||
): void => {
|
||||
render(
|
||||
<VariableItem
|
||||
@@ -116,9 +105,9 @@ const renderVariableItem = (
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
validateName={validateNameFn}
|
||||
validateAttributeKey={validateAttributeKeyFn}
|
||||
mode={VARIABLE_MODE}
|
||||
/>,
|
||||
{ wrapper } as any,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -203,6 +192,184 @@ describe('VariableItem Component', () => {
|
||||
// Error should not be visible
|
||||
expect(screen.queryByText(TEXT.VARIABLE_EXISTS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows error when variable name contains whitespace', () => {
|
||||
renderVariableItem({ ...basicVariableData, name: '' });
|
||||
|
||||
// Enter a name with whitespace
|
||||
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
|
||||
fireEvent.change(nameInput, { target: { value: 'variable name' } });
|
||||
|
||||
// Error message should be displayed
|
||||
expect(screen.getByText(TEXT.VARIABLE_WHITESPACE)).toBeInTheDocument();
|
||||
|
||||
// Save button should be disabled
|
||||
const saveButton = screen.getByRole('button', { name: /save variable/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('allows variable name without whitespace', () => {
|
||||
renderVariableItem({ ...basicVariableData, name: '' });
|
||||
|
||||
// Enter a valid name without whitespace
|
||||
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
|
||||
fireEvent.change(nameInput, { target: { value: 'variable.name' } });
|
||||
|
||||
// Error should not be visible
|
||||
expect(screen.queryByText(TEXT.VARIABLE_WHITESPACE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('validates whitespace in auto-generated name for dynamic variables', () => {
|
||||
// Create a dynamic variable with empty name
|
||||
const dynamicVariable: IDashboardVariable = {
|
||||
...basicVariableData,
|
||||
name: '',
|
||||
type: 'DYNAMIC',
|
||||
dynamicVariablesAttribute: 'service name', // Contains whitespace
|
||||
dynamicVariablesSource: 'All Sources',
|
||||
};
|
||||
|
||||
renderVariableItem(dynamicVariable);
|
||||
|
||||
// Error message should be displayed for auto-generated name
|
||||
expect(screen.getByText(TEXT.VARIABLE_WHITESPACE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Variable Attribute Key Validation', () => {
|
||||
test('shows error when attribute key already exists', async () => {
|
||||
// Mock validateAttributeKey to return false (attribute key exists)
|
||||
const mockValidateAttributeKey = jest.fn().mockReturnValue(false);
|
||||
|
||||
// Create a dynamic variable
|
||||
const dynamicVariable: IDashboardVariable = {
|
||||
...basicVariableData,
|
||||
name: 'test-variable',
|
||||
type: 'DYNAMIC',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'All Sources',
|
||||
};
|
||||
|
||||
renderVariableItem(
|
||||
dynamicVariable,
|
||||
{},
|
||||
validateName,
|
||||
mockValidateAttributeKey,
|
||||
);
|
||||
|
||||
// Switch to Dynamic type to trigger the validation
|
||||
const dynamicButton = findButtonByText(TEXT.DYNAMIC);
|
||||
if (dynamicButton) {
|
||||
fireEvent.click(dynamicButton);
|
||||
}
|
||||
|
||||
// Error message should be displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(TEXT.ATTRIBUTE_KEY_EXISTS)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Save button should be disabled
|
||||
const saveButton = screen.getByRole('button', { name: /save variable/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('allows saving when attribute key is unique', async () => {
|
||||
// Mock validateAttributeKey to return true (attribute key is unique)
|
||||
const mockValidateAttributeKey = jest.fn().mockReturnValue(true);
|
||||
|
||||
// Create a dynamic variable
|
||||
const dynamicVariable: IDashboardVariable = {
|
||||
...basicVariableData,
|
||||
name: 'test-variable',
|
||||
type: 'DYNAMIC',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'All Sources',
|
||||
};
|
||||
|
||||
renderVariableItem(
|
||||
dynamicVariable,
|
||||
{},
|
||||
validateName,
|
||||
mockValidateAttributeKey,
|
||||
);
|
||||
|
||||
// Switch to Dynamic type
|
||||
const dynamicButton = findButtonByText(TEXT.DYNAMIC);
|
||||
if (dynamicButton) {
|
||||
fireEvent.click(dynamicButton);
|
||||
}
|
||||
|
||||
// Error should not be visible
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Save button should not be disabled due to attribute key error
|
||||
const saveButton = screen.getByRole('button', { name: /save variable/i });
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('allows same attribute key for current variable being edited', async () => {
|
||||
// Mock validateAttributeKey to return true for same variable
|
||||
const mockValidateAttributeKey = jest.fn().mockImplementation(
|
||||
(attributeKey, currentVariableId) =>
|
||||
// Allow if it's the same variable ID
|
||||
currentVariableId === TEST_VAR_IDS.VAR1,
|
||||
);
|
||||
|
||||
// Create a dynamic variable
|
||||
const dynamicVariable: IDashboardVariable = {
|
||||
...basicVariableData,
|
||||
id: TEST_VAR_IDS.VAR1,
|
||||
name: 'test-variable',
|
||||
type: 'DYNAMIC',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'All Sources',
|
||||
};
|
||||
|
||||
renderVariableItem(
|
||||
dynamicVariable,
|
||||
{},
|
||||
validateName,
|
||||
mockValidateAttributeKey,
|
||||
);
|
||||
|
||||
// Error should not be visible
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('does not validate attribute key for non-dynamic variables', async () => {
|
||||
// Mock validateAttributeKey to return false (would show error for dynamic)
|
||||
const mockValidateAttributeKey = jest.fn().mockReturnValue(false);
|
||||
|
||||
// Create a non-dynamic variable
|
||||
const queryVariable: IDashboardVariable = {
|
||||
...basicVariableData,
|
||||
name: 'test-variable',
|
||||
type: 'QUERY',
|
||||
};
|
||||
|
||||
renderVariableItem(
|
||||
queryVariable,
|
||||
{},
|
||||
validateName,
|
||||
mockValidateAttributeKey,
|
||||
);
|
||||
|
||||
// No error should be displayed for query variables
|
||||
expect(
|
||||
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// validateAttributeKey should not be called for non-dynamic variables
|
||||
expect(mockValidateAttributeKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variable Type Switching', () => {
|
||||
@@ -324,6 +491,7 @@ describe('VariableItem Component', () => {
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
}),
|
||||
expect.anything(), // widgetIds prop
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,26 +6,39 @@ import { Button, Collapse, Input, Select, Switch, Tag, Typography } from 'antd';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import cx from 'classnames';
|
||||
import Editor from 'components/Editor';
|
||||
import { CustomSelect } from 'components/NewSelect';
|
||||
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
createDynamicVariableToWidgetsMap,
|
||||
getWidgetsHavingDynamicVariableAttribute,
|
||||
} from 'hooks/dashboard/utils';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
|
||||
import { map } from 'lodash-es';
|
||||
import { isEmpty, map } from 'lodash-es';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import {
|
||||
IDashboardVariable,
|
||||
TSortVariableValuesType,
|
||||
TVariableQueryType,
|
||||
VariableSortTypeArr,
|
||||
Widgets,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
|
||||
import {
|
||||
@@ -34,7 +47,9 @@ import {
|
||||
} from '../../../DashboardVariablesSelection/util';
|
||||
import { variablePropsToPayloadVariables } from '../../../utils';
|
||||
import { TVariableMode } from '../types';
|
||||
import DynamicVariable from './DynamicVariable/DynamicVariable';
|
||||
import { LabelContainer, VariableItemRow } from './styles';
|
||||
import { WidgetSelector } from './WidgetSelector';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
@@ -42,8 +57,16 @@ interface VariableItemProps {
|
||||
variableData: IDashboardVariable;
|
||||
existingVariables: Record<string, IDashboardVariable>;
|
||||
onCancel: () => void;
|
||||
onSave: (mode: TVariableMode, variableData: IDashboardVariable) => void;
|
||||
onSave: (
|
||||
mode: TVariableMode,
|
||||
variableData: IDashboardVariable,
|
||||
widgetIds?: string[],
|
||||
) => void;
|
||||
validateName: (arg0: string) => boolean;
|
||||
validateAttributeKey: (
|
||||
attributeKey: string,
|
||||
currentVariableId?: string,
|
||||
) => boolean;
|
||||
mode: TVariableMode;
|
||||
}
|
||||
function VariableItem({
|
||||
@@ -52,16 +75,21 @@ function VariableItem({
|
||||
onCancel,
|
||||
onSave,
|
||||
validateName,
|
||||
validateAttributeKey,
|
||||
mode,
|
||||
}: VariableItemProps): JSX.Element {
|
||||
const [variableName, setVariableName] = useState<string>(
|
||||
variableData.name || '',
|
||||
);
|
||||
const [
|
||||
hasUserManuallyChangedName,
|
||||
setHasUserManuallyChangedName,
|
||||
] = useState<boolean>(false);
|
||||
const [variableDescription, setVariableDescription] = useState<string>(
|
||||
variableData.description || '',
|
||||
);
|
||||
const [queryType, setQueryType] = useState<TVariableQueryType>(
|
||||
variableData.type || 'QUERY',
|
||||
variableData.type || 'DYNAMIC',
|
||||
);
|
||||
const [variableQueryValue, setVariableQueryValue] = useState<string>(
|
||||
variableData.queryValue || '',
|
||||
@@ -85,11 +113,162 @@ function VariableItem({
|
||||
variableData.showALLOption || false,
|
||||
);
|
||||
const [previewValues, setPreviewValues] = useState<string[]>([]);
|
||||
const [variableDefaultValue, setVariableDefaultValue] = useState<string>(
|
||||
(variableData.defaultValue as string) || '',
|
||||
);
|
||||
|
||||
const [
|
||||
dynamicVariablesSelectedValue,
|
||||
setDynamicVariablesSelectedValue,
|
||||
] = useState<{ name: string; value: string }>();
|
||||
|
||||
// Error messages
|
||||
const [errorName, setErrorName] = useState<boolean>(false);
|
||||
const [errorNameMessage, setErrorNameMessage] = useState<string>('');
|
||||
const [errorAttributeKey, setErrorAttributeKey] = useState<boolean>(false);
|
||||
const [
|
||||
errorAttributeKeyMessage,
|
||||
setErrorAttributeKeyMessage,
|
||||
] = useState<string>('');
|
||||
const [errorPreview, setErrorPreview] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
variableData.dynamicVariablesAttribute &&
|
||||
variableData.dynamicVariablesSource
|
||||
) {
|
||||
setDynamicVariablesSelectedValue({
|
||||
name: variableData.dynamicVariablesAttribute,
|
||||
value: variableData.dynamicVariablesSource,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
variableData.dynamicVariablesAttribute,
|
||||
variableData.dynamicVariablesSource,
|
||||
]);
|
||||
|
||||
// Validate attribute key uniqueness for dynamic variables
|
||||
useEffect(() => {
|
||||
if (queryType === 'DYNAMIC' && dynamicVariablesSelectedValue?.name) {
|
||||
if (
|
||||
!validateAttributeKey(dynamicVariablesSelectedValue.name, variableData.id)
|
||||
) {
|
||||
setErrorAttributeKey(true);
|
||||
setErrorAttributeKeyMessage(
|
||||
'A variable with this attribute key already exists',
|
||||
);
|
||||
} else {
|
||||
setErrorAttributeKey(false);
|
||||
setErrorAttributeKeyMessage('');
|
||||
}
|
||||
} else {
|
||||
setErrorAttributeKey(false);
|
||||
setErrorAttributeKeyMessage('');
|
||||
}
|
||||
}, [
|
||||
queryType,
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
validateAttributeKey,
|
||||
variableData.id,
|
||||
]);
|
||||
|
||||
// Auto-set variable name to selected attribute name in creation mode when user hasn't manually changed it
|
||||
useEffect(() => {
|
||||
if (
|
||||
mode === 'ADD' && // Only in creation mode
|
||||
queryType === 'DYNAMIC' && // Only for dynamic variables
|
||||
dynamicVariablesSelectedValue?.name && // Attribute is selected
|
||||
!hasUserManuallyChangedName // User hasn't manually changed the name
|
||||
) {
|
||||
const newName = dynamicVariablesSelectedValue.name;
|
||||
setVariableName(newName);
|
||||
|
||||
// Trigger validation for the auto-set name
|
||||
if (/\s/.test(newName)) {
|
||||
setErrorName(true);
|
||||
setErrorNameMessage('Variable name cannot contain whitespaces');
|
||||
} else if (!validateName(newName)) {
|
||||
setErrorName(true);
|
||||
setErrorNameMessage('Variable name already exists');
|
||||
} else {
|
||||
setErrorName(false);
|
||||
setErrorNameMessage('');
|
||||
}
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
queryType,
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
hasUserManuallyChangedName,
|
||||
validateName,
|
||||
]);
|
||||
|
||||
const REQUIRED_NAME_MESSAGE = 'Variable name is required';
|
||||
|
||||
// Initialize error state for empty name
|
||||
useEffect(() => {
|
||||
if (!variableName.trim()) {
|
||||
setErrorName(true);
|
||||
}
|
||||
}, [variableName]);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const { data: fieldValues } = useGetFieldValues({
|
||||
signal:
|
||||
dynamicVariablesSelectedValue?.value === 'All Sources'
|
||||
? undefined
|
||||
: (dynamicVariablesSelectedValue?.value?.toLowerCase() as
|
||||
| 'traces'
|
||||
| 'logs'
|
||||
| 'metrics'),
|
||||
name: dynamicVariablesSelectedValue?.name || '',
|
||||
enabled:
|
||||
!!dynamicVariablesSelectedValue?.name &&
|
||||
!!dynamicVariablesSelectedValue?.value,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
});
|
||||
|
||||
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
|
||||
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
useEffect(() => {
|
||||
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,
|
||||
) || [];
|
||||
const widgetsHavingDynamicVariables = createDynamicVariableToWidgetsMap(
|
||||
dynamicVariables,
|
||||
widgets as Widgets[],
|
||||
);
|
||||
|
||||
if (variableData?.id && variableData.id in widgetsHavingDynamicVariables) {
|
||||
setSelectedWidgets(widgetsHavingDynamicVariables[variableData.id] || []);
|
||||
} else if (dynamicVariablesSelectedValue?.name) {
|
||||
const widgets = getWidgetsHavingDynamicVariableAttribute(
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
(selectedDashboard?.data?.widgets?.filter(
|
||||
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
|
||||
) || []) as Widgets[],
|
||||
variableData.name,
|
||||
);
|
||||
setSelectedWidgets(widgets || []);
|
||||
}
|
||||
}, [
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
selectedDashboard,
|
||||
variableData.id,
|
||||
variableData.name,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryType === 'CUSTOM') {
|
||||
setPreviewValues(
|
||||
@@ -110,6 +289,64 @@ function VariableItem({
|
||||
variableSortType,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
queryType === 'DYNAMIC' &&
|
||||
fieldValues &&
|
||||
dynamicVariablesSelectedValue?.name &&
|
||||
dynamicVariablesSelectedValue?.value
|
||||
) {
|
||||
setPreviewValues(
|
||||
sortValues(
|
||||
fieldValues.payload?.normalizedValues || [],
|
||||
variableSortType,
|
||||
) as never,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
fieldValues,
|
||||
variableSortType,
|
||||
queryType,
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
dynamicVariablesSelectedValue?.value,
|
||||
]);
|
||||
|
||||
const variableValue = useMemo(() => {
|
||||
if (variableMultiSelect) {
|
||||
let value = variableData.selectedValue;
|
||||
if (isEmpty(value)) {
|
||||
if (variableData.showALLOption) {
|
||||
if (variableDefaultValue) {
|
||||
value = variableDefaultValue;
|
||||
} else {
|
||||
value = previewValues;
|
||||
}
|
||||
} else if (variableDefaultValue) {
|
||||
value = variableDefaultValue;
|
||||
} else {
|
||||
value = previewValues?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isEmpty(variableData.selectedValue)) {
|
||||
if (variableDefaultValue) {
|
||||
return variableDefaultValue;
|
||||
}
|
||||
return previewValues?.[0]?.toString();
|
||||
}
|
||||
|
||||
return variableData.selectedValue || variableDefaultValue;
|
||||
}, [
|
||||
variableMultiSelect,
|
||||
variableData.selectedValue,
|
||||
variableData.showALLOption,
|
||||
variableDefaultValue,
|
||||
previewValues,
|
||||
]);
|
||||
|
||||
const handleSave = (): void => {
|
||||
// Check for cyclic dependencies
|
||||
const newVariable = {
|
||||
@@ -120,15 +357,24 @@ function VariableItem({
|
||||
customValue: variableCustomValue,
|
||||
textboxValue: variableTextboxValue,
|
||||
multiSelect: variableMultiSelect,
|
||||
showALLOption: variableShowALLOption,
|
||||
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
|
||||
sort: variableSortType,
|
||||
...(queryType === 'TEXTBOX' && {
|
||||
selectedValue: (variableData.selectedValue ||
|
||||
variableTextboxValue) as never,
|
||||
}),
|
||||
...(queryType !== 'TEXTBOX' && {
|
||||
defaultValue: variableDefaultValue as never,
|
||||
}),
|
||||
modificationUUID: generateUUID(),
|
||||
id: variableData.id || generateUUID(),
|
||||
order: variableData.order,
|
||||
...(queryType === 'DYNAMIC' && {
|
||||
dynamicVariablesAttribute: dynamicVariablesSelectedValue?.name,
|
||||
dynamicVariablesSource: dynamicVariablesSelectedValue?.value,
|
||||
}),
|
||||
selectedValue: variableValue,
|
||||
allSelected: variableData.allSelected,
|
||||
};
|
||||
|
||||
const allVariables = [...Object.values(existingVariables), newVariable];
|
||||
@@ -145,7 +391,7 @@ function VariableItem({
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(mode, newVariable);
|
||||
onSave(mode, newVariable, selectedWidgets);
|
||||
};
|
||||
|
||||
// Fetches the preview values for the SQL variable query
|
||||
@@ -223,17 +469,34 @@ function VariableItem({
|
||||
placeholder="Unique name of the variable"
|
||||
value={variableName}
|
||||
className="name-input"
|
||||
onChange={(e): void => {
|
||||
setVariableName(e.target.value);
|
||||
setErrorName(
|
||||
!validateName(e.target.value) && e.target.value !== variableData.name,
|
||||
);
|
||||
onChange={({ target: { value } }): void => {
|
||||
setVariableName(value);
|
||||
setHasUserManuallyChangedName(true); // Mark that user has manually changed the name
|
||||
|
||||
// Check for empty name
|
||||
if (!value.trim()) {
|
||||
setErrorName(true);
|
||||
setErrorNameMessage(REQUIRED_NAME_MESSAGE);
|
||||
}
|
||||
// Check for whitespace in name
|
||||
else if (/\s/.test(value)) {
|
||||
setErrorName(true);
|
||||
setErrorNameMessage('Variable name cannot contain whitespaces');
|
||||
}
|
||||
// Check for duplicate name
|
||||
else if (!validateName(value) && value !== variableData.name) {
|
||||
setErrorName(true);
|
||||
setErrorNameMessage('Variable name already exists');
|
||||
}
|
||||
// No errors
|
||||
else {
|
||||
setErrorName(false);
|
||||
setErrorNameMessage('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="warning">
|
||||
{errorName ? 'Variable name already exists' : ''}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="warning">{errorNameMessage}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
@@ -258,18 +521,25 @@ function VariableItem({
|
||||
<div className="variable-type-btn-group">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DatabaseZap size={14} />}
|
||||
icon={<Pyramid size={14} />}
|
||||
className={cx(
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
'variable-type-btn',
|
||||
queryType === 'QUERY' ? 'selected' : '',
|
||||
queryType === 'DYNAMIC' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
setQueryType('QUERY');
|
||||
setQueryType('DYNAMIC');
|
||||
setPreviewValues([]);
|
||||
// Reset manual change flag if no name is entered
|
||||
if (!variableName.trim()) {
|
||||
setHasUserManuallyChangedName(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Query
|
||||
Dynamic
|
||||
<Tag bordered={false} className="sidenav-beta-tag" color="geekblue">
|
||||
Beta
|
||||
</Tag>
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -281,6 +551,10 @@ function VariableItem({
|
||||
onClick={(): void => {
|
||||
setQueryType('TEXTBOX');
|
||||
setPreviewValues([]);
|
||||
// Reset manual change flag if no name is entered
|
||||
if (!variableName.trim()) {
|
||||
setHasUserManuallyChangedName(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Textbox
|
||||
@@ -295,12 +569,47 @@ function VariableItem({
|
||||
onClick={(): void => {
|
||||
setQueryType('CUSTOM');
|
||||
setPreviewValues([]);
|
||||
// Reset manual change flag if no name is entered
|
||||
if (!variableName.trim()) {
|
||||
setHasUserManuallyChangedName(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DatabaseZap size={14} />}
|
||||
className={cx(
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
'variable-type-btn',
|
||||
queryType === 'QUERY' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
setQueryType('QUERY');
|
||||
setPreviewValues([]);
|
||||
// Reset manual change flag if no name is entered
|
||||
if (!variableName.trim()) {
|
||||
setHasUserManuallyChangedName(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Query
|
||||
<Tag bordered={false} className="sidenav-beta-tag" color="warning">
|
||||
Not Recommended
|
||||
</Tag>
|
||||
</Button>
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
{queryType === 'DYNAMIC' && (
|
||||
<div className="variable-dynamic-section">
|
||||
<DynamicVariable
|
||||
setDynamicVariablesSelectedValue={setDynamicVariablesSelectedValue}
|
||||
dynamicVariablesSelectedValue={dynamicVariablesSelectedValue}
|
||||
errorAttributeKeyMessage={errorAttributeKeyMessage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{queryType === 'QUERY' && (
|
||||
<div className="query-container">
|
||||
<LabelContainer>
|
||||
@@ -388,7 +697,9 @@ function VariableItem({
|
||||
/>
|
||||
</VariableItemRow>
|
||||
)}
|
||||
{(queryType === 'QUERY' || queryType === 'CUSTOM') && (
|
||||
{(queryType === 'QUERY' ||
|
||||
queryType === 'CUSTOM' ||
|
||||
queryType === 'DYNAMIC') && (
|
||||
<>
|
||||
<VariableItemRow className="variables-preview-section">
|
||||
<LabelContainer style={{ width: '100%' }}>
|
||||
@@ -444,7 +755,7 @@ function VariableItem({
|
||||
}}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
{variableMultiSelect && (
|
||||
{variableMultiSelect && queryType !== 'DYNAMIC' && (
|
||||
<VariableItemRow className="all-option-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">
|
||||
@@ -457,8 +768,40 @@ function VariableItem({
|
||||
/>
|
||||
</VariableItemRow>
|
||||
)}
|
||||
<VariableItemRow className="default-value-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Default Value</Typography>
|
||||
<Typography className="default-value-description">
|
||||
{queryType === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<CustomSelect
|
||||
placeholder="Select a default value"
|
||||
value={variableDefaultValue}
|
||||
onChange={(value): void => setVariableDefaultValue(value)}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}))}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
</>
|
||||
)}
|
||||
{queryType === 'DYNAMIC' && (
|
||||
<VariableItemRow className="dynamic-variable-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">
|
||||
Select Panels to apply this variable
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<WidgetSelector
|
||||
selectedWidgets={selectedWidgets}
|
||||
setSelectedWidgets={setSelectedWidgets}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="variable-item-footer">
|
||||
@@ -474,7 +817,7 @@ function VariableItem({
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
disabled={errorName}
|
||||
disabled={errorName || errorAttributeKey}
|
||||
icon={<Check size={14} />}
|
||||
className="footer-btn-save"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CustomMultiSelect } from 'components/NewSelect';
|
||||
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
|
||||
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import React from 'react';
|
||||
|
||||
export function WidgetSelector({
|
||||
selectedWidgets,
|
||||
setSelectedWidgets,
|
||||
}: {
|
||||
selectedWidgets: string[];
|
||||
setSelectedWidgets: (widgets: string[]) => void;
|
||||
}): JSX.Element {
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
// Get layout IDs for cross-referencing
|
||||
const layoutIds = new Set(
|
||||
(selectedDashboard?.data?.layout || []).map((item) => item.i),
|
||||
);
|
||||
|
||||
// Filter and deduplicate widgets by ID, keeping only those with layout entries
|
||||
// and excluding row widgets since they are not panels that can have variables
|
||||
const widgets = Object.values(
|
||||
(selectedDashboard?.data?.widgets || []).reduce(
|
||||
(acc: Record<string, any>, widget: any) => {
|
||||
if (
|
||||
widget.id &&
|
||||
layoutIds.has(widget.id) &&
|
||||
widget.panelTypes !== PANEL_GROUP_TYPES.ROW
|
||||
) {
|
||||
acc[widget.id] = widget;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
// Filter selectedWidgets to only include widgets that are present in the current layout
|
||||
const validSelectedWidgets = selectedWidgets.filter((widgetId) =>
|
||||
layoutIds.has(widgetId),
|
||||
);
|
||||
|
||||
// Update selectedWidgets if any invalid widgets were removed
|
||||
React.useEffect(() => {
|
||||
if (validSelectedWidgets.length !== selectedWidgets.length) {
|
||||
setSelectedWidgets(validSelectedWidgets);
|
||||
}
|
||||
}, [validSelectedWidgets, selectedWidgets.length, setSelectedWidgets]);
|
||||
|
||||
return (
|
||||
<CustomMultiSelect
|
||||
placeholder="Select Panels"
|
||||
options={widgets.map((widget: any) => ({
|
||||
label: generateGridTitle(widget.title),
|
||||
value: widget.id,
|
||||
}))}
|
||||
value={validSelectedWidgets}
|
||||
onChange={(value): void => setSelectedWidgets(value as string[])}
|
||||
showLabels
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
removeKeysFromExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
|
||||
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* Updates the query filters in a builder query by appending new tag filters
|
||||
*/
|
||||
const updateQueryFilters = (
|
||||
queryData: IBuilderQuery,
|
||||
filter: TagFilterItem,
|
||||
): IBuilderQuery => {
|
||||
const existingFilters = queryData.filters?.items || [];
|
||||
|
||||
// addition | update
|
||||
const currentFilterKey = filter.key?.key;
|
||||
const valueToAdd = filter.value.toString();
|
||||
const newItems: TagFilterItem[] = [];
|
||||
|
||||
existingFilters.forEach((existingFilter) => {
|
||||
const newFilter = cloneDeep(existingFilter);
|
||||
if (
|
||||
newFilter.key?.key === currentFilterKey &&
|
||||
!(isArray(newFilter.value) && newFilter.value.includes(valueToAdd)) &&
|
||||
newFilter.value !== valueToAdd
|
||||
) {
|
||||
if (isEmpty(newFilter.value)) {
|
||||
newFilter.value = valueToAdd;
|
||||
newFilter.op = 'IN';
|
||||
} else {
|
||||
newFilter.value = (isArray(newFilter.value)
|
||||
? [...newFilter.value, valueToAdd]
|
||||
: [newFilter.value, valueToAdd]) as string[] | string;
|
||||
|
||||
newFilter.op = 'IN';
|
||||
}
|
||||
}
|
||||
|
||||
newItems.push(newFilter);
|
||||
});
|
||||
|
||||
// if yet the filter key doesn't get added then add it
|
||||
if (!newItems.find((item) => item.key?.key === currentFilterKey)) {
|
||||
newItems.push(filter);
|
||||
}
|
||||
|
||||
const newFilterToUpdate = {
|
||||
...queryData.filters,
|
||||
items: newItems,
|
||||
op: queryData.filters?.op || 'AND',
|
||||
};
|
||||
|
||||
return {
|
||||
...queryData,
|
||||
...convertFiltersToExpressionWithExistingQuery(
|
||||
newFilterToUpdate,
|
||||
queryData.filter?.expression,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a single widget by adding filters to its query
|
||||
*/
|
||||
const updateSingleWidget = (
|
||||
widget: Widgets,
|
||||
filter: TagFilterItem,
|
||||
): Widgets => {
|
||||
if (!widget.query?.builder?.queryData || isEmpty(filter)) {
|
||||
return widget;
|
||||
}
|
||||
|
||||
return {
|
||||
...widget,
|
||||
query: {
|
||||
...widget.query,
|
||||
builder: {
|
||||
...widget.query.builder,
|
||||
queryData: widget.query.builder.queryData.map((queryData) =>
|
||||
updateQueryFilters(queryData, filter),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const removeIfPresent = (
|
||||
queryData: IBuilderQuery,
|
||||
filter: TagFilterItem,
|
||||
): IBuilderQuery => {
|
||||
const existingFilters = queryData.filters?.items || [];
|
||||
|
||||
// addition | update
|
||||
const currentFilterKey = filter.key?.key;
|
||||
const valueToAdd = filter.value.toString();
|
||||
const newItems: TagFilterItem[] = [];
|
||||
|
||||
existingFilters.forEach((existingFilter) => {
|
||||
const newFilter = cloneDeep(existingFilter);
|
||||
if (newFilter.key?.key === currentFilterKey) {
|
||||
if (isArray(newFilter.value) && newFilter.value.includes(valueToAdd)) {
|
||||
newFilter.value = newFilter.value.filter((value) => value !== valueToAdd);
|
||||
} else if (newFilter.value === valueToAdd) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
newItems.push(newFilter);
|
||||
});
|
||||
|
||||
return {
|
||||
...queryData,
|
||||
filters: {
|
||||
...queryData.filters,
|
||||
items: newItems,
|
||||
op: queryData.filters?.op || 'AND',
|
||||
},
|
||||
filter: {
|
||||
...queryData.filter,
|
||||
expression: removeKeysFromExpression(
|
||||
queryData.filter?.expression ?? '',
|
||||
filter.key?.key ? [filter.key.key] : [],
|
||||
true,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const updateAfterRemoval = (
|
||||
widget: Widgets,
|
||||
filter: TagFilterItem,
|
||||
): Widgets => {
|
||||
if (!widget.query?.builder?.queryData || isEmpty(filter)) {
|
||||
return widget;
|
||||
}
|
||||
|
||||
// remove the filters where the current filter is available as value as this widget is not selected anymore, hence removal
|
||||
return {
|
||||
...widget,
|
||||
query: {
|
||||
...widget.query,
|
||||
builder: {
|
||||
...widget.query.builder,
|
||||
queryData: widget.query.builder.queryData.map((queryData) =>
|
||||
removeIfPresent(queryData, filter),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A function that takes a dashboard configuration and a list of tag filters
|
||||
* and returns an updated dashboard with the filters appended to widget queries.
|
||||
*
|
||||
* @param dashboard The dashboard configuration
|
||||
* @param filters Array of tag filters to apply to widgets
|
||||
* @param widgetIds Optional array of widget IDs to filter which widgets get updated
|
||||
* @returns Updated dashboard configuration with filters applied
|
||||
*/
|
||||
export const addTagFiltersToDashboard = (
|
||||
dashboard: Dashboard | undefined,
|
||||
filter: TagFilterItem,
|
||||
widgetIds?: string[],
|
||||
applyToAll?: boolean,
|
||||
): Dashboard | undefined => {
|
||||
if (!dashboard || isEmpty(filter)) {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
// Create a deep copy to avoid mutating the original dashboard
|
||||
const updatedDashboard = cloneDeep(dashboard);
|
||||
|
||||
// Process each widget to add filters
|
||||
if (updatedDashboard.data.widgets) {
|
||||
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
|
||||
(widget) => {
|
||||
// Only apply to widgets with 'query' property
|
||||
if ('query' in widget) {
|
||||
// If widgetIds is provided, only update widgets with matching IDs
|
||||
if (!applyToAll && widgetIds && !widgetIds.includes(widget.id)) {
|
||||
// removal if needed
|
||||
return updateAfterRemoval(widget as Widgets, filter);
|
||||
}
|
||||
return updateSingleWidget(widget as Widgets, filter);
|
||||
}
|
||||
return widget;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return updatedDashboard;
|
||||
};
|
||||
@@ -15,11 +15,12 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Modal, Row, Space, Table, Typography } from 'antd';
|
||||
import { RowProps } from 'antd/lib';
|
||||
import { convertVariablesToDbFormat } from 'container/NewDashboard/DashboardVariablesSelection/util';
|
||||
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { PenLine, Trash2 } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
@@ -52,8 +53,10 @@ function TableRow({ children, ...props }: RowProps): JSX.Element {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if ((child as React.ReactElement).key === 'name') {
|
||||
return React.cloneElement(child as React.ReactElement, {
|
||||
const childElement = child as React.ReactElement;
|
||||
if (childElement.key === 'name') {
|
||||
return React.cloneElement(childElement, {
|
||||
key: 'name-with-drag',
|
||||
children: (
|
||||
<div className="variable-name-drag">
|
||||
<HolderOutlined
|
||||
@@ -68,7 +71,7 @@ function TableRow({ children, ...props }: RowProps): JSX.Element {
|
||||
});
|
||||
}
|
||||
|
||||
return child;
|
||||
return childElement;
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
@@ -81,6 +84,8 @@ function VariablesSetting({
|
||||
}): JSX.Element {
|
||||
const variableToDelete = useRef<IDashboardVariable | null>(null);
|
||||
const [deleteVariableModal, setDeleteVariableModal] = useState(false);
|
||||
const variableToApplyToAll = useRef<IDashboardVariable | null>(null);
|
||||
const [applyToAllModal, setApplyToAllModal] = useState(false);
|
||||
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
|
||||
@@ -88,7 +93,9 @@ function VariablesSetting({
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { variables = {} } = selectedDashboard?.data || {};
|
||||
const variables = useMemo(() => selectedDashboard?.data?.variables || {}, [
|
||||
selectedDashboard?.data?.variables,
|
||||
]);
|
||||
|
||||
const [variablesTableData, setVariablesTableData] = useState<any>([]);
|
||||
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
|
||||
@@ -127,6 +134,8 @@ function VariablesSetting({
|
||||
|
||||
const updateMutation = useUpdateDashboard();
|
||||
|
||||
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
|
||||
|
||||
useEffect(() => {
|
||||
const tableRowData = [];
|
||||
const variableOrderArr = [];
|
||||
@@ -164,17 +173,31 @@ function VariablesSetting({
|
||||
|
||||
const updateVariables = (
|
||||
updatedVariablesData: Dashboard['data']['variables'],
|
||||
currentRequestedId?: string,
|
||||
widgetIds?: string[],
|
||||
applyToAll?: boolean,
|
||||
): void => {
|
||||
if (!selectedDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDashboard =
|
||||
(currentRequestedId &&
|
||||
updatedVariablesData[currentRequestedId || '']?.type === 'DYNAMIC' &&
|
||||
addDynamicVariableToPanels(
|
||||
selectedDashboard,
|
||||
updatedVariablesData[currentRequestedId || ''],
|
||||
widgetIds,
|
||||
applyToAll,
|
||||
)) ||
|
||||
selectedDashboard;
|
||||
|
||||
updateMutation.mutateAsync(
|
||||
{
|
||||
id: selectedDashboard.id,
|
||||
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...newDashboard.data,
|
||||
variables: updatedVariablesData,
|
||||
},
|
||||
},
|
||||
@@ -202,6 +225,8 @@ function VariablesSetting({
|
||||
const onVariableSaveHandler = (
|
||||
mode: TVariableMode,
|
||||
variableData: IDashboardVariable,
|
||||
widgetIds?: string[],
|
||||
applyToAll?: boolean,
|
||||
): void => {
|
||||
const updatedVariableData = {
|
||||
...variableData,
|
||||
@@ -225,7 +250,7 @@ function VariablesSetting({
|
||||
const variables = convertVariablesToDbFormat(newVariablesArr);
|
||||
|
||||
setVariablesTableData(newVariablesArr);
|
||||
updateVariables(variables);
|
||||
updateVariables(variables, variableData?.id, widgetIds, applyToAll);
|
||||
onDoneVariableViewMode();
|
||||
};
|
||||
|
||||
@@ -251,9 +276,46 @@ function VariablesSetting({
|
||||
setDeleteVariableModal(false);
|
||||
};
|
||||
|
||||
const onApplyToAllHandler = (variable: IDashboardVariable): void => {
|
||||
variableToApplyToAll.current = variable;
|
||||
setApplyToAllModal(true);
|
||||
};
|
||||
|
||||
const handleApplyToAllConfirm = (): void => {
|
||||
if (variableToApplyToAll.current) {
|
||||
onVariableSaveHandler(
|
||||
variableViewMode || 'EDIT',
|
||||
variableToApplyToAll.current,
|
||||
[],
|
||||
true,
|
||||
);
|
||||
}
|
||||
variableToApplyToAll.current = null;
|
||||
setApplyToAllModal(false);
|
||||
};
|
||||
|
||||
const handleApplyToAllCancel = (): void => {
|
||||
variableToApplyToAll.current = null;
|
||||
setApplyToAllModal(false);
|
||||
};
|
||||
|
||||
const validateVariableName = (name: string): boolean =>
|
||||
!existingVariableNamesMap[name];
|
||||
|
||||
const validateAttributeKey = (
|
||||
attributeKey: string,
|
||||
currentVariableId?: string,
|
||||
): boolean => {
|
||||
// Check if any other dynamic variable already uses this attribute key
|
||||
const isDuplicateAttributeKey = Object.values(variables).some(
|
||||
(variable: IDashboardVariable) =>
|
||||
variable.type === 'DYNAMIC' &&
|
||||
variable.dynamicVariablesAttribute === attributeKey &&
|
||||
variable.id !== currentVariableId, // Exclude current variable being edited
|
||||
);
|
||||
return !isDuplicateAttributeKey;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Variable',
|
||||
@@ -271,6 +333,16 @@ function VariablesSetting({
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
<Space className="actions-btns">
|
||||
{variable.type === 'DYNAMIC' && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(): void => onApplyToAllHandler(variable)}
|
||||
className="apply-to-all-button"
|
||||
loading={updateMutation.isLoading}
|
||||
>
|
||||
<Typography.Text>Apply to all</Typography.Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(): void => onVariableViewModeEnter('EDIT', variable)}
|
||||
@@ -349,6 +421,7 @@ function VariablesSetting({
|
||||
onSave={onVariableSaveHandler}
|
||||
onCancel={onDoneVariableViewMode}
|
||||
validateName={validateVariableName}
|
||||
validateAttributeKey={validateAttributeKey}
|
||||
mode={variableViewMode}
|
||||
/>
|
||||
) : (
|
||||
@@ -415,6 +488,24 @@ function VariablesSetting({
|
||||
?
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Apply variable to all panels"
|
||||
centered
|
||||
open={applyToAllModal}
|
||||
onOk={handleApplyToAllConfirm}
|
||||
onCancel={handleApplyToAllCancel}
|
||||
okText="Apply to all"
|
||||
cancelText="Cancel"
|
||||
>
|
||||
<Typography.Text>
|
||||
Are you sure you want to apply variable{' '}
|
||||
<span className="apply-to-all-variable-name">
|
||||
{variableToApplyToAll?.current?.name}
|
||||
</span>{' '}
|
||||
to all panels? This action may affect panels where this variable is not
|
||||
applicable.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
import { Alert, Row } from 'antd';
|
||||
import { Row } from 'antd';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
@@ -9,6 +9,7 @@ import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import DynamicVariableSelection from './DynamicVariableSelection';
|
||||
import {
|
||||
buildDependencies,
|
||||
buildDependencyGraph,
|
||||
@@ -99,16 +100,23 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
[JSON.stringify(dependencyData?.order), minTime, maxTime],
|
||||
);
|
||||
|
||||
// Performance optimization: For dynamic variables with allSelected=true, we don't store
|
||||
// individual values in localStorage since we can always derive them from available options.
|
||||
// This makes localStorage much lighter and more efficient.
|
||||
const onValueUpdate = (
|
||||
name: string,
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
// isMountedCall?: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): void => {
|
||||
if (id) {
|
||||
updateLocalStorageDashboardVariables(name, value, allSelected);
|
||||
// For dynamic variables, only store in localStorage when NOT allSelected
|
||||
// This makes localStorage much lighter by avoiding storing all individual values
|
||||
const variable = variables?.[id] || variables?.[name];
|
||||
const isDynamic = variable?.type === 'DYNAMIC';
|
||||
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
|
||||
|
||||
if (selectedDashboard) {
|
||||
setSelectedDashboard((prev) => {
|
||||
@@ -121,6 +129,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
...oldVariables[id],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
if (oldVariables?.[name]) {
|
||||
@@ -128,6 +137,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
...oldVariables[name],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -170,22 +180,22 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dependencyData?.hasCycle && (
|
||||
<Alert
|
||||
message={`Circular dependency detected: ${dependencyData?.cycleNodes?.join(
|
||||
' → ',
|
||||
)}`}
|
||||
type="error"
|
||||
showIcon
|
||||
className="cycle-error-alert"
|
||||
/>
|
||||
)}
|
||||
<Row style={{ display: 'flex', gap: '12px' }}>
|
||||
{orderBasedSortedVariables &&
|
||||
Array.isArray(orderBasedSortedVariables) &&
|
||||
orderBasedSortedVariables.length > 0 &&
|
||||
orderBasedSortedVariables.map((variable) => (
|
||||
<Row style={{ display: 'flex', gap: '12px' }}>
|
||||
{orderBasedSortedVariables &&
|
||||
Array.isArray(orderBasedSortedVariables) &&
|
||||
orderBasedSortedVariables.length > 0 &&
|
||||
orderBasedSortedVariables.map((variable) =>
|
||||
variable.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableSelection
|
||||
key={`${variable.name}${variable.id}${variable.order}`}
|
||||
existingVariables={variables}
|
||||
variableData={{
|
||||
name: variable.name,
|
||||
...variable,
|
||||
}}
|
||||
onValueUpdate={onValueUpdate}
|
||||
/>
|
||||
) : (
|
||||
<VariableItem
|
||||
key={`${variable.name}${variable.id}}${variable.order}`}
|
||||
existingVariables={variables}
|
||||
@@ -198,9 +208,9 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||
dependencyData={dependencyData}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
),
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { isEmpty, isUndefined } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { ALL_SELECT_VALUE } from '../utils';
|
||||
import { SelectItemStyle } from './styles';
|
||||
import {
|
||||
areArraysEqual,
|
||||
getOptionsForDynamicVariable,
|
||||
uniqueValues,
|
||||
} from './util';
|
||||
import { getSelectValue } from './VariableItem';
|
||||
|
||||
interface DynamicVariableSelectionProps {
|
||||
variableData: IDashboardVariable;
|
||||
existingVariables: Record<string, IDashboardVariable>;
|
||||
onValueUpdate: (
|
||||
name: string,
|
||||
id: string,
|
||||
arg1: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function DynamicVariableSelection({
|
||||
variableData,
|
||||
onValueUpdate,
|
||||
existingVariables,
|
||||
}: DynamicVariableSelectionProps): JSX.Element {
|
||||
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<null | string>(null);
|
||||
|
||||
const [isComplete, setIsComplete] = useState<boolean>(false);
|
||||
|
||||
const [filteredOptionsData, setFilteredOptionsData] = useState<
|
||||
(string | number | boolean)[]
|
||||
>([]);
|
||||
|
||||
const [relatedValues, setRelatedValues] = useState<string[]>([]);
|
||||
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [tempSelection, setTempSelection] = useState<
|
||||
string | string[] | undefined
|
||||
>(undefined);
|
||||
|
||||
// Track dropdown open state for auto-checking new values
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||
|
||||
// Create a dependency key from all dynamic variables
|
||||
const dynamicVariablesKey = useMemo(() => {
|
||||
if (!existingVariables) return 'no_variables';
|
||||
|
||||
const dynamicVars = Object.values(existingVariables)
|
||||
.filter((v) => v.type === 'DYNAMIC')
|
||||
.map(
|
||||
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
|
||||
)
|
||||
.join('|');
|
||||
|
||||
return dynamicVars || 'no_dynamic_variables';
|
||||
}, [existingVariables]);
|
||||
|
||||
const [apiSearchText, setApiSearchText] = useState<string>('');
|
||||
|
||||
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
// existing query is the query made from the other dynamic variables around this one with there current values
|
||||
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
|
||||
const existingQuery = useMemo(() => {
|
||||
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryParts: string[] = [];
|
||||
|
||||
Object.entries(existingVariables).forEach(([, variable]) => {
|
||||
// Skip the current variable being processed
|
||||
if (variable.id === variableData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only include dynamic variables that have selected values and are not selected as ALL
|
||||
if (
|
||||
variable.type === 'DYNAMIC' &&
|
||||
variable.dynamicVariablesAttribute &&
|
||||
variable.selectedValue &&
|
||||
!isEmpty(variable.selectedValue) &&
|
||||
(variable.showALLOption ? !variable.allSelected : true)
|
||||
) {
|
||||
const attribute = variable.dynamicVariablesAttribute;
|
||||
const values = Array.isArray(variable.selectedValue)
|
||||
? variable.selectedValue
|
||||
: [variable.selectedValue];
|
||||
|
||||
// Filter out empty values and convert to strings
|
||||
const validValues = values
|
||||
.filter((value) => value !== null && value !== undefined && value !== '')
|
||||
.map((value) => value.toString());
|
||||
|
||||
if (validValues.length > 0) {
|
||||
// Format values for query - wrap strings in quotes, keep numbers as is
|
||||
const formattedValues = validValues.map((value) => {
|
||||
// Check if value is a number
|
||||
const numValue = Number(value);
|
||||
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
|
||||
return value; // Keep as number
|
||||
}
|
||||
// Escape single quotes and wrap in quotes
|
||||
return `'${value.replace(/'/g, "\\'")}'`;
|
||||
});
|
||||
|
||||
if (formattedValues.length === 1) {
|
||||
queryParts.push(`${attribute} = ${formattedValues[0]}`);
|
||||
} else {
|
||||
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return queryParts.join(' AND ');
|
||||
}, [
|
||||
existingVariables,
|
||||
variableData.id,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
]);
|
||||
|
||||
const { isLoading, refetch } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
variableData.name || `variable_${variableData.id}`,
|
||||
dynamicVariablesKey,
|
||||
minTime,
|
||||
maxTime,
|
||||
debouncedApiSearchText,
|
||||
],
|
||||
{
|
||||
enabled: variableData.type === 'DYNAMIC',
|
||||
queryFn: () =>
|
||||
getFieldValues(
|
||||
variableData.dynamicVariablesSource?.toLowerCase() === 'all sources'
|
||||
? undefined
|
||||
: (variableData.dynamicVariablesSource?.toLowerCase() as
|
||||
| 'traces'
|
||||
| 'logs'
|
||||
| 'metrics'),
|
||||
variableData.dynamicVariablesAttribute,
|
||||
debouncedApiSearchText,
|
||||
minTime,
|
||||
maxTime,
|
||||
existingQuery,
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
const newNormalizedValues = data.payload?.normalizedValues || [];
|
||||
const newRelatedValues = data.payload?.relatedValues || [];
|
||||
|
||||
if (!debouncedApiSearchText) {
|
||||
setOptionsData(newNormalizedValues);
|
||||
setIsComplete(data.payload?.complete || false);
|
||||
}
|
||||
setFilteredOptionsData(newNormalizedValues);
|
||||
setRelatedValues(newRelatedValues);
|
||||
setOriginalRelatedValues(newRelatedValues);
|
||||
|
||||
// Only run auto-check logic when necessary to avoid performance issues
|
||||
if (variableData.allSelected && isDropdownOpen) {
|
||||
// Build the latest full list from API (normalized + related)
|
||||
const latestValues = [
|
||||
...new Set([
|
||||
...newNormalizedValues.map((v) => v.toString()),
|
||||
...newRelatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
];
|
||||
|
||||
// Update temp selection to exactly reflect latest API values when ALL is active
|
||||
const currentStrings = Array.isArray(tempSelection)
|
||||
? tempSelection.map((v) => v.toString())
|
||||
: tempSelection
|
||||
? [tempSelection.toString()]
|
||||
: [];
|
||||
const areSame =
|
||||
currentStrings.length === latestValues.length &&
|
||||
latestValues.every((v) => currentStrings.includes(v));
|
||||
if (!areSame) {
|
||||
setTempSelection(latestValues);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error) {
|
||||
let message = SOMETHING_WENT_WRONG;
|
||||
if (error?.message) {
|
||||
message = error?.message;
|
||||
} else {
|
||||
message =
|
||||
'Please make sure configuration is valid and you have required setup and permissions';
|
||||
}
|
||||
setErrorMessage(message);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(inputValue: string | string[]): void => {
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
|
||||
if (
|
||||
value === variableData.selectedValue ||
|
||||
(Array.isArray(value) &&
|
||||
Array.isArray(variableData.selectedValue) &&
|
||||
areArraysEqual(value, variableData.selectedValue))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (variableData.name) {
|
||||
if (
|
||||
value === ALL_SELECT_VALUE ||
|
||||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
||||
) {
|
||||
// For ALL selection in dynamic variables, pass null to avoid storing values
|
||||
// The parent component will handle this appropriately
|
||||
onValueUpdate(variableData.name, variableData.id, null, true);
|
||||
} else {
|
||||
// Build union of available options shown in dropdown (normalized + related)
|
||||
const allAvailableOptionStrings = [
|
||||
...new Set([
|
||||
...optionsData.map((v) => v.toString()),
|
||||
...relatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
];
|
||||
|
||||
const haveCustomValuesSelected =
|
||||
Array.isArray(value) &&
|
||||
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
|
||||
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
value,
|
||||
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
|
||||
haveCustomValuesSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[variableData, onValueUpdate, optionsData, relatedValues],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
variableData.dynamicVariablesSource &&
|
||||
variableData.dynamicVariablesAttribute
|
||||
) {
|
||||
refetch();
|
||||
}
|
||||
}, [
|
||||
refetch,
|
||||
variableData.dynamicVariablesSource,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
debouncedApiSearchText,
|
||||
]);
|
||||
|
||||
// Build a memoized list of all currently available option strings (normalized + related)
|
||||
const allAvailableOptionStrings = useMemo(
|
||||
() => [
|
||||
...new Set([
|
||||
...optionsData.map((v) => v.toString()),
|
||||
...relatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
],
|
||||
[optionsData, relatedValues],
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(text: string) => {
|
||||
if (isComplete) {
|
||||
if (!text) {
|
||||
setFilteredOptionsData(optionsData);
|
||||
setRelatedValues(originalRelatedValues);
|
||||
return;
|
||||
}
|
||||
|
||||
const localFilteredOptionsData: (string | number | boolean)[] = [];
|
||||
optionsData.forEach((option) => {
|
||||
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
|
||||
localFilteredOptionsData.push(option);
|
||||
}
|
||||
});
|
||||
setFilteredOptionsData(localFilteredOptionsData);
|
||||
setRelatedValues(
|
||||
originalRelatedValues.filter((value) =>
|
||||
value.toLowerCase().includes(text.toLowerCase()),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setApiSearchText(text);
|
||||
}
|
||||
},
|
||||
[isComplete, optionsData, originalRelatedValues],
|
||||
);
|
||||
|
||||
const { selectedValue } = variableData;
|
||||
const selectedValueStringified = useMemo(
|
||||
() => getSelectValue(selectedValue, variableData),
|
||||
[selectedValue, variableData],
|
||||
);
|
||||
|
||||
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
|
||||
|
||||
const selectValue =
|
||||
variableData.allSelected && enableSelectAll
|
||||
? ALL_SELECT_VALUE
|
||||
: selectedValueStringified;
|
||||
|
||||
// Add a handler for tracking temporary selection changes
|
||||
const handleTempChange = useCallback(
|
||||
(inputValue: string | string[]): void => {
|
||||
// Store the selection in temporary state while dropdown is open
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
const sanitizedValue = uniqueValues(value);
|
||||
setTempSelection(sanitizedValue);
|
||||
},
|
||||
[variableData.multiSelect],
|
||||
);
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
const handleDropdownVisibleChange = (visible: boolean): void => {
|
||||
// Update dropdown open state for auto-checking
|
||||
setIsDropdownOpen(visible);
|
||||
|
||||
// Initialize temp selection when opening dropdown
|
||||
if (visible) {
|
||||
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
|
||||
// When ALL is selected, set selection to exactly the latest available values
|
||||
const latestAll = [...allAvailableOptionStrings];
|
||||
setTempSelection(latestAll);
|
||||
} else {
|
||||
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
|
||||
}
|
||||
}
|
||||
// Apply changes when closing dropdown
|
||||
else if (!visible && tempSelection !== undefined) {
|
||||
// Only call handleChange if there's actually a change in the selection
|
||||
const currentValue = variableData.selectedValue;
|
||||
|
||||
// Helper function to check if arrays have the same elements regardless of order
|
||||
const areArraysEqualIgnoreOrder = (a: any[], b: any[]): boolean => {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return areArraysEqual(sortedA, sortedB);
|
||||
};
|
||||
|
||||
// If ALL was selected before and remains ALL after, skip updating
|
||||
const wasAllSelected = enableSelectAll && variableData.allSelected;
|
||||
const isAllSelectedAfter =
|
||||
enableSelectAll &&
|
||||
Array.isArray(tempSelection) &&
|
||||
tempSelection.length === allAvailableOptionStrings.length &&
|
||||
allAvailableOptionStrings.every((v) => tempSelection.includes(v));
|
||||
|
||||
if (wasAllSelected && isAllSelectedAfter) {
|
||||
setTempSelection(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChanged =
|
||||
tempSelection !== currentValue &&
|
||||
!(
|
||||
Array.isArray(tempSelection) &&
|
||||
Array.isArray(currentValue) &&
|
||||
areArraysEqualIgnoreOrder(tempSelection, currentValue)
|
||||
);
|
||||
|
||||
if (hasChanged) {
|
||||
handleChange(tempSelection);
|
||||
}
|
||||
setTempSelection(undefined);
|
||||
}
|
||||
|
||||
// Always reset filtered data when dropdown closes, regardless of tempSelection state
|
||||
if (!visible) {
|
||||
setFilteredOptionsData(optionsData);
|
||||
setRelatedValues(originalRelatedValues);
|
||||
setApiSearchText('');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
// Cleanup on unmount
|
||||
setTempSelection(undefined);
|
||||
setFilteredOptionsData([]);
|
||||
setRelatedValues([]);
|
||||
setApiSearchText('');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const finalSelectedValues = useMemo(() => {
|
||||
if (variableData.multiSelect) {
|
||||
let value = tempSelection || selectedValue;
|
||||
if (isEmpty(value)) {
|
||||
if (variableData.showALLOption) {
|
||||
if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else if (variableData.allSelected) {
|
||||
// If ALL is selected but no stored values, derive from available options
|
||||
// This handles the case where we don't store values in localStorage for ALL
|
||||
value = allAvailableOptionStrings;
|
||||
} else {
|
||||
value = optionsData;
|
||||
}
|
||||
} else if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
if (isEmpty(selectedValue)) {
|
||||
if (variableData.defaultValue) {
|
||||
return variableData.defaultValue;
|
||||
}
|
||||
return optionsData[0]?.toString();
|
||||
}
|
||||
|
||||
return selectedValue;
|
||||
}, [
|
||||
variableData.multiSelect,
|
||||
variableData.showALLOption,
|
||||
variableData.defaultValue,
|
||||
variableData.allSelected,
|
||||
selectedValue,
|
||||
tempSelection,
|
||||
optionsData,
|
||||
allAvailableOptionStrings,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(variableData.multiSelect && !(tempSelection || selectValue)) ||
|
||||
isEmpty(selectValue)
|
||||
) {
|
||||
handleChange(finalSelectedValues as string[] | string);
|
||||
}
|
||||
}, [
|
||||
finalSelectedValues,
|
||||
handleChange,
|
||||
selectValue,
|
||||
tempSelection,
|
||||
variableData.multiSelect,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<Typography.Text className="variable-name" ellipsis>
|
||||
${variableData.name}
|
||||
{variableData.description && (
|
||||
<Tooltip title={variableData.description}>
|
||||
<InfoCircleOutlined className="info-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
<div className="variable-value">
|
||||
{variableData.multiSelect ? (
|
||||
<CustomMultiSelect
|
||||
key={variableData.id}
|
||||
options={getOptionsForDynamicVariable(
|
||||
filteredOptionsData || [],
|
||||
relatedValues || [],
|
||||
)}
|
||||
defaultValue={variableData.defaultValue}
|
||||
onChange={handleTempChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
placement="bottomLeft"
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
maxTagCount={2}
|
||||
getPopupContainer={popupContainer}
|
||||
value={
|
||||
(tempSelection || selectValue) === ALL_SELECT_VALUE
|
||||
? 'ALL'
|
||||
: tempSelection || selectValue
|
||||
}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
errorMessage={errorMessage}
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
maxTagPlaceholder={(omittedValues): JSX.Element => {
|
||||
const maxDisplayValues = 10;
|
||||
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
|
||||
const hasMore = omittedValues.length > maxDisplayValues;
|
||||
const tooltipText =
|
||||
valuesToShow.map(({ value }) => value).join(', ') +
|
||||
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipText}>
|
||||
<span>+ {omittedValues.length} </span>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
onClear={(): void => {
|
||||
handleChange([]);
|
||||
}}
|
||||
enableAllSelection={enableSelectAll}
|
||||
maxTagTextLength={30}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
setErrorMessage(null);
|
||||
refetch();
|
||||
}}
|
||||
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||
/>
|
||||
) : (
|
||||
<CustomSelect
|
||||
key={variableData.id}
|
||||
onChange={handleChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
getPopupContainer={popupContainer}
|
||||
options={getOptionsForDynamicVariable(
|
||||
filteredOptionsData || [],
|
||||
relatedValues || [],
|
||||
)}
|
||||
value={selectValue}
|
||||
defaultValue={variableData.defaultValue}
|
||||
errorMessage={errorMessage}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
setErrorMessage(null);
|
||||
refetch();
|
||||
}}
|
||||
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariableSelection;
|
||||
@@ -167,7 +167,7 @@ describe('VariableItem', () => {
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('ALL')).toBeInTheDocument();
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls useEffect when the component mounts', () => {
|
||||
|
||||
@@ -8,23 +8,14 @@ import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
import { orange } from '@ant-design/colors';
|
||||
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Checkbox,
|
||||
Input,
|
||||
Popover,
|
||||
Select,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import { Input, Popover, Tooltip, Typography } from 'antd';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
|
||||
import { debounce, isArray, isString } from 'lodash-es';
|
||||
import map from 'lodash-es/map';
|
||||
import { ChangeEvent, memo, useEffect, useMemo, useState } from 'react';
|
||||
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -33,17 +24,10 @@ import { VariableResponseProps } from 'types/api/dashboard/variables/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { variablePropsToPayloadVariables } from '../utils';
|
||||
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
|
||||
import { SelectItemStyle } from './styles';
|
||||
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
|
||||
|
||||
const ALL_SELECT_VALUE = '__ALL__';
|
||||
|
||||
enum ToggleTagValue {
|
||||
Only = 'Only',
|
||||
All = 'All',
|
||||
}
|
||||
|
||||
interface VariableItemProps {
|
||||
variableData: IDashboardVariable;
|
||||
existingVariables: Record<string, IDashboardVariable>;
|
||||
@@ -58,7 +42,7 @@ interface VariableItemProps {
|
||||
dependencyData: IDependencyData | null;
|
||||
}
|
||||
|
||||
const getSelectValue = (
|
||||
export const getSelectValue = (
|
||||
selectedValue: IDashboardVariable['selectedValue'],
|
||||
variableData: IDashboardVariable,
|
||||
): string | string[] | undefined => {
|
||||
@@ -83,6 +67,9 @@ function VariableItem({
|
||||
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
|
||||
[],
|
||||
);
|
||||
const [tempSelection, setTempSelection] = useState<
|
||||
string | string[] | undefined
|
||||
>(undefined);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
@@ -139,6 +126,7 @@ function VariableItem({
|
||||
valueNotInList = true;
|
||||
}
|
||||
}
|
||||
|
||||
// variablesData.allSelected is added for the case where on change of options we need to update the
|
||||
// local storage
|
||||
if (
|
||||
@@ -146,28 +134,32 @@ function VariableItem({
|
||||
variableData.name &&
|
||||
(validVariableUpdate() || valueNotInList || variableData.allSelected)
|
||||
) {
|
||||
let value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
// The default value for multi-select is ALL and first value for
|
||||
// single select
|
||||
if (valueNotInList) {
|
||||
if (variableData.multiSelect) {
|
||||
value = newOptionsData;
|
||||
allSelected = true;
|
||||
} else {
|
||||
[value] = newOptionsData;
|
||||
}
|
||||
} else if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
newOptionsData.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
selectedValue.length === newOptionsData.length &&
|
||||
newOptionsData.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
if (
|
||||
variableData.allSelected &&
|
||||
variableData.multiSelect &&
|
||||
variableData.showALLOption
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
|
||||
|
||||
if (variableData && variableData?.name && variableData?.id) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(newOptionsData.map((option) => option.toString()));
|
||||
}
|
||||
} else {
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
newOptionsData.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
newOptionsData.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
|
||||
if (variableData && variableData?.name && variableData?.id) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +183,7 @@ function VariableItem({
|
||||
}
|
||||
};
|
||||
|
||||
const { isLoading } = useQuery(
|
||||
const { isLoading, refetch } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
variableData.name || '',
|
||||
@@ -242,26 +234,62 @@ function VariableItem({
|
||||
},
|
||||
);
|
||||
|
||||
const handleChange = (inputValue: string | string[]): void => {
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
const handleChange = useCallback(
|
||||
(inputValue: string | string[]): void => {
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
|
||||
if (
|
||||
value === variableData.selectedValue ||
|
||||
(Array.isArray(value) &&
|
||||
Array.isArray(variableData.selectedValue) &&
|
||||
areArraysEqual(value, variableData.selectedValue))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (variableData.name) {
|
||||
if (
|
||||
value === ALL_SELECT_VALUE ||
|
||||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
||||
value === variableData.selectedValue ||
|
||||
(Array.isArray(value) &&
|
||||
Array.isArray(variableData.selectedValue) &&
|
||||
areArraysEqual(value, variableData.selectedValue))
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
||||
} else {
|
||||
onValueUpdate(variableData.name, variableData.id, value, false);
|
||||
return;
|
||||
}
|
||||
if (variableData.name) {
|
||||
// Check if ALL is effectively selected by comparing with available options
|
||||
const isAllSelected =
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
optionsData.every((option) => value.includes(option.toString()));
|
||||
|
||||
if (isAllSelected && variableData.showALLOption) {
|
||||
// For ALL selection, pass null to avoid storing values
|
||||
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
||||
} else {
|
||||
onValueUpdate(variableData.name, variableData.id, value, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
variableData.multiSelect,
|
||||
variableData.selectedValue,
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
onValueUpdate,
|
||||
optionsData,
|
||||
variableData.showALLOption,
|
||||
],
|
||||
);
|
||||
|
||||
// Add a handler for tracking temporary selection changes
|
||||
const handleTempChange = (inputValue: string | string[]): void => {
|
||||
// Store the selection in temporary state while dropdown is open
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
setTempSelection(value);
|
||||
};
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
const handleDropdownVisibleChange = (visible: boolean): void => {
|
||||
// Initialize temp selection when opening dropdown
|
||||
if (visible) {
|
||||
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
|
||||
}
|
||||
// Apply changes when closing dropdown
|
||||
else if (!visible && tempSelection !== undefined) {
|
||||
// Call handleChange with the temporarily stored selection
|
||||
handleChange(tempSelection);
|
||||
setTempSelection(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -281,10 +309,58 @@ function VariableItem({
|
||||
? 'ALL'
|
||||
: selectedValueStringified;
|
||||
|
||||
const mode: 'multiple' | undefined =
|
||||
variableData.multiSelect && !variableData.allSelected
|
||||
? 'multiple'
|
||||
: undefined;
|
||||
// Apply default value on first render if no selection exists
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const finalSelectedValues = useMemo(() => {
|
||||
if (variableData.multiSelect) {
|
||||
let value = tempSelection || selectedValue;
|
||||
if (isEmpty(value)) {
|
||||
if (variableData.showALLOption) {
|
||||
if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData;
|
||||
}
|
||||
} else if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
if (isEmpty(selectedValue)) {
|
||||
if (variableData.defaultValue) {
|
||||
return variableData.defaultValue;
|
||||
}
|
||||
return optionsData[0]?.toString();
|
||||
}
|
||||
|
||||
return selectedValue;
|
||||
}, [
|
||||
variableData.multiSelect,
|
||||
variableData.showALLOption,
|
||||
variableData.defaultValue,
|
||||
selectedValue,
|
||||
tempSelection,
|
||||
optionsData,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(variableData.multiSelect && !(tempSelection || selectValue)) ||
|
||||
isEmpty(selectValue)
|
||||
) {
|
||||
handleChange(finalSelectedValues as string[] | string);
|
||||
}
|
||||
}, [
|
||||
finalSelectedValues,
|
||||
handleChange,
|
||||
selectValue,
|
||||
tempSelection,
|
||||
variableData.multiSelect,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch options for CUSTOM Type
|
||||
@@ -294,113 +370,6 @@ function VariableItem({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [variableData.type, variableData.customValue]);
|
||||
|
||||
const checkAll = (e: MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const isChecked =
|
||||
variableData.allSelected || selectValue?.includes(ALL_SELECT_VALUE);
|
||||
|
||||
if (isChecked) {
|
||||
handleChange([]);
|
||||
} else {
|
||||
handleChange(ALL_SELECT_VALUE);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionSelect = (
|
||||
e: CheckboxChangeEvent,
|
||||
option: string | number | boolean,
|
||||
): void => {
|
||||
const newSelectedValue = Array.isArray(selectedValue)
|
||||
? ((selectedValue.filter(
|
||||
(val) => val.toString() !== option.toString(),
|
||||
) as unknown) as string[])
|
||||
: [];
|
||||
|
||||
if (
|
||||
!e.target.checked &&
|
||||
Array.isArray(selectedValueStringified) &&
|
||||
selectedValueStringified.includes(option.toString())
|
||||
) {
|
||||
if (newSelectedValue.length === 1) {
|
||||
handleChange(newSelectedValue[0].toString());
|
||||
return;
|
||||
}
|
||||
handleChange(newSelectedValue);
|
||||
} else if (!e.target.checked && selectedValue === option.toString()) {
|
||||
handleChange(ALL_SELECT_VALUE);
|
||||
} else if (newSelectedValue.length === optionsData.length - 1) {
|
||||
handleChange(ALL_SELECT_VALUE);
|
||||
}
|
||||
};
|
||||
|
||||
const [optionState, setOptionState] = useState({
|
||||
tag: '',
|
||||
visible: false,
|
||||
});
|
||||
|
||||
function currentToggleTagValue({
|
||||
option,
|
||||
}: {
|
||||
option: string;
|
||||
}): ToggleTagValue {
|
||||
if (
|
||||
option.toString() === selectValue ||
|
||||
(Array.isArray(selectValue) &&
|
||||
selectValue?.includes(option.toString()) &&
|
||||
selectValue.length === 1)
|
||||
) {
|
||||
return ToggleTagValue.All;
|
||||
}
|
||||
return ToggleTagValue.Only;
|
||||
}
|
||||
|
||||
function handleToggle(e: ChangeEvent, option: string): void {
|
||||
e.stopPropagation();
|
||||
const mode = currentToggleTagValue({ option: option as string });
|
||||
const isChecked =
|
||||
variableData.allSelected ||
|
||||
option.toString() === selectValue ||
|
||||
(Array.isArray(selectValue) && selectValue?.includes(option.toString()));
|
||||
|
||||
if (isChecked) {
|
||||
if (mode === ToggleTagValue.Only && variableData.multiSelect) {
|
||||
handleChange([option.toString()]);
|
||||
} else if (!variableData.multiSelect) {
|
||||
handleChange(option.toString());
|
||||
} else {
|
||||
handleChange(ALL_SELECT_VALUE);
|
||||
}
|
||||
} else {
|
||||
handleChange(option.toString());
|
||||
}
|
||||
}
|
||||
|
||||
function retProps(
|
||||
option: string,
|
||||
): {
|
||||
onMouseOver: () => void;
|
||||
onMouseOut: () => void;
|
||||
} {
|
||||
return {
|
||||
onMouseOver: (): void =>
|
||||
setOptionState({
|
||||
tag: option.toString(),
|
||||
visible: true,
|
||||
}),
|
||||
onMouseOut: (): void =>
|
||||
setOptionState({
|
||||
tag: option.toString(),
|
||||
visible: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const ensureValidOption = (option: string): boolean =>
|
||||
!(
|
||||
currentToggleTagValue({ option }) === ToggleTagValue.All && !enableSelectAll
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<Typography.Text className="variable-name" ellipsis>
|
||||
@@ -428,105 +397,90 @@ function VariableItem({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
!errorMessage &&
|
||||
optionsData && (
|
||||
<Select
|
||||
optionsData &&
|
||||
(variableData.multiSelect ? (
|
||||
<CustomMultiSelect
|
||||
key={
|
||||
selectValue && Array.isArray(selectValue)
|
||||
? selectValue.join(' ')
|
||||
: selectValue || variableData.id
|
||||
}
|
||||
defaultValue={selectValue}
|
||||
onChange={handleChange}
|
||||
options={optionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
}))}
|
||||
defaultValue={variableData.defaultValue || selectValue}
|
||||
onChange={handleTempChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
placement="bottomLeft"
|
||||
mode={mode}
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
maxTagCount={4}
|
||||
maxTagCount={2}
|
||||
getPopupContainer={popupContainer}
|
||||
value={tempSelection || selectValue}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
errorMessage={errorMessage}
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
tagRender={(props): JSX.Element => (
|
||||
<Tag closable onClose={props.onClose}>
|
||||
{props.value}
|
||||
</Tag>
|
||||
)}
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
maxTagPlaceholder={(omittedValues): JSX.Element => (
|
||||
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
|
||||
<span>+ {omittedValues.length} </span>
|
||||
</Tooltip>
|
||||
)}
|
||||
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
|
||||
>
|
||||
{enableSelectAll && (
|
||||
<Select.Option data-testid="option-ALL" value={ALL_SELECT_VALUE}>
|
||||
<div className="all-label" onClick={(e): void => checkAll(e as any)}>
|
||||
<Checkbox checked={variableData.allSelected} />
|
||||
ALL
|
||||
</div>
|
||||
</Select.Option>
|
||||
)}
|
||||
{map(optionsData, (option) => (
|
||||
<Select.Option
|
||||
data-testid={`option-${option}`}
|
||||
key={option.toString()}
|
||||
value={option}
|
||||
>
|
||||
<div
|
||||
className={variableData.multiSelect ? 'dropdown-checkbox-label' : ''}
|
||||
>
|
||||
{variableData.multiSelect && (
|
||||
<Checkbox
|
||||
onChange={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleOptionSelect(e, option);
|
||||
}}
|
||||
checked={
|
||||
variableData.allSelected ||
|
||||
option.toString() === selectValue ||
|
||||
(Array.isArray(selectValue) &&
|
||||
selectValue?.includes(option.toString()))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-value"
|
||||
{...retProps(option as string)}
|
||||
onClick={(e): void => handleToggle(e as any, option as string)}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{
|
||||
tooltip: {
|
||||
placement: variableData.multiSelect ? 'top' : 'right',
|
||||
autoAdjustOverflow: true,
|
||||
},
|
||||
}}
|
||||
className="option-text"
|
||||
>
|
||||
{option.toString()}
|
||||
</Typography.Text>
|
||||
maxTagPlaceholder={(omittedValues): JSX.Element => {
|
||||
const maxDisplayValues = 10;
|
||||
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
|
||||
const hasMore = omittedValues.length > maxDisplayValues;
|
||||
const tooltipText =
|
||||
valuesToShow.map(({ value }) => value).join(', ') +
|
||||
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
|
||||
|
||||
{variableData.multiSelect &&
|
||||
optionState.tag === option.toString() &&
|
||||
optionState.visible &&
|
||||
ensureValidOption(option as string) && (
|
||||
<Typography.Text className="toggle-tag-label">
|
||||
{currentToggleTagValue({ option: option as string })}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
return (
|
||||
<Tooltip title={tooltipText}>
|
||||
<span>+ {omittedValues.length} </span>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
onClear={(): void => {
|
||||
handleChange([]);
|
||||
}}
|
||||
enableAllSelection={enableSelectAll}
|
||||
maxTagTextLength={30}
|
||||
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
|
||||
onRetry={(): void => {
|
||||
setErrorMessage(null);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CustomSelect
|
||||
key={
|
||||
selectValue && Array.isArray(selectValue)
|
||||
? selectValue.join(' ')
|
||||
: selectValue || variableData.id
|
||||
}
|
||||
defaultValue={variableData.defaultValue || selectValue}
|
||||
onChange={handleChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
getPopupContainer={popupContainer}
|
||||
options={optionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
}))}
|
||||
value={selectValue}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={(): void => {
|
||||
setErrorMessage(null);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{variableData.type !== 'TEXTBOX' && errorMessage && (
|
||||
<span style={{ margin: '0 0.5rem' }}>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as ReactQuery from 'react-query';
|
||||
import * as ReactRedux from 'react-redux';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DynamicVariableSelection from '../DynamicVariableSelection';
|
||||
|
||||
// Don't mock the components - use real ones
|
||||
|
||||
// Mock for useQuery
|
||||
const mockQueryResult = {
|
||||
data: undefined,
|
||||
error: null,
|
||||
isError: false,
|
||||
isIdle: false,
|
||||
isLoading: false,
|
||||
isPreviousData: false,
|
||||
isSuccess: true,
|
||||
status: 'success',
|
||||
isFetched: true,
|
||||
isFetchingNextPage: false,
|
||||
isFetchingPreviousPage: false,
|
||||
isPlaceholderData: false,
|
||||
isPaused: false,
|
||||
isRefetchError: false,
|
||||
isRefetching: false,
|
||||
isStale: false,
|
||||
isLoadingError: false,
|
||||
isFetching: false,
|
||||
isFetchedAfterMount: true,
|
||||
dataUpdatedAt: 0,
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
refetch: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
fetchNextPage: jest.fn(),
|
||||
fetchPreviousPage: jest.fn(),
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
|
||||
// Sample data for testing
|
||||
const mockApiResponse = {
|
||||
payload: {
|
||||
normalizedValues: ['frontend', 'backend', 'database'],
|
||||
complete: true,
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
// Mock scrollIntoView since it's not available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
describe('DynamicVariableSelection Component', () => {
|
||||
const mockOnValueUpdate = jest.fn();
|
||||
|
||||
const mockDynamicVariableData: IDashboardVariable = {
|
||||
id: 'var1',
|
||||
name: 'service',
|
||||
type: 'DYNAMIC',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
selectedValue: 'frontend',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
allSelected: false,
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
};
|
||||
|
||||
const mockMultiSelectDynamicVariableData: IDashboardVariable = {
|
||||
...mockDynamicVariableData,
|
||||
id: 'var2',
|
||||
name: 'services',
|
||||
multiSelect: true,
|
||||
selectedValue: ['frontend', 'backend'],
|
||||
showALLOption: true,
|
||||
};
|
||||
|
||||
const mockExistingVariables: Record<string, IDashboardVariable> = {
|
||||
var1: mockDynamicVariableData,
|
||||
var2: mockMultiSelectDynamicVariableData,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockOnValueUpdate.mockClear();
|
||||
|
||||
// Mock useSelector
|
||||
const useSelectorSpy = jest.spyOn(ReactRedux, 'useSelector');
|
||||
useSelectorSpy.mockReturnValue({
|
||||
minTime: '2023-01-01T00:00:00Z',
|
||||
maxTime: '2023-01-02T00:00:00Z',
|
||||
});
|
||||
|
||||
// Mock useQuery with success state
|
||||
const useQuerySpy = jest.spyOn(ReactQuery, 'useQuery');
|
||||
useQuerySpy.mockReturnValue({
|
||||
...mockQueryResult,
|
||||
data: mockApiResponse,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with single select variable correctly', () => {
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify component renders correctly
|
||||
expect(
|
||||
screen.getByText(`$${mockDynamicVariableData.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Verify the selected value is displayed
|
||||
const selectedItem = screen.getByRole('combobox');
|
||||
expect(selectedItem).toBeInTheDocument();
|
||||
|
||||
// CustomSelect doesn't use the 'mode' attribute for single select
|
||||
expect(selectedItem).not.toHaveAttribute('mode');
|
||||
});
|
||||
|
||||
it('renders with multi select variable correctly', () => {
|
||||
// First set up allSelected to true to properly test the ALL display
|
||||
const multiSelectWithAllSelected = {
|
||||
...mockMultiSelectDynamicVariableData,
|
||||
allSelected: true,
|
||||
};
|
||||
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={multiSelectWithAllSelected}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify variable name is rendered
|
||||
expect(
|
||||
screen.getByText(`$${multiSelectWithAllSelected.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// In ALL selected mode, there should be an "ALL" text element
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state correctly', () => {
|
||||
// Mock loading state
|
||||
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
|
||||
...mockQueryResult,
|
||||
data: null,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
isSuccess: false,
|
||||
status: 'loading',
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify component renders in loading state
|
||||
expect(
|
||||
screen.getByText(`$${mockDynamicVariableData.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Open dropdown to see loading text
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// The loading text should appear in the dropdown
|
||||
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles error state correctly', () => {
|
||||
const errorMessage = 'Failed to fetch data';
|
||||
|
||||
// Mock error state
|
||||
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
|
||||
...mockQueryResult,
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isSuccess: false,
|
||||
isError: true,
|
||||
status: 'error',
|
||||
error: { message: errorMessage },
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the component renders
|
||||
expect(
|
||||
screen.getByText(`$${mockDynamicVariableData.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// For error states, we should check that error handling is in place
|
||||
// Without opening the dropdown as the error message might be handled differently
|
||||
expect(ReactQuery.useQuery).toHaveBeenCalled();
|
||||
// We don't need to check refetch as it might be called during component initialization
|
||||
});
|
||||
|
||||
it('makes API call to fetch variable values', () => {
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the useQuery hook was called with expected parameters
|
||||
expect(ReactQuery.useQuery).toHaveBeenCalledWith(
|
||||
[
|
||||
'DASHBOARD_BY_ID',
|
||||
mockDynamicVariableData.name,
|
||||
'service:"frontend"|services:["frontend","backend"]', // The actual dynamicVariablesKey
|
||||
'2023-01-01T00:00:00Z', // minTime from useSelector mock
|
||||
'2023-01-02T00:00:00Z', // maxTime from useSelector mock
|
||||
],
|
||||
expect.objectContaining({
|
||||
enabled: true, // Type is 'DYNAMIC'
|
||||
queryFn: expect.any(Function),
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('has the correct selected value', () => {
|
||||
// Use a different variable configuration to test different behavior
|
||||
const customVariable = {
|
||||
...mockDynamicVariableData,
|
||||
id: 'custom1',
|
||||
name: 'customService',
|
||||
selectedValue: 'backend',
|
||||
};
|
||||
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={customVariable}
|
||||
existingVariables={{ ...mockExistingVariables, custom1: customVariable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the component correctly displays the selected value
|
||||
expect(screen.getByText(`$${customVariable.name}`)).toBeInTheDocument();
|
||||
|
||||
// Find the selection item in the component using data-testid
|
||||
const selectElement = screen.getByTestId('variable-select');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
|
||||
// Check that the selected value is displayed in the select element
|
||||
expect(selectElement).toHaveTextContent('backend');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCallback } from 'react';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { convertVariablesToDbFormat } from './util';
|
||||
|
||||
// Note: This logic completely mimics the logic in DashboardVariableSelection.tsx
|
||||
// but is separated to avoid unnecessary logic addition.
|
||||
interface UseDashboardVariableUpdateReturn {
|
||||
onValueUpdate: (
|
||||
name: string,
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
createVariable: (
|
||||
name: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
type?: IDashboardVariable['type'],
|
||||
description?: string,
|
||||
source?: 'logs' | 'traces' | 'metrics' | 'all sources',
|
||||
widgetId?: string,
|
||||
) => void;
|
||||
updateVariables: (
|
||||
updatedVariablesData: Dashboard['data']['variables'],
|
||||
currentRequestedId?: string,
|
||||
widgetIds?: string[],
|
||||
applyToAll?: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn => {
|
||||
const {
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboard();
|
||||
|
||||
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
|
||||
const updateMutation = useUpdateDashboard();
|
||||
|
||||
const onValueUpdate = useCallback(
|
||||
(
|
||||
name: string,
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
): void => {
|
||||
if (id) {
|
||||
// Performance optimization: For dynamic variables with allSelected=true, we don't store
|
||||
// individual values in localStorage since we can always derive them from available options.
|
||||
// This makes localStorage much lighter and more efficient.
|
||||
// currently all the variables are dynamic
|
||||
const isDynamic = true;
|
||||
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
|
||||
|
||||
if (selectedDashboard) {
|
||||
setSelectedDashboard((prev) => {
|
||||
if (prev) {
|
||||
const oldVariables = prev?.data.variables;
|
||||
// this is added to handle case where we have two different
|
||||
// schemas for variable response
|
||||
if (oldVariables?.[id]) {
|
||||
oldVariables[id] = {
|
||||
...oldVariables[id],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
if (oldVariables?.[name]) {
|
||||
oldVariables[name] = {
|
||||
...oldVariables[name],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
data: {
|
||||
...prev?.data,
|
||||
variables: {
|
||||
...oldVariables,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
],
|
||||
);
|
||||
|
||||
const updateVariables = useCallback(
|
||||
(
|
||||
updatedVariablesData: Dashboard['data']['variables'],
|
||||
currentRequestedId?: string,
|
||||
widgetIds?: string[],
|
||||
applyToAll?: boolean,
|
||||
): void => {
|
||||
if (!selectedDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDashboard =
|
||||
(currentRequestedId &&
|
||||
addDynamicVariableToPanels(
|
||||
selectedDashboard,
|
||||
updatedVariablesData[currentRequestedId || ''],
|
||||
widgetIds,
|
||||
applyToAll,
|
||||
)) ||
|
||||
selectedDashboard;
|
||||
|
||||
updateMutation.mutateAsync(
|
||||
{
|
||||
id: selectedDashboard.id,
|
||||
|
||||
data: {
|
||||
...newDashboard.data,
|
||||
variables: updatedVariablesData,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setSelectedDashboard(updatedDashboard.data);
|
||||
// notifications.success({
|
||||
// message: t('variable_updated_successfully'),
|
||||
// });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
selectedDashboard,
|
||||
addDynamicVariableToPanels,
|
||||
updateMutation,
|
||||
setSelectedDashboard,
|
||||
],
|
||||
);
|
||||
|
||||
const createVariable = useCallback(
|
||||
(
|
||||
name: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
type: IDashboardVariable['type'] = 'DYNAMIC',
|
||||
description = '',
|
||||
source: 'logs' | 'traces' | 'metrics' | 'all sources' = 'all sources',
|
||||
// widgetId?: string,
|
||||
): void => {
|
||||
if (!selectedDashboard) {
|
||||
console.warn('No dashboard selected for variable creation');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('createVariable', { name, value, type, description, source });
|
||||
|
||||
// Get current dashboard variables
|
||||
const currentVariables = selectedDashboard.data.variables || {};
|
||||
|
||||
// Create tableRowData like Dashboard Settings does
|
||||
const tableRowData = [];
|
||||
const variableOrderArr = [];
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const [key, value] of Object.entries(currentVariables)) {
|
||||
const { order, id } = value;
|
||||
|
||||
tableRowData.push({
|
||||
key,
|
||||
name: key,
|
||||
...currentVariables[key],
|
||||
id,
|
||||
});
|
||||
|
||||
if (order) {
|
||||
variableOrderArr.push(order);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by order
|
||||
tableRowData.sort((a, b) => a.order - b.order);
|
||||
variableOrderArr.sort((a, b) => a - b);
|
||||
|
||||
// Create new variable
|
||||
const nextOrder =
|
||||
variableOrderArr.length > 0 ? Math.max(...variableOrderArr) + 1 : 0;
|
||||
const newVariable: any = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
type: 'DYNAMIC' as const,
|
||||
description,
|
||||
order: nextOrder,
|
||||
selectedValue: value,
|
||||
allSelected: false,
|
||||
haveCustomValuesSelected: false,
|
||||
sort: 'ASC' as const,
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
dynamicVariablesAttribute: name,
|
||||
dynamicVariablesSource: source,
|
||||
dynamicVariablesWidgetIds: [],
|
||||
queryValue: '',
|
||||
};
|
||||
|
||||
// Add to tableRowData
|
||||
tableRowData.push({
|
||||
key: newVariable.id,
|
||||
...newVariable,
|
||||
id: newVariable.id,
|
||||
});
|
||||
|
||||
// Convert to dashboard format and update
|
||||
const updatedVariables = convertVariablesToDbFormat(tableRowData);
|
||||
updateVariables(updatedVariables, newVariable.id, [], false);
|
||||
},
|
||||
[selectedDashboard, updateVariables],
|
||||
);
|
||||
|
||||
return {
|
||||
onValueUpdate,
|
||||
createVariable,
|
||||
updateVariables,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDashboardVariableUpdate;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OptionData } from 'components/NewSelect/types';
|
||||
import { isEmpty, isNull } from 'lodash-es';
|
||||
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
@@ -294,3 +295,74 @@ export const checkAPIInvocation = (
|
||||
variablesToGetUpdated[0] === variableData.name
|
||||
);
|
||||
};
|
||||
|
||||
export const getOptionsForDynamicVariable = (
|
||||
normalizedValues: (string | number | boolean)[],
|
||||
relatedValues: string[],
|
||||
): OptionData[] => {
|
||||
const options: OptionData[] = [];
|
||||
|
||||
if (relatedValues.length > 0) {
|
||||
// Add Related Values group
|
||||
options.push({
|
||||
label: 'Related Values',
|
||||
value: 'relatedValues',
|
||||
options: relatedValues.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Add All Values group (complete union - shows everything)
|
||||
options.push({
|
||||
label: 'All Values',
|
||||
value: 'allValues',
|
||||
options: normalizedValues.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
})),
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
return normalizedValues.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
}));
|
||||
};
|
||||
|
||||
export const uniqueOptions = (options: OptionData[]): OptionData[] => {
|
||||
const uniqueOptions: OptionData[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
options.forEach((option) => {
|
||||
const value = option.value || '';
|
||||
if (seenValues.has(value)) {
|
||||
return;
|
||||
}
|
||||
seenValues.add(value);
|
||||
uniqueOptions.push(option);
|
||||
});
|
||||
|
||||
return uniqueOptions;
|
||||
};
|
||||
|
||||
export const uniqueValues = (values: string[] | string): string[] | string => {
|
||||
if (Array.isArray(values)) {
|
||||
const uniqueValues: string[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
values.forEach((value) => {
|
||||
if (seenValues.has(value)) {
|
||||
return;
|
||||
}
|
||||
seenValues.add(value);
|
||||
uniqueValues.push(value);
|
||||
});
|
||||
|
||||
return uniqueValues;
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import GridGraphLayout from 'container/GridCardLayout';
|
||||
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import { GridComponentSliderContainer } from './styles';
|
||||
@@ -11,7 +12,7 @@ function GridGraphs(props: GridGraphsProps): JSX.Element {
|
||||
const { handle } = props;
|
||||
return (
|
||||
<GridComponentSliderContainer>
|
||||
<GridGraphLayout handle={handle} />
|
||||
<GridGraphLayout handle={handle} enableDrillDown={isDrilldownEnabled()} />
|
||||
</GridComponentSliderContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import * as ReactRedux from 'react-redux';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DynamicVariableSelection from '../DashboardVariablesSelection/DynamicVariableSelection';
|
||||
|
||||
// Mock the getFieldValues API
|
||||
jest.mock('api/dynamicVariables/getFieldValues', () => ({
|
||||
getFieldValues: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Dynamic Variable Default Behavior', () => {
|
||||
const mockOnValueUpdate = jest.fn();
|
||||
const mockApiResponse = {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'success',
|
||||
payload: {
|
||||
values: {
|
||||
stringValues: ['frontend', 'backend', 'database', 'cache'],
|
||||
},
|
||||
normalizedValues: ['frontend', 'backend', 'database', 'cache'],
|
||||
complete: true,
|
||||
},
|
||||
};
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const renderWithQueryClient = (component: React.ReactElement): RenderResult =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock scrollIntoView for JSDOM environment
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create a new QueryClient for each test
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock getFieldValues API to return our test data
|
||||
(getFieldValues as jest.Mock).mockResolvedValue(mockApiResponse);
|
||||
|
||||
jest.spyOn(ReactRedux, 'useSelector').mockReturnValue({
|
||||
minTime: '2023-01-01T00:00:00Z',
|
||||
maxTime: '2023-01-02T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single Select Default Values', () => {
|
||||
it('should use default value when no previous selection exists', () => {
|
||||
const variableData: IDashboardVariable = {
|
||||
id: 'var1',
|
||||
name: 'service',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: false,
|
||||
defaultValue: 'backend' as any,
|
||||
selectedValue: undefined,
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
showALLOption: false,
|
||||
allSelected: false,
|
||||
};
|
||||
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should call onValueUpdate with default value
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'service',
|
||||
'var1',
|
||||
'backend',
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve previous selection over default value', () => {
|
||||
const variableData: IDashboardVariable = {
|
||||
id: 'var1',
|
||||
name: 'service',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: false,
|
||||
defaultValue: 'backend' as any,
|
||||
selectedValue: 'frontend',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
showALLOption: false,
|
||||
allSelected: false,
|
||||
};
|
||||
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should NOT call onValueUpdate since previous selection exists
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalledWith();
|
||||
|
||||
// Check if the previous selection 'frontend' is displayed in the UI
|
||||
// For single select, the value should be visible in the select component
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
|
||||
// Open dropdown to check if 'frontend' is selected
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check if 'frontend' option is present and selected in dropdown
|
||||
const frontendOption = screen.getByRole('option', { name: 'frontend' });
|
||||
expect(frontendOption).toHaveClass('selected');
|
||||
|
||||
// Verify that 'backend' (default value) is NOT present in the dropdown
|
||||
// since previous selection 'frontend' takes priority
|
||||
expect(screen.queryByText('backend')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use first value when no default and no previous selection', async () => {
|
||||
const variableData: IDashboardVariable = {
|
||||
id: 'var1',
|
||||
name: 'service',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: false,
|
||||
defaultValue: undefined,
|
||||
selectedValue: undefined,
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
showALLOption: false,
|
||||
allSelected: false,
|
||||
};
|
||||
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Component should render without errors
|
||||
expect(screen.getByText('$service')).toBeInTheDocument();
|
||||
|
||||
// Check if the dropdown is present
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
|
||||
// Wait for API call to complete and data to be loaded
|
||||
await waitFor(() => {
|
||||
expect(getFieldValues).toHaveBeenCalledWith(
|
||||
'traces',
|
||||
'service.name',
|
||||
'',
|
||||
'2023-01-01T00:00:00Z',
|
||||
'2023-01-02T00:00:00Z',
|
||||
);
|
||||
});
|
||||
|
||||
// Open dropdown to check available options
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to populate with API data
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'frontend' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('option', { name: 'backend' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'database' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'cache' })).toBeInTheDocument();
|
||||
|
||||
// Check if the first value 'frontend' is selected (active)
|
||||
const frontendOption = screen.getByRole('option', { name: 'frontend' });
|
||||
expect(frontendOption).toHaveClass('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi Select Default Values - ALL Enabled', () => {
|
||||
it('should use default value when no previous selection exists', () => {
|
||||
const variableData: IDashboardVariable = {
|
||||
id: 'var1',
|
||||
name: 'services',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
defaultValue: ['backend', 'database'] as any,
|
||||
selectedValue: undefined,
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
allSelected: false,
|
||||
};
|
||||
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'services',
|
||||
'var1',
|
||||
['backend', 'database'],
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve previous selection over default', async () => {
|
||||
const variableData: IDashboardVariable = {
|
||||
id: 'var1',
|
||||
name: 'services',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
defaultValue: ['backend'] as any,
|
||||
selectedValue: ['frontend', 'cache'],
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
allSelected: false,
|
||||
};
|
||||
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should NOT call onValueUpdate since previous selection exists
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalledWith();
|
||||
|
||||
// The component shows "ALL" because the previous selection ['frontend', 'cache']
|
||||
// is treated as all available values (the component considers this equivalent to all selected)
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
|
||||
// Verify that the ALL selection wrapper is present
|
||||
const allSelectedWrapper = screen
|
||||
.getByText('ALL')
|
||||
.closest('.custom-multiselect-wrapper');
|
||||
expect(allSelectedWrapper).toHaveClass('all-selected');
|
||||
|
||||
// Verify that individual values are not displayed when ALL is shown
|
||||
expect(screen.queryByText('frontend')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('cache')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('backend')).not.toBeInTheDocument();
|
||||
|
||||
// Open the dropdown to see which specific options are selected in the dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for API data to be loaded and dropdown to populate
|
||||
await waitFor(
|
||||
() => {
|
||||
// Check if any options are visible in dropdown - there might be checkboxes or selectable items
|
||||
const dropdownElements = screen.queryAllByRole('option');
|
||||
|
||||
// If options are available, verify the selection states
|
||||
if (dropdownElements.length > 0) {
|
||||
// Frontend and cache should be selected (checked) in dropdown
|
||||
// Backend (default) should NOT be selected since previous selection takes priority
|
||||
const frontendOption = screen.queryByRole('option', { name: 'frontend' });
|
||||
const cacheOption = screen.queryByRole('option', { name: 'cache' });
|
||||
const backendOption = screen.queryByRole('option', { name: 'backend' });
|
||||
|
||||
if (frontendOption) expect(frontendOption).toHaveClass('selected');
|
||||
if (cacheOption) expect(cacheOption).toHaveClass('selected');
|
||||
if (backendOption) expect(backendOption).not.toHaveClass('selected');
|
||||
}
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to ALL when no default and no previous selection', () => {
|
||||
const variableData: IDashboardVariable = {
|
||||
id: 'var21',
|
||||
name: 'services',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
defaultValue: undefined,
|
||||
selectedValue: undefined,
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
allSelected: false,
|
||||
};
|
||||
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open dropdown to check if ALL option is selected
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check if ALL option is present in dropdown
|
||||
const allOption = screen.getByText('ALL');
|
||||
expect(allOption).toBeInTheDocument();
|
||||
|
||||
// Verify that the ALL option is available for selection
|
||||
const allOptionContainer = allOption.closest(
|
||||
'.option-item, .ant-select-item-option',
|
||||
);
|
||||
expect(allOptionContainer).toBeInTheDocument();
|
||||
|
||||
// Check if the checkbox exists (it should be unchecked initially)
|
||||
const checkbox = allOptionContainer?.querySelector(
|
||||
'input[type="checkbox"]',
|
||||
) as HTMLInputElement;
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
|
||||
// Should call onValueUpdate with all values (ALL selection)
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'services',
|
||||
'var21',
|
||||
[], // Empty array when allSelected is true
|
||||
true, // allSelected = true
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi Select Default Values - ALL Disabled', () => {
|
||||
it('should use default value over first value when provided', () => {
|
||||
const variableData: IDashboardVariable = {
|
||||
id: 'var1',
|
||||
name: 'services',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showALLOption: false,
|
||||
defaultValue: ['database', 'cache'] as any,
|
||||
selectedValue: undefined,
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
allSelected: false,
|
||||
};
|
||||
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should call onValueUpdate with default values
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'services',
|
||||
'var1',
|
||||
['database', 'cache'],
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALL Option Special Value', () => {
|
||||
it('should display ALL correctly when all values are selected', async () => {
|
||||
const variableData: IDashboardVariable = {
|
||||
id: 'var1',
|
||||
name: 'services',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
allSelected: true,
|
||||
selectedValue: ['frontend', 'backend', 'database', 'cache'],
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
};
|
||||
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Component should render without errors
|
||||
expect(screen.getByText('$services')).toBeInTheDocument();
|
||||
|
||||
// Check if ALL is displayed in the UI (in the main selection area)
|
||||
const allTextElement = screen.getByText('ALL');
|
||||
expect(allTextElement).toBeInTheDocument();
|
||||
|
||||
// Verify that the ALL selection wrapper is present and has correct class
|
||||
const allSelectedWrapper = allTextElement.closest(
|
||||
'.custom-multiselect-wrapper',
|
||||
);
|
||||
expect(allSelectedWrapper).toHaveClass('all-selected');
|
||||
|
||||
// Open dropdown to check if ALL option is selected/active
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for API data to be loaded and dropdown to populate
|
||||
await waitFor(() => {
|
||||
expect(getFieldValues).toHaveBeenCalledWith(
|
||||
'traces',
|
||||
'service.name',
|
||||
'',
|
||||
'2023-01-01T00:00:00Z',
|
||||
'2023-01-02T00:00:00Z',
|
||||
);
|
||||
});
|
||||
|
||||
// Check if ALL option is present in dropdown and selected
|
||||
// Use getAllByText to get all ALL elements and find the one in dropdown
|
||||
const allElements = screen.getAllByText('ALL');
|
||||
expect(allElements.length).toBeGreaterThan(1); // Should have ALL in UI and dropdown
|
||||
|
||||
// Find the ALL option in the dropdown (should have the 'all-option' class)
|
||||
const dropdownAllOption = screen.getByRole('option', { name: 'ALL' });
|
||||
expect(dropdownAllOption).toBeInTheDocument();
|
||||
expect(dropdownAllOption).toHaveClass('all-option');
|
||||
expect(dropdownAllOption).toHaveClass('selected');
|
||||
|
||||
// Check if the checkbox for ALL option is checked
|
||||
const checkbox = dropdownAllOption.querySelector(
|
||||
'input[type="checkbox"]',
|
||||
) as HTMLInputElement;
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
// Import dependency building functions
|
||||
import {
|
||||
buildDependencies,
|
||||
buildDependencyGraph,
|
||||
} from '../DashboardVariablesSelection/util';
|
||||
|
||||
// Mock scrollIntoView since it's not available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
function createMockStore(globalTime?: any): any {
|
||||
return configureStore([])(() => ({
|
||||
globalTime: globalTime || {
|
||||
minTime: '2023-01-01T00:00:00Z',
|
||||
maxTime: '2023-01-02T00:00:00Z',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Mock the dashboard provider
|
||||
const mockDashboard = {
|
||||
data: {
|
||||
variables: {
|
||||
env: {
|
||||
id: 'env',
|
||||
name: 'env',
|
||||
type: 'DYNAMIC',
|
||||
selectedValue: 'production',
|
||||
order: 1,
|
||||
dynamicVariablesAttribute: 'environment',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
},
|
||||
service: {
|
||||
id: 'service',
|
||||
name: 'service',
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT DISTINCT service_name WHERE env = $env',
|
||||
selectedValue: 'api-service',
|
||||
order: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the dashboard provider with stable functions to prevent infinite loops
|
||||
const mockSetSelectedDashboard = jest.fn();
|
||||
const mockUpdateLocalStorageDashboardVariables = jest.fn();
|
||||
const mockSetVariablesToGetUpdated = jest.fn();
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard', () => ({
|
||||
useDashboard: (): any => ({
|
||||
selectedDashboard: mockDashboard,
|
||||
setSelectedDashboard: mockSetSelectedDashboard,
|
||||
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
|
||||
variablesToGetUpdated: ['env'], // Stable initial value
|
||||
setVariablesToGetUpdated: mockSetVariablesToGetUpdated,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface TestWrapperProps {
|
||||
store: any;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function TestWrapper({ store, children }: TestWrapperProps): JSX.Element {
|
||||
return (
|
||||
<Provider store={store || createMockStore()}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
TestWrapper.displayName = 'TestWrapper';
|
||||
|
||||
describe('Dynamic Variables Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Variable Dependencies and Updates', () => {
|
||||
it('should build dependency graph correctly for variables', async () => {
|
||||
// Convert mock dashboard variables to array format expected by the functions
|
||||
const { variables } = mockDashboard.data as any;
|
||||
const variablesArray = Object.values(variables) as any[];
|
||||
|
||||
// Test the actual dependency building logic
|
||||
const dependencies = buildDependencies(variablesArray);
|
||||
const dependencyData = buildDependencyGraph(dependencies);
|
||||
|
||||
// Verify the dependency graph structure
|
||||
expect(dependencies).toBeDefined();
|
||||
expect(dependencyData).toBeDefined();
|
||||
expect(dependencyData.order).toBeDefined();
|
||||
expect(dependencyData.graph).toBeDefined();
|
||||
expect(dependencyData.hasCycle).toBeDefined();
|
||||
|
||||
// Verify that service depends on env (based on queryValue containing $env)
|
||||
// The dependencies object shows which variables depend on each variable
|
||||
// So dependencies.env should contain 'service' because service references $env
|
||||
expect(dependencies.env).toContain('service');
|
||||
|
||||
// Verify the topological order (env should come before service)
|
||||
expect(dependencyData.order).toContain('env');
|
||||
expect(dependencyData.order).toContain('service');
|
||||
expect(dependencyData.order.indexOf('env')).toBeLessThan(
|
||||
dependencyData.order.indexOf('service'),
|
||||
);
|
||||
|
||||
// Verify no cycles in this simple case
|
||||
expect(dependencyData.hasCycle).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle circular dependency detection', () => {
|
||||
// Create variables with circular dependency
|
||||
const circularVariables = [
|
||||
{
|
||||
id: 'var1',
|
||||
name: 'var1',
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT * WHERE field = $var2',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'var2',
|
||||
name: 'var2',
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT * WHERE field = $var1',
|
||||
order: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// Test the actual circular dependency detection logic
|
||||
const dependencies = buildDependencies(circularVariables as any);
|
||||
const dependencyData = buildDependencyGraph(dependencies);
|
||||
|
||||
// Verify that circular dependency is detected
|
||||
expect(dependencyData.hasCycle).toBe(true);
|
||||
expect(dependencyData.cycleNodes).toBeDefined();
|
||||
expect(dependencyData.cycleNodes).toContain('var1');
|
||||
expect(dependencyData.cycleNodes).toContain('var2');
|
||||
|
||||
// Verify the dependency structure
|
||||
expect(dependencies.var1).toContain('var2');
|
||||
expect(dependencies.var2).toContain('var1');
|
||||
|
||||
// Verify that topological order is incomplete due to cycle
|
||||
expect(dependencyData.order.length).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
Dashboard,
|
||||
IDashboardVariable,
|
||||
Widgets,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
|
||||
import { useAddDynamicVariableToPanels } from '../../../hooks/dashboard/useAddDynamicVariableToPanels';
|
||||
import { WidgetSelector } from '../DashboardSettings/Variables/VariableItem/WidgetSelector';
|
||||
|
||||
// Mock scrollIntoView since it's not available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
// Constants to avoid duplication
|
||||
const CPU_USAGE_TEXT = 'CPU Usage';
|
||||
const MEMORY_USAGE_TEXT = 'Memory Usage';
|
||||
const ROW_WIDGET_TEXT = 'Row Widget';
|
||||
|
||||
// Helper function to create variable config
|
||||
const createVariableConfig = (
|
||||
name: string,
|
||||
attribute: string,
|
||||
): IDashboardVariable => ({
|
||||
id: `var_${name}`,
|
||||
name,
|
||||
type: 'DYNAMIC',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
dynamicVariablesAttribute: attribute,
|
||||
});
|
||||
|
||||
const mockDashboard = {
|
||||
data: {
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget1',
|
||||
title: CPU_USAGE_TEXT,
|
||||
panelTypes: 'GRAPH',
|
||||
},
|
||||
{
|
||||
id: 'widget2',
|
||||
title: MEMORY_USAGE_TEXT,
|
||||
panelTypes: 'TABLE',
|
||||
},
|
||||
{
|
||||
id: 'widget3',
|
||||
title: ROW_WIDGET_TEXT,
|
||||
panelTypes: 'ROW', // Should be filtered out
|
||||
},
|
||||
],
|
||||
layout: [{ i: 'widget1' }, { i: 'widget2' }, { i: 'widget3' }],
|
||||
},
|
||||
};
|
||||
// Mock dependencies
|
||||
jest.mock('providers/Dashboard/Dashboard', () => ({
|
||||
useDashboard: (): any => ({
|
||||
selectedDashboard: mockDashboard,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('constants/queryBuilder', () => ({
|
||||
PANEL_GROUP_TYPES: {
|
||||
ROW: 'ROW',
|
||||
},
|
||||
PANEL_TYPES: {
|
||||
TIME_SERIES: 'graph',
|
||||
VALUE: 'value',
|
||||
TABLE: 'table',
|
||||
LIST: 'list',
|
||||
TRACE: 'trace',
|
||||
BAR: 'bar',
|
||||
PIE: 'pie',
|
||||
HISTOGRAM: 'histogram',
|
||||
EMPTY_WIDGET: 'EMPTY_WIDGET',
|
||||
},
|
||||
initialQueriesMap: {
|
||||
metrics: {
|
||||
builder: {
|
||||
queryData: [{}],
|
||||
},
|
||||
},
|
||||
logs: {
|
||||
builder: {
|
||||
queryData: [{}],
|
||||
},
|
||||
},
|
||||
traces: {
|
||||
builder: {
|
||||
queryData: [{}],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('container/GridPanelSwitch/utils', () => ({
|
||||
generateGridTitle: (title: string): string => title || 'Untitled Panel',
|
||||
}));
|
||||
|
||||
describe('Panel Management Tests', () => {
|
||||
describe('WidgetSelector Component', () => {
|
||||
const mockSetSelectedWidgets = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should display panel titles using generateGridTitle', () => {
|
||||
render(
|
||||
<WidgetSelector
|
||||
selectedWidgets={[]}
|
||||
setSelectedWidgets={mockSetSelectedWidgets}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open the dropdown to see options
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Should show panel titles (excluding ROW widgets) in dropdown
|
||||
expect(screen.getByText(CPU_USAGE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(MEMORY_USAGE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(ROW_WIDGET_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter out row widgets and widgets without layout', () => {
|
||||
const modifiedDashboard = {
|
||||
...mockDashboard,
|
||||
data: {
|
||||
...mockDashboard.data,
|
||||
widgets: [
|
||||
...mockDashboard.data.widgets,
|
||||
{
|
||||
id: 'widget4',
|
||||
title: 'Orphaned Widget',
|
||||
panelTypes: 'GRAPH',
|
||||
}, // No layout entry
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Temporarily mock the dashboard
|
||||
jest.doMock('providers/Dashboard/Dashboard', () => ({
|
||||
useDashboard: (): any => ({
|
||||
selectedDashboard: modifiedDashboard,
|
||||
}),
|
||||
}));
|
||||
|
||||
render(
|
||||
<WidgetSelector
|
||||
selectedWidgets={[]}
|
||||
setSelectedWidgets={mockSetSelectedWidgets}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not show orphaned widget
|
||||
expect(screen.queryByText('Orphaned Widget')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show selected widgets correctly', () => {
|
||||
render(
|
||||
<WidgetSelector
|
||||
selectedWidgets={['widget1', 'widget2']}
|
||||
setSelectedWidgets={mockSetSelectedWidgets}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Component should show ALL text when all widgets are selected
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
|
||||
// Check if the dropdown shows selected state correctly
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Should show the selected panels in the dropdown
|
||||
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Usage')).toBeInTheDocument();
|
||||
|
||||
// Check if the specific options (CPU Usage, Memory Usage) are properly selected/checked
|
||||
const cpuOption = screen.getByText('CPU Usage');
|
||||
const memoryOption = screen.getByText('Memory Usage');
|
||||
|
||||
// Find the specific checkboxes for CPU Usage and Memory Usage
|
||||
// Navigate from the text to find the associated checkbox
|
||||
const cpuContainer = cpuOption.closest(
|
||||
'.ant-select-item-option, .option-item',
|
||||
);
|
||||
const memoryContainer = memoryOption.closest(
|
||||
'.ant-select-item-option, .option-item',
|
||||
);
|
||||
|
||||
const cpuCheckbox = cpuContainer?.querySelector(
|
||||
'input[type="checkbox"]',
|
||||
) as HTMLInputElement;
|
||||
const memoryCheckbox = memoryContainer?.querySelector(
|
||||
'input[type="checkbox"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
// Verify that the specific checkboxes for our selected widgets are checked
|
||||
expect(cpuCheckbox).toBeInTheDocument();
|
||||
expect(memoryCheckbox).toBeInTheDocument();
|
||||
expect(cpuCheckbox.checked).toBe(true);
|
||||
expect(memoryCheckbox.checked).toBe(true);
|
||||
|
||||
// Also verify the checkbox wrappers have the checked class
|
||||
const cpuCheckboxWrapper = cpuCheckbox?.closest('.ant-checkbox');
|
||||
const memoryCheckboxWrapper = memoryCheckbox?.closest('.ant-checkbox');
|
||||
|
||||
expect(cpuCheckboxWrapper).toHaveClass('ant-checkbox-checked');
|
||||
expect(memoryCheckboxWrapper).toHaveClass('ant-checkbox-checked');
|
||||
|
||||
// Additional verification: ensure these are the correct options by checking their labels
|
||||
expect(cpuOption).toBeInTheDocument();
|
||||
expect(memoryOption).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAddDynamicVariableToPanels Hook', () => {
|
||||
it('should add tag filters to specific selected panels', () => {
|
||||
const { result } = renderHook(() => useAddDynamicVariableToPanels());
|
||||
const addDynamicVariableToPanels = result.current;
|
||||
|
||||
const dashboard: Dashboard = {
|
||||
data: {
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget1',
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filter: { expression: '' },
|
||||
filters: { items: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const variableConfig = createVariableConfig('service', 'service.name');
|
||||
|
||||
const updatedDashboard = addDynamicVariableToPanels(
|
||||
dashboard,
|
||||
variableConfig,
|
||||
[],
|
||||
false,
|
||||
);
|
||||
|
||||
// Verify tag filter was added to the specific widget using new filter expression format
|
||||
const widget = updatedDashboard?.data?.widgets?.[0] as any;
|
||||
const queryData = widget?.query?.builder?.queryData?.[0];
|
||||
|
||||
// Check that filter expression contains the variable reference
|
||||
expect(queryData.filter.expression).toContain('service.name in $service');
|
||||
|
||||
// Check that filters array also contains the filter item
|
||||
const filters = queryData.filters.items;
|
||||
expect(filters).toContainEqual({
|
||||
id: expect.any(String),
|
||||
key: {
|
||||
id: expect.any(String),
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
type: '',
|
||||
},
|
||||
op: 'IN',
|
||||
value: '$service',
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply to all panels when applyToAll is true', () => {
|
||||
const { result } = renderHook(() => useAddDynamicVariableToPanels());
|
||||
const addDynamicVariableToPanels = result.current;
|
||||
|
||||
const dashboard: Dashboard = {
|
||||
data: {
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget1',
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filter: { expression: '' },
|
||||
filters: { items: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'widget2',
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filter: { expression: '' },
|
||||
filters: { items: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const variableConfig = createVariableConfig('service', 'service.name');
|
||||
|
||||
const updatedDashboard = addDynamicVariableToPanels(
|
||||
dashboard,
|
||||
variableConfig,
|
||||
[],
|
||||
true, // Apply to all
|
||||
);
|
||||
|
||||
// Both widgets should have the filter expression
|
||||
const widget1QueryData = (updatedDashboard?.data?.widgets?.[0] as Widgets)
|
||||
?.query?.builder?.queryData?.[0];
|
||||
const widget2QueryData = (updatedDashboard?.data?.widgets?.[1] as Widgets)
|
||||
?.query?.builder?.queryData?.[0];
|
||||
|
||||
// Check filter expressions
|
||||
expect(widget1QueryData?.filter?.expression).toContain(
|
||||
'service.name in $service',
|
||||
);
|
||||
expect(widget2QueryData?.filter?.expression).toContain(
|
||||
'service.name in $service',
|
||||
);
|
||||
|
||||
// Check filters arrays
|
||||
const widget1Filters = widget1QueryData?.filters?.items;
|
||||
const widget2Filters = widget2QueryData?.filters?.items;
|
||||
|
||||
expect(widget1Filters).toContainEqual({
|
||||
id: expect.any(String),
|
||||
key: {
|
||||
id: expect.any(String),
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
type: '',
|
||||
},
|
||||
op: 'IN',
|
||||
value: '$service',
|
||||
});
|
||||
expect(widget2Filters).toContainEqual({
|
||||
id: expect.any(String),
|
||||
key: {
|
||||
id: expect.any(String),
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
type: '',
|
||||
},
|
||||
op: 'IN',
|
||||
value: '$service',
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate tag filter format with variable name', () => {
|
||||
const { result } = renderHook(() => useAddDynamicVariableToPanels());
|
||||
const addDynamicVariableToPanels = result.current;
|
||||
|
||||
const dashboard: Dashboard = {
|
||||
data: {
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget1',
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [] },
|
||||
filter: { expression: '' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const variableConfig = {
|
||||
name: 'custom_service_var',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesWidgetIds: ['widget1'],
|
||||
};
|
||||
|
||||
const updatedDashboard = addDynamicVariableToPanels(
|
||||
dashboard,
|
||||
variableConfig as any,
|
||||
[],
|
||||
false,
|
||||
);
|
||||
|
||||
const filters = (updatedDashboard?.data?.widgets?.[0] as Widgets)?.query
|
||||
?.builder?.queryData?.[0]?.filters?.items;
|
||||
|
||||
const filterExpression = (updatedDashboard?.data?.widgets?.[0] as Widgets)
|
||||
?.query?.builder?.queryData?.[0]?.filter?.expression;
|
||||
|
||||
expect(filterExpression).toContain('service.name in $custom_service_var');
|
||||
expect(filters).toContainEqual(
|
||||
expect.objectContaining({
|
||||
value: '$custom_service_var', // Should use exact variable name
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty widget selection gracefully', () => {
|
||||
const { result } = renderHook(() => useAddDynamicVariableToPanels());
|
||||
const addDynamicVariableToPanels = result.current;
|
||||
|
||||
const dashboard: Dashboard = {
|
||||
data: { widgets: [] },
|
||||
} as any;
|
||||
|
||||
const variableConfig = {
|
||||
name: 'service',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesWidgetIds: [], // Empty selection
|
||||
};
|
||||
|
||||
const updatedDashboard = addDynamicVariableToPanels(
|
||||
dashboard,
|
||||
variableConfig as any,
|
||||
[],
|
||||
false,
|
||||
);
|
||||
|
||||
// Should return dashboard unchanged
|
||||
expect(updatedDashboard).toEqual(dashboard);
|
||||
});
|
||||
|
||||
it('should handle undefined dashboard gracefully', () => {
|
||||
const { result } = renderHook(() => useAddDynamicVariableToPanels());
|
||||
const addDynamicVariableToPanels = result.current;
|
||||
|
||||
const variableConfig = {
|
||||
name: 'service',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesWidgetIds: ['widget1'],
|
||||
};
|
||||
|
||||
const updatedDashboard = addDynamicVariableToPanels(
|
||||
undefined,
|
||||
variableConfig as any,
|
||||
[],
|
||||
false,
|
||||
);
|
||||
|
||||
// Should return undefined
|
||||
expect(updatedDashboard).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve existing filters when adding new variable filter', () => {
|
||||
const { result } = renderHook(() => useAddDynamicVariableToPanels());
|
||||
const addDynamicVariableToPanels = result.current;
|
||||
|
||||
const dashboard: Dashboard = {
|
||||
data: {
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget1',
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filter: {
|
||||
expression: "service.name IN ['adservice']",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const variableConfig = {
|
||||
name: 'host.name',
|
||||
dynamicVariablesAttribute: 'host.name',
|
||||
dynamicVariablesWidgetIds: ['widget1'],
|
||||
description: '',
|
||||
type: 'DYNAMIC',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textboxValue: '',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
sort: 'DISABLED',
|
||||
defaultValue: '3',
|
||||
modificationUUID: 'bd3a85ab-1393-4783-971e-c252adfd4920',
|
||||
id: '6b6f526a-6404-46fc-8d87-dc53e7ba8e1f',
|
||||
order: 0,
|
||||
dynamicVariablesSource: 'Traces',
|
||||
};
|
||||
|
||||
const updatedDashboard = addDynamicVariableToPanels(
|
||||
dashboard,
|
||||
variableConfig as any,
|
||||
[],
|
||||
false,
|
||||
);
|
||||
|
||||
const filterExpression = (updatedDashboard?.data?.widgets?.[0] as Widgets)
|
||||
?.query?.builder?.queryData?.[0]?.filter?.expression;
|
||||
|
||||
expect(filterExpression).toContain(
|
||||
"service.name IN ['adservice'] host.name in $host.name",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import VariableItem from '../DashboardSettings/Variables/VariableItem/VariableItem';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
|
||||
jest.mock('hooks/dynamicVariables/useGetFieldValues', () => ({
|
||||
useGetFieldValues: (): any => ({
|
||||
data: {
|
||||
payload: {
|
||||
normalizedValues: ['frontend', 'backend', 'database'],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
jest.mock('components/Editor', () => {
|
||||
function MockEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<textarea
|
||||
data-testid="sql-editor"
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
MockEditor.displayName = 'MockEditor';
|
||||
return MockEditor;
|
||||
});
|
||||
|
||||
const mockStore = configureStore([])(() => ({
|
||||
globalTime: {
|
||||
minTime: '2023-01-01T00:00:00Z',
|
||||
maxTime: '2023-01-02T00:00:00Z',
|
||||
},
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<Provider store={mockStore}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
TestWrapper.displayName = 'TestWrapper';
|
||||
|
||||
describe('VariableItem Component - Creation Flow', () => {
|
||||
const mockOnSave = jest.fn();
|
||||
const mockOnCancel = jest.fn();
|
||||
const mockValidateName = jest.fn();
|
||||
const mockValidateAttributeKey = jest.fn();
|
||||
// Constants to avoid string duplication
|
||||
const VARIABLE_NAME_PLACEHOLDER = 'Unique name of the variable';
|
||||
const VARIABLE_DESCRIPTION_PLACEHOLDER =
|
||||
'Enter a description for the variable';
|
||||
const SAVE_BUTTON_TEXT = 'Save Variable';
|
||||
const DISCARD_BUTTON_TEXT = 'Discard';
|
||||
|
||||
const defaultProps = {
|
||||
variableData: {} as IDashboardVariable,
|
||||
existingVariables: {},
|
||||
onCancel: mockOnCancel,
|
||||
onSave: mockOnSave,
|
||||
validateName: mockValidateName,
|
||||
mode: 'ADD' as const,
|
||||
validateAttributeKey: mockValidateAttributeKey,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockValidateName.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('Dynamic Variable Creation', () => {
|
||||
it('should switch between variable types correctly', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<VariableItem {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
// Test switching to different variable types
|
||||
const textboxButton = screen.getByText('Textbox');
|
||||
fireEvent.click(textboxButton);
|
||||
// Check if the button has the selected class or is in selected state
|
||||
expect(textboxButton.closest('button')).toHaveClass('selected');
|
||||
|
||||
const customButton = screen.getByText('Custom');
|
||||
fireEvent.click(customButton);
|
||||
expect(customButton.closest('button')).toHaveClass('selected');
|
||||
|
||||
const queryButton = screen.getByText('Query');
|
||||
fireEvent.click(queryButton);
|
||||
expect(queryButton.closest('button')).toHaveClass('selected');
|
||||
|
||||
// Verify SQL editor appears for QUERY type
|
||||
expect(screen.getByTestId('sql-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should validate variable name and show errors', () => {
|
||||
mockValidateName.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<VariableItem {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
|
||||
fireEvent.change(nameInput, { target: { value: 'duplicate_name' } });
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText('Variable name already exists')).toBeInTheDocument();
|
||||
|
||||
// Save button should be disabled
|
||||
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
|
||||
expect(saveButton.closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should detect and prevent cyclic dependencies', async () => {
|
||||
const existingVariables = {
|
||||
var1: {
|
||||
id: 'var1',
|
||||
name: 'var1',
|
||||
queryValue: 'SELECT * WHERE field = $var2',
|
||||
type: 'QUERY',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
} as IDashboardVariable,
|
||||
var2: {
|
||||
id: 'var2',
|
||||
name: 'var2',
|
||||
queryValue: 'SELECT * WHERE field = $var1',
|
||||
type: 'QUERY',
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
} as IDashboardVariable,
|
||||
};
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<VariableItem {...defaultProps} existingVariables={existingVariables} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
// Fill in name and create a variable that would create cycle
|
||||
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
|
||||
fireEvent.change(nameInput, { target: { value: 'var3' } });
|
||||
|
||||
// Switch to QUERY type
|
||||
const queryButton = screen.getByText('Query');
|
||||
fireEvent.click(queryButton);
|
||||
|
||||
// Add query that creates dependency
|
||||
const sqlEditor = screen.getByTestId('sql-editor');
|
||||
fireEvent.change(sqlEditor, {
|
||||
target: { value: 'SELECT * WHERE field = $var1' },
|
||||
});
|
||||
|
||||
// Try to save
|
||||
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Should show cycle detection error
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Circular dependency detected/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cancel button functionality', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<VariableItem {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
// Fill in some data
|
||||
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
|
||||
fireEvent.change(nameInput, { target: { value: 'test_variable' } });
|
||||
|
||||
// Click cancel
|
||||
const cancelButton = screen.getByText(DISCARD_BUTTON_TEXT);
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Should call onCancel
|
||||
expect(mockOnCancel).toHaveBeenCalledWith(expect.any(Object));
|
||||
});
|
||||
|
||||
it('should persist form fields when switching between variable types', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<VariableItem {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
// Fill in name and description
|
||||
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
|
||||
const descriptionInput = screen.getByPlaceholderText(
|
||||
VARIABLE_DESCRIPTION_PLACEHOLDER,
|
||||
);
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'persistent_var' } });
|
||||
fireEvent.change(descriptionInput, {
|
||||
target: { value: 'Persistent description' },
|
||||
});
|
||||
|
||||
// Switch to TEXTBOX type
|
||||
const textboxButton = screen.getByText('Textbox');
|
||||
fireEvent.click(textboxButton);
|
||||
|
||||
// Switch back to DYNAMIC
|
||||
const dynamicButton = screen.getByText('Dynamic');
|
||||
fireEvent.click(dynamicButton);
|
||||
|
||||
// Name and description should be preserved
|
||||
expect(nameInput).toHaveValue('persistent_var');
|
||||
expect(descriptionInput).toHaveValue('Persistent description');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,3 +14,5 @@ export function variablePropsToPayloadVariables(
|
||||
|
||||
return payloadVariables;
|
||||
}
|
||||
|
||||
export const ALL_SELECT_VALUE = '__ALL__';
|
||||
|
||||
@@ -16,6 +16,7 @@ function WidgetGraphContainer({
|
||||
setRequestData,
|
||||
selectedWidget,
|
||||
isLoadingPanelData,
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphContainerProps): JSX.Element {
|
||||
if (queryResponse.data && selectedGraph === PANEL_TYPES.BAR) {
|
||||
const sortedSeriesData = getSortedSeriesData(
|
||||
@@ -86,6 +87,7 @@ function WidgetGraphContainer({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
selectedGraph={selectedGraph}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ function WidgetGraph({
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
selectedGraph,
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
@@ -188,6 +189,7 @@ function WidgetGraph({
|
||||
onClickHandler={graphClickHandler}
|
||||
graphVisibility={graphVisibility}
|
||||
setGraphVisibility={setGraphVisibility}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -201,6 +203,11 @@ interface WidgetGraphProps {
|
||||
>;
|
||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
selectedGraph: PANEL_TYPES;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export default WidgetGraph;
|
||||
|
||||
WidgetGraph.defaultProps = {
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ function WidgetGraph({
|
||||
setRequestData,
|
||||
selectedWidget,
|
||||
isLoadingPanelData,
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphContainerProps): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -57,6 +58,7 @@ function WidgetGraph({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
selectedWidget={selectedWidget}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ function LeftContainer({
|
||||
setRequestData,
|
||||
isLoadingPanelData,
|
||||
setQueryResponse,
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
// const { selectedDashboard } = useDashboard();
|
||||
@@ -64,6 +65,7 @@ function LeftContainer({
|
||||
setRequestData={setRequestData}
|
||||
selectedWidget={selectedWidget}
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
<QueryContainer className="query-section-left-container">
|
||||
<QuerySection selectedGraph={selectedGraph} queryResponse={queryResponse} />
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.right-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
.context-link-form-container {
|
||||
margin-top: 16px;
|
||||
|
||||
.form-label {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.add-url-parameter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.url-parameters-section {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.parameter-header {
|
||||
margin-bottom: 8px;
|
||||
|
||||
strong {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-row {
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
|
||||
.ant-input {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.delete-parameter-btn {
|
||||
color: var(--bg-vanilla-400);
|
||||
padding: 4px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-cherry-400) !important;
|
||||
border-color: var(--bg-cherry-400) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.params-container {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.context-link-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.context-link-form-container {
|
||||
.url-parameters-section {
|
||||
.parameter-row {
|
||||
.delete-parameter-btn {
|
||||
color: var(--bg-slate-400);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-cherry-500) !important;
|
||||
border-color: var(--bg-cherry-500) !important;
|
||||
background-color: var(--bg-cherry-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-link-footer {
|
||||
border-top-color: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import './UpdateContextLinks.styles.scss';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input as AntInput,
|
||||
Input,
|
||||
Row,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { CONTEXT_LINK_FIELDS } from 'container/NewWidget/RightContainer/ContextLinks/constants';
|
||||
import {
|
||||
getInitialValues,
|
||||
getUrlParams,
|
||||
updateUrlWithParams,
|
||||
} from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ContextLinkProps } from 'types/api/dashboard/getAll';
|
||||
|
||||
const { TextArea } = AntInput;
|
||||
|
||||
interface UpdateContextLinksProps {
|
||||
selectedContextLink: ContextLinkProps | null;
|
||||
onSave: (newContextLink: ContextLinkProps) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function UpdateContextLinks({
|
||||
selectedContextLink,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: UpdateContextLinksProps): JSX.Element {
|
||||
const [form] = Form.useForm();
|
||||
const label = Form.useWatch(CONTEXT_LINK_FIELDS.LABEL, form);
|
||||
const url = Form.useWatch(CONTEXT_LINK_FIELDS.URL, form);
|
||||
|
||||
const [params, setParams] = useState<
|
||||
{
|
||||
key: string;
|
||||
value: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
// Function to get current domain
|
||||
const getCurrentDomain = (): string => window.location.origin;
|
||||
|
||||
console.log('FORM VALUES', { label, url });
|
||||
useEffect(() => {
|
||||
((window as unknown) as Record<string, unknown>).form = form;
|
||||
}, [form]);
|
||||
|
||||
// Parse URL and update params when URL changes
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
const urlParams = getUrlParams(url);
|
||||
setParams(urlParams);
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
try {
|
||||
// Validate form fields
|
||||
await form.validateFields();
|
||||
const newContextLink = {
|
||||
id: form.getFieldValue(CONTEXT_LINK_FIELDS.ID),
|
||||
label:
|
||||
form.getFieldValue(CONTEXT_LINK_FIELDS.LABEL) ||
|
||||
form.getFieldValue(CONTEXT_LINK_FIELDS.URL),
|
||||
url: form.getFieldValue(CONTEXT_LINK_FIELDS.URL),
|
||||
};
|
||||
// If validation passes, call onSave
|
||||
onSave(newContextLink);
|
||||
} catch (error) {
|
||||
// Form validation failed, don't call onSave
|
||||
console.log('Form validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUrlParameter = (): void => {
|
||||
const isLastParamEmpty =
|
||||
params.length > 0 &&
|
||||
params[params.length - 1].key.trim() === '' &&
|
||||
params[params.length - 1].value.trim() === '';
|
||||
const canAddParam = params.length === 0 || !isLastParamEmpty;
|
||||
|
||||
if (canAddParam) {
|
||||
const newParams = [
|
||||
...params,
|
||||
{
|
||||
key: '',
|
||||
value: '',
|
||||
},
|
||||
];
|
||||
setParams(newParams);
|
||||
const updatedUrl = updateUrlWithParams(url, newParams);
|
||||
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, updatedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteParameter = (index: number): void => {
|
||||
const newParams = params.filter((_, i) => i !== index);
|
||||
setParams(newParams);
|
||||
const updatedUrl = updateUrlWithParams(url, newParams);
|
||||
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, updatedUrl);
|
||||
};
|
||||
|
||||
const handleParamChange = (
|
||||
index: number,
|
||||
field: 'key' | 'value',
|
||||
value: string,
|
||||
): void => {
|
||||
const newParams = [...params];
|
||||
newParams[index][field] = value;
|
||||
setParams(newParams);
|
||||
const updatedUrl = updateUrlWithParams(url, newParams);
|
||||
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, updatedUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="context-link-form-container">
|
||||
<Form
|
||||
form={form}
|
||||
name="contextLink"
|
||||
initialValues={getInitialValues(selectedContextLink)}
|
||||
// onFinish={() => {}}
|
||||
>
|
||||
{/* //label */}
|
||||
<Typography.Text className="form-label">Label</Typography.Text>
|
||||
<Form.Item
|
||||
name={CONTEXT_LINK_FIELDS.LABEL}
|
||||
rules={[{ required: false, message: 'Please input the label' }]}
|
||||
>
|
||||
<Input placeholder="View Traces details: {{_traceId}}" />
|
||||
</Form.Item>
|
||||
{/* //url */}
|
||||
<Typography.Text className="form-label">
|
||||
URL <span className="required-asterisk">*</span>
|
||||
</Typography.Text>
|
||||
<Form.Item
|
||||
name={CONTEXT_LINK_FIELDS.URL}
|
||||
// label="URL"
|
||||
rules={[
|
||||
{ required: true, message: 'Please input the URL' },
|
||||
{
|
||||
pattern: /^(https?:\/\/|\/|{{.*}}\/)/,
|
||||
message: 'URLs must start with http(s), /, or {{.*}}/',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
placeholder={`${getCurrentDomain()}/trace/{{_traceId}}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="params-container">
|
||||
{/* URL Parameters Section */}
|
||||
{params.length > 0 && (
|
||||
<div className="url-parameters-section">
|
||||
<Row gutter={[8, 8]} className="parameter-header">
|
||||
<Col span={11}>Key</Col>
|
||||
<Col span={11}>Value</Col>
|
||||
<Col span={2}>{/* Empty column for spacing */}</Col>
|
||||
</Row>
|
||||
|
||||
{params.map((param, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Row gutter={[8, 8]} key={index} className="parameter-row">
|
||||
<Col span={11}>
|
||||
<Input
|
||||
id={`param-key-${index}`}
|
||||
placeholder="Key"
|
||||
value={param.key}
|
||||
onChange={(e): void =>
|
||||
handleParamChange(index, 'key', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<TextArea
|
||||
rows={1}
|
||||
placeholder="Value"
|
||||
value={param.value}
|
||||
onChange={(event): void =>
|
||||
handleParamChange(index, 'value', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={(): void => handleDeleteParameter(index)}
|
||||
className="delete-parameter-btn"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add URL parameter btn */}
|
||||
<Button
|
||||
type="primary"
|
||||
className="add-url-parameter-btn"
|
||||
icon={<Plus size={12} />}
|
||||
onClick={handleAddUrlParameter}
|
||||
>
|
||||
Add URL parameter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer with Cancel and Save buttons */}
|
||||
<div className="context-link-footer">
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpdateContextLinks;
|
||||
@@ -0,0 +1,634 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||
|
||||
import ContextLinks from '../index';
|
||||
|
||||
// Mock data for testing
|
||||
const MOCK_EMPTY_CONTEXT_LINKS: ContextLinksData = {
|
||||
linksData: [],
|
||||
};
|
||||
|
||||
const MOCK_CONTEXT_LINKS: ContextLinksData = {
|
||||
linksData: [
|
||||
{
|
||||
id: '1',
|
||||
label: 'Dashboard 1',
|
||||
url: 'https://example.com/dashboard1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: 'External Tool',
|
||||
url: 'https://external.com/tool',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
label: 'Grafana',
|
||||
url: 'https://grafana.example.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Test wrapper component
|
||||
const renderWithProviders = (
|
||||
component: React.ReactElement,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>{component}</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
describe('ContextLinks Component', () => {
|
||||
describe('Component Rendering & Initial State', () => {
|
||||
it('should render correctly with existing context links', () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the component renders
|
||||
expect(screen.getByText('Context Links')).toBeInTheDocument();
|
||||
|
||||
// Check that the add button is present
|
||||
expect(
|
||||
screen.getByRole('button', { name: /context link/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check that all context link items are displayed
|
||||
expect(screen.getByText('Dashboard 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('External Tool')).toBeInTheDocument();
|
||||
expect(screen.getByText('Grafana')).toBeInTheDocument();
|
||||
|
||||
// Check that URLs are displayed
|
||||
expect(
|
||||
screen.getByText('https://example.com/dashboard1'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('https://external.com/tool')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://grafana.example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Context Link" add button', () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the add button is present and has correct text
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
expect(addButton).toBeInTheDocument();
|
||||
expect(addButton).toHaveTextContent('Context Link');
|
||||
expect(addButton).toHaveClass('add-context-link-button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Add Context Link Functionality', () => {
|
||||
it('should show "Add a context link" title in modal when adding new link', () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click the add button to open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Check that modal content is displayed
|
||||
expect(screen.getByText('Add a context link')).toBeInTheDocument();
|
||||
|
||||
// Check that save and cancel buttons are present
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call setContextLinks when saving new context link', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click the add button to open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Fill in the form fields using placeholder text
|
||||
const labelInput = screen.getByPlaceholderText(
|
||||
'View Traces details: {{_traceId}}',
|
||||
);
|
||||
fireEvent.change(labelInput, { target: { value: 'New Link' } });
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
);
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
|
||||
|
||||
// Click save button in modal
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Wait for the modal to close and state to update
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Add a context link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify that setContextLinks was called
|
||||
expect(mockSetContextLinks).toHaveBeenCalledTimes(1);
|
||||
|
||||
// setContextLinks is called with a function (state updater)
|
||||
const setContextLinksCall = mockSetContextLinks.mock.calls[0][0];
|
||||
expect(typeof setContextLinksCall).toBe('function');
|
||||
|
||||
// Test the function by calling it with the current state
|
||||
const result = setContextLinksCall(MOCK_EMPTY_CONTEXT_LINKS);
|
||||
expect(result).toEqual({
|
||||
linksData: [
|
||||
{
|
||||
id: expect.any(String), // ID is generated dynamically
|
||||
label: 'New Link',
|
||||
url: 'https://example.com',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should close modal when cancel button is clicked', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click the add button to open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Modal should be visible
|
||||
expect(screen.getByText('Add a context link')).toBeInTheDocument();
|
||||
|
||||
// Click cancel button
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Modal should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Add a context link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call setContextLinks when cancel button is clicked', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click the add button to open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Click cancel button
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Wait for modal to close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Add a context link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify that setContextLinks was not called
|
||||
expect(mockSetContextLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show form fields in the modal', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click the add button to open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Check that form field labels are present
|
||||
expect(screen.getByText('Label')).toBeInTheDocument();
|
||||
expect(screen.getByText('URL')).toBeInTheDocument();
|
||||
|
||||
// Check that form field inputs are present using placeholder text
|
||||
const labelInput = screen.getByPlaceholderText(
|
||||
'View Traces details: {{_traceId}}',
|
||||
);
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
);
|
||||
expect(labelInput.tagName).toBe('INPUT');
|
||||
expect(urlInput.tagName).toBe('INPUT');
|
||||
});
|
||||
|
||||
it('should validate form fields before saving', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click the add button to open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Try to save without filling required fields
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Form validation should prevent saving
|
||||
await waitFor(() => {
|
||||
expect(mockSetContextLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should still be open
|
||||
expect(screen.getByText('Add a context link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pre-populate form with existing data when editing a context link', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find and click the edit button for the first context link using CSS class
|
||||
const editButtons = document.querySelectorAll('.edit-context-link-btn');
|
||||
expect(editButtons).toHaveLength(3); // Should have 3 edit buttons for 3 context links
|
||||
fireEvent.click(editButtons[0]); // Click edit button for first link
|
||||
|
||||
// Modal should open with "Edit context link" title
|
||||
expect(screen.getByText('Edit context link')).toBeInTheDocument();
|
||||
|
||||
// Form should be pre-populated with existing data from the first context link
|
||||
const labelInput = screen.getByPlaceholderText(
|
||||
'View Traces details: {{_traceId}}',
|
||||
);
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
);
|
||||
|
||||
// Check that the form is pre-populated with the first context link's data
|
||||
expect(labelInput).toHaveAttribute('value', 'Dashboard 1');
|
||||
expect(urlInput).toHaveAttribute('value', 'https://example.com/dashboard1');
|
||||
|
||||
// Verify save and cancel buttons are present
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL and Query Parameter Functionality', () => {
|
||||
it('should parse URL with query parameters and display them in parameter table', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open modal to add new context link
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Type a URL with query parameters
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
);
|
||||
const testUrl =
|
||||
'https://example.com/api?param1=value1¶m2=value2¶m3=value3';
|
||||
fireEvent.change(urlInput, { target: { value: testUrl } });
|
||||
|
||||
// Wait for parameter parsing and display
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify all parameters are displayed
|
||||
expect(screen.getByDisplayValue('param1')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('value1')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('param2')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('value2')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('param3')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('value3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should add new URL parameter when "Add URL parameter" button is clicked', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Initially no parameters should be visible
|
||||
expect(screen.queryByText('Key')).not.toBeInTheDocument();
|
||||
|
||||
// Click "Add URL parameter" button
|
||||
const addParamButton = screen.getByRole('button', {
|
||||
name: /add url parameter/i,
|
||||
});
|
||||
fireEvent.click(addParamButton);
|
||||
|
||||
// Parameter table should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should have one empty parameter row
|
||||
const keyInputs = screen.getAllByPlaceholderText('Key');
|
||||
const valueInputs = screen.getAllByPlaceholderText('Value');
|
||||
expect(keyInputs).toHaveLength(1);
|
||||
expect(valueInputs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should update URL when parameter values are changed', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Add a parameter
|
||||
const addParamButton = screen.getByRole('button', {
|
||||
name: /add url parameter/i,
|
||||
});
|
||||
fireEvent.click(addParamButton);
|
||||
|
||||
// Fill in parameter key and value
|
||||
const keyInput = screen.getByPlaceholderText('Key');
|
||||
const valueInput = screen.getAllByPlaceholderText('Value')[0];
|
||||
|
||||
fireEvent.change(keyInput, { target: { value: 'search' } });
|
||||
fireEvent.change(valueInput, { target: { value: 'query' } });
|
||||
|
||||
// URL should be updated with the parameter
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
) as HTMLInputElement;
|
||||
expect(urlInput.value).toBe('?search=query');
|
||||
});
|
||||
|
||||
it('should delete URL parameter when delete button is clicked', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Add a parameter
|
||||
const addParamButton = screen.getByRole('button', {
|
||||
name: /add url parameter/i,
|
||||
});
|
||||
fireEvent.click(addParamButton);
|
||||
|
||||
// Fill in parameter
|
||||
const keyInput = screen.getByPlaceholderText('Key');
|
||||
const valueInput = screen.getAllByPlaceholderText('Value')[0];
|
||||
fireEvent.change(keyInput, { target: { value: 'test' } });
|
||||
fireEvent.change(valueInput, { target: { value: 'value' } });
|
||||
|
||||
// Verify parameter is added
|
||||
expect(screen.getByDisplayValue('test')).toBeInTheDocument();
|
||||
|
||||
// Click delete button for the parameter
|
||||
const deleteButtons = screen.getAllByRole('button', { name: '' });
|
||||
const deleteButton = deleteButtons.find((btn) =>
|
||||
btn.className.includes('delete-parameter-btn'),
|
||||
);
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
fireEvent.click(deleteButton!);
|
||||
|
||||
// Parameter should be removed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByDisplayValue('test')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// URL should be cleaned up
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
) as HTMLInputElement;
|
||||
expect(urlInput.value).toBe('');
|
||||
});
|
||||
|
||||
it('should handle multiple parameters and maintain URL synchronization', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Add first parameter
|
||||
const addParamButton = screen.getByRole('button', {
|
||||
name: /add url parameter/i,
|
||||
});
|
||||
fireEvent.click(addParamButton);
|
||||
|
||||
// Fill first parameter
|
||||
let keyInputs = screen.getAllByPlaceholderText('Key');
|
||||
let valueInputs = screen.getAllByPlaceholderText('Value');
|
||||
fireEvent.change(keyInputs[0], { target: { value: 'page' } });
|
||||
fireEvent.change(valueInputs[0], { target: { value: '1' } });
|
||||
|
||||
// Add second parameter
|
||||
fireEvent.click(addParamButton);
|
||||
|
||||
// Get updated inputs after adding second parameter
|
||||
keyInputs = screen.getAllByPlaceholderText('Key');
|
||||
valueInputs = screen.getAllByPlaceholderText('Value');
|
||||
|
||||
// Fill second parameter
|
||||
fireEvent.change(keyInputs[1], { target: { value: 'size' } });
|
||||
fireEvent.change(valueInputs[1], { target: { value: '10' } });
|
||||
|
||||
// URL should contain both parameters
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
) as HTMLInputElement;
|
||||
expect(urlInput.value).toBe('?page=1&size=10');
|
||||
|
||||
// Change first parameter value
|
||||
fireEvent.change(valueInputs[0], { target: { value: '2' } });
|
||||
|
||||
// URL should be updated
|
||||
expect(urlInput.value).toBe('?page=2&size=10');
|
||||
});
|
||||
|
||||
it('should validate URL format and show appropriate error messages', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Try to save with invalid URL
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
);
|
||||
fireEvent.change(urlInput, { target: { value: 'invalid-url' } });
|
||||
|
||||
// Try to save
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Should show validation error
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('URLs must start with http(s), /, or {{.*}}/'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// setContextLinks should not be called due to validation failure
|
||||
expect(mockSetContextLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle special characters in parameter keys and values correctly', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Add parameter with special characters
|
||||
const addParamButton = screen.getByRole('button', {
|
||||
name: /add url parameter/i,
|
||||
});
|
||||
fireEvent.click(addParamButton);
|
||||
|
||||
// Fill parameter with special characters
|
||||
const keyInput = screen.getByPlaceholderText('Key');
|
||||
const valueInput = screen.getAllByPlaceholderText('Value')[0];
|
||||
|
||||
fireEvent.change(keyInput, { target: { value: 'user@domain' } });
|
||||
fireEvent.change(valueInput, { target: { value: 'John Doe & Co.' } });
|
||||
|
||||
// URL should be properly encoded
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
) as HTMLInputElement;
|
||||
expect(urlInput.value).toBe('?user%40domain=John%20Doe%20%26%20Co.');
|
||||
});
|
||||
|
||||
it('should support template variables in URL and parameters', async () => {
|
||||
const mockSetContextLinks = jest.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<ContextLinks
|
||||
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
|
||||
setContextLinks={mockSetContextLinks}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open modal
|
||||
const addButton = screen.getByRole('button', { name: /context link/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
// Type URL with template variable
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
'http://localhost/trace/{{_traceId}}',
|
||||
);
|
||||
const testUrl =
|
||||
'https://example.com/trace/{{_traceId}}?service={{_serviceName}}';
|
||||
fireEvent.change(urlInput, { target: { value: testUrl } });
|
||||
|
||||
// Wait for parameter parsing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should parse template variable as parameter
|
||||
expect(screen.getByDisplayValue('service')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('{{_serviceName}}')).toBeInTheDocument();
|
||||
|
||||
// URL should maintain template variables
|
||||
expect((urlInput as HTMLInputElement).value).toBe(
|
||||
'https://example.com/trace/{{_traceId}}?service={{_serviceName}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export const CONTEXT_LINK_FIELDS = {
|
||||
ID: 'id',
|
||||
LABEL: 'label',
|
||||
URL: 'url',
|
||||
// OPEN_IN_NEW_TAB: 'openInNewTab'
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import './styles.scss';
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Modal, Typography } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { ContextLinkProps, ContextLinksData } from 'types/api/dashboard/getAll';
|
||||
|
||||
import UpdateContextLinks from './UpdateContextLinks';
|
||||
import useContextLinkModal from './useContextLinkModal';
|
||||
|
||||
function SortableContextLink({
|
||||
contextLink,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
contextLink: ContextLinkProps;
|
||||
onDelete: (contextLink: ContextLinkProps) => void;
|
||||
onEdit: (contextLink: ContextLinkProps) => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: contextLink.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="context-link-item drag-enabled"
|
||||
>
|
||||
<div {...attributes} {...listeners} className="drag-handle">
|
||||
<div className="drag-handle-icon">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<div className="context-link-content">
|
||||
<span className="context-link-label">{contextLink.label}</span>
|
||||
<span className="context-link-url">{contextLink.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-link-actions">
|
||||
<Button
|
||||
className="edit-context-link-btn periscope-btn"
|
||||
size="small"
|
||||
icon={<Pencil size={12} />}
|
||||
onClick={(): void => {
|
||||
onEdit(contextLink);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="delete-context-link-btn periscope-btn"
|
||||
size="small"
|
||||
icon={<Trash2 size={12} />}
|
||||
onClick={(): void => {
|
||||
onDelete(contextLink);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextLinks({
|
||||
contextLinks,
|
||||
setContextLinks,
|
||||
}: {
|
||||
contextLinks: ContextLinksData;
|
||||
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
|
||||
}): JSX.Element {
|
||||
// Use the custom hook for modal functionality
|
||||
const {
|
||||
isModalOpen,
|
||||
selectedContextLink,
|
||||
handleEditContextLink,
|
||||
handleAddContextLink,
|
||||
handleCancelModal,
|
||||
handleSaveContextLink,
|
||||
} = useContextLinkModal({ setContextLinks });
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setContextLinks((prev) => {
|
||||
const items = [...prev.linksData];
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
linksData: arrayMove(items, oldIndex, newIndex),
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteContextLink = (contextLink: ContextLinkProps): void => {
|
||||
setContextLinks((prev) => ({
|
||||
...prev,
|
||||
linksData: prev.linksData.filter((link) => link.id !== contextLink.id),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="context-links-container">
|
||||
<Typography.Text className="context-links-text">
|
||||
Context Links
|
||||
</Typography.Text>
|
||||
|
||||
<div className="context-links-list">
|
||||
<OverlayScrollbar>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={contextLinks.linksData.map((link) => link.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{contextLinks.linksData.map((contextLink) => (
|
||||
<SortableContextLink
|
||||
key={contextLink.id}
|
||||
contextLink={contextLink}
|
||||
onDelete={handleDeleteContextLink}
|
||||
onEdit={handleEditContextLink}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</OverlayScrollbar>
|
||||
|
||||
{/* button to add context link */}
|
||||
<Button
|
||||
type="primary"
|
||||
className="add-context-link-button"
|
||||
icon={<Plus size={12} />}
|
||||
onClick={handleAddContextLink}
|
||||
>
|
||||
Context Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={selectedContextLink ? 'Edit context link' : 'Add a context link'}
|
||||
open={isModalOpen}
|
||||
onCancel={handleCancelModal}
|
||||
destroyOnClose
|
||||
width={672}
|
||||
footer={null}
|
||||
>
|
||||
<UpdateContextLinks
|
||||
selectedContextLink={selectedContextLink}
|
||||
onSave={handleSaveContextLink}
|
||||
onCancel={handleCancelModal}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContextLinks;
|
||||
@@ -0,0 +1,149 @@
|
||||
.context-links-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.context-links-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.52px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.context-links-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.context-link-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-grow: 1;
|
||||
cursor: grab;
|
||||
min-width: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.context-link-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.context-link-label {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.context-link-url {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.context-link-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-context-link-btn,
|
||||
.delete-context-link-btn {
|
||||
padding: 6px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.delete-context-link-btn {
|
||||
&:hover {
|
||||
color: var(--bg-cherry-400) !important;
|
||||
border-color: var(--bg-cherry-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-slate-400);
|
||||
|
||||
.context-link-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-context-link-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.context-links-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.context-link-item {
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
.context-link-label {
|
||||
color: var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.context-link-url {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.drag-handle-icon {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.delete-context-link-btn {
|
||||
&:hover {
|
||||
color: var(--bg-cherry-500);
|
||||
border-color: var(--bg-cherry-500);
|
||||
background-color: var(--bg-cherry-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { ContextLinkProps, ContextLinksData } from 'types/api/dashboard/getAll';
|
||||
|
||||
interface ContextLinkModalProps {
|
||||
isModalOpen: boolean;
|
||||
selectedContextLink: ContextLinkProps | null;
|
||||
handleEditContextLink: (contextLink: ContextLinkProps) => void;
|
||||
handleAddContextLink: () => void;
|
||||
handleCancelModal: () => void;
|
||||
handleSaveContextLink: (newContextLink: ContextLinkProps) => void;
|
||||
}
|
||||
|
||||
const useContextLinkModal = ({
|
||||
setContextLinks,
|
||||
}: {
|
||||
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
|
||||
}): ContextLinkModalProps => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [
|
||||
selectedContextLink,
|
||||
setSelectedContextLink,
|
||||
] = useState<ContextLinkProps | null>(null);
|
||||
|
||||
const handleEditContextLink = (contextLink: ContextLinkProps): void => {
|
||||
setSelectedContextLink(contextLink);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAddContextLink = (): void => {
|
||||
setSelectedContextLink(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCancelModal = (): void => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedContextLink(null);
|
||||
};
|
||||
|
||||
const handleSaveContextLink = (newContextLink: ContextLinkProps): void => {
|
||||
setContextLinks((prev) => {
|
||||
const links = [...prev.linksData];
|
||||
const existing = links.filter((link) => link.id === newContextLink.id)[0];
|
||||
if (existing) {
|
||||
const idx = links.findIndex((link) => link.id === newContextLink.id);
|
||||
links[idx] = { ...existing, ...newContextLink };
|
||||
return { ...prev, linksData: links };
|
||||
}
|
||||
links.push(newContextLink);
|
||||
return { ...prev, linksData: links };
|
||||
});
|
||||
setIsModalOpen(false);
|
||||
setSelectedContextLink(null);
|
||||
};
|
||||
|
||||
return {
|
||||
isModalOpen,
|
||||
selectedContextLink,
|
||||
handleEditContextLink,
|
||||
handleAddContextLink,
|
||||
handleCancelModal,
|
||||
handleSaveContextLink,
|
||||
};
|
||||
};
|
||||
|
||||
export default useContextLinkModal;
|
||||
@@ -0,0 +1,181 @@
|
||||
import { CONTEXT_LINK_FIELDS } from 'container/NewWidget/RightContainer/ContextLinks/constants';
|
||||
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
|
||||
import { ContextLinkProps } from 'types/api/dashboard/getAll';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface UrlParam {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ProcessedContextLink {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const getInitialValues = (
|
||||
contextLink: ContextLinkProps | null,
|
||||
): Record<string, string> => ({
|
||||
[CONTEXT_LINK_FIELDS.ID]: contextLink?.id || uuid(),
|
||||
[CONTEXT_LINK_FIELDS.LABEL]: contextLink?.label || '',
|
||||
[CONTEXT_LINK_FIELDS.URL]: contextLink?.url || '',
|
||||
});
|
||||
|
||||
const getUrlParams = (url: string): UrlParam[] => {
|
||||
try {
|
||||
const [, queryString] = url.split('?');
|
||||
|
||||
if (!queryString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paramPairs = queryString.split('&');
|
||||
const params: UrlParam[] = [];
|
||||
|
||||
paramPairs.forEach((pair) => {
|
||||
try {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
const decodedKey = decodeURIComponent(key);
|
||||
const decodedValue = decodeURIComponent(value || '');
|
||||
|
||||
// Double decode the value for display
|
||||
let displayValue = decodedValue;
|
||||
try {
|
||||
// Try to double decode if it looks like it was double encoded
|
||||
const doubleDecoded = decodeURIComponent(decodedValue);
|
||||
// Check if double decoding produced a different result
|
||||
if (doubleDecoded !== decodedValue) {
|
||||
displayValue = doubleDecoded;
|
||||
}
|
||||
} catch {
|
||||
// If double decoding fails, use single decoded value
|
||||
displayValue = decodedValue;
|
||||
}
|
||||
|
||||
params.push({
|
||||
key: decodedKey,
|
||||
value: displayValue,
|
||||
});
|
||||
}
|
||||
} catch (paramError) {
|
||||
// Skip malformed parameters and continue processing
|
||||
console.warn('Failed to parse URL parameter:', pair, paramError);
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse URL parameters, returning empty array:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const updateUrlWithParams = (url: string, params: UrlParam[]): string => {
|
||||
// Get base URL without query parameters
|
||||
const [baseUrl] = url.split('?');
|
||||
|
||||
// Create query parameter string from current parameters
|
||||
const validParams = params.filter((param) => param.key.trim() !== '');
|
||||
const queryString = validParams
|
||||
.map(
|
||||
(param) =>
|
||||
`${encodeURIComponent(param.key.trim())}=${encodeURIComponent(
|
||||
param.value,
|
||||
)}`,
|
||||
)
|
||||
.join('&');
|
||||
|
||||
// Construct final URL
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
};
|
||||
|
||||
// Utility function to process context links with variable resolution and URL encoding
|
||||
const processContextLinks = (
|
||||
contextLinks: ContextLinkProps[],
|
||||
processedVariables: Record<string, string>,
|
||||
maxLength?: number,
|
||||
): ProcessedContextLink[] => {
|
||||
// Extract all labels and URLs for batch processing
|
||||
const labels = contextLinks.map(({ label }) => label);
|
||||
const urls = contextLinks.map(({ url }) => url);
|
||||
|
||||
// Resolve variables in labels
|
||||
const resolvedLabels = resolveTexts({
|
||||
texts: labels,
|
||||
processedVariables,
|
||||
maxLength,
|
||||
});
|
||||
|
||||
// Process URLs with proper encoding/decoding
|
||||
const finalUrls = urls.map((url) => {
|
||||
if (typeof url !== 'string') return url;
|
||||
|
||||
try {
|
||||
// 1. Get the URL and extract base URL and query string
|
||||
const [baseUrl, queryString] = url.split('?');
|
||||
// Resolve variables in base URL.
|
||||
const resolvedBaseUrlResult = resolveTexts({
|
||||
texts: [baseUrl],
|
||||
processedVariables,
|
||||
});
|
||||
const resolvedBaseUrl = resolvedBaseUrlResult.fullTexts[0];
|
||||
|
||||
if (!queryString) return resolvedBaseUrl;
|
||||
|
||||
// 2. Extract all query params using URLSearchParams
|
||||
const searchParams = new URLSearchParams(queryString);
|
||||
const processedParams: Record<string, string> = {};
|
||||
|
||||
// 3. Process each parameter
|
||||
Array.from(searchParams.entries()).forEach(([key, value]) => {
|
||||
// 4. Decode twice to handle double encoding
|
||||
let decodedValue = decodeURIComponent(value);
|
||||
try {
|
||||
const doubleDecoded = decodeURIComponent(decodedValue);
|
||||
// Check if double decoding produced a different result
|
||||
if (doubleDecoded !== decodedValue) {
|
||||
decodedValue = doubleDecoded;
|
||||
}
|
||||
} catch {
|
||||
// If double decoding fails, use single decoded value
|
||||
}
|
||||
|
||||
// 5. Pass through resolve text for variable resolution
|
||||
const resolvedTextsResult = resolveTexts({
|
||||
texts: [decodedValue],
|
||||
processedVariables,
|
||||
});
|
||||
const resolvedValue = resolvedTextsResult.fullTexts[0];
|
||||
|
||||
// 6. Encode the resolved value
|
||||
processedParams[key] = encodeURIComponent(resolvedValue);
|
||||
});
|
||||
|
||||
// 7. Create new URL with processed parameters
|
||||
const newQueryString = Object.entries(processedParams)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${value}`)
|
||||
.join('&');
|
||||
|
||||
return `${resolvedBaseUrl}?${newQueryString}`;
|
||||
} catch (error) {
|
||||
console.warn('Failed to process URL, using original URL:', error);
|
||||
return url;
|
||||
}
|
||||
});
|
||||
|
||||
// Return processed context links
|
||||
return contextLinks.map((link, index) => ({
|
||||
id: link.id,
|
||||
label: resolvedLabels.fullTexts[index],
|
||||
url: finalUrls[index],
|
||||
}));
|
||||
};
|
||||
|
||||
export {
|
||||
getInitialValues,
|
||||
getUrlParams,
|
||||
processContextLinks,
|
||||
updateUrlWithParams,
|
||||
};
|
||||
@@ -335,6 +335,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.context-links {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
|
||||
.alerts {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
@@ -512,6 +516,10 @@
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.context-links {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
|
||||
@@ -178,3 +178,17 @@ export const panelTypeVsLegendColors: {
|
||||
[PANEL_TYPES.HISTOGRAM]: true,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: false,
|
||||
} as const;
|
||||
|
||||
export const panelTypeVsContextLinks: {
|
||||
[key in PANEL_TYPES]: boolean;
|
||||
} = {
|
||||
[PANEL_TYPES.TIME_SERIES]: true,
|
||||
[PANEL_TYPES.VALUE]: true,
|
||||
[PANEL_TYPES.TABLE]: true,
|
||||
[PANEL_TYPES.LIST]: false,
|
||||
[PANEL_TYPES.PIE]: true,
|
||||
[PANEL_TYPES.BAR]: true,
|
||||
[PANEL_TYPES.HISTOGRAM]: true,
|
||||
[PANEL_TYPES.TRACE]: false,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: false,
|
||||
} as const;
|
||||
|
||||
@@ -34,6 +34,7 @@ import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import {
|
||||
ColumnUnit,
|
||||
ContextLinksData,
|
||||
LegendPosition,
|
||||
Widgets,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
@@ -45,6 +46,7 @@ import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
|
||||
import {
|
||||
panelTypeVsBucketConfig,
|
||||
panelTypeVsColumnUnitPreferences,
|
||||
panelTypeVsContextLinks,
|
||||
panelTypeVsCreateAlert,
|
||||
panelTypeVsFillSpan,
|
||||
panelTypeVsLegendColors,
|
||||
@@ -56,6 +58,7 @@ import {
|
||||
panelTypeVsThreshold,
|
||||
panelTypeVsYAxisUnit,
|
||||
} from './constants';
|
||||
import ContextLinks from './ContextLinks';
|
||||
import LegendColors from './LegendColors/LegendColors';
|
||||
import ThresholdSelector from './Threshold/ThresholdSelector';
|
||||
import { ThresholdProps } from './Threshold/types';
|
||||
@@ -113,6 +116,9 @@ function RightContainer({
|
||||
customLegendColors,
|
||||
setCustomLegendColors,
|
||||
queryResponse,
|
||||
contextLinks,
|
||||
setContextLinks,
|
||||
enableDrillDown = false,
|
||||
}: RightContainerProps): JSX.Element {
|
||||
const { selectedDashboard } = useDashboard();
|
||||
const [inputValue, setInputValue] = useState(title);
|
||||
@@ -152,6 +158,8 @@ function RightContainer({
|
||||
|
||||
const allowPanelColumnPreference =
|
||||
panelTypeVsColumnUnitPreferences[selectedGraph];
|
||||
const allowContextLinks =
|
||||
panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -497,6 +505,15 @@ function RightContainer({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowContextLinks && (
|
||||
<section className="context-links">
|
||||
<ContextLinks
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowThreshold && (
|
||||
<section>
|
||||
<ThresholdSelector
|
||||
@@ -558,11 +575,15 @@ interface RightContainerProps {
|
||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||
Error
|
||||
>;
|
||||
contextLinks: ContextLinksData;
|
||||
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
RightContainer.defaultProps = {
|
||||
selectedWidget: undefined,
|
||||
queryResponse: null,
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
export default RightContainer;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
||||
@@ -41,6 +42,7 @@ import { AppState } from 'store/reducers';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import {
|
||||
ColumnUnit,
|
||||
ContextLinksData,
|
||||
LegendPosition,
|
||||
Widgets,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
@@ -72,7 +74,10 @@ import {
|
||||
placeWidgetBetweenRows,
|
||||
} from './utils';
|
||||
|
||||
function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
function NewWidget({
|
||||
selectedGraph,
|
||||
enableDrillDown = false,
|
||||
}: NewWidgetProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const {
|
||||
selectedDashboard,
|
||||
@@ -239,6 +244,10 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
selectedWidget?.columnUnits || {},
|
||||
);
|
||||
|
||||
const [contextLinks, setContextLinks] = useState<ContextLinksData>(
|
||||
selectedWidget?.contextLinks || { linksData: [] },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedWidget((prev) => {
|
||||
if (!prev) {
|
||||
@@ -268,6 +277,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths: columnWidths?.[selectedWidget?.id],
|
||||
contextLinks,
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -294,6 +304,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths,
|
||||
contextLinks,
|
||||
]);
|
||||
|
||||
const closeModal = (): void => {
|
||||
@@ -504,6 +515,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
|
||||
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
|
||||
customLegendColors: selectedWidget?.customLegendColors || {},
|
||||
contextLinks: selectedWidget?.contextLinks || { linksData: [] },
|
||||
},
|
||||
]
|
||||
: [
|
||||
@@ -533,6 +545,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
|
||||
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
|
||||
customLegendColors: selectedWidget?.customLegendColors || {},
|
||||
contextLinks: selectedWidget?.contextLinks || { linksData: [] },
|
||||
},
|
||||
...afterWidgets,
|
||||
],
|
||||
@@ -690,6 +703,26 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
}
|
||||
}, [selectedLogFields, selectedTracesFields, currentQuery, selectedGraph]);
|
||||
|
||||
const showSwitchToViewModeButton =
|
||||
enableDrillDown && !isNewDashboard && !!query.get('widgetId');
|
||||
|
||||
const handleSwitchToViewMode = useCallback(() => {
|
||||
if (!query.get('widgetId')) return;
|
||||
const widgetId = query.get('widgetId') || '';
|
||||
const queryParams = {
|
||||
[QueryParams.expandedWidgetId]: widgetId,
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(currentQuery),
|
||||
),
|
||||
};
|
||||
|
||||
const updatedSearch = createQueryParams(queryParams);
|
||||
safeNavigate({
|
||||
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
|
||||
search: updatedSearch,
|
||||
});
|
||||
}, [query, safeNavigate, dashboardId, currentQuery]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="edit-header">
|
||||
@@ -706,31 +739,42 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</div>
|
||||
{isSaveDisabled && (
|
||||
<Button
|
||||
type="primary"
|
||||
data-testid="new-widget-save"
|
||||
loading={updateDashboardMutation.isLoading}
|
||||
disabled={isSaveDisabled}
|
||||
onClick={onSaveDashboard}
|
||||
className="save-btn"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
{!isSaveDisabled && (
|
||||
<Button
|
||||
type="primary"
|
||||
data-testid="new-widget-save"
|
||||
loading={updateDashboardMutation.isLoading}
|
||||
disabled={isSaveDisabled}
|
||||
onClick={onSaveDashboard}
|
||||
icon={<Check size={14} />}
|
||||
className="save-btn"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
<div className="right-header">
|
||||
{showSwitchToViewModeButton && (
|
||||
<Button
|
||||
data-testid="switch-to-view-mode"
|
||||
disabled={isSaveDisabled || !currentQuery}
|
||||
onClick={handleSwitchToViewMode}
|
||||
>
|
||||
Switch to View Mode
|
||||
</Button>
|
||||
)}
|
||||
{isSaveDisabled && (
|
||||
<Button
|
||||
type="primary"
|
||||
data-testid="new-widget-save"
|
||||
loading={updateDashboardMutation.isLoading}
|
||||
disabled={isSaveDisabled}
|
||||
onClick={onSaveDashboard}
|
||||
className="save-btn"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
{!isSaveDisabled && (
|
||||
<Button
|
||||
type="primary"
|
||||
data-testid="new-widget-save"
|
||||
loading={updateDashboardMutation.isLoading}
|
||||
disabled={isSaveDisabled}
|
||||
onClick={onSaveDashboard}
|
||||
icon={<Check size={14} />}
|
||||
className="save-btn"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PanelContainer>
|
||||
@@ -749,6 +793,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
setRequestData={setRequestData}
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
setQueryResponse={setQueryResponse}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
</OverlayScrollbar>
|
||||
@@ -799,6 +844,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
setSoftMin={setSoftMin}
|
||||
softMax={softMax}
|
||||
setSoftMax={setSoftMax}
|
||||
contextLinks={contextLinks}
|
||||
setContextLinks={setContextLinks}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</RightContainerWrapper>
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface NewWidgetProps {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
yAxisUnit: Widgets['yAxisUnit'];
|
||||
fillSpans: Widgets['fillSpans'];
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetGraphProps {
|
||||
@@ -32,6 +33,7 @@ export interface WidgetGraphProps {
|
||||
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
|
||||
>
|
||||
>;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export type WidgetGraphContainerProps = {
|
||||
@@ -45,4 +47,5 @@ export type WidgetGraphContainerProps = {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
selectedWidget: Widgets;
|
||||
isLoadingPanelData: boolean;
|
||||
enableDrillDown?: boolean;
|
||||
};
|
||||
|
||||
@@ -556,6 +556,7 @@ export const getDefaultWidgetData = (
|
||||
dataType: field.fieldDataType ?? '',
|
||||
})),
|
||||
selectedTracesFields: defaultTraceSelectedColumns,
|
||||
// contextLinks: { linksData: [] },
|
||||
});
|
||||
|
||||
export const PANEL_TYPE_TO_QUERY_TYPES: Record<PANEL_TYPES, EQueryType[]> = {
|
||||
|
||||
@@ -2,12 +2,15 @@ import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { buildHistogramData } from './histogram';
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
@@ -20,11 +23,61 @@ function HistogramPanelWrapper({
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
onClickHandler,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: widget.contextLinks,
|
||||
panelType: widget.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label });
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
const histogramData = buildHistogramData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
@@ -73,7 +126,9 @@ function HistogramPanelWrapper({
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
graphsVisibilityStates: graphVisibility,
|
||||
mergeAllQueries: widget.mergeAllActiveQueries,
|
||||
onClickHandler: onClickHandler || _noop,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
}),
|
||||
[
|
||||
containerDimensions,
|
||||
@@ -85,6 +140,8 @@ function HistogramPanelWrapper({
|
||||
widget.id,
|
||||
widget.mergeAllActiveQueries,
|
||||
widget.panelTypes,
|
||||
clickHandlerWithContextMenu,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
],
|
||||
);
|
||||
@@ -92,6 +149,13 @@ function HistogramPanelWrapper({
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
|
||||
<GraphManager
|
||||
data={histogramData}
|
||||
|
||||
@@ -21,6 +21,7 @@ function PanelWrapper({
|
||||
onOpenTraceBtnClick,
|
||||
customSeries,
|
||||
customOnRowClick,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const Component = PanelTypeVsPanelWrapper[
|
||||
selectedGraph || widget.panelTypes
|
||||
@@ -49,6 +50,7 @@ function PanelWrapper({
|
||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||
customOnRowClick={customOnRowClick}
|
||||
customSeries={customSeries}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import { Pie } from '@visx/shape';
|
||||
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { isNaN } from 'lodash-es';
|
||||
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
|
||||
@@ -19,6 +22,7 @@ import { lightenColor, tooltipStyles } from './utils';
|
||||
function PiePanelWrapper({
|
||||
queryResponse,
|
||||
widget,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const [active, setActive] = useState<{
|
||||
label: string;
|
||||
@@ -48,6 +52,7 @@ function PiePanelWrapper({
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: any;
|
||||
}[] = [].concat(
|
||||
...(panelData
|
||||
.map((d) => {
|
||||
@@ -55,6 +60,7 @@ function PiePanelWrapper({
|
||||
return {
|
||||
label,
|
||||
value: d?.values?.[0]?.[1],
|
||||
record: d,
|
||||
color:
|
||||
widget?.customLegendColors?.[label] ||
|
||||
generateColor(
|
||||
@@ -142,6 +148,29 @@ function PiePanelWrapper({
|
||||
return active.color === color ? color : lightenedColor;
|
||||
};
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: widget.contextLinks,
|
||||
panelType: widget.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="piechart-wrapper">
|
||||
{!pieChartData.length && <div className="piechart-no-data">No data</div>}
|
||||
@@ -165,7 +194,7 @@ function PiePanelWrapper({
|
||||
height={size}
|
||||
>
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, sonarjs/cognitive-complexity
|
||||
(pie) =>
|
||||
pie.arcs.map((arc) => {
|
||||
const { label } = arc.data;
|
||||
@@ -226,6 +255,17 @@ function PiePanelWrapper({
|
||||
hideTooltip();
|
||||
setActive(null);
|
||||
}}
|
||||
onClick={(e): void => {
|
||||
if (enableDrillDown) {
|
||||
const data = getPieChartClickData(arc);
|
||||
if (data && data?.queryName) {
|
||||
onClick(
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
{ ...data, label: data.label },
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<path d={arcPath || ''} fill={getFillColor(arcFill)} />
|
||||
|
||||
@@ -284,6 +324,13 @@ function PiePanelWrapper({
|
||||
})
|
||||
}
|
||||
</Pie>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Add total value in the center */}
|
||||
<text
|
||||
|
||||
@@ -12,6 +12,7 @@ function TablePanelWrapper({
|
||||
openTracesButton,
|
||||
onOpenTraceBtnClick,
|
||||
customOnRowClick,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const panelData =
|
||||
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
|
||||
@@ -31,6 +32,10 @@ function TablePanelWrapper({
|
||||
widgetId={widget.id}
|
||||
renderColumnCell={widget.renderColumnCell}
|
||||
customColTitles={widget.customColTitles}
|
||||
contextLinks={widget.contextLinks}
|
||||
enableDrillDown={enableDrillDown}
|
||||
panelType={widget.panelTypes}
|
||||
queryRange={queryResponse}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...GRID_TABLE_CONFIG}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,8 @@ import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -13,14 +15,16 @@ import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
import { getTimeRangeFromStepInterval } from './utils';
|
||||
|
||||
function UplotPanelWrapper({
|
||||
queryResponse,
|
||||
@@ -34,6 +38,7 @@ function UplotPanelWrapper({
|
||||
selectedGraph,
|
||||
customTooltipElement,
|
||||
customSeries,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -65,6 +70,28 @@ function UplotPanelWrapper({
|
||||
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
contextLinks: widget.contextLinks,
|
||||
panelType: widget.panelTypes,
|
||||
queryRange: queryResponse,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
@@ -114,6 +141,53 @@ function UplotPanelWrapper({
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
xValue,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
axesData,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
console.log('onClickData: ', data);
|
||||
// Compute time range if needed and if axes data is available
|
||||
let timeRange;
|
||||
if (axesData && queryData?.queryName) {
|
||||
// Get the compositeQuery from the response params
|
||||
const compositeQuery = (queryResponse?.data?.params as any)?.compositeQuery;
|
||||
|
||||
if (compositeQuery?.queries) {
|
||||
// Find the specific query by name from the queries array
|
||||
const specificQuery = compositeQuery.queries.find(
|
||||
(query: any) => query.spec?.name === queryData.queryName,
|
||||
);
|
||||
|
||||
// Use the stepInterval from the specific query, fallback to default
|
||||
const stepInterval = specificQuery?.spec?.stepInterval || 60;
|
||||
timeRange = getTimeRangeFromStepInterval(stepInterval, xValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label, timeRange });
|
||||
}
|
||||
},
|
||||
[onClick, queryResponse],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
@@ -123,7 +197,9 @@ function UplotPanelWrapper({
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
yAxisUnit: widget?.yAxisUnit,
|
||||
onClickHandler: onClickHandler || _noop,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
thresholds: widget.thresholds,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
@@ -152,7 +228,7 @@ function UplotPanelWrapper({
|
||||
containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
onClickHandler,
|
||||
clickHandlerWithContextMenu,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
graphVisibility,
|
||||
@@ -163,6 +239,8 @@ function UplotPanelWrapper({
|
||||
customTooltipElement,
|
||||
timezone.value,
|
||||
customSeries,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
widget,
|
||||
],
|
||||
);
|
||||
@@ -170,6 +248,13 @@ function UplotPanelWrapper({
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{widget?.stackedBarChart && isFullViewMode && (
|
||||
<Alert
|
||||
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PanelWrapperProps } from './panelWrapper.types';
|
||||
function ValuePanelWrapper({
|
||||
widget,
|
||||
queryResponse,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const { yAxisUnit, thresholds } = widget;
|
||||
const data = getUPlotChartData(queryResponse?.data?.payload);
|
||||
@@ -22,6 +23,10 @@ function ValuePanelWrapper({
|
||||
data={gridValueData}
|
||||
yAxisUnit={yAxisUnit}
|
||||
thresholds={thresholds}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
contextLinks={widget.contextLinks}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,22 +266,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
demo-app
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
demo-app
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
4.35 s
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
4.35 s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -292,22 +304,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
customer
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
customer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -318,22 +342,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
mysql
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
mysql
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -344,22 +380,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
frontend
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
frontend
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
287 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
287 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -370,22 +418,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
driver
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
driver
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
230 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
230 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -396,22 +456,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
route
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
route
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
66.4 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
66.4 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -422,22 +494,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
redis
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
redis
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
31.3 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
31.3 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -18,6 +18,10 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
|
||||
@@ -30,6 +30,7 @@ export type PanelWrapperProps = {
|
||||
onOpenTraceBtnClick?: (record: RowData) => void;
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customSeries?: (data: QueryData[]) => uPlot.Series[];
|
||||
enableDrillDown?: boolean;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -71,3 +71,29 @@ export const lightenColor = (color: string, opacity: number): string => {
|
||||
// Create a new RGBA color string with the specified opacity
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
};
|
||||
|
||||
export const getTimeRangeFromUplotAxis = (
|
||||
axis: any,
|
||||
xValue: number,
|
||||
): { startTime: number; endTime: number } => {
|
||||
let gap =
|
||||
(axis as any)._splits && (axis as any)._splits.length > 1
|
||||
? (axis as any)._splits[1] - (axis as any)._splits[0]
|
||||
: 600; // 10 minutes in seconds
|
||||
|
||||
gap = Math.max(gap, 600); // Minimum gap of 10 minutes in seconds
|
||||
|
||||
const startTime = xValue - gap;
|
||||
const endTime = xValue + gap;
|
||||
|
||||
return { startTime, endTime };
|
||||
};
|
||||
|
||||
export const getTimeRangeFromStepInterval = (
|
||||
stepInterval: number,
|
||||
xValue: number,
|
||||
): { startTime: number; endTime: number } => {
|
||||
const startTime = xValue;
|
||||
const endTime = xValue + stepInterval;
|
||||
return { startTime, endTime };
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
unset,
|
||||
} from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import {
|
||||
KeyboardEvent,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
@@ -246,6 +248,16 @@ function QueryBuilderSearchV2(
|
||||
return false;
|
||||
}, [currentState, query.aggregateAttribute?.dataType, query.dataSource]);
|
||||
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
const dynamicVariables = useMemo(
|
||||
() =>
|
||||
Object.values(selectedDashboard?.data?.variables || {})?.filter(
|
||||
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
|
||||
),
|
||||
[selectedDashboard],
|
||||
);
|
||||
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
{
|
||||
searchText: searchValue?.split(' ')[0],
|
||||
@@ -788,6 +800,19 @@ function QueryBuilderSearchV2(
|
||||
const dataType = currentFilterItem?.key?.dataType || DataTypes.String;
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
values.push(...(attributeValues?.payload?.[key] || []));
|
||||
|
||||
// here we want to suggest the variable name matching with the key here, we will go over the dynamic variables for the keys
|
||||
const variableName = dynamicVariables?.find(
|
||||
(variable) =>
|
||||
variable?.dynamicVariablesAttribute === currentFilterItem?.key?.key,
|
||||
)?.name;
|
||||
|
||||
if (variableName) {
|
||||
const variableValue = `$${variableName}`;
|
||||
if (!values.includes(variableValue)) {
|
||||
values.unshift(variableValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDropdownOptions(
|
||||
@@ -807,6 +832,8 @@ function QueryBuilderSearchV2(
|
||||
searchValue,
|
||||
suggestionsData?.payload?.attributes,
|
||||
operatorConfigKey,
|
||||
currentFilterItem?.key?.key,
|
||||
dynamicVariables,
|
||||
]);
|
||||
|
||||
// keep the query in sync with the selected tags in logs explorer page
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import {
|
||||
act,
|
||||
@@ -90,6 +91,11 @@ const renderWithContext = (props = {}): RenderResult => {
|
||||
);
|
||||
};
|
||||
|
||||
// Constants to fix linter errors
|
||||
const TYPE_TAG = 'tag';
|
||||
const IS_COLUMN_FALSE = false;
|
||||
const IS_JSON_FALSE = false;
|
||||
|
||||
const mockAggregateKeysData = {
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
@@ -97,9 +103,17 @@ const mockAggregateKeysData = {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
key: 'http.status',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
type: TYPE_TAG,
|
||||
id: 'http.status--string--tag--false',
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: TYPE_TAG,
|
||||
isColumn: IS_COLUMN_FALSE,
|
||||
isJSON: IS_JSON_FALSE,
|
||||
id: 'service.name--string--tag--false',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -125,6 +139,34 @@ jest.mock('hooks/queryBuilder/useGetAggregateValues', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the dynamic variables hook to test variable suggestion feature
|
||||
const mockDynamicVariables = [
|
||||
{
|
||||
id: 'var1',
|
||||
name: 'service',
|
||||
type: 'DYNAMIC',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
selectedValue: 'frontend',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
allSelected: false,
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
dashboardName: 'Test Dashboard',
|
||||
dashboardId: 'dashboard-123',
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock('hooks/dashboard/useGetDynamicVariables', () => ({
|
||||
useGetDynamicVariables: jest.fn(() => ({
|
||||
dynamicVariables: mockDynamicVariables,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
@@ -193,3 +235,66 @@ describe('Suggestion Key -> Operator -> Value Flow', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Variable Suggestions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should suggest dynamic variable when key matches a variable attribute', async () => {
|
||||
const { container } = renderWithContext();
|
||||
|
||||
// Get the combobox input
|
||||
const combobox = container.querySelector(
|
||||
'.query-builder-search-v2 .ant-select-selection-search-input',
|
||||
) as HTMLInputElement;
|
||||
|
||||
// Focus and type to trigger key suggestions for service.name
|
||||
await act(async () => {
|
||||
fireEvent.focus(combobox);
|
||||
fireEvent.change(combobox, { target: { value: 'service.' } });
|
||||
});
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
// Select service.name key from suggestions
|
||||
const serviceNameOption = await screen.findByText('service.name');
|
||||
await act(async () => {
|
||||
fireEvent.click(serviceNameOption);
|
||||
});
|
||||
|
||||
// Select equals operator
|
||||
await act(async () => {
|
||||
const equalsOption = screen.getByText('=');
|
||||
fireEvent.click(equalsOption);
|
||||
});
|
||||
|
||||
// Should show value suggestions including the dynamic variable
|
||||
// For 'service.name', we expect to see '$service' as the first suggestion
|
||||
const variableSuggestion = await screen.findByText('$service');
|
||||
expect(variableSuggestion).toBeInTheDocument();
|
||||
|
||||
// Regular values should still be shown
|
||||
expect(screen.getByText('200')).toBeInTheDocument();
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
|
||||
// Select the variable suggestion
|
||||
await act(async () => {
|
||||
fireEvent.click(variableSuggestion);
|
||||
});
|
||||
|
||||
// Verify the query was updated with the variable as value
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'service.name' }),
|
||||
op: '=',
|
||||
value: '$service',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
136
frontend/src/container/QueryTable/Drilldown/BreakoutOptions.tsx
Normal file
136
frontend/src/container/QueryTable/Drilldown/BreakoutOptions.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import './Breakoutoptions.styles.scss';
|
||||
|
||||
import { Input, Skeleton } from 'antd';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
|
||||
import { BreakoutOptionsProps } from './contextConfig';
|
||||
|
||||
function OptionsSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div className="breakout-options-skeleton">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakoutOptions({
|
||||
queryData,
|
||||
onColumnClick,
|
||||
}: BreakoutOptionsProps): JSX.Element {
|
||||
const { groupBy = [] } = queryData;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const debouncedSearchText = useDebounce(searchText, 400);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = e.target.value.trim().toLowerCase();
|
||||
setSearchText(value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Using getKeySuggestions directly like in QuerySearch
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'keySuggestions',
|
||||
queryData.dataSource,
|
||||
debouncedSearchText,
|
||||
queryData.aggregateAttribute?.key,
|
||||
],
|
||||
() =>
|
||||
getKeySuggestions({
|
||||
signal: queryData.dataSource,
|
||||
searchText: debouncedSearchText,
|
||||
metricName:
|
||||
(queryData.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
queryData.aggregateAttribute?.key,
|
||||
}),
|
||||
{
|
||||
enabled: !!queryData,
|
||||
},
|
||||
);
|
||||
|
||||
const breakoutOptions = useMemo(() => {
|
||||
if (!data?.data?.data?.keys) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { keys } = data.data.data;
|
||||
const transformedOptions: BaseAutocompleteData[] = [];
|
||||
|
||||
// Transform the response to match BaseAutocompleteData format
|
||||
Object.values(keys).forEach((keyArray) => {
|
||||
keyArray.forEach((keyData) => {
|
||||
transformedOptions.push({
|
||||
key: keyData.name,
|
||||
dataType: DataTypes.EMPTY,
|
||||
type: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Filter out already selected groupBy keys
|
||||
const groupByKeys = groupBy.map((item: BaseAutocompleteData) => item.key);
|
||||
return transformedOptions.filter(
|
||||
(item: BaseAutocompleteData) => !groupByKeys.includes(item.key),
|
||||
);
|
||||
}, [data, groupBy]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="search" style={{ padding: '8px 0' }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={searchText}
|
||||
placeholder="Search breakout options..."
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</section>
|
||||
<div>
|
||||
<OverlayScrollbar
|
||||
style={{ maxHeight: '200px' }}
|
||||
options={{
|
||||
overflow: {
|
||||
x: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
|
||||
<>
|
||||
{isFetching ? (
|
||||
<OptionsSkeleton />
|
||||
) : (
|
||||
breakoutOptions?.map((item: BaseAutocompleteData) => (
|
||||
<ContextMenu.Item
|
||||
key={item.key}
|
||||
onClick={(): void => onColumnClick(item)}
|
||||
>
|
||||
{item.key}
|
||||
</ContextMenu.Item>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BreakoutOptions;
|
||||
@@ -0,0 +1,7 @@
|
||||
.breakout-options-skeleton {
|
||||
.ant-skeleton-input {
|
||||
width: 100% !important;
|
||||
height: 20px !important;
|
||||
margin: 8px 5px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Button } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import useTableContextMenu from '../useTableContextMenu';
|
||||
import {
|
||||
MOCK_AGGREGATE_DATA,
|
||||
MOCK_COORDINATES,
|
||||
MOCK_FILTER_DATA,
|
||||
MOCK_KEY_SUGGESTIONS_RESPONSE,
|
||||
// MOCK_KEY_SUGGESTIONS_SEARCH_RESPONSE,
|
||||
MOCK_QUERY,
|
||||
} from './mockTableData';
|
||||
|
||||
// Mock the necessary hooks and dependencies
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/GridCardLayout/useResolveQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): any => ({
|
||||
getUpdatedQuery: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.DASHBOARD}/`,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: (): any => ({
|
||||
globalTime: {
|
||||
selectedTime: {
|
||||
startTime: 1713734400000,
|
||||
endTime: 1713738000000,
|
||||
},
|
||||
maxTime: 1713738000000,
|
||||
minTime: 1713734400000,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/QueryTable/Drilldown/useDashboardVarConfig', () => ({
|
||||
__esModule: true,
|
||||
default: (): any => ({
|
||||
dashbaordVariablesConfig: {
|
||||
items: <>items</>,
|
||||
},
|
||||
// contextItems: <></>,
|
||||
}),
|
||||
}));
|
||||
|
||||
function MockTableDrilldown(): JSX.Element {
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
|
||||
const { menuItemsConfig } = useTableContextMenu({
|
||||
widgetId: 'test-widget',
|
||||
query: MOCK_QUERY as Query,
|
||||
clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
});
|
||||
|
||||
const handleClick = (type: 'aggregate' | 'filter'): void => {
|
||||
// Simulate the same flow as handleColumnClick in QueryTable
|
||||
onClick(
|
||||
MOCK_COORDINATES,
|
||||
type === 'aggregate' ? MOCK_AGGREGATE_DATA : MOCK_FILTER_DATA,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Button type="primary" onClick={(): void => handleClick('aggregate')}>
|
||||
Aggregate
|
||||
</Button>
|
||||
|
||||
<Button type="primary" onClick={(): void => handleClick('filter')}>
|
||||
Filter
|
||||
</Button>
|
||||
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
onClose={onClose}
|
||||
items={menuItemsConfig.items}
|
||||
title={
|
||||
typeof menuItemsConfig.header === 'string'
|
||||
? menuItemsConfig.header
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderWithProviders = (
|
||||
component: React.ReactElement,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>{component}</Provider>
|
||||
</MemoryRouter>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
describe('TableDrilldown Breakout Functionality', () => {
|
||||
beforeEach((): void => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock the substitute_vars API that's causing network errors
|
||||
server.use(
|
||||
rest.post('*/api/v5/substitute_vars', (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show breakout options when "Breakout by" is clicked', async (): Promise<void> => {
|
||||
// Mock the MSW server to intercept the keySuggestions API call
|
||||
server.use(
|
||||
rest.get('*/fields/keys', (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(MOCK_KEY_SUGGESTIONS_RESPONSE)),
|
||||
),
|
||||
);
|
||||
|
||||
renderWithProviders(<MockTableDrilldown />);
|
||||
|
||||
// Find and click the aggregate button to show context menu
|
||||
const aggregateButton = screen.getByRole('button', { name: /aggregate/i });
|
||||
fireEvent.click(aggregateButton);
|
||||
|
||||
// Find and click "Breakout by" option
|
||||
const breakoutOption = screen.getByText(/Breakout by/);
|
||||
fireEvent.click(breakoutOption);
|
||||
|
||||
// Wait for the breakout options to load and verify they are displayed
|
||||
await screen.findByText('Breakout by');
|
||||
|
||||
// Check that the search input is displayed
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search breakout options...'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Wait for the API call to complete and options to load
|
||||
// Check what's actually being rendered instead of waiting for specific text
|
||||
await screen.findByText('deployment.environment');
|
||||
|
||||
// Check that the breakout options are loaded and displayed
|
||||
// Based on the test output, these are the actual options being rendered
|
||||
expect(screen.getByText('deployment.environment')).toBeInTheDocument();
|
||||
expect(screen.getByText('http.method')).toBeInTheDocument();
|
||||
expect(screen.getByText('http.status_code')).toBeInTheDocument();
|
||||
|
||||
// Verify that the breakout header is displayed
|
||||
expect(screen.getByText('Breakout by')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should add selected breakout option to groupBy and redirect with correct query', async (): Promise<void> => {
|
||||
// Mock the MSW server to intercept the keySuggestions API call
|
||||
server.use(
|
||||
rest.get('*/fields/keys', (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(MOCK_KEY_SUGGESTIONS_RESPONSE)),
|
||||
),
|
||||
);
|
||||
|
||||
renderWithProviders(<MockTableDrilldown />);
|
||||
|
||||
// Navigate to breakout options
|
||||
const aggregateButton = screen.getByRole('button', { name: /aggregate/i });
|
||||
fireEvent.click(aggregateButton);
|
||||
|
||||
const breakoutOption = screen.getByText(/Breakout by/);
|
||||
fireEvent.click(breakoutOption);
|
||||
|
||||
// Wait for breakout options to load
|
||||
await screen.findByText('deployment.environment');
|
||||
|
||||
// Click on a breakout option (e.g., deployment.environment)
|
||||
const breakoutOptionItem = screen.getByText('deployment.environment');
|
||||
fireEvent.click(breakoutOptionItem);
|
||||
|
||||
// Verify redirectWithQueryBuilderData was called
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [
|
||||
query,
|
||||
queryParams,
|
||||
,
|
||||
newTab,
|
||||
] = mockRedirectWithQueryBuilderData.mock.calls[0];
|
||||
|
||||
// Check that the query contains the correct structure
|
||||
expect(query.builder).toBeDefined();
|
||||
expect(query.builder.queryData).toBeDefined();
|
||||
|
||||
// Find the query data for the aggregate query (queryName: 'A')
|
||||
const aggregateQueryData = query.builder.queryData.find(
|
||||
(item: any) => item.queryName === 'A',
|
||||
);
|
||||
expect(aggregateQueryData).toBeDefined();
|
||||
|
||||
// Verify that the groupBy has been updated to only contain the selected breakout option
|
||||
expect(aggregateQueryData.groupBy).toHaveLength(1);
|
||||
expect(aggregateQueryData.groupBy[0].key).toEqual('deployment.environment');
|
||||
|
||||
// Verify that orderBy has been cleared (as per getBreakoutQuery logic)
|
||||
expect(aggregateQueryData.orderBy).toEqual([]);
|
||||
|
||||
// Verify that the legend has been updated (check the actual value being returned)
|
||||
// The legend logic in getBreakoutQuery: legend: item.legend && groupBy.key ? `{{${groupBy.key}}}` : ''
|
||||
// Since the original legend might be empty, the result could be empty string
|
||||
expect(aggregateQueryData.legend).toBeDefined();
|
||||
|
||||
// Check that the queryParams contain the expandedWidgetId
|
||||
expect(queryParams).toEqual({ expandedWidgetId: 'test-widget' });
|
||||
|
||||
// Check that newTab is true
|
||||
expect(newTab).toBe(true);
|
||||
|
||||
// Verify that the original filters are preserved and new filters are added
|
||||
expect(aggregateQueryData.filter.expression).toContain(
|
||||
"service.name in $service.name AND trace_id EXISTS AND deployment.environment = '$env'",
|
||||
);
|
||||
// The new filter from the clicked data should also be present
|
||||
expect(aggregateQueryData.filter.expression).toContain(
|
||||
"service.name = 'adservice' AND trace_id = 'df2cfb0e57bb8736207689851478cd50'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Button } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import useTableContextMenu from '../useTableContextMenu';
|
||||
import {
|
||||
MOCK_AGGREGATE_DATA,
|
||||
MOCK_COORDINATES,
|
||||
MOCK_FILTER_DATA,
|
||||
MOCK_QUERY,
|
||||
MOCK_QUERY_WITH_FILTER,
|
||||
} from './mockTableData';
|
||||
|
||||
// Mock the necessary hooks and dependencies
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.DASHBOARD}/`,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: (): any => ({
|
||||
globalTime: {
|
||||
selectedTime: {
|
||||
startTime: 1713734400000,
|
||||
endTime: 1713738000000,
|
||||
},
|
||||
maxTime: 1713738000000,
|
||||
minTime: 1713734400000,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/QueryTable/Drilldown/useDashboardVarConfig', () => ({
|
||||
__esModule: true,
|
||||
default: (): any => ({
|
||||
dashbaordVariablesConfig: {
|
||||
items: <>items</>,
|
||||
},
|
||||
// contextItems: <></>,
|
||||
}),
|
||||
}));
|
||||
|
||||
function MockTableDrilldown(): JSX.Element {
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
|
||||
const { menuItemsConfig } = useTableContextMenu({
|
||||
widgetId: 'test-widget',
|
||||
query: MOCK_QUERY as Query,
|
||||
clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
});
|
||||
|
||||
const handleClick = (type: 'aggregate' | 'filter'): void => {
|
||||
// Simulate the same flow as handleColumnClick in QueryTable
|
||||
onClick(
|
||||
MOCK_COORDINATES,
|
||||
type === 'aggregate' ? MOCK_AGGREGATE_DATA : MOCK_FILTER_DATA,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Button type="primary" onClick={(): void => handleClick('aggregate')}>
|
||||
Aggregate
|
||||
</Button>
|
||||
|
||||
<Button type="primary" onClick={(): void => handleClick('filter')}>
|
||||
Filter
|
||||
</Button>
|
||||
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
onClose={onClose}
|
||||
items={menuItemsConfig.items}
|
||||
title={
|
||||
typeof menuItemsConfig.header === 'string'
|
||||
? menuItemsConfig.header
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderWithProviders = (
|
||||
component: React.ReactElement,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>{component}</Provider>
|
||||
</MemoryRouter>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
describe('TableDrilldown', () => {
|
||||
beforeEach((): void => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show context menu filter options when button is clicked', (): void => {
|
||||
renderWithProviders(<MockTableDrilldown />);
|
||||
|
||||
// Find and click the button
|
||||
const button = screen.getByRole('button', { name: /filter/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
// Check that the context menu options are displayed
|
||||
expect(screen.getByText('Filter by trace_id')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show context menu aggregate options when button is clicked', (): void => {
|
||||
renderWithProviders(<MockTableDrilldown />);
|
||||
|
||||
// Find and click the button
|
||||
const button = screen.getByRole('button', { name: /aggregate/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
// Check that the context menu options are displayed
|
||||
expect(screen.getByText('logs')).toBeInTheDocument();
|
||||
expect(screen.getByText('count()')).toBeInTheDocument();
|
||||
expect(screen.getByText('View in Logs')).toBeInTheDocument();
|
||||
expect(screen.getByText('View in Traces')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Breakout by/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate to logs explorer with correct query when "View in Logs" is clicked', (): void => {
|
||||
renderWithProviders(<MockTableDrilldown />);
|
||||
|
||||
// Find and click the button to show context menu
|
||||
const button = screen.getByRole('button', { name: /aggregate/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
// Find and click "View in Logs" option
|
||||
const viewInLogsOption = screen.getByText('View in Logs');
|
||||
fireEvent.click(viewInLogsOption);
|
||||
|
||||
// Verify safeNavigate was called with the correct URL
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = mockSafeNavigate.mock.calls[0];
|
||||
|
||||
// Check the URL structure
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain('?');
|
||||
|
||||
// Parse the URL to check query parameters
|
||||
const urlObj = new URL(url, 'http://localhost');
|
||||
|
||||
// Check that compositeQuery parameter exists and contains the query with filters
|
||||
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
urlObj.searchParams.get('compositeQuery') || '{}',
|
||||
);
|
||||
|
||||
// Verify the query structure includes the filters from clicked data
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
|
||||
// Check that the query contains the correct filter expression
|
||||
// The filter should include the clicked data filters (service.name = 'adservice', trace_id = 'df2cfb0e57bb8736207689851478cd50')
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filters).toBeDefined();
|
||||
|
||||
// Check that newTab option is set to true
|
||||
expect(options).toEqual({ newTab: true });
|
||||
});
|
||||
|
||||
it('should navigate to traces explorer with correct query when "View in Traces" is clicked', (): void => {
|
||||
renderWithProviders(<MockTableDrilldown />);
|
||||
|
||||
// Find and click the button to show context menu
|
||||
const button = screen.getByRole('button', { name: /aggregate/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
// Find and click "View in Traces" option
|
||||
const viewInTracesOption = screen.getByText('View in Traces');
|
||||
fireEvent.click(viewInTracesOption);
|
||||
|
||||
// Verify safeNavigate was called with the correct URL
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = mockSafeNavigate.mock.calls[0];
|
||||
|
||||
// Check the URL structure
|
||||
expect(url).toContain(ROUTES.TRACES_EXPLORER);
|
||||
expect(url).toContain('?');
|
||||
|
||||
// Parse the URL to check query parameters
|
||||
const urlObj = new URL(url, 'http://localhost');
|
||||
|
||||
// Check that compositeQuery parameter exists and contains the query with filters
|
||||
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
urlObj.searchParams.get('compositeQuery') || '{}',
|
||||
);
|
||||
// Verify the query structure includes the filters from clicked data
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
|
||||
// Check that the query contains the correct filter expression
|
||||
// The filter should include the clicked data filters (service.name = 'adservice', trace_id = 'df2cfb0e57bb8736207689851478cd50')
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toEqual(MOCK_QUERY_WITH_FILTER);
|
||||
|
||||
// Check that newTab option is set to true
|
||||
expect(options).toEqual({ newTab: true });
|
||||
});
|
||||
|
||||
it('should show filter options and navigate with correct query when filter option is clicked', (): void => {
|
||||
renderWithProviders(<MockTableDrilldown />);
|
||||
|
||||
// Find and click the Filter button to show filter context menu
|
||||
const filterButton = screen.getByRole('button', { name: /filter/i });
|
||||
fireEvent.click(filterButton);
|
||||
|
||||
// Check that the filter context menu is displayed
|
||||
expect(screen.getByText('Filter by trace_id')).toBeInTheDocument();
|
||||
|
||||
// Check that the filter operators are displayed
|
||||
expect(screen.getByText('Is this')).toBeInTheDocument(); // = operator
|
||||
expect(screen.getByText('Is not this')).toBeInTheDocument(); // != operator
|
||||
|
||||
// Click on "Is this" (equals operator)
|
||||
const equalsOption = screen.getByText('Is this');
|
||||
fireEvent.click(equalsOption);
|
||||
|
||||
// Verify redirectWithQueryBuilderData was called instead of safeNavigate
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [
|
||||
query,
|
||||
queryParams,
|
||||
,
|
||||
newTab,
|
||||
] = mockRedirectWithQueryBuilderData.mock.calls[0];
|
||||
|
||||
// Check that the query contains the filter that was added
|
||||
expect(query.builder).toBeDefined();
|
||||
expect(query.builder.queryData).toBeDefined();
|
||||
|
||||
const firstQueryData = query.builder.queryData[0];
|
||||
|
||||
// The filter should include the original filter plus the new one from clicked data
|
||||
// Original: "service.name = '$service.name' AND trace_id EXISTS AND deployment.environment = '$env'"
|
||||
// New: trace_id = 'df2cfb0e57bb8736207689851478cd50'
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"service.name in $service.name AND trace_id EXISTS AND deployment.environment = '$env'",
|
||||
);
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"trace_id = 'df2cfb0e57bb8736207689851478cd50'",
|
||||
);
|
||||
|
||||
// Check that the queryParams contain the expandedWidgetId
|
||||
expect(queryParams).toEqual({ expandedWidgetId: 'test-widget' });
|
||||
|
||||
// Check that newTab is true
|
||||
expect(newTab).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
export default MockTableDrilldown;
|
||||
@@ -0,0 +1,407 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
getQueryData,
|
||||
getViewQuery,
|
||||
isValidQueryName,
|
||||
} from '../drilldownUtils';
|
||||
|
||||
// Mock the transformMetricsToLogsTraces function since it's not exported
|
||||
// We'll test it indirectly through getViewQuery
|
||||
describe('drilldownUtils', () => {
|
||||
describe('getQueryData', () => {
|
||||
it('should return the first query that matches the queryName', () => {
|
||||
const mockQuery: Query = {
|
||||
id: 'test-query',
|
||||
queryType: 'builder' as any,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'query1',
|
||||
dataSource: 'metrics' as any,
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
functions: [],
|
||||
legend: '',
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: undefined,
|
||||
orderBy: [],
|
||||
},
|
||||
{
|
||||
queryName: 'query2',
|
||||
dataSource: 'logs' as any,
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
functions: [],
|
||||
legend: '',
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: undefined,
|
||||
orderBy: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
};
|
||||
|
||||
const result = getQueryData(mockQuery, 'query2');
|
||||
expect(result?.queryName).toBe('query2');
|
||||
expect(result?.dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('should return undefined if no query matches the queryName', () => {
|
||||
const mockQuery: Query = {
|
||||
id: 'test-query',
|
||||
queryType: 'builder' as any,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'query1',
|
||||
dataSource: 'metrics' as any,
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
functions: [],
|
||||
legend: '',
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: undefined,
|
||||
orderBy: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
};
|
||||
|
||||
const result = getQueryData(mockQuery, 'nonexistent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidQueryName', () => {
|
||||
it('should return false for empty queryName', () => {
|
||||
expect(isValidQueryName('')).toBe(false);
|
||||
expect(isValidQueryName(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for queryName starting with F', () => {
|
||||
expect(isValidQueryName('F1')).toBe(false);
|
||||
expect(isValidQueryName('Formula1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for valid queryName', () => {
|
||||
expect(isValidQueryName('query1')).toBe(true);
|
||||
expect(isValidQueryName('metrics_query')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getViewQuery with metric-to-logs/traces transformations', () => {
|
||||
// Mock data for testing transformations
|
||||
const mockMetricsQuery: Query = {
|
||||
id: 'metrics-query',
|
||||
queryType: 'builder' as any,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'metrics_query',
|
||||
dataSource: 'metrics' as any,
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
functions: [],
|
||||
legend: '',
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: undefined,
|
||||
orderBy: [],
|
||||
filter: {
|
||||
expression:
|
||||
'operation = "GET" AND span.kind = SPAN_KIND_SERVER AND status.code = STATUS_CODE_OK',
|
||||
},
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
};
|
||||
|
||||
const mockFilters = [
|
||||
{ filterKey: 'service', filterValue: 'test-service', operator: '=' },
|
||||
];
|
||||
|
||||
it('should transform metrics query when drilling down to logs', () => {
|
||||
const result = getViewQuery(
|
||||
mockMetricsQuery,
|
||||
mockFilters,
|
||||
'view_logs',
|
||||
'metrics_query',
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.builder.queryData).toHaveLength(1);
|
||||
|
||||
// Check if the filter expression was transformed
|
||||
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||
expect(filterExpression).toBeDefined();
|
||||
|
||||
// Verify transformations were applied
|
||||
if (filterExpression) {
|
||||
// Rule 2: operation → name
|
||||
expect(filterExpression).toContain('name = "GET"');
|
||||
expect(filterExpression).not.toContain('operation = "GET"');
|
||||
|
||||
// Rule 3: span.kind → kind
|
||||
expect(filterExpression).toContain('kind = 2');
|
||||
expect(filterExpression).not.toContain('span.kind = SPAN_KIND_SERVER');
|
||||
|
||||
// Rule 4: status.code → status_code_string with value mapping
|
||||
expect(filterExpression).toContain("status_code_string = 'Ok'");
|
||||
expect(filterExpression).not.toContain('status.code = STATUS_CODE_OK');
|
||||
}
|
||||
});
|
||||
|
||||
it('should transform metrics query when drilling down to traces', () => {
|
||||
const result = getViewQuery(
|
||||
mockMetricsQuery,
|
||||
mockFilters,
|
||||
'view_traces',
|
||||
'metrics_query',
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.builder.queryData).toHaveLength(1);
|
||||
|
||||
// Check if the filter expression was transformed
|
||||
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||
expect(filterExpression).toBeDefined();
|
||||
|
||||
// Verify transformations were applied
|
||||
if (filterExpression) {
|
||||
// Rule 2: operation → name
|
||||
expect(filterExpression).toContain('name = "GET"');
|
||||
expect(filterExpression).not.toContain('operation = "GET"');
|
||||
|
||||
// Rule 3: span.kind → kind
|
||||
expect(filterExpression).toContain('kind = 2');
|
||||
expect(filterExpression).not.toContain('span.kind = SPAN_KIND_SERVER');
|
||||
|
||||
// Rule 4: status.code → status_code_string with value mapping
|
||||
expect(filterExpression).toContain("status_code_string = 'Ok'");
|
||||
expect(filterExpression).not.toContain('status.code = STATUS_CODE_OK');
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT transform metrics query when drilling down to metrics', () => {
|
||||
const result = getViewQuery(
|
||||
mockMetricsQuery,
|
||||
mockFilters,
|
||||
'view_metrics',
|
||||
'metrics_query',
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.builder.queryData).toHaveLength(1);
|
||||
|
||||
// Check that the filter expression was NOT transformed
|
||||
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||
expect(filterExpression).toBeDefined();
|
||||
|
||||
// Verify NO transformations were applied
|
||||
if (filterExpression) {
|
||||
// Should still contain original metric format
|
||||
expect(filterExpression).toContain('operation = "GET"');
|
||||
expect(filterExpression).toContain('span.kind = SPAN_KIND_SERVER');
|
||||
expect(filterExpression).toContain('status.code = STATUS_CODE_OK');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle complex filter expressions with multiple transformations', () => {
|
||||
const complexQuery: Query = {
|
||||
...mockMetricsQuery,
|
||||
builder: {
|
||||
...mockMetricsQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockMetricsQuery.builder.queryData[0],
|
||||
filter: {
|
||||
expression:
|
||||
'operation = "POST" AND span.kind = SPAN_KIND_CLIENT AND status.code = STATUS_CODE_ERROR AND http.status_code = 500',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getViewQuery(
|
||||
complexQuery,
|
||||
mockFilters,
|
||||
'view_logs',
|
||||
'metrics_query',
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||
|
||||
if (filterExpression) {
|
||||
// All transformations should be applied
|
||||
expect(filterExpression).toContain('name = "POST"');
|
||||
expect(filterExpression).toContain('kind = 3');
|
||||
expect(filterExpression).toContain("status_code_string = 'Error'");
|
||||
expect(filterExpression).toContain('http.status_code = 500');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle filter expressions with no transformations needed', () => {
|
||||
const simpleQuery: Query = {
|
||||
...mockMetricsQuery,
|
||||
builder: {
|
||||
...mockMetricsQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockMetricsQuery.builder.queryData[0],
|
||||
filter: {
|
||||
expression: 'service = "test-service" AND method = "GET"',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getViewQuery(
|
||||
simpleQuery,
|
||||
mockFilters,
|
||||
'view_logs',
|
||||
'metrics_query',
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||
|
||||
if (filterExpression) {
|
||||
// No transformations should be applied
|
||||
expect(filterExpression).toContain('service = "test-service"');
|
||||
expect(filterExpression).toContain('method = "GET"');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle all status code value mappings correctly', () => {
|
||||
const statusCodeTests = [
|
||||
{ input: 'STATUS_CODE_UNSET', expected: 'Unset' },
|
||||
{ input: 'STATUS_CODE_OK', expected: 'Ok' },
|
||||
{ input: 'STATUS_CODE_ERROR', expected: 'Error' },
|
||||
];
|
||||
|
||||
statusCodeTests.forEach(({ input, expected }) => {
|
||||
const testQuery: Query = {
|
||||
...mockMetricsQuery,
|
||||
builder: {
|
||||
...mockMetricsQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockMetricsQuery.builder.queryData[0],
|
||||
filter: {
|
||||
expression: `status.code = ${input}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getViewQuery(
|
||||
testQuery,
|
||||
mockFilters,
|
||||
'view_logs',
|
||||
'metrics_query',
|
||||
);
|
||||
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||
|
||||
expect(filterExpression).toContain(`status_code_string = '${expected}'`);
|
||||
expect(filterExpression).not.toContain(`status.code = ${input}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve non-metric attributes during transformation', () => {
|
||||
const mixedQuery: Query = {
|
||||
...mockMetricsQuery,
|
||||
builder: {
|
||||
...mockMetricsQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockMetricsQuery.builder.queryData[0],
|
||||
filter: {
|
||||
expression:
|
||||
'operation = "GET" AND service = "test-service" AND span.kind = SPAN_KIND_SERVER AND environment = "prod"',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getViewQuery(
|
||||
mixedQuery,
|
||||
mockFilters,
|
||||
'view_logs',
|
||||
'metrics_query',
|
||||
);
|
||||
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||
|
||||
if (filterExpression) {
|
||||
// Transformed attributes
|
||||
expect(filterExpression).toContain('name = "GET"');
|
||||
expect(filterExpression).toContain('kind = 2');
|
||||
|
||||
// Preserved non-metric attributes
|
||||
expect(filterExpression).toContain('service = "test-service"');
|
||||
expect(filterExpression).toContain('environment = "prod"');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle all span.kind value mappings correctly', () => {
|
||||
const spanKindTests = [
|
||||
{ input: 'SPAN_KIND_INTERNAL', expected: '1' },
|
||||
{ input: 'SPAN_KIND_CONSUMER', expected: '5' },
|
||||
{ input: 'SPAN_KIND_CLIENT', expected: '3' },
|
||||
{ input: 'SPAN_KIND_PRODUCER', expected: '4' },
|
||||
{ input: 'SPAN_KIND_SERVER', expected: '2' },
|
||||
];
|
||||
|
||||
spanKindTests.forEach(({ input, expected }) => {
|
||||
const testQuery: Query = {
|
||||
...mockMetricsQuery,
|
||||
builder: {
|
||||
...mockMetricsQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockMetricsQuery.builder.queryData[0],
|
||||
filter: {
|
||||
expression: `span.kind = ${input}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getViewQuery(
|
||||
testQuery,
|
||||
mockFilters,
|
||||
'view_logs',
|
||||
'metrics_query',
|
||||
);
|
||||
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
|
||||
|
||||
expect(filterExpression).toContain(`kind = ${expected}`);
|
||||
expect(filterExpression).not.toContain(`span.kind = ${input}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export const MOCK_COORDINATES = {
|
||||
x: 996,
|
||||
y: 421,
|
||||
};
|
||||
|
||||
export const MOCK_AGGREGATE_DATA = {
|
||||
record: {
|
||||
'service.name': 'adservice',
|
||||
trace_id: 'df2cfb0e57bb8736207689851478cd50',
|
||||
A: 3,
|
||||
},
|
||||
column: {
|
||||
dataIndex: 'A',
|
||||
title: 'count()',
|
||||
width: 145,
|
||||
isValueColumn: true,
|
||||
queryName: 'A',
|
||||
},
|
||||
tableColumns: [
|
||||
{
|
||||
dataIndex: 'service.name',
|
||||
title: 'service.name',
|
||||
width: 145,
|
||||
isValueColumn: false,
|
||||
queryName: 'A',
|
||||
},
|
||||
{
|
||||
dataIndex: 'trace_id',
|
||||
title: 'trace_id',
|
||||
width: 145,
|
||||
isValueColumn: false,
|
||||
queryName: 'A',
|
||||
},
|
||||
{
|
||||
dataIndex: 'A',
|
||||
title: 'count()',
|
||||
width: 145,
|
||||
isValueColumn: true,
|
||||
queryName: 'A',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MOCK_QUERY_WITH_FILTER =
|
||||
"service.name in $service.name AND trace_id EXISTS AND deployment.environment = '$env' service.name = 'adservice' AND trace_id = 'df2cfb0e57bb8736207689851478cd50'";
|
||||
|
||||
export const MOCK_FILTER_DATA = {
|
||||
record: {
|
||||
'service.name': 'adservice',
|
||||
trace_id: 'df2cfb0e57bb8736207689851478cd50',
|
||||
A: 3,
|
||||
},
|
||||
column: {
|
||||
dataIndex: 'trace_id',
|
||||
title: 'trace_id',
|
||||
width: 145,
|
||||
isValueColumn: false,
|
||||
queryName: 'A',
|
||||
},
|
||||
tableColumns: [
|
||||
{
|
||||
dataIndex: 'service.name',
|
||||
title: 'service.name',
|
||||
width: 145,
|
||||
isValueColumn: false,
|
||||
queryName: 'A',
|
||||
},
|
||||
{
|
||||
dataIndex: 'trace_id',
|
||||
title: 'trace_id',
|
||||
width: 145,
|
||||
isValueColumn: false,
|
||||
queryName: 'A',
|
||||
},
|
||||
{
|
||||
dataIndex: 'A',
|
||||
title: 'count()',
|
||||
width: 145,
|
||||
isValueColumn: true,
|
||||
queryName: 'A',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MOCK_QUERY = {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregations: [
|
||||
{
|
||||
expression: 'count()',
|
||||
},
|
||||
],
|
||||
dataSource: DataSource.LOGS,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression:
|
||||
"service.name in $service.name AND trace_id EXISTS AND deployment.environment = '$env'",
|
||||
},
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: 'string',
|
||||
id: 'service.name--string--resource--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
id: 'trace_id--string----true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
key: 'trace_id',
|
||||
},
|
||||
],
|
||||
having: {
|
||||
expression: '',
|
||||
},
|
||||
havingExpression: {
|
||||
expression: '',
|
||||
},
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
stepInterval: 60,
|
||||
},
|
||||
{
|
||||
aggregations: [
|
||||
{
|
||||
expression: 'count()',
|
||||
},
|
||||
],
|
||||
dataSource: 'logs',
|
||||
disabled: true,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: '',
|
||||
},
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: 'string',
|
||||
id: 'service.name--string--resource--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
},
|
||||
],
|
||||
having: {
|
||||
expression: '',
|
||||
},
|
||||
havingExpression: {
|
||||
expression: '',
|
||||
},
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'B',
|
||||
stepInterval: 60,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '6092c3fd-6877-4cb8-836a-7f30db4e4bfe',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MOCK_KEY_SUGGESTIONS_RESPONSE = {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
resource: [
|
||||
{
|
||||
name: 'service.name',
|
||||
label: 'Service Name',
|
||||
type: 'resource',
|
||||
signal: 'logs',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
label: 'Environment',
|
||||
type: 'resource',
|
||||
signal: 'logs',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
attribute: [
|
||||
{
|
||||
name: 'http.method',
|
||||
label: 'HTTP Method',
|
||||
type: 'attribute',
|
||||
signal: 'logs',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{
|
||||
name: 'http.status_code',
|
||||
label: 'HTTP Status Code',
|
||||
type: 'attribute',
|
||||
signal: 'logs',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_KEY_SUGGESTIONS_SEARCH_RESPONSE = {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
resource: [
|
||||
{
|
||||
name: 'service.name',
|
||||
label: 'Service Name',
|
||||
type: 'resource',
|
||||
signal: 'logs',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
label: 'Environment',
|
||||
type: 'resource',
|
||||
signal: 'logs',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_KEY_SUGGESTIONS_SINGLE_RESPONSE = {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
resource: [
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
label: 'Environment',
|
||||
type: 'resource',
|
||||
signal: 'logs',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
153
frontend/src/container/QueryTable/Drilldown/contextConfig.tsx
Normal file
153
frontend/src/container/QueryTable/Drilldown/contextConfig.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
PANEL_TYPES,
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
|
||||
import { ReactNode } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import BreakoutOptions from './BreakoutOptions';
|
||||
import {
|
||||
getAggregateColumnHeader,
|
||||
getBaseMeta,
|
||||
getQueryData,
|
||||
} from './drilldownUtils';
|
||||
import { AGGREGATE_OPTIONS, SUPPORTED_OPERATORS } from './menuOptions';
|
||||
import { getBreakoutQuery } from './tableDrilldownUtils';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
export type ContextMenuItem = ReactNode;
|
||||
|
||||
export enum ConfigType {
|
||||
GROUP = 'group',
|
||||
AGGREGATE = 'aggregate',
|
||||
}
|
||||
|
||||
export interface ContextMenuConfigParams {
|
||||
configType: ConfigType;
|
||||
query: Query;
|
||||
clickedData: ClickedData;
|
||||
panelType?: string;
|
||||
onColumnClick: (key: string, query?: Query) => void;
|
||||
subMenu?: string;
|
||||
}
|
||||
|
||||
export interface GroupContextMenuConfig {
|
||||
header?: string;
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
export interface AggregateContextMenuConfig {
|
||||
header?: string | ReactNode;
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
export interface BreakoutOptionsProps {
|
||||
queryData: IBuilderQuery;
|
||||
onColumnClick: (groupBy: BaseAutocompleteData) => void;
|
||||
}
|
||||
|
||||
export function getGroupContextMenuConfig({
|
||||
query,
|
||||
clickedData,
|
||||
panelType,
|
||||
onColumnClick,
|
||||
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
|
||||
const filterKey = clickedData?.column?.dataIndex;
|
||||
|
||||
const filterDataType =
|
||||
getBaseMeta(query, filterKey as string)?.dataType || 'string';
|
||||
|
||||
const operators =
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
];
|
||||
|
||||
const filterOperators = operators.filter(
|
||||
(operator) => SUPPORTED_OPERATORS[operator],
|
||||
);
|
||||
|
||||
if (panelType === PANEL_TYPES.TABLE && clickedData?.column) {
|
||||
return {
|
||||
items: (
|
||||
<>
|
||||
<ContextMenu.Header>
|
||||
<div>Filter by {filterKey}</div>
|
||||
</ContextMenu.Header>
|
||||
{filterOperators.map((operator) => (
|
||||
<ContextMenu.Item
|
||||
key={operator}
|
||||
icon={SUPPORTED_OPERATORS[operator].icon}
|
||||
onClick={(): void => onColumnClick(SUPPORTED_OPERATORS[operator].value)}
|
||||
>
|
||||
{SUPPORTED_OPERATORS[operator].label}
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getAggregateContextMenuConfig({
|
||||
subMenu,
|
||||
query,
|
||||
onColumnClick,
|
||||
aggregateData,
|
||||
}: {
|
||||
subMenu?: string;
|
||||
query: Query;
|
||||
onColumnClick: (key: string, query?: Query) => void;
|
||||
aggregateData: AggregateData | null;
|
||||
}): AggregateContextMenuConfig {
|
||||
if (subMenu === 'breakout') {
|
||||
const queryData = getQueryData(query, aggregateData?.queryName || '');
|
||||
return {
|
||||
header: 'Breakout by',
|
||||
items: (
|
||||
<BreakoutOptions
|
||||
queryData={queryData}
|
||||
onColumnClick={(groupBy: BaseAutocompleteData): void => {
|
||||
// Use aggregateData.filters
|
||||
const filtersToAdd = aggregateData?.filters || [];
|
||||
const breakoutQuery = getBreakoutQuery(
|
||||
query,
|
||||
aggregateData,
|
||||
groupBy,
|
||||
filtersToAdd,
|
||||
);
|
||||
onColumnClick('breakout', breakoutQuery);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Use aggregateData.queryName
|
||||
const queryName = aggregateData?.queryName;
|
||||
const { dataSource, aggregations } = getAggregateColumnHeader(
|
||||
query,
|
||||
queryName as string,
|
||||
);
|
||||
|
||||
return {
|
||||
header: (
|
||||
<div>
|
||||
<div style={{ textTransform: 'capitalize' }}>{dataSource}</div>
|
||||
<div>{aggregations}</div>
|
||||
</div>
|
||||
),
|
||||
items: AGGREGATE_OPTIONS.map(({ key, label, icon }) => (
|
||||
<ContextMenu.Item
|
||||
key={key}
|
||||
icon={icon}
|
||||
onClick={(): void => onColumnClick(key)}
|
||||
>
|
||||
{label}
|
||||
</ContextMenu.Item>
|
||||
)),
|
||||
};
|
||||
}
|
||||
473
frontend/src/container/QueryTable/Drilldown/drilldownUtils.tsx
Normal file
473
frontend/src/container/QueryTable/Drilldown/drilldownUtils.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import { PieArcDatum } from '@visx/shape/lib/shapes/Pie';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
OPERATORS,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export function getBaseMeta(
|
||||
query: Query,
|
||||
filterKey: string,
|
||||
): BaseAutocompleteData | null {
|
||||
const steps = query.builder.queryData;
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const { groupBy = [] } = steps[i];
|
||||
for (let j = 0; j < groupBy.length; j++) {
|
||||
if (groupBy[j].key === filterKey) {
|
||||
return groupBy[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getRoute = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'view_logs':
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case 'view_metrics':
|
||||
return ROUTES.METRICS_EXPLORER;
|
||||
case 'view_traces':
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const isNumberDataType = (dataType: DataTypes | undefined): boolean => {
|
||||
if (!dataType) return false;
|
||||
return dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
|
||||
};
|
||||
|
||||
export interface FilterData {
|
||||
filterKey: string;
|
||||
filterValue: string | number;
|
||||
operator: string;
|
||||
}
|
||||
|
||||
// Helper function to avoid code duplication
|
||||
function addFiltersToQuerySteps(
|
||||
query: Query,
|
||||
filters: FilterData[],
|
||||
queryName?: string,
|
||||
): Query {
|
||||
// 1) clone so we don't mutate the original
|
||||
const q = cloneDeep(query);
|
||||
|
||||
// 2) map over builder.queryData to return a new modified version
|
||||
q.builder.queryData = q.builder.queryData.map((step) => {
|
||||
// Only modify the step that matches the queryName (if provided)
|
||||
if (queryName && step.queryName !== queryName) {
|
||||
return step;
|
||||
}
|
||||
|
||||
// 3) build the new filters array
|
||||
const newFilters = {
|
||||
...step.filters,
|
||||
op: step?.filters?.op || 'AND',
|
||||
items: [...(step?.filters?.items || [])],
|
||||
};
|
||||
|
||||
// Add each filter to the items array
|
||||
filters.forEach(({ filterKey, filterValue, operator }) => {
|
||||
// skip if this step doesn't group by our key
|
||||
const baseMeta = step.groupBy.find((g) => g.key === filterKey);
|
||||
if (!baseMeta) return;
|
||||
|
||||
newFilters.items.push({
|
||||
id: uuid(),
|
||||
key: baseMeta,
|
||||
op: operator,
|
||||
value: filterValue,
|
||||
});
|
||||
});
|
||||
|
||||
const resolvedFilters = convertFiltersToExpressionWithExistingQuery(
|
||||
newFilters,
|
||||
step.filter?.expression,
|
||||
);
|
||||
|
||||
// 4) return a new step object with updated filters
|
||||
return {
|
||||
...step,
|
||||
...resolvedFilters,
|
||||
};
|
||||
});
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
export function addFilterToQuery(query: Query, filters: FilterData[]): Query {
|
||||
return addFiltersToQuerySteps(query, filters);
|
||||
}
|
||||
|
||||
export const addFilterToSelectedQuery = (
|
||||
query: Query,
|
||||
filters: FilterData[],
|
||||
queryName: string,
|
||||
): Query => addFiltersToQuerySteps(query, filters, queryName);
|
||||
|
||||
export const getAggregateColumnHeader = (
|
||||
query: Query,
|
||||
queryName: string,
|
||||
): { dataSource: string; aggregations: string } => {
|
||||
// Find the query step with the matching queryName
|
||||
const queryStep = query?.builder?.queryData.find(
|
||||
(step) => step.queryName === queryName,
|
||||
);
|
||||
|
||||
if (!queryStep) {
|
||||
return { dataSource: '', aggregations: '' };
|
||||
}
|
||||
|
||||
const { dataSource, aggregations } = queryStep; // TODO: check if this is correct
|
||||
|
||||
// Extract aggregation expressions based on data source type
|
||||
let aggregationExpressions: string[] = [];
|
||||
|
||||
if (aggregations && aggregations.length > 0) {
|
||||
if (dataSource === 'metrics') {
|
||||
// For metrics, construct expression from spaceAggregation(metricName)
|
||||
aggregationExpressions = aggregations.map((agg: any) => {
|
||||
const { spaceAggregation, metricName } = agg;
|
||||
return `${spaceAggregation}(${metricName})`;
|
||||
});
|
||||
} else {
|
||||
// For traces and logs, use the expression field directly
|
||||
aggregationExpressions = aggregations.map((agg: any) => agg.expression);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
aggregations: aggregationExpressions.join(', '),
|
||||
};
|
||||
};
|
||||
|
||||
const getFiltersFromMetric = (metric: any): FilterData[] =>
|
||||
Object.keys(metric).map((key) => ({
|
||||
filterKey: key,
|
||||
filterValue: metric[key],
|
||||
operator: OPERATORS['='],
|
||||
}));
|
||||
|
||||
export const getUplotClickData = ({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
}: {
|
||||
metric?: { [key: string]: string };
|
||||
queryData?: { queryName: string; inFocusOrNot: boolean };
|
||||
absoluteMouseX: number;
|
||||
absoluteMouseY: number;
|
||||
focusedSeries?: {
|
||||
seriesIndex: number;
|
||||
seriesName: string;
|
||||
value: number;
|
||||
color: string;
|
||||
show: boolean;
|
||||
isFocused: boolean;
|
||||
} | null;
|
||||
}): {
|
||||
coord: { x: number; y: number };
|
||||
record: { queryName: string; filters: FilterData[] };
|
||||
label: string | React.ReactNode;
|
||||
} | null => {
|
||||
if (!queryData?.queryName || !metric) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = {
|
||||
queryName: queryData.queryName,
|
||||
filters: getFiltersFromMetric(metric),
|
||||
};
|
||||
|
||||
// Generate label from focusedSeries data
|
||||
let label: string | React.ReactNode = '';
|
||||
if (focusedSeries && focusedSeries.seriesName) {
|
||||
label = (
|
||||
<span style={{ color: focusedSeries.color }}>
|
||||
{focusedSeries.seriesName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
coord: {
|
||||
x: absoluteMouseX,
|
||||
y: absoluteMouseY,
|
||||
},
|
||||
record,
|
||||
label,
|
||||
};
|
||||
};
|
||||
|
||||
export const getPieChartClickData = (
|
||||
arc: PieArcDatum<{
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: any;
|
||||
}>,
|
||||
): {
|
||||
queryName: string;
|
||||
filters: FilterData[];
|
||||
label: string | React.ReactNode;
|
||||
} | null => {
|
||||
const { metric, queryName } = arc.data.record;
|
||||
if (!queryName || !metric) return null;
|
||||
|
||||
const label = <span style={{ color: arc.data.color }}>{arc.data.label}</span>;
|
||||
return {
|
||||
queryName,
|
||||
filters: getFiltersFromMetric(metric), // TODO: add where clause query as well.
|
||||
label,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the query data that matches the aggregate data's queryName
|
||||
*/
|
||||
export const getQueryData = (
|
||||
query: Query,
|
||||
queryName: string,
|
||||
): IBuilderQuery => {
|
||||
const queryData = query?.builder?.queryData?.filter(
|
||||
(item: IBuilderQuery) => item.queryName === queryName,
|
||||
);
|
||||
return queryData[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a query name is valid for drilldown operations
|
||||
* Returns false if queryName is empty or starts with 'F'
|
||||
* Note: Checking if queryName starts with 'F' is a hack to know if it's a Formulae based query
|
||||
*/
|
||||
export const isValidQueryName = (queryName: string): boolean => {
|
||||
if (!queryName || queryName.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
return !queryName.startsWith('F');
|
||||
};
|
||||
|
||||
const VIEW_QUERY_MAP: Record<string, IBuilderQuery> = {
|
||||
view_logs: initialQueryBuilderFormValuesMap.logs,
|
||||
view_metrics: initialQueryBuilderFormValuesMap.metrics,
|
||||
view_traces: initialQueryBuilderFormValuesMap.traces,
|
||||
};
|
||||
|
||||
/**
|
||||
* TEMP LOGIC - REMOVE AFTER TESTING
|
||||
* Transforms metric query filters to logs/traces format
|
||||
* Applies the following transformations:
|
||||
* - Rule 2: operation → name
|
||||
* - Rule 3: span.kind → kind
|
||||
* - Rule 4: status.code → status_code_string with value mapping
|
||||
* - Rule 5: http.status_code type conversion
|
||||
*/
|
||||
const transformMetricsToLogsTraces = (
|
||||
filterExpression: string | undefined,
|
||||
): string | undefined => {
|
||||
if (!filterExpression) return filterExpression;
|
||||
|
||||
// ===========================================
|
||||
// MAPPING OBJECTS - ALL TRANSFORMATIONS DEFINED HERE
|
||||
// ===========================================
|
||||
const METRIC_TO_LOGS_TRACES_MAPPINGS = {
|
||||
// Rule 2: operation → name
|
||||
attributeRenames: {
|
||||
operation: 'name',
|
||||
},
|
||||
|
||||
// Rule 3: span.kind → kind with value mapping
|
||||
spanKindMapping: {
|
||||
attribute: 'span.kind',
|
||||
newAttribute: 'kind',
|
||||
valueMappings: {
|
||||
SPAN_KIND_INTERNAL: '1',
|
||||
SPAN_KIND_SERVER: '2',
|
||||
SPAN_KIND_CLIENT: '3',
|
||||
SPAN_KIND_PRODUCER: '4',
|
||||
SPAN_KIND_CONSUMER: '5',
|
||||
},
|
||||
},
|
||||
|
||||
// Rule 4: status.code → status_code_string with value mapping
|
||||
statusCodeMapping: {
|
||||
attribute: 'status.code',
|
||||
newAttribute: 'status_code_string',
|
||||
valueMappings: {
|
||||
// From metrics format → To logs/traces format
|
||||
STATUS_CODE_UNSET: 'Unset',
|
||||
STATUS_CODE_OK: 'Ok',
|
||||
STATUS_CODE_ERROR: 'Error',
|
||||
},
|
||||
},
|
||||
|
||||
// Rule 5: http.status_code type conversion
|
||||
typeConversions: {
|
||||
'http.status_code': 'number',
|
||||
},
|
||||
};
|
||||
// ===========================================
|
||||
|
||||
let transformedExpression = filterExpression;
|
||||
|
||||
// Apply attribute renames
|
||||
Object.entries(METRIC_TO_LOGS_TRACES_MAPPINGS.attributeRenames).forEach(
|
||||
([oldAttr, newAttr]) => {
|
||||
const regex = new RegExp(`\\b${oldAttr}\\b`, 'g');
|
||||
transformedExpression = transformedExpression.replace(regex, newAttr);
|
||||
},
|
||||
);
|
||||
|
||||
// Apply span.kind → kind transformation
|
||||
const { spanKindMapping } = METRIC_TO_LOGS_TRACES_MAPPINGS;
|
||||
if (spanKindMapping) {
|
||||
// Replace attribute name - use word boundaries to avoid partial matches
|
||||
const attrRegex = new RegExp(
|
||||
`\\b${spanKindMapping.attribute.replace(/\./g, '\\.')}\\b`,
|
||||
'g',
|
||||
);
|
||||
transformedExpression = transformedExpression.replace(
|
||||
attrRegex,
|
||||
spanKindMapping.newAttribute,
|
||||
);
|
||||
|
||||
// Replace values
|
||||
Object.entries(spanKindMapping.valueMappings).forEach(
|
||||
([oldValue, newValue]) => {
|
||||
const valueRegex = new RegExp(`\\b${oldValue}\\b`, 'g');
|
||||
transformedExpression = transformedExpression.replace(valueRegex, newValue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Apply status.code → status_code_string transformation
|
||||
const { statusCodeMapping } = METRIC_TO_LOGS_TRACES_MAPPINGS;
|
||||
if (statusCodeMapping) {
|
||||
// Replace attribute name - use word boundaries to avoid partial matches
|
||||
// This prevents http.status_code from being transformed
|
||||
const attrRegex = new RegExp(
|
||||
`\\b${statusCodeMapping.attribute.replace(/\./g, '\\.')}\\b`,
|
||||
'g',
|
||||
);
|
||||
transformedExpression = transformedExpression.replace(
|
||||
attrRegex,
|
||||
statusCodeMapping.newAttribute,
|
||||
);
|
||||
|
||||
// Replace values
|
||||
Object.entries(statusCodeMapping.valueMappings).forEach(
|
||||
([oldValue, newValue]) => {
|
||||
const valueRegex = new RegExp(`\\b${oldValue}\\b`, 'g');
|
||||
transformedExpression = transformedExpression.replace(
|
||||
valueRegex,
|
||||
`'${newValue}'`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Note: Type conversions (Rule 5) would need more complex parsing
|
||||
// of the filter expression to implement properly
|
||||
|
||||
return transformedExpression;
|
||||
};
|
||||
|
||||
export const getViewQuery = (
|
||||
query: Query,
|
||||
filtersToAdd: FilterData[],
|
||||
key: string,
|
||||
queryName: string,
|
||||
): Query | null => {
|
||||
const newQuery = cloneDeep(query);
|
||||
|
||||
const queryBuilderData = VIEW_QUERY_MAP[key];
|
||||
|
||||
if (!queryBuilderData) return null;
|
||||
|
||||
let existingFilters: TagFilterItem[] = [];
|
||||
let existingFilterExpression: string | undefined;
|
||||
if (queryName) {
|
||||
const queryData = getQueryData(query, queryName);
|
||||
existingFilters = queryData?.filters?.items || [];
|
||||
existingFilterExpression = queryData?.filter?.expression;
|
||||
}
|
||||
|
||||
newQuery.builder.queryData = [queryBuilderData];
|
||||
|
||||
const filters = filtersToAdd.reduce((acc: any[], filter) => {
|
||||
// use existing query to get baseMeta
|
||||
const baseMeta = getBaseMeta(query, filter.filterKey);
|
||||
if (!baseMeta) return acc;
|
||||
|
||||
acc.push({
|
||||
id: uuid(),
|
||||
key: baseMeta,
|
||||
op: filter.operator,
|
||||
value: filter.filterValue,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const allFilters = [...existingFilters, ...filters];
|
||||
|
||||
const {
|
||||
// filters: newFilters,
|
||||
filter: newFilterExpression,
|
||||
} = convertFiltersToExpressionWithExistingQuery(
|
||||
{
|
||||
items: allFilters,
|
||||
op: 'AND',
|
||||
},
|
||||
existingFilterExpression,
|
||||
);
|
||||
|
||||
// newQuery.builder.queryData[0].filters = newFilters;
|
||||
|
||||
newQuery.builder.queryData[0].filter = newFilterExpression;
|
||||
|
||||
try {
|
||||
// ===========================================
|
||||
// TEMP LOGIC - TO BE REMOVED LATER
|
||||
// ===========================================
|
||||
// Apply metric-to-logs/traces transformations
|
||||
if (key === 'view_logs' || key === 'view_traces') {
|
||||
const transformedExpression = transformMetricsToLogsTraces(
|
||||
newFilterExpression?.expression,
|
||||
);
|
||||
newQuery.builder.queryData[0].filter = {
|
||||
expression: transformedExpression || '',
|
||||
};
|
||||
}
|
||||
// ===========================================
|
||||
} catch (error) {
|
||||
console.error('Error transforming metrics to logs/traces:', error);
|
||||
}
|
||||
|
||||
return newQuery;
|
||||
};
|
||||
|
||||
export function isDrilldownEnabled(): boolean {
|
||||
return true;
|
||||
// temp code
|
||||
// if (typeof window === 'undefined') return false;
|
||||
// const drilldownValue = window.localStorage.getItem('drilldown');
|
||||
// return drilldownValue === 'true';
|
||||
}
|
||||
114
frontend/src/container/QueryTable/Drilldown/menuOptions.tsx
Normal file
114
frontend/src/container/QueryTable/Drilldown/menuOptions.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { Braces, ChartBar, DraftingCompass, ScrollText } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Supported operators for filtering with their display properties
|
||||
*/
|
||||
export const SUPPORTED_OPERATORS = {
|
||||
[OPERATORS['=']]: {
|
||||
label: 'Is this',
|
||||
icon: '=',
|
||||
value: '=',
|
||||
},
|
||||
[OPERATORS['!=']]: {
|
||||
label: 'Is not this',
|
||||
icon: '!=',
|
||||
value: '!=',
|
||||
},
|
||||
[OPERATORS['>=']]: {
|
||||
label: 'Is greater than or equal to',
|
||||
icon: '>=',
|
||||
value: '>=',
|
||||
},
|
||||
[OPERATORS['<=']]: {
|
||||
label: 'Is less than or equal to',
|
||||
icon: '<=',
|
||||
value: '<=',
|
||||
},
|
||||
[OPERATORS['<']]: {
|
||||
label: 'Is less than',
|
||||
icon: '<',
|
||||
value: '<',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate menu options for different views
|
||||
*/
|
||||
// TO REMOVE
|
||||
export const AGGREGATE_OPTIONS = [
|
||||
{
|
||||
key: 'view_logs',
|
||||
icon: <ScrollText size={16} />,
|
||||
label: 'View in Logs',
|
||||
},
|
||||
// {
|
||||
// key: 'view_metrics',
|
||||
// icon: <BarChart2 size={16} />,
|
||||
// label: 'View in Metrics',
|
||||
// },
|
||||
{
|
||||
key: 'view_traces',
|
||||
icon: <DraftingCompass size={16} />,
|
||||
label: 'View in Traces',
|
||||
},
|
||||
{
|
||||
key: 'breakout',
|
||||
icon: <ChartBar size={16} />,
|
||||
label: 'Breakout by ..',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Aggregate menu options for different views
|
||||
*/
|
||||
export const getBaseContextConfig = ({
|
||||
handleBaseDrilldown,
|
||||
setSubMenu,
|
||||
showDashboardVariablesOption,
|
||||
showBreakoutOption,
|
||||
}: {
|
||||
handleBaseDrilldown: (key: string) => void;
|
||||
setSubMenu: (subMenu: string) => void;
|
||||
showDashboardVariablesOption: boolean;
|
||||
showBreakoutOption: boolean;
|
||||
}): {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
hidden?: boolean;
|
||||
}[] => [
|
||||
{
|
||||
key: 'dashboard_variables',
|
||||
icon: <Braces size={16} />,
|
||||
label: 'Dashboard Variables',
|
||||
onClick: (): void => setSubMenu('dashboard_variables'),
|
||||
hidden: !showDashboardVariablesOption,
|
||||
},
|
||||
{
|
||||
key: 'view_logs',
|
||||
icon: <ScrollText size={16} />,
|
||||
label: 'View in Logs',
|
||||
onClick: (): void => handleBaseDrilldown('view_logs'),
|
||||
},
|
||||
// {
|
||||
// key: 'view_metrics',
|
||||
// icon: <BarChart2 size={16} />,
|
||||
// label: 'View in Metrics',
|
||||
// onClick: () => handleBaseDrilldown('view_metrics'),
|
||||
// },
|
||||
{
|
||||
key: 'view_traces',
|
||||
icon: <DraftingCompass size={16} />,
|
||||
label: 'View in Traces',
|
||||
onClick: (): void => handleBaseDrilldown('view_traces'),
|
||||
},
|
||||
{
|
||||
key: 'breakout',
|
||||
icon: <ChartBar size={16} />,
|
||||
label: 'Breakout by ..',
|
||||
onClick: (): void => setSubMenu('breakout'),
|
||||
hidden: !showBreakoutOption,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,92 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { addFilterToSelectedQuery, FilterData } from './drilldownUtils';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
export const isEmptyFilterValue = (value: any): boolean =>
|
||||
value === '' || value === null || value === undefined || value === 'n/a';
|
||||
|
||||
/**
|
||||
* Creates filters to add to the query from table columns for view mode navigation
|
||||
*/
|
||||
export const getFiltersToAddToView = (clickedData: any): FilterData[] => {
|
||||
if (!clickedData) {
|
||||
console.warn('clickedData is null in getFiltersToAddToView');
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
clickedData?.tableColumns
|
||||
?.filter((col: any) => !col.isValueColumn)
|
||||
.reduce((acc: FilterData[], col: any) => {
|
||||
// only add table col which have isValueColumn false. and the filter value suffices the isEmptyFilterValue condition.
|
||||
const { dataIndex } = col;
|
||||
if (!dataIndex || typeof dataIndex !== 'string') return acc;
|
||||
if (
|
||||
clickedData?.column?.isValueColumn &&
|
||||
isEmptyFilterValue(clickedData?.record?.[dataIndex])
|
||||
)
|
||||
return acc;
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
filterKey: dataIndex,
|
||||
filterValue: clickedData?.record?.[dataIndex] || '',
|
||||
operator: OPERATORS['='],
|
||||
},
|
||||
];
|
||||
}, []) || []
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a breakout query by adding filters and updating the groupBy
|
||||
*/
|
||||
export const getBreakoutQuery = (
|
||||
query: Query,
|
||||
aggregateData: AggregateData | null,
|
||||
groupBy: BaseAutocompleteData,
|
||||
filtersToAdd: FilterData[],
|
||||
): Query => {
|
||||
if (!aggregateData) {
|
||||
console.warn('aggregateData is null in getBreakoutQuery');
|
||||
return query;
|
||||
}
|
||||
|
||||
const queryWithFilters = addFilterToSelectedQuery(
|
||||
query,
|
||||
filtersToAdd,
|
||||
aggregateData.queryName,
|
||||
);
|
||||
const newQuery = cloneDeep(queryWithFilters);
|
||||
|
||||
// OLD LOGIC - Preserved all queries, modified only the matching one
|
||||
// newQuery.builder.queryData = newQuery.builder.queryData.map(
|
||||
// (item: IBuilderQuery) => {
|
||||
// if (item.queryName === aggregateData.queryName) {
|
||||
// return {
|
||||
// ...item,
|
||||
// groupBy: [groupBy],
|
||||
// orderBy: [],
|
||||
// legend: item.legend && groupBy.key ? `{{${groupBy.key}}}` : '',
|
||||
// };
|
||||
// }
|
||||
// return item;
|
||||
// },
|
||||
// );
|
||||
|
||||
// NEW LOGIC - Filter to keep only the query that matches queryName
|
||||
newQuery.builder.queryData = newQuery.builder.queryData
|
||||
.filter((item: IBuilderQuery) => item.queryName === aggregateData.queryName)
|
||||
.map((item: IBuilderQuery) => ({
|
||||
...item,
|
||||
groupBy: [groupBy],
|
||||
orderBy: [],
|
||||
legend: item.legend && groupBy.key ? `{{${groupBy.key}}}` : '',
|
||||
}));
|
||||
|
||||
return newQuery;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user