Compare commits

..

1 Commits

Author SHA1 Message Date
Abhi Kumar
c67f7e69ce feat(dashboards): add the heatmap chart layer
Bucket × time density grid drawn on canvas through uPlot hooks: columns are
time slices, rows are bucket ranges, and cell colour is the observation count,
so a distribution can be watched changing shape instead of collapsing to
percentile lines.

- renderer, palettes and DOM hover overlay under lib/uPlotV2/plugins/HeatmapPlugin
- bucket axis places log10 values on a linear uPlot scale, extending
  symmetrically when the boundaries include zero or negatives
- `null` (no data) renders hatched and is never conflated with a `0` count
- purpose-built tooltip: neighbouring buckets, or per-group contribution when
  the cell sums more than one group
- ColorBar scale key, reusable by other density visualisations
- group legend through the shared Legend, isolate on label / exclude on marker

The chart takes bucket bounds plus per-group series and pivots them itself.
Panel kind, spec and the `heatmap` request type land separately.

Assisted-by: Claude Opus 5
2026-08-20 21:18:56 +05:30
202 changed files with 8548 additions and 9970 deletions

View File

@@ -20,16 +20,6 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
});
```
- **Extended fixtures:** For features needing complex setup (seeded data, API calls, cleanup), import from domain-specific fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the full pattern.
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
```ts
// Alert list tests - need rules, no history
import { test, expect } from '../../../fixtures/alerts/alert-rules';
// Alert history tests - need evaluated history rows
import { test, expect } from '../../../fixtures/alerts/alert-history';
```
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.

View File

@@ -49,7 +49,6 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.

View File

@@ -8024,6 +8024,13 @@ components:
- schemaVersion
- spec
type: object
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
type: string
required:
- id
type: object
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -13255,6 +13262,117 @@ paths:
summary: Gets service account roles
tags:
- serviceaccount
post:
deprecated: true
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:attach
- role:attach
summary: Create service account role
tags:
- serviceaccount
/api/v1/service_accounts/{id}/roles/{rid}:
delete:
deprecated: true
description: This endpoint revokes a role from service account
operationId: DeleteServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: rid
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:detach
- role:detach
- tokenizer:
- serviceaccount:detach
- role:detach
summary: Delete service account role
tags:
- serviceaccount
/api/v1/service_accounts/me:
get:
deprecated: false
@@ -14258,9 +14376,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:list
- ADMIN
- tokenizer:
- auth-domain:list
- ADMIN
summary: List all auth domains
tags:
- authdomains
@@ -14320,13 +14438,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:create
- auth-domain:attach
- role:attach
- ADMIN
- tokenizer:
- auth-domain:create
- auth-domain:attach
- role:attach
- ADMIN
summary: Create auth domain
tags:
- authdomains
@@ -14370,9 +14484,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:delete
- ADMIN
- tokenizer:
- auth-domain:delete
- ADMIN
summary: Delete auth domain
tags:
- authdomains
@@ -14427,9 +14541,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:read
- ADMIN
- tokenizer:
- auth-domain:read
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
@@ -14483,17 +14597,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
- ADMIN
- tokenizer:
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
- ADMIN
summary: Update auth domain
tags:
- authdomains

View File

@@ -112,41 +112,6 @@ These two folders look similar but mean different things:
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
### Extended fixtures
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
**Fixture scopes:**
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
```
fixtures/alerts/
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
```
Specs import from the fixture they need:
```ts
// List tests — just need rules, no history
import { test, expect } from '../../../fixtures/alerts/alert-rules';
// History tests — need history rows from ruler evaluation
import { test, expect } from '../../../fixtures/alerts/alert-history';
```
**When creating new fixtures:**
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
Each spec follows these principles:
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
@@ -267,14 +232,11 @@ cd tests/e2e
# Single feature dir
npx playwright test tests/alerts/ --project=chromium
# Single sub-area
npx playwright test tests/alerts/history/ --project=chromium
# Single file
npx playwright test tests/alerts/page.spec.ts --project=chromium
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
# Single test by title grep
npx playwright test --project=chromium -g "AL-01"
npx playwright test --project=chromium -g "TC-01"
```
### Iterative modes
@@ -308,14 +270,7 @@ yarn test:staging
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
```bash
# runs against a locally served frontend, not whatever .env.local points at
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
```
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
### Playwright options

View File

@@ -2,6 +2,8 @@
// Mock for uplot library used in tests
export interface MockUPlotInstance {
/** Consumers read `root.parentElement` to detect a re-mounted container. */
root: HTMLDivElement;
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
}
// Create mock instance methods
const createMockUPlotInstance = (): MockUPlotInstance => ({
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
});
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
const root = document.createElement('div');
// Real uPlot mounts its root inside the target; without it a re-render reads
// `root.parentElement` off undefined and throws.
target?.appendChild(root);
return {
root,
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
};
};
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
const createMockPathBuilder = (name: string): jest.Mock =>
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
function MockUPlot(
_options: unknown,
_data: unknown,
_target: HTMLElement,
target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance();
return createMockUPlotInstance(target);
}
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;
// Export the constructor as default
export default MockUPlot;

View File

@@ -53,7 +53,7 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
};
}
// anything else
console.error('ErrorResponseHandler: unclassified error');
console.error('any');
return {
statusCode: 500,
payload: null,

View File

@@ -22,7 +22,10 @@ import type {
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRoleDeprecated201,
CreateServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
@@ -36,6 +39,7 @@ import type {
ListServiceAccounts200,
RenderErrorResponseDTO,
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
@@ -1249,6 +1253,194 @@ export const invalidateGetServiceAccountRoles = async (
return queryClient;
};
/**
* This endpoint assigns a role to a service account
* @deprecated
* @summary Create service account role
*/
export const createServiceAccountRoleDeprecated = (
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['createServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createServiceAccountRoleDeprecated(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
>;
export type CreateServiceAccountRoleDeprecatedMutationBody =
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create service account role
*/
export const useCreateServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
return useMutation(
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint revokes a role from service account
* @deprecated
* @summary Delete service account role
*/
export const deleteServiceAccountRoleDeprecated = (
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
method: 'DELETE',
signal,
});
};
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRoleDeprecated(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
>;
export type DeleteServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete service account role
*/
export const useDeleteServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
return useMutation(
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint gets my service account
* @summary Gets my service account

View File

@@ -9092,6 +9092,13 @@ export interface SavedviewtypesUpdatableSavedViewDTO {
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -11013,6 +11020,21 @@ export type GetServiceAccountRoles200 = {
status: string;
};
export type CreateServiceAccountRoleDeprecatedPathParameters = {
id: string;
};
export type CreateServiceAccountRoleDeprecated201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
id: string;
rid: string;
};
export type GetMyServiceAccount200 = {
data: ServiceaccounttypesServiceAccountWithRolesDTO;
/**

View File

@@ -1,3 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#0078D4" d="M22.379 23.343a1.62 1.62 0 0 0 1.536-2.14v.002L17.35 1.76A1.62 1.62 0 0 0 15.816.657H8.184A1.62 1.62 0 0 0 6.65 1.76L.086 21.204a1.62 1.62 0 0 0 1.536 2.139h4.741a1.62 1.62 0 0 0 1.535-1.103l.977-2.892 4.947 3.675c.28.208.618.32.966.32m-3.084-12.531 3.624 10.739a.54.54 0 0 1-.51.713v-.001h-.03a.54.54 0 0 1-.322-.106l-9.287-6.9h4.853m6.313 7.006c.116-.326.13-.694.007-1.058L9.79 1.76a1.722 1.722 0 0 0-.007-.02h6.034a.54.54 0 0 1 .512.366l6.562 19.445a.54.54 0 0 1-.338.684"/>
</svg>

Before

Width:  |  Height:  |  Size: 583 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -1,5 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Dify</title>
<path d="M7.043 6.487c1.635 0 2.241-1.003 2.241-2.243S8.681 2 7.044 2C5.405 2 4.801 3.003 4.801 4.244c0 1.24.604 2.243 2.241 2.243z" fill="#03F" />
<path d="M14.883 6.97v1.443h-3.679v3.203h3.68v8.012H8.801V8.41h-8v3.203h4.48v8.012H0v3.203h24v-3.203h-5.6v-8.012H24V8.41h-5.6V5.206H24V2.003h-4.161a4.97 4.97 0 00-4.961 4.967h.005z" fill="#03F" />
</svg>

Before

Width:  |  Height:  |  Size: 447 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#D1D5DB;fill-rule:evenodd;clip-rule:evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill-rule:evenodd;clip-rule:evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,3 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#9CA3AF" d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,24 +0,0 @@
<svg viewBox="0 0 44.8 40" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="kong-a" x1="38.204" x2="8.732" y1="18.417" y2="48.543" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-b" x1="38.107" x2="8.635" y1="18.322" y2="48.448" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-c" x1="29.439" x2="-0.033" y1="9.842" y2="39.968" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-d" x1="30.291" x2="0.819" y1="10.676" y2="40.801" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
</defs>
<path d="m14.7 32.9-1.1 1.4 2.5 3.9-.3 1.8h10.6l.7-1.8-4.2-5.3z" fill="url(#kong-a)" />
<path d="M20.5 9.4 16.7 16l18.6 22-.5 2h8.5l1.5-7.1L24.9 9.4z" fill="url(#kong-b)" />
<path d="m23 4.4-1.8 3.3h4.5l7.7 9.2 4.6-3.8v-2.4l-1.6-2.2 1.2-1.2L28.4 0z" fill="url(#kong-c)" />
<path d="M9.1 22.9H6.6L0 31.3V40h7.1l1.3-1.6 5.5-7.1h7.9l2.4-3.7-8.6-10.2z" fill="url(#kong-d)" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +0,0 @@
<svg fill="none" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M63 0.018v63.535L38.418 42.25v21.303H0V0l63 .018ZM7.723 55.839h22.972V25.323l24.583 21.725V7.729L7.723 7.716v48.123Z" fill="#37C38F" />
</svg>

Before

Width:  |  Height:  |  Size: 226 B

View File

@@ -8,14 +8,12 @@ export interface AlertBreadcrumbProps {
items: BreadcrumbItemConfig[];
className?: string;
showDivider?: boolean;
testId?: string;
}
function AlertBreadcrumb({
items,
className,
showDivider = true,
testId,
}: AlertBreadcrumbProps): JSX.Element {
const breadcrumbItems = items.map((item) => ({
title: <BreadcrumbItem {...item} />,
@@ -26,7 +24,6 @@ function AlertBreadcrumb({
<Breadcrumb
className={`${styles.breadcrumb} ${className || ''}`}
items={breadcrumbItems}
data-testid={testId}
/>
{showDivider && <Divider className={styles.divider} />}
</>

View File

@@ -0,0 +1,5 @@
.client-side-qb-search {
.ant-select-selection-search {
width: max-content !important;
}
}

View File

@@ -0,0 +1,661 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Select, Tooltip } from 'antd';
import {
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
QUERY_BUILDER_SEARCH_VALUES,
} from 'constants/queryBuilder';
import { CustomTagProps } from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { PLACEHOLDER } from 'container/QueryBuilder/filters/QueryBuilderSearch/constant';
import { TypographyText } from 'container/QueryBuilder/filters/QueryBuilderSearch/style';
import {
checkCommaInValue,
getOperatorFromValue,
getOperatorValue,
getTagToken,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import {
DropdownState,
ITag,
Option,
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import Suggestions from 'container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { validationMapper } from 'hooks/queryBuilder/useIsValidTag';
import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { isArray, isEmpty, isEqual, isObject } from 'lodash-es';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import './ClientSideQBSearch.styles.scss';
import { Badge } from '@signozhq/ui/badge';
export interface AttributeKey {
key: string;
}
export interface AttributeValuesMap {
[key: string]: AttributeValue;
}
interface ClientSideQBSearchProps {
filters: TagFilter;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
placeholder?: string;
className?: string;
suffixIcon?: React.ReactNode;
attributeValuesMap?: AttributeValuesMap;
attributeKeys: AttributeKey[];
}
interface AttributeValue {
stringAttributeValues: string[] | [];
numberAttributeValues: number[] | [];
boolAttributeValues: boolean[] | [];
}
function ClientSideQBSearch(
props: ClientSideQBSearchProps,
): React.ReactElement {
const {
onChange,
placeholder,
className,
suffixIcon,
whereClauseConfig,
attributeValuesMap,
attributeKeys,
filters,
} = props;
const isDarkMode = useIsDarkMode();
const selectRef = useRef<BaseSelectRef>(null);
const [isOpen, setIsOpen] = useState<boolean>(false);
// create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync.
const [tags, setTags] = useState<ITag[]>(filters.items as ITag[]);
// this will maintain the current state of in process filter item
const [currentFilterItem, setCurrentFilterItem] = useState<ITag | undefined>();
const [currentState, setCurrentState] = useState<DropdownState>(
DropdownState.ATTRIBUTE_KEY,
);
// to maintain the current running state until the tokenization happens for the tag
const [searchValue, setSearchValue] = useState<string>('');
const [dropdownOptions, setDropdownOptions] = useState<Option[]>([]);
const attributeValues = useMemo(() => {
if (currentFilterItem?.key?.key) {
return attributeValuesMap?.[currentFilterItem.key.key];
}
return {
stringAttributeValues: [],
numberAttributeValues: [],
boolAttributeValues: [],
};
}, [attributeValuesMap, currentFilterItem?.key?.key]);
const handleDropdownSelect = useCallback(
(value: string) => {
let parsedValue: BaseAutocompleteData | string;
try {
parsedValue = JSON.parse(value);
} catch {
parsedValue = value;
}
if (currentState === DropdownState.ATTRIBUTE_KEY) {
setCurrentFilterItem((prev) => ({
...prev,
key: parsedValue as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
setSearchValue((parsedValue as BaseAutocompleteData)?.key);
} else if (currentState === DropdownState.OPERATOR) {
if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: value,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: value as string,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setSearchValue(`${currentFilterItem?.key?.key} ${value}`);
}
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY;
if (isMulti) {
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
// this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value.
// not the best interaction but in sync with what we have today!
if (tagValue.includes(String(value))) {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: tagValue,
} as ITag,
]);
return;
}
// this is for adding subsequent comma seperated values
const newSearch = [...tagValue];
newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value;
const newSearchValue = newSearch.join(',');
setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`);
} else {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value,
} as ITag,
]);
}
}
},
[currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue],
);
const handleSearch = useCallback((value: string) => {
setSearchValue(value);
}, []);
const onInputKeyDownHandler = useCallback(
(event: KeyboardEvent<Element>): void => {
if (event.key === 'Backspace' && !searchValue) {
event.stopPropagation();
setTags((prev) => prev.slice(0, -1));
}
},
[searchValue],
);
const handleOnBlur = useCallback((): void => {
if (searchValue) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
// if key is added and operator is not present then convert to body CONTAINS key
if (
currentFilterItem?.key &&
isEmpty(currentFilterItem?.op) &&
whereClauseConfig?.customKey === 'body' &&
whereClauseConfig?.customOp === OPERATORS.CONTAINS
) {
setTags((prev) => [
...prev,
{
key: {
key: 'body',
dataType: DataTypes.String,
type: '',
id: 'body--string----true',
},
op: OPERATORS.CONTAINS,
value: currentFilterItem?.key?.key,
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
currentFilterItem?.op === OPERATORS.EXISTS ||
currentFilterItem?.op === OPERATORS.NOT_EXISTS
) {
// is exists and not exists operator is present then convert directly to tag! no need of value here
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
// if the current state is in sync with the kind of operator used then convert into a tag
validationMapper[operatorType]?.(
isArray(currentFilterItem?.value)
? currentFilterItem?.value.length || 0
: 1,
)
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: currentFilterItem?.value || '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
}
}, [
currentFilterItem?.key,
currentFilterItem?.op,
currentFilterItem?.value,
searchValue,
whereClauseConfig?.customKey,
whereClauseConfig?.customOp,
]);
// this useEffect takes care of tokenisation based on the search state
useEffect(() => {
// if there is no search value reset to the default state
if (!searchValue) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
// split the current search value based on delimiters
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
if (
// Case 1 - if key is defined but the search text doesn't match with the set key,
// can happen when user selects from dropdown and then deletes a few characters
currentFilterItem?.key &&
currentFilterItem?.key?.key !== tagKey.split(' ')[0]
) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (tagOperator && isEmpty(currentFilterItem?.op)) {
// Case 2 -> key is set and now typing for the operator
if (
tagOperator === OPERATORS.EXISTS ||
tagOperator === OPERATORS.NOT_EXISTS
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: tagOperator,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: tagOperator,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
}
} else if (
// Case 3 -> selected operator from dropdown and then erased a part of it
!isEmpty(currentFilterItem?.op) &&
tagOperator !== currentFilterItem?.op
) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
// Case 4 -> the final value state where we set the current filter values and the tokenisation happens on either
// dropdown click or blur event
const currentValue = {
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: tagValue,
};
if (!isEqual(currentValue, currentFilterItem)) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: prev?.op as string,
value: tagValue,
}));
}
}
}, [
currentFilterItem,
currentFilterItem?.key,
currentFilterItem?.op,
searchValue,
currentState,
]);
// the useEffect takes care of setting the dropdown values correctly on change of the current state
useEffect(() => {
if (currentState === DropdownState.ATTRIBUTE_KEY) {
const filteredAttributeKeys = attributeKeys.filter((key) =>
key.key.startsWith(searchValue),
);
setDropdownOptions(
filteredAttributeKeys?.map(
(key) =>
({
label: key.key,
value: key,
}) as Option,
) || [],
);
}
if (currentState === DropdownState.OPERATOR) {
const keyOperator = searchValue.split(' ');
const partialOperator = keyOperator?.[1];
const strippedKey = keyOperator?.[0];
let operatorOptions;
if (currentFilterItem?.key?.dataType) {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[
currentFilterItem.key
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
].map((operator) => ({
label: operator,
value: operator,
}));
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
} else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) {
operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({
label: operator,
value: operator,
}));
setDropdownOptions(operatorOptions);
} else {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map(
(operator) => ({
label: operator,
value: operator,
}),
);
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
}
}
if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const values: Array<string | number | boolean> = [];
const { tagValue } = getTagToken(searchValue);
if (isArray(tagValue)) {
if (!isEmpty(tagValue[tagValue.length - 1])) {
values.push(tagValue[tagValue.length - 1]);
}
} else if (!isEmpty(tagValue)) {
values.push(tagValue);
}
const currentAttributeValues =
attributeValues?.stringAttributeValues ||
attributeValues?.numberAttributeValues ||
attributeValues?.boolAttributeValues ||
[];
values.push(...currentAttributeValues);
if (attributeValuesMap) {
setDropdownOptions(
values.map(
(val) =>
({
label: checkCommaInValue(String(val)),
value: val,
}) as Option,
),
);
} else {
// If attributeValuesMap is not provided, don't set dropdown options
setDropdownOptions([]);
}
}
}, [
attributeValues,
currentFilterItem?.key?.dataType,
currentState,
attributeKeys,
searchValue,
attributeValuesMap,
]);
useEffect(() => {
const filterTags: IBuilderQuery['filters'] = {
op: 'AND',
items: [],
};
tags.forEach((tag) => {
const computedTagValue =
tag.value &&
Array.isArray(tag.value) &&
tag.value[tag.value.length - 1] === ''
? tag.value?.slice(0, -1)
: (tag.value ?? '');
filterTags.items.push({
id: tag.id || uuid().slice(0, 8),
key: tag.key,
op: getOperatorValue(tag.op),
value: computedTagValue,
});
});
if (!isEqual(filters, filterTags)) {
onChange(filterTags);
setTags(
filterTags.items.map((tag) => ({
...tag,
op: getOperatorFromValue(tag.op),
})) as ITag[],
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
const queryTags = useMemo(
() => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`),
[tags],
);
const onTagRender = ({
value,
closable,
onClose,
}: CustomTagProps): React.ReactElement => {
const { tagOperator } = getTagToken(value);
const isInNin = isInNInOperator(tagOperator);
const chipValue = isInNin
? value?.trim()?.replace(/,\s*$/, '')
: value?.trim();
const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value));
const tagDetails = tags[indexInQueryTags];
const onCloseHandler = (): void => {
onClose();
setSearchValue('');
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const tagEditHandler = (value: string): void => {
setCurrentFilterItem(tagDetails);
setSearchValue(value);
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const isDisabled = !!searchValue;
return (
<span className="qb-search-bar-tokenised-tags">
<Badge
color="vanilla"
className={tagDetails?.key?.type || ''}
closable={!searchValue && closable}
onClose={(e): void => {
e.preventDefault();
onCloseHandler();
}}
>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
$isEnabled={!!searchValue}
$disabled={isDisabled}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);
}
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Badge>
</span>
);
};
const suffixIconContent = useMemo(() => {
if (suffixIcon) {
return suffixIcon;
}
return isOpen ? (
<ChevronUp
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
) : (
<ChevronDown
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
);
}, [isDarkMode, isOpen, suffixIcon]);
return (
<div className="query-builder-search-v2 ">
<Select
ref={selectRef}
getPopupContainer={popupContainer}
virtual={false}
showSearch
tagRender={onTagRender}
transitionName=""
choiceTransitionName=""
filterOption={false}
open={isOpen}
suffixIcon={suffixIconContent}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={placeholder}
value={queryTags}
searchValue={searchValue}
className={className}
rootClassName="query-builder-search client-side-qb-search"
disabled={!attributeKeys.length}
style={selectStyle}
onSearch={handleSearch}
onSelect={handleDropdownSelect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={null}
showAction={['focus']}
onBlur={handleOnBlur}
>
{dropdownOptions.map((option) => {
let val = option.value;
try {
if (isObject(option.value)) {
val = JSON.stringify(option.value);
} else {
val = option.value;
}
} catch {
val = option.value;
}
return (
<Select.Option key={isObject(val) ? `select-option` : val} value={val}>
<Suggestions
label={option.label}
value={option.value}
option={currentState}
searchValue={searchValue}
/>
</Select.Option>
);
})}
</Select>
</div>
);
}
ClientSideQBSearch.defaultProps = {
placeholder: PLACEHOLDER,
className: '',
suffixIcon: null,
whereClauseConfig: {},
attributeValuesMap: {},
};
export default ClientSideQBSearch;

View File

@@ -8,7 +8,7 @@ import {
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import FilterQueryParser, {
AndExpressionContext,

View File

@@ -5,7 +5,7 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';

View File

@@ -1,280 +0,0 @@
import { renderHook } from '@testing-library/react';
import {
useRecoverFromEmptyPage,
UseRecoverFromEmptyPageParams,
} from '../useRecoverFromEmptyPage';
const REPLACE = { history: 'replace' };
function renderRecovery(
overrides: Partial<UseRecoverFromEmptyPageParams> = {},
): { setPage: jest.Mock; rerender: (next?: unknown) => void } {
const setPage = jest.fn();
const props: UseRecoverFromEmptyPageParams = {
page: 1,
pageSize: 10,
rowCount: 10,
total: 100,
isFetching: false,
setPage,
...overrides,
};
const { rerender } = renderHook(
(next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next),
{ initialProps: props },
);
return {
setPage,
rerender: (next?: unknown): void =>
rerender({ ...props, ...(next as Partial<UseRecoverFromEmptyPageParams>) }),
};
}
describe('useRecoverFromEmptyPage', () => {
it('leaves the page alone while it still holds rows', () => {
const { setPage } = renderRecovery({ page: 3, rowCount: 10 });
expect(setPage).not.toHaveBeenCalled();
});
it('leaves the page alone on page 1 with no rows at all', () => {
const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 });
expect(setPage).not.toHaveBeenCalled();
});
it('jumps to the last page that holds data when the page is out of range', () => {
const { setPage } = renderRecovery({
page: 7,
pageSize: 10,
rowCount: 0,
total: 25,
});
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
});
it('replaces the history entry so the back button does not return to the empty page', () => {
const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('falls back to page 1 when the total is unknown', () => {
const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('steps back one page when the total claims the page should have data', () => {
// total says 100 rows exist, yet page 5 came back empty — step back rather
// than stall on a page the query cannot actually serve.
const { setPage } = renderRecovery({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledWith(4, REPLACE);
});
it('clamps a page below the first one', () => {
const { setPage } = renderRecovery({ page: 0, rowCount: 10 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('falls back to page 1 when pageSize is zero', () => {
const { setPage } = renderRecovery({
page: 5,
pageSize: 0,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('waits for the request to settle before moving the user', () => {
const { setPage, rerender } = renderRecovery({
page: 3,
rowCount: 0,
total: 10,
isFetching: true,
});
expect(setPage).not.toHaveBeenCalled();
rerender({ page: 3, rowCount: 0, total: 10, isFetching: false });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('clamps a page below the first one even when the query failed', () => {
// A negative offset is what made the request fail (400 "offset cannot be
// negative"), so retrying the same page loops forever — clamp regardless.
const { setPage } = renderRecovery({
page: 0,
rowCount: 0,
total: 0,
isDisabled: true,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('clamps a page below the first one while the query is still in flight', () => {
const { setPage } = renderRecovery({
page: -2,
rowCount: 0,
isFetching: true,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('keeps the page when the query failed so a retry lands where the user was', () => {
const { setPage } = renderRecovery({
page: 3,
rowCount: 0,
total: 0,
isDisabled: true,
});
expect(setPage).not.toHaveBeenCalled();
});
it('clamps a page below the first one exactly once while the request settles', () => {
// The clamp runs ahead of both gates, so a request settling underneath an
// uncorrected page must not re-issue the same history rewrite.
const { setPage, rerender } = renderRecovery({
page: 0,
rowCount: 0,
total: 0,
isFetching: true,
});
expect(setPage).toHaveBeenCalledTimes(1);
rerender({ page: 0, rowCount: 0, total: 0, isFetching: false });
expect(setPage).toHaveBeenCalledTimes(1);
});
it('stops correcting once the corrected page comes back with rows', () => {
const { setPage, rerender } = renderRecovery({
page: 7,
pageSize: 10,
rowCount: 0,
total: 25,
});
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
// The correction lands: the query refetches, then resolves with the rows page 3 holds.
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true });
rerender({
page: 3,
pageSize: 10,
rowCount: 5,
total: 25,
isFetching: false,
});
expect(setPage).toHaveBeenCalledTimes(1);
});
it('does not correct again while the same page is still being observed', () => {
const { setPage, rerender } = renderRecovery({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledTimes(1);
// A refetch cycle that leaves the page untouched — the correction is already in flight.
rerender({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
isFetching: true,
});
rerender({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
isFetching: false,
});
expect(setPage).toHaveBeenCalledTimes(1);
});
it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => {
// `total` claims 400 rows exist, but every page comes back empty. Walking back one
// page at a time would cost a request per hop, so bail out to page 1 instead.
const { setPage, rerender } = renderRecovery({
page: 40,
pageSize: 10,
rowCount: 0,
total: 400,
});
expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE);
rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 });
expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE);
rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 });
expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE);
expect(setPage).toHaveBeenCalledTimes(3);
});
it('corrects again when the user returns to a page that is still empty', () => {
const { setPage, rerender } = renderRecovery({
page: 3,
pageSize: 10,
rowCount: 0,
total: 10,
});
expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE);
rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 });
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 });
expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE);
});
it('does not re-run the correction when setPage is a fresh function each render', () => {
// The hook reads setPage through a ref, so an inline arrow must not turn the
// ungated `page < 1` clamp into a per-render history rewrite.
const setPage = jest.fn();
const { rerender } = renderHook(
() =>
useRecoverFromEmptyPage({
page: 0,
pageSize: 10,
rowCount: 0,
total: 0,
isFetching: false,
setPage: (nextPage, options): void => setPage(nextPage, options),
}),
{ initialProps: undefined },
);
rerender(undefined);
rerender(undefined);
expect(setPage).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,91 +0,0 @@
import { renderHook } from '@testing-library/react';
import {
useStableTotalCount,
UseStableTotalCountParams,
} from '../useStableTotalCount';
function renderStableTotalCount(initial: UseStableTotalCountParams): {
result: { current: number };
rerender: (next: UseStableTotalCountParams) => void;
} {
const { result, rerender } = renderHook(
(params: UseStableTotalCountParams) => useStableTotalCount(params),
{ initialProps: initial },
);
return { result, rerender };
}
describe('useStableTotalCount', () => {
it('reports the total once the load settles', () => {
const { result } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(100);
});
it('keeps the last count while the same list refetches', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'pods' });
expect(result.current).toBe(100);
});
it('forgets the count when the list changes', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
expect(result.current).toBe(0);
});
it('reports the new list total once it arrives', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
rerender({ total: 25, isLoading: false, resetKey: 'nodes' });
expect(result.current).toBe(25);
});
it('reports zero for a list that is genuinely empty', () => {
const { result } = renderStableTotalCount({
total: 0,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(0);
});
it('holds nothing back when no reset key is given', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: undefined,
});
// Without a key there is no context to compare, so the cache behaves as it
// always did: it survives the refetch.
rerender({ total: 0, isLoading: true, resetKey: undefined });
expect(result.current).toBe(100);
});
});

View File

@@ -9,7 +9,6 @@ export * from './useCalculatedPageSize';
export * from './useColumnState';
export * from './useColumnStore';
export * from './usePreferredPageSize.store';
export * from './useRecoverFromEmptyPage';
export * from './useTableParams';
/**
@@ -286,51 +285,6 @@ export * from './useTableParams';
*
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
* to reset to auto-calculated size.
*
* **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the
* current history entry instead of pushing a new one. Use `replace` for corrections the user
* did not ask for — otherwise the back button walks straight back into the state that was just
* corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it.
*
* @example useRecoverFromEmptyPage — send the user back to a page that has data
*
* When rows disappear underneath the current page (filters narrowed, time range moved, items
* deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook
* watches the fetched result and corrects the page with `history: 'replace'`, so the back
* button does not return to the empty page.
*
* Correction rules:
* - `page < 1` → jump to page 1, even while fetching or disabled. Such a page usually maps to a
* negative offset the API rejects (400 `offset cannot be negative`), so the response can never
* confirm the page is empty — deferring to it would strand the user on a permanent error.
* - Page is empty and not page 1 → go to `min(ceil(total / pageSize), page - 1)`. When `total`
* is trustworthy that lands on the last page holding data; when `total` is unknown or zero it
* lands on page 1; and when `total` claims this page should have had rows it steps back a
* single page. Repeated step-backs give up and jump to page 1 after the second one, so a
* badly inflated `total` cannot walk the user down one request at a time.
* - Page has rows, or the user is already on page 1 → do nothing (an empty page 1 means there
* is genuinely nothing to show).
*
* Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed
* request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp.
*
* ```tsx
* import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView';
*
* const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 });
* const { data, isLoading, isFetching, isError } = useListQuery({ page, limit });
*
* useRecoverFromEmptyPage({
* page,
* pageSize: limit,
* rowCount: data?.rows.length ?? 0,
* total: data?.total ?? 0,
* isFetching: isLoading || isFetching,
* // Skip correction on errors — no rows there means "request failed", not "page is empty".
* isDisabled: isError,
* setPage,
* });
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -1,107 +0,0 @@
import { useEffect, useRef } from 'react';
import { SetPageOptions } from './useTableParams';
const FIRST_PAGE = 1;
const REPLACE_HISTORY: SetPageOptions = { history: 'replace' };
/**
* How many single-page step-backs to attempt before giving up and going to page 1.
*
* A step-back only happens when `total` claims the current page should hold data but the
* response came back empty. Each hop costs a request, so an inflated `total` on a high page
* number would otherwise walk the user down one page at a time behind a spinner.
*/
const MAX_STEP_BACKS = 2;
type Correction = {
from: number;
to: number;
};
export type UseRecoverFromEmptyPageParams = {
page: number;
pageSize: number;
rowCount: number;
total: number;
isFetching: boolean;
isDisabled?: boolean;
setPage: (page: number, options?: SetPageOptions) => void;
};
export function useRecoverFromEmptyPage({
page,
pageSize,
rowCount,
total,
isFetching,
isDisabled = false,
setPage,
}: UseRecoverFromEmptyPageParams): void {
const setPageRef = useRef(setPage);
const lastCorrectionRef = useRef<Correction | null>(null);
const stepBacksRef = useRef(0);
useEffect(() => {
setPageRef.current = setPage;
});
useEffect(() => {
if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) {
lastCorrectionRef.current = null;
}
const correctTo = (nextPage: number): boolean => {
if (lastCorrectionRef.current?.to === nextPage) {
return false;
}
lastCorrectionRef.current = { from: page, to: nextPage };
setPageRef.current(nextPage, REPLACE_HISTORY);
return true;
};
// A page below the first one is invalid on its own terms — it usually maps to a
// negative offset the API rejects outright, so waiting for a response that will
// never arrive (or trusting a failed one) would strand the user for good.
if (page < FIRST_PAGE) {
stepBacksRef.current = 0;
void correctTo(FIRST_PAGE);
return;
}
if (isFetching || isDisabled) {
return;
}
// The page has data, or there is genuinely nothing to show anywhere.
if (rowCount > 0 || page === FIRST_PAGE) {
stepBacksRef.current = 0;
return;
}
const currentPage = Math.floor(page);
const lastPageWithData =
pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE;
const nextPage = Math.max(
FIRST_PAGE,
Math.min(lastPageWithData, currentPage - 1),
);
// `total` disagrees with the response: it says this page should have rows, so the
// only safe move is one page back. Cap how often that repeats — every hop is a
// request, and a badly inflated `total` would otherwise crawl down from page 40.
const isStepBack = nextPage === currentPage - 1;
if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) {
if (correctTo(FIRST_PAGE)) {
stepBacksRef.current = 0;
}
return;
}
if (correctTo(nextPage) && isStepBack) {
stepBacksRef.current += 1;
}
}, [isFetching, isDisabled, page, pageSize, rowCount, total]);
}

View File

@@ -1,35 +0,0 @@
import { useRef } from 'react';
export type UseStableTotalCountParams = {
total: number | undefined;
isLoading: boolean;
/**
* Identifies the list being counted. When it changes, the cached count is
* dropped so the previous list's page count cannot outlive it.
*/
resetKey: string | undefined;
};
/**
* Holds on to the last non-zero total so the pagination does not flash while the
* same list refetches, and forgets it as soon as `resetKey` moves to another list.
*/
export function useStableTotalCount({
total,
isLoading,
resetKey,
}: UseStableTotalCountParams): number {
const prevTotalRef = useRef(total || 0);
const prevResetKeyRef = useRef(resetKey);
if (prevResetKeyRef.current !== resetKey) {
prevResetKeyRef.current = resetKey;
prevTotalRef.current = 0;
}
if (total && total > 0) {
prevTotalRef.current = total;
}
return isLoading ? prevTotalRef.current : total || 0;
}

View File

@@ -29,16 +29,12 @@ type Defaults = {
cleanupOnUnmount?: boolean;
};
export type SetPageOptions = {
history?: 'push' | 'replace';
};
export type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
expanded: ExpandedState;
setPage: (p: number, options?: SetPageOptions) => void;
setPage: (p: number) => void;
setLimit: (l: number) => void;
setOrderBy: (s: SortState | null) => void;
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
@@ -253,17 +249,6 @@ export function useTableParams(
[],
);
const setUrlPageWithOptions = useCallback(
(page: number, options?: SetPageOptions): void => {
void setUrlPage(page, options);
},
[setUrlPage],
);
const setLocalPageValue = useCallback((page: number): void => {
setLocalPage(page);
}, []);
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const prevOrderByRef = useRef<string | null>(null);
@@ -318,7 +303,7 @@ export function useTableParams(
limit: useUrlForLimit ? urlLimit : localLimit,
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,

View File

@@ -29,7 +29,6 @@ function PopoverContent({
<Link
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
className="contributor-row-popover-buttons__button"
data-testid="alert-popover-view-logs"
>
<div className="icon">
<LogsIcon />
@@ -41,7 +40,6 @@ function PopoverContent({
<Link
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
className="contributor-row-popover-buttons__button"
data-testid="alert-popover-view-traces"
>
<div className="icon">
<DraftingCompass

View File

@@ -26,10 +26,7 @@ function ChangePercentage({
}: ChangePercentageProps): JSX.Element {
if (direction > 0) {
return (
<div
className="change-percentage change-percentage--success"
data-testid="stats-card-change"
>
<div className="change-percentage change-percentage--success">
<div className="change-percentage__icon">
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
</div>
@@ -41,10 +38,7 @@ function ChangePercentage({
}
if (direction < 0) {
return (
<div
className="change-percentage change-percentage--error"
data-testid="stats-card-change"
>
<div className="change-percentage change-percentage--error">
<div className="change-percentage__icon">
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
</div>
@@ -56,10 +50,7 @@ function ChangePercentage({
}
return (
<div
className="change-percentage change-percentage--no-previous-data"
data-testid="stats-card-change"
>
<div className="change-percentage change-percentage--no-previous-data">
<div className="change-percentage__label">no previous data</div>
</div>
);
@@ -112,12 +103,7 @@ function StatsCard({
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
return (
<div
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
data-testid="stats-card"
data-stats-title={title}
data-empty={isEmpty ? 'true' : 'false'}
>
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
<div className="stats-card__title-wrapper">
<div className="title">{title}</div>
<div className="duration-indicator">
@@ -137,7 +123,7 @@ function StatsCard({
</div>
<div className="stats-card__stats">
<div className="count-label" data-testid="stats-card-value">
<div className="count-label">
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
</div>

View File

@@ -81,11 +81,7 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
);
return (
<div
style={{ height: '100%', width: '100%' }}
ref={graphRef}
data-testid="stats-card-sparkline"
>
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<Uplot data={[xData, yData]} options={options} />
</div>
);

View File

@@ -48,16 +48,11 @@ function TopContributorsCard({
return (
<>
<div className="top-contributors-card" data-testid="top-contributors-card">
<div className="top-contributors-card">
<div className="top-contributors-card__header">
<div className="title">top contributors</div>
{topContributorsData.length > 3 && (
<Button
type="text"
className="view-all"
onClick={toggleViewAllDrawer}
data-testid="top-contributors-view-all"
>
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
<div className="label">View all</div>
<div className="icon">
<ArrowRight

View File

@@ -68,10 +68,7 @@ function TopContributorsRows({
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div
className="total-contribution"
data-testid="top-contributors-row-count"
>
<div className="total-contribution">
{count}/{totalCurrentTriggers}
</div>
</ConditionalAlertPopover>
@@ -81,10 +78,7 @@ function TopContributorsRows({
const handleRowClick = (
record: AlertRuleTopContributors,
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
'data-testid': string;
} => ({
'data-testid': 'top-contributors-row',
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
onClick: (): void => {
logEvent('Alert history: Top contributors row: Clicked', {
labels: record.labels,

View File

@@ -31,10 +31,7 @@ function ViewAllDrawer({
}}
title="Viewing All Contributors"
>
<div
className="top-contributors-card--view-all"
data-testid="top-contributors-drawer"
>
<div className="top-contributors-card--view-all">
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData}

View File

@@ -32,8 +32,8 @@ function GraphWrapper({
}, [data?.data]);
return (
<div className="timeline-graph" data-testid="timeline-graph">
<div className="timeline-graph__title" data-testid="timeline-graph-title">
<div className="timeline-graph">
<div className="timeline-graph__title">
{totalCurrentTriggers} triggers in {relativeTime}
</div>
<div className="timeline-graph__chart">

View File

@@ -118,10 +118,7 @@ function TimelineTableContent(): JSX.Element {
const handleRowClick = (
record: AlertRuleTimelineTableResponse,
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
'data-testid': string;
} => ({
'data-testid': 'timeline-row',
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
onClick: (): void => {
void logEvent('Alert history: Timeline table row: Clicked', {
ruleId: record.ruleID,
@@ -131,15 +128,12 @@ function TimelineTableContent(): JSX.Element {
});
return (
<div className="timeline-table" data-testid="timeline-table">
<div className="timeline-table">
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
{!isLoadingKeys && hardcodedAttributeKeys ? (
<div className="timeline-table__filter">
<div className="timeline-table__filter-row">
<div
className="timeline-table__filter-search"
data-testid="timeline-filter-search"
>
<div className="timeline-table__filter-search">
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
@@ -161,7 +155,6 @@ function TimelineTableContent(): JSX.Element {
<Skeleton.Input
className="timeline-table__filter--loading-skeleton"
active
data-testid="timeline-filter-skeleton"
/>
</div>
)}
@@ -179,17 +172,14 @@ function TimelineTableContent(): JSX.Element {
locale={{
emptyText:
isError && apiError ? (
<div className="timeline-table__error" data-testid="timeline-error">
<div className="timeline-table__error">
<ErrorContent error={apiError} />
</div>
) : undefined,
}}
footer={(): JSX.Element => (
<div className="timeline-table__pagination">
<div
className="timeline-table__pagination-info"
data-testid="timeline-footer-range"
>
<div className="timeline-table__pagination-info">
{paginationConfig.showTotal?.(totalItems, [
totalItems === 0
? 0

View File

@@ -21,14 +21,18 @@ export const timelineTableColumns = ({
sorter: true,
width: 140,
render: (value): JSX.Element => (
<AlertState state={value} showLabel testId="timeline-row-state" />
<div className="alert-rule-state">
<AlertState state={value} showLabel />
</div>
),
},
{
title: 'LABELS',
dataIndex: 'labels',
render: (labels): JSX.Element => (
<AlertLabels labels={labels} testId="timeline-row-labels" />
<div className="alert-rule-labels">
<AlertLabels labels={labels} />
</div>
),
},
{
@@ -36,10 +40,7 @@ export const timelineTableColumns = ({
dataIndex: 'unixMilli',
width: 200,
render: (value): JSX.Element => (
<div
className="alert-rule__created-at"
data-testid="timeline-row-created-at"
>
<div className="alert-rule__created-at">
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
</div>
),
@@ -52,7 +53,7 @@ export const timelineTableColumns = ({
if (!record.relatedTracesLink && !record.relatedLogsLink) {
return (
<Tooltip title="No links available for this item">
<Button type="text" ghost disabled data-testid="timeline-row-actions">
<Button type="text" ghost disabled>
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</Tooltip>
@@ -64,7 +65,7 @@ export const timelineTableColumns = ({
relatedTracesLink={record.relatedTracesLink ?? ''}
relatedLogsLink={record.relatedLogsLink ?? ''}
>
<Button type="text" ghost data-testid="timeline-row-actions">
<Button type="text" ghost>
<Ellipsis className="dropdown-icon" size="md" />
</Button>
</ConditionalAlertPopover>

View File

@@ -23,7 +23,6 @@ function TimelineTabs(): JSX.Element {
{
value: TimelineTab.OVERALL_STATUS,
label: 'Overall Status',
testId: 'timeline-tab-overall-status',
},
{
value: TimelineTab.TOP_5_CONTRIBUTORS,
@@ -34,7 +33,6 @@ function TimelineTabs(): JSX.Element {
</div>
),
disabled: true,
testId: 'timeline-tab-top-contributors',
},
];
@@ -59,17 +57,14 @@ function TimelineFilters(): JSX.Element {
{
value: TimelineFilter.ALL,
label: 'All',
testId: 'timeline-filter-all',
},
{
value: TimelineFilter.FIRED,
label: 'Fired',
testId: 'timeline-filter-fired',
},
{
value: TimelineFilter.RESOLVED,
label: 'Resolved',
testId: 'timeline-filter-resolved',
},
];

View File

@@ -34,7 +34,6 @@ function AdvancedOptions(): JSX.Element {
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
testId="send-notification-if-data-is-missing-input"
/>
<Typography.Text>Minutes</Typography.Text>
</div>
@@ -67,7 +66,6 @@ function AdvancedOptions(): JSX.Element {
})
}
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
testId="enforce-minimum-datapoints-input"
/>
<Typography.Text>Datapoints</Typography.Text>
</div>

View File

@@ -66,7 +66,6 @@ function EvaluationWindowPopover({
tabIndex={0}
data-value={option.value}
data-section-id={sectionId}
data-testid={`${sectionId}-option-${option.value}`}
onClick={(): void => onChange(option.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {

View File

@@ -186,7 +186,6 @@ function Footer(): JSX.Element {
color="primary"
onClick={handleSaveAlert}
disabled={disableButtons || Boolean(alertValidationMessage)}
testId="save-alert-rule-button"
>
{isCreatingAlertRule || isUpdatingAlertRule ? (
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
@@ -219,7 +218,6 @@ function Footer(): JSX.Element {
color="secondary"
onClick={handleTestNotification}
disabled={disableButtons || Boolean(alertValidationMessage)}
testId="test-notification-button"
>
{isTestingAlertRule ? (
<Loader data-testid="test-notification-loader-icon" size={14} />
@@ -251,7 +249,6 @@ function Footer(): JSX.Element {
color="secondary"
onClick={handleDiscard}
disabled={disableButtons}
testId="discard-alert-rule-button"
>
<X size={14} /> Discard
</Button>

View File

@@ -38,6 +38,9 @@ export default function ChartWrapper({
customTooltip,
pinnedTooltipElement,
tooltipPortalRoot,
customLegend,
legendLabels,
contentFooter,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
@@ -47,6 +50,10 @@ export default function ChartWrapper({
if (!showLegend) {
return null;
}
// Charts whose legend does not list uPlot series supply their own.
if (customLegend) {
return customLegend(averageLegendWidth);
}
return (
<UPlotLegend
config={config}
@@ -55,7 +62,7 @@ export default function ChartWrapper({
/>
);
},
[config, legendConfig.position, showLegend],
[config, legendConfig.position, showLegend, customLegend],
);
const renderTooltipCallback = useCallback(
@@ -86,6 +93,8 @@ export default function ChartWrapper({
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
seriesLabels={legendLabels}
contentFooter={contentFooter}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (

View File

@@ -0,0 +1,311 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import ColorBar from 'lib/uPlotV2/components/ColorBar/ColorBar';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import HeatmapTooltip from 'lib/uPlotV2/components/Tooltip/HeatmapTooltip';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import {
createHeatmapColorResolver,
DEFAULT_HEATMAP_COLORS,
resolveCountDomain,
resolveExtremeColor,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
resolveGroupPeaks,
resolveHeatmapGrid,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
import {
HeatmapAxisScale,
HeatmapCell,
HeatmapColorMode,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { HeatmapChartProps } from './types';
import { useHeatmapGroupLegend } from './useHeatmapGroupLegend';
import { buildHeatmapConfig, prepareHeatmapChartData } from './utils';
/** Vertical space the colour bar takes out of the container. */
const COLOR_BAR_HEIGHT = 28;
/**
* Columns are time slices, rows are bucket ranges, cell colour is the observation
* count — so a distribution can be watched changing shape instead of collapsing to
* percentile lines. Drawn on canvas (see `createHeatmapHooks`): a 40 × 240 grid is
* ~9,600 cells, far past what per-cell DOM carries.
*/
export default function Heatmap(props: HeatmapChartProps): JSX.Element {
const {
id,
buckets,
step,
series,
width,
height,
isDarkMode,
axisScale = HeatmapAxisScale.Log,
yAxisUnit,
decimalPrecision,
timezone,
showVisualMap = true,
showLegend = true,
legendPosition = LegendPosition.BOTTOM,
dimOnHover = true,
showTooltip = true,
canPinTooltip = false,
pinKey,
seriesColor,
minTimeScale,
maxTimeScale,
onDragSelect,
onCellClick,
renderTooltipFooter,
tooltipPortalRoot,
layoutChildren,
'data-testid': testId,
} = props;
const [hoveredCell, setHoveredCell] = useState<HeatmapCell | null>(null);
const hoveredCellRef = useRef<HeatmapCell | null>(null);
const onCellClickRef = useRef(onCellClick);
onCellClickRef.current = onCellClick;
const groups = useMemo(() => series.map((entry) => entry.label), [series]);
// One series has nothing to choose between.
const hasGroupLegend = showLegend && groups.length > 1;
const colors = useMemo(
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
[props.colors],
);
// The opacity fill no longer follows a group colour: with several groups enabled
// at once there is no single one to follow.
const resolvedSeriesColor = seriesColor ?? DEFAULT_HEATMAP_COLORS.fill;
// Opacity mode keeps the solid fill; a partially transparent marker is hard to
// read against the panel.
const extremeColor = resolveExtremeColor({
options: colors,
isDarkMode,
seriesColor: resolvedSeriesColor,
});
const {
visibleGroups,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = useHeatmapGroupLegend({ groups });
const grid = useMemo(
() => resolveHeatmapGrid({ buckets, step, series, visibleGroups }),
[buckets, step, series, visibleGroups],
);
const yAxis = useMemo(
() => resolveHeatmapYAxis(grid.bounds, axisScale),
[grid.bounds, axisScale],
);
const hasGrid = yAxis.rows.length > 0 && grid.timestamps.length > 0;
const data = useMemo(
() =>
hasGrid
? prepareHeatmapChartData(grid, yAxis.rows.length)
: ([[]] as unknown as ReturnType<typeof prepareHeatmapChartData>),
[grid, yAxis.rows.length, hasGrid],
);
const colorResolver = useMemo(
() =>
createHeatmapColorResolver({
options: colors,
domain: resolveCountDomain(colors, grid.counts),
isDarkMode,
seriesColor: resolvedSeriesColor,
}),
[colors, grid.counts, isDarkMode, resolvedSeriesColor],
);
// Stable: the renderer captures it at config-build time, so a new identity would
// recreate the plot on every hover.
const handleHoverChange = useCallback((cell: HeatmapCell | null): void => {
hoveredCellRef.current = cell;
setHoveredCell(cell);
}, []);
const config = useMemo(
() =>
buildHeatmapConfig({
id,
grid,
yAxis,
colors,
isDarkMode,
seriesColor: resolvedSeriesColor,
dimOnHover,
onHoverChange: handleHoverChange,
yAxisUnit,
decimalPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
id,
grid,
yAxis,
colors,
isDarkMode,
resolvedSeriesColor,
dimOnHover,
handleHoverChange,
yAxisUnit,
decimalPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
],
);
// Each marker takes the ramp colour for where that group's densest cell falls on
// the colour bar, so a swatch reads against the same scale as the grid.
const groupPeaks = useMemo(() => resolveGroupPeaks(series), [series]);
const isPaletteMode = colors.mode === HeatmapColorMode.Palette;
const legendItems = useMemo<LegendItem[]>(
() =>
groups.map((group, index) => ({
// +1 mirrors uPlot's 1-based data series, so the shared legend's index
// handling is identical across charts.
seriesIndex: index + 1,
label: group,
color: isPaletteMode
? (colorResolver.colorFor(groupPeaks.get(group) ?? 0) ?? extremeColor)
: extremeColor,
show: visibleGroups.includes(group),
})),
[
groups,
visibleGroups,
isPaletteMode,
colorResolver,
groupPeaks,
extremeColor,
],
);
const renderTooltip = useCallback(
(args: TooltipRenderArgs): React.ReactNode => (
<HeatmapTooltip
{...args}
id={id}
yAxis={yAxis}
step={grid.step}
series={series}
visibleGroups={visibleGroups}
groupColor={extremeColor}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
timezone={timezone}
canPinTooltip={canPinTooltip}
renderTooltipFooter={renderTooltipFooter}
/>
),
[
id,
yAxis,
grid.step,
series,
visibleGroups,
extremeColor,
yAxisUnit,
decimalPrecision,
timezone,
canPinTooltip,
renderTooltipFooter,
],
);
const handleClick = useCallback((clickData: ChartClickData): void => {
if (hoveredCellRef.current) {
onCellClickRef.current?.(hoveredCellRef.current, clickData);
}
}, []);
const groupLegend = useCallback(
(averageLegendWidth: number): React.ReactNode => (
<Legend
items={legendItems}
position={legendPosition}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
),
[
legendItems,
legendPosition,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
],
);
const visualMap = useMemo(() => {
if (!showVisualMap || !hasGrid) {
return null;
}
return (
<ColorBar
label="count"
ramp={colorResolver.ramp}
minLabel={colorResolver.domain.min.toLocaleString()}
maxLabel={colorResolver.domain.max.toLocaleString()}
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
/>
);
}, [showVisualMap, hasGrid, colorResolver, hoveredCell]);
return (
<ChartWrapper
config={config}
data={data}
width={width}
height={
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
}
legendConfig={{ position: legendPosition }}
showLegend={hasGroupLegend}
customLegend={groupLegend}
legendLabels={groups}
showTooltip={showTooltip}
canPinTooltip={canPinTooltip}
pinKey={pinKey}
onClick={onCellClick ? handleClick : undefined}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
timezone={timezone}
customTooltip={renderTooltip}
renderTooltipFooter={renderTooltipFooter}
tooltipPortalRoot={tooltipPortalRoot}
contentFooter={visualMap}
layoutChildren={layoutChildren}
data-testid={testId}
/>
);
}

View File

@@ -0,0 +1,252 @@
import type React from 'react';
import userEvent from '@testing-library/user-event';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { render, screen } from 'tests/test-utils';
import {
createHeatmapColorResolver,
DEFAULT_HEATMAP_COLORS,
resolveCountDomain,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
import {
HeatmapColorMode,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import Heatmap from '../Heatmap';
// The shared Legend virtualises its items; render them all so they are queryable.
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div>
{data.map((item, index) => (
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
))}
</div>
),
}));
const BUCKETS = [128, 256, 1024];
const STEP = 60;
/** Two groups whose counts sum to a peak of 1,204 in the combined view. */
const SERIES: HeatmapSeries[] = [
{
label: 'service.name=cart',
points: [
{ timestamp: 1000, counts: [1, 4, 7, 10] },
{ timestamp: 1060, counts: [2, null, 8, 11] },
{ timestamp: 1120, counts: [3, 6, 9, 1200] },
],
},
{
label: 'service.name=checkout',
points: [{ timestamp: 1120, counts: [0, 0, 0, 4] }],
},
];
function renderHeatmap(
props: Partial<React.ComponentProps<typeof Heatmap>> = {},
): ReturnType<typeof render> {
return render(
<Heatmap
id="panel-1"
buckets={BUCKETS}
step={STEP}
series={SERIES}
width={800}
height={400}
isDarkMode
data-testid="heatmap"
{...props}
/>,
);
}
describe('Heatmap', () => {
it('renders the plot container', () => {
renderHeatmap();
expect(screen.getByTestId('heatmap')).toBeInTheDocument();
});
it('shows the colour bar with the resolved count domain', () => {
renderHeatmap();
expect(screen.getByTestId('color-bar')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('1,204')).toBeInTheDocument();
});
it('puts the colour bar against the plot, with the legend after it', () => {
renderHeatmap();
const bar = screen.getByTestId('color-bar');
const legend = screen.getByText('service.name=cart').closest('.legend-item');
// The bar is the scale key for the grid, so it reads before the controls.
expect(
bar.compareDocumentPosition(legend as Node) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('keeps the colour bar inside the chart column, not below the legend', () => {
renderHeatmap();
expect(
screen.getByTestId('color-bar').closest('.chart-layout__content'),
).not.toBeNull();
});
it('hides the colour bar when the visual map is off', () => {
renderHeatmap({ showVisualMap: false });
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
});
it('labels the colour bar with an explicit clamp instead of the data range', () => {
renderHeatmap({ colors: { minCount: 5, maxCount: 500 } });
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('500')).toBeInTheDocument();
});
it('falls back to the no-data state when the metric has no buckets', () => {
renderHeatmap({ buckets: [] });
expect(screen.getByText('No Data')).toBeInTheDocument();
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
});
it('falls back to the no-data state when no columns came back', () => {
renderHeatmap({ series: [] });
expect(screen.getByText('No Data')).toBeInTheDocument();
});
});
describe('Heatmap group legend', () => {
const CART = 'service.name=cart';
const CHECKOUT = 'service.name=checkout';
function legendItem(label: string): HTMLElement {
const item = screen.getByText(label).closest('.legend-item');
if (!item) {
throw new Error(`no legend item for ${label}`);
}
return item as HTMLElement;
}
function marker(label: string): HTMLElement {
const element = legendItem(label).querySelector<HTMLElement>(
'[data-is-legend-marker]',
);
if (!element) {
throw new Error(`no marker for ${label}`);
}
return element;
}
it('lists the groups, with no combined-view entry', () => {
renderHeatmap();
expect(screen.getByText(CART)).toBeInTheDocument();
expect(screen.getByText(CHECKOUT)).toBeInTheDocument();
expect(screen.queryByText(/all groups/i)).not.toBeInTheDocument();
});
it('enables every group to begin with', () => {
renderHeatmap();
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
expect(legendItem(CHECKOUT)).not.toHaveClass('legend-item-off');
});
it('isolates a group when its label is clicked', async () => {
renderHeatmap();
await userEvent.click(screen.getByText(CART));
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
});
it('restores every group when the isolated label is clicked again', async () => {
renderHeatmap();
await userEvent.click(screen.getByText(CART));
await userEvent.click(screen.getByText(CART));
expect(legendItem(CHECKOUT)).not.toHaveClass('legend-item-off');
});
it('excludes just one group when its marker is clicked', async () => {
renderHeatmap();
await userEvent.click(marker(CHECKOUT));
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
});
/** The ramp the cells and colour bar are drawn from, for the default options. */
function activeRamp(): string[] {
const grid = resolveHeatmapGrid({
buckets: BUCKETS,
step: STEP,
series: SERIES,
});
return createHeatmapColorResolver({
options: DEFAULT_HEATMAP_COLORS,
domain: resolveCountDomain(DEFAULT_HEATMAP_COLORS, grid.counts),
isDarkMode: true,
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
}).ramp.map((color) => color.toLowerCase());
}
it('places each marker where its group sits on the colour bar', () => {
renderHeatmap();
const ramp = activeRamp();
// cart peaks at 1200, checkout at 4, so cart sits further along the ramp.
// The DOM lowercases hex; the ramp is built uppercase.
const cart = ramp.indexOf(marker(CART).style.borderColor.toLowerCase());
const checkout = ramp.indexOf(
marker(CHECKOUT).style.borderColor.toLowerCase(),
);
expect(cart).toBeGreaterThan(-1);
expect(checkout).toBeGreaterThan(-1);
expect(cart).toBeGreaterThan(checkout);
});
it('gives every marker the solid fill in opacity mode', () => {
renderHeatmap({
colors: { mode: HeatmapColorMode.Opacity, fill: '#e5484d' },
});
// A partially transparent marker is hard to read against the panel.
expect(marker(CART).style.borderColor).toBe(
marker(CHECKOUT).style.borderColor,
);
expect(marker(CART).style.borderColor).not.toBe('');
});
it('hides the legend when there is only one group to choose from', () => {
renderHeatmap({ series: [SERIES[0]] });
expect(screen.queryByText(CART)).not.toBeInTheDocument();
});
it('hides the legend when asked', () => {
renderHeatmap({ showLegend: false });
expect(screen.queryByText(CART)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,147 @@
import { act, renderHook } from '@testing-library/react';
import type { MouseEvent } from 'react';
import { useHeatmapGroupLegend } from '../useHeatmapGroupLegend';
const GROUPS = ['cart', 'checkout', 'payments'];
/** Mimics a click on an item's label, as the shared Legend renders it. */
function labelClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
const wrapper = document.createElement('div');
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
const label = document.createElement('span');
wrapper.appendChild(label);
return { target: label } as unknown as MouseEvent<HTMLDivElement>;
}
/** Mimics a click on the item's marker circle. */
function markerClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
const wrapper = document.createElement('div');
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
const marker = document.createElement('div');
marker.dataset.isLegendMarker = 'true';
wrapper.appendChild(marker);
return { target: marker } as unknown as MouseEvent<HTMLDivElement>;
}
function render(
groups: string[] = GROUPS,
): ReturnType<
typeof renderHook<ReturnType<typeof useHeatmapGroupLegend>, unknown>
> {
return renderHook(() => useHeatmapGroupLegend({ groups }));
}
describe('useHeatmapGroupLegend', () => {
it('enables every group to begin with', () => {
const { result } = render();
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('isolates a group when its label is clicked', () => {
const { result } = render();
act(() => result.current.onLegendClick(labelClick(2)));
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
});
it('restores every group when the isolated label is clicked again', () => {
const { result } = render();
act(() => result.current.onLegendClick(labelClick(2)));
act(() => result.current.onLegendClick(labelClick(2)));
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('moves the isolation when a different label is clicked', () => {
const { result } = render();
act(() => result.current.onLegendClick(labelClick(1)));
act(() => result.current.onLegendClick(labelClick(3)));
expect(result.current.visibleGroups).toStrictEqual(['payments']);
});
it('excludes just one group when its marker is clicked', () => {
const { result } = render();
act(() => result.current.onLegendClick(markerClick(2)));
expect(result.current.visibleGroups).toStrictEqual(['cart', 'payments']);
});
it('re-includes a group when its marker is clicked again', () => {
const { result } = render();
act(() => result.current.onLegendClick(markerClick(2)));
act(() => result.current.onLegendClick(markerClick(2)));
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('excludes more than one group', () => {
const { result } = render();
act(() => result.current.onLegendClick(markerClick(1)));
act(() => result.current.onLegendClick(markerClick(3)));
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
});
it('drops the isolation when a marker is clicked, so the label can isolate again', () => {
const { result } = render();
act(() => result.current.onLegendClick(labelClick(1)));
// Re-including cart by marker leaves it enabled but no longer isolated.
act(() => result.current.onLegendClick(markerClick(2)));
act(() => result.current.onLegendClick(labelClick(1)));
expect(result.current.visibleGroups).toStrictEqual(['cart']);
});
it('allows every group to be excluded, as the other legends do', () => {
const { result } = render();
GROUPS.forEach((_, index) =>
act(() => result.current.onLegendClick(markerClick(index + 1))),
);
expect(result.current.visibleGroups).toStrictEqual([]);
});
it('ignores clicks that miss an entry', () => {
const { result } = render();
const stray = {
target: document.createElement('div'),
} as unknown as MouseEvent<HTMLDivElement>;
act(() => result.current.onLegendClick(stray));
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('forgets a hidden group that left the result', () => {
const { result, rerender } = renderHook(
({ groups }) => useHeatmapGroupLegend({ groups }),
{ initialProps: { groups: GROUPS } },
);
act(() => result.current.onLegendClick(markerClick(3)));
rerender({ groups: ['cart', 'checkout'] });
expect(result.current.visibleGroups).toStrictEqual(['cart', 'checkout']);
});
it('tracks the hovered entry for the legend"s focus highlight', () => {
const { result } = render();
act(() => result.current.onLegendMouseMove(labelClick(2)));
expect(result.current.focusedSeriesIndex).toBe(2);
act(() => result.current.onLegendMouseLeave());
expect(result.current.focusedSeriesIndex).toBeNull();
});
});

View File

@@ -0,0 +1,195 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import {
HeatmapAxisScale,
HeatmapGrid,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type uPlot from 'uplot';
import { buildHeatmapConfig, prepareHeatmapChartData } from '../utils';
const GRID: HeatmapGrid = {
bounds: [128, 256, 1024],
timestamps: [1000, 1060, 1120],
step: 60,
counts: [
[1, 2, 3],
[4, null, 6],
[7, 8, 9],
[0, 0, 0],
],
};
const Y_AXIS = resolveHeatmapYAxis(GRID.bounds, HeatmapAxisScale.Log);
/** Tall enough that no tick needs thinning. */
const TALL_PLOT = { bbox: { height: 1000 } } as uPlot;
function readSplits(
config: ReturnType<typeof buildHeatmapConfig>,
plot: uPlot,
): number[] {
const [, yAxisConfig] = config.getConfig().axes ?? [];
return (yAxisConfig.splits as (self: uPlot) => number[])(plot);
}
function readLabels(
config: ReturnType<typeof buildHeatmapConfig>,
splits: number[],
): string[] {
const [, yAxisConfig] = config.getConfig().axes ?? [];
return (yAxisConfig.values as (u: uPlot, splits: number[]) => string[])(
{} as uPlot,
splits,
);
}
function readRange(scale?: uPlot.Scale): [number, number] {
const range = scale?.range as (
u: uPlot,
min: number,
max: number,
) => [number, number];
return range({} as uPlot, 0, 0);
}
function buildConfig(
overrides: Partial<Parameters<typeof buildHeatmapConfig>[0]> = {},
): ReturnType<typeof buildHeatmapConfig> {
return buildHeatmapConfig({
id: 'panel-1',
grid: GRID,
yAxis: Y_AXIS,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
...overrides,
});
}
describe('prepareHeatmapChartData', () => {
it('puts timestamps first and one series per bucket row', () => {
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
expect(data).toHaveLength(Y_AXIS.rows.length + 1);
expect(data[0]).toStrictEqual(GRID.timestamps);
expect(data[1]).toStrictEqual([1, 2, 3]);
});
it('preserves null cells rather than zeroing them', () => {
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
expect(data[2]).toStrictEqual([4, null, 6]);
});
it('pads short rows so every uPlot data array is the same length', () => {
const data = prepareHeatmapChartData(
{ ...GRID, counts: [[1]] },
Y_AXIS.rows.length,
);
expect(data[1]).toStrictEqual([1, null, null]);
});
it('pads missing rows up to the resolved row count', () => {
const data = prepareHeatmapChartData({ ...GRID, counts: [] }, 2);
expect(data).toHaveLength(3);
expect(data[2]).toStrictEqual([null, null, null]);
});
});
describe('buildHeatmapConfig', () => {
it('registers one series per bucket row, plus uPlot"s timestamp series', () => {
const config = buildHeatmapConfig({
id: 'panel-1',
grid: GRID,
yAxis: Y_AXIS,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
}).getConfig();
expect(config.series).toHaveLength(Y_AXIS.rows.length + 1);
});
it('draws no paths or points per series — the renderer paints the cells', () => {
const [, firstRow] = buildConfig().getConfig().series ?? [];
expect((firstRow as uPlot.Series).paths?.({} as uPlot, 1, 0, 1)).toBeNull();
expect((firstRow as uPlot.Series).points?.show).toBe(false);
});
it('labels series by bucket range, including the open-ended rows', () => {
const labels = (buildConfig().getConfig().series ?? [])
.slice(1)
.map((series) => series.label);
expect(labels[0]).toContain('≤');
expect(labels[labels.length - 1]).toContain('>');
});
it('spans the x scale to the end of the last column, not its start', () => {
const { x } = buildConfig().getConfig().scales ?? {};
expect(readRange(x)).toStrictEqual([1000, 1180]);
});
it('prefers the query window over the grid extent', () => {
const { x } =
buildConfig({ minTimeScale: 900, maxTimeScale: 1500 }).getConfig().scales ??
{};
expect(readRange(x)).toStrictEqual([900, 1500]);
});
it('pins the y scale to the bucket axis instead of auto-ranging on counts', () => {
const { y } = buildConfig().getConfig().scales ?? {};
expect(y?.auto).toBe(false);
expect(readRange(y)).toStrictEqual([Y_AXIS.min, Y_AXIS.max]);
});
it('puts a y tick on every bucket boundary plus the overflow row"s upper edge', () => {
const splits = readSplits(buildConfig(), TALL_PLOT);
expect(splits).toStrictEqual([...Y_AXIS.splits, Y_AXIS.overflowSplit]);
});
it('labels the overflow edge as infinite and the rest by bucket value', () => {
const config = buildConfig();
const labels = readLabels(config, readSplits(config, TALL_PLOT));
expect(labels[0]).toBe('128');
expect(labels[labels.length - 1]).toBe('∞');
});
it('thins the tick set when the panel is too short to label every boundary', () => {
const config = buildConfig();
const splits = readSplits(config, { bbox: { height: 40 } } as uPlot);
expect(splits.length).toBeLessThan(Y_AXIS.splits.length + 1);
// The infinite edge is the one label that must never be dropped.
expect(readLabels(config, splits).at(-1)).toBe('∞');
});
it('disables uPlot cursor focus and points, which cannot read a colour axis', () => {
const config = buildConfig().getConfig();
expect(config.cursor?.focus?.prox).toBe(-1);
expect(config.cursor?.points?.show).toBe(false);
});
it('keeps focus alpha at 1 so focusing a row does not force a full redraw', () => {
expect(buildConfig().getConfig().focus?.alpha).toBe(1);
});
it('registers the renderer hooks', () => {
const { hooks } = buildConfig().getConfig();
expect(hooks?.init).toHaveLength(1);
expect(hooks?.draw).toHaveLength(1);
expect(hooks?.setCursor).toHaveLength(1);
expect(hooks?.destroy).toHaveLength(1);
});
});

View File

@@ -0,0 +1,67 @@
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import type { PrecisionOption } from 'components/Graph/types';
import type {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import type {
HeatmapAxisScale,
HeatmapCell,
HeatmapColorOptions,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
/**
* Data arrives as the query response carries it — bucket bounds plus one series per
* group — and the chart pivots and sums it, so no caller has to get the transpose
* or the combined view right. It builds its own `UPlotConfigBuilder` too, since the
* y axis *is* the bucket axis and `buckets` fully determines it.
*
* `buckets`, `series` and `colors` must be referentially stable: a new identity
* rebuilds the config, which recreates the plot.
*/
export interface HeatmapChartProps {
id: string;
/** Ascending. N boundaries describe N+1 rows. */
buckets: number[];
/** The *effective* step the server used (`meta.stepIntervals[queryName]`), not
* the requested one. Cannot be inferred: the last column has no successor. */
step: number;
/** One entry per group; a query without grouping yields one series. */
series: HeatmapSeries[];
width: number;
height: number;
isDarkMode: boolean;
/** Overrides on top of `DEFAULT_HEATMAP_COLORS`. */
colors?: Partial<HeatmapColorOptions>;
/** Default log. */
axisScale?: HeatmapAxisScale;
/** Unit of the bucket boundaries; counts are always plain numbers. */
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
timezone?: Timezone;
/** Colour bar below the grid. Default true. */
showVisualMap?: boolean;
/** Default true; hidden anyway when there is only one group. Every group starts
* enabled — the label isolates one, the marker excludes one. */
showLegend?: boolean;
legendPosition?: LegendPosition;
/** Default true. */
dimOnHover?: boolean;
showTooltip?: boolean;
canPinTooltip?: boolean;
pinKey?: string;
/** Overrides the opacity-mode fill, which otherwise follows the selected
* group's legend colour so the grid matches the swatch that was clicked. */
seriesColor?: string;
/** Query window, in seconds. Falls back to the data's own extent. */
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect?: (startTime: number, endTime: number) => void;
onCellClick?: (cell: HeatmapCell, clickData: ChartClickData) => void;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
tooltipPortalRoot?: HTMLElement | null;
layoutChildren?: React.ReactNode;
'data-testid'?: string;
}

View File

@@ -0,0 +1,100 @@
import { MouseEvent, useCallback, useMemo, useRef, useState } from 'react';
export interface UseHeatmapGroupLegendResult {
/** Groups currently enabled. The grid sums exactly these. */
visibleGroups: string[];
focusedSeriesIndex: number | null;
onLegendClick: (event: MouseEvent<HTMLDivElement>) => void;
onLegendMouseMove: (event: MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
}
/** The shared Legend tags each item and delegates interaction to the container. */
function getLegendIndex(event: MouseEvent<HTMLDivElement>): number | null {
const element = (event.target as HTMLElement | null)?.closest<HTMLElement>(
'[data-legend-item-id]',
);
const id = element?.dataset.legendItemId;
return id === undefined ? null : Number(id);
}
function isMarkerClick(event: MouseEvent<HTMLDivElement>): boolean {
return Boolean((event.target as HTMLElement).dataset.isLegendMarker);
}
/**
* Group visibility for the heatmap legend, matching every other legend in the
* product: the label isolates a group, the marker excludes one, and everything is
* enabled to begin with. Counts are additive, so whatever is enabled is summed
* client-side and needs no extra request.
*
* Visibility only. Marker colour is resolved by the caller, which owns the colour
* ramp — and that ramp depends on which groups this hook has enabled.
*/
export function useHeatmapGroupLegend({
groups,
}: {
groups: string[];
}): UseHeatmapGroupLegendResult {
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
null,
);
const isolatedRef = useRef<string | null>(null);
const visibleGroups = useMemo(
() => groups.filter((group) => !hidden.has(group)),
[groups, hidden],
);
const onLegendClick = useCallback(
(event: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(event);
const group = index === null ? undefined : groups[index - 1];
if (group === undefined) {
return;
}
if (isMarkerClick(event)) {
isolatedRef.current = null;
setHidden((previous) => {
const next = new Set(previous);
if (next.has(group)) {
next.delete(group);
} else {
next.add(group);
}
return next;
});
return;
}
// Label click isolates; clicking the isolated group again restores all.
const isReset = isolatedRef.current === group;
isolatedRef.current = isReset ? null : group;
setHidden(
isReset ? new Set() : new Set(groups.filter((entry) => entry !== group)),
);
},
[groups],
);
const onLegendMouseMove = useCallback(
(event: MouseEvent<HTMLDivElement>): void => {
setFocusedSeriesIndex(getLegendIndex(event));
},
[],
);
const onLegendMouseLeave = useCallback((): void => {
setFocusedSeriesIndex(null);
}, []);
return {
visibleGroups,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -0,0 +1,193 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
decimateAxisSplits,
formatRowLabel,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
createHeatmapHooks,
HeatmapRenderOptions,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin';
import { HeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import uPlot from 'uplot';
/** Minimum gap between y tick labels, in CSS pixels. */
const MIN_Y_TICK_GAP_PX = 18;
/** Label for the edge above the overflow row. */
const OVERFLOW_AXIS_LABEL = '∞';
/**
* Flattens the grid into `[timestamps, ...rows]`, one series per bucket row so
* `setData` handles refetches. The series draw nothing; the renderer paints cells.
*
* Rows are padded to `rowCount` — which can differ from `bounds.length + 1` when
* the response carried duplicate boundaries — since uPlot requires equal lengths.
*/
export function prepareHeatmapChartData(
grid: HeatmapGrid,
rowCount: number,
): uPlot.AlignedData {
const columnCount = grid.timestamps.length;
const rows = Array.from({ length: rowCount }, (_, row) => {
const counts = grid.counts[row] ?? [];
return Array.from({ length: columnCount }, (_, column) =>
counts[column] === undefined ? null : counts[column],
);
});
return [grid.timestamps, ...rows] as unknown as uPlot.AlignedData;
}
export interface BuildHeatmapConfigArgs extends Omit<
HeatmapRenderOptions,
'step'
> {
id: string;
grid: HeatmapGrid;
/** Unit of the bucket boundaries; counts are never formatted with it. */
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
timezone?: Timezone;
/** Query window, in seconds. Falls back to the grid's own extent. */
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect?: (startTime: number, endTime: number) => void;
}
export function buildHeatmapConfig({
id,
grid,
yAxis,
colors,
isDarkMode,
seriesColor,
dimOnHover,
onHoverChange,
yAxisUnit,
decimalPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}: BuildHeatmapConfigArgs): UPlotConfigBuilder {
const tzDate = timezone
? (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
: undefined;
const builder = new UPlotConfigBuilder({ id, onDragSelect, tzDate });
// uPlot's focus picks the series closest in value space, meaningless when the
// value is a colour; the renderer focuses the hovered row itself. alpha 1 keeps
// that call off uPlot's full-redraw path.
builder.setFocus({ alpha: 1 });
builder.setCursor({ focus: { prox: -1 }, points: { show: false } });
const formatBucketValue = (value: number): string =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
const lastTimestamp = grid.timestamps[grid.timestamps.length - 1] ?? 0;
const xRange: [number, number] = [
minTimeScale ?? grid.timestamps[0] ?? 0,
maxTimeScale ?? lastTimestamp + grid.step,
];
builder.addScale({
scaleKey: 'x',
time: true,
range: (): [number, number] => xRange,
});
builder.addScale({
scaleKey: 'y',
time: false,
auto: false,
range: (): [number, number] => [yAxis.min, yAxis.max],
});
builder.addAxis({
scaleKey: 'x',
side: 2,
isDarkMode,
values: uPlotXAxisValuesFormat as uPlot.Axis.Values,
});
// Ticks sit on row edges, so the overflow row is the band between the last
// boundary and `∞`. A centre label would sit half a row from the boundary tick
// and collide with it.
const overflowRow = yAxis.rows[yAxis.rows.length - 1];
const hasOverflowTick =
yAxis.overflowSplit !== null && overflowRow?.isOverflow === true;
const axisSplits = hasOverflowTick
? [...yAxis.splits, yAxis.overflowSplit as number]
: yAxis.splits;
// From the boundaries themselves, not by inverting the transform:
// 10 ** Math.log10(128) is 127.999…, which formats as "127.99".
const splitLabels = new Map<number, string>();
yAxis.splits.forEach((split, index) => {
splitLabels.set(split, formatBucketValue(yAxis.rows[index].upper));
});
if (hasOverflowTick) {
splitLabels.set(yAxis.overflowSplit as number, OVERFLOW_AXIS_LABEL);
}
builder.addAxis({
scaleKey: 'y',
side: 3,
isDarkMode,
yAxisUnit,
decimalPrecision,
// Thinned to whatever fits: a histogram can carry more boundaries than the
// panel has room to label.
splits: (self): number[] =>
decimateAxisSplits({
splits: axisSplits,
min: yAxis.min,
max: yAxis.max,
plotHeight: self.bbox.height / uPlot.pxRatio,
minGapPx: MIN_Y_TICK_GAP_PX,
}),
values: (_, splits): string[] =>
splits.map(
(split) =>
splitLabels.get(split) ?? formatBucketValue(yAxis.toBucketValue(split)),
),
});
yAxis.rows.forEach((row) => {
builder.addSeries({
scaleKey: 'y',
// Nothing is stroked per series; the draw hook paints the grid.
drawStyle: DrawStyle.Line,
pathBuilder: (): null => null,
showPoints: false,
spanGaps: false,
label: formatRowLabel(row, formatBucketValue),
colorMapping: {},
isDarkMode,
});
});
const hooks = createHeatmapHooks({
yAxis,
step: grid.step,
colors,
isDarkMode,
seriesColor,
dimOnHover,
onHoverChange,
});
// Order matters — see the HeatmapHooks doc comment.
builder.addHook('init', hooks.init);
builder.addHook('draw', hooks.draw);
builder.addHook('setCursor', hooks.setCursor);
builder.addHook('destroy', hooks.destroy);
return builder;
}

View File

@@ -31,6 +31,13 @@ interface BaseChartProps {
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
tooltipPortalRoot?: HTMLElement | null;
/** Replaces the config-driven legend, for charts whose legend lists something
* other than uPlot series — heatmap groups, where the series are bucket rows. */
customLegend?: (averageLegendWidth: number) => React.ReactNode;
/** Measured against for the chart/legend split. Pair with `customLegend`. */
legendLabels?: string[];
/** Rendered under the plot but above the legend, inside the chart column. */
contentFooter?: React.ReactNode;
'data-testid'?: string;
}
interface UPlotBasedChartProps {

View File

@@ -16,20 +16,31 @@ export interface ChartLayoutProps {
averageLegendWidth: number;
}) => React.ReactNode;
layoutChildren?: React.ReactNode;
/**
* Rendered directly under the plot, inside the chart column — so it stays next to
* the axis with the legend below it, and beside a RIGHT legend rather than under
* it. `layoutChildren` sits below everything instead.
*/
contentFooter?: React.ReactNode;
containerWidth: number;
containerHeight: number;
legendConfig: LegendConfig;
config: UPlotConfigBuilder;
/** Defaults to the chart's series labels. Pass them when the legend lists
* something else, or the split is measured against the wrong text. */
seriesLabels?: string[];
}
export default function ChartLayout({
showLegend = true,
legendComponent,
children,
layoutChildren,
contentFooter,
containerWidth,
containerHeight,
legendConfig,
config,
seriesLabels,
}: ChartLayoutProps): JSX.Element {
const chartDimensions = useMemo(
() => {
@@ -42,19 +53,20 @@ export default function ChartLayout({
averageLegendWidth: MAX_LEGEND_WIDTH,
};
}
const legendItemsMap = config.getLegendItems();
const seriesLabels = Object.values(legendItemsMap)
.map((item) => item.label)
.filter((label): label is string => label !== undefined);
const resolvedLabels =
seriesLabels ??
Object.values(config.getLegendItems())
.map((item) => item.label)
.filter((label): label is string => label !== undefined);
return calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig,
seriesLabels,
seriesLabels: resolvedLabels,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[containerWidth, containerHeight, legendConfig, showLegend],
[containerWidth, containerHeight, legendConfig, showLegend, seriesLabels],
);
return (
@@ -72,6 +84,7 @@ export default function ChartLayout({
chartHeight: chartDimensions.height,
averageLegendWidth: chartDimensions.averageLegendWidth,
})}
{contentFooter}
</div>
{showLegend && (
<div

View File

@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react';
import { Select, Spin } from 'antd';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { StringOperators } from 'types/common/queryBuilder';

View File

@@ -119,7 +119,6 @@ function BasicInfo({
<SeveritySelect
getPopupContainer={popupContainer}
defaultValue="critical"
data-testid="alert-severity-select"
onChange={(value: unknown | string): void => {
const s = (value as string) || 'critical';
setAlertDef({
@@ -148,7 +147,6 @@ function BasicInfo({
]}
>
<InputSmall
data-testid="alert-name-input-v1"
onChange={(e): void => {
setAlertDef({
...alertDef,
@@ -163,7 +161,6 @@ function BasicInfo({
name={['annotations', 'description']}
>
<TextareaMedium
data-testid="alert-description-input"
onChange={(e): void => {
setAlertDef({
...alertDef,

View File

@@ -105,7 +105,7 @@ function QuerySection({
{
label: (
<Tooltip title="Query Builder">
<Button className="nav-btns" data-testid="query-builder-tab">
<Button className="nav-btns">
<Atom size={14} />
<Typography.Text>Query Builder</Typography.Text>
</Button>
@@ -122,11 +122,7 @@ function QuerySection({
: 'ClickHouse'
}
>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="clickhouse-tab"
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
@@ -166,11 +162,7 @@ function QuerySection({
: 'ClickHouse'
}
>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="clickhouse-tab"
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
@@ -188,11 +180,7 @@ function QuerySection({
: 'PromQL'
}
>
<Button
className="nav-btns"
disabled={isAnomalyDetection}
data-testid="promql-tab"
>
<Button className="nav-btns" disabled={isAnomalyDetection}>
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>

View File

@@ -80,7 +80,6 @@ function RuleOptions({
defaultValue={defaultCompareOp}
value={alertDef.condition?.op}
style={{ minWidth: '120px' }}
data-testid="alert-threshold-op-select"
onChange={(value: string | unknown): void => {
const newOp = (value as string) || '';
@@ -117,7 +116,6 @@ function RuleOptions({
defaultValue={defaultMatchType}
style={{ minWidth: '130px' }}
value={alertDef.condition?.matchType}
data-testid="alert-threshold-match-type-select-v1"
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
>
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
@@ -179,7 +177,6 @@ function RuleOptions({
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
@@ -197,7 +194,6 @@ function RuleOptions({
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
@@ -399,7 +395,6 @@ function RuleOptions({
value={alertDef?.condition?.target}
onChange={onChange}
type="number"
data-testid="alert-threshold-target-input"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>

View File

@@ -844,6 +844,8 @@ function FormAlertRules({
return (
<>
{Element}
<div
id="top"
className={`form-alert-rules-container ${
@@ -966,7 +968,6 @@ function FormAlertRules({
!isChannelConfigurationValid ||
queryStatus === 'error'
}
data-testid="alert-save-button"
>
{isNewRule ? t('button_createrule') : t('button_savechanges')}
</ActionButton>
@@ -980,7 +981,6 @@ function FormAlertRules({
}
type="default"
onClick={onTestRuleHandler}
data-testid="alert-test-button"
>
{' '}
{t('button_testrule')}
@@ -989,7 +989,6 @@ function FormAlertRules({
disabled={loading || false}
type="default"
onClick={onCancelHandler}
data-testid="alert-cancel-button"
>
{isNewRule && t('button_cancelchanges')}
{ruleId && !isEmpty(ruleId) && t('button_discard')}
@@ -999,7 +998,6 @@ function FormAlertRules({
</div>
<ConfirmDialog
testId="alert-save-confirm-dialog"
open={isConfirmSaveOpen}
onOpenChange={setIsConfirmSaveOpen}
title={t('confirm_save_title')}

View File

@@ -174,7 +174,6 @@ function LabelSelect({
<div style={{ display: 'flex', width: '100%' }}>
<Input
data-testid="alert-labels-input-v1"
placeholder={renderPlaceholder()}
onChange={handleLabelChange}
onKeyUp={(e): void => {

View File

@@ -7,7 +7,6 @@ import TanStackTable, {
TableColumnDef,
useCalculatedPageSize,
useHiddenColumnIds,
useRecoverFromEmptyPage,
useTableParams,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
@@ -137,7 +136,6 @@ export function K8sBaseList<
page: currentPage,
limit: currentPageSize,
setLimit,
setPage,
} = useTableParams(
{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
@@ -245,16 +243,6 @@ export function K8sBaseList<
const totalCount = data?.total || 0;
const hasFilters = !!expression?.trim();
useRecoverFromEmptyPage({
page: currentPage,
pageSize: currentPageSize,
rowCount: pageData.length,
total: totalCount,
isFetching: isLoading || isFetching,
isDisabled: isError || Boolean(data?.error),
setPage,
});
const getGroupKeyFn = useCallback(
(item: T) => getGroupedByMeta(item, groupBy),
[groupBy],

View File

@@ -591,14 +591,12 @@ describe('K8sBaseList', () => {
});
describe('with empty data', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
@@ -607,7 +605,6 @@ describe('K8sBaseList', () => {
});
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
@@ -628,177 +625,6 @@ describe('K8sBaseList', () => {
expect(fetchListDataMock).toHaveBeenCalled();
});
});
it('should not rewrite the page when already on the first page', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const pageUpdates = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean);
expect(pageUpdates).toHaveLength(0);
});
});
describe('with a page beyond the end of the list', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
// 25 rows exist, so pages 1-3 serve data and page 7 of 10 comes back empty.
const rows: TestItem[] = Array.from({ length: 25 }, (_, index) => ({
id: `pod-${index + 1}`,
}));
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
// Offset-aware on purpose: a mock that answers empty for every offset would let
// the assertions pass against a page the recovery has already moved on from.
fetchListDataMock.mockImplementation(async ({ offset = 0, limit = 10 }) => ({
data: rows.slice(offset, offset + limit),
total: rows.length,
error: null,
}));
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '7', pageSize: '10' },
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should send the user back to the last page holding data', async () => {
// The rows of page 3 on screen are what proves the recovery settled there,
// rather than passing through on its way somewhere else.
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
const pageUpdates = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean);
expect(pageUpdates).toStrictEqual(['3']);
});
it('should correct the page in a single hop', async () => {
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
// Only the original out-of-range page and the corrected one are requested.
expect(
fetchListDataMock.mock.calls.map((call) => call[0].offset),
).toStrictEqual([60, 20]);
});
it('should replace the history entry instead of pushing the correction', async () => {
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
const pageCorrection = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('page') === '3',
);
expect(pageCorrection?.[0].options.history).toBe('replace');
});
});
describe('with a page below the first one', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
// page=0 turns into offset=-10, which the API rejects outright — the list
// can only recover by clamping the page, never by reading the response.
fetchListDataMock.mockImplementation(async ({ offset = 0 }) => {
if (offset < 0) {
throw new APIError({
httpStatusCode: 400,
error: {
code: 'invalid_input',
message: 'offset cannot be negative',
url: '',
errors: [],
},
});
}
return { data: [{ id: 'pod-1' }], total: 1, error: null };
});
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '0', pageSize: '10' },
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should reject the request that carried the negative offset', async () => {
await waitFor(() => {
expect(
fetchListDataMock.mock.calls.some((call) => call[0].offset === -10),
).toBe(true);
});
await expect(
fetchListDataMock.mock.results[0].value as Promise<unknown>,
).rejects.toThrow('offset cannot be negative');
});
it('should clamp the page to the first one even though the request failed', async () => {
await waitFor(() => {
expect(onUrlUpdateMock).toHaveBeenCalled();
});
// Page 1 is the default, so the correction drops the param rather than
// writing `page=1`.
const pageCorrection = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('page') === null,
);
expect(pageCorrection).toBeDefined();
expect(pageCorrection?.[0].queryString).toBe('?pageSize=10');
});
it('should replace the history entry instead of pushing the correction', async () => {
await waitFor(() => {
expect(onUrlUpdateMock).toHaveBeenCalled();
});
const pageCorrection = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('page') === null,
);
expect(pageCorrection?.[0].options.history).toBe('replace');
});
it('should refetch with a non-negative offset after clamping', async () => {
await waitFor(() => {
expect(
fetchListDataMock.mock.calls.some((call) => call[0].offset === 0),
).toBe(true);
});
await waitFor(() => {
expect(screen.getByText('pod-1')).toBeInTheDocument();
});
});
});
describe('with error response', () => {

View File

@@ -47,7 +47,6 @@ import {
useInfraMonitoringCategory,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItemParams,
} from './hooks';
@@ -68,7 +67,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setOrderBy] = useInfraMonitoringOrderBy();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -220,7 +218,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
redirectWithQueryBuilderData({
...currentQuery,

View File

@@ -1,128 +0,0 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
import { AppProvider } from 'providers/App/App';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import TimezoneProvider from 'providers/Timezone';
import store from 'store';
import { K8sCategories } from '../constants';
import InfraMonitoringK8s from '../InfraMonitoringK8s';
// Quick filters fire their own field APIs and are irrelevant to pagination.
jest.mock('components/QuickFilters/QuickFilters', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="quick-filters" />,
}));
// The list owns its own page recovery; stubbing it keeps the page param under the
// sole control of the category handler being tested here.
jest.mock('../Base/K8sDynamicList', () => ({
__esModule: true,
K8sDynamicList: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
default: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
}));
// Analytics only; jsdom lacks the Performance navigation entries it reads.
jest.mock('lib/navigation', () => ({
getNavigationReferrer: (): string => 'direct',
}));
function renderPage(
queryParams: Record<string, string>,
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
): void {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<MemoryRouter>
<MemoryRouterV5>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<AppProvider>
<Provider store={store}>
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
>
<TooltipProvider>
<QueryBuilderProvider>
<InfraMonitoringK8s />
</QueryBuilderProvider>
</TooltipProvider>
</NuqsTestingAdapter>
</Provider>
</AppProvider>
</QueryClientProvider>
</TimezoneProvider>
</MemoryRouterV5>
</MemoryRouter>,
);
}
describe('InfraMonitoringK8s', () => {
describe('when the category changes from a page other than the first', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(async () => {
onUrlUpdateMock.mockClear();
renderPage(
{ category: K8sCategories.PODS, page: '3', pageSize: '10' },
onUrlUpdateMock,
);
await screen.findByTestId(`category-${K8sCategories.NODES}`);
});
it('should drop the page so the new category starts at the first one', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
// Page 3 of pods says nothing about nodes — keeping it asks the new entity
// for an offset it may not have. The param is cleared rather than set to 1,
// since an absent page already means the first one.
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
);
expect(categorySwitch).toBeDefined();
expect(categorySwitch?.[0].searchParams.get('page')).toBeNull();
});
});
it('should keep the page size, which is not category specific', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
);
expect(categorySwitch?.[0].searchParams.get('pageSize')).toBe('10');
});
});
it('should leave the page alone when the same category is clicked again', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.PODS}`));
await waitFor(() => {
expect(screen.getByTestId('k8s-dynamic-list')).toBeInTheDocument();
});
const droppedPage = onUrlUpdateMock.mock.calls.some(
(call) => !call[0].searchParams.has('page'),
);
expect(droppedPage).toBe(false);
});
});
});

View File

@@ -34,9 +34,6 @@ export const useInfraMonitoringPageListing = (): UseQueryStateReturn<
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
// do not use .withDefault here, this can cause bugs when
// two hooks of nuqs define default twice, this is also
// defined at useTableParams
parseAsInteger.withOptions(defaultNuqsOptions),
);

View File

@@ -10,7 +10,7 @@ import {
RESTRICTED_SELECTED_FIELDS,
} from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import {
BaseAutocompleteData,

View File

@@ -42,7 +42,7 @@ jest.mock('hooks/useDebounce', () => ({
}));
jest.mock(
'container/QueryBuilder/filters/OptionRenderer/OptionRenderer',
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
() => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,

View File

@@ -21,7 +21,6 @@ import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg';
import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
import azureUrl from '@/assets/Logos/azure.svg';
import basetenUrl from '@/assets/Logos/baseten.svg';
import cassandraUrl from '@/assets/Logos/cassandra.svg';
import celeryUrl from '@/assets/Logos/celery.svg';
@@ -29,7 +28,6 @@ import certManagerUrl from '@/assets/Logos/cert-manager.svg';
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
import cloudnativePgUrl from '@/assets/Logos/cloudnative-pg.svg';
import cloudwatchLogsUrl from '@/assets/Logos/cloudwatch-logs.svg';
import cohereUrl from '@/assets/Logos/cohere.svg';
import confluentKafkaUrl from '@/assets/Logos/confluent-kafka.svg';
@@ -41,7 +39,6 @@ import datadogUrl from '@/assets/Logos/datadog.svg';
import dbosUrl from '@/assets/Logos/dbos.svg';
import deepseekUrl from '@/assets/Logos/deepseek.svg';
import denoUrl from '@/assets/Logos/deno.svg';
import difyUrl from '@/assets/Logos/dify.svg';
import dockerUrl from '@/assets/Logos/docker.svg';
import documentLoadUrl from '@/assets/Logos/document-load.svg';
import dotnetUrl from '@/assets/Logos/dotnet.svg';
@@ -73,8 +70,6 @@ import gcpCloudStorageUrl from '@/assets/Logos/gcp-cloud-storage.svg';
import gcpComputeEngineUrl from '@/assets/Logos/gcp-compute-engine.svg';
import gcpGkeUrl from '@/assets/Logos/gcp-gke.svg';
import gcpVpcUrl from '@/assets/Logos/gcp-vpc.svg';
import gcpUrl from '@/assets/Logos/gcp.svg';
import githubCopilotUrl from '@/assets/Logos/github-copilot.svg';
import githubUrl from '@/assets/Logos/github.svg';
import goUrl from '@/assets/Logos/go.svg';
import googleAdkUrl from '@/assets/Logos/google-adk.svg';
@@ -101,8 +96,6 @@ import javascriptUrl from '@/assets/Logos/javascript.svg';
import jbossUrl from '@/assets/Logos/jboss.svg';
import jenkinsUrl from '@/assets/Logos/jenkins.svg';
import kafkaUrl from '@/assets/Logos/kafka.svg';
import kedaUrl from '@/assets/Logos/keda.svg';
import kongUrl from '@/assets/Logos/kong.svg';
import kubernetesUrl from '@/assets/Logos/kubernetes.svg';
import lambdaUrl from '@/assets/Logos/lambda.svg';
import langchainUrl from '@/assets/Logos/langchain.svg';
@@ -121,7 +114,6 @@ import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg';
import mistralUrl from '@/assets/Logos/mistral.svg';
import mongoUrl from '@/assets/Logos/mongo.svg';
import n8nUrl from '@/assets/Logos/n8n.svg';
import neonUrl from '@/assets/Logos/neon.svg';
import newrelicUrl from '@/assets/Logos/newrelic.svg';
import nextjsUrl from '@/assets/Logos/nextjs.svg';
import nginxUrl from '@/assets/Logos/nginx.svg';
@@ -2867,25 +2859,6 @@ const onboardingConfigWithLinks = [
label: 'Traces',
imgUrl: lambdaUrl,
link: '/docs/aws-monitoring/lambda/lambda-traces/',
question: {
desc: 'Which runtime does your Lambda function use?',
helpText:
'Python, Node.js, Java, and Ruby use the OpenTelemetry auto-instrumentation layer. Go has no layer, so you add the SDK to your code.',
options: [
{
key: 'aws-lambda-traces-auto',
label: 'Python, Node.js, Java, Ruby',
imgUrl: lambdaUrl,
link: '/docs/aws-monitoring/lambda/lambda-traces/',
},
{
key: 'aws-lambda-traces-golang',
label: 'Go',
imgUrl: goUrl,
link: '/docs/aws-monitoring/lambda/lambda-traces-golang/',
},
],
},
},
{
key: 'aws-lambda-metrics',
@@ -5537,10 +5510,8 @@ const onboardingConfigWithLinks = [
module: 'metrics',
relatedSearchKeywords: [
'integrations',
'logs',
'metrics',
'supabase',
'supabase logs',
'supabase metrics',
'supabase monitoring',
'supabase observability',
@@ -5574,23 +5545,43 @@ const onboardingConfigWithLinks = [
label: 'Traefik',
imgUrl: opentelemetryUrl,
tags: ['infrastructure monitoring'],
module: 'apm',
module: 'infrastructure',
relatedSearchKeywords: [
'infrastructure',
'traefik',
'traefik access logs',
'traefik logs',
'traefik metrics',
'traefik monitoring',
'traefik observability',
'traefik tracing',
],
link: '/docs/tutorial/traefik-observability/',
question: {
desc: 'Which Traefik signals do you want to send to SigNoz?',
type: 'select',
options: [
{
key: 'traefik-metrics-traces',
label: 'Metrics & Traces',
imgUrl: opentelemetryUrl,
link: '/docs/tutorial/traefik-observability/',
},
{
key: 'traefik-logs',
label: 'Access Logs',
imgUrl: opentelemetryUrl,
link: '/docs/integrations/opentelemetry-traefik/',
},
],
},
},
{
dataSource: 'mongodb-atlas',
label: 'MongoDB (Atlas)',
imgUrl: mongoUrl,
tags: ['database'],
module: 'metrics',
module: 'database',
relatedSearchKeywords: [
'atlas',
'atlas metrics',
@@ -5609,15 +5600,36 @@ const onboardingConfigWithLinks = [
label: 'MySQL',
imgUrl: opentelemetryUrl,
tags: ['database'],
module: 'metrics',
module: 'database',
relatedSearchKeywords: [
'database',
'mysql',
'mysql error log',
'mysql logs',
'mysql metrics',
'mysql monitoring',
'mysql observability',
'mysql slow query log',
],
link: '/docs/metrics-management/mysql-metrics/',
question: {
desc: 'Which MySQL signals do you want to send to SigNoz?',
type: 'select',
options: [
{
key: 'mysql-metrics',
label: 'Metrics',
imgUrl: opentelemetryUrl,
link: '/docs/metrics-management/mysql-metrics/',
},
{
key: 'mysql-logs',
label: 'Logs',
imgUrl: opentelemetryUrl,
link: '/docs/integrations/opentelemetry-mysql/',
},
],
},
},
{
dataSource: 'jmx',
@@ -6980,514 +6992,5 @@ const onboardingConfigWithLinks = [
id: 'dspy',
link: '/docs/dspy-observability/',
},
{
dataSource: 'grok-build',
label: 'Grok Build',
imgUrl: grokUrl,
tags: ['LLM Monitoring'],
module: 'metrics',
relatedSearchKeywords: [
'coding agent',
'grok build',
'grok build events',
'grok build logs',
'grok build metrics',
'grok build monitoring',
'grok build observability',
'llm',
'llm monitoring',
'metrics',
'monitoring',
'observability',
'otel grok build integration',
'terminal coding agent',
'token usage',
'xai',
],
id: 'grok-build',
link: '/docs/grok-build-observability/',
},
{
dataSource: 'neon',
label: 'Neon',
imgUrl: neonUrl,
tags: ['database'],
module: 'metrics',
relatedSearchKeywords: [
'database',
'neon',
'neon database',
'neon db',
'neon logs',
'neon metrics',
'neon monitoring',
'neon observability',
'neondb',
'opentelemetry neon',
'postgres',
'postgresql',
'serverless postgres',
],
id: 'neon',
link: '/docs/integrations/opentelemetry-neondb/',
},
{
dataSource: 'dify',
label: 'Dify',
imgUrl: difyUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'ai agent',
'dify',
'dify monitoring',
'dify observability',
'dify traces',
'llm',
'llm monitoring',
'metrics',
'no code ai',
'observability',
'opentelemetry dify',
'traces',
],
id: 'dify',
link: '/docs/dify-observability/',
},
{
dataSource: 'firecrawl',
label: 'Firecrawl',
imgUrl: llmMonitoringUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'crawl',
'firecrawl',
'firecrawl metrics',
'firecrawl monitoring',
'firecrawl observability',
'firecrawl traces',
'llm',
'llm monitoring',
'opentelemetry firecrawl',
'scrape',
'traces',
'web scraping',
],
id: 'firecrawl',
link: '/docs/firecrawl-monitoring/',
},
{
dataSource: 'keda',
label: 'KEDA',
imgUrl: kedaUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'autoscaling',
'event driven autoscaling',
'keda',
'keda metrics',
'keda monitoring',
'kubernetes',
'kubernetes autoscaling',
'metrics',
'opentelemetry keda',
'scaled jobs',
'scaled objects',
],
id: 'keda',
link: '/docs/metrics-management/keda-metrics/',
},
{
dataSource: 'opentelemetry-collector-metrics',
label: 'OpenTelemetry Collector Metrics',
imgUrl: opentelemetryUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'collector health',
'collector internal metrics',
'collector metrics',
'metrics',
'opentelemetry',
'opentelemetry collector',
'otel collector',
'otelcol metrics',
'pipeline health',
],
id: 'opentelemetry-collector-metrics',
link: '/docs/metrics-management/opentelemetry-collector-metrics/',
},
{
dataSource: 'cloudnative-pg',
label: 'CloudNativePG',
imgUrl: cloudnativePgUrl,
tags: ['database'],
module: 'metrics',
relatedSearchKeywords: [
'cloud native postgres',
'cloudnativepg',
'cnpg',
'cnpg metrics',
'database',
'kubernetes postgres',
'metrics',
'opentelemetry cloudnativepg',
'postgres',
'postgresql',
],
id: 'cloudnative-pg',
link: '/docs/metrics-management/opentelemetry-cloudnative-pg/',
},
{
dataSource: 'kong-gateway',
label: 'Kong Gateway',
imgUrl: kongUrl,
tags: ['infrastructure monitoring'],
module: 'apm',
relatedSearchKeywords: [
'api gateway',
'kong',
'kong gateway',
'kong logs',
'kong metrics',
'kong monitoring',
'kong observability',
'kong traces',
'opentelemetry kong',
'proxy',
'traces',
],
id: 'kong-gateway',
link: '/docs/integrations/kong-gateway/',
},
{
dataSource: 'github-copilot',
label: 'GitHub Copilot',
imgUrl: githubCopilotUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'coding agent',
'copilot',
'copilot chat',
'github copilot',
'github copilot metrics',
'github copilot monitoring',
'github copilot observability',
'github copilot traces',
'llm',
'llm monitoring',
'monitoring',
'observability',
'otel github copilot integration',
'traces',
'tracing',
'vs code',
],
id: 'github-copilot',
link: '/docs/github-copilot-monitoring/',
},
{
dataSource: 'serilog',
label: 'Serilog',
imgUrl: dotnetUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'.net',
'asp.net',
'c#',
'csharp',
'dotnet',
'dotnet logs',
'logging',
'logs',
'monitoring',
'observability',
'otel serilog integration',
'serilog',
'serilog logs',
'serilog sink',
'structured logging',
],
id: 'serilog',
link: '/docs/logs-management/send-logs/serilog-to-signoz/',
},
{
dataSource: 'gcp-integration',
label: 'GCP Integration',
imgUrl: gcpUrl,
tags: ['GCP'],
module: 'metrics',
relatedSearchKeywords: [
'cloud monitoring metrics',
'connect gcp account',
'gcp',
'gcp integration',
'gcp manual setup',
'gcp metrics',
'gcp monitoring',
'gcp observability',
'gcp service account',
'google cloud',
'google cloud integration',
'metrics',
'monitoring',
'observability',
'opentelemetry collector gcp',
],
id: 'gcp-integration',
link: '/docs/integrations/gcp/gcp-integration/',
},
{
dataSource: 'azure-cosmos-db',
label: 'Azure Cosmos DB',
imgUrl: azureUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure cosmos db',
'azure integration',
'cosmos db',
'cosmosdb',
'database',
'metrics',
'monitoring',
'nosql',
'observability',
'one click azure',
'request units',
],
id: 'azure-cosmos-db',
link: '/integrations/azure?service=cosmosdb',
internalRedirect: true,
},
{
dataSource: 'azure-mongodb',
label: 'Azure MongoDB vCore',
imgUrl: mongoUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure integration',
'azure mongodb',
'azure mongodb vcore',
'database',
'metrics',
'mongodb',
'mongodb vcore',
'monitoring',
'nosql',
'observability',
'one click azure',
],
id: 'azure-mongodb',
link: '/integrations/azure?service=mongodb',
internalRedirect: true,
},
{
dataSource: 'azure-postgresql-flexible-server',
label: 'Azure PostgreSQL Flexible Server',
imgUrl: postgresqlUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure integration',
'azure postgresql',
'azure postgresql flexible server',
'database',
'metrics',
'monitoring',
'observability',
'one click azure',
'postgres',
'postgresql',
'postgresql flexible server',
],
id: 'azure-postgresql-flexible-server',
link: '/integrations/azure?service=postgresqlflexibleserver',
internalRedirect: true,
},
{
dataSource: 'azure-cache-redis',
label: 'Azure Cache for Redis',
imgUrl: redisUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure cache for redis',
'azure integration',
'azure redis',
'cache',
'database',
'metrics',
'monitoring',
'observability',
'one click azure',
'redis',
],
id: 'azure-cache-redis',
link: '/integrations/azure?service=redis',
internalRedirect: true,
},
{
dataSource: 'azure-sql-managed-instance',
label: 'Azure SQL Managed Instance',
imgUrl: azureSqlDatabaseMetricsUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure integration',
'azure sql',
'azure sql managed instance',
'database',
'managed instance',
'metrics',
'monitoring',
'observability',
'one click azure',
'sql server',
],
id: 'azure-sql-managed-instance',
link: '/integrations/azure?service=sqldatabasemi',
internalRedirect: true,
},
{
dataSource: 'azure-cassandra-db',
label: 'Azure Managed Instance for Apache Cassandra',
imgUrl: cassandraUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'apache cassandra',
'azure',
'azure cassandra',
'azure integration',
'azure managed instance for apache cassandra',
'cassandra',
'database',
'metrics',
'monitoring',
'observability',
'one click azure',
],
id: 'azure-cassandra-db',
link: '/integrations/azure?service=cassandradb',
internalRedirect: true,
},
{
dataSource: 'gcp-cloud-sql-postgresql',
label: 'GCP Cloud SQL for PostgreSQL',
imgUrl: gcpCloudSqlUrl,
tags: ['GCP'],
module: 'dashboards',
relatedSearchKeywords: [
'cloud sql',
'cloud sql for postgresql',
'database',
'gcp',
'gcp integration',
'google cloud',
'metrics',
'monitoring',
'observability',
'postgres',
'postgresql',
],
id: 'gcp-cloud-sql-postgresql',
link: '/integrations/gcp?service=cloudsql_postgres',
internalRedirect: true,
},
{
dataSource: 'gcp-memorystore-redis',
label: 'GCP Memorystore for Redis',
imgUrl: redisUrl,
tags: ['GCP'],
module: 'dashboards',
relatedSearchKeywords: [
'cache',
'database',
'gcp',
'gcp integration',
'google cloud',
'memorystore',
'memorystore for redis',
'metrics',
'monitoring',
'observability',
'redis',
],
id: 'gcp-memorystore-redis',
link: '/integrations/gcp?service=memorystore_redis',
internalRedirect: true,
},
{
dataSource: 'supabase-logs',
label: 'Supabase Logs',
imgUrl: supabaseUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'database',
'logging',
'logs',
'postgres',
'postgresql',
'send supabase logs',
'supabase',
'supabase log drains',
'supabase logs',
'supabase observability',
],
id: 'supabase-logs',
link: '/docs/logs-management/send-logs/supabase-logs/',
},
{
dataSource: 'traefik-logs',
label: 'Traefik Access Logs',
imgUrl: opentelemetryUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'access logs',
'logging',
'logs',
'opentelemetry traefik',
'proxy',
'reverse proxy',
'traefik',
'traefik access logs',
'traefik logs',
],
id: 'traefik-logs',
link: '/docs/integrations/opentelemetry-traefik/',
},
{
dataSource: 'mysql-logs',
label: 'MySQL Logs',
imgUrl: opentelemetryUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'database',
'logging',
'logs',
'mysql',
'mysql error log',
'mysql general query log',
'mysql logs',
'mysql slow query log',
'opentelemetry mysql',
],
id: 'mysql-logs',
link: '/docs/integrations/opentelemetry-mysql/',
},
];
export default onboardingConfigWithLinks;

View File

@@ -1,15 +1,11 @@
import { ReactNode } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
export type WhereClauseConfig = {
customKey: string;
customOp: string;
};
type FilterConfigs = {
[Key in keyof Omit<IBuilderQuery, 'filters'>]: {
isHidden: boolean;

View File

@@ -0,0 +1,45 @@
import { useMemo } from 'react';
import { InputNumber, InputNumberProps } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
function AggregateEveryFilter({
onChange,
query,
disabled,
}: AggregateEveryFilterProps): JSX.Element {
const isMetricsDataSource = useMemo(
() => query.dataSource === DataSource.METRICS,
[query.dataSource],
);
const onChangeHandler: InputNumberProps<number>['onChange'] = (event) => {
if (event && event >= 0) {
onChange(event);
}
};
const isDisabled =
(isMetricsDataSource && !query.aggregateAttribute?.key) || disabled;
return (
<InputNumber
placeholder="Enter in seconds"
disabled={isDisabled}
style={selectStyle}
value={query?.stepInterval}
onChange={onChangeHandler}
min={0}
/>
);
}
interface AggregateEveryFilterProps {
onChange: (values: number) => void;
query: IBuilderQuery;
disabled: boolean;
}
export default AggregateEveryFilter;

View File

@@ -24,8 +24,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
// ** Types
import { AgregatorFilterProps } from './AggregatorFilter.intefaces';

View File

@@ -1,6 +1,6 @@
import { InputNumber } from 'antd';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { handleKeyDownLimitFilter } from '../../utils';
import { LimitFilterProps } from './types';

View File

@@ -4,7 +4,7 @@ import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { OrderByProps } from './types';
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';

View File

@@ -16,7 +16,7 @@ import {
mapLabelValuePairs,
orderByValueDelimiter,
} from '../../OrderByFilter/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearch/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
import { IOrderByFormulaFilterProps } from './types';
import { transformToOrderByStringValuesByFormula } from './utils';

View File

@@ -23,8 +23,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { GroupByFilterProps } from './GroupByFilter.interfaces';
export const GroupByFilter = memo(function GroupByFilter({

View File

@@ -0,0 +1,31 @@
import { InputNumber } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
import { handleKeyDownLimitFilter } from '../utils';
function LimitFilter({ onChange, query }: LimitFilterProps): JSX.Element {
const isMetricsDataSource = query.dataSource === DataSource.METRICS;
const isDisabled = isMetricsDataSource && !query.aggregateAttribute?.key;
return (
<InputNumber
min={1}
type="number"
value={query.limit}
style={selectStyle}
disabled={isDisabled}
onChange={onChange}
onKeyDown={handleKeyDownLimitFilter}
/>
);
}
interface LimitFilterProps {
onChange: (values: number | null) => void;
query: IBuilderQuery;
}
export default LimitFilter;

View File

@@ -31,7 +31,7 @@ jest.mock('hooks/useDebounce', () => ({
default: <T,>(value: T): T => value,
}));
jest.mock('../OptionRenderer/OptionRenderer', () => ({
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}));

View File

@@ -16,8 +16,8 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import './MetricNameSelector.styles.scss';

View File

@@ -2,7 +2,7 @@ import { memo } from 'react';
import { Select } from 'antd';
// ** Types
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { selectStyle } from '../QueryBuilderSearch/config';
import { OperatorsSelectProps } from './OperatorsSelect.interfaces';
export const OperatorsSelect = memo(function OperatorsSelect({

View File

@@ -1,22 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
&&& {
display: flex;
font-weight: 300;
font-size: 0.6rem;
}
`;
export const TagLabel = styled.span`
font-weight: 400;
`;
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -5,7 +5,7 @@ import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { selectStyle } from '../QueryBuilderSearch/config';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
import { useOrderByFilter } from './useOrderByFilter';

View File

@@ -7,7 +7,7 @@ import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
import { getRemoveOrderFromValue } from '../QueryBuilderSearch/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../utils';
import { ORDERBY_FILTERS } from './config';
import { SIGNOZ_VALUE } from './constants';

View File

@@ -1,5 +1,7 @@
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import './QueryBuilderSearch.styles.scss';
function ExampleQueriesRendererForLogs({
label,
value,

View File

@@ -0,0 +1,70 @@
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Zap } from '@signozhq/icons';
import { getOptionType } from './utils';
import './QueryBuilderSearch.styles.scss';
function OptionRendererForLogs({
label,
value,
dataType,
isIndexed,
setDynamicPlaceholder,
}: OptionRendererProps): JSX.Element {
const optionType = getOptionType(label);
return (
<span
className="option"
onMouseEnter={(): void => setDynamicPlaceholder(value)}
onFocus={(): void => setDynamicPlaceholder(value)}
>
{optionType ? (
<Tooltip title={value} placement="topLeft">
<div className="logs-options-select">
<section className="left-section">
{isIndexed ? (
<Zap size={12} fill={Color.BG_AMBER_500} />
) : (
<div className="dot" />
)}
<Typography.Text className="text value" truncate={1}>
{value}
</Typography.Text>
</section>
<section className="right-section">
<div className="text tags data-type-tag">{dataType}</div>
<div className={cx('text tags option-type-tag', optionType)}>
<div className="dot" />
{optionType}
</div>
</section>
</div>
</Tooltip>
) : (
<Tooltip title={label} placement="topLeft">
<div className="without-option-type">
<div className="dot" />
<Typography.Text className="text" truncate={1}>
{label}
</Typography.Text>
</div>
</Tooltip>
)}
</span>
);
}
interface OptionRendererProps {
label: string;
value: string;
dataType: string;
isIndexed: boolean;
setDynamicPlaceholder: React.Dispatch<React.SetStateAction<string>>;
}
export default OptionRendererForLogs;

View File

@@ -0,0 +1,298 @@
.query-builder-search-container {
position: relative;
display: flex;
align-items: center;
gap: 12px;
}
.logs-popup {
&.hide-scroll {
.rc-virtual-list-holder {
height: 100px;
}
}
}
.logs-explorer-popup {
padding: 0px;
.ant-select-item-group {
padding: 12px 14px 8px 14px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.88px;
text-transform: uppercase;
}
.show-all-filter-props {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 13px;
width: 100%;
cursor: pointer;
.content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.left-section {
display: flex;
align-items: center;
gap: 4px;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.text:hover {
color: var(--l1-foreground);
}
}
.right-section {
display: flex;
align-items: center;
gap: 4px;
.keyboard-shortcut-slash {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--l1-border);
border-right: 1.143px solid var(--l1-border);
border-bottom: 2.286px solid var(--l1-border);
border-left: 1.143px solid var(--l1-border);
background: var(--l2-background);
}
}
}
}
.show-all-filter-props:hover {
background: color-mix(
in srgb,
var(--l1-foreground) 4%,
transparent
) !important;
}
.example-queries {
cursor: default;
.heading {
padding: 12px 14px 8px 14px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.88px;
text-transform: uppercase;
}
.query-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0px 12px 12px 12px;
cursor: pointer;
.example-query {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: -0.07px;
width: fit-content;
}
.example-query:hover {
color: var(--l1-foreground);
}
}
}
.ant-select-item-option-grouped {
padding-inline-start: 0px;
padding: 7px 13px;
}
.keyboard-shortcuts {
display: flex;
align-items: center;
border-radius: 0px 0px 4px 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
padding: 11px 16px;
cursor: default;
.icons {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--l3-background);
border-right: 1.143px solid var(--l3-background);
border-bottom: 2.286px solid var(--l3-background);
border-left: 1.143px solid var(--l3-background);
background: var(--l2-background);
}
.keyboard-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 142.857% */
letter-spacing: -0.07px;
}
.navigate {
display: flex;
align-items: center;
padding-right: 12px;
gap: 4px;
border-right: 1px solid var(--l1-border);
}
.update-query {
display: flex;
align-items: center;
margin-left: 12px;
gap: 4px;
}
}
.without-option-type {
display: flex;
gap: 8px;
align-items: center;
.dot {
height: 5px;
width: 5px;
border-radius: 50%;
background-color: var(--l3-background);
}
}
.logs-options-select {
display: flex;
align-items: center;
justify-content: space-between;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.tags {
display: flex;
height: 20px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 20px;
}
.dot {
height: 5px;
width: 5px;
border-radius: 50%;
flex-shrink: 0;
}
.left-section {
display: flex;
align-items: center;
gap: 8px;
width: 90%;
.dot {
background-color: var(--l3-background);
}
.value {
width: 100%;
}
}
.right-section {
display: flex;
align-items: center;
gap: 4px;
.data-type-tag {
background: color-mix(in srgb, var(--l1-foreground) 8%, transparent);
}
.option-type-tag {
display: flex;
gap: 4px;
align-items: center;
padding: 0px 6px;
text-transform: capitalize;
}
.tag {
border-radius: 50px;
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
color: var(--bg-sienna-400);
.dot {
background-color: var(--bg-sienna-400);
}
}
.resource {
border-radius: 50px;
background: color-mix(in srgb, var(--bg-sakura-400) 10%, transparent);
color: var(--bg-sakura-400);
.dot {
background-color: var(--bg-sakura-400);
}
}
}
}
.ant-select-item-option-active {
.logs-options-select {
.left-section {
.value {
color: var(--l1-foreground);
}
}
}
}
}
.span-scope-selector {
width: 160px;
}

View File

@@ -0,0 +1,558 @@
import {
KeyboardEvent,
ReactElement,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { Button, Select, Spin, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import {
INFRA_LONG_TO_SHORT_OPERATOR_MAP,
OPERATORS,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { getDataTypes } from 'container/LogDetailedView/utils';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import {
useAutoComplete,
WhereClauseConfig,
} from 'hooks/queryBuilder/useAutoComplete';
import { useFetchKeysAndValues } from 'hooks/queryBuilder/useFetchKeysAndValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isEqual, isUndefined } from 'lodash-es';
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronUp,
Command,
CornerDownLeft,
Filter,
Slash,
} from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from './config';
import { PLACEHOLDER } from './constant';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
import OptionRenderer from './OptionRenderer';
import OptionRendererForLogs from './OptionRendererForLogs';
import { StyledCheckOutlined, TypographyText } from './style';
import {
convertExampleQueriesToOptions,
getOperatorValue,
getRemovePrefixFromKey,
getTagToken,
isExistsNotExistsOperator,
isInNInOperator,
} from './utils';
import './QueryBuilderSearch.styles.scss';
function getOperatorValueForContext(
op: string,
isInfraMonitoring?: boolean,
): string {
const mappedOp =
isInfraMonitoring && INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
? INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
: op;
return getOperatorValue(mappedOp);
}
function QueryBuilderSearch({
query,
onChange,
whereClauseConfig,
className,
placeholder,
suffixIcon,
isInfraMonitoring,
isMetricsExplorer,
disableNavigationShortcuts,
entity,
}: QueryBuilderSearchProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,
[pathname],
);
const [isEditingTag, setIsEditingTag] = useState(false);
const {
updateTag,
handleClearTag,
handleKeyDown,
handleOnBlur,
handleSearch,
handleSelect,
tags,
options,
searchValue,
isMulti,
isFetching,
setSearchKey,
setSearchValue,
searchKey,
key,
exampleQueries,
} = useAutoComplete(
query,
whereClauseConfig,
isLogsExplorerPage,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [showAllFilters, setShowAllFilters] = useState<boolean>(false);
const [dynamicPlacholder, setDynamicPlaceholder] = useState<string>(
placeholder || '',
);
const selectRef = useRef<BaseSelectRef>(null);
const { sourceKeys, handleRemoveSourceKey } = useFetchKeysAndValues(
searchValue,
query,
searchKey,
isLogsExplorerPage,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { handleRunQuery, currentQuery } = useQueryBuilder();
const toggleEditMode = useCallback(
(value: boolean) => {
// Editing mode is required only in infra monitoring or metrics explorer
if (isInfraMonitoring || isMetricsExplorer) {
setIsEditingTag(value);
}
},
[isInfraMonitoring, isMetricsExplorer],
);
const onTagRender = ({
value,
closable,
onClose,
}: CustomTagProps): ReactElement => {
const { tagOperator } = getTagToken(value);
const isInNin = isInNInOperator(tagOperator);
const chipValue = isInNin
? value?.trim()?.replace(/,\s*$/, '')
: value?.trim();
const onCloseHandler = (): void => {
onClose();
// Editing is done after closing a tag
toggleEditMode(false);
handleSearch('');
setSearchKey('');
};
const tagEditHandler = (value: string): void => {
updateTag(value);
// Editing starts
toggleEditMode(true);
if (isInfraMonitoring || isMetricsExplorer) {
setSearchValue(value);
} else {
handleSearch(value);
}
};
const isDisabled = !!searchValue;
return (
<Badge
color="vanilla"
closable={!searchValue && closable}
onClose={(e): void => {
e.preventDefault();
onCloseHandler();
}}
>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
$isEnabled={!!searchValue}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);
}
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Badge>
);
};
const onChangeHandler = (value: string[]): void => {
if (!isMulti) {
handleSearch(value[value.length - 1]);
}
};
const onInputKeyDownHandler = (event: KeyboardEvent<Element>): void => {
if (isMulti || event.key === 'Backspace') {
handleKeyDown(event);
}
if (isExistsNotExistsOperator(searchValue)) {
handleKeyDown(event);
}
// Editing is done after enter key press
if (event.key === 'Enter') {
toggleEditMode(false);
}
if (
!disableNavigationShortcuts &&
(event.ctrlKey || event.metaKey) &&
event.key === 'Enter'
) {
event.preventDefault();
event.stopPropagation();
handleRunQuery();
setIsOpen(false);
}
if (
!disableNavigationShortcuts &&
(event.ctrlKey || event.metaKey) &&
event.key === '/'
) {
event.preventDefault();
event.stopPropagation();
setShowAllFilters((prev) => !prev);
}
};
const handleDeselect = useCallback(
(deselectedItem: string) => {
handleClearTag(deselectedItem);
handleRemoveSourceKey(deselectedItem);
},
[handleClearTag, handleRemoveSourceKey],
);
const isMetricsDataSource = useMemo(
() =>
query.dataSource === DataSource.METRICS &&
!isInfraMonitoring &&
!isMetricsExplorer,
[query.dataSource, isInfraMonitoring, isMetricsExplorer],
);
const fetchValueDataType = (value: unknown, operator: string): DataTypes => {
if (operator === OPERATORS.HAS || operator === OPERATORS.NHAS) {
return getDataTypes([value]);
}
return DataTypes.EMPTY;
};
const queryTags = useMemo(() => {
if (!query.aggregateAttribute?.key && isMetricsDataSource) {
return [];
}
return tags;
}, [isMetricsDataSource, query.aggregateAttribute?.key, tags]);
useEffect(() => {
const initialTagFilters: TagFilter = { items: [], op: 'AND' };
const initialSourceKeys = query.filters?.items?.map(
(item) => item.key as BaseAutocompleteData,
);
initialTagFilters.items = tags.map((tag) => {
const { tagKey, tagOperator, tagValue } = getTagToken(tag);
const filterAttribute = [
...(initialSourceKeys || []),
...(sourceKeys || []),
].find((key) => key?.key === getRemovePrefixFromKey(tagKey));
const computedTagValue =
tagValue && Array.isArray(tagValue) && tagValue[tagValue.length - 1] === ''
? tagValue?.slice(0, -1)
: (tagValue ?? '');
return {
id: uuid().slice(0, 8),
key: filterAttribute ?? {
key: tagKey,
dataType: fetchValueDataType(computedTagValue, tagOperator),
type: '',
},
op: getOperatorValueForContext(tagOperator, isInfraMonitoring),
value: computedTagValue,
};
});
// If in infra monitoring or metrics explorer, only run the onChange query when editing is finsished.
if (isInfraMonitoring || isMetricsExplorer) {
if (!isEditingTag) {
onChange(initialTagFilters);
}
} else {
onChange(initialTagFilters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKeys]);
const isLastQuery = useMemo(
() =>
isEqual(
currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1],
query,
),
[currentQuery, query],
);
useEffect(() => {
if (isLastQuery && !disableNavigationShortcuts) {
registerShortcut(LogsExplorerShortcuts.FocusTheSearchBar, () => {
// set timeout is needed here else the select treats the hotkey as input value
setTimeout(() => {
selectRef.current?.focus();
}, 0);
});
}
return (): void =>
deregisterShortcut(LogsExplorerShortcuts.FocusTheSearchBar);
}, [
deregisterShortcut,
disableNavigationShortcuts,
isLastQuery,
registerShortcut,
]);
useEffect(() => {
if (!isOpen) {
setDynamicPlaceholder(placeholder || '');
}
}, [isOpen, placeholder]);
const userOs = getUserOperatingSystem();
// conditional changes here to use a seperate component to render the example queries based on the option group label
const customRendererForLogsExplorer = options.map((option) => (
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRendererForLogs
label={option.label}
value={option.value}
dataType={option.dataType || ''}
isIndexed={option.isIndexed || false}
setDynamicPlaceholder={setDynamicPlaceholder}
/>
{option.selected && <StyledCheckOutlined />}
</Select.Option>
));
return (
<div className="query-builder-search-container">
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={getPopupContainer}
transitionName=""
choiceTransitionName=""
virtual={false}
showSearch
tagRender={onTagRender}
filterOption={false}
open={isOpen}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={dynamicPlacholder}
value={queryTags}
searchValue={searchValue}
className={cx(
className,
isLogsExplorerPage ? 'logs-popup' : '',
!showAllFilters && options.length > 3 && !key ? 'hide-scroll' : '',
)}
rootClassName="query-builder-search"
disabled={isMetricsDataSource && !query.aggregateAttribute?.key}
style={selectStyle}
onSearch={handleSearch}
onChange={onChangeHandler}
onSelect={handleSelect}
onDeselect={handleDeselect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={isFetching ? <Spin size="small" /> : null}
suffixIcon={
!isUndefined(suffixIcon) ? (
suffixIcon
) : isOpen ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)
}
showAction={['focus']}
onBlur={(e: React.FocusEvent<HTMLInputElement>): void => {
handleOnBlur(e);
// Editing is done after tapping out of the input
toggleEditMode(false);
}}
popupClassName={isLogsExplorerPage ? 'logs-explorer-popup' : ''}
dropdownRender={(menu): ReactElement => (
<div>
{!searchKey && isLogsExplorerPage && (
<div className="ant-select-item-group ">Suggested Filters</div>
)}
{menu}
{isLogsExplorerPage && (
<div>
{!searchKey && tags.length === 0 && (
<div className="example-queries">
<div className="heading"> Example Queries </div>
<div className="query-container">
{convertExampleQueriesToOptions(exampleQueries).map((query) => (
<ExampleQueriesRendererForLogs
key={query.label}
label={query.label}
value={query.value}
handleAddTag={onChange}
/>
))}
</div>
</div>
)}
{!key && !isFetching && !showAllFilters && options.length > 3 && (
<Button
type="text"
className="show-all-filter-props"
onClick={(): void => {
setShowAllFilters(true);
// when clicking on the button the search bar looses the focus
selectRef?.current?.focus();
}}
>
<div className="content">
<section className="left-section">
<Filter size={14} />
<Typography.Text className="text">
Show all filters properties
</Typography.Text>
</section>
<section className="right-section">
{userOs === UserOperatingSystem.MACOS ? (
<Command size={14} className="keyboard-shortcut-slash" />
) : (
<ChevronUp size={14} className="keyboard-shortcut-slash" />
)}
+
<Slash size={14} className="keyboard-shortcut-slash" />
</section>
</div>
</Button>
)}
<div className="keyboard-shortcuts">
<section className="navigate">
<ArrowDown size={10} className="icons" />
<ArrowUp size={10} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
<section className="update-query">
<CornerDownLeft size={10} className="icons" />
<span className="keyboard-text">to update query</span>
</section>
</div>
</div>
)}
</div>
)}
>
{isLogsExplorerPage
? customRendererForLogsExplorer
: options.map((option) => (
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRenderer
label={option.label}
value={option.value}
dataType={option.dataType || ''}
type={option.type || ''}
/>
{option.selected && <StyledCheckOutlined />}
</Select.Option>
))}
</Select>
</div>
);
}
interface QueryBuilderSearchProps {
query: IBuilderQuery;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
className?: string;
placeholder?: string;
suffixIcon?: React.ReactNode;
isInfraMonitoring?: boolean;
disableNavigationShortcuts?: boolean;
// TODO: Remove the dependency of InfraMonitoring from this code
entity?: InfraMonitoringEntity | null;
isMetricsExplorer?: boolean;
}
QueryBuilderSearch.defaultProps = {
whereClauseConfig: undefined,
className: '',
placeholder: PLACEHOLDER,
suffixIcon: undefined,
isInfraMonitoring: false,
disableNavigationShortcuts: false,
entity: null,
isMetricsExplorer: false,
};
export interface CustomTagProps {
label: ReactNode;
value: string;
disabled: boolean;
onClose: () => void;
closable: boolean;
}
export default QueryBuilderSearch;

View File

@@ -1,3 +1,5 @@
import { Check } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TypographyText = styled.span<{
@@ -15,3 +17,27 @@ export const TypographyText = styled.span<{
padding-left: 8px;
${({ $disabled }): string => ($disabled ? 'opacity: 0.6' : '')}
`;
export const StyledCheckOutlined = styled(Check)`
float: right;
`;
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
&&& {
display: flex;
font-weight: 300;
font-size: 0.6rem;
}
`;
export const TagLabel = styled.span`
font-weight: 400;
`;
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -1,7 +1,8 @@
import { OPERATORS } from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import { queryFilterTags } from 'hooks/queryBuilder/useTag';
import { parse } from 'papaparse';
import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { orderByValueDelimiter } from '../OrderByFilter/utils';
@@ -190,6 +191,18 @@ export function getRemoveOrderFromValue(tag: string): string {
return tag;
}
export function getOptionType(label: string): MetricsType | undefined {
let optionType;
if (label.startsWith('tag_')) {
optionType = MetricsType.Tag;
} else if (label.startsWith('resource_')) {
optionType = MetricsType.Resource;
}
return optionType;
}
/**
*
* @param exampleQueries the example queries based on recommendation engine

View File

@@ -11,8 +11,8 @@ import {
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
import { convertExampleQueriesToOptions } from './utils';
import ExampleQueriesRendererForLogs from '../QueryBuilderSearch/ExampleQueriesRendererForLogs';
import { convertExampleQueriesToOptions } from '../QueryBuilderSearch/utils';
import { ITag, Option } from './QueryBuilderSearchV2';
import './QueryBuilderSearchV2.styles.scss';

View File

@@ -19,10 +19,10 @@ import {
QUERY_BUILDER_SEARCH_VALUES,
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
@@ -53,16 +53,16 @@ import { DataSource } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from './config';
import { PLACEHOLDER } from './constant';
import { TypographyText } from './style';
import { selectStyle } from '../QueryBuilderSearch/config';
import { PLACEHOLDER } from '../QueryBuilderSearch/constant';
import { TypographyText } from '../QueryBuilderSearch/style';
import {
checkCommaInValue,
getOperatorFromValue,
getOperatorValue,
getTagToken,
isInNInOperator,
} from './utils';
} from '../QueryBuilderSearch/utils';
import { filterByOperatorConfig } from '../utils';
import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown';
import SpanScopeSelector from './SpanScopeSelector';

View File

@@ -7,7 +7,7 @@ import { isEmpty, isObject } from 'lodash-es';
import { Check, Zap } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { getTagToken } from './utils';
import { getTagToken } from '../QueryBuilderSearch/utils';
import { DropdownState } from './QueryBuilderSearchV2';
import './Suggestions.styles.scss';

View File

@@ -1,3 +1,4 @@
import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch';
import { OperatorConfigKeys, OPERATORS_CONFIG } from 'constants/queryBuilder';
import { HAVING_FILTER_REGEXP } from 'constants/regExp';
import { IOption } from 'hooks/useResourceAttribute/types';
@@ -10,7 +11,7 @@ import {
orderByValueDelimiter,
splitOrderByFromString,
} from './OrderByFilter/utils';
import { getRemoveOrderFromValue } from './QueryBuilderSearchV2/utils';
import { getRemoveOrderFromValue } from './QueryBuilderSearch/utils';
export const handleKeyDownLimitFilter: React.KeyboardEventHandler<
HTMLInputElement
@@ -100,6 +101,23 @@ export const getValidOrderByResult = (result: IOption[]): IOption[] =>
return acc;
}, []);
export const transformKeyValuesToAttributeValuesMap = (
attributeValuesMap: Record<string, string[] | number[] | boolean[]>,
): AttributeValuesMap =>
Object.fromEntries(
Object.entries(attributeValuesMap || {}).map(([key, values]) => [
key,
{
stringAttributeValues:
typeof values[0] === 'string' ? (values as string[]) : [],
numberAttributeValues:
typeof values[0] === 'number' ? (values as number[]) : [],
boolAttributeValues:
typeof values[0] === 'boolean' ? (values as boolean[]) : [],
},
]),
);
export const filterByOperatorConfig = (
options: IOption[],
key?: OperatorConfigKeys,

View File

@@ -3,7 +3,7 @@ import {
formatValueForExpression,
removeKeysFromExpression,
} from 'components/QueryBuilderV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { IQueryPair } from 'types/antlrQueryTypes';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isQuoted, unquote } from 'utils/stringUtils';

View File

@@ -9,7 +9,7 @@ import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';

View File

@@ -0,0 +1,185 @@
import { KeyboardEvent, useCallback, useState } from 'react';
import { OPERATORS } from 'constants/queryBuilder';
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
getRemovePrefixFromKey,
getTagToken,
replaceStringWithMaxLength,
tagRegexp,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { parse } from 'papaparse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { useFetchKeysAndValues } from './useFetchKeysAndValues';
import { useOptions, WHERE_CLAUSE_CUSTOM_SUFFIX } from './useOptions';
import { useSetCurrentKeyAndOperator } from './useSetCurrentKeyAndOperator';
import { useTag } from './useTag';
import { useTagValidation } from './useTagValidation';
export type WhereClauseConfig = {
customKey: string;
customOp: string;
};
export const useAutoComplete = (
query: IBuilderQuery,
whereClauseConfig?: WhereClauseConfig,
shouldUseSuggestions?: boolean,
isInfraMonitoring?: boolean,
entity?: InfraMonitoringEntity | null,
isMetricsExplorer?: boolean,
): IAutoComplete => {
const [searchValue, setSearchValue] = useState<string>('');
const [searchKey, setSearchKey] = useState<string>('');
const { keys, results, isFetching, exampleQueries } = useFetchKeysAndValues(
searchValue,
query,
searchKey,
shouldUseSuggestions,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const [key, operator, result] = useSetCurrentKeyAndOperator(searchValue, keys);
const handleSearch = (value: string): void => {
const prefixFreeValue = getRemovePrefixFromKey(getTagToken(value).tagKey);
setSearchValue(value);
setSearchKey(prefixFreeValue);
};
const { isValidTag, isExist, isValidOperator, isMulti } = useTagValidation(
operator,
result,
);
const { handleAddTag, handleClearTag, tags, updateTag } = useTag(
isValidTag,
handleSearch,
query,
setSearchKey,
whereClauseConfig,
isInfraMonitoring,
);
const handleSelect = useCallback(
(value: string): void => {
if (isMulti) {
setSearchValue((prev: string) => {
const matches = prev?.matchAll(tagRegexp);
const [match] = matches ? Array.from(matches) : [];
const [, , , matchTagValue] = match;
const data = parse(matchTagValue).data.flat();
return replaceStringWithMaxLength(prev, data as string[], value);
});
}
if (!isMulti) {
handleAddTag(value);
}
},
[handleAddTag, isMulti],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent): void => {
if (
event.key === ' ' &&
(searchValue.endsWith(' ') || searchValue.length === 0)
) {
event.preventDefault();
}
if (event.key === 'Enter' && searchValue && isValidTag) {
if (isMulti) {
event.stopPropagation();
}
event.preventDefault();
handleAddTag(searchValue);
}
if (event.key === 'Backspace' && !searchValue) {
event.stopPropagation();
const last = tags[tags.length - 1];
handleClearTag(last);
}
},
[handleAddTag, handleClearTag, isMulti, isValidTag, searchValue, tags],
);
const handleOnBlur = (event: React.FocusEvent<HTMLInputElement>): void => {
event.preventDefault();
if (searchValue) {
if (
key &&
!operator &&
whereClauseConfig?.customKey === 'body' &&
whereClauseConfig.customOp === OPERATORS.CONTAINS
) {
const value = `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`;
handleAddTag(value);
return;
}
handleAddTag(searchValue);
}
};
const options = useOptions(
key,
keys,
operator,
searchValue,
isMulti,
isValidOperator,
isExist,
results,
result,
isFetching,
whereClauseConfig,
isInfraMonitoring,
);
return {
updateTag,
handleSearch,
handleClearTag,
handleSelect,
handleKeyDown,
handleOnBlur,
options,
tags,
searchValue,
isMulti,
isFetching,
setSearchKey,
setSearchValue,
searchKey,
key,
exampleQueries,
};
};
interface IAutoComplete {
updateTag: (value: string) => void;
handleSearch: (value: string) => void;
handleClearTag: (value: string) => void;
handleSelect: (value: string) => void;
handleKeyDown: (event: React.KeyboardEvent) => void;
handleOnBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
options: Option[];
tags: string[];
searchValue: string;
isMulti: boolean;
isFetching: boolean;
setSearchKey: (value: string) => void;
setSearchValue: (value: string) => void;
searchKey: string;
key: string;
exampleQueries: TagFilter[];
isInfraMonitoring?: boolean;
}

View File

@@ -0,0 +1,308 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDebounce } from 'react-use';
import { getAttributesValues } from 'api/queryBuilder/getAttributesValues';
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import {
GetK8sEntityToAggregateAttribute,
InfraMonitoringEntity,
} from 'container/InfraMonitoringK8sV2/constants';
import {
getRemovePrefixFromKey,
getTagToken,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import useDebounceValue from 'hooks/useDebounce';
import { cloneDeep, isEqual, uniqWith, unset } from 'lodash-es';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useGetAggregateKeys } from './useGetAggregateKeys';
import { useGetAttributeSuggestions } from './useGetAttributeSuggestions';
type IuseFetchKeysAndValues = {
keys: BaseAutocompleteData[];
results: string[];
isFetching: boolean;
sourceKeys: BaseAutocompleteData[];
handleRemoveSourceKey: (newSourceKey: string) => void;
exampleQueries: TagFilter[];
};
/**
* Custom hook to fetch attribute keys and values from an API
* @param searchValue - the search query value
* @param query - an object containing data for the query
* @returns an object containing the fetched attribute keys, results, and the status of the fetch
*/
export const useFetchKeysAndValues = (
searchValue: string,
query: IBuilderQuery,
searchKey: string,
shouldUseSuggestions?: boolean,
isInfraMonitoring?: boolean,
entity?: InfraMonitoringEntity | null,
isMetricsExplorer?: boolean,
): IuseFetchKeysAndValues => {
const [keys, setKeys] = useState<BaseAutocompleteData[]>([]);
const [exampleQueries, setExampleQueries] = useState<TagFilter[]>([]);
const [sourceKeys, setSourceKeys] = useState<BaseAutocompleteData[]>([]);
const [results, setResults] = useState<string[]>([]);
const [isAggregateFetching, setAggregateFetching] = useState<boolean>(false);
const memoizedSearchParams = useMemo(
() => [
searchKey,
query.dataSource,
query.aggregateOperator,
query.aggregateAttribute?.key,
],
[
searchKey,
query.dataSource,
query.aggregateOperator,
query.aggregateAttribute?.key,
],
);
const searchParams = useDebounceValue(memoizedSearchParams, DEBOUNCE_DELAY);
const queryFiltersWithoutId = useMemo(
() => ({
...query.filters,
items: query.filters?.items?.map((item) => {
const filterWithoutId = cloneDeep(item);
unset(filterWithoutId, 'id');
return filterWithoutId;
}),
}),
[query.filters],
);
const memoizedSuggestionsParams = useMemo(
() => [searchKey, query.dataSource, queryFiltersWithoutId],
[query.dataSource, queryFiltersWithoutId, searchKey],
);
const suggestionsParams = useDebounceValue(
memoizedSuggestionsParams,
DEBOUNCE_DELAY,
);
const isQueryEnabled = useMemo(
() =>
query.dataSource === DataSource.METRICS &&
!isInfraMonitoring &&
!isMetricsExplorer
? !!query.dataSource && !!query.aggregateAttribute?.dataType
: true,
[
isInfraMonitoring,
isMetricsExplorer,
query.aggregateAttribute?.dataType,
query.dataSource,
],
);
const { data, isFetching, status } = useGetAggregateKeys(
{
searchText: searchKey,
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
aggregateAttribute:
isInfraMonitoring && entity
? GetK8sEntityToAggregateAttribute(entity)
: query.aggregateAttribute?.key || '',
tagType: query.aggregateAttribute?.type ?? null,
},
{
queryKey: [searchParams],
enabled: isMetricsExplorer ? false : isQueryEnabled && !shouldUseSuggestions,
},
isInfraMonitoring, // isInfraMonitoring
entity, // infraMonitoringEntity
);
const {
data: suggestionsData,
isFetching: isFetchingSuggestions,
status: fetchingSuggestionsStatus,
} = useGetAttributeSuggestions(
{
searchText: searchKey,
dataSource: query.dataSource,
filters: query.filters || { items: [], op: 'AND' },
},
{
queryKey: [suggestionsParams],
enabled: isQueryEnabled && shouldUseSuggestions,
},
);
function isAttributeValuesResponse(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
): payload is IAttributeValuesResponse {
return (
payload &&
(Array.isArray(payload.stringAttributeValues) ||
payload.stringAttributeValues === null ||
Array.isArray(payload.numberAttributeValues) ||
payload.numberAttributeValues === null ||
Array.isArray(payload.boolAttributeValues) ||
payload.boolAttributeValues === null)
);
}
/**
* Fetches the options to be displayed based on the selected value
* @param value - the selected value
* @param query - an object containing data for the query
*/
const handleFetchOption = async (
value: string,
query: IBuilderQuery,
keys: BaseAutocompleteData[],
): Promise<void> => {
if (!value) {
return;
}
const { tagKey, tagOperator, tagValue } = getTagToken(value);
const filterAttributeKey = keys.find(
(item) => item.key === getRemovePrefixFromKey(tagKey),
);
setResults([]);
if (!tagKey || !tagOperator) {
return;
}
setAggregateFetching(true);
try {
let payload;
if (isInfraMonitoring && entity) {
const response = await getAttributesValues({
aggregateOperator: 'noop',
dataSource: query.dataSource,
aggregateAttribute:
GetK8sEntityToAggregateAttribute(entity) ||
query.aggregateAttribute?.key ||
'',
attributeKey: filterAttributeKey?.key ?? tagKey,
filterAttributeKeyDataType:
filterAttributeKey?.dataType ?? DataTypes.EMPTY,
tagType: filterAttributeKey?.type ?? '',
searchText: isInNInOperator(tagOperator)
? (tagValue[tagValue.length - 1]?.toString() ?? '')
: (tagValue?.toString() ?? ''),
});
payload = response.payload;
} else {
const response = await getAttributesValues({
aggregateOperator: query.aggregateOperator || '',
dataSource: query.dataSource,
aggregateAttribute: query.aggregateAttribute?.key || '',
attributeKey: filterAttributeKey?.key ?? tagKey,
filterAttributeKeyDataType:
filterAttributeKey?.dataType ?? DataTypes.EMPTY,
tagType: filterAttributeKey?.type ?? '',
searchText: isInNInOperator(tagOperator)
? (tagValue[tagValue.length - 1]?.toString() ?? '')
: (tagValue?.toString() ?? ''),
});
payload = response.payload;
}
if (payload && isAttributeValuesResponse(payload)) {
const dataType = filterAttributeKey?.dataType ?? DataTypes.String;
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
setResults(key ? payload[key] || [] : []);
return;
}
} catch (e) {
console.error(e);
} finally {
setAggregateFetching(false);
}
};
const handleRemoveSourceKey = useCallback((sourceKey: string) => {
setSourceKeys((prevState) =>
prevState.filter((item) => item.key !== sourceKey),
);
}, []);
// creates a ref to the fetch function so that it doesn't change on every render
const clearFetcher = useRef(handleFetchOption).current;
// debounces the fetch function to avoid excessive API calls
useDebounce(() => clearFetcher(searchValue, query, keys), 750, [
clearFetcher,
searchValue,
query,
keys,
]);
// update the fetched keys when the fetch status changes
useEffect(() => {
if (status === 'success' && data?.payload?.attributeKeys) {
setKeys(data.payload.attributeKeys);
setSourceKeys((prevState) =>
uniqWith([...(data.payload.attributeKeys ?? []), ...prevState], isEqual),
);
} else {
setKeys([]);
}
}, [data?.payload?.attributeKeys, status]);
useEffect(() => {
if (
fetchingSuggestionsStatus === 'success' &&
suggestionsData?.payload?.attributes
) {
if (!isInfraMonitoring) {
setKeys(suggestionsData.payload.attributes);
setSourceKeys((prevState) =>
uniqWith(
[...(suggestionsData.payload.attributes ?? []), ...prevState],
isEqual,
),
);
}
} else {
setKeys([]);
}
if (
fetchingSuggestionsStatus === 'success' &&
suggestionsData?.payload?.example_queries
) {
setExampleQueries(suggestionsData.payload.example_queries);
} else {
setExampleQueries([]);
}
}, [
suggestionsData?.payload?.attributes,
fetchingSuggestionsStatus,
suggestionsData?.payload?.example_queries,
isInfraMonitoring,
]);
return {
keys,
results,
isFetching: isFetching || isAggregateFetching || isFetchingSuggestions,
sourceKeys,
handleRemoveSourceKey,
exampleQueries,
};
};

View File

@@ -0,0 +1,31 @@
import { useMemo } from 'react';
import {
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
} from 'constants/queryBuilder';
import { getRemovePrefixFromKey } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
type IOperators =
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.universal
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.string
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.bool
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.int64
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.float64;
export const useOperators = (
key: string,
keys: BaseAutocompleteData[],
): IOperators =>
useMemo(() => {
const currentKey = keys?.find((el) => el.key === getRemovePrefixFromKey(key));
const strippedKey = key.split(' ')[0];
return currentKey?.dataType
? QUERY_BUILDER_OPERATORS_BY_TYPES[
currentKey.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
]
: strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')
? [OPERATORS.HAS, OPERATORS.NHAS]
: QUERY_BUILDER_OPERATORS_BY_TYPES.universal;
}, [keys, key]);

View File

@@ -0,0 +1,220 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder';
import {
checkCommaInValue,
getTagToken,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { isEmpty } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { WhereClauseConfig } from './useAutoComplete';
import { useOperators } from './useOperators';
export const WHERE_CLAUSE_CUSTOM_SUFFIX = '-custom';
export const useOptions = (
key: string,
keys: BaseAutocompleteData[],
operator: string,
searchValue: string,
isMulti: boolean,
isValidOperator: boolean,
isExist: boolean,
results: string[],
result: string[],
isFetching: boolean,
whereClauseConfig?: WhereClauseConfig,
isInfraMonitoring?: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): Option[] => {
const [options, setOptions] = useState<Option[]>([]);
const operators = useOperators(key, keys);
// get matching dynamic variables to suggest
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const variableName = dashboardDynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === key,
)?.name;
const variableAsValue = variableName ? `$${variableName}` : '';
const getLabel = useCallback(
(data: BaseAutocompleteData): Option['label'] => data?.key,
[],
);
const getOptionsFromKeys = useCallback(
(items: BaseAutocompleteData[]): Option[] =>
items?.map((item) => ({
label: `${getLabel(item)}`,
value: item.key,
dataType: item.dataType,
isIndexed: item?.isIndexed,
type: item?.type || '',
})),
[getLabel],
);
const getKeyOpValue = useCallback(
(items: string[]): Option[] =>
items?.map((item) => ({
label: `${key} ${operator} ${item}`,
value: `${key} ${operator} ${item}`,
})),
[key, operator],
);
const getOptionsWithValidOperator = useCallback(
(key: string, results: string[], searchValue: string) => {
const hasAllResults = results.every((value) => result.includes(value));
let newResults = results;
if (!isEmpty(variableAsValue)) {
newResults = [variableAsValue, ...newResults];
}
const values = getKeyOpValue(newResults);
return hasAllResults
? [
{
label: searchValue,
value: searchValue,
},
]
: [
{
label: searchValue,
value: searchValue,
},
...values,
];
},
[getKeyOpValue, result, variableAsValue],
);
const getKeyOperatorOptions = useCallback(
(key: string) => {
const keyOperator = key.split(' ');
const partialOperator = keyOperator?.[1];
const partialKey = keyOperator?.[0];
const filteredOperators = !isEmpty(partialOperator)
? operators?.filter((operator) =>
operator.startsWith(partialOperator?.toUpperCase()),
)
: operators;
const operatorsOptions = filteredOperators?.map((op) => {
const labelOp =
isInfraMonitoring && INFRA_SHORT_TO_LONG_OPERATOR_MAP[op]
? INFRA_SHORT_TO_LONG_OPERATOR_MAP[op]
: op;
return {
value: `${partialKey} ${op} `,
label: `${partialKey} ${labelOp} `,
};
});
if (whereClauseConfig) {
return [
{
label: `${searchValue} `,
value: `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`,
},
...operatorsOptions,
];
}
return operatorsOptions;
},
[isInfraMonitoring, operators, searchValue, whereClauseConfig],
);
useEffect(() => {
let newOptions: Option[] = [];
if (!key) {
newOptions = searchValue
? [
{
label: `${searchValue} `,
value: `${searchValue} `,
},
...getOptionsFromKeys(keys),
]
: getOptionsFromKeys(keys);
} else if (key && !operator) {
newOptions = getKeyOperatorOptions(key);
} else if (key && operator) {
if (isMulti) {
const resultsWithVariable = isEmpty(variableAsValue)
? results
: [variableAsValue, ...results];
newOptions = resultsWithVariable.map((item) => ({
label: checkCommaInValue(String(item)),
value: String(item),
}));
} else if (isExist) {
newOptions = [];
} else if (isValidOperator) {
newOptions = getOptionsWithValidOperator(key, results, searchValue);
}
}
if (newOptions.length > 0) {
setOptions(newOptions);
}
if (isFetching) {
setOptions([]);
}
}, [
whereClauseConfig,
getKeyOpValue,
getOptionsFromKeys,
isExist,
isMulti,
isValidOperator,
key,
keys,
operator,
operators,
result,
results,
searchValue,
getKeyOperatorOptions,
getOptionsWithValidOperator,
isFetching,
variableAsValue,
]);
return useMemo(
() =>
(
options.filter(
(option, index, self) =>
index ===
self.findIndex(
(o) =>
o.label === option.label &&
o.value === option.value &&
(o.type || '') === (option.type || '') &&
(o.dataType || '') === (option.dataType || ''), // keep entries with same key but different type/dataType
) && option.value !== '',
) || []
).map((option) => {
const { tagValue } = getTagToken(searchValue);
if (isMulti) {
return {
...option,
selected: tagValue
.filter((i) => i.trim().replace(/^\s+/, '') === option.value)
.includes(option.value),
};
}
return option;
}),
[isMulti, options, searchValue],
);
};

View File

@@ -0,0 +1,32 @@
import { useMemo } from 'react';
import {
getRemovePrefixFromKey,
getTagToken,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
type ICurrentKeyAndOperator = [string, string, string[]];
export const useSetCurrentKeyAndOperator = (
value: string,
keys: BaseAutocompleteData[],
): ICurrentKeyAndOperator => {
const [key, operator, result] = useMemo(() => {
let key = '';
let operator = '';
let result: string[] = [];
const { tagKey, tagOperator, tagValue } = getTagToken(value);
const isSuggestKey = keys?.some(
(el) => el?.key === getRemovePrefixFromKey(tagKey),
);
if (isSuggestKey || keys.length === 0) {
key = tagKey || '';
operator = tagOperator || '';
result = tagValue || [];
}
return [key, operator, result];
}, [value, keys]);
return [key, operator, result];
};

View File

@@ -1,10 +1,18 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder';
import {
getOperatorFromValue,
getTagToken,
isExistsNotExistsOperator,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { unparse } from 'papaparse';
import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { WhereClauseConfig } from './useAutoComplete';
/**
* Helper for formatting a TagFilter object into filter item strings
@@ -34,3 +42,95 @@ export function queryFilterTags(
return `${ele.key?.key} ${displayOp} ${ele.value}`;
});
}
type IUseTag = {
handleAddTag: (value: string) => void;
handleClearTag: (value: string) => void;
tags: string[];
updateTag: (value: string) => void;
};
/**
* A custom React hook for handling tags.
* @param {string} key - A string value to identify tags.
* @param {boolean} isValidTag - A boolean value to indicate whether the tag is valid.
* @param {function} handleSearch - A callback function to handle search.
* @returns {IUseTag} The return object containing handlers and tags.
*/
export const useTag = (
isValidTag: boolean,
handleSearch: (value: string) => void,
query: IBuilderQuery,
setSearchKey: (value: string) => void,
whereClauseConfig?: WhereClauseConfig,
isInfraMonitoring?: boolean,
): IUseTag => {
const initTagsData = useMemo(
() =>
queryFilterTags(
query?.filters || { items: [], op: 'AND' },
isInfraMonitoring,
),
[query?.filters, isInfraMonitoring],
);
const [tags, setTags] = useState<string[]>(initTagsData);
const updateTag = (value: string): void => {
const newTags = tags?.filter((item: string) => item !== value);
setTags(newTags);
};
/**
* Adds a new tag to the tag list.
* @param {string} value - The tag value to be added.
*/
const handleAddTag = useCallback(
(value: string): void => {
const { tagKey } = getTagToken(value);
const parts = tagKey.split('-');
// this is done to ensure that `hello-world` also gets converted to `body CONTAINS hello-world`
let id = parts[parts.length - 1];
let key = parts.slice(0, -1).join('-');
if (parts.length === 1) {
id = '';
[key] = parts;
}
if (id === 'custom') {
const customValue = whereClauseConfig
? `${whereClauseConfig.customKey} ${whereClauseConfig.customOp} ${key}`
: '';
setTags((prevTags) =>
prevTags.includes(customValue) ? prevTags : [...prevTags, customValue],
);
handleSearch('');
setSearchKey('');
return;
}
if ((value && key && isValidTag) || isExistsNotExistsOperator(value)) {
setTags((prevTags) => [...prevTags, value]);
handleSearch('');
setSearchKey('');
}
},
[whereClauseConfig, isValidTag, handleSearch, setSearchKey],
);
/**
* Removes a tag from the tag list.
* @param {string} value - The tag value to be removed.
*/
const handleClearTag = useCallback((value: string): void => {
setTags((prevTags) => prevTags.filter((v) => v !== value));
}, []);
useEffect(() => {
setTags(initTagsData);
}, [initTagsData]);
return { handleAddTag, handleClearTag, tags, updateTag };
};

Some files were not shown because too many files have changed in this diff Show More