mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-29 08:00:59 +00:00
Compare commits
272 Commits
temporalit
...
feat/cross
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e3700e27 | ||
|
|
bcd21cee74 | ||
|
|
2dbe0777f4 | ||
|
|
7602d863dd | ||
|
|
68d9c6c3cc | ||
|
|
10c6e1fac7 | ||
|
|
3999a64c64 | ||
|
|
5c0ece454a | ||
|
|
729bfb31f1 | ||
|
|
052fb8b703 | ||
|
|
5d9247f591 | ||
|
|
4c86c0650c | ||
|
|
3c380353a3 | ||
|
|
c0a9948146 | ||
|
|
917814e903 | ||
|
|
ff6f3a382d | ||
|
|
f3569a9a02 | ||
|
|
0df1ed3b57 | ||
|
|
d0132f11ae | ||
|
|
f61e859901 | ||
|
|
4daec45d98 | ||
|
|
382d9d4a87 | ||
|
|
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 |
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -42,3 +42,7 @@
|
||||
/pkg/telemetrymetadata/ @srikanthccv
|
||||
/pkg/telemetrymetrics/ @srikanthccv
|
||||
/pkg/telemetrytraces/ @srikanthccv
|
||||
|
||||
# AuthN / AuthZ Owners
|
||||
|
||||
/pkg/authz/ @vikrantgupta25 @grandwizard28
|
||||
|
||||
2
.github/workflows/build-community.yaml
vendored
2
.github/workflows/build-community.yaml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.23
|
||||
GO_VERSION: 1.24
|
||||
GO_NAME: signoz-community
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -93,7 +93,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.23
|
||||
GO_VERSION: 1.24
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.23
|
||||
GO_VERSION: 1.24
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
10
.github/workflows/goci.yaml
vendored
10
.github/workflows/goci.yaml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_TEST_CONTEXT: ./...
|
||||
GO_VERSION: 1.23
|
||||
GO_VERSION: 1.24
|
||||
fmt:
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.23
|
||||
GO_VERSION: 1.24
|
||||
lint:
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.23
|
||||
GO_VERSION: 1.24
|
||||
deps:
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.23
|
||||
GO_VERSION: 1.24
|
||||
build:
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24"
|
||||
- name: qemu-install
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: aarch64-install
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
4
.github/workflows/gor-signoz.yaml
vendored
4
.github/workflows/gor-signoz.yaml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -86,6 +86,8 @@ queries.active
|
||||
.devenv/**/tmp/**
|
||||
.qodo
|
||||
|
||||
.dev
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -5,7 +5,7 @@ COPY ./frontend/ ./
|
||||
RUN CI=1 yarn install
|
||||
RUN CI=1 yarn build
|
||||
|
||||
FROM golang:1.23-bullseye
|
||||
FROM golang:1.24-bullseye
|
||||
|
||||
ARG OS="linux"
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -57,7 +57,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
|
||||
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
|
||||
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore, signoz.Cache),
|
||||
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
|
||||
Signoz: signoz,
|
||||
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
)
|
||||
@@ -57,7 +57,7 @@ func Unauthorized(err error) *ApiError {
|
||||
func BadRequestStr(s string) *ApiError {
|
||||
return &ApiError{
|
||||
Typ: basemodel.ErrorBadData,
|
||||
Err: fmt.Errorf(s),
|
||||
Err: errors.New(s),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func InternalError(err error) *ApiError {
|
||||
func InternalErrorStr(s string) *ApiError {
|
||||
return &ApiError{
|
||||
Typ: basemodel.ErrorInternal,
|
||||
Err: fmt.Errorf(s),
|
||||
Err: errors.New(s),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 = signal;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
params.name = 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;
|
||||
63
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
63
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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,
|
||||
value?: string,
|
||||
startUnixMilli?: number,
|
||||
endUnixMilli?: number,
|
||||
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (signal) {
|
||||
params.signal = signal;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
params.name = name;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
params.value = value;
|
||||
}
|
||||
|
||||
if (startUnixMilli) {
|
||||
params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString();
|
||||
}
|
||||
|
||||
if (endUnixMilli) {
|
||||
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
|
||||
}
|
||||
|
||||
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.values(response.data.data.values).forEach((valueArray: any) => {
|
||||
if (Array.isArray(valueArray)) {
|
||||
allValues.push(...valueArray.map(String));
|
||||
}
|
||||
});
|
||||
|
||||
// Add a normalized values array to the response
|
||||
response.data.data.normalizedValues = allValues;
|
||||
}
|
||||
|
||||
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>),
|
||||
};
|
||||
|
||||
@@ -20,13 +20,15 @@
|
||||
.ant-card-body {
|
||||
height: calc(100% - 18px);
|
||||
|
||||
.widget-graph-container {
|
||||
&.bar {
|
||||
height: calc(100% - 110px);
|
||||
}
|
||||
.widget-graph-component-container {
|
||||
.widget-graph-container {
|
||||
&.bar-panel-container {
|
||||
height: calc(100% - 110px);
|
||||
}
|
||||
|
||||
&.graph {
|
||||
height: calc(100% - 80px);
|
||||
&.graph-panel-container {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,9 +84,11 @@
|
||||
.ant-card-body {
|
||||
height: calc(100% - 18px);
|
||||
|
||||
.widget-graph-container {
|
||||
&.bar {
|
||||
height: calc(100% - 110px);
|
||||
.widget-graph-component-container {
|
||||
.widget-graph-container {
|
||||
&.bar-panel-container {
|
||||
height: calc(100% - 110px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,31 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-input-prefix {
|
||||
.live-dot-icon {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-forest-500);
|
||||
animation: ripple 1s infinite;
|
||||
|
||||
margin-right: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-suffix-icon-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -59,7 +59,9 @@ interface CustomTimePickerProps {
|
||||
customDateTimeVisible?: boolean;
|
||||
setCustomDTPickerVisible?: Dispatch<SetStateAction<boolean>>;
|
||||
onCustomDateHandler?: (dateTimeRange: DateTimeRangeType) => void;
|
||||
handleGoLive?: () => void;
|
||||
showLiveLogs?: boolean;
|
||||
onGoLive?: () => void;
|
||||
onExitLiveLogs?: () => void;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -76,7 +78,9 @@ function CustomTimePicker({
|
||||
customDateTimeVisible,
|
||||
setCustomDTPickerVisible,
|
||||
onCustomDateHandler,
|
||||
handleGoLive,
|
||||
onGoLive,
|
||||
onExitLiveLogs,
|
||||
showLiveLogs,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [
|
||||
selectedTimePlaceholderValue,
|
||||
@@ -165,9 +169,13 @@ function CustomTimePicker({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const value = getSelectedTimeRangeLabel(selectedTime, selectedValue);
|
||||
setSelectedTimePlaceholderValue(value);
|
||||
}, [selectedTime, selectedValue]);
|
||||
if (showLiveLogs) {
|
||||
setSelectedTimePlaceholderValue('Live');
|
||||
} else {
|
||||
const value = getSelectedTimeRangeLabel(selectedTime, selectedValue);
|
||||
setSelectedTimePlaceholderValue(value);
|
||||
}
|
||||
}, [selectedTime, selectedValue, showLiveLogs]);
|
||||
|
||||
const hide = (): void => {
|
||||
setOpen(false);
|
||||
@@ -338,6 +346,28 @@ function CustomTimePicker({
|
||||
return '';
|
||||
};
|
||||
|
||||
const getInputPrefix = (): JSX.Element => {
|
||||
if (showLiveLogs) {
|
||||
return (
|
||||
<div className="time-input-prefix">
|
||||
<div className="live-dot-icon" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-input-prefix">
|
||||
{inputValue && inputStatus === 'success' ? (
|
||||
<CheckCircle size={14} color="#51E7A8" />
|
||||
) : (
|
||||
<Tooltip title="Enter time in format (e.g., 1m, 2h, 3d, 4w)">
|
||||
<Clock size={14} className="cursor-pointer" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="custom-time-picker">
|
||||
<Tooltip title={getTooltipTitle()} placement="top">
|
||||
@@ -357,7 +387,8 @@ function CustomTimePicker({
|
||||
setCustomDTPickerVisible={defaultTo(setCustomDTPickerVisible, noop)}
|
||||
onCustomDateHandler={defaultTo(onCustomDateHandler, noop)}
|
||||
onSelectHandler={handleSelect}
|
||||
handleGoLive={defaultTo(handleGoLive, noop)}
|
||||
onGoLive={defaultTo(onGoLive, noop)}
|
||||
onExitLiveLogs={defaultTo(onExitLiveLogs, noop)}
|
||||
options={items}
|
||||
selectedTime={selectedTime}
|
||||
activeView={activeView}
|
||||
@@ -392,17 +423,7 @@ function CustomTimePicker({
|
||||
onBlur={handleBlur}
|
||||
onChange={handleInputChange}
|
||||
data-1p-ignore
|
||||
prefix={
|
||||
<div className="time-input-prefix">
|
||||
{inputValue && inputStatus === 'success' ? (
|
||||
<CheckCircle size={14} color="#51E7A8" />
|
||||
) : (
|
||||
<Tooltip title="Enter time in format (e.g., 1m, 2h, 3d, 4w)">
|
||||
<Clock size={14} className="cursor-pointer" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
prefix={getInputPrefix()}
|
||||
suffix={
|
||||
<div className="time-input-suffix">
|
||||
{!!isTimezoneOverridden && activeTimezoneOffset && (
|
||||
@@ -439,6 +460,8 @@ CustomTimePicker.defaultProps = {
|
||||
customDateTimeVisible: false,
|
||||
setCustomDTPickerVisible: noop,
|
||||
onCustomDateHandler: noop,
|
||||
handleGoLive: noop,
|
||||
onGoLive: noop,
|
||||
onCustomTimeStatusUpdate: noop,
|
||||
onExitLiveLogs: noop,
|
||||
showLiveLogs: false,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import DatePickerV2 from 'components/DatePickerV2/DatePickerV2';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import {
|
||||
@@ -16,7 +17,14 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import { Clock, PenLine } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getCustomTimeRanges } from 'utils/customTimeRangeUtils';
|
||||
|
||||
@@ -32,12 +40,13 @@ interface CustomTimePickerPopoverContentProps {
|
||||
lexicalContext?: LexicalContext,
|
||||
) => void;
|
||||
onSelectHandler: (label: string, value: string) => void;
|
||||
handleGoLive: () => void;
|
||||
onGoLive: () => void;
|
||||
selectedTime: string;
|
||||
activeView: 'datetime' | 'timezone';
|
||||
setActiveView: Dispatch<SetStateAction<'datetime' | 'timezone'>>;
|
||||
isOpenedFromFooter: boolean;
|
||||
setIsOpenedFromFooter: Dispatch<SetStateAction<boolean>>;
|
||||
onExitLiveLogs: () => void;
|
||||
}
|
||||
|
||||
interface RecentlyUsedDateTimeRange {
|
||||
@@ -56,12 +65,13 @@ function CustomTimePickerPopoverContent({
|
||||
setCustomDTPickerVisible,
|
||||
onCustomDateHandler,
|
||||
onSelectHandler,
|
||||
handleGoLive,
|
||||
onGoLive,
|
||||
selectedTime,
|
||||
activeView,
|
||||
setActiveView,
|
||||
isOpenedFromFooter,
|
||||
setIsOpenedFromFooter,
|
||||
onExitLiveLogs,
|
||||
}: CustomTimePickerPopoverContentProps): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -69,6 +79,19 @@ function CustomTimePickerPopoverContent({
|
||||
pathname,
|
||||
]);
|
||||
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
|
||||
let panelTypeFromURL = url.get(QueryParams.panelTypes);
|
||||
|
||||
try {
|
||||
panelTypeFromURL = JSON.parse(panelTypeFromURL as string);
|
||||
} catch {
|
||||
// fallback → leave as-is
|
||||
}
|
||||
|
||||
const isLogsListView =
|
||||
panelTypeFromURL !== 'table' && panelTypeFromURL !== 'graph'; // we do not select list view in the url
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
const activeTimezoneOffset = timezone.offset;
|
||||
|
||||
@@ -76,6 +99,12 @@ function CustomTimePickerPopoverContent({
|
||||
RecentlyUsedDateTimeRange[]
|
||||
>([]);
|
||||
|
||||
const handleExitLiveLogs = useCallback((): void => {
|
||||
if (isLogsExplorerPage) {
|
||||
onExitLiveLogs();
|
||||
}
|
||||
}, [isLogsExplorerPage, onExitLiveLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!customDateTimeVisible) {
|
||||
const customTimeRanges = getCustomTimeRanges();
|
||||
@@ -107,6 +136,7 @@ function CustomTimePickerPopoverContent({
|
||||
className="time-btns"
|
||||
key={option.label + option.value}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onSelectHandler(option.label, option.value);
|
||||
}}
|
||||
>
|
||||
@@ -140,12 +170,17 @@ function CustomTimePickerPopoverContent({
|
||||
);
|
||||
}
|
||||
|
||||
const handleGoLive = (): void => {
|
||||
onGoLive();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="date-time-popover">
|
||||
{!customDateTimeVisible && (
|
||||
<div className="date-time-options">
|
||||
{isLogsExplorerPage && (
|
||||
{isLogsExplorerPage && isLogsListView && (
|
||||
<Button className="data-time-live" type="text" onClick={handleGoLive}>
|
||||
Live
|
||||
</Button>
|
||||
@@ -155,6 +190,7 @@ function CustomTimePickerPopoverContent({
|
||||
type="text"
|
||||
key={option.label + option.value}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onSelectHandler(option.label, option.value);
|
||||
}}
|
||||
className={cx(
|
||||
@@ -169,7 +205,6 @@ function CustomTimePickerPopoverContent({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'relative-date-time',
|
||||
@@ -199,12 +234,14 @@ function CustomTimePickerPopoverContent({
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleExitLiveLogs();
|
||||
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
key={range.value}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -71,6 +72,8 @@ function Metrics({
|
||||
[hostName, timeRange.startTime, timeRange.endTime, dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const { dynamicVariables } = useGetDynamicVariables();
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: ['host-metrics', payload, ENTITY_VERSION_V4, 'HOST'],
|
||||
@@ -78,7 +81,8 @@ function Metrics({
|
||||
signal,
|
||||
}: QueryFunctionContext): Promise<
|
||||
SuccessResponse<MetricRangePayloadProps>
|
||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, signal),
|
||||
> =>
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V4, dynamicVariables, signal),
|
||||
enabled: !!payload && visibilities[index],
|
||||
keepPreviousData: true,
|
||||
})),
|
||||
|
||||
@@ -28,6 +28,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForMultiSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
@@ -37,7 +38,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 +63,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
allowClear = false,
|
||||
onRetry,
|
||||
maxTagTextLength,
|
||||
onDropdownVisibleChange,
|
||||
showIncompleteDataMessage = false,
|
||||
showLabels = false,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
@@ -78,6 +82,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 +130,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 +144,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 +164,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 +195,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange, allAvailableValues, options, enableAllSelection],
|
||||
[
|
||||
allOptionShown,
|
||||
isAllSelected,
|
||||
onChange,
|
||||
allAvailableValues,
|
||||
options,
|
||||
enableAllSelection,
|
||||
],
|
||||
);
|
||||
|
||||
// ===== Existing Callbacks (potentially needing adjustment later) =====
|
||||
@@ -510,11 +537,19 @@ 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(0);
|
||||
}
|
||||
|
||||
if (onSearch) onSearch(trimmedValue);
|
||||
},
|
||||
[onSearch, isOpen, selectedValues, onChange],
|
||||
);
|
||||
@@ -528,28 +563,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 +601,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 +779,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,7 +813,7 @@ 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',
|
||||
});
|
||||
}
|
||||
@@ -784,6 +845,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 +1201,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 +1231,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 +1244,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 +1290,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 +1336,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
allOptionShown,
|
||||
isAllSelected,
|
||||
isOpen,
|
||||
activeIndex,
|
||||
getVisibleChipIndices,
|
||||
getLastVisibleChipIndex,
|
||||
selectedChips,
|
||||
isSelectionMode,
|
||||
isOpen,
|
||||
activeChipIndex,
|
||||
selectedValues,
|
||||
visibleOptions,
|
||||
@@ -1278,10 +1359,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
startSelection,
|
||||
selectionEnd,
|
||||
extendSelection,
|
||||
activeIndex,
|
||||
onDropdownVisibleChange,
|
||||
handleSelectAll,
|
||||
getVisibleChipIndices,
|
||||
getLastVisibleChipIndex,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1306,6 +1385,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
|
||||
@@ -1382,6 +1469,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
onMouseDown={handleDropdownMouseDown}
|
||||
onClick={handleDropdownClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleDropdownScroll}
|
||||
onBlur={handleBlur}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
@@ -1460,15 +1548,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
{/* 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">
|
||||
@@ -1494,9 +1585,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
</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 +1614,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
handleDropdownMouseDown,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
handleDropdownScroll,
|
||||
handleBlur,
|
||||
activeIndex,
|
||||
loading,
|
||||
@@ -1522,8 +1624,31 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
renderOptionWithIndex,
|
||||
handleSelectAll,
|
||||
onRetry,
|
||||
showIncompleteDataMessage,
|
||||
isScrolledToBottom,
|
||||
]);
|
||||
|
||||
// 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 +1710,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 +1799,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">
|
||||
@@ -504,9 +567,19 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
</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 +593,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
isLabelPresent,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
handleDropdownScroll,
|
||||
activeOptionIndex,
|
||||
loading,
|
||||
errorMessage,
|
||||
@@ -527,8 +601,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 +670,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
|
||||
@@ -35,6 +35,43 @@ $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;
|
||||
@@ -158,7 +195,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 +313,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 +363,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;
|
||||
@@ -656,6 +697,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 +881,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,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -133,3 +133,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;
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -32,6 +32,7 @@ export enum LOCALSTORAGE {
|
||||
BANNER_DISMISSED = 'BANNER_DISMISSED',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export enum QueryParams {
|
||||
msgSystem = 'msgSystem',
|
||||
destination = 'destination',
|
||||
kindString = 'kindString',
|
||||
summaryFilters = 'summaryFilters',
|
||||
tab = 'tab',
|
||||
thresholds = 'thresholds',
|
||||
selectedExplorerView = 'selectedExplorerView',
|
||||
|
||||
@@ -2,4 +2,5 @@ export const USER_PREFERENCES = {
|
||||
SIDENAV_PINNED: 'sidenav_pinned',
|
||||
NAV_SHORTCUTS: 'nav_shortcuts',
|
||||
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
|
||||
};
|
||||
|
||||
@@ -48,10 +48,10 @@
|
||||
line-height: 36px;
|
||||
}
|
||||
}
|
||||
&__graph {
|
||||
&__alert-history-graph {
|
||||
margin-top: 80px;
|
||||
|
||||
.graph {
|
||||
.alert-history-graph {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ function StatsCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="stats-card__graph">
|
||||
<div className="graph">
|
||||
<div className="stats-card__alert-history-graph">
|
||||
<div className="alert-history-graph">
|
||||
{!isEmpty && timeSeries.length > 1 && (
|
||||
<StatsGraph timeSeries={timeSeries} changeDirection={changeDirection} />
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
'list-graph-container': isListView,
|
||||
})}
|
||||
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,
|
||||
'list-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 {
|
||||
@@ -325,6 +327,7 @@ function WidgetGraphComponent({
|
||||
setHovered(false);
|
||||
}}
|
||||
id={widget.id}
|
||||
className="widget-graph-component-container"
|
||||
>
|
||||
<Modal
|
||||
destroyOnClose
|
||||
@@ -364,6 +367,7 @@ function WidgetGraphComponent({
|
||||
onClickHandler={onClickHandler ?? graphClickHandler}
|
||||
customOnDragSelect={customOnDragSelect}
|
||||
setCurrentGraphRef={setCurrentGraphRef}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -396,7 +400,10 @@ function WidgetGraphComponent({
|
||||
)}
|
||||
{(queryResponse.isSuccess || widget.panelTypes === PANEL_TYPES.LIST) && (
|
||||
<div
|
||||
className={cx('widget-graph-container', widget.panelTypes)}
|
||||
className={cx(
|
||||
'widget-graph-container',
|
||||
`${widget.panelTypes}-panel-container`,
|
||||
)}
|
||||
ref={graphRef}
|
||||
>
|
||||
<PanelWrapper
|
||||
@@ -414,6 +421,7 @@ function WidgetGraphComponent({
|
||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||
customSeries={customSeries}
|
||||
customOnRowClick={customOnRowClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -426,6 +434,7 @@ WidgetGraphComponent.defaultProps = {
|
||||
setLayout: undefined,
|
||||
onClickHandler: undefined,
|
||||
customTimeRangeWindowForCoRelation: undefined,
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
export default WidgetGraphComponent;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
|
||||
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||
@@ -13,7 +15,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 +54,7 @@ function GridCardGraph({
|
||||
customTimeRange,
|
||||
customOnRowClick,
|
||||
customTimeRangeWindowForCoRelation,
|
||||
enableDrillDown,
|
||||
}: GridCardGraphProps): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
@@ -62,14 +64,18 @@ function GridCardGraph({
|
||||
const {
|
||||
toScrollWidgetId,
|
||||
setToScrollWidgetId,
|
||||
variablesToGetUpdated,
|
||||
setDashboardQueryRangeCalled,
|
||||
} = useDashboard();
|
||||
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { dynamicVariables } = useGetDynamicVariables();
|
||||
const dynamicVariableToWidgetsMap = useMemo(
|
||||
() => createDynamicVariableToWidgetsMap(dynamicVariables, [widget]),
|
||||
[dynamicVariables, widget],
|
||||
);
|
||||
|
||||
const handleBackNavigation = (): void => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
@@ -120,11 +126,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 +165,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)) {
|
||||
@@ -218,12 +222,23 @@ 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' ||
|
||||
(dynamicVariableToWidgetsMap?.[id] &&
|
||||
dynamicVariableToWidgetsMap?.[id].includes(widget.id))
|
||||
) {
|
||||
return { ...acc, [id]: variable.selectedValue };
|
||||
}
|
||||
return acc;
|
||||
}, {})
|
||||
: {},
|
||||
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
|
||||
? [customTimeRange.startTime, customTimeRange.endTime]
|
||||
: []),
|
||||
@@ -317,6 +332,7 @@ function GridCardGraph({
|
||||
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
|
||||
customOnRowClick={customOnRowClick}
|
||||
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -332,6 +348,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,7 @@ export interface GridCardGraphProps {
|
||||
};
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export interface GetGraphVisibilityStateOnLegendClickProps {
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.widget-graph-container {
|
||||
&.graph {
|
||||
height: 100%;
|
||||
.widget-graph-component-container {
|
||||
.widget-graph-container {
|
||||
&.graph-panel-container {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,11 +91,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.widget-graph-container {
|
||||
height: 100%;
|
||||
.widget-graph-component-container {
|
||||
.widget-graph-container {
|
||||
height: 100%;
|
||||
|
||||
&.graph {
|
||||
height: calc(100% - 30px);
|
||||
&.graph-panel-container {
|
||||
height: calc(100% - 30px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,11 +53,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,
|
||||
@@ -584,6 +585,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
version={ENTITY_VERSION_V5}
|
||||
onDragSelect={onDragSelect}
|
||||
dataAvailable={checkIfDataExists}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</Card>
|
||||
</CardContainer>
|
||||
@@ -670,3 +672,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,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSubstituteVars } from 'api/dashboard/substitute_vars';
|
||||
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
|
||||
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
|
||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { useCallback } from 'react';
|
||||
@@ -34,6 +35,8 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
|
||||
const queryRangeMutation = useMutation(getSubstituteVars);
|
||||
|
||||
const { dynamicVariables } = useGetDynamicVariables();
|
||||
|
||||
const getUpdatedQuery = useCallback(
|
||||
async ({
|
||||
widgetConfig,
|
||||
@@ -47,6 +50,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
globalSelectedInterval,
|
||||
variables: getDashboardVariables(selectedDashboard?.data?.variables),
|
||||
originalGraphType: widgetConfig.panelTypes,
|
||||
dynamicVariables,
|
||||
});
|
||||
|
||||
// Execute query and process results
|
||||
@@ -55,7 +59,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,
|
||||
@@ -180,9 +186,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)
|
||||
@@ -193,11 +199,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.name],
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -98,6 +99,8 @@ function EntityMetrics<T>({
|
||||
],
|
||||
);
|
||||
|
||||
const { dynamicVariables } = useGetDynamicVariables();
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: [queryKey, payload, ENTITY_VERSION_V4, category],
|
||||
@@ -105,7 +108,8 @@ function EntityMetrics<T>({
|
||||
signal,
|
||||
}: QueryFunctionContext): Promise<
|
||||
SuccessResponse<MetricRangePayloadProps>
|
||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, signal),
|
||||
> =>
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V4, dynamicVariables, signal),
|
||||
enabled: !!payload && visibilities[index],
|
||||
keepPreviousData: true,
|
||||
})),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useCallback } from 'react';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { SpinnerWrapper, Wrapper } from './styles';
|
||||
import { SpinnerWrapper } from './styles';
|
||||
|
||||
function ListViewPanel(): JSX.Element {
|
||||
const { config } = useOptionsMenu({
|
||||
@@ -42,7 +42,7 @@ function ListViewPanel(): JSX.Element {
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="live-logs-settings-panel">
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
style={defaultSelectStyle}
|
||||
@@ -68,7 +68,7 @@ function ListViewPanel(): JSX.Element {
|
||||
<Spinner style={{ height: 'auto' }} />
|
||||
</SpinnerWrapper>
|
||||
)}
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.live-logs-chart-container {
|
||||
height: 200px;
|
||||
min-height: 200px;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.live-logs-settings-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--bg-ink-300);
|
||||
|
||||
.live-logs-frequency-chart-view-controller {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.live-logs-settings-panel {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,89 @@
|
||||
import { Col } from 'antd';
|
||||
import Spinner from 'components/Spinner';
|
||||
import './LiveLogsContainer.styles.scss';
|
||||
|
||||
import { Button, Switch, Typography } from 'antd';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import GoToTop from 'container/GoToTop';
|
||||
import FiltersInput from 'container/LiveLogs/FiltersInput';
|
||||
import LiveLogsTopNav from 'container/LiveLogsTopNav';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useEventSourceEvent } from 'hooks/useEventSourceEvent';
|
||||
import { prepareQueryRangePayload } from 'lib/dashboard/prepareQueryRangePayload';
|
||||
import { Sliders } from 'lucide-react';
|
||||
import { useEventSource } from 'providers/EventSource';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import { idObject } from '../constants';
|
||||
import ListViewPanel from '../ListViewPanel';
|
||||
import LiveLogsList from '../LiveLogsList';
|
||||
import { ILiveLogsLog } from '../LiveLogsList/types';
|
||||
import LiveLogsListChart from '../LiveLogsListChart';
|
||||
import { QueryHistoryState } from '../types';
|
||||
import { prepareQueryByFilter } from '../utils';
|
||||
import { ContentWrapper, LiveLogsChart, Wrapper } from './styles';
|
||||
|
||||
function LiveLogsContainer(): JSX.Element {
|
||||
const location = useLocation();
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [logs, setLogs] = useState<ILiveLogsLog[]>([]);
|
||||
const { currentQuery, stagedQuery } = useQueryBuilder();
|
||||
const [showLiveLogsFrequencyChart, setShowLiveLogsFrequencyChart] = useState(
|
||||
true,
|
||||
);
|
||||
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const listQuery = useMemo(() => {
|
||||
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null;
|
||||
|
||||
return stagedQuery.builder.queryData.find((item) => !item.disabled) || null;
|
||||
}, [stagedQuery]);
|
||||
|
||||
const queryLocationState = location.state as QueryHistoryState;
|
||||
|
||||
const batchedEventsRef = useRef<ILog[]>([]);
|
||||
const batchedEventsRef = useRef<ILiveLogsLog[]>([]);
|
||||
|
||||
const { selectedTime: globalSelectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const prevFilterExpressionRef = useRef<string | null>(null);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
const formatItems = [
|
||||
{
|
||||
key: 'raw',
|
||||
label: 'Raw',
|
||||
data: {
|
||||
title: 'max lines per row',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: 'Default',
|
||||
},
|
||||
{
|
||||
key: 'table',
|
||||
label: 'Column',
|
||||
data: {
|
||||
title: 'columns',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleToggleShowFormatOptions = (): void =>
|
||||
setShowFormatMenuItems(!showFormatMenuItems);
|
||||
|
||||
useClickOutside({
|
||||
ref: menuRef,
|
||||
onClickOutside: () => {
|
||||
if (showFormatMenuItems) {
|
||||
setShowFormatMenuItems(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
handleStartOpenConnection,
|
||||
@@ -53,7 +96,7 @@ function LiveLogsContainer(): JSX.Element {
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
|
||||
const updateLogs = useCallback((newLogs: ILog[]) => {
|
||||
const updateLogs = useCallback((newLogs: ILiveLogsLog[]) => {
|
||||
setLogs((prevState) =>
|
||||
[...newLogs, ...prevState].slice(0, MAX_LOGS_LIST_SIZE),
|
||||
);
|
||||
@@ -67,7 +110,7 @@ function LiveLogsContainer(): JSX.Element {
|
||||
}, 500);
|
||||
|
||||
const batchLiveLog = useCallback(
|
||||
(log: ILog): void => {
|
||||
(log: ILiveLogsLog): void => {
|
||||
batchedEventsRef.current.push(log);
|
||||
|
||||
debouncedUpdateLogs();
|
||||
@@ -77,7 +120,7 @@ function LiveLogsContainer(): JSX.Element {
|
||||
|
||||
const handleGetLiveLogs = useCallback(
|
||||
(event: MessageEvent<string>) => {
|
||||
const data: ILog = JSON.parse(event.data);
|
||||
const data: ILiveLogsLog = JSON.parse(event?.data);
|
||||
|
||||
batchLiveLog(data);
|
||||
},
|
||||
@@ -91,72 +134,65 @@ function LiveLogsContainer(): JSX.Element {
|
||||
useEventSourceEvent('message', handleGetLiveLogs);
|
||||
useEventSourceEvent('error', handleError);
|
||||
|
||||
const getPreparedQuery = useCallback(
|
||||
(query: Query): Query => {
|
||||
const firstLogId: string | null = logs.length ? logs[0].id : null;
|
||||
|
||||
const preparedQuery: Query = prepareQueryByFilter(
|
||||
query,
|
||||
idObject,
|
||||
firstLogId,
|
||||
);
|
||||
|
||||
return preparedQuery;
|
||||
},
|
||||
[logs],
|
||||
);
|
||||
|
||||
const openConnection = useCallback(
|
||||
(query: Query) => {
|
||||
const { queryPayload } = prepareQueryRangePayload({
|
||||
query,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
});
|
||||
|
||||
const encodedQueryPayload = encodeURIComponent(JSON.stringify(queryPayload));
|
||||
const queryString = `q=${encodedQueryPayload}`;
|
||||
|
||||
handleStartOpenConnection({ queryString });
|
||||
(filterExpression?: string | null) => {
|
||||
handleStartOpenConnection(filterExpression || '');
|
||||
},
|
||||
[globalSelectedTime, handleStartOpenConnection],
|
||||
[handleStartOpenConnection],
|
||||
);
|
||||
|
||||
const handleStartNewConnection = useCallback(
|
||||
(query: Query) => {
|
||||
(filterExpression?: string | null) => {
|
||||
handleCloseConnection();
|
||||
|
||||
const preparedQuery = getPreparedQuery(query);
|
||||
|
||||
openConnection(preparedQuery);
|
||||
openConnection(filterExpression);
|
||||
},
|
||||
[getPreparedQuery, handleCloseConnection, openConnection],
|
||||
[handleCloseConnection, openConnection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compositeQuery) return;
|
||||
const currentFilterExpression =
|
||||
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() || '';
|
||||
|
||||
// Check if filterExpression has actually changed
|
||||
if (
|
||||
(initialLoading && !isConnectionLoading) ||
|
||||
compositeQuery.id !== stagedQuery?.id
|
||||
!prevFilterExpressionRef.current ||
|
||||
prevFilterExpressionRef.current !== currentFilterExpression
|
||||
) {
|
||||
handleStartNewConnection(compositeQuery);
|
||||
const validationResult = validateQuery(currentFilterExpression || '');
|
||||
|
||||
if (validationResult.isValid) {
|
||||
setLogs([]);
|
||||
batchedEventsRef.current = [];
|
||||
handleStartNewConnection(currentFilterExpression);
|
||||
}
|
||||
|
||||
prevFilterExpressionRef.current = currentFilterExpression || null;
|
||||
}
|
||||
}, [
|
||||
compositeQuery,
|
||||
initialLoading,
|
||||
stagedQuery,
|
||||
isConnectionLoading,
|
||||
openConnection,
|
||||
handleStartNewConnection,
|
||||
]);
|
||||
}, [currentQuery, handleStartNewConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoading && !isConnectionLoading) {
|
||||
const currentFilterExpression =
|
||||
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() || '';
|
||||
|
||||
const validationResult = validateQuery(currentFilterExpression || '');
|
||||
|
||||
if (validationResult.isValid) {
|
||||
handleStartNewConnection(currentFilterExpression);
|
||||
prevFilterExpressionRef.current = currentFilterExpression || null;
|
||||
} else {
|
||||
handleStartNewConnection(null);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialLoading, isConnectionLoading, handleStartNewConnection]);
|
||||
|
||||
useEffect((): (() => void) | undefined => {
|
||||
if (isConnectionError && reconnectDueToError && compositeQuery) {
|
||||
if (isConnectionError && reconnectDueToError) {
|
||||
// Small delay to prevent immediate reconnection attempts
|
||||
const reconnectTimer = setTimeout(() => {
|
||||
handleStartNewConnection(compositeQuery);
|
||||
handleStartNewConnection();
|
||||
}, 1000);
|
||||
|
||||
return (): void => clearTimeout(reconnectTimer);
|
||||
@@ -169,50 +205,70 @@ function LiveLogsContainer(): JSX.Element {
|
||||
handleStartNewConnection,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefetchedList = queryLocationState?.listQueryPayload[0]?.list;
|
||||
// clean up the connection when the component unmounts
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
handleCloseConnection();
|
||||
},
|
||||
[handleCloseConnection],
|
||||
);
|
||||
|
||||
if (prefetchedList) {
|
||||
const prefetchedLogs: ILog[] = prefetchedList
|
||||
.map((item) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}))
|
||||
.reverse();
|
||||
|
||||
updateLogs(prefetchedLogs);
|
||||
}
|
||||
}, [queryLocationState, updateLogs]);
|
||||
const handleToggleFrequencyChart = useCallback(() => {
|
||||
setShowLiveLogsFrequencyChart(!showLiveLogsFrequencyChart);
|
||||
}, [showLiveLogsFrequencyChart]);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<LiveLogsTopNav />
|
||||
<ContentWrapper gutter={[0, 20]} style={{ color: themeColors.lightWhite }}>
|
||||
<Col span={24}>
|
||||
<FiltersInput />
|
||||
</Col>
|
||||
{initialLoading && logs.length === 0 ? (
|
||||
<Col span={24}>
|
||||
<Spinner style={{ height: 'auto' }} tip="Fetching Logs" />
|
||||
</Col>
|
||||
) : (
|
||||
<>
|
||||
<Col span={24}>
|
||||
<LiveLogsChart
|
||||
initialData={queryLocationState?.graphQueryPayload || null}
|
||||
<div className="live-logs-container">
|
||||
<div className="live-logs-content">
|
||||
<div className="live-logs-settings-panel">
|
||||
<div className="live-logs-frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showLiveLogsFrequencyChart}
|
||||
defaultChecked
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="format-options-container" ref={menuRef}>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
onClick={handleToggleShowFormatOptions}
|
||||
icon={<Sliders size={14} />}
|
||||
/>
|
||||
|
||||
{showFormatMenuItems && (
|
||||
<LogsFormatOptionsMenu
|
||||
title="FORMAT"
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<ListViewPanel />
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<LiveLogsList logs={logs} />
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLiveLogsFrequencyChart && (
|
||||
<div className="live-logs-chart-container">
|
||||
<LiveLogsListChart
|
||||
initialData={queryLocationState?.graphQueryPayload || null}
|
||||
className="live-logs-chart"
|
||||
isShowingLiveLogs
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<GoToTop />
|
||||
</ContentWrapper>
|
||||
</Wrapper>
|
||||
|
||||
<div className="live-logs-list-container">
|
||||
<LiveLogsList
|
||||
logs={logs}
|
||||
isLoading={initialLoading && logs.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GoToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
.live-logs-container {
|
||||
.live-logs-content {
|
||||
.live-logs-chart-container {
|
||||
padding: 0px 8px;
|
||||
|
||||
.logs-frequency-chart {
|
||||
.ant-card-body {
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
padding: 0 16px 22px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.live-logs-list {
|
||||
.live-logs-list-loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.live-logs-list-loading {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.loading-live-logs-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-gif {
|
||||
height: 72px;
|
||||
margin-left: -24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.live-logs-list-loading {
|
||||
.loading-live-logs-content {
|
||||
.ant-typography {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,23 @@
|
||||
import './LiveLogsList.styles.scss';
|
||||
|
||||
import { Card, Typography } from 'antd';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import ListLogView from 'components/Logs/ListLogView';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { CARD_BODY_STYLE } from 'constants/card';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
|
||||
import InfinityTableView from 'container/LogsExplorerList/InfinityTableView';
|
||||
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
||||
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
||||
import { Heading } from 'container/LogsTable/styles';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useEventSource } from 'providers/EventSource';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
// interfaces
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -26,11 +25,9 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { LiveLogsListProps } from './types';
|
||||
|
||||
function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
|
||||
function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
|
||||
const ref = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const { t } = useTranslation(['logs']);
|
||||
|
||||
const { isConnectionLoading } = useEventSource();
|
||||
|
||||
const { activeLogId } = useCopyLogLink();
|
||||
@@ -43,6 +40,12 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
|
||||
onSetActiveLog,
|
||||
} = useActiveLog();
|
||||
|
||||
// get only data from the logs object
|
||||
const formattedLogs: ILog[] = useMemo(
|
||||
() => logs.map((log) => log?.data).flat(),
|
||||
[logs],
|
||||
);
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
@@ -50,8 +53,8 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
|
||||
});
|
||||
|
||||
const activeLogIndex = useMemo(
|
||||
() => logs.findIndex(({ id }) => id === activeLogId),
|
||||
[logs, activeLogId],
|
||||
() => formattedLogs.findIndex(({ id }) => id === activeLogId),
|
||||
[formattedLogs, activeLogId],
|
||||
);
|
||||
|
||||
const selectedFields = convertKeysToColumnFields([
|
||||
@@ -105,30 +108,39 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
|
||||
});
|
||||
}, [activeLogId, activeLogIndex]);
|
||||
|
||||
const isLoadingList = isConnectionLoading && logs.length === 0;
|
||||
const isLoadingList = isConnectionLoading && formattedLogs.length === 0;
|
||||
|
||||
if (isLoadingList) {
|
||||
return <Spinner style={{ height: 'auto' }} tip="Fetching Logs" />;
|
||||
}
|
||||
const renderLoading = useCallback(
|
||||
() => (
|
||||
<div className="live-logs-list-loading">
|
||||
<div className="loading-live-logs-content">
|
||||
<img
|
||||
className="loading-gif"
|
||||
src="/Icons/loading-plane.gif"
|
||||
alt="wait-icon"
|
||||
/>
|
||||
|
||||
<Typography>Fetching live logs...</Typography>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.format !== OptionFormatTypes.TABLE && (
|
||||
<Heading>
|
||||
<Typography.Text>Event</Typography.Text>
|
||||
</Heading>
|
||||
)}
|
||||
<div className="live-logs-list">
|
||||
{(formattedLogs.length === 0 || isLoading || isLoadingList) &&
|
||||
renderLoading()}
|
||||
|
||||
{logs.length === 0 && <Typography>{t('fetching_log_lines')}</Typography>}
|
||||
|
||||
{logs.length !== 0 && (
|
||||
{formattedLogs.length !== 0 && (
|
||||
<InfinityWrapperStyled>
|
||||
{options.format === OptionFormatTypes.TABLE ? (
|
||||
<InfinityTableView
|
||||
ref={ref}
|
||||
isLoading={false}
|
||||
tableViewProps={{
|
||||
logs,
|
||||
logs: formattedLogs,
|
||||
fields: selectedFields,
|
||||
linesPerRow: options.maxLines,
|
||||
fontSize: options.fontSize,
|
||||
@@ -142,8 +154,8 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
|
||||
<Virtuoso
|
||||
ref={ref}
|
||||
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
|
||||
data={logs}
|
||||
totalCount={logs.length}
|
||||
data={formattedLogs}
|
||||
totalCount={formattedLogs.length}
|
||||
itemContent={getItemContent}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
@@ -151,15 +163,18 @@ function LiveLogsList({ logs }: LiveLogsListProps): JSX.Element {
|
||||
)}
|
||||
</InfinityWrapperStyled>
|
||||
)}
|
||||
<LogDetail
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={onClearActiveLog}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onGroupByAttribute={onGroupByAttribute}
|
||||
onClickActionItem={onAddToQuery}
|
||||
/>
|
||||
</>
|
||||
|
||||
{activeLog && (
|
||||
<LogDetail
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={onClearActiveLog}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onGroupByAttribute={onGroupByAttribute}
|
||||
onClickActionItem={onAddToQuery}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
export interface ILiveLogsLog {
|
||||
data: ILog[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type LiveLogsListProps = {
|
||||
logs: ILog[];
|
||||
logs: ILiveLogsLog[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
@@ -9,24 +9,33 @@ import { useMemo } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import { LiveLogsListChartProps } from './types';
|
||||
|
||||
function LiveLogsListChart({
|
||||
className,
|
||||
initialData,
|
||||
isShowingLiveLogs = false,
|
||||
}: LiveLogsListChartProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { isConnectionOpen } = useEventSource();
|
||||
|
||||
const listChartQuery: Query | null = useMemo(() => {
|
||||
if (!stagedQuery) return null;
|
||||
if (!currentQuery) return null;
|
||||
|
||||
const currentFilterExpression =
|
||||
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() || '';
|
||||
|
||||
const validationResult = validateQuery(currentFilterExpression || '');
|
||||
|
||||
if (!validationResult.isValid) return null;
|
||||
|
||||
return {
|
||||
...stagedQuery,
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...stagedQuery.builder,
|
||||
queryData: stagedQuery.builder.queryData.map((item) => ({
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
disabled: false,
|
||||
aggregateOperator: LogsAggregatorOperator.COUNT,
|
||||
@@ -39,7 +48,7 @@ function LiveLogsListChart({
|
||||
})),
|
||||
},
|
||||
};
|
||||
}, [stagedQuery]);
|
||||
}, [currentQuery]);
|
||||
|
||||
const { data, isFetching } = useGetExplorerQueryRange(
|
||||
listChartQuery,
|
||||
@@ -62,12 +71,15 @@ function LiveLogsListChart({
|
||||
}, [data, initialData]);
|
||||
|
||||
return (
|
||||
<LogsExplorerChart
|
||||
isLoading={initialData ? false : isFetching}
|
||||
data={chartData}
|
||||
isLabelEnabled={false}
|
||||
className={className}
|
||||
/>
|
||||
<div className="live-logs-chart-container">
|
||||
<LogsExplorerChart
|
||||
isLoading={initialData ? false : isFetching}
|
||||
data={chartData}
|
||||
isLabelEnabled={false}
|
||||
className={className}
|
||||
isLogsExplorerViews={isShowingLiveLogs}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ import { QueryData } from 'types/api/widgets/getQuery';
|
||||
export type LiveLogsListChartProps = {
|
||||
className?: string;
|
||||
initialData: QueryData[] | null;
|
||||
isShowingLiveLogs: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { PauseCircleFilled, PlayCircleFilled } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useEventSource } from 'providers/EventSource';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
function LiveLogsPauseResume(): JSX.Element {
|
||||
const {
|
||||
isConnectionOpen,
|
||||
isConnectionLoading,
|
||||
initialLoading,
|
||||
handleCloseConnection,
|
||||
handleStartOpenConnection,
|
||||
handleSetInitialLoading,
|
||||
} = useEventSource();
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const isPlaying = isConnectionOpen || isConnectionLoading || initialLoading;
|
||||
|
||||
const openConnection = useCallback(
|
||||
(filterExpression?: string | null) => {
|
||||
handleStartOpenConnection(filterExpression || '');
|
||||
},
|
||||
[handleStartOpenConnection],
|
||||
);
|
||||
|
||||
const handleStartNewConnection = useCallback(
|
||||
(filterExpression?: string | null) => {
|
||||
handleCloseConnection();
|
||||
|
||||
openConnection(filterExpression);
|
||||
},
|
||||
[handleCloseConnection, openConnection],
|
||||
);
|
||||
|
||||
const onLiveButtonClick = useCallback(() => {
|
||||
if (initialLoading) {
|
||||
handleSetInitialLoading(false);
|
||||
}
|
||||
|
||||
if ((!isConnectionOpen && isConnectionLoading) || isConnectionOpen) {
|
||||
handleCloseConnection();
|
||||
} else {
|
||||
const currentFilterExpression =
|
||||
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() || '';
|
||||
|
||||
const validationResult = validateQuery(currentFilterExpression || '');
|
||||
|
||||
if (validationResult.isValid) {
|
||||
handleStartNewConnection(currentFilterExpression);
|
||||
} else {
|
||||
handleStartNewConnection(null);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
initialLoading,
|
||||
isConnectionOpen,
|
||||
isConnectionLoading,
|
||||
currentQuery,
|
||||
handleSetInitialLoading,
|
||||
handleCloseConnection,
|
||||
handleStartNewConnection,
|
||||
]);
|
||||
|
||||
// clean up the connection when the component unmounts
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
handleCloseConnection();
|
||||
},
|
||||
[handleCloseConnection],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="live-logs-pause-resume">
|
||||
<Button
|
||||
icon={isPlaying ? <PauseCircleFilled /> : <PlayCircleFilled />}
|
||||
danger={isPlaying}
|
||||
onClick={onLiveButtonClick}
|
||||
type="primary"
|
||||
className={`periscope-btn ${isPlaying ? 'warning' : 'success'}`}
|
||||
>
|
||||
{isPlaying ? 'Pause' : 'Resume'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveLogsPauseResume;
|
||||
@@ -6,4 +6,5 @@ export type LogsExplorerChartProps = {
|
||||
isLogsExplorerViews?: boolean;
|
||||
isLabelEnabled?: boolean;
|
||||
className?: string;
|
||||
isShowingLiveLogs?: boolean;
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ function LogsExplorerChart({
|
||||
isLabelEnabled = true,
|
||||
className,
|
||||
isLogsExplorerViews = false,
|
||||
isShowingLiveLogs = false,
|
||||
}: LogsExplorerChartProps): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
@@ -55,6 +56,11 @@ function LogsExplorerChart({
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
// Do not allow dragging on live logs chart
|
||||
if (isShowingLiveLogs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
@@ -75,7 +81,7 @@ function LogsExplorerChart({
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
},
|
||||
[dispatch, location.pathname, safeNavigate, urlQuery],
|
||||
[dispatch, location.pathname, safeNavigate, urlQuery, isShowingLiveLogs],
|
||||
);
|
||||
|
||||
const graphData = useMemo(
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getDraggedColumns } from 'hooks/useDragColumns/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { isEmpty, isEqual } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
interface ColumnViewProps {
|
||||
@@ -51,6 +51,8 @@ function ColumnView({
|
||||
onGroupByAttribute: handleGroupByAttribute,
|
||||
} = useActiveLog();
|
||||
|
||||
const [showActiveLog, setShowActiveLog] = useState<boolean>(false);
|
||||
|
||||
const { queryData: activeLogId } = useUrlQueryData<string | null>(
|
||||
QueryParams.activeLogId,
|
||||
null,
|
||||
@@ -72,9 +74,10 @@ function ColumnView({
|
||||
|
||||
if (log) {
|
||||
handleSetActiveLog(log);
|
||||
setShowActiveLog(true);
|
||||
}
|
||||
}
|
||||
}, [activeLogId, logs, handleSetActiveLog]);
|
||||
}, []);
|
||||
|
||||
const tableViewProps = {
|
||||
logs,
|
||||
@@ -88,7 +91,6 @@ function ColumnView({
|
||||
const { dataSource, columns } = useTableView({
|
||||
...tableViewProps,
|
||||
onClickExpand: handleSetActiveLog,
|
||||
onOpenLogsContext: handleClearActiveLog,
|
||||
});
|
||||
|
||||
const { draggedColumns, onColumnOrderChange } = useDragColumns<
|
||||
@@ -222,9 +224,22 @@ function ColumnView({
|
||||
const handleRowClick = (row: Row<Record<string, unknown>>): void => {
|
||||
const currentLog = logs.find(({ id }) => id === row.original.id);
|
||||
|
||||
setShowActiveLog(true);
|
||||
handleSetActiveLog(currentLog as ILog);
|
||||
};
|
||||
|
||||
const removeQueryParam = (key: string): void => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete(key);
|
||||
window.history.replaceState({}, '', url);
|
||||
};
|
||||
|
||||
const handleLogDetailClose = (): void => {
|
||||
removeQueryParam(QueryParams.activeLogId);
|
||||
handleClearActiveLog();
|
||||
setShowActiveLog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`logs-list-table-view-container ${
|
||||
@@ -246,11 +261,11 @@ function ColumnView({
|
||||
scrollToIndexRef={scrollToIndexRef}
|
||||
/>
|
||||
|
||||
{activeLog && (
|
||||
{showActiveLog && activeLog && (
|
||||
<LogDetail
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={handleClearActiveLog}
|
||||
onClose={handleLogDetailClose}
|
||||
onAddToQuery={handleAddToQuery}
|
||||
onClickActionItem={handleAddToQuery}
|
||||
onGroupByAttribute={handleGroupByAttribute}
|
||||
|
||||
@@ -141,6 +141,7 @@ describe('LogsExplorerList - empty states', () => {
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
@@ -205,6 +206,7 @@ describe('LogsExplorerList - empty states', () => {
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
|
||||
@@ -50,7 +50,6 @@ function LogsExplorerList({
|
||||
isFilterApplied,
|
||||
}: LogsExplorerListProps): JSX.Element {
|
||||
const ref = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const { activeLogId } = useCopyLogLink();
|
||||
|
||||
const {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Button, Switch, Typography } from 'antd';
|
||||
import { WsDataEvent } from 'api/common/getQueryStats';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Download from 'container/DownloadV2/DownloadV2';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import { ArrowUp10, Minus, Sliders } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import QueryStatus from './QueryStatus';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
queryStats,
|
||||
selectedPanelType,
|
||||
showFrequencyChart,
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
flattenLogData,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
showFrequencyChart: boolean;
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
flattenLogData: any;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
queryStats: WsDataEvent | undefined;
|
||||
}): JSX.Element {
|
||||
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
const formatItems = [
|
||||
{
|
||||
key: 'raw',
|
||||
label: 'Raw',
|
||||
data: {
|
||||
title: 'max lines per row',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: 'Default',
|
||||
},
|
||||
{
|
||||
key: 'table',
|
||||
label: 'Column',
|
||||
data: {
|
||||
title: 'columns',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleToggleShowFormatOptions = (): void =>
|
||||
setShowFormatMenuItems(!showFormatMenuItems);
|
||||
|
||||
useClickOutside({
|
||||
ref: menuRef,
|
||||
onClickOutside: () => {
|
||||
if (showFormatMenuItems) {
|
||||
setShowFormatMenuItems(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="logs-actions-container">
|
||||
<div className="tab-options">
|
||||
<div className="tab-options-left">
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<div className="frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showFrequencyChart}
|
||||
defaultChecked
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tab-options-right">
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<>
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={(value): void => setOrderBy(value)}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
<Download
|
||||
data={flattenLogData}
|
||||
isLoading={isFetching}
|
||||
fileName="log_data"
|
||||
/>
|
||||
<div className="format-options-container" ref={menuRef}>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
onClick={handleToggleShowFormatOptions}
|
||||
icon={<Sliders size={14} />}
|
||||
data-testid="periscope-btn"
|
||||
/>
|
||||
|
||||
{showFormatMenuItems && (
|
||||
<LogsFormatOptionsMenu
|
||||
title="FORMAT"
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
|
||||
selectedPanelType === PANEL_TYPES.TABLE) && (
|
||||
<div className="query-stats">
|
||||
<QueryStatus
|
||||
loading={isLoading || isFetching}
|
||||
error={isError}
|
||||
success={isSuccess}
|
||||
/>
|
||||
|
||||
{queryStats?.read_rows && (
|
||||
<Typography.Text className="rows">
|
||||
{getYAxisFormattedValue(queryStats.read_rows?.toString(), 'short')}{' '}
|
||||
rows
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{queryStats?.elapsed_ms && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<Typography.Text className="time">
|
||||
{getYAxisFormattedValue(queryStats?.elapsed_ms?.toString(), 'ms')}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogsActionsContainer;
|
||||
@@ -1,14 +1,10 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import './LogsExplorerViews.styles.scss';
|
||||
|
||||
import { Button, Switch, Typography } from 'antd';
|
||||
import getFromLocalstorage from 'api/browser/localstorage/get';
|
||||
import setToLocalstorage from 'api/browser/localstorage/set';
|
||||
import { getQueryStats, WsDataEvent } from 'api/common/getQueryStats';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -22,20 +18,18 @@ import {
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
||||
import Download from 'container/DownloadV2/DownloadV2';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import GoToTop from 'container/GoToTop';
|
||||
import {} from 'container/LiveLogs/constants';
|
||||
import LogsExplorerChart from 'container/LogsExplorerChart';
|
||||
import LogsExplorerList from 'container/LogsExplorerList';
|
||||
import LogsExplorerTable from 'container/LogsExplorerTable';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
@@ -49,7 +43,7 @@ import {
|
||||
omit,
|
||||
set,
|
||||
} from 'lodash-es';
|
||||
import { ArrowUp10, Minus, Sliders } from 'lucide-react';
|
||||
import LiveLogs from 'pages/LiveLogs';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
@@ -77,16 +71,12 @@ import {
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import {
|
||||
DataSource,
|
||||
LogsAggregatorOperator,
|
||||
StringOperators,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import QueryStatus from './QueryStatus';
|
||||
import LogsActionsContainer from './LogsActionsContainer';
|
||||
|
||||
function LogsExplorerViewsContainer({
|
||||
selectedView,
|
||||
@@ -94,6 +84,7 @@ function LogsExplorerViewsContainer({
|
||||
listQueryKeyRef,
|
||||
chartQueryKeyRef,
|
||||
setWarning,
|
||||
showLiveLogs,
|
||||
}: {
|
||||
selectedView: ExplorerViews;
|
||||
setIsLoadingQueries: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -102,6 +93,7 @@ function LogsExplorerViewsContainer({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
chartQueryKeyRef: MutableRefObject<any>;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
showLiveLogs: boolean;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dispatch = useDispatch();
|
||||
@@ -149,7 +141,6 @@ function LogsExplorerViewsContainer({
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [requestData, setRequestData] = useState<Query | null>(null);
|
||||
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
|
||||
const [queryId, setQueryId] = useState<string>(v4());
|
||||
const [queryStats, setQueryStats] = useState<WsDataEvent>();
|
||||
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
|
||||
@@ -162,12 +153,6 @@ function LogsExplorerViewsContainer({
|
||||
return stagedQuery.builder.queryData.find((item) => !item.disabled) || null;
|
||||
}, [stagedQuery]);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
const isMultipleQueries = useMemo(
|
||||
() =>
|
||||
currentQuery?.builder?.queryData?.length > 1 ||
|
||||
@@ -603,41 +588,6 @@ function LogsExplorerViewsContainer({
|
||||
return isGroupByExist ? data.payload.data.result : firstPayloadQueryArray;
|
||||
}, [stagedQuery, panelType, data, listChartData, listQuery]);
|
||||
|
||||
const formatItems = [
|
||||
{
|
||||
key: 'raw',
|
||||
label: 'Raw',
|
||||
data: {
|
||||
title: 'max lines per row',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: 'Default',
|
||||
},
|
||||
{
|
||||
key: 'table',
|
||||
label: 'Column',
|
||||
data: {
|
||||
title: 'columns',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleToggleShowFormatOptions = (): void =>
|
||||
setShowFormatMenuItems(!showFormatMenuItems);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside({
|
||||
ref: menuRef,
|
||||
onClickOutside: () => {
|
||||
if (showFormatMenuItems) {
|
||||
setShowFormatMenuItems(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isLoading ||
|
||||
@@ -695,104 +645,40 @@ function LogsExplorerViewsContainer({
|
||||
return (
|
||||
<div className="logs-explorer-views-container">
|
||||
<div className="logs-explorer-views-types">
|
||||
<div className="logs-actions-container">
|
||||
<div className="tab-options">
|
||||
<div className="tab-options-left">
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<div className="frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showFrequencyChart}
|
||||
defaultChecked
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tab-options-right">
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<>
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={(value): void => setOrderBy(value)}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
<Download
|
||||
data={flattenLogData}
|
||||
isLoading={isFetching}
|
||||
fileName="log_data"
|
||||
/>
|
||||
<div className="format-options-container" ref={menuRef}>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
onClick={handleToggleShowFormatOptions}
|
||||
icon={<Sliders size={14} />}
|
||||
data-testid="periscope-btn"
|
||||
/>
|
||||
|
||||
{showFormatMenuItems && (
|
||||
<LogsFormatOptionsMenu
|
||||
title="FORMAT"
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
|
||||
selectedPanelType === PANEL_TYPES.TABLE) && (
|
||||
<div className="query-stats">
|
||||
<QueryStatus
|
||||
loading={isLoading || isFetching}
|
||||
error={isError}
|
||||
success={isSuccess}
|
||||
/>
|
||||
|
||||
{queryStats?.read_rows && (
|
||||
<Typography.Text className="rows">
|
||||
{getYAxisFormattedValue(queryStats.read_rows?.toString(), 'short')}{' '}
|
||||
rows
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{queryStats?.elapsed_ms && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<Typography.Text className="time">
|
||||
{getYAxisFormattedValue(queryStats?.elapsed_ms?.toString(), 'ms')}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedPanelType === PANEL_TYPES.LIST && showFrequencyChart && (
|
||||
<div className="logs-frequency-chart-container">
|
||||
<LogsExplorerChart
|
||||
className="logs-frequency-chart"
|
||||
isLoading={isFetchingListChartData || isLoadingListChartData}
|
||||
data={chartData}
|
||||
isLogsExplorerViews={panelType === PANEL_TYPES.LIST}
|
||||
/>
|
||||
</div>
|
||||
{!showLiveLogs && (
|
||||
<LogsActionsContainer
|
||||
listQuery={listQuery}
|
||||
queryStats={queryStats}
|
||||
selectedPanelType={selectedPanelType}
|
||||
showFrequencyChart={showFrequencyChart}
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
flattenLogData={flattenLogData}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isSuccess={isSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedPanelType === PANEL_TYPES.LIST &&
|
||||
showFrequencyChart &&
|
||||
!showLiveLogs && (
|
||||
<div className="logs-frequency-chart-container">
|
||||
<LogsExplorerChart
|
||||
className="logs-frequency-chart"
|
||||
isLoading={isFetchingListChartData || isLoadingListChartData}
|
||||
data={chartData}
|
||||
isLogsExplorerViews={panelType === PANEL_TYPES.LIST}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="logs-explorer-views-type-content">
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
{showLiveLogs && <LiveLogs />}
|
||||
|
||||
{selectedPanelType === PANEL_TYPES.LIST && !showLiveLogs && (
|
||||
<LogsExplorerList
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
@@ -805,7 +691,8 @@ function LogsExplorerViewsContainer({
|
||||
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
|
||||
/>
|
||||
)}
|
||||
{selectedPanelType === PANEL_TYPES.TIME_SERIES && (
|
||||
|
||||
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
|
||||
<TimeSeriesView
|
||||
isLoading={isLoading || isFetching}
|
||||
data={data}
|
||||
@@ -817,7 +704,7 @@ function LogsExplorerViewsContainer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedPanelType === PANEL_TYPES.TABLE && (
|
||||
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
|
||||
<LogsExplorerTable
|
||||
data={
|
||||
(data?.payload?.data?.newResult?.data?.result ||
|
||||
|
||||
@@ -174,6 +174,7 @@ const renderer = (): RenderResult =>
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
@@ -235,6 +236,7 @@ describe('LogsExplorerViews -', () => {
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import './BreakDown.styles.scss';
|
||||
|
||||
import { Alert, Typography } from 'antd';
|
||||
// import useFilterConfig from 'components/QuickFilters/hooks/useFilterConfig';
|
||||
// import { SignalType } from 'components/QuickFilters/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GridCard from 'container/GridCardLayout/GridCard';
|
||||
import { Card, CardContainer } from 'container/GridCardLayout/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
// import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
// import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
@@ -109,65 +106,11 @@ function Section(section: MetricSection): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// function FilterDropdown({ attrKey }: { attrKey: string }): JSX.Element {
|
||||
// const {
|
||||
// data: keyValueSuggestions,
|
||||
// isLoading: isLoadingKeyValueSuggestions,
|
||||
// } = useGetQueryKeyValueSuggestions({
|
||||
// key: attrKey,
|
||||
// signal: DataSource.METRICS,
|
||||
// signalSource: 'meter',
|
||||
// options: {
|
||||
// keepPreviousData: true,
|
||||
// },
|
||||
// });
|
||||
|
||||
// const responseData = keyValueSuggestions?.data as any;
|
||||
// const values = responseData?.data?.values || {};
|
||||
// const stringValues = values.stringValues || [];
|
||||
// const numberValues = values.numberValues || [];
|
||||
|
||||
// const stringOptions = stringValues.filter(
|
||||
// (value: string | null | undefined): value is string =>
|
||||
// value !== null && value !== undefined && value !== '',
|
||||
// );
|
||||
|
||||
// const numberOptions = numberValues
|
||||
// .filter(
|
||||
// (value: number | null | undefined): value is number =>
|
||||
// value !== null && value !== undefined,
|
||||
// )
|
||||
// .map((value: number) => value.toString());
|
||||
|
||||
// const vals = [...stringOptions, ...numberOptions];
|
||||
|
||||
// return (
|
||||
// <div className="filter-dropdown">
|
||||
// <Typography.Text>{attrKey}</Typography.Text>
|
||||
// <Select
|
||||
// loading={isLoadingKeyValueSuggestions}
|
||||
// options={vals?.map((suggestion: any) => ({
|
||||
// label: suggestion,
|
||||
// value: suggestion,
|
||||
// }))}
|
||||
// placeholder={`Select ${attrKey}`}
|
||||
// />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
function BreakDown(): JSX.Element {
|
||||
// const { customFilters } = useFilterConfig({
|
||||
// signal: SignalType.METER_EXPLORER,
|
||||
// config: [],
|
||||
// });
|
||||
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
return (
|
||||
<div className="meter-explorer-breakdown">
|
||||
<section className="meter-explorer-date-time">
|
||||
{/* {customFilters.map((filter) => (
|
||||
<FilterDropdown key={filter.key} attrKey={filter.key} />
|
||||
))} */}
|
||||
<DateTimeSelectionV2 showAutoRefresh={false} />
|
||||
</section>
|
||||
<section className="meter-explorer-graphs">
|
||||
@@ -178,11 +121,13 @@ function BreakDown(): JSX.Element {
|
||||
message="Billing is calculated in UTC. To match your meter data with billing, select full-day ranges in UTC time (00:00 – 23:59 UTC).
|
||||
For example, if you’re in IST, for the billing of Jan 1, select your time range as Jan 1, 5:30 AM – Jan 2, 5:29 AM IST."
|
||||
/>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="Meter module data is accurate only from 22nd August 2025, 00:00 UTC onwards. Data before this time was collected during the beta phase and may be inaccurate."
|
||||
/>
|
||||
{isCloudUser && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="Meter module data is accurate only from 22nd August 2025, 00:00 UTC onwards. Data before this time was collected during the beta phase and may be inaccurate."
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<section className="total">
|
||||
<Section
|
||||
|
||||
@@ -70,6 +70,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 +123,10 @@
|
||||
.edit-variable-button {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.apply-to-all-button {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.dynamic-variable-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 32px 200px;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 24px 0;
|
||||
|
||||
.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 {
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
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,
|
||||
}: {
|
||||
setDynamicVariablesSelectedValue: Dispatch<
|
||||
SetStateAction<
|
||||
| {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
>;
|
||||
dynamicVariablesSelectedValue:
|
||||
| {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
| undefined;
|
||||
}): 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 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 errorMessage = (error as any)?.message;
|
||||
return (
|
||||
<div className="dynamic-variable-container">
|
||||
<CustomSelect
|
||||
placeholder="Select an Attribute"
|
||||
options={Object.keys(filteredAttributes).map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}))}
|
||||
loading={isLoading}
|
||||
status={errorMessage ? 'error' : undefined}
|
||||
onChange={(value): void => {
|
||||
setSelectedAttribute(value);
|
||||
}}
|
||||
showSearch
|
||||
errorMessage={errorMessage as any}
|
||||
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariable;
|
||||
@@ -0,0 +1,376 @@
|
||||
/* 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,
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,6 @@
|
||||
|
||||
.variable-type-btn-group {
|
||||
display: flex;
|
||||
width: 342px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
@@ -199,6 +198,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 +476,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);
|
||||
|
||||
@@ -6,7 +6,9 @@ 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 { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
|
||||
import { map } from 'lodash-es';
|
||||
@@ -16,16 +18,20 @@ import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import {
|
||||
IDashboardVariable,
|
||||
TSortVariableValuesType,
|
||||
TVariableQueryType,
|
||||
VariableSortTypeArr,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
|
||||
import {
|
||||
@@ -34,7 +40,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;
|
||||
|
||||
@@ -61,7 +69,7 @@ function VariableItem({
|
||||
variableData.description || '',
|
||||
);
|
||||
const [queryType, setQueryType] = useState<TVariableQueryType>(
|
||||
variableData.type || 'QUERY',
|
||||
variableData.type || 'DYNAMIC',
|
||||
);
|
||||
const [variableQueryValue, setVariableQueryValue] = useState<string>(
|
||||
variableData.queryValue || '',
|
||||
@@ -85,11 +93,61 @@ 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 }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
variableData.dynamicVariablesAttribute &&
|
||||
variableData.dynamicVariablesSource
|
||||
) {
|
||||
setDynamicVariablesSelectedValue({
|
||||
name: variableData.dynamicVariablesAttribute,
|
||||
value: variableData.dynamicVariablesSource,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
variableData.dynamicVariablesAttribute,
|
||||
variableData.dynamicVariablesSource,
|
||||
]);
|
||||
// Error messages
|
||||
const [errorName, setErrorName] = useState<boolean>(false);
|
||||
const [errorPreview, setErrorPreview] = useState<string | null>(null);
|
||||
|
||||
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[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryType === 'DYNAMIC') {
|
||||
setSelectedWidgets(variableData?.dynamicVariablesWidgetIds || []);
|
||||
}
|
||||
}, [queryType, variableData?.dynamicVariablesWidgetIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryType === 'CUSTOM') {
|
||||
setPreviewValues(
|
||||
@@ -110,6 +168,29 @@ 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,
|
||||
dynamicVariablesSelectedValue,
|
||||
]);
|
||||
|
||||
const handleSave = (): void => {
|
||||
// Check for cyclic dependencies
|
||||
const newVariable = {
|
||||
@@ -126,9 +207,20 @@ function VariableItem({
|
||||
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,
|
||||
}),
|
||||
...(queryType === 'DYNAMIC' && {
|
||||
dynamicVariablesWidgetIds:
|
||||
selectedWidgets?.length > 0 ? selectedWidgets : [],
|
||||
}),
|
||||
};
|
||||
|
||||
const allVariables = [...Object.values(existingVariables), newVariable];
|
||||
@@ -258,18 +350,18 @@ 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([]);
|
||||
}}
|
||||
>
|
||||
Query
|
||||
Dynamic
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -299,8 +391,31 @@ function VariableItem({
|
||||
>
|
||||
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([]);
|
||||
}}
|
||||
>
|
||||
Query
|
||||
</Button>
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
{queryType === 'DYNAMIC' && (
|
||||
<div className="variable-dynamic-section">
|
||||
<DynamicVariable
|
||||
setDynamicVariablesSelectedValue={setDynamicVariablesSelectedValue}
|
||||
dynamicVariablesSelectedValue={dynamicVariablesSelectedValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{queryType === 'QUERY' && (
|
||||
<div className="query-container">
|
||||
<LabelContainer>
|
||||
@@ -388,7 +503,9 @@ function VariableItem({
|
||||
/>
|
||||
</VariableItemRow>
|
||||
)}
|
||||
{(queryType === 'QUERY' || queryType === 'CUSTOM') && (
|
||||
{(queryType === 'QUERY' ||
|
||||
queryType === 'CUSTOM' ||
|
||||
queryType === 'DYNAMIC') && (
|
||||
<>
|
||||
<VariableItemRow className="variables-preview-section">
|
||||
<LabelContainer style={{ width: '100%' }}>
|
||||
@@ -457,8 +574,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">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CustomMultiSelect } from 'components/NewSelect';
|
||||
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
|
||||
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
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) => {
|
||||
if (
|
||||
widget.id &&
|
||||
layoutIds.has(widget.id) &&
|
||||
widget.panelTypes !== PANEL_GROUP_TYPES.ROW
|
||||
) {
|
||||
acc[widget.id] = widget;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomMultiSelect
|
||||
placeholder="Select Panels"
|
||||
options={widgets.map((widget) => ({
|
||||
label: generateGridTitle(widget.title),
|
||||
value: widget.id,
|
||||
}))}
|
||||
value={selectedWidgets}
|
||||
onChange={(value): void => setSelectedWidgets(value as string[])}
|
||||
showLabels
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/* 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), // todo - Sagar: check for multiple query or not
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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] : [],
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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), // todo - Sagar: check for multiple query or not
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,13 +15,20 @@ 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 { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
|
||||
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';
|
||||
import {
|
||||
Dashboard,
|
||||
IDashboardVariable,
|
||||
Widgets,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
|
||||
import { TVariableMode } from './types';
|
||||
import VariableItem from './VariableItem/VariableItem';
|
||||
@@ -88,7 +95,7 @@ function VariablesSetting({
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { variables = {} } = selectedDashboard?.data || {};
|
||||
const { variables = {}, widgets = [] } = selectedDashboard?.data || {};
|
||||
|
||||
const [variablesTableData, setVariablesTableData] = useState<any>([]);
|
||||
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
|
||||
@@ -162,19 +169,61 @@ function VariablesSetting({
|
||||
setExistingVariableNamesMap(variableNamesMap);
|
||||
}, [variables]);
|
||||
|
||||
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
|
||||
|
||||
const { dynamicVariables } = useGetDynamicVariables();
|
||||
|
||||
const dynamicVariableToWidgetsMap = useMemo(
|
||||
() =>
|
||||
createDynamicVariableToWidgetsMap(
|
||||
dynamicVariables,
|
||||
(widgets as Widgets[]) || [],
|
||||
),
|
||||
[dynamicVariables, widgets],
|
||||
);
|
||||
|
||||
// initialize and adjust dynamicVariablesWidgetIds values for all variables
|
||||
useEffect(() => {
|
||||
const newVariablesArr = Object.values(variables).map(
|
||||
(variable: IDashboardVariable) => {
|
||||
if (variable.type === 'DYNAMIC') {
|
||||
return {
|
||||
...variable,
|
||||
dynamicVariablesWidgetIds: dynamicVariableToWidgetsMap[variable.id] || [],
|
||||
};
|
||||
}
|
||||
|
||||
return variable;
|
||||
},
|
||||
);
|
||||
|
||||
setVariablesTableData(newVariablesArr);
|
||||
}, [variables, dynamicVariableToWidgetsMap]);
|
||||
|
||||
const updateVariables = (
|
||||
updatedVariablesData: Dashboard['data']['variables'],
|
||||
currentRequestedId?: string,
|
||||
applyToAll?: boolean,
|
||||
): void => {
|
||||
if (!selectedDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDashboard =
|
||||
(currentRequestedId &&
|
||||
addDynamicVariableToPanels(
|
||||
selectedDashboard,
|
||||
updatedVariablesData[currentRequestedId || ''],
|
||||
applyToAll,
|
||||
)) ||
|
||||
selectedDashboard;
|
||||
|
||||
updateMutation.mutateAsync(
|
||||
{
|
||||
id: selectedDashboard.id,
|
||||
|
||||
data: {
|
||||
...selectedDashboard.data,
|
||||
...newDashboard.data,
|
||||
variables: updatedVariablesData,
|
||||
},
|
||||
},
|
||||
@@ -202,6 +251,7 @@ function VariablesSetting({
|
||||
const onVariableSaveHandler = (
|
||||
mode: TVariableMode,
|
||||
variableData: IDashboardVariable,
|
||||
applyToAll?: boolean,
|
||||
): void => {
|
||||
const updatedVariableData = {
|
||||
...variableData,
|
||||
@@ -225,7 +275,7 @@ function VariablesSetting({
|
||||
const variables = convertVariablesToDbFormat(newVariablesArr);
|
||||
|
||||
setVariablesTableData(newVariablesArr);
|
||||
updateVariables(variables);
|
||||
updateVariables(variables, variableData?.id, applyToAll);
|
||||
onDoneVariableViewMode();
|
||||
};
|
||||
|
||||
@@ -271,6 +321,18 @@ function VariablesSetting({
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
<Space className="actions-btns">
|
||||
{variable.type === 'DYNAMIC' && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(): void =>
|
||||
onVariableSaveHandler(variableViewMode || 'EDIT', variable, true)
|
||||
}
|
||||
className="apply-to-all-button"
|
||||
loading={updateMutation.isLoading}
|
||||
>
|
||||
<Typography.Text>Apply to all</Typography.Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(): void => onVariableViewModeEnter('EDIT', variable)}
|
||||
|
||||
@@ -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,
|
||||
@@ -104,7 +105,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
// isMountedCall?: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): void => {
|
||||
if (id) {
|
||||
@@ -121,6 +122,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
...oldVariables[id],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
if (oldVariables?.[name]) {
|
||||
@@ -128,6 +130,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
...oldVariables[name],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -170,22 +173,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 +201,9 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||
dependencyData={dependencyData}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
),
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
/* 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 } 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 [tempSelection, setTempSelection] = useState<
|
||||
string | string[] | undefined
|
||||
>(undefined);
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
const { isLoading, refetch } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
variableData.name || `variable_${variableData.id}`,
|
||||
dynamicVariablesKey,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
{
|
||||
enabled: variableData.type === 'DYNAMIC',
|
||||
queryFn: () =>
|
||||
getFieldValues(
|
||||
variableData.dynamicVariablesSource?.toLowerCase() === 'all sources'
|
||||
? undefined
|
||||
: (variableData.dynamicVariablesSource?.toLowerCase() as
|
||||
| 'traces'
|
||||
| 'logs'
|
||||
| 'metrics'),
|
||||
variableData.dynamicVariablesAttribute,
|
||||
debouncedApiSearchText,
|
||||
minTime,
|
||||
maxTime,
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
setOptionsData(data.payload?.normalizedValues || []);
|
||||
setIsComplete(data.payload?.complete || false);
|
||||
setFilteredOptionsData(data.payload?.normalizedValues || []);
|
||||
},
|
||||
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))
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
||||
} else {
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
value,
|
||||
optionsData.every((v) => value.includes(v.toString())),
|
||||
Array.isArray(value) &&
|
||||
!value.every((v) => optionsData.includes(v.toString())),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[variableData, onValueUpdate, optionsData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
variableData.dynamicVariablesSource &&
|
||||
variableData.dynamicVariablesAttribute
|
||||
) {
|
||||
refetch();
|
||||
}
|
||||
}, [
|
||||
refetch,
|
||||
variableData.dynamicVariablesSource,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
debouncedApiSearchText,
|
||||
]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(text: string) => {
|
||||
if (isComplete) {
|
||||
if (!text) {
|
||||
setFilteredOptionsData(optionsData);
|
||||
return;
|
||||
}
|
||||
|
||||
const localFilteredOptionsData: (string | number | boolean)[] = [];
|
||||
optionsData.forEach((option) => {
|
||||
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
|
||||
localFilteredOptionsData.push(option);
|
||||
}
|
||||
});
|
||||
setFilteredOptionsData(localFilteredOptionsData);
|
||||
} else {
|
||||
setApiSearchText(text);
|
||||
}
|
||||
},
|
||||
[isComplete, optionsData],
|
||||
);
|
||||
|
||||
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 = (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) {
|
||||
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
|
||||
// set all options from the optionsData and the selectedValue, make sure to remove duplicates
|
||||
const allOptions = [
|
||||
...new Set([
|
||||
...optionsData.map((option) => option.toString()),
|
||||
...(variableData.selectedValue
|
||||
? Array.isArray(variableData.selectedValue)
|
||||
? variableData.selectedValue.map((v) => v.toString())
|
||||
: [variableData.selectedValue.toString()]
|
||||
: []),
|
||||
]),
|
||||
];
|
||||
setTempSelection(allOptions);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
]);
|
||||
|
||||
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={
|
||||
selectValue && Array.isArray(selectValue)
|
||||
? selectValue.join(' ')
|
||||
: selectValue || variableData.id
|
||||
}
|
||||
options={filteredOptionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
}))}
|
||||
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 => (
|
||||
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
|
||||
<span>+ {omittedValues.length} </span>
|
||||
</Tooltip>
|
||||
)}
|
||||
onClear={(): void => {
|
||||
handleChange([]);
|
||||
}}
|
||||
enableAllSelection={enableSelectAll}
|
||||
maxTagTextLength={30}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||
/>
|
||||
) : (
|
||||
<CustomSelect
|
||||
key={
|
||||
selectValue && Array.isArray(selectValue)
|
||||
? selectValue.join(' ')
|
||||
: selectValue || 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={filteredOptionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
}))}
|
||||
value={selectValue}
|
||||
defaultValue={variableData.defaultValue}
|
||||
errorMessage={errorMessage}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
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,
|
||||
@@ -146,18 +133,21 @@ function VariableItem({
|
||||
variableData.name &&
|
||||
(validVariableUpdate() || valueNotInList || variableData.allSelected)
|
||||
) {
|
||||
let value = variableData.selectedValue;
|
||||
const 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) {
|
||||
// console.log(valueNotInList);
|
||||
// if (valueNotInList) {
|
||||
// if (variableData.multiSelect) {
|
||||
// value = newOptionsData;
|
||||
// allSelected = true;
|
||||
// } else {
|
||||
// [value] = newOptionsData;
|
||||
// }
|
||||
// } else
|
||||
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
newOptionsData.length > 0 &&
|
||||
@@ -242,26 +232,57 @@ 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) {
|
||||
if (
|
||||
value === ALL_SELECT_VALUE ||
|
||||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
||||
) {
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
// 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 +302,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 +363,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 +390,73 @@ 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}
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
tagRender={(props): JSX.Element => (
|
||||
<Tag closable onClose={props.onClose}>
|
||||
{props.value}
|
||||
</Tag>
|
||||
)}
|
||||
value={tempSelection || selectValue}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
errorMessage={errorMessage}
|
||||
// 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>
|
||||
)}
|
||||
onClear={(): void => {
|
||||
handleChange([]);
|
||||
}}
|
||||
enableAllSelection={enableSelectAll}
|
||||
maxTagTextLength={30}
|
||||
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>
|
||||
|
||||
{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>
|
||||
)
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{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,231 @@
|
||||
/* 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';
|
||||
|
||||
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,
|
||||
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) {
|
||||
updateLocalStorageDashboardVariables(name, value, allSelected);
|
||||
|
||||
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,
|
||||
applyToAll?: boolean,
|
||||
): void => {
|
||||
if (!selectedDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDashboard =
|
||||
(currentRequestedId &&
|
||||
addDynamicVariableToPanels(
|
||||
selectedDashboard,
|
||||
updatedVariablesData[currentRequestedId || ''],
|
||||
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);
|
||||
},
|
||||
[selectedDashboard, updateVariables],
|
||||
);
|
||||
|
||||
return {
|
||||
onValueUpdate,
|
||||
createVariable,
|
||||
updateVariables,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDashboardVariableUpdate;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -73,7 +73,6 @@ export function ColumnUnitSelector(
|
||||
<Typography.Text className="heading">Column Units</Typography.Text>
|
||||
{aggregationQueries.map(({ value, label }) => (
|
||||
<YAxisUnitSelector
|
||||
defaultValue={columnUnits[value]}
|
||||
value={columnUnits[value] || ''}
|
||||
onSelect={(unitValue: string): void =>
|
||||
handleColumnUnitSelect(value, unitValue)
|
||||
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user