mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-18 04:10:31 +01:00
Compare commits
36 Commits
anomaly-v2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac5b5947eb | ||
|
|
c563d79ee0 | ||
|
|
b9b92f951b | ||
|
|
6bcc2d66d9 | ||
|
|
5ee1ca7d05 | ||
|
|
98c0c53a2b | ||
|
|
6d3861d60f | ||
|
|
20e13f66e0 | ||
|
|
07fe353b94 | ||
|
|
9fb89fafc8 | ||
|
|
0655328fa9 | ||
|
|
f24102c0db | ||
|
|
edee102b52 | ||
|
|
9a26998d18 | ||
|
|
fbf2e62044 | ||
|
|
62c44f5eac | ||
|
|
65fde71b72 | ||
|
|
7f5f63b20a | ||
|
|
63cfbe8bfb | ||
|
|
0c28b8b251 | ||
|
|
10a0d262d9 | ||
|
|
334155a226 | ||
|
|
42de840534 | ||
|
|
891106d1ca | ||
|
|
707f01bd9c | ||
|
|
3cd0c21703 | ||
|
|
181feb3eb9 | ||
|
|
bf35d12398 | ||
|
|
1a1f7b2d78 | ||
|
|
a2778aa45f | ||
|
|
16c1fce326 | ||
|
|
06b762b7ef | ||
|
|
466edf1f1c | ||
|
|
849353b5c0 | ||
|
|
8e4d7c68b3 | ||
|
|
2704317c6a |
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
init-clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
image: clickhouse/clickhouse-server:25.12.5
|
||||
container_name: init-clickhouse
|
||||
command:
|
||||
- bash
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
volumes:
|
||||
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.5.6
|
||||
image: clickhouse/clickhouse-server:25.12.5
|
||||
container_name: clickhouse
|
||||
volumes:
|
||||
- ${PWD}/fs/etc/clickhouse-server/config.d/config.xml:/etc/clickhouse-server/config.d/config.xml
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
telemetrystore-migrator:
|
||||
image: signoz/signoz-otel-collector:v0.142.0
|
||||
image: signoz/signoz-otel-collector:v0.144.6
|
||||
container_name: telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -54,22 +54,24 @@ jobs:
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- rawexportdata
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
- clickhousecluster
|
||||
- metricreduction
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
sqlite-mode:
|
||||
- wal
|
||||
clickhouse-version:
|
||||
- 25.5.6
|
||||
- 25.12.5
|
||||
schema-migrator-version:
|
||||
- v0.144.3
|
||||
- v0.144.6
|
||||
postgres-version:
|
||||
- 15
|
||||
if: |
|
||||
|
||||
@@ -4259,6 +4259,34 @@ components:
|
||||
type: number
|
||||
clusterName:
|
||||
type: string
|
||||
counts:
|
||||
properties:
|
||||
daemonSets:
|
||||
format: int64
|
||||
type: integer
|
||||
deployments:
|
||||
format: int64
|
||||
type: integer
|
||||
jobs:
|
||||
format: int64
|
||||
type: integer
|
||||
namespaces:
|
||||
format: int64
|
||||
type: integer
|
||||
nodes:
|
||||
format: int64
|
||||
type: integer
|
||||
statefulSets:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- nodes
|
||||
- namespaces
|
||||
- deployments
|
||||
- daemonSets
|
||||
- jobs
|
||||
- statefulSets
|
||||
type: object
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -4279,6 +4307,7 @@ components:
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- counts
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesClusters:
|
||||
@@ -4800,6 +4829,26 @@ components:
|
||||
type: object
|
||||
InframonitoringtypesNamespaceRecord:
|
||||
properties:
|
||||
counts:
|
||||
properties:
|
||||
daemonSets:
|
||||
format: int64
|
||||
type: integer
|
||||
deployments:
|
||||
format: int64
|
||||
type: integer
|
||||
jobs:
|
||||
format: int64
|
||||
type: integer
|
||||
statefulSets:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- deployments
|
||||
- daemonSets
|
||||
- jobs
|
||||
- statefulSets
|
||||
type: object
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -4823,6 +4872,7 @@ components:
|
||||
- namespaceMemory
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- counts
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNamespaces:
|
||||
@@ -7521,6 +7571,7 @@ components:
|
||||
groupBy:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
newGroupEvalDelay:
|
||||
type: string
|
||||
@@ -7592,6 +7643,7 @@ components:
|
||||
alertStates:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
nullable: true
|
||||
type: array
|
||||
enabled:
|
||||
type: boolean
|
||||
@@ -7998,6 +8050,15 @@ components:
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableSpanMappers:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapper'
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -8150,7 +8211,7 @@ components:
|
||||
type: boolean
|
||||
fieldContext:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
group_id:
|
||||
groupId:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
@@ -8163,7 +8224,7 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- group_id
|
||||
- groupId
|
||||
- name
|
||||
- fieldContext
|
||||
- config
|
||||
@@ -13742,7 +13803,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMappers'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -24405,9 +24466,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Query range
|
||||
tags:
|
||||
- querier
|
||||
@@ -24474,9 +24543,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Query range preview
|
||||
tags:
|
||||
- querier
|
||||
|
||||
@@ -63,5 +63,6 @@
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
}
|
||||
@@ -88,5 +88,6 @@
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
}
|
||||
@@ -5669,6 +5669,39 @@ export interface InframonitoringtypesChecksDTO {
|
||||
type: InframonitoringtypesCheckTypeDTO;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesClusterRecordDTOCounts = {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
daemonSets: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
deployments: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
jobs: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
namespaces: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
nodes: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
statefulSets: number;
|
||||
};
|
||||
|
||||
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -5813,6 +5846,10 @@ export interface InframonitoringtypesClusterRecordDTO {
|
||||
* @type string
|
||||
*/
|
||||
clusterName: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
counts: InframonitoringtypesClusterRecordDTOCounts;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
@@ -6368,6 +6405,29 @@ export interface InframonitoringtypesJobsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesNamespaceRecordDTOCounts = {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
daemonSets: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
deployments: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
jobs: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
statefulSets: number;
|
||||
};
|
||||
|
||||
export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -6379,6 +6439,10 @@ export type InframonitoringtypesNamespaceRecordDTOMeta =
|
||||
InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
|
||||
|
||||
export interface InframonitoringtypesNamespaceRecordDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
counts: InframonitoringtypesNamespaceRecordDTOCounts;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
@@ -8611,9 +8675,9 @@ export interface RuletypesGettableTestRuleDTO {
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
alertStates?: RuletypesAlertStateDTO[];
|
||||
alertStates?: RuletypesAlertStateDTO[] | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -8626,9 +8690,9 @@ export interface RuletypesRenotifyDTO {
|
||||
|
||||
export interface RuletypesNotificationSettingsDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: string[];
|
||||
groupBy?: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9194,6 +9258,76 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
sources: SpantypesSpanMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
groupId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMappersDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
items: SpantypesSpanMapperDTO[];
|
||||
}
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -9440,30 +9574,6 @@ export interface SpantypesPostableFlamegraphDTO {
|
||||
selectedSpanId?: string;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
sources: SpantypesSpanMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
@@ -9512,45 +9622,6 @@ export interface SpantypesPostableWaterfallDTO {
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
group_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesUpdatableSpanMapperDTO {
|
||||
config?: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
@@ -10852,7 +10923,7 @@ export type ListSpanMappersPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type ListSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperGroupsDTO;
|
||||
data: SpantypesGettableSpanMappersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useExportRawData } from 'hooks/useExportData/useServerExport';
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -18,11 +19,13 @@ import './DownloadOptionsMenu.styles.scss';
|
||||
interface DownloadOptionsMenuProps {
|
||||
dataSource: DataSource;
|
||||
selectedColumns?: TelemetryFieldKey[];
|
||||
panelType?: PANEL_TYPES;
|
||||
}
|
||||
|
||||
export default function DownloadOptionsMenu({
|
||||
dataSource,
|
||||
selectedColumns,
|
||||
panelType,
|
||||
}: DownloadOptionsMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
|
||||
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
|
||||
@@ -33,6 +36,7 @@ export default function DownloadOptionsMenu({
|
||||
|
||||
const { isDownloading, handleExportRawData } = useExportRawData({
|
||||
dataSource,
|
||||
panelType,
|
||||
});
|
||||
|
||||
const handleExport = useCallback(async (): Promise<void> => {
|
||||
|
||||
33
frontend/src/components/ExportMenu/ExportMenu.styles.scss
Normal file
33
frontend/src/components/ExportMenu/ExportMenu.styles.scss
Normal file
@@ -0,0 +1,33 @@
|
||||
.export-menu-popover {
|
||||
width: 240px;
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.export-format {
|
||||
padding: 12px 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// radio option labels — radix popover content inherits the root font
|
||||
// size; pin to the app's 13px base the antd popover used to impose
|
||||
label {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
92
frontend/src/components/ExportMenu/ExportMenu.tsx
Normal file
92
frontend/src/components/ExportMenu/ExportMenu.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Download } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
ClientExportData,
|
||||
useClientExport,
|
||||
} from 'hooks/useExportData/useClientExport';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import './ExportMenu.styles.scss';
|
||||
|
||||
interface ExportMenuProps {
|
||||
dataSource: DataSource;
|
||||
// The queryRange response object the view holds — the hook picks the
|
||||
// serializer (timeseries / table) from what it carries.
|
||||
data: ClientExportData;
|
||||
query?: Query;
|
||||
yAxisUnit?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
// Download menu for in-memory query results (client-side serialization).
|
||||
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
|
||||
export default function ExportMenu({
|
||||
dataSource,
|
||||
data,
|
||||
query,
|
||||
yAxisUnit,
|
||||
fileName,
|
||||
}: ExportMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const { isExporting, handleExport: handleClientExport } = useClientExport({
|
||||
data,
|
||||
query,
|
||||
yAxisUnit,
|
||||
fileName,
|
||||
});
|
||||
|
||||
const handleExport = useCallback((): void => {
|
||||
setIsPopoverOpen(false);
|
||||
handleClientExport({ format: exportFormat as ExportFormat });
|
||||
}, [exportFormat, handleClientExport]);
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipSimple title="Download">
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Download"
|
||||
data-testid={`export-menu-${dataSource}`}
|
||||
disabled={isExporting}
|
||||
loading={isExporting}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipSimple>
|
||||
<PopoverContent align="end" className="export-menu-popover">
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup value={exportFormat} onChange={setExportFormat}>
|
||||
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
|
||||
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="export-button"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
loading={isExporting}
|
||||
prefix={<Download size={16} />}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import ExportMenu from '../ExportMenu';
|
||||
|
||||
const mockHandleExport = jest.fn();
|
||||
let mockIsExporting = false;
|
||||
|
||||
jest.mock('hooks/useExportData/useClientExport', () => ({
|
||||
useClientExport: (): unknown => ({
|
||||
isExporting: mockIsExporting,
|
||||
handleExport: mockHandleExport,
|
||||
}),
|
||||
}));
|
||||
|
||||
const data = {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: { data: { result: [], resultType: 'time_series' } },
|
||||
} as unknown as MetricQueryRangeSuccessResponse;
|
||||
|
||||
const TEST_ID = `export-menu-${DataSource.LOGS}`;
|
||||
|
||||
function renderMenu(): void {
|
||||
render(
|
||||
<ExportMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
data={data}
|
||||
fileName="logs-timeseries"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ExportMenu', () => {
|
||||
beforeEach(() => {
|
||||
mockHandleExport.mockReset();
|
||||
mockIsExporting = false;
|
||||
});
|
||||
|
||||
it('renders the download trigger button', () => {
|
||||
renderMenu();
|
||||
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only format options — no shape, row-count, or column controls', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId(TEST_ID));
|
||||
|
||||
expect(screen.getByText('FORMAT')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'csv' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'jsonl' })).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Number of Rows')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('radio', { name: 'long' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('radio', { name: 'wide' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exports as csv by default', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId(TEST_ID));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
expect(mockHandleExport).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'csv' });
|
||||
});
|
||||
|
||||
it('exports as jsonl when selected', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId(TEST_ID));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
|
||||
});
|
||||
|
||||
it('disables the trigger while an export is in progress', () => {
|
||||
mockIsExporting = true;
|
||||
renderMenu();
|
||||
|
||||
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
@@ -148,6 +150,8 @@ export function applyCheckboxToggle({
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
sectionType,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
@@ -157,6 +161,8 @@ export function applyCheckboxToggle({
|
||||
value: string;
|
||||
checked: boolean;
|
||||
isOnlyOrAllClicked: boolean;
|
||||
previousState?: CheckedState;
|
||||
sectionType?: SectionType;
|
||||
}): Query {
|
||||
const activeItems =
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
|
||||
@@ -216,6 +222,7 @@ export function applyCheckboxToggle({
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
@@ -246,9 +253,29 @@ export function applyCheckboxToggle({
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
// Related section: clicking to exclude creates NOT_IN for just this value
|
||||
if (sectionType === SectionType.RELATED) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else if (isArray(currentFilter.value)) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
@@ -275,11 +302,38 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
case 'not in': {
|
||||
// NOT IN means "exclude these values"
|
||||
// Check if value is currently in the exclusion list
|
||||
const isValueInFilter = isArray(currentFilter.value)
|
||||
? currentFilter.value.includes(value)
|
||||
: currentFilter.value === value;
|
||||
|
||||
// When clicking unchecked "Other" item, user wants to SELECT it
|
||||
// Replace NOT IN filter with IN [value]
|
||||
if (previousState === 'unchecked' && checked) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else if (!checked || !isValueInFilter) {
|
||||
// Add to NOT IN when:
|
||||
// - checked=false (user explicitly unchecked to exclude)
|
||||
// - checked=true but value not in filter (clicking "other" value to exclude)
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
@@ -292,7 +346,6 @@ export function applyCheckboxToggle({
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
@@ -304,8 +357,9 @@ export function applyCheckboxToggle({
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
} else {
|
||||
// Remove from NOT IN when value IS in filter and checked=true
|
||||
// (user wants to include this value back)
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
@@ -346,6 +400,7 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
@@ -389,10 +444,11 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
// No filter for this key - all are visually selected.
|
||||
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
@@ -24,6 +26,8 @@ interface UseCheckboxFilterActionsReturn {
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
@@ -53,6 +57,8 @@ function useCheckboxFilterActions({
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
): void => {
|
||||
dispatch(
|
||||
applyCheckboxToggle({
|
||||
@@ -64,6 +70,8 @@ function useCheckboxFilterActions({
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
sectionType,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
.checkboxFilter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-6);
|
||||
gap: var(--spacing-6);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-focus-border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.searchSpinner {
|
||||
color: var(--l2-foreground);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sectionValues {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.loadingValues {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-6) 0;
|
||||
}
|
||||
|
||||
.loadingMore {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.noData {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.showMore {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showMoreText {
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.goToDocs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 44px;
|
||||
}
|
||||
|
||||
.goToDocsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.goToDocsMessage {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.goToDocsButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
cursor: pointer;
|
||||
margin: 0 0 var(--spacing-2);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.goToDocsButtonText {
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from '../../../types';
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
|
||||
export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Environment',
|
||||
attributeKey: {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: 1700000000000,
|
||||
endUnixMilli: 1700003600000,
|
||||
existingQuery: null,
|
||||
};
|
||||
|
||||
export function mockFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
numberValues?: (number | null)[];
|
||||
}): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: response.relatedValues ?? [],
|
||||
stringValues: response.stringValues ?? [],
|
||||
numberValues: response.numberValues ?? [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function mockFieldsValuesAPILoading(): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
res(ctx.delay(10000)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function setupServer(): void {
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
}
|
||||
|
||||
export interface FilterItemConfig {
|
||||
op: string;
|
||||
value: string | string[];
|
||||
}
|
||||
|
||||
export function renderWithFilter(
|
||||
onFilterChange: jest.Mock,
|
||||
filterItem?: FilterItemConfig,
|
||||
): RenderResult {
|
||||
const items: TagFilterItem[] = filterItem
|
||||
? [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: filterItem.op,
|
||||
value: filterItem.value,
|
||||
} as TagFilterItem,
|
||||
]
|
||||
: [];
|
||||
|
||||
return render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items, op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getFilterFromCall(
|
||||
onFilterChange: jest.Mock,
|
||||
callIndex = 0,
|
||||
): TagFilterItem | undefined {
|
||||
const query = onFilterChange.mock.calls[callIndex]?.[0] as Query | undefined;
|
||||
return query?.builder.queryData[0]?.filters?.items?.find(
|
||||
(item) => item.key?.key === 'deployment.environment',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
|
||||
import useActiveQueryIndex from '../useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from '../useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from '../useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from '../useCheckboxFilterState';
|
||||
import { useFieldValues } from './useFieldValues';
|
||||
import { useExistingQuery } from './useExistingQuery';
|
||||
import { isKeyMatch } from '../utils';
|
||||
|
||||
import { CheckboxFilterV2Header } from './CheckboxFilterV2Header';
|
||||
import { CheckboxFilterV2Section } from './CheckboxFilterV2Section';
|
||||
import { buildSelectedSet, useSectionedValues } from './useSectionedValues';
|
||||
import { useStaleRelatedExclusions } from './useStaleRelatedExclusions';
|
||||
|
||||
import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface CheckboxFilterV2Props {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
}
|
||||
|
||||
export default function CheckboxFilterV2(
|
||||
props: CheckboxFilterV2Props,
|
||||
): JSX.Element {
|
||||
const { source, filter, onFilterChange, useFieldApis } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
} = useCheckboxDisclosure({ filter, activeQueryIndex });
|
||||
|
||||
// Auto-preserve open state when filter is present
|
||||
useEffect(() => {
|
||||
if (isSomeFilterPresentForCurrentAttribute && userToggleState === null) {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
}, [isSomeFilterPresentForCurrentAttribute, userToggleState]);
|
||||
|
||||
const { existingQuery, hasExistingQuery } = useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
});
|
||||
|
||||
const { relatedValues, allValues, isLoading, isFetching } = useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace: useFieldApis.metricNamespace,
|
||||
startUnixMilli: useFieldApis.startUnixMilli,
|
||||
endUnixMilli: useFieldApis.endUnixMilli,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Track if initial load completed (don't show skeleton after first load)
|
||||
// Must track if loading ever started, otherwise hasLoadedOnce gets set
|
||||
// immediately on first render when query is disabled (isLoading=false)
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const wasLoading = useRef(false);
|
||||
if (isLoading) {
|
||||
wasLoading.current = true;
|
||||
}
|
||||
if (!isLoading && wasLoading.current && !hasLoadedOnce.current) {
|
||||
hasLoadedOnce.current = true;
|
||||
}
|
||||
|
||||
// Combine for state derivation
|
||||
const attributeValues = useMemo(() => {
|
||||
const combined = [...relatedValues, ...allValues];
|
||||
return [...new Set(combined)];
|
||||
}, [relatedValues, allValues]);
|
||||
|
||||
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
|
||||
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
});
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
const currentFilterOp = useMemo(() => {
|
||||
const filterSync = currentQuery?.builder.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items.find((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
return filterSync?.op;
|
||||
}, [
|
||||
currentQuery?.builder.queryData,
|
||||
activeQueryIndex,
|
||||
filter.attributeKey.key,
|
||||
]);
|
||||
|
||||
const isNotInOperator = NON_SELECTED_OPERATORS.includes(currentFilterOp || '');
|
||||
|
||||
const selectedValues = useMemo(
|
||||
() => [
|
||||
...buildSelectedSet(
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
),
|
||||
],
|
||||
[currentFilterState, isSomeFilterPresentForCurrentAttribute, isNotInOperator],
|
||||
);
|
||||
|
||||
const isRefreshing = isFetching && !isLoading && searchText === '';
|
||||
const relatedExclusions = useStaleRelatedExclusions({
|
||||
selectedValues,
|
||||
isFetching,
|
||||
isRefreshing,
|
||||
});
|
||||
|
||||
const { sections, totalCount } = useSectionedValues({
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
visibleItemsCount,
|
||||
relatedExclusions,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
|
||||
<CheckboxFilterV2Header
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
<section>
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{totalCount > 0 && (
|
||||
<section className={styles.values}>
|
||||
{sections.map((section, index) => (
|
||||
<CheckboxFilterV2Section
|
||||
key={section.type}
|
||||
section={section}
|
||||
index={index}
|
||||
isFilterDisabled={isFilterDisabled}
|
||||
filter={filter}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
}
|
||||
isMultipleValuesTrueForTheKey={isMultipleValuesTrueForTheKey}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{totalCount === 0 && hasLoadedOnce.current && !isFetching && (
|
||||
<section
|
||||
className={styles.noData}
|
||||
data-testid={
|
||||
searchText
|
||||
? 'checkbox-filter-no-search-results'
|
||||
: 'checkbox-filter-empty'
|
||||
}
|
||||
>
|
||||
<Typography.Text>No values found</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visibleItemsCount < totalCount &&
|
||||
!(searchText && (isLoading || isFetching)) && (
|
||||
<section className={styles.showMore}>
|
||||
<Typography.Text
|
||||
className={styles.showMoreText}
|
||||
onClick={onShowMore}
|
||||
data-testid="checkbox-filter-show-more"
|
||||
>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leftAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.rightAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.clearAll {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import styles from './CheckboxFilterV2Header.module.scss';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onClear: () => void;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
onToggleOpen,
|
||||
onClear,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.header}
|
||||
onClick={onToggleOpen}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onToggleOpen();
|
||||
}
|
||||
}}
|
||||
data-testid="checkbox-filter-header"
|
||||
data-state={isOpen ? 'open' : 'closed'}
|
||||
>
|
||||
<section className={styles.leftAction}>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.rightAction}>
|
||||
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
|
||||
<Typography.Text
|
||||
className={styles.clearAll}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
data-testid="checkbox-filter-clear-all"
|
||||
>
|
||||
Clear
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
CheckedState,
|
||||
} from 'components/QuickFilters/types';
|
||||
|
||||
import { CheckboxFilterV2ValueRow } from './CheckboxFilterV2ValueRow';
|
||||
import { SectionDivider } from './SectionDivider';
|
||||
import { Section } from './useSectionedValues';
|
||||
import { SectionType } from './itemRules';
|
||||
|
||||
import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface SectionConfig {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
function getSectionConfig(type: SectionType): SectionConfig | null {
|
||||
switch (type) {
|
||||
case SectionType.SELECTED:
|
||||
return { label: 'Selected' };
|
||||
case SectionType.RELATED:
|
||||
return {
|
||||
label: 'Related',
|
||||
tooltip: 'Values that are filtered by your current selection.',
|
||||
};
|
||||
case SectionType.ALL_VALUES:
|
||||
return { label: 'All values' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface CheckboxFilterV2SectionProps {
|
||||
section: Section;
|
||||
index: number;
|
||||
isFilterDisabled: boolean;
|
||||
filter: IQuickFiltersConfig;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
isMultipleValuesTrueForTheKey: boolean;
|
||||
onChange: (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnly: boolean,
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Section(
|
||||
props: CheckboxFilterV2SectionProps,
|
||||
): JSX.Element | null {
|
||||
const {
|
||||
section,
|
||||
index,
|
||||
isFilterDisabled,
|
||||
filter,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isMultipleValuesTrueForTheKey,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
if (section.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = getSectionConfig(section.type);
|
||||
|
||||
// Show divider for all sections except first SELECTED section
|
||||
const showDivider =
|
||||
config !== null && (index > 0 || section.type !== SectionType.SELECTED);
|
||||
|
||||
return (
|
||||
<div data-testid={`section-${section.type}`} className={styles.sectionValues}>
|
||||
{showDivider && config && (
|
||||
<SectionDivider label={config.label} tooltip={config.tooltip} />
|
||||
)}
|
||||
{section.items.map(({ value, badge, checkedState }) => {
|
||||
const isChecked = checkedState === 'checked';
|
||||
|
||||
return (
|
||||
<CheckboxFilterV2ValueRow
|
||||
key={value}
|
||||
value={value}
|
||||
checkedState={checkedState}
|
||||
disabled={isFilterDisabled}
|
||||
title={filter.title}
|
||||
badge={badge}
|
||||
onlyButtonLabel={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
? isChecked && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'
|
||||
}
|
||||
customRendererForValue={filter.customRendererForValue}
|
||||
onCheckboxChange={(checked, previousState): void =>
|
||||
onChange(value, checked, false, previousState, section.type)
|
||||
}
|
||||
onOnlyOrAllClick={(): void => onChange(value, isChecked, true)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
.valueRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.valueButton {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
width: calc(100% - 24px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.valueLabel {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
|
||||
// Stack badge / only / toggle in a single cell so the crossfade overlaps
|
||||
// instead of laying them side-by-side mid-transition.
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
}
|
||||
|
||||
.onlyButton {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
--button-height: 21px;
|
||||
--button-padding: var(--spacing-5);
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
--button-height: 21px;
|
||||
--button-padding: var(--spacing-5);
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.valueLabel {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.onlyButton {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.valueButton:hover {
|
||||
.onlyButton {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox:hover ~ .valueButton {
|
||||
.toggleButton {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.badge,
|
||||
.onlyButton,
|
||||
.toggleButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.indicatorFalse {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
|
||||
.indicatorTrue {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-forest-500);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { BadgeConfig } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
import styles from './CheckboxFilterV2ValueRow.module.scss';
|
||||
|
||||
interface ValueRowProps {
|
||||
value: string;
|
||||
checkedState: CheckedState;
|
||||
disabled: boolean;
|
||||
title: string;
|
||||
onlyButtonLabel: string;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
onCheckboxChange: (checked: boolean, previousState: CheckedState) => void;
|
||||
onOnlyOrAllClick: () => void;
|
||||
badge: BadgeConfig | null;
|
||||
}
|
||||
|
||||
function toCheckboxValue(state: CheckedState): boolean | 'indeterminate' {
|
||||
if (state === 'indeterminate') {
|
||||
return 'indeterminate';
|
||||
}
|
||||
return state === 'checked';
|
||||
}
|
||||
|
||||
const INDICATOR_CLASS_MAP = {
|
||||
false: styles.indicatorFalse,
|
||||
true: styles.indicatorTrue,
|
||||
} as Record<string, string>;
|
||||
|
||||
export function CheckboxFilterV2ValueRow({
|
||||
value,
|
||||
checkedState,
|
||||
disabled,
|
||||
title,
|
||||
onlyButtonLabel,
|
||||
customRendererForValue,
|
||||
onCheckboxChange,
|
||||
onOnlyOrAllClick,
|
||||
badge,
|
||||
}: ValueRowProps): JSX.Element {
|
||||
const indicatorClass = INDICATOR_CLASS_MAP[value];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.valueRow}
|
||||
data-testid={`checkbox-value-row-${value}`}
|
||||
data-state={checkedState}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
<div className={styles.checkbox}>
|
||||
<Checkbox
|
||||
onChange={(isChecked): void =>
|
||||
onCheckboxChange(isChecked === true, checkedState)
|
||||
}
|
||||
value={toCheckboxValue(checkedState)}
|
||||
disabled={disabled}
|
||||
color="primary"
|
||||
testId={`checkbox-${title}-${value}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cx(styles.valueButton, disabled && styles.isDisabled)}
|
||||
onClick={(): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onOnlyOrAllClick();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onOnlyOrAllClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{indicatorClass && <div className={indicatorClass} />}
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text title={value} className={styles.valueLabel}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
color={badge.color}
|
||||
className={styles.badge}
|
||||
testId={`badge-${badge.key}`}
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" color="secondary" className={styles.onlyButton}>
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button variant="ghost" color="secondary" className={styles.toggleButton}>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) 0;
|
||||
|
||||
margin-bottom: calc(-1 * var(--spacing-4));
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Info } from '@signozhq/icons';
|
||||
|
||||
import styles from './SectionDivider.module.scss';
|
||||
|
||||
interface SectionDividerProps {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function SectionDivider({
|
||||
label,
|
||||
tooltip,
|
||||
}: SectionDividerProps): JSX.Element {
|
||||
const testId = label.toLowerCase().replace(/\s+/g, '-');
|
||||
return (
|
||||
<div className={styles.divider} data-testid={`section-divider-${testId}`}>
|
||||
<div className={styles.line} />
|
||||
<Typography.Text className={styles.label}>{label}</Typography.Text>
|
||||
{tooltip && (
|
||||
<Tooltip title={tooltip}>
|
||||
<Info
|
||||
size={12}
|
||||
className={styles.infoIcon}
|
||||
data-testid="section-divider-info"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
const USE_FIELD_APIS_AUTO_DERIVE = {
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: undefined,
|
||||
};
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
const captureExistingQuery = (): Promise<string | null> =>
|
||||
new Promise((resolve) => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const existingQuery = req.url.searchParams.get('existingQuery');
|
||||
resolve(existingQuery);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['test'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('useFieldApis.existingQuery takes precedence', () => {
|
||||
it('uses useFieldApis.existingQuery when provided', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'custom.query = "value"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('custom.query = "value"');
|
||||
});
|
||||
|
||||
it('returns undefined when useFieldApis.existingQuery is null', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: null,
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('V5 filter.expression preferred over V3 filters.items', () => {
|
||||
it('uses V5 filter.expression when both exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'from-v3-items',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'v5.expression = "preferred"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('v5.expression = "preferred"');
|
||||
});
|
||||
|
||||
it('uses V5 filter.expression when no V3 items exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'only.v5 = "expression"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('only.v5 = "expression"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('V3 filters.items fallback', () => {
|
||||
it('converts V3 filters.items to expression when no V5 expression exists', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe("service.name = 'api-service'");
|
||||
});
|
||||
|
||||
it('converts multiple V3 filters.items with AND operator', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api',
|
||||
},
|
||||
{
|
||||
key: { key: 'env', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'prod',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe("service.name = 'api' AND env = 'prod'");
|
||||
});
|
||||
|
||||
it('returns undefined when no filters exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,768 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
getFilterFromCall,
|
||||
mockFieldsValuesAPI,
|
||||
renderWithFilter,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - interactions', () => {
|
||||
describe('search functionality', () => {
|
||||
it('filters values based on search text', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let searchTextReceived = '';
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
searchTextReceived = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
const values =
|
||||
searchTextReceived === ''
|
||||
? ['production', 'staging', 'development']
|
||||
: ['production'];
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: values,
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchTextReceived).toBe('prod');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters values via search while preserving existingQuery context', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: searchText === 'prod' ? ['production'] : [],
|
||||
stringValues: searchText === 'prod' ? ['production'] : ['staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
// Related values now appear in "Related" section (no badge, uses divider instead)
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-production'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows filtered results in all_values section when searching with existingQuery', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-prod-match'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty search results message when no matches found', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : [],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'xyz-no-match');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-no-search-results'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows both RELATED and ALL_VALUES sections with non-overlapping values', async () => {
|
||||
// This tests the bug fix where different pod names in relatedValues vs stringValues
|
||||
// caused all items to go to ALL_VALUES instead of showing RELATED section
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
// Non-overlapping: different pod instances
|
||||
relatedValues: ['pod-a-instance-1', 'pod-b-instance-1'],
|
||||
stringValues: ['pod-a-instance-2', 'pod-b-instance-2'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for values to load
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-instance-1');
|
||||
|
||||
// RELATED section should exist with relatedValues
|
||||
expect(screen.getByTestId('section-related')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-instance-1'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-b-instance-1'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// ALL_VALUES section should exist with stringValues
|
||||
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-instance-2'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-b-instance-2'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows both sections during search with filtered non-overlapping values', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
// Simulate API filtering - both arrays filtered but remain non-overlapping
|
||||
const relatedValues =
|
||||
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
|
||||
const stringValues =
|
||||
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues,
|
||||
stringValues,
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-v1');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'pod-a');
|
||||
|
||||
// After search, both sections should still appear with filtered results
|
||||
await waitFor(() => {
|
||||
// RELATED section with filtered relatedValues
|
||||
expect(screen.getByTestId('section-related')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-v1'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// ALL_VALUES section with filtered stringValues
|
||||
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-v2'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Other values should be filtered out
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-pod-b-v1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('header interactions', () => {
|
||||
it('collapses when header clicked on open filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-production'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands when header clicked on closed filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show more functionality', () => {
|
||||
it('shows "Show More..." when more than 10 values', async () => {
|
||||
const values = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
mockFieldsValuesAPI({ stringValues: values });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-show-more')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-value-10'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads more values when "Show More..." clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const values = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
mockFieldsValuesAPI({ stringValues: values });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-show-more'));
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-10');
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-value-14'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear functionality', () => {
|
||||
it('shows clear button when filter is open and has filter applied', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter applied for attribute', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value row interactions', () => {
|
||||
it('calls onFilterChange when checkbox value clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
|
||||
await user.click(within(productionRow).getByText('production'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates NOT IN filter when unchecking related item with no existing filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with no filter, related items show as checked
|
||||
// Clicking unchecks them → creates NOT IN filter to exclude
|
||||
renderWithFilter(onFilterChange);
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
await user.click(within(rowA).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toBe('valueA');
|
||||
});
|
||||
|
||||
it('converts NOT IN to IN when toggling unchecked (other) item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Clicking unchecked "Other" item with NOT IN filter should convert to IN [B]
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueA'] });
|
||||
|
||||
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toBe('valueB');
|
||||
});
|
||||
|
||||
it('accumulates both values in IN when toggling checked (related) then unchecked (other)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with IN filter for valueA
|
||||
renderWithFilter(onFilterChange, { op: 'in', value: ['valueA'] });
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
const rowB = screen.getByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
// Toggle B (unchecked -> should add to IN)
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
|
||||
});
|
||||
|
||||
it('adds to NOT IN when toggling checked (related) with existing NOT IN filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with NOT IN filter for valueB
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueB'] });
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
// Related values show as checked
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Toggle A (checked -> should add to NOT IN)
|
||||
await user.click(within(rowA).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toStrictEqual(['valueB', 'valueA']);
|
||||
});
|
||||
|
||||
it('creates NOT IN for single value when toggling related item with existing IN filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['relatedValue'],
|
||||
stringValues: ['otherValue'],
|
||||
});
|
||||
|
||||
// Start with IN filter for otherValue (Selected section)
|
||||
// relatedValue is in Related section (checked visually)
|
||||
renderWithFilter(onFilterChange, { op: 'in', value: ['otherValue'] });
|
||||
|
||||
const selectedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-otherValue',
|
||||
);
|
||||
expect(selectedRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-relatedValue');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Toggle related item (checked -> should create NOT IN with just this value)
|
||||
await user.click(within(relatedRow).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toBe('relatedValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom renderer', () => {
|
||||
it('uses customRendererForValue when provided', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
const customRenderer = (value: string): JSX.Element => (
|
||||
<span data-testid="custom-rendered">{`ENV: ${value}`}</span>
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('custom-rendered');
|
||||
expect(screen.getByText('ENV: production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - item rules', () => {
|
||||
describe('no existing query', () => {
|
||||
it('all values show as checked with no badge when no query exists', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(within(productionRow).getByText('production')).toBeInTheDocument();
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// No section dividers when no existing query (all in "selected" section)
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-related'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-all-values'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with existing query (related values)', () => {
|
||||
it('shows related values in "Related" section with checked state', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(within(productionRow).getByText('production')).toBeInTheDocument();
|
||||
|
||||
// Related values show as checked in "Related" section
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Other values show as unchecked in "All values" section
|
||||
expect(screen.getByTestId('section-divider-all-values')).toBeInTheDocument();
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
|
||||
it('shows no badge for values not in relatedValues (unchecked state in All values section)', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const stagingRow = await screen.findByTestId('checkbox-value-row-staging');
|
||||
expect(within(stagingRow).getByText('staging')).toBeInTheDocument();
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows related values as checked when hasFilterForThisKey=true and isInRelatedValues=true', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production', 'staging'],
|
||||
stringValues: ['development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
// production is selected - in selected section
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// staging is related - in related section with checked state
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected values with IN operator', () => {
|
||||
it('shows checked state with no badge for IN-selected values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(
|
||||
within(productionRow).queryByTestId(/^badge-/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected values with NOT IN operator', () => {
|
||||
it('shows unchecked state with no badge for NOT_IN-selected values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(
|
||||
within(productionRow).queryByTestId(/^badge-/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section ordering', () => {
|
||||
it('orders selected section before related section before other section', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-value'],
|
||||
stringValues: ['other-value', 'selected-value'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-value'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-selected-value');
|
||||
|
||||
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
|
||||
const values = allRows.map((row) =>
|
||||
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
|
||||
);
|
||||
|
||||
expect(values[0]).toBe('selected-value');
|
||||
expect(values[1]).toBe('related-value');
|
||||
expect(values[2]).toBe('other-value');
|
||||
});
|
||||
|
||||
it('sorts alphabetically within same section', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['zebra', 'alpha', 'mike'],
|
||||
stringValues: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-alpha');
|
||||
|
||||
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
|
||||
const values = allRows.map((row) =>
|
||||
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['alpha', 'mike', 'zebra']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed state scenarios', () => {
|
||||
it('handles mixed state: IN-selected + related + other in same list', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-env'],
|
||||
stringValues: ['other-env', 'selected-env'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const selectedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-selected-env',
|
||||
);
|
||||
expect(selectedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(within(selectedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
|
||||
// Related values now show as checked
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
const otherRow = screen.getByTestId('checkbox-value-row-other-env');
|
||||
expect(otherRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(otherRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles NOT_IN-selected alongside related values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-env'],
|
||||
stringValues: ['other-env', 'excluded-env'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['excluded-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const excludedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-excluded-env',
|
||||
);
|
||||
expect(excludedRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(excludedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
|
||||
// Related values show as checked
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
mockFieldsValuesAPILoading,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - states', () => {
|
||||
describe('loading states', () => {
|
||||
it('shows skeleton while loading initial data', async () => {
|
||||
mockFieldsValuesAPILoading();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-v2')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows skeleton when initially closed filter is opened for the first time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFieldsValuesAPILoading();
|
||||
|
||||
const closedFilter = { ...DEFAULT_FILTER, defaultOpen: false };
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={closedFilter}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Filter starts closed - no skeleton, no content
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-empty'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Click header to open
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
await user.click(header);
|
||||
|
||||
// Should show skeleton while loading, NOT "No values found"
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-empty'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search spinner when fetching after initial load', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['production', 'staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res(ctx.delay(10000));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-search-loading'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty states', () => {
|
||||
it('shows "No values found" when API returns empty arrays', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: [],
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emptySection = await screen.findByTestId('checkbox-filter-empty');
|
||||
expect(emptySection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value rendering', () => {
|
||||
it('renders values from API response', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging', 'development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-development'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders number values converted to strings', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
numberValues: [200, 404, 500],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row200 = await screen.findByTestId('checkbox-value-row-200');
|
||||
expect(within(row200).getByText('200')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('checkbox-value-row-404')).getByText('404'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('checkbox-value-row-500')).getByText('500'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters null/undefined values from response', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['valid', null, '', undefined as unknown as string],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const validRow = await screen.findByTestId('checkbox-value-row-valid');
|
||||
expect(within(validRow).getByText('valid')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId(/^checkbox-value-row-/)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CheckboxFilterV2Header } from '../CheckboxFilterV2Header';
|
||||
|
||||
describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
showClearAll: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('collapsed state', () => {
|
||||
it('renders title', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(screen.getByText('Environment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets data-state="closed" when collapsed', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not show clear button when collapsed', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expanded state', () => {
|
||||
it('sets data-state="open" when expanded', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('shows clear button when expanded + showClearAll=true', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when showClearAll=false', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter present for attribute', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
showClearAll
|
||||
isSomeFilterPresentForCurrentAttribute={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('calls onToggleOpen on header click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-header'));
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleOpen on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
screen.getByTestId('checkbox-filter-header').focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleOpen on Space key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
screen.getByTestId('checkbox-filter-header').focus();
|
||||
await user.keyboard(' ');
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClear on clear button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clear button click does not trigger onToggleOpen', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { BadgeConfig } from '../itemRules';
|
||||
import { CheckedState } from '../../../../types';
|
||||
import { CheckboxFilterV2ValueRow } from '../CheckboxFilterV2ValueRow';
|
||||
|
||||
describe('CheckboxFilterV2ValueRow', () => {
|
||||
const defaultProps = {
|
||||
value: 'production',
|
||||
checkedState: 'unchecked' as CheckedState,
|
||||
disabled: false,
|
||||
title: 'Environment',
|
||||
onlyButtonLabel: 'Only',
|
||||
onCheckboxChange: jest.fn(),
|
||||
onOnlyOrAllClick: jest.fn(),
|
||||
badge: null as BadgeConfig | null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checked states', () => {
|
||||
it('sets data-state="unchecked" for unchecked state', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
|
||||
it('sets data-state="checked" for checked state', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="checked" />,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('badge variations', () => {
|
||||
it('renders no badge when badge=null', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} badge={null} />);
|
||||
|
||||
expect(screen.queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Not in" warning badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
expect(screen.getByText('Not in')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Related" robin badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'related', label: 'Related', color: 'robin' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-related')).toBeInTheDocument();
|
||||
expect(screen.getByText('Related')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Other" secondary badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
|
||||
expect(screen.getByText('Other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('only/all button label', () => {
|
||||
it('shows "Only" label by default', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="Only" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "All" label when appropriate', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="All" />);
|
||||
|
||||
expect(screen.getByText('All')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('sets data-disabled=true when disabled', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} disabled />);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-disabled', 'true');
|
||||
});
|
||||
|
||||
it('does not call onOnlyOrAllClick when disabled + clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('production'));
|
||||
|
||||
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onOnlyOrAllClick on keydown when disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByText('production').focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('special value indicators', () => {
|
||||
it('renders row for "true" value', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="true" />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-value-row-true')).toBeInTheDocument();
|
||||
expect(screen.getByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row for "false" value', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="false" />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-value-row-false')).toBeInTheDocument();
|
||||
expect(screen.getByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row for regular values', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="production" />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-production'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('renders checkbox with correct testId', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-Environment-production'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on value text click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('production'));
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueButton = screen
|
||||
.getByText('production')
|
||||
.closest('[role="button"]');
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
if (valueButton && document.activeElement === valueButton) {
|
||||
await user.keyboard('{Enter}');
|
||||
}
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on Space key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueButton = screen
|
||||
.getByText('production')
|
||||
.closest('[role="button"]');
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
if (valueButton && document.activeElement === valueButton) {
|
||||
await user.keyboard(' ');
|
||||
}
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows Toggle button', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Toggle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom renderer', () => {
|
||||
it('uses customRendererForValue when provided', () => {
|
||||
const customRenderer = (value: string): JSX.Element => (
|
||||
<span data-testid="custom-render">{`Custom: ${value}`}</span>
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
customRendererForValue={customRenderer}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-render')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom: production')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows default value text when no custom renderer', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state combinations', () => {
|
||||
it('checked + not_in badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
checkedState="unchecked"
|
||||
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disabled + badge still shows badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { deriveItemConfig, ItemContext, SectionType } from '../itemRules';
|
||||
|
||||
describe('itemRules', () => {
|
||||
describe('deriveItemConfig', () => {
|
||||
it('no query at all → section selected, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('selected + IN operator → section selected, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: true,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('selected + NOT IN operator → section selected, no badge, unchecked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: true,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('has query, not selected, in related → section related, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.RELATED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('has query, has filter for this key, in related → section related, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.RELATED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('has query, not in related → section all_values, unchecked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('has query + filter for key, not selected, not in related → section all_values, unchecked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('no query but has filter for key, not selected → fallback to checked (DEFAULT_CONFIG)', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { SectionType } from '../itemRules';
|
||||
import { useSectionedValues, SectionedItem } from '../useSectionedValues';
|
||||
|
||||
function flattenSections(
|
||||
sections: { type: SectionType; items: SectionedItem[] }[],
|
||||
): SectionedItem[] {
|
||||
return sections.flatMap((s) => s.items);
|
||||
}
|
||||
|
||||
describe('useSectionedValues', () => {
|
||||
const baseInput = {
|
||||
relatedValues: ['val1', 'val2'],
|
||||
allValues: ['val1', 'val2', 'val3'],
|
||||
currentFilterState: {},
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
visibleItemsCount: 10,
|
||||
relatedExclusions: [] as string[],
|
||||
};
|
||||
|
||||
it('no query at all → all items in selected section, no badges', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: false,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sections).toHaveLength(1);
|
||||
expect(result.current.sections[0].type).toBe(SectionType.SELECTED);
|
||||
expect(result.current.sections[0].items).toHaveLength(3);
|
||||
result.current.sections[0].items.forEach((item) => {
|
||||
expect(item.badge).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('has query, no filter for key → related and all_values sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
const relatedItems = allItems.filter(
|
||||
(item) => item.value === 'val1' || item.value === 'val2',
|
||||
);
|
||||
const otherItems = allItems.filter((item) => item.value === 'val3');
|
||||
|
||||
// Related values should be in related section, checked
|
||||
relatedItems.forEach((item) => {
|
||||
expect(item.section).toBe(SectionType.RELATED);
|
||||
expect(item.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
// Other values should be in all_values section, unchecked
|
||||
otherItems.forEach((item) => {
|
||||
expect(item.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(item.checkedState).toBe('unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
it('has query + filter for key, selected value → in selected section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true, val2: false, val3: false },
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
const selectedItem = allItems.find((item) => item.value === 'val1');
|
||||
|
||||
expect(selectedItem?.section).toBe(SectionType.SELECTED);
|
||||
expect(selectedItem?.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('has query + filter for key, NOT IN operator → excluded values in selected section, no badge', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
isNotInOperator: true,
|
||||
currentFilterState: { val1: false, val2: true, val3: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
// val1 is unchecked + NOT IN = excluded
|
||||
const excludedItem = allItems.find((item) => item.value === 'val1');
|
||||
|
||||
expect(excludedItem?.section).toBe(SectionType.SELECTED);
|
||||
expect(excludedItem?.badge).toBeNull();
|
||||
expect(excludedItem?.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('items within same section sorted alphabetically', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['zebra', 'apple', 'mango'],
|
||||
allValues: ['zebra', 'apple', 'mango'],
|
||||
hasExistingQuery: false,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// All items have section selected, should be sorted alphabetically
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
const values = allItems.map((item) => item.value);
|
||||
expect(values).toStrictEqual(['apple', 'mango', 'zebra']);
|
||||
});
|
||||
|
||||
describe('filtered results from API', () => {
|
||||
it('keeps items in their natural sections with filtered API results', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
expect(sectionTypes).toContain(SectionType.SELECTED);
|
||||
expect(sectionTypes).toContain(SectionType.RELATED);
|
||||
});
|
||||
|
||||
it('returns empty sections array when no values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: [],
|
||||
allValues: [],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
currentFilterState: {},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sections).toHaveLength(0);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('non-related items go to ALL_VALUES section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: [],
|
||||
allValues: ['other1', 'other2', 'other3'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const allValuesSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.ALL_VALUES,
|
||||
);
|
||||
expect(allValuesSection?.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('handles non-overlapping relatedValues and allValues correctly', () => {
|
||||
// This tests the bug where different pod names in relatedValues vs allValues
|
||||
// caused all items to go to ALL_VALUES instead of RELATED
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['pod-a-1', 'pod-b-1', 'pod-c-1'],
|
||||
allValues: ['pod-a-2', 'pod-b-2', 'pod-c-2'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
|
||||
// RELATED section should exist with relatedValues items
|
||||
expect(sectionTypes).toContain(SectionType.RELATED);
|
||||
const relatedSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.RELATED,
|
||||
);
|
||||
expect(relatedSection?.items).toHaveLength(3);
|
||||
expect(relatedSection?.items.map((i) => i.value)).toStrictEqual(
|
||||
expect.arrayContaining(['pod-a-1', 'pod-b-1', 'pod-c-1']),
|
||||
);
|
||||
|
||||
// ALL_VALUES section should exist with allValues items
|
||||
expect(sectionTypes).toContain(SectionType.ALL_VALUES);
|
||||
const allValuesSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.ALL_VALUES,
|
||||
);
|
||||
expect(allValuesSection?.items).toHaveLength(3);
|
||||
expect(allValuesSection?.items.map((i) => i.value)).toStrictEqual(
|
||||
expect.arrayContaining(['pod-a-2', 'pod-b-2', 'pod-c-2']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stale related values during refresh (relatedExclusions)', () => {
|
||||
// Scenario: key A selected (existing query) + key B had value "oldSelected".
|
||||
// User selects a different value on key B -> "oldSelected" is de-selected.
|
||||
// keepPreviousData keeps the stale response where "oldSelected" is still in
|
||||
// relatedValues, so it would wrongly render as "related". It is excluded.
|
||||
it('subtracts only the excluded value from the RELATED section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
// stale API data kept via keepPreviousData
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: ['oldSelected'],
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
|
||||
// oldSelected must NOT render as related (dropped entirely - not in
|
||||
// allValues nor selected)
|
||||
expect(
|
||||
allItems.find((item) => item.value === 'oldSelected'),
|
||||
).toBeUndefined();
|
||||
|
||||
// other related values are kept
|
||||
expect(allItems.find((item) => item.value === 'otherRelated')?.section).toBe(
|
||||
SectionType.RELATED,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves every related value that is not the excluded (previously selected) one', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
// oldSelected was just de-selected; the rest are genuinely related
|
||||
relatedValues: ['oldSelected', 'relatedA', 'relatedB', 'relatedC'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: ['oldSelected'],
|
||||
}),
|
||||
);
|
||||
|
||||
const relatedSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.RELATED,
|
||||
);
|
||||
const relatedValues = relatedSection?.items.map((i) => i.value) ?? [];
|
||||
|
||||
// all non-excluded related values are kept
|
||||
expect(relatedValues).toStrictEqual(['relatedA', 'relatedB', 'relatedC']);
|
||||
// and they stay checked
|
||||
relatedSection?.items.forEach((item) => {
|
||||
expect(item.checkedState).toBe('checked');
|
||||
});
|
||||
// the excluded value is gone
|
||||
expect(relatedValues).not.toContain('oldSelected');
|
||||
});
|
||||
|
||||
it('keeps the RELATED section when other related values remain', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: ['oldSelected'],
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
expect(sectionTypes).toContain(SectionType.RELATED);
|
||||
expect(sectionTypes).toContain(SectionType.SELECTED);
|
||||
});
|
||||
|
||||
it('shows the excluded value normally when exclusions are empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
expect(allItems.find((item) => item.value === 'oldSelected')?.section).toBe(
|
||||
SectionType.RELATED,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('section ordering', () => {
|
||||
it('sections appear in order: SELECTED → RELATED → ALL_VALUES', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['related1'],
|
||||
allValues: ['all1'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { selected1: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
|
||||
// Verify order
|
||||
const selectedIdx = sectionTypes.indexOf(SectionType.SELECTED);
|
||||
const relatedIdx = sectionTypes.indexOf(SectionType.RELATED);
|
||||
const allValuesIdx = sectionTypes.indexOf(SectionType.ALL_VALUES);
|
||||
|
||||
expect(selectedIdx).toBeLessThan(relatedIdx);
|
||||
expect(relatedIdx).toBeLessThan(allValuesIdx);
|
||||
});
|
||||
|
||||
it('RELATED section appears before ALL_VALUES even with many items', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['r1', 'r2', 'r3', 'r4', 'r5'],
|
||||
allValues: ['a1', 'a2', 'a3', 'a4', 'a5'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
|
||||
expect(sectionTypes[0]).toBe(SectionType.RELATED);
|
||||
expect(sectionTypes[1]).toBe(SectionType.ALL_VALUES);
|
||||
});
|
||||
|
||||
it('visibleItemsCount limits total items across sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['r1', 'r2', 'r3'],
|
||||
allValues: ['a1', 'a2', 'a3'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const totalItems = result.current.sections.reduce(
|
||||
(sum, s) => sum + s.items.length,
|
||||
0,
|
||||
);
|
||||
expect(totalItems).toBe(4);
|
||||
|
||||
// RELATED section should get priority (3 items)
|
||||
const relatedSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.RELATED,
|
||||
);
|
||||
expect(relatedSection?.items).toHaveLength(3);
|
||||
|
||||
// ALL_VALUES gets remaining (1 item)
|
||||
const allValuesSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.ALL_VALUES,
|
||||
);
|
||||
expect(allValuesSection?.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { CheckedState } from '../../../types';
|
||||
|
||||
export enum SectionType {
|
||||
SELECTED = 'selected',
|
||||
RELATED = 'related',
|
||||
ALL_VALUES = 'all_values',
|
||||
}
|
||||
|
||||
export interface BadgeConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
color: 'robin' | 'warning' | 'secondary';
|
||||
}
|
||||
|
||||
export interface ItemConfig {
|
||||
section: SectionType;
|
||||
badge: BadgeConfig | null;
|
||||
checkedState: CheckedState;
|
||||
}
|
||||
|
||||
export interface ItemContext {
|
||||
isSelectedOnFilter: boolean;
|
||||
isInRelatedValues: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
hasFilterForThisKey: boolean;
|
||||
}
|
||||
|
||||
export interface DerivedItem extends ItemConfig {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ItemRule {
|
||||
condition: (ctx: ItemContext) => boolean;
|
||||
config: ItemConfig;
|
||||
}
|
||||
|
||||
const ITEM_RULES: ItemRule[] = [
|
||||
// No existing query and no filter → all checked (selected section)
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
!ctx.hasExistingQuery && !ctx.hasFilterForThisKey,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Selected with NOT IN operator → unchecked, no badge
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
// Selected with IN operator → checked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && !ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Related values (from existing query) → checked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.hasExistingQuery && ctx.isInRelatedValues,
|
||||
config: {
|
||||
section: SectionType.RELATED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// All values (has existing query but not related) → unchecked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.hasExistingQuery,
|
||||
config: {
|
||||
section: SectionType.ALL_VALUES,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Fallback when no rule matches
|
||||
const DEFAULT_CONFIG: ItemConfig = {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
};
|
||||
|
||||
export function deriveItemConfig(ctx: ItemContext): ItemConfig {
|
||||
for (const rule of ITEM_RULES) {
|
||||
if (rule.condition(ctx)) {
|
||||
return rule.config;
|
||||
}
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
export function deriveItems(
|
||||
values: string[],
|
||||
relatedSet: Set<string>,
|
||||
selectedOnFilterSet: Set<string>,
|
||||
ctx: Omit<ItemContext, 'isSelectedOnFilter' | 'isInRelatedValues'>,
|
||||
): DerivedItem[] {
|
||||
return values.map((value) => {
|
||||
const itemCtx: ItemContext = {
|
||||
...ctx,
|
||||
isSelectedOnFilter: selectedOnFilterSet.has(value),
|
||||
isInRelatedValues: relatedSet.has(value),
|
||||
};
|
||||
const config = deriveItemConfig(itemCtx);
|
||||
return { value, ...config };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { QuickFilterCheckboxUseFieldApis } from 'components/QuickFilters/types';
|
||||
|
||||
interface UseExistingQueryParams {
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseExistingQueryResult {
|
||||
existingQuery: string | undefined;
|
||||
hasExistingQuery: boolean;
|
||||
}
|
||||
|
||||
export function useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
}: UseExistingQueryParams): UseExistingQueryResult {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const existingQuery = useMemo(() => {
|
||||
if (useFieldApis.existingQuery === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (useFieldApis.existingQuery) {
|
||||
return useFieldApis.existingQuery;
|
||||
}
|
||||
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
|
||||
// Prefer V5 filter.expression
|
||||
if (queryData?.filter?.expression) {
|
||||
return queryData.filter.expression;
|
||||
}
|
||||
|
||||
// Fall back to V3 filters.items
|
||||
if (queryData?.filters?.items?.length) {
|
||||
return convertFiltersToExpression(queryData.filters).expression;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
useFieldApis.existingQuery,
|
||||
currentQuery.builder.queryData,
|
||||
activeQueryIndex,
|
||||
]);
|
||||
|
||||
// Check if ANY filters exist in query (V3 items or V5 expression)
|
||||
// This is separate from existingQuery because existingQuery can be explicitly
|
||||
// disabled (null) while filters still exist in the query for UI purposes
|
||||
const hasExistingQuery = useMemo(() => {
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
|
||||
const hasV5Expression = !!queryData?.filter?.expression;
|
||||
return hasV3Items || hasV5Expression || !!existingQuery;
|
||||
}, [currentQuery.builder.queryData, activeQueryIndex, existingQuery]);
|
||||
|
||||
return { existingQuery, hasExistingQuery };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
searchText: string;
|
||||
existingQuery?: string;
|
||||
metricNamespace?: string;
|
||||
startUnixMilli?: number;
|
||||
endUnixMilli?: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseFieldValuesReturn {
|
||||
relatedValues: string[];
|
||||
allValues: string[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
export function useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
}: UseFieldValuesProps): UseFieldValuesReturn {
|
||||
const { data, isLoading, isFetching } = useGetFieldsValues(
|
||||
{
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
endUnixMilli,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
cacheTime: FIELD_API_CACHE_TIME,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const relatedValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
values.relatedValues?.filter(
|
||||
(value): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || []
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const allValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stringValues =
|
||||
values.stringValues?.filter(
|
||||
(value): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || [];
|
||||
const numberValues =
|
||||
values.numberValues
|
||||
?.filter((value): value is number => value !== null && value !== undefined)
|
||||
.map((value) => value.toString()) || [];
|
||||
|
||||
return [...stringValues, ...numberValues];
|
||||
}, [data]);
|
||||
|
||||
return { relatedValues, allValues, isLoading, isFetching };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { BadgeConfig, deriveItems, SectionType } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
|
||||
interface SectionedValuesInput {
|
||||
relatedValues: string[];
|
||||
allValues: string[];
|
||||
currentFilterState: Record<string, boolean>;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
visibleItemsCount: number;
|
||||
relatedExclusions: string[];
|
||||
}
|
||||
|
||||
export interface SectionedItem {
|
||||
value: string;
|
||||
section: SectionType;
|
||||
badge: BadgeConfig | null;
|
||||
checkedState: CheckedState;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
type: SectionType;
|
||||
items: SectionedItem[];
|
||||
}
|
||||
|
||||
interface SectionedValuesOutput {
|
||||
sections: Section[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const SECTION_ORDER: SectionType[] = [
|
||||
SectionType.SELECTED,
|
||||
SectionType.RELATED,
|
||||
SectionType.ALL_VALUES,
|
||||
];
|
||||
|
||||
export function buildSelectedSet(
|
||||
currentFilterState: Record<string, boolean>,
|
||||
isSomeFilterPresentForCurrentAttribute: boolean,
|
||||
isNotInOperator: boolean,
|
||||
): Set<string> {
|
||||
const selectedSet = new Set<string>();
|
||||
if (!isSomeFilterPresentForCurrentAttribute) {
|
||||
return selectedSet;
|
||||
}
|
||||
|
||||
for (const [val, isChecked] of Object.entries(currentFilterState)) {
|
||||
// NOT IN: unchecked = explicitly excluded
|
||||
// IN: checked = explicitly selected
|
||||
const shouldAdd = isNotInOperator ? !isChecked : isChecked;
|
||||
if (shouldAdd) {
|
||||
selectedSet.add(val);
|
||||
}
|
||||
}
|
||||
return selectedSet;
|
||||
}
|
||||
|
||||
export function useSectionedValues({
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
visibleItemsCount,
|
||||
relatedExclusions,
|
||||
}: SectionedValuesInput): SectionedValuesOutput {
|
||||
const items = useMemo(() => {
|
||||
const selectedSet = buildSelectedSet(
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
);
|
||||
|
||||
const exclusionSet = new Set(relatedExclusions);
|
||||
const effectiveRelatedValues = relatedValues.filter(
|
||||
(value) => !exclusionSet.has(value),
|
||||
);
|
||||
|
||||
// Combine all values - API already filters both arrays by searchText
|
||||
const allUniqueValues = Array.from(
|
||||
new Set([...effectiveRelatedValues, ...allValues]),
|
||||
);
|
||||
|
||||
// Include selected values at top - may not be in API response
|
||||
const finalValues = [
|
||||
...new Set([...Array.from(selectedSet), ...allUniqueValues]),
|
||||
];
|
||||
const relatedSet = new Set(effectiveRelatedValues);
|
||||
|
||||
return deriveItems(finalValues, relatedSet, selectedSet, {
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
hasFilterForThisKey: isSomeFilterPresentForCurrentAttribute,
|
||||
});
|
||||
}, [
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
relatedExclusions,
|
||||
]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
// Group items by section
|
||||
const sectionMap = new Map<SectionType, SectionedItem[]>();
|
||||
for (const sectionType of SECTION_ORDER) {
|
||||
sectionMap.set(sectionType, []);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
sectionMap.get(item.section)?.push(item);
|
||||
}
|
||||
|
||||
// Sort items within each section alphabetically
|
||||
for (const sectionItems of sectionMap.values()) {
|
||||
sectionItems.sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
|
||||
// Apply visibleItemsCount across all sections
|
||||
let remaining = visibleItemsCount;
|
||||
const result: Section[] = [];
|
||||
|
||||
for (const sectionType of SECTION_ORDER) {
|
||||
const sectionItems = sectionMap.get(sectionType) || [];
|
||||
|
||||
if (sectionItems.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemsToTake = Math.min(sectionItems.length, remaining);
|
||||
if (itemsToTake === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
result.push({
|
||||
type: sectionType,
|
||||
items: sectionItems.slice(0, itemsToTake),
|
||||
});
|
||||
remaining -= itemsToTake;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, visibleItemsCount]);
|
||||
|
||||
return { sections, totalCount: items.length };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
const EMPTY: string[] = [];
|
||||
|
||||
interface UseStaleRelatedExclusionsParams {
|
||||
selectedValues: string[];
|
||||
isFetching: boolean;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
export function useStaleRelatedExclusions({
|
||||
selectedValues,
|
||||
isFetching,
|
||||
isRefreshing,
|
||||
}: UseStaleRelatedExclusionsParams): string[] {
|
||||
const selectionAtLastFetchRef = useRef<string[]>(selectedValues);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching) {
|
||||
selectionAtLastFetchRef.current = selectedValues;
|
||||
}
|
||||
}, [isFetching, selectedValues]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!isRefreshing) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const current = new Set(selectedValues);
|
||||
const removed = selectionAtLastFetchRef.current.filter(
|
||||
(value) => !current.has(value),
|
||||
);
|
||||
|
||||
return removed.length ? removed : EMPTY;
|
||||
}, [isRefreshing, selectedValues]);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
|
||||
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
|
||||
import Duration from './FilterRenderers/Duration/Duration';
|
||||
import Slider from './FilterRenderers/Slider/Slider';
|
||||
import useFilterConfig from './hooks/useFilterConfig';
|
||||
@@ -51,6 +52,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
signal,
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
} = props;
|
||||
const { user } = useAppContext();
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
@@ -297,21 +299,45 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
{filterConfig.map((filter) => {
|
||||
switch (filter.type) {
|
||||
case FiltersType.CHECKBOX:
|
||||
return (
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
case FiltersType.DURATION:
|
||||
return <Duration filter={filter} onFilterChange={onFilterChange} />;
|
||||
return (
|
||||
<Duration
|
||||
key={filter.attributeKey.key}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
case FiltersType.SLIDER:
|
||||
return <Slider />;
|
||||
return <Slider key={filter.attributeKey.key} />;
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
default:
|
||||
return (
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
@@ -381,4 +407,5 @@ QuickFilters.defaultProps = {
|
||||
config: [],
|
||||
showFilterCollapse: true,
|
||||
showQueryName: true,
|
||||
useFieldApis: undefined,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,11 @@ export enum SignalType {
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing export from signozhq/ui/checkbox, TODO(H4ad): Add and remove this type definition
|
||||
*/
|
||||
export type CheckedState = 'checked' | 'unchecked' | 'indeterminate';
|
||||
|
||||
export interface IQuickFiltersConfig {
|
||||
type: FiltersType;
|
||||
title: string;
|
||||
@@ -46,6 +51,7 @@ export interface IQuickFiltersProps {
|
||||
className?: string;
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
useFieldApis?: QuickFilterCheckboxUseFieldApis;
|
||||
}
|
||||
|
||||
export enum QuickFiltersSource {
|
||||
@@ -56,3 +62,19 @@ export enum QuickFiltersSource {
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
|
||||
*/
|
||||
export type QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: number;
|
||||
endUnixMilli: number;
|
||||
/**
|
||||
* If you didn't specify a string, we automatically try to extract this from the currentQuery,
|
||||
* from the filter.expression or filter.items.
|
||||
*
|
||||
* Use null to ignore/disable this behavior.
|
||||
*/
|
||||
existingQuery?: string | null;
|
||||
metricNamespace?: string;
|
||||
};
|
||||
|
||||
@@ -11,17 +11,17 @@ import { FlatItem, TableRowContext } from './types';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type VirtuosoTableRowProps<TData> = ComponentProps<
|
||||
type VirtuosoTableRowProps<TData, TItemKey = string> = ComponentProps<
|
||||
NonNullable<
|
||||
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
|
||||
TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>>['TableRow']
|
||||
>
|
||||
>;
|
||||
|
||||
function TanStackCustomTableRow<TData>({
|
||||
function TanStackCustomTableRow<TData, TItemKey = string>({
|
||||
item,
|
||||
context,
|
||||
...props
|
||||
}: VirtuosoTableRowProps<TData>): JSX.Element {
|
||||
}: VirtuosoTableRowProps<TData, TItemKey>): JSX.Element {
|
||||
const rowId = item.row.id;
|
||||
const rowData = item.row.original;
|
||||
|
||||
@@ -84,9 +84,9 @@ function TanStackCustomTableRow<TData>({
|
||||
// This looks overkill but ensures the table is stable and doesn't re-render on every change
|
||||
// If you add any new prop to context, remember to update this function
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function areTableRowPropsEqual<TData>(
|
||||
prev: Readonly<VirtuosoTableRowProps<TData>>,
|
||||
next: Readonly<VirtuosoTableRowProps<TData>>,
|
||||
function areTableRowPropsEqual<TData, TItemKey = string>(
|
||||
prev: Readonly<VirtuosoTableRowProps<TData, TItemKey>>,
|
||||
next: Readonly<VirtuosoTableRowProps<TData, TItemKey>>,
|
||||
): boolean {
|
||||
if (prev.item.row.id !== next.item.row.id) {
|
||||
return false;
|
||||
@@ -141,7 +141,9 @@ function areTableRowPropsEqual<TData>(
|
||||
return true;
|
||||
}
|
||||
|
||||
export default memo(
|
||||
TanStackCustomTableRow,
|
||||
areTableRowPropsEqual,
|
||||
) as typeof TanStackCustomTableRow;
|
||||
export default memo(TanStackCustomTableRow, areTableRowPropsEqual as any) as <
|
||||
TData,
|
||||
TItemKey = string,
|
||||
>(
|
||||
props: VirtuosoTableRowProps<TData, TItemKey>,
|
||||
) => JSX.Element;
|
||||
|
||||
@@ -8,23 +8,23 @@ import { TableRowContext } from './types';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type TanStackRowCellsProps<TData> = {
|
||||
type TanStackRowCellsProps<TData, TItemKey = string> = {
|
||||
row: TanStackRowModel<TData>;
|
||||
context: TableRowContext<TData> | undefined;
|
||||
context: TableRowContext<TData, TItemKey> | undefined;
|
||||
itemKind: 'row' | 'expansion';
|
||||
hasSingleColumn: boolean;
|
||||
columnOrderKey: string;
|
||||
columnVisibilityKey: string;
|
||||
};
|
||||
|
||||
function TanStackRowCellsInner<TData>({
|
||||
function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
row,
|
||||
context,
|
||||
itemKind,
|
||||
hasSingleColumn,
|
||||
columnOrderKey: _columnOrderKey,
|
||||
columnVisibilityKey: _columnVisibilityKey,
|
||||
}: TanStackRowCellsProps<TData>): JSX.Element {
|
||||
}: TanStackRowCellsProps<TData, TItemKey>): JSX.Element {
|
||||
const hasHovered = useIsRowHovered(row.id);
|
||||
const rowData = row.original;
|
||||
const visibleCells = row.getVisibleCells();
|
||||
@@ -40,8 +40,10 @@ function TanStackRowCellsInner<TData>({
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLTableCellElement>) => {
|
||||
// Fall back to an empty key so row clicks still fire for consumers
|
||||
// that don't provide getRowKey (e.g. Logs Explorer / Live Logs).
|
||||
const keyData = getRowKeyData?.(rowIndex);
|
||||
const itemKey = keyData?.itemKey ?? '';
|
||||
const itemKey = keyData?.itemKey ?? ('' as TItemKey);
|
||||
|
||||
// Handle ctrl+click or cmd+click (open in new tab)
|
||||
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
|
||||
@@ -131,6 +133,8 @@ function areRowCellsPropsEqual<TData>(
|
||||
const TanStackRowCells = memo(
|
||||
TanStackRowCellsInner,
|
||||
areRowCellsPropsEqual as any,
|
||||
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
|
||||
) as <T, TItemKey = string>(
|
||||
props: TanStackRowCellsProps<T, TItemKey>,
|
||||
) => JSX.Element;
|
||||
|
||||
export default TanStackRowCells;
|
||||
|
||||
@@ -67,7 +67,7 @@ const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
|
||||
const noopColumnVisibility = (): void => {};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackTableInner<TData>(
|
||||
function TanStackTableInner<TData, TItemKey = string>(
|
||||
{
|
||||
data,
|
||||
columns,
|
||||
@@ -107,7 +107,7 @@ function TanStackTableInner<TData>(
|
||||
suffixPaginationContent,
|
||||
enableAlternatingRowColors,
|
||||
disableVirtualScroll,
|
||||
}: TanStackTableProps<TData>,
|
||||
}: TanStackTableProps<TData, TItemKey>,
|
||||
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
|
||||
): JSX.Element {
|
||||
if (disableVirtualScroll && onEndReached) {
|
||||
@@ -193,7 +193,7 @@ function TanStackTableInner<TData>(
|
||||
skeletonRowCount,
|
||||
});
|
||||
|
||||
const { rowKeyData, getRowKeyData } = useRowKeyData({
|
||||
const { rowKeyData, getRowKeyData } = useRowKeyData<TData, TItemKey>({
|
||||
data: effectiveData,
|
||||
isLoading,
|
||||
getRowKey,
|
||||
@@ -229,7 +229,7 @@ function TanStackTableInner<TData>(
|
||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
|
||||
() =>
|
||||
effectiveColumns.map((colDef) =>
|
||||
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
|
||||
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
|
||||
),
|
||||
[effectiveColumns, isRowActive, getRowKeyData],
|
||||
);
|
||||
@@ -356,7 +356,7 @@ function TanStackTableInner<TData>(
|
||||
[effectiveVisibility, columnIds],
|
||||
);
|
||||
|
||||
const virtuosoContext = useMemo<TableRowContext<TData>>(
|
||||
const virtuosoContext = useMemo<TableRowContext<TData, TItemKey>>(
|
||||
() => ({
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
@@ -520,13 +520,15 @@ function TanStackTableInner<TData>(
|
||||
);
|
||||
|
||||
type VirtuosoTableComponentProps = ComponentProps<
|
||||
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
|
||||
NonNullable<
|
||||
TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>>['Table']
|
||||
>
|
||||
>;
|
||||
|
||||
// Use refs in virtuosoComponents to keep the component reference stable during resize
|
||||
// This prevents Virtuoso from re-rendering all rows when columns are resized
|
||||
const virtuosoComponents = useMemo(
|
||||
() => ({
|
||||
(): TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>> => ({
|
||||
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
|
||||
<table className={tableStyles.tanStackTable} style={style}>
|
||||
<VirtuosoTableColGroup
|
||||
@@ -582,7 +584,7 @@ function TanStackTableInner<TData>(
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
|
||||
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData, TItemKey>>
|
||||
className={virtuosoClassName}
|
||||
ref={virtuosoRef}
|
||||
{...restTableScrollerProps}
|
||||
@@ -660,8 +662,11 @@ function TanStackTableInner<TData>(
|
||||
);
|
||||
}
|
||||
|
||||
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
|
||||
props: TanStackTableProps<TData> & {
|
||||
const TanStackTableForward = forwardRef(TanStackTableInner) as <
|
||||
TData,
|
||||
TItemKey = string,
|
||||
>(
|
||||
props: TanStackTableProps<TData, TItemKey> & {
|
||||
ref?: React.Ref<TanStackTableHandle>;
|
||||
},
|
||||
) => JSX.Element;
|
||||
|
||||
@@ -57,6 +57,39 @@ describe('TanStackRowCells', () => {
|
||||
});
|
||||
|
||||
it('calls onRowClick when a cell is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
});
|
||||
|
||||
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
|
||||
// Mirrors Logs Explorer / Live Logs, which set onRowClick but no getRowKey.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
@@ -84,7 +117,6 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
// onRowClick receives (rowData, itemKey) - itemKey is empty when getRowKeyData not provided
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
});
|
||||
|
||||
@@ -97,6 +129,7 @@ describe('TanStackRowCells', () => {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: () => true,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
@@ -194,6 +227,7 @@ describe('TanStackRowCells', () => {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
@@ -216,7 +250,7 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -227,6 +261,7 @@ describe('TanStackRowCells', () => {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
@@ -249,7 +284,7 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -260,6 +295,7 @@ describe('TanStackRowCells', () => {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
|
||||
@@ -614,6 +614,34 @@ describe('TanStackTableView Integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onRowClick with object itemKey when getItemKey returns object', async () => {
|
||||
type SelectionParams = { id: string; name: string };
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn<void, [unknown, SelectionParams]>();
|
||||
|
||||
renderTanStackTable<
|
||||
(typeof import('./testUtils').defaultData)[0],
|
||||
SelectionParams
|
||||
>({
|
||||
props: {
|
||||
onRowClick,
|
||||
getRowKey: (row) => row.id,
|
||||
getItemKey: (row) => ({ id: row.id, name: row.name }),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
{ id: '1', name: 'Item 1' },
|
||||
);
|
||||
});
|
||||
|
||||
it('applies active class when isRowActive returns true', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
|
||||
@@ -42,14 +42,14 @@ export const defaultData: TestRow[] = [
|
||||
{ id: '3', name: 'Item 3', value: 300 },
|
||||
];
|
||||
|
||||
export type RenderTanStackTableOptions<T> = {
|
||||
props?: Partial<TanStackTableProps<T>>;
|
||||
export type RenderTanStackTableOptions<T, TItemKey = string> = {
|
||||
props?: Partial<TanStackTableProps<T, TItemKey>>;
|
||||
queryParams?: Record<string, string>;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
};
|
||||
|
||||
export function renderTanStackTable<T = TestRow>(
|
||||
options: RenderTanStackTableOptions<T> = {},
|
||||
export function renderTanStackTable<T = TestRow, TItemKey = string>(
|
||||
options: RenderTanStackTableOptions<T, TItemKey> = {},
|
||||
): RenderResult {
|
||||
const { props = {}, queryParams, onUrlUpdate } = options;
|
||||
|
||||
@@ -57,7 +57,7 @@ export function renderTanStackTable<T = TestRow>(
|
||||
data: defaultData as unknown as T[],
|
||||
columns: defaultColumns as unknown as TableColumnDef<T>[],
|
||||
...props,
|
||||
} as TanStackTableProps<T>;
|
||||
} as TanStackTableProps<T, TItemKey>;
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
||||
@@ -65,7 +65,7 @@ export function renderTanStackTable<T = TestRow>(
|
||||
value={{ viewportHeight: 500, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<TanStackTable<T> {...mergedProps} />
|
||||
<TanStackTable<T, TItemKey> {...mergedProps} />
|
||||
</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
|
||||
@@ -123,6 +123,22 @@ export * from './useTableParams';
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Object itemKey — use generic `TItemKey` when selection needs compound keys.
|
||||
* ```tsx
|
||||
* type SelectionParams = { id: string; cluster: string; namespace: string };
|
||||
*
|
||||
* <TanStackTable<Row, SelectionParams>
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* getRowKey={(row) => row.uid}
|
||||
* getItemKey={(row) => ({ id: row.name, cluster: row.cluster, namespace: row.namespace })}
|
||||
* onRowClick={(row, itemKey) => {
|
||||
* // itemKey is typed as SelectionParams
|
||||
* setSelection(itemKey);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Expandable rows. `renderExpandedRow` receives `(row, rowKey, groupMeta?)`.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
|
||||
@@ -24,17 +24,15 @@ export type TableCellContext<TData, TValue> = {
|
||||
isExpanded: boolean;
|
||||
canExpand: boolean;
|
||||
toggleExpanded: () => void;
|
||||
/** Business/selection key for the row */
|
||||
itemKey: string;
|
||||
/** Group metadata when row is part of a grouped view */
|
||||
groupMeta?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type RowKeyData = {
|
||||
export type RowKeyData<TItemKey = string> = {
|
||||
/** Final unique key (with duplicate suffix if needed) */
|
||||
finalKey: string;
|
||||
/** Business/selection key */
|
||||
itemKey: string;
|
||||
itemKey: TItemKey;
|
||||
/** Group metadata */
|
||||
groupMeta?: Record<string, string>;
|
||||
};
|
||||
@@ -82,14 +80,14 @@ export type FlatItem<TData> =
|
||||
| { kind: 'row'; row: TanStackRowType<TData> }
|
||||
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
||||
|
||||
export type TableRowContext<TData> = {
|
||||
export type TableRowContext<TData, TItemKey = string> = {
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: string) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
@@ -97,7 +95,7 @@ export type TableRowContext<TData> = {
|
||||
groupMeta?: Record<string, string>,
|
||||
) => ReactNode;
|
||||
/** Get key data for a row by index */
|
||||
getRowKeyData?: (index: number) => RowKeyData | undefined;
|
||||
getRowKeyData?: (index: number) => RowKeyData<TItemKey> | undefined;
|
||||
colCount: number;
|
||||
isDarkMode?: boolean;
|
||||
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
|
||||
@@ -147,7 +145,7 @@ export type TanstackTableQueryParamsConfig = {
|
||||
expanded?: string;
|
||||
};
|
||||
|
||||
export type TanStackTableProps<TData> = {
|
||||
export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
data: TData[];
|
||||
columns: TableColumnDef<TData>[];
|
||||
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
|
||||
@@ -172,7 +170,7 @@ export type TanStackTableProps<TData> = {
|
||||
* When set, enables automatic duplicate key detection and group-aware key composition. */
|
||||
getRowKey?: (row: TData) => string;
|
||||
/** Function to get the business/selection key. Defaults to getRowKey result. */
|
||||
getItemKey?: (row: TData) => string;
|
||||
getItemKey?: (row: TData) => TItemKey;
|
||||
/** When set, enables group-aware key generation (prefixes rowKey with group values). */
|
||||
groupBy?: Array<{ key: string }>;
|
||||
/** Extract group metadata from a row. Required when groupBy is set. */
|
||||
@@ -181,9 +179,9 @@ export type TanStackTableProps<TData> = {
|
||||
getRowClassName?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: string) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export interface RowKeyDataItem {
|
||||
export interface RowKeyDataItem<TItemKey = string> {
|
||||
/** Final unique key for the row (with dedup suffix if needed) */
|
||||
finalKey: string;
|
||||
/** Item key for tracking (may differ from finalKey) */
|
||||
itemKey: string;
|
||||
itemKey: TItemKey;
|
||||
/** Group metadata when grouped */
|
||||
groupMeta: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export interface UseRowKeyDataOptions<TData> {
|
||||
export interface UseRowKeyDataOptions<TData, TItemKey = string> {
|
||||
data: TData[];
|
||||
isLoading: boolean;
|
||||
getRowKey?: (item: TData) => string;
|
||||
getItemKey?: (item: TData) => string;
|
||||
getItemKey?: (item: TData) => TItemKey;
|
||||
groupBy?: Array<{ key: string }>;
|
||||
getGroupKey?: (item: TData) => Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UseRowKeyDataResult {
|
||||
export interface UseRowKeyDataResult<TItemKey = string> {
|
||||
/** Array of key data for each row, undefined if getRowKey not provided or loading */
|
||||
rowKeyData: RowKeyDataItem[] | undefined;
|
||||
getRowKeyData: (index: number) => RowKeyDataItem | undefined;
|
||||
rowKeyData: RowKeyDataItem<TItemKey>[] | undefined;
|
||||
getRowKeyData: (index: number) => RowKeyDataItem<TItemKey> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes unique row keys with duplicate handling and group prefixes.
|
||||
*/
|
||||
export function useRowKeyData<TData>({
|
||||
export function useRowKeyData<TData, TItemKey = string>({
|
||||
data,
|
||||
isLoading,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
groupBy,
|
||||
getGroupKey,
|
||||
}: UseRowKeyDataOptions<TData>): UseRowKeyDataResult {
|
||||
}: UseRowKeyDataOptions<TData, TItemKey>): UseRowKeyDataResult<TItemKey> {
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const rowKeyData = useMemo((): RowKeyDataItem[] | undefined => {
|
||||
const rowKeyData = useMemo((): RowKeyDataItem<TItemKey>[] | undefined => {
|
||||
if (!getRowKey || isLoading) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyCount = new Map<string, number>();
|
||||
|
||||
return data.map((item, index): RowKeyDataItem => {
|
||||
return data.map((item, index): RowKeyDataItem<TItemKey> => {
|
||||
const itemIdentifier = getRowKey(item);
|
||||
const itemKey = getItemKey?.(item) ?? itemIdentifier;
|
||||
const itemKey = getItemKey?.(item) ?? (itemIdentifier as TItemKey);
|
||||
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
|
||||
|
||||
// Build rowKey with group prefix when grouped
|
||||
|
||||
@@ -86,10 +86,10 @@ const buildAccessorFn = <TData>(
|
||||
};
|
||||
};
|
||||
|
||||
export function buildTanstackColumnDef<TData>(
|
||||
export function buildTanstackColumnDef<TData, TItemKey = string>(
|
||||
colDef: TableColumnDef<TData>,
|
||||
isRowActive?: (row: TData) => boolean,
|
||||
getRowKeyData?: (index: number) => RowKeyData | undefined,
|
||||
getRowKeyData?: (index: number) => RowKeyData<TItemKey> | undefined,
|
||||
): ColumnDef<TData> {
|
||||
const isFixed = colDef.width?.fixed != null;
|
||||
const headerFn =
|
||||
@@ -140,7 +140,6 @@ export function buildTanstackColumnDef<TData>(
|
||||
toggleExpanded: (): void => {
|
||||
row.toggleExpanded();
|
||||
},
|
||||
itemKey: keyData?.itemKey ?? '',
|
||||
groupMeta: keyData?.groupMeta,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -57,4 +57,7 @@ export enum QueryParams {
|
||||
isTestAlert = 'isTestAlert',
|
||||
yAxisUnit = 'yAxisUnit',
|
||||
ruleName = 'ruleName',
|
||||
matchType = 'matchType',
|
||||
compareOp = 'compareOp',
|
||||
evaluationWindowPreset = 'evaluationWindowPreset',
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const DASHBOARD_CACHE_TIME = 30_000;
|
||||
// keep it low or zero, otherwise, when enabled auto-refresh, this causes OOM due to accumulated queries in cache
|
||||
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;
|
||||
|
||||
export const FIELD_API_CACHE_TIME = 60_000;
|
||||
|
||||
@@ -7,13 +7,11 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
// The setup-guide button only exists in the classic form, which is reachable
|
||||
// via the unadvertised showClassicCreateAlertsPage param.
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
search: 'ruleType=anomaly_rule',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -22,7 +20,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
|
||||
useNavigationType: jest.fn(() => 'PUSH'),
|
||||
useLocation: jest.fn(() => ({
|
||||
pathname: '/alerts/new',
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
search: 'ruleType=anomaly_rule',
|
||||
hash: '',
|
||||
state: null,
|
||||
})),
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('CreateAlertRule', () => {
|
||||
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render new flow when ruleType is anomaly_rule', () => {
|
||||
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'false';
|
||||
@@ -163,8 +163,8 @@ describe('CreateAlertRule', () => {
|
||||
return null;
|
||||
});
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL when provided', () => {
|
||||
|
||||
@@ -100,9 +100,10 @@ function CreateRules(): JSX.Element {
|
||||
return <SelectAlertType onSelect={handleSelectType} />;
|
||||
}
|
||||
|
||||
// The classic experience is no longer offered in the UI; the query param
|
||||
// is kept as an unadvertised escape hatch until the flow is removed.
|
||||
if (showClassicCreateAlertsPageFlag) {
|
||||
if (
|
||||
showClassicCreateAlertsPageFlag ||
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
) {
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={alertType}
|
||||
|
||||
@@ -2,9 +2,7 @@ import { useQuery } from 'react-query';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import classNames from 'classnames';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { Activity, ChartLine } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ChartLine } from '@signozhq/icons';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
@@ -21,7 +19,6 @@ import './styles.scss';
|
||||
|
||||
function AlertCondition(): JSX.Element {
|
||||
const { alertType, setAlertType } = useCreateAlertState();
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -33,15 +30,9 @@ function AlertCondition(): JSX.Element {
|
||||
});
|
||||
const channels = data?.data || [];
|
||||
|
||||
const isAnomalyDetectionEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
|
||||
?.active || false;
|
||||
|
||||
// Anomaly alerts always show both tabs so existing rules stay editable;
|
||||
// metric alerts only offer the anomaly tab when the feature is enabled.
|
||||
const showMultipleTabs =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
|
||||
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
|
||||
alertType === AlertTypes.METRICS_BASED_ALERT;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -49,15 +40,16 @@ function AlertCondition(): JSX.Element {
|
||||
icon: <ChartLine size={14} data-testid="threshold-view" />,
|
||||
value: AlertTypes.METRICS_BASED_ALERT,
|
||||
},
|
||||
...(showMultipleTabs
|
||||
? [
|
||||
{
|
||||
label: 'Anomaly',
|
||||
icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Hide anomaly tab for now
|
||||
// ...(showMultipleTabs
|
||||
// ? [
|
||||
// {
|
||||
// label: 'Anomaly',
|
||||
// icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
// value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
// },
|
||||
// ]
|
||||
// : []),
|
||||
];
|
||||
|
||||
const handleAlertTypeChange = (value: AlertTypes): void => {
|
||||
|
||||
@@ -188,7 +188,7 @@ function AnomalyThreshold({
|
||||
}}
|
||||
options={ANOMALY_SEASONALITY_OPTIONS}
|
||||
/>
|
||||
{!notificationSettings.routingPolicies ? (
|
||||
{notificationSettings.routingPolicies ? (
|
||||
<>
|
||||
<Typography.Text
|
||||
data-testid="seasonality-text"
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import AlertCondition from '../AlertCondition';
|
||||
@@ -100,23 +96,6 @@ const createTestQueryClient = (): QueryClient =>
|
||||
},
|
||||
});
|
||||
|
||||
const ANOMALY_DETECTION_FLAG: FeatureFlagProps = {
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
};
|
||||
|
||||
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
|
||||
|
||||
const mockAppContext = (isAnomalyDetectionEnabled: boolean): void => {
|
||||
useAppContextSpy.mockReturnValue({
|
||||
...getAppContextMockState(),
|
||||
featureFlags: isAnomalyDetectionEnabled ? [ANOMALY_DETECTION_FLAG] : [],
|
||||
});
|
||||
};
|
||||
|
||||
const renderAlertCondition = (
|
||||
alertType?: string,
|
||||
): ReturnType<typeof render> => {
|
||||
@@ -134,10 +113,6 @@ const renderAlertCondition = (
|
||||
};
|
||||
|
||||
describe('AlertCondition', () => {
|
||||
beforeEach(() => {
|
||||
mockAppContext(true);
|
||||
});
|
||||
|
||||
it('renders the stepper with correct step number and label', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
|
||||
@@ -150,9 +125,10 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(
|
||||
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
// ).not.toBeInTheDocument();
|
||||
|
||||
// Verify threshold tab is active by default
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -160,7 +136,8 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders threshold tab by default', () => {
|
||||
@@ -175,27 +152,13 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer the anomaly tab when anomaly detection is disabled', () => {
|
||||
mockAppContext(false);
|
||||
renderAlertCondition();
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows both tabs for anomaly alerts even when anomaly detection is disabled', () => {
|
||||
mockAppContext(false);
|
||||
renderAlertCondition('ANOMALY_BASED_ALERT');
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AlertThreshold component when alert type is not anomaly based', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
@@ -204,7 +167,8 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Click on anomaly tab to switch to anomaly-based alert
|
||||
@@ -215,7 +179,8 @@ describe('AlertCondition', () => {
|
||||
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches between threshold and anomaly tabs', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('switches between threshold and anomaly tabs', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Initially shows threshold component
|
||||
@@ -240,7 +205,8 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies active tab styling correctly', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('applies active tab styling correctly', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -261,10 +227,11 @@ describe('AlertCondition', () => {
|
||||
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
|
||||
renderAlertCondition('METRIC_BASED_ALERT');
|
||||
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
|
||||
@@ -272,8 +239,9 @@ describe('AlertCondition', () => {
|
||||
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only threshold tab for LOGS_BASED_ALERT', () => {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { RotateCcw } from '@signozhq/icons';
|
||||
import { useAlertRuleOptional } from 'providers/Alert';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
@@ -15,6 +22,8 @@ function CreateAlertHeader(): JSX.Element {
|
||||
const alertRuleContext = useAlertRuleOptional();
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const groupByLabels = useMemo(() => {
|
||||
const labels = new Array<string>();
|
||||
@@ -37,6 +46,14 @@ function CreateAlertHeader(): JSX.Element {
|
||||
[groupByLabels],
|
||||
);
|
||||
|
||||
const handleSwitchToClassicExperience = useCallback(() => {
|
||||
logEvent('Alert: Switch to classic experience button clicked', {});
|
||||
|
||||
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
|
||||
safeNavigate(url, { replace: true });
|
||||
}, [safeNavigate, urlQuery]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
|
||||
@@ -44,6 +61,15 @@ function CreateAlertHeader(): JSX.Element {
|
||||
{!isEditMode && (
|
||||
<div className="alert-header__tab-bar">
|
||||
<div className="alert-header__tab">New Alert Rule</div>
|
||||
<Button
|
||||
prefix={<RotateCcw size={12} />}
|
||||
onClick={handleSwitchToClassicExperience}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Switch to Classic Experience
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="alert-header__content">
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
|
||||
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
|
||||
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import * as rulesHook from '../../../../api/generated/services/rules';
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import CreateAlertHeader from '../CreateAlertHeader';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
});
|
||||
|
||||
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
@@ -98,8 +106,34 @@ describe('CreateAlertHeader', () => {
|
||||
).toHaveValue('TEST_ALERT');
|
||||
});
|
||||
|
||||
it('should not render "switch to classic experience" button', () => {
|
||||
it('should navigate to classic experience when button is clicked', () => {
|
||||
renderCreateAlertHeader();
|
||||
const switchToClassicExperienceButton = screen.getByText(
|
||||
'Switch to Classic Experience',
|
||||
);
|
||||
expect(switchToClassicExperienceButton).toBeInTheDocument();
|
||||
fireEvent.click(switchToClassicExperienceButton);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`${ROUTES.ALERTS_NEW}?${params.toString()}`,
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render "switch to classic experience" button when isEditMode is true', () => {
|
||||
render(
|
||||
<CreateAlertProvider
|
||||
isEditMode
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
initialAlertState={getCreateAlertLocalStateFromAlertDef(
|
||||
defaultPostableAlertRuleV2,
|
||||
)}
|
||||
>
|
||||
<CreateAlertHeader />
|
||||
</CreateAlertProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText('Switch to Classic Experience'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -14,7 +14,10 @@ import APIError from 'types/api/error';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
|
||||
import {
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
validateCreateAlertState,
|
||||
} from './utils';
|
||||
|
||||
import './styles.scss';
|
||||
import {
|
||||
@@ -82,7 +85,7 @@ function Footer(): JSX.Element {
|
||||
);
|
||||
|
||||
const handleTestNotification = useCallback((): void => {
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
@@ -119,7 +122,7 @@ function Footer(): JSX.Element {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleSaveAlert = useCallback((): void => {
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
|
||||
@@ -18,8 +18,6 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { BuildCreateAlertRulePayloadArgs } from '../types';
|
||||
import {
|
||||
buildCreateAlertRulePayload,
|
||||
buildCreateAnomalyAlertRulePayload,
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
getAlertOnAbsentProps,
|
||||
getEnforceMinimumDatapointsProps,
|
||||
@@ -552,115 +550,4 @@ describe('Footer utils', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildCreateAnomalyAlertRulePayload', () => {
|
||||
const mockCreateAlertContextState = createMockAlertContextState();
|
||||
const ANOMALY_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
|
||||
basicAlertState: mockCreateAlertContextState.alertState,
|
||||
thresholdState: {
|
||||
...mockCreateAlertContextState.thresholdState,
|
||||
thresholds: [
|
||||
{
|
||||
...mockCreateAlertContextState.thresholdState.thresholds[0],
|
||||
thresholdValue: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
advancedOptions: mockCreateAlertContextState.advancedOptions,
|
||||
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
|
||||
notificationSettings: mockCreateAlertContextState.notificationSettings,
|
||||
query: initialQueriesMap.metrics,
|
||||
alertType: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
};
|
||||
|
||||
it('builds a v2alpha1 anomaly rule payload', () => {
|
||||
const props = buildCreateAnomalyAlertRulePayload(ANOMALY_PAYLOAD_ARGS);
|
||||
expect(props.ruleType).toBe('anomaly_rule');
|
||||
expect(props.schemaVersion).toBe('v2alpha1');
|
||||
expect(props.version).toBe('v5');
|
||||
// The stored alertType is metric based; anomaly is a rule type
|
||||
expect(props.alertType).toBe('METRIC_BASED_ALERT');
|
||||
expect(props.condition.algorithm).toBe(
|
||||
ANOMALY_PAYLOAD_ARGS.thresholdState.algorithm,
|
||||
);
|
||||
expect(props.condition.seasonality).toBe(
|
||||
ANOMALY_PAYLOAD_ARGS.thresholdState.seasonality,
|
||||
);
|
||||
expect(props.condition.selectedQueryName).toBe(
|
||||
ANOMALY_PAYLOAD_ARGS.thresholdState.selectedQuery,
|
||||
);
|
||||
// Evaluation comes from the anomaly condition's own window
|
||||
expect(props.evaluation).toStrictEqual({
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: ANOMALY_PAYLOAD_ARGS.thresholdState.evaluationWindow,
|
||||
frequency: '1m',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the target positive for the above operator', () => {
|
||||
const props = buildCreateAnomalyAlertRulePayload({
|
||||
...ANOMALY_PAYLOAD_ARGS,
|
||||
thresholdState: {
|
||||
...ANOMALY_PAYLOAD_ARGS.thresholdState,
|
||||
operator: 'above',
|
||||
},
|
||||
});
|
||||
expect(props.condition.thresholds?.spec[0].target).toBe(3);
|
||||
expect(props.condition.thresholds?.spec[0].op).toBe('above');
|
||||
});
|
||||
|
||||
it.each([['below'], ['2']])(
|
||||
'negates the target for the below operator (%s)',
|
||||
(op) => {
|
||||
const props = buildCreateAnomalyAlertRulePayload({
|
||||
...ANOMALY_PAYLOAD_ARGS,
|
||||
thresholdState: {
|
||||
...ANOMALY_PAYLOAD_ARGS.thresholdState,
|
||||
operator: op,
|
||||
},
|
||||
});
|
||||
expect(props.condition.thresholds?.spec[0].target).toBe(-3);
|
||||
expect(props.condition.thresholds?.spec[0].op).toBe(op);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the target positive for the outside_bounds operator', () => {
|
||||
const props = buildCreateAnomalyAlertRulePayload({
|
||||
...ANOMALY_PAYLOAD_ARGS,
|
||||
thresholdState: {
|
||||
...ANOMALY_PAYLOAD_ARGS.thresholdState,
|
||||
operator: 'outside_bounds',
|
||||
},
|
||||
});
|
||||
expect(props.condition.thresholds?.spec[0].target).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCreateAlertRulePayload', () => {
|
||||
const mockCreateAlertContextState = createMockAlertContextState();
|
||||
const args: BuildCreateAlertRulePayloadArgs = {
|
||||
basicAlertState: mockCreateAlertContextState.alertState,
|
||||
thresholdState: mockCreateAlertContextState.thresholdState,
|
||||
advancedOptions: mockCreateAlertContextState.advancedOptions,
|
||||
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
|
||||
notificationSettings: mockCreateAlertContextState.notificationSettings,
|
||||
query: initialQueriesMap.metrics,
|
||||
alertType: mockCreateAlertContextState.alertType,
|
||||
};
|
||||
|
||||
it('builds an anomaly payload for anomaly based alerts', () => {
|
||||
const props = buildCreateAlertRulePayload({
|
||||
...args,
|
||||
alertType: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
});
|
||||
expect(props.ruleType).toBe('anomaly_rule');
|
||||
});
|
||||
|
||||
it('builds a threshold payload for other alert types', () => {
|
||||
const props = buildCreateAlertRulePayload(args);
|
||||
expect(props.ruleType).toBe('threshold_rule');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
BasicThreshold,
|
||||
PostableAlertRuleV2,
|
||||
@@ -12,11 +11,9 @@ import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelo
|
||||
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertThresholdOperator,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from '../context/types';
|
||||
import { normalizeOperator } from '../utils';
|
||||
import { BuildCreateAlertRulePayloadArgs } from './types';
|
||||
|
||||
// Get formatted time/unit pairs for create alert api payload
|
||||
@@ -291,15 +288,16 @@ export function buildCreateThresholdAlertRulePayload(
|
||||
}
|
||||
|
||||
// Build Create Anomaly Alert Rule Payload
|
||||
// TODO: Update this function before enabling anomaly alert rule creation
|
||||
export function buildCreateAnomalyAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
const {
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
query,
|
||||
notificationSettings,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
} = args;
|
||||
|
||||
@@ -315,55 +313,19 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
unit: basicAlertState.yAxisUnit,
|
||||
});
|
||||
|
||||
// v2alpha1 thresholds are literal: "3 deviations below the predicted data"
|
||||
// means the anomaly z-score must drop under -3, so the target is negated
|
||||
// for the below operator (the deviations input is always positive).
|
||||
const isBelowOperator =
|
||||
normalizeOperator(thresholdState.operator) ===
|
||||
AlertThresholdOperator.IS_BELOW;
|
||||
const thresholds: BasicThreshold[] = thresholdState.thresholds.map(
|
||||
(threshold) => {
|
||||
const deviations = Math.abs(parseFloat(threshold.thresholdValue.toString()));
|
||||
return {
|
||||
name: threshold.label,
|
||||
target: isBelowOperator ? -deviations : deviations,
|
||||
matchType: thresholdState.matchType,
|
||||
op: thresholdState.operator,
|
||||
channels: threshold.channels,
|
||||
targetUnit: threshold.unit,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
|
||||
const enforceMinimumDatapointsProps =
|
||||
getEnforceMinimumDatapointsProps(advancedOptions);
|
||||
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
const notificationSettingsProps =
|
||||
getNotificationSettingsProps(notificationSettings);
|
||||
|
||||
// The anomaly condition carries its own evaluation window
|
||||
// ("during the last X"), so the evaluation is always a rolling window.
|
||||
const frequency = getFormattedTimeValue(
|
||||
advancedOptions.evaluationCadence.default.value,
|
||||
advancedOptions.evaluationCadence.default.timeUnit,
|
||||
);
|
||||
|
||||
return {
|
||||
alert: basicAlertState.name,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
alertType:
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? AlertTypes.METRICS_BASED_ALERT
|
||||
: alertType,
|
||||
alertType,
|
||||
condition: {
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: thresholds,
|
||||
},
|
||||
compositeQuery,
|
||||
selectedQueryName: thresholdState.selectedQuery,
|
||||
algorithm: thresholdState.algorithm,
|
||||
seasonality: thresholdState.seasonality,
|
||||
...alertOnAbsentProps,
|
||||
...enforceMinimumDatapointsProps,
|
||||
},
|
||||
@@ -373,25 +335,9 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: thresholdState.evaluationWindow,
|
||||
frequency,
|
||||
},
|
||||
},
|
||||
version: 'v5',
|
||||
schemaVersion: 'v2alpha1',
|
||||
evaluation: evaluationProps,
|
||||
version: '',
|
||||
schemaVersion: '',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Build the create/test alert rule payload for the selected alert type
|
||||
export function buildCreateAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
if (args.alertType === AlertTypes.ANOMALY_BASED_ALERT) {
|
||||
return buildCreateAnomalyAlertRulePayload(args);
|
||||
}
|
||||
return buildCreateThresholdAlertRulePayload(args);
|
||||
}
|
||||
|
||||
@@ -316,60 +316,6 @@ describe('CreateAlertV2 utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
|
||||
const anomalyAlertDef: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
ruleType: 'anomaly_rule',
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
algorithm: 'standard',
|
||||
seasonality: 'daily',
|
||||
selectedQueryName: 'A',
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
target: -3,
|
||||
targetUnit: '',
|
||||
channels: ['email'],
|
||||
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
op: AlertThresholdOperator.IS_BELOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: '1h0m0s',
|
||||
frequency: '1m',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('shows the absolute deviation value for negative anomaly targets', () => {
|
||||
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
|
||||
expect(props.thresholds[0].thresholdValue).toBe(3);
|
||||
expect(props.operator).toBe(AlertThresholdOperator.IS_BELOW);
|
||||
});
|
||||
|
||||
it('hydrates the anomaly evaluation window, algorithm and seasonality', () => {
|
||||
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
|
||||
expect(props.evaluationWindow).toBe('1h0m0s');
|
||||
expect(props.algorithm).toBe('standard');
|
||||
expect(props.seasonality).toBe('daily');
|
||||
});
|
||||
|
||||
it('does not touch the target for non-anomaly rules', () => {
|
||||
const props = getThresholdStateFromAlertDef({
|
||||
...anomalyAlertDef,
|
||||
ruleType: 'threshold_rule',
|
||||
});
|
||||
expect(props.thresholds[0].thresholdValue).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeOperator', () => {
|
||||
it.each([
|
||||
['1', AlertThresholdOperator.IS_ABOVE],
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { CreateAlertProvider, useCreateAlertState } from '../index';
|
||||
|
||||
// The provider only needs a query with a unit + empty builder for these assertions.
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: {
|
||||
unit: 'bytes',
|
||||
builder: { queryData: [], queryFormulas: [] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mutation = { mutate: jest.fn(), isLoading: false };
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useCreateRule: (): unknown => mutation,
|
||||
useTestRule: (): unknown => mutation,
|
||||
useUpdateRuleByID: (): unknown => mutation,
|
||||
}));
|
||||
|
||||
function Probe(): JSX.Element {
|
||||
const { thresholdState, evaluationWindow } = useCreateAlertState();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="match-type">{thresholdState.matchType}</span>
|
||||
<span data-testid="operator">{thresholdState.operator}</span>
|
||||
<span data-testid="threshold-count">{thresholdState.thresholds.length}</span>
|
||||
<span data-testid="threshold-value">
|
||||
{thresholdState.thresholds[0]?.thresholdValue}
|
||||
</span>
|
||||
<span data-testid="window-type">{evaluationWindow.windowType}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithSearch(search: string): void {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function serializeThreshold(thresholdValue: number): string {
|
||||
return encodeURIComponent(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 't1',
|
||||
label: 'critical',
|
||||
thresholdValue,
|
||||
recoveryThresholdValue: null,
|
||||
unit: 'bytes',
|
||||
channels: [],
|
||||
color: '#F1575F',
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
|
||||
it('applies matchType, operator, and thresholds without the meter window', () => {
|
||||
// Dashboard-style prefill: no evaluationWindowPreset → default window.
|
||||
renderWithSearch(
|
||||
`?matchType=on_average&compareOp=below&thresholds=${serializeThreshold(90)}`,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('match-type')).toHaveTextContent('on_average');
|
||||
expect(screen.getByTestId('operator')).toHaveTextContent('below');
|
||||
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('90');
|
||||
// The meter evaluation-window preset must NOT fire.
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
|
||||
it('adjusts only the occurrence type when no thresholds are supplied', () => {
|
||||
renderWithSearch(`?matchType=in_total`);
|
||||
|
||||
expect(screen.getByTestId('match-type')).toHaveTextContent('in_total');
|
||||
// The default single critical threshold and operator are retained.
|
||||
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('0');
|
||||
expect(screen.getByTestId('operator')).toHaveTextContent('above');
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
|
||||
it('applies the meter evaluation window when the preset is declared', () => {
|
||||
// Ingestion-style prefill: explicit in_total + meter preset.
|
||||
renderWithSearch(
|
||||
`?matchType=in_total&evaluationWindowPreset=meter&thresholds=${serializeThreshold(
|
||||
500,
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('match-type')).toHaveTextContent('in_total');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('500');
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('cumulative');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { AlertThresholdMatchType, AlertThresholdOperator } from '../types';
|
||||
import {
|
||||
EvaluationWindowPreset,
|
||||
resolveUrlAlertPrefill,
|
||||
} from '../resolveUrlAlertPrefill';
|
||||
|
||||
function resolve(search: string): ReturnType<typeof resolveUrlAlertPrefill> {
|
||||
return resolveUrlAlertPrefill(new URLSearchParams(search));
|
||||
}
|
||||
|
||||
describe('resolveUrlAlertPrefill', () => {
|
||||
it('returns an empty plan when no params are present', () => {
|
||||
expect(resolve('')).toStrictEqual({
|
||||
thresholds: undefined,
|
||||
matchType: undefined,
|
||||
operator: undefined,
|
||||
evaluationWindowPreset: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a thresholds array', () => {
|
||||
const thresholds = [{ id: 't1', thresholdValue: 90 }];
|
||||
const { thresholds: parsed } = resolve(
|
||||
`thresholds=${encodeURIComponent(JSON.stringify(thresholds))}`,
|
||||
);
|
||||
expect(parsed).toStrictEqual(thresholds);
|
||||
});
|
||||
|
||||
it('ignores malformed or non-array thresholds without throwing', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(resolve('thresholds=not-json').thresholds).toBeUndefined();
|
||||
expect(
|
||||
resolve(`thresholds=${encodeURIComponent('{"a":1}')}`).thresholds,
|
||||
).toBeUndefined();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('normalizes matchType aliases', () => {
|
||||
expect(resolve('matchType=on_average').matchType).toBe(
|
||||
AlertThresholdMatchType.ON_AVERAGE,
|
||||
);
|
||||
expect(resolve('matchType=sum').matchType).toBe(
|
||||
AlertThresholdMatchType.IN_TOTAL,
|
||||
);
|
||||
expect(resolve('matchType=nonsense').matchType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes operator aliases', () => {
|
||||
expect(resolve('compareOp=above').operator).toBe(
|
||||
AlertThresholdOperator.IS_ABOVE,
|
||||
);
|
||||
expect(resolve('compareOp=%3E').operator).toBe(
|
||||
AlertThresholdOperator.IS_ABOVE,
|
||||
);
|
||||
expect(resolve('compareOp=nonsense').operator).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognizes the meter evaluation-window preset only', () => {
|
||||
expect(resolve('evaluationWindowPreset=meter').evaluationWindowPreset).toBe(
|
||||
EvaluationWindowPreset.METER,
|
||||
);
|
||||
expect(
|
||||
resolve('evaluationWindowPreset=rolling').evaluationWindowPreset,
|
||||
).toBeUndefined();
|
||||
expect(resolve('').evaluationWindowPreset).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -239,39 +239,6 @@ describe('CreateAlertV2 Context Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should set evaluation window', () => {
|
||||
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
|
||||
type: 'SET_EVALUATION_WINDOW',
|
||||
payload: TimeDuration.ONE_HOUR,
|
||||
});
|
||||
expect(result).toStrictEqual({
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
evaluationWindow: TimeDuration.ONE_HOUR,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set algorithm', () => {
|
||||
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
|
||||
type: 'SET_ALGORITHM',
|
||||
payload: Algorithm.STANDARD,
|
||||
});
|
||||
expect(result).toStrictEqual({
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
algorithm: Algorithm.STANDARD,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set seasonality', () => {
|
||||
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
|
||||
type: 'SET_SEASONALITY',
|
||||
payload: Seasonality.WEEKLY,
|
||||
});
|
||||
expect(result).toStrictEqual({
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
seasonality: Seasonality.WEEKLY,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set thresholds', () => {
|
||||
const newThresholds = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { AlertThresholdMatchType, AlertThresholdOperator } from './types';
|
||||
|
||||
// Mirrors the backend's CompareOperator.Normalize() in
|
||||
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
|
||||
// the dropdown understands. Returns undefined for aliases the UI does not
|
||||
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
|
||||
// value on screen instead of silently rewriting it.
|
||||
export function normalizeOperator(
|
||||
raw: string | undefined,
|
||||
): AlertThresholdOperator | undefined {
|
||||
switch (raw) {
|
||||
case '1':
|
||||
case 'above':
|
||||
case '>':
|
||||
return AlertThresholdOperator.IS_ABOVE;
|
||||
case '2':
|
||||
case 'below':
|
||||
case '<':
|
||||
return AlertThresholdOperator.IS_BELOW;
|
||||
case '3':
|
||||
case 'equal':
|
||||
case 'eq':
|
||||
case '=':
|
||||
return AlertThresholdOperator.IS_EQUAL_TO;
|
||||
case '4':
|
||||
case 'not_equal':
|
||||
case 'not_eq':
|
||||
case '!=':
|
||||
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
|
||||
case '7':
|
||||
case 'outside_bounds':
|
||||
return AlertThresholdOperator.ABOVE_BELOW;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
|
||||
export function normalizeMatchType(
|
||||
raw: string | undefined,
|
||||
): AlertThresholdMatchType | undefined {
|
||||
switch (raw) {
|
||||
case '1':
|
||||
case 'at_least_once':
|
||||
return AlertThresholdMatchType.AT_LEAST_ONCE;
|
||||
case '2':
|
||||
case 'all_the_times':
|
||||
return AlertThresholdMatchType.ALL_THE_TIME;
|
||||
case '3':
|
||||
case 'on_average':
|
||||
case 'avg':
|
||||
return AlertThresholdMatchType.ON_AVERAGE;
|
||||
case '4':
|
||||
case 'in_total':
|
||||
case 'sum':
|
||||
return AlertThresholdMatchType.IN_TOTAL;
|
||||
case '5':
|
||||
case 'last':
|
||||
return AlertThresholdMatchType.LAST;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,13 @@ import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/map
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { INITIAL_CREATE_ALERT_STATE } from './constants';
|
||||
import {
|
||||
EvaluationWindowPreset,
|
||||
resolveUrlAlertPrefill,
|
||||
} from './resolveUrlAlertPrefill';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
AlertThresholdAction,
|
||||
AlertThresholdMatchType,
|
||||
CreateAlertAction,
|
||||
CreateAlertSlice,
|
||||
EvaluationWindowAction,
|
||||
@@ -123,9 +126,13 @@ export function CreateAlertProvider(
|
||||
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const thresholdsFromURL = queryParams.get(QueryParams.thresholds);
|
||||
const ruleNameFromURL = queryParams.get(QueryParams.ruleName);
|
||||
const yAxisUnitFromURL = queryParams.get(QueryParams.yAxisUnit);
|
||||
// Prefill declared in the URL; applied verbatim, agnostic of the producer.
|
||||
const urlPrefill = useMemo(
|
||||
() => resolveUrlAlertPrefill(new URLSearchParams(location.search)),
|
||||
[location.search],
|
||||
);
|
||||
|
||||
const [alertType, setAlertType] = useState<AlertTypes>(() => {
|
||||
if (isEditMode) {
|
||||
@@ -168,32 +175,41 @@ export function CreateAlertProvider(
|
||||
},
|
||||
});
|
||||
|
||||
if (thresholdsFromURL) {
|
||||
try {
|
||||
const thresholds = JSON.parse(thresholdsFromURL);
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing thresholds from URL:', error);
|
||||
}
|
||||
|
||||
if (urlPrefill.thresholds) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.EVALUATION_WINDOW,
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_INITIAL_STATE_FOR_METER',
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: urlPrefill.thresholds,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (urlPrefill.matchType) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: AlertThresholdMatchType.IN_TOTAL,
|
||||
payload: urlPrefill.matchType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (urlPrefill.operator) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_OPERATOR',
|
||||
payload: urlPrefill.operator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.EVALUATION_WINDOW,
|
||||
action: {
|
||||
type: 'SET_INITIAL_STATE_FOR_METER',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -219,7 +235,7 @@ export function CreateAlertProvider(
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [alertType, thresholdsFromURL, ruleNameFromURL, yAxisUnitFromURL]);
|
||||
}, [alertType, urlPrefill, ruleNameFromURL, yAxisUnitFromURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && initialAlertState) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
import { normalizeMatchType, normalizeOperator } from './conditionNormalizers';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
Threshold,
|
||||
} from './types';
|
||||
|
||||
export enum EvaluationWindowPreset {
|
||||
METER = 'meter',
|
||||
}
|
||||
|
||||
export interface ResolvedAlertPrefill {
|
||||
thresholds?: Threshold[];
|
||||
matchType?: AlertThresholdMatchType;
|
||||
operator?: AlertThresholdOperator;
|
||||
evaluationWindowPreset?: EvaluationWindowPreset;
|
||||
}
|
||||
|
||||
function parseThresholds(raw: string | null): Threshold[] | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as Threshold[]) : undefined;
|
||||
} catch (error) {
|
||||
console.error('Error parsing thresholds from URL:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseEvaluationWindowPreset(
|
||||
raw: string | null,
|
||||
): EvaluationWindowPreset | undefined {
|
||||
return raw === EvaluationWindowPreset.METER
|
||||
? EvaluationWindowPreset.METER
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** URL → declarative prefill plan; the consumer applies it without knowing the producer. */
|
||||
export function resolveUrlAlertPrefill(
|
||||
params: URLSearchParams,
|
||||
): ResolvedAlertPrefill {
|
||||
return {
|
||||
thresholds: parseThresholds(params.get(QueryParams.thresholds)),
|
||||
matchType: normalizeMatchType(params.get(QueryParams.matchType) ?? undefined),
|
||||
operator: normalizeOperator(params.get(QueryParams.compareOp) ?? undefined),
|
||||
evaluationWindowPreset: parseEvaluationWindowPreset(
|
||||
params.get(QueryParams.evaluationWindowPreset),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -124,12 +124,6 @@ export const alertThresholdReducer = (
|
||||
return { ...state, operator: action.payload };
|
||||
case 'SET_MATCH_TYPE':
|
||||
return { ...state, matchType: action.payload };
|
||||
case 'SET_EVALUATION_WINDOW':
|
||||
return { ...state, evaluationWindow: action.payload };
|
||||
case 'SET_ALGORITHM':
|
||||
return { ...state, algorithm: action.payload };
|
||||
case 'SET_SEASONALITY':
|
||||
return { ...state, seasonality: action.payload };
|
||||
case 'SET_THRESHOLDS':
|
||||
return { ...state, thresholds: action.payload };
|
||||
case 'RESET':
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Spin } from 'antd';
|
||||
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { getRandomColor } from 'container/ExplorerOptions/utils';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@@ -239,84 +238,23 @@ export function getAdvancedOptionsStateFromAlertDef(
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the backend's CompareOperator.Normalize() in
|
||||
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
|
||||
// the dropdown understands. Returns undefined for aliases the UI does not
|
||||
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
|
||||
// value on screen instead of silently rewriting it.
|
||||
export function normalizeOperator(
|
||||
raw: string | undefined,
|
||||
): AlertThresholdOperator | undefined {
|
||||
switch (raw) {
|
||||
case '1':
|
||||
case 'above':
|
||||
case '>':
|
||||
return AlertThresholdOperator.IS_ABOVE;
|
||||
case '2':
|
||||
case 'below':
|
||||
case '<':
|
||||
return AlertThresholdOperator.IS_BELOW;
|
||||
case '3':
|
||||
case 'equal':
|
||||
case 'eq':
|
||||
case '=':
|
||||
return AlertThresholdOperator.IS_EQUAL_TO;
|
||||
case '4':
|
||||
case 'not_equal':
|
||||
case 'not_eq':
|
||||
case '!=':
|
||||
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
|
||||
case '7':
|
||||
case 'outside_bounds':
|
||||
return AlertThresholdOperator.ABOVE_BELOW;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
|
||||
export function normalizeMatchType(
|
||||
raw: string | undefined,
|
||||
): AlertThresholdMatchType | undefined {
|
||||
switch (raw) {
|
||||
case '1':
|
||||
case 'at_least_once':
|
||||
return AlertThresholdMatchType.AT_LEAST_ONCE;
|
||||
case '2':
|
||||
case 'all_the_times':
|
||||
return AlertThresholdMatchType.ALL_THE_TIME;
|
||||
case '3':
|
||||
case 'on_average':
|
||||
case 'avg':
|
||||
return AlertThresholdMatchType.ON_AVERAGE;
|
||||
case '4':
|
||||
case 'in_total':
|
||||
case 'sum':
|
||||
return AlertThresholdMatchType.IN_TOTAL;
|
||||
case '5':
|
||||
case 'last':
|
||||
return AlertThresholdMatchType.LAST;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
// Condition-alias normalizers live in a leaf module (they depend only on the
|
||||
// enums) so `context/index` can consume them without an index↔utils cycle.
|
||||
export {
|
||||
normalizeOperator,
|
||||
normalizeMatchType,
|
||||
} from './context/conditionNormalizers';
|
||||
|
||||
export function getThresholdStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): AlertThresholdState {
|
||||
// Anomaly targets are stored as literal z-scores (negative for the below
|
||||
// operator); the deviations select always shows the positive value.
|
||||
const isAnomalyRule =
|
||||
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
return {
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
thresholds:
|
||||
alertDef.condition.thresholds?.spec.map((threshold) => ({
|
||||
id: v4(),
|
||||
label: threshold.name,
|
||||
thresholdValue: isAnomalyRule
|
||||
? Math.abs(threshold.target)
|
||||
: threshold.target,
|
||||
thresholdValue: threshold.target,
|
||||
recoveryThresholdValue: null,
|
||||
unit: threshold.targetUnit,
|
||||
color: getColorForThreshold(threshold.name),
|
||||
@@ -328,18 +266,6 @@ export function getThresholdStateFromAlertDef(
|
||||
matchType:
|
||||
alertDef.condition.thresholds?.spec[0].matchType ||
|
||||
AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
...(isAnomalyRule
|
||||
? {
|
||||
evaluationWindow:
|
||||
alertDef.evaluation?.spec?.evalWindow ||
|
||||
INITIAL_ALERT_THRESHOLD_STATE.evaluationWindow,
|
||||
algorithm:
|
||||
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
|
||||
seasonality:
|
||||
alertDef.condition.seasonality ||
|
||||
INITIAL_ALERT_THRESHOLD_STATE.seasonality,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
@@ -66,7 +67,6 @@ import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMapp
|
||||
import { cloneDeep, isEqual, omit } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { FormattingOptions } from 'providers/preferences/types';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
@@ -1031,26 +1031,19 @@ function ExplorerOptions({
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
<ExportPanelContainer
|
||||
open={isExport}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
onClose={onCancel(false)}
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1058,7 +1051,7 @@ function ExplorerOptions({
|
||||
export interface ExplorerOptionsProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (
|
||||
dashboard: Dashboard | null,
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -80,7 +81,7 @@ const renderExplorerOptionWrapper = (
|
||||
isLoading: false,
|
||||
onExport: jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
@@ -150,7 +151,7 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
@@ -179,15 +180,16 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the "New Dashboard" button
|
||||
const newDashboardButton = screen.getByRole('button', {
|
||||
name: /new dashboard/i,
|
||||
});
|
||||
// Click the "New dashboard" button
|
||||
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
|
||||
await user.click(newDashboardButton);
|
||||
|
||||
// Wait for the API call to complete and onExport to be called
|
||||
await waitFor(() => {
|
||||
expect(testOnExport).toHaveBeenCalledWith(mockNewDashboard, true);
|
||||
expect(testOnExport).toHaveBeenCalledWith(
|
||||
{ id: NEW_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE },
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,7 +197,7 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
@@ -229,13 +231,12 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -251,19 +252,21 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard1.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for onExport to be called with the selected dashboard
|
||||
await waitFor(() => {
|
||||
expect(testOnExport).toHaveBeenCalledWith(mockDashboard1, false);
|
||||
expect(testOnExport).toHaveBeenCalledWith(
|
||||
{ id: 'dashboard-1', title: 'Dashboard 1' },
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,7 +287,7 @@ describe('ExplorerOptionWrapper', () => {
|
||||
|
||||
// Create a real handleExport function similar to LogsExplorerViews
|
||||
// This should NOT call useUpdateDashboard (as per PR #8029)
|
||||
const handleExport = (dashboard: Dashboard | null): void => {
|
||||
const handleExport = (dashboard: ExportDashboard | null): void => {
|
||||
if (!dashboard) {
|
||||
return;
|
||||
}
|
||||
@@ -326,13 +329,12 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -348,14 +350,13 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for the handleExport function to be called and navigation to occur
|
||||
@@ -375,7 +376,7 @@ describe('ExplorerOptionWrapper', () => {
|
||||
it('should not show export buttons when component is disabled', () => {
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
|
||||
74
frontend/src/container/ExportPanel/ExportDashboardSelect.tsx
Normal file
74
frontend/src/container/ExportPanel/ExportDashboardSelect.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components
|
||||
import { Select, SelectProps } from 'antd';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
|
||||
import { getSelectOptions } from './utils';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
interface ExportDashboardSelectProps {
|
||||
dashboards: ExportDashboard[];
|
||||
value: string | null;
|
||||
/** The picked dashboard, pinned as an option so its label survives a later search. */
|
||||
selectedDashboard: ExportDashboard | null;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (dashboardId: string) => void;
|
||||
onSearch: (search: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard picker for the "Add to dashboard" dialog. Server-side search (`filterOption`
|
||||
* off, typing via `onSearch`); the selected dashboard is pinned as an option so its label
|
||||
* survives a narrowing search, and `getPopupContainer` keeps the overlay from clipping the
|
||||
* dropdown.
|
||||
*/
|
||||
function ExportDashboardSelect({
|
||||
dashboards,
|
||||
value,
|
||||
selectedDashboard,
|
||||
loading,
|
||||
disabled,
|
||||
onChange,
|
||||
onSearch,
|
||||
}: ExportDashboardSelectProps): JSX.Element {
|
||||
const options = useMemo<SelectProps['options']>(() => {
|
||||
const base = getSelectOptions(dashboards) ?? [];
|
||||
if (
|
||||
selectedDashboard &&
|
||||
!base.some((option) => option.value === selectedDashboard.id)
|
||||
) {
|
||||
return [
|
||||
{ label: selectedDashboard.title, value: selectedDashboard.id },
|
||||
...base,
|
||||
];
|
||||
}
|
||||
return base;
|
||||
}, [dashboards, selectedDashboard]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={styles.dashboardSelect}
|
||||
placeholder="Select a dashboard"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSearch={onSearch}
|
||||
data-testid="export-dashboard-select"
|
||||
options={options}
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement ?? document.body
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ExportDashboardSelect.defaultProps = {
|
||||
loading: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default ExportDashboardSelect;
|
||||
42
frontend/src/container/ExportPanel/ExportPanel.module.scss
Normal file
42
frontend/src/container/ExportPanel/ExportPanel.module.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.dashboardSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.newDashboard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,127 +1,159 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import createDashboard from 'api/v1/dashboards/create';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { ExportPanelProps } from '.';
|
||||
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
|
||||
import {
|
||||
DashboardSelect,
|
||||
NewDashboardButton,
|
||||
SelectWrapper,
|
||||
Title,
|
||||
Wrapper,
|
||||
} from './styles';
|
||||
import { filterOptions, getSelectOptions } from './utils';
|
||||
ExportDashboard,
|
||||
useExportDashboards,
|
||||
} from 'hooks/dashboard/useExportDashboards';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ExportDashboardSelect from './ExportDashboardSelect';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
) => void;
|
||||
query: Query | null;
|
||||
/** Controlled open state of the dialog. */
|
||||
open: boolean;
|
||||
/** Called when the dialog requests to close (Cancel / overlay / Esc). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Add to dashboard" dialog: export the panel into an existing dashboard or a newly
|
||||
* created one. Navigation is the caller's job via `onExport` (flag-aware V1/V2 editor).
|
||||
*/
|
||||
function ExportPanelContainer({
|
||||
isLoading,
|
||||
onExport,
|
||||
open,
|
||||
onClose,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
// Track the object, not just the id, so export survives a search that narrows it out.
|
||||
const [selectedDashboard, setSelectedDashboard] =
|
||||
useState<ExportDashboard | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const {
|
||||
data,
|
||||
dashboards,
|
||||
isLoading: isAllDashboardsLoading,
|
||||
refetch,
|
||||
} = useGetAllDashboard();
|
||||
isFetching: isDashboardsFetching,
|
||||
} = useExportDashboards(searchText);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const { mutate: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useMutation(createDashboard, {
|
||||
onSuccess: (data) => {
|
||||
if (data.data) {
|
||||
onExport(data?.data, true);
|
||||
}
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(error as APIError);
|
||||
},
|
||||
const { create: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useCreateExportDashboard({
|
||||
title: t('new_dashboard_title', { ns: 'dashboard' }),
|
||||
onCreated: (dashboard) => onExport(dashboard, true),
|
||||
});
|
||||
|
||||
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
|
||||
|
||||
const handleExportClick = useCallback((): void => {
|
||||
const currentSelectedDashboard = data?.data?.find(
|
||||
({ id }) => id === dashboardId,
|
||||
);
|
||||
|
||||
onExport(currentSelectedDashboard || null, false);
|
||||
}, [data, dashboardId, onExport]);
|
||||
// Reset on close so each open starts fresh (the dialog stays mounted).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedDashboard(null);
|
||||
setSearchText('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedDashboardId: string): void => {
|
||||
setDashboardId(selectedDashboardId);
|
||||
(dashboardId: string): void => {
|
||||
setSelectedDashboard(
|
||||
dashboards.find(({ id }) => id === dashboardId) ?? null,
|
||||
);
|
||||
},
|
||||
[setDashboardId],
|
||||
[dashboards],
|
||||
);
|
||||
|
||||
const handleNewDashboard = useCallback(async () => {
|
||||
try {
|
||||
await createNewDashboard({
|
||||
title: t('new_dashboard_title', {
|
||||
ns: 'dashboard',
|
||||
}),
|
||||
uploadedGrafana: false,
|
||||
version: ENTITY_VERSION_V5,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [createNewDashboard, t, showErrorModal]);
|
||||
const handleExportClick = useCallback((): void => {
|
||||
onExport(selectedDashboard, false);
|
||||
}, [selectedDashboard, onExport]);
|
||||
|
||||
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
|
||||
|
||||
const isDisabled =
|
||||
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
|
||||
const isExportDisabled =
|
||||
isAllDashboardsLoading || !selectedDashboard || isLoading;
|
||||
|
||||
return (
|
||||
<Wrapper direction="vertical">
|
||||
<Title>Export Panel</Title>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Add to dashboard"
|
||||
testId="export-panel-dialog"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="export-panel-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
disabled={isExportDisabled}
|
||||
onClick={handleExportClick}
|
||||
testId="export-panel-export"
|
||||
>
|
||||
Add to dashboard
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Select a dashboard
|
||||
</Typography.Text>
|
||||
<ExportDashboardSelect
|
||||
dashboards={dashboards}
|
||||
value={selectedDashboard?.id ?? null}
|
||||
selectedDashboard={selectedDashboard}
|
||||
loading={isDashboardsFetching}
|
||||
disabled={isAllDashboardsLoading || createDashboardLoading}
|
||||
onChange={handleSelect}
|
||||
onSearch={setSearchText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectWrapper direction="horizontal">
|
||||
<DashboardSelect
|
||||
placeholder="Select Dashboard"
|
||||
options={options}
|
||||
showSearch
|
||||
loading={isDashboardLoading}
|
||||
disabled={isDashboardLoading}
|
||||
value={dashboardId}
|
||||
onSelect={handleSelect}
|
||||
filterOption={filterOptions}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isLoading}
|
||||
disabled={isDisabled}
|
||||
onClick={handleExportClick}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</SelectWrapper>
|
||||
|
||||
<Typography>
|
||||
Or create dashboard with this panel -
|
||||
<NewDashboardButton
|
||||
disabled={createDashboardLoading}
|
||||
loading={createDashboardLoading}
|
||||
type="link"
|
||||
onClick={handleNewDashboard}
|
||||
>
|
||||
New Dashboard
|
||||
</NewDashboardButton>
|
||||
</Typography>
|
||||
</Wrapper>
|
||||
<div className={styles.newDashboard}>
|
||||
<Typography.Text className={styles.hint}>
|
||||
Or create a new dashboard with this panel
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
loading={createDashboardLoading}
|
||||
disabled={createDashboardLoading}
|
||||
onClick={createNewDashboard}
|
||||
testId="export-panel-new-dashboard"
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
ExportPanelContainer.defaultProps = {
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export default ExportPanelContainer;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ExportPanelContainer from './ExportPanelContainer';
|
||||
|
||||
function ExportPanel({
|
||||
isLoading,
|
||||
onExport,
|
||||
query,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const [isExport, setIsExport] = useState<boolean>(false);
|
||||
|
||||
const onModalToggle = useCallback((value: boolean) => {
|
||||
setIsExport(value);
|
||||
}, []);
|
||||
|
||||
const onCancel = (value: boolean) => (): void => {
|
||||
onModalToggle(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
open={isExport}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={query}
|
||||
isLoading={isLoading}
|
||||
onExport={onExport}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
|
||||
query: Query | null;
|
||||
}
|
||||
|
||||
ExportPanel.defaultProps = { isLoading: false };
|
||||
|
||||
export default ExportPanel;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Button, Select, SelectProps, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const DashboardSelect: FunctionComponent<SelectProps> = styled(
|
||||
Select,
|
||||
)<SelectProps>`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SelectWrapper = styled(Space)`
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.ant-space-item:first-child {
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Space)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const NewDashboardButton = styled(Button)`
|
||||
&&& {
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Title = styled(Typography.Text)`
|
||||
font-size: 1rem;
|
||||
`;
|
||||
@@ -1,16 +1,10 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
|
||||
export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
|
||||
data.map(({ id, data }) => ({
|
||||
label: data.title,
|
||||
export const getSelectOptions = (
|
||||
data: ExportDashboard[],
|
||||
): SelectProps['options'] =>
|
||||
data.map(({ id, title }) => ({
|
||||
label: title,
|
||||
value: id,
|
||||
}));
|
||||
|
||||
export const filterOptions: SelectProps['filterOption'] = (
|
||||
input,
|
||||
options,
|
||||
): boolean =>
|
||||
(options?.label?.toString() ?? '')
|
||||
?.toLowerCase()
|
||||
.includes(input.toLowerCase());
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listHosts } from 'api/generated/services/inframonitoring';
|
||||
import {
|
||||
InframonitoringtypesHostRecordDTO,
|
||||
InframonitoringtypesHostStatusDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -19,7 +23,10 @@ import K8sBaseDetails, {
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
|
||||
import StatusFilter from 'container/InfraMonitoringHostsV2/StatusFilter';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -43,6 +50,7 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
@@ -51,6 +59,17 @@ function Hosts(): JSX.Element {
|
||||
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
|
||||
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
|
||||
const { startUnixMilli, endUnixMilli } = useMemo(() => {
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
return {
|
||||
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTime, getMinMaxTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current) {
|
||||
return;
|
||||
@@ -122,13 +141,12 @@ function Hosts(): JSX.Element {
|
||||
endTimeBeforeRetention: data.endTimeBeforeRetention,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch hosts';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesHostRecordDTO[],
|
||||
total: 0,
|
||||
error: errMsg,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -141,7 +159,7 @@ function Hosts(): JSX.Element {
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesHostRecordDTO | null;
|
||||
error?: string | null;
|
||||
error?: APIError | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listHosts(
|
||||
@@ -159,11 +177,10 @@ function Hosts(): JSX.Element {
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch host';
|
||||
return {
|
||||
data: null,
|
||||
error: errMsg,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -208,6 +225,12 @@ function Hosts(): JSX.Element {
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={{
|
||||
metricNamespace:
|
||||
METRIC_NAMESPACE_BY_ENTITY[InfraMonitoringEntity.HOSTS],
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import {
|
||||
getHostQueryPayload,
|
||||
hostWidgetInfo,
|
||||
@@ -107,8 +108,10 @@ export function getHostMetricsQueryPayload(
|
||||
|
||||
export { hostWidgetInfo };
|
||||
|
||||
export const hostGetSelectedItemExpression = (hostName: string): string =>
|
||||
`host.name = ${formatValueForExpression(hostName)}`;
|
||||
export const hostGetSelectedItemExpression = (
|
||||
params: SelectedItemParams,
|
||||
): string =>
|
||||
`host.name = ${formatValueForExpression(params.selectedItem ?? '')}`;
|
||||
|
||||
export function hostInitialLogTracesExpression(
|
||||
host: InframonitoringtypesHostRecordDTO,
|
||||
|
||||
@@ -11,8 +11,6 @@ import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/const
|
||||
import { CellValueTooltip } from 'container/InfraMonitoringK8sV2/components';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
const HOSTNAME_DOCS_URL =
|
||||
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#host-name-is-blankempty';
|
||||
|
||||
@@ -23,11 +21,7 @@ export function HostnameCell({
|
||||
}): React.ReactElement {
|
||||
const isEmpty = !hostName || !hostName.trim();
|
||||
if (!isEmpty) {
|
||||
return (
|
||||
<CellValueTooltip value={hostName}>
|
||||
<TanStackTable.Text>{hostName}</TanStackTable.Text>
|
||||
</CellValueTooltip>
|
||||
);
|
||||
return <CellValueTooltip value={hostName} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
}
|
||||
|
||||
.columnHeaderLabel {
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
padding: var(--spacing-2) var(--spacing-2) var(--spacing-2) 0px;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ function ColumnHeader({
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
Learn more.
|
||||
|
||||
@@ -30,7 +30,7 @@ function EntityGroupHeader({
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
Learn more.
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import APIError from 'types/api/error';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -52,9 +54,10 @@ import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
|
||||
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringEventsFilters,
|
||||
useInfraMonitoringLogFilters,
|
||||
useInfraMonitoringSelectedItem,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
useInfraMonitoringTracesFilters,
|
||||
useInfraMonitoringView,
|
||||
} from '../hooks';
|
||||
@@ -62,6 +65,10 @@ import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import '../EntityDetailsUtils/entityDetails.styles.scss';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import {
|
||||
EntityCountConfig,
|
||||
EntityCountsSection,
|
||||
} from './components/EntityCountsSection/EntityCountsSection';
|
||||
|
||||
const TimeRangeOffset = 1000000000;
|
||||
|
||||
@@ -71,29 +78,51 @@ export interface K8sDetailsMetadataConfig<T> {
|
||||
render?: (value: string | number, entity: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
|
||||
|
||||
export interface K8sDetailsFilters {
|
||||
filter: { expression: string };
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface CustomTabRenderProps<T> {
|
||||
entity: T;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface CustomTab<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
render: (props: CustomTabRenderProps<T>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface K8sBaseDetailsProps<T> {
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: string;
|
||||
// Data fetching configuration
|
||||
getSelectedItemExpression: (selectedItem: string) => string;
|
||||
getSelectedItemExpression: (params: SelectedItemParams) => string;
|
||||
fetchEntityData: (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ data: T | null; error?: string | null }>;
|
||||
) => Promise<{ data: T | null; error?: APIError | null }>;
|
||||
// Entity configuration
|
||||
getEntityName: (entity: T) => string;
|
||||
getInitialLogTracesExpression: (entity: T) => string;
|
||||
getInitialEventsExpression: (entity: T) => string;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
docPath?: string;
|
||||
}[];
|
||||
getEntityQueryPayload: (
|
||||
entity: T,
|
||||
@@ -110,20 +139,7 @@ export interface K8sBaseDetailsProps<T> {
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
};
|
||||
customTabs?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
render: (props: {
|
||||
entity: T;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}) => React.ReactNode;
|
||||
}>;
|
||||
customTabs?: Array<CustomTab<T>>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -136,6 +152,8 @@ export default function K8sBaseDetails<T>({
|
||||
getInitialLogTracesExpression,
|
||||
getInitialEventsExpression,
|
||||
metadataConfig,
|
||||
countsConfig,
|
||||
getCountsFilterExpression,
|
||||
entityWidgetInfo,
|
||||
getEntityQueryPayload,
|
||||
queryKeyPrefix,
|
||||
@@ -152,7 +170,9 @@ export default function K8sBaseDetails<T>({
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
|
||||
const [selectedItemParams, setSelectedItemParams] =
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const selectedItem = selectedItemParams.selectedItem;
|
||||
|
||||
const entityQueryKey = useMemo(
|
||||
() =>
|
||||
@@ -160,8 +180,17 @@ export default function K8sBaseDetails<T>({
|
||||
selectedTime,
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
),
|
||||
[queryKeyPrefix, selectedItem, selectedTime, getAutoRefreshQueryKey],
|
||||
[
|
||||
queryKeyPrefix,
|
||||
selectedItem,
|
||||
selectedItemParams.clusterName,
|
||||
selectedItemParams.namespaceName,
|
||||
selectedTime,
|
||||
getAutoRefreshQueryKey,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -178,7 +207,7 @@ export default function K8sBaseDetails<T>({
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
|
||||
const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
|
||||
const expression = getSelectedItemExpression(selectedItem);
|
||||
const expression = getSelectedItemExpression(selectedItemParams);
|
||||
|
||||
return fetchEntityData({ filter: { expression }, start, end }, signal);
|
||||
},
|
||||
@@ -203,8 +232,8 @@ export default function K8sBaseDetails<T>({
|
||||
}, [entity, getInitialEventsExpression]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setSelectedItem(null);
|
||||
}, [setSelectedItem]);
|
||||
setSelectedItemParams(null);
|
||||
}, [setSelectedItemParams]);
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
@@ -246,6 +275,33 @@ export default function K8sBaseDetails<T>({
|
||||
const [selectedView, setSelectedView] = useInfraMonitoringView();
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
|
||||
const validTabs = useMemo(() => {
|
||||
const tabs: string[] = [];
|
||||
if (tabVisibility.showMetrics) {
|
||||
tabs.push(VIEW_TYPES.METRICS);
|
||||
}
|
||||
if (tabVisibility.showLogs) {
|
||||
tabs.push(VIEW_TYPES.LOGS);
|
||||
}
|
||||
if (tabVisibility.showTraces) {
|
||||
tabs.push(VIEW_TYPES.TRACES);
|
||||
}
|
||||
if (tabVisibility.showEvents) {
|
||||
tabs.push(VIEW_TYPES.EVENTS);
|
||||
}
|
||||
if (customTabs) {
|
||||
tabs.push(...customTabs.map((t) => t.key));
|
||||
}
|
||||
return tabs;
|
||||
}, [tabVisibility, customTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
|
||||
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
void setSelectedView(firstValid);
|
||||
}
|
||||
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
|
||||
|
||||
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
|
||||
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
|
||||
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
|
||||
@@ -281,7 +337,10 @@ export default function K8sBaseDetails<T>({
|
||||
}
|
||||
}, [getMinMaxTime, selectedTime]);
|
||||
|
||||
const handleTabChange = (value: string): void => {
|
||||
const handleTabChange = (value: string | null): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setSelectedView(value);
|
||||
setLogFiltersParam(null);
|
||||
setTracesFiltersParam(null);
|
||||
@@ -424,12 +483,18 @@ export default function K8sBaseDetails<T>({
|
||||
{isEntityLoading && <LoadingContainer />}
|
||||
{(isEntityError || hasResponseError) && (
|
||||
<div className="entity-error-container">
|
||||
<Typography.Text color="danger">
|
||||
{entityResponse?.error ||
|
||||
(entityError instanceof Error
|
||||
? entityError.message
|
||||
: 'Failed to load entity details')}
|
||||
</Typography.Text>
|
||||
<ErrorContent
|
||||
error={
|
||||
entityResponse?.error ??
|
||||
(entityError instanceof APIError ? entityError : null) ?? {
|
||||
code: 500,
|
||||
message:
|
||||
entityError instanceof Error
|
||||
? entityError.message
|
||||
: 'Failed to load entity details',
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{entity && !isEntityLoading && !hasResponseError && (
|
||||
@@ -467,6 +532,19 @@ export default function K8sBaseDetails<T>({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
countsConfig.length > 0 &&
|
||||
selectedItem &&
|
||||
getCountsFilterExpression && (
|
||||
<EntityCountsSection
|
||||
entity={entity}
|
||||
countsConfig={countsConfig}
|
||||
selectedItem={selectedItem}
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hideDetailViewTabs && (
|
||||
|
||||
@@ -10,19 +10,21 @@ import TanStackTable, {
|
||||
} from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { Querybuildertypesv5QueryWarnDataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
useInfraMonitoringStatusFilter,
|
||||
} from '../hooks';
|
||||
import { useInfraMonitoringLineClamp } from '../components';
|
||||
@@ -32,13 +34,14 @@ import K8sHeader from './K8sHeader';
|
||||
import { K8sPaginationWarning } from './K8sPaginationWarning';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { getGroupedByMeta } from './utils';
|
||||
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
|
||||
|
||||
import styles from './K8sBaseList.module.scss';
|
||||
import cx from 'classnames';
|
||||
|
||||
export type K8sBaseListEmptyStateContext = {
|
||||
isError: boolean;
|
||||
error?: string | null;
|
||||
error?: APIError | null;
|
||||
totalCount: number;
|
||||
hasFilters: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -49,7 +52,10 @@ export type K8sBaseListEmptyStateContext = {
|
||||
/** Base type constraint for K8s entity data */
|
||||
export type K8sEntityData = { meta?: Record<string, string> | null };
|
||||
|
||||
export type K8sBaseListProps<T extends K8sEntityData> = {
|
||||
export type K8sBaseListProps<
|
||||
T extends K8sEntityData,
|
||||
TItemKey extends string | SelectedItemParams = string,
|
||||
> = {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
leftFilters?: React.ReactNode;
|
||||
entity: InfraMonitoringEntity;
|
||||
@@ -62,15 +68,15 @@ export type K8sBaseListProps<T extends K8sEntityData> = {
|
||||
records?: T[];
|
||||
data?: T[];
|
||||
total: number;
|
||||
error?: string | null;
|
||||
error?: APIError | null;
|
||||
rawData?: unknown;
|
||||
endTimeBeforeRetention?: boolean;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO | null;
|
||||
}>;
|
||||
/** Function to get the unique key for a row. */
|
||||
getRowKey?: (record: T) => string;
|
||||
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
|
||||
getItemKey?: (record: T) => string;
|
||||
/** Function to get the item key used for selection. Can return string or SelectedItemParams. */
|
||||
getItemKey?: (record: T) => TItemKey;
|
||||
eventCategory: InfraMonitoringEvents;
|
||||
renderEmptyState?: (
|
||||
context: K8sBaseListEmptyStateContext,
|
||||
@@ -78,7 +84,10 @@ export type K8sBaseListProps<T extends K8sEntityData> = {
|
||||
extraQueryKeyParts?: string[];
|
||||
};
|
||||
|
||||
export function K8sBaseList<T extends K8sEntityData>({
|
||||
export function K8sBaseList<
|
||||
T extends K8sEntityData,
|
||||
TItemKey extends string | SelectedItemParams = string,
|
||||
>({
|
||||
controlListPrefix,
|
||||
leftFilters,
|
||||
entity,
|
||||
@@ -89,17 +98,16 @@ export function K8sBaseList<T extends K8sEntityData>({
|
||||
eventCategory,
|
||||
renderEmptyState,
|
||||
extraQueryKeyParts = [],
|
||||
}: K8sBaseListProps<T>): JSX.Element {
|
||||
}: K8sBaseListProps<T, TItemKey>): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const expression = currentQuery.builder.queryData[0]?.filter?.expression || '';
|
||||
const lineClamp = useInfraMonitoringLineClamp();
|
||||
const [groupBy] = useInfraMonitoringGroupBy();
|
||||
const [orderBy] = useInfraMonitoringOrderBy();
|
||||
const [statusFilter] = useInfraMonitoringStatusFilter();
|
||||
const [selectedItem, setSelectedItem] = useQueryState(
|
||||
'selectedItem',
|
||||
parseAsString,
|
||||
);
|
||||
const [selectedItemParams, setSelectedItemParams] =
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const selectedItem = selectedItemParams.selectedItem;
|
||||
|
||||
const columnStorageKey = `k8s-${entity}-columns`;
|
||||
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
|
||||
@@ -226,9 +234,17 @@ export function K8sBaseList<T extends K8sEntityData>({
|
||||
}, [eventCategory, totalCount]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(_record: T, itemKey: string): void => {
|
||||
(_record: T, itemKey: TItemKey): void => {
|
||||
if (groupBy.length === 0) {
|
||||
void setSelectedItem(itemKey);
|
||||
if (typeof itemKey === 'object' && itemKey !== null) {
|
||||
setSelectedItemParams(itemKey);
|
||||
} else {
|
||||
setSelectedItemParams({
|
||||
selectedItem: itemKey,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
@@ -237,18 +253,43 @@ export function K8sBaseList<T extends K8sEntityData>({
|
||||
category: eventCategory,
|
||||
});
|
||||
},
|
||||
[eventCategory, groupBy.length, setSelectedItem],
|
||||
[eventCategory, groupBy.length, setSelectedItemParams],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(_record: T, itemKey: string): void => {
|
||||
(_record: T, itemKey: TItemKey): void => {
|
||||
if (groupBy.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build URL with selectedItem param
|
||||
// Build URL with selectedItem params
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('selectedItem', itemKey);
|
||||
if (typeof itemKey === 'object' && itemKey !== null) {
|
||||
const params = itemKey;
|
||||
if (params.selectedItem) {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
|
||||
params.selectedItem,
|
||||
);
|
||||
}
|
||||
if (params.clusterName) {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME,
|
||||
params.clusterName,
|
||||
);
|
||||
}
|
||||
if (params.namespaceName) {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME,
|
||||
params.namespaceName,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
url.searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
|
||||
itemKey,
|
||||
);
|
||||
}
|
||||
openInNewTab(url.pathname + url.search);
|
||||
|
||||
void logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
@@ -274,7 +315,7 @@ export function K8sBaseList<T extends K8sEntityData>({
|
||||
rowKey: string,
|
||||
groupMeta?: Record<string, string>,
|
||||
): JSX.Element => (
|
||||
<K8sExpandedRow<T>
|
||||
<K8sExpandedRow<T, TItemKey>
|
||||
rowKey={rowKey}
|
||||
groupMeta={groupMeta}
|
||||
entity={entity}
|
||||
@@ -338,6 +379,8 @@ export function K8sBaseList<T extends K8sEntityData>({
|
||||
cancelQuery={cancelQuery}
|
||||
/>
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
<K8sInstrumentationChecksCallout entity={entity} />
|
||||
|
||||
{isError && (
|
||||
<Typography>
|
||||
{data?.error?.toString() || 'Something went wrong'}
|
||||
@@ -347,7 +390,7 @@ export function K8sBaseList<T extends K8sEntityData>({
|
||||
{showEmptyState ? (
|
||||
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
|
||||
) : (
|
||||
<TanStackTable<T>
|
||||
<TanStackTable<T, TItemKey>
|
||||
data={pageData}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={columnStorageKey}
|
||||
|
||||
@@ -44,13 +44,11 @@
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.actions {
|
||||
.errorContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
gap: var(--spacing-3);
|
||||
max-width: 500px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { LifeBuoy, TriangleAlert } from '@signozhq/icons';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
|
||||
@@ -13,26 +9,12 @@ import styles from './K8sEmptyState.module.scss';
|
||||
|
||||
type K8sEmptyStateProps = Partial<K8sBaseListEmptyStateContext>;
|
||||
|
||||
const handleContactSupport = (isCloudUser: boolean): void => {
|
||||
if (isCloudUser) {
|
||||
history.push('/support');
|
||||
} else {
|
||||
window.open('https://signoz.io/slack', '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
export function K8sEmptyState({
|
||||
isError,
|
||||
error,
|
||||
isLoading,
|
||||
endTimeBeforeRetention,
|
||||
}: K8sEmptyStateProps): JSX.Element | null {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const handleSupport = useCallback(() => {
|
||||
handleContactSupport(isCloudUser);
|
||||
}, [isCloudUser]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
@@ -40,25 +22,15 @@ export function K8sEmptyState({
|
||||
if (isError || error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<TriangleAlert size={32} className={styles.errorIcon} />
|
||||
<span className={styles.message}>
|
||||
{error || 'An error occurred while fetching data.'}
|
||||
</span>
|
||||
<p>
|
||||
Our team is getting on top to resolve this. Please reach out to support if
|
||||
the issue persists.
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
onClick={handleSupport}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.errorContent}>
|
||||
<ErrorContent
|
||||
error={
|
||||
error ?? {
|
||||
code: 500,
|
||||
message: 'An error occurred while fetching data.',
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLocation } from 'react-router-dom';
|
||||
import { Querybuildertypesv5QueryWarnDataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import APIError from 'types/api/error';
|
||||
import TanStackTable, {
|
||||
SortState,
|
||||
TableColumnDef,
|
||||
@@ -22,10 +23,11 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItem,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../hooks';
|
||||
import { K8sBaseFilters } from './types';
|
||||
|
||||
@@ -34,7 +36,7 @@ import { buildExpressionFromGroupMeta } from './utils';
|
||||
|
||||
const EXPANDED_ROW_LIMIT = 10;
|
||||
|
||||
export type K8sExpandedRowProps<T> = {
|
||||
export type K8sExpandedRowProps<T, TItemKey = string> = {
|
||||
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
|
||||
rowKey: string;
|
||||
/** Group metadata for building filters */
|
||||
@@ -50,7 +52,7 @@ export type K8sExpandedRowProps<T> = {
|
||||
records?: T[];
|
||||
data?: T[];
|
||||
total: number;
|
||||
error?: string | null;
|
||||
error?: APIError | null;
|
||||
rawData?: unknown;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO | null;
|
||||
}>;
|
||||
@@ -59,10 +61,10 @@ export type K8sExpandedRowProps<T> = {
|
||||
/** Function to get the unique key for a row. */
|
||||
getRowKey?: (record: T) => string;
|
||||
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
|
||||
getItemKey?: (record: T) => string;
|
||||
getItemKey?: (record: T) => TItemKey;
|
||||
};
|
||||
|
||||
export function K8sExpandedRow<T>({
|
||||
export function K8sExpandedRow<T, TItemKey = string>({
|
||||
rowKey,
|
||||
groupMeta,
|
||||
entity,
|
||||
@@ -71,13 +73,13 @@ export function K8sExpandedRow<T>({
|
||||
extraQueryKeyParts = [],
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
}: K8sExpandedRowProps<T>): JSX.Element {
|
||||
}: K8sExpandedRowProps<T, TItemKey>): JSX.Element {
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const parentExpression =
|
||||
currentQuery.builder.queryData[0]?.filter?.expression || '';
|
||||
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
|
||||
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
@@ -175,10 +177,18 @@ export function K8sExpandedRow<T>({
|
||||
const expandedData = data?.data ?? [];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(_row: T, itemKey: string): void => {
|
||||
void setSelectedItem(itemKey);
|
||||
(_row: T, itemKey: TItemKey): void => {
|
||||
if (typeof itemKey === 'object' && itemKey !== null) {
|
||||
setSelectedItemParams(itemKey as unknown as SelectedItemParams);
|
||||
} else {
|
||||
setSelectedItemParams({
|
||||
selectedItem: itemKey as string,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setSelectedItem],
|
||||
[setSelectedItemParams],
|
||||
);
|
||||
|
||||
const handleViewAllClick = (): void => {
|
||||
@@ -239,7 +249,7 @@ export function K8sExpandedRow<T>({
|
||||
|
||||
<div data-testid="expanded-table">
|
||||
<TanStackTableStateProvider>
|
||||
<TanStackTable<T>
|
||||
<TanStackTable<T, TItemKey>
|
||||
data={expandedData}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={storageKey}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Box } from '@signozhq/icons';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
VIEW_TYPES,
|
||||
} from '../../constants';
|
||||
import K8sBaseDetails from '../K8sBaseDetails';
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="mock-datetime" />,
|
||||
}));
|
||||
|
||||
type TestEntity = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
cluster: string;
|
||||
};
|
||||
|
||||
const mockEntity: TestEntity = {
|
||||
name: 'test-pod',
|
||||
namespace: 'default',
|
||||
cluster: 'test-cluster',
|
||||
};
|
||||
|
||||
function createBaseProps() {
|
||||
return {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
eventCategory: 'Pod',
|
||||
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
fetchEntityData: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: mockEntity, error: null }),
|
||||
getEntityName: (e: TestEntity): string => e.name,
|
||||
getInitialLogTracesExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
getInitialEventsExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
metadataConfig: [
|
||||
{ label: 'Name', getValue: (e: TestEntity): string => e.name },
|
||||
],
|
||||
entityWidgetInfo: [{ title: 'CPU', yAxisUnit: 'percent' }],
|
||||
getEntityQueryPayload: jest.fn().mockReturnValue([]),
|
||||
queryKeyPrefix: 'testPod',
|
||||
};
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
view?: string;
|
||||
tabsConfig?: {
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
};
|
||||
customTabs?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
render: () => React.ReactNode;
|
||||
}>;
|
||||
}
|
||||
|
||||
function renderK8sBaseDetails({
|
||||
view = VIEW_TYPES.METRICS,
|
||||
tabsConfig,
|
||||
customTabs,
|
||||
}: RenderOptions = {}) {
|
||||
const searchParams: Record<string, string> = {
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: 'test-pod',
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
|
||||
};
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<K8sBaseDetails<TestEntity>
|
||||
{...createBaseProps()}
|
||||
tabsConfig={tabsConfig}
|
||||
customTabs={customTabs}
|
||||
/>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
function getSelectedTabText(): string | null {
|
||||
const selectedTab = document.querySelector('[aria-checked="true"]');
|
||||
return selectedTab?.textContent ?? null;
|
||||
}
|
||||
|
||||
describe('K8sBaseDetails - Tab Validation', () => {
|
||||
it('should reset view to METRICS when selected view is invalid', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({ view: 'invalid-tab' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Metrics');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset to first available tab when METRICS is disabled and view is invalid', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
view: 'invalid-tab',
|
||||
tabsConfig: { showMetrics: false },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Logs');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset to custom tab when all standard tabs disabled and custom tab exists', async () => {
|
||||
const customTabKey = 'pod-metrics';
|
||||
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
view: 'invalid-tab',
|
||||
tabsConfig: {
|
||||
showMetrics: false,
|
||||
showLogs: false,
|
||||
showTraces: false,
|
||||
showEvents: false,
|
||||
},
|
||||
customTabs: [
|
||||
{
|
||||
key: customTabKey,
|
||||
label: 'Pod Metrics',
|
||||
icon: <Box size={14} />,
|
||||
render: (): React.ReactNode => <div>Custom Tab</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Pod Metrics');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT reset view when selected view is valid', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({ view: VIEW_TYPES.LOGS });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Logs');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT reset view when custom tab is selected and exists', async () => {
|
||||
const customTabKey = 'pod-metrics';
|
||||
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
view: customTabKey,
|
||||
customTabs: [
|
||||
{
|
||||
key: customTabKey,
|
||||
label: 'Pod Metrics',
|
||||
icon: <Box size={14} />,
|
||||
render: (): React.ReactNode => <div>Custom Tab</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Pod Metrics');
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the selected tab when the active tab is clicked again (untoggle guard)', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
act(() => {
|
||||
renderK8sBaseDetails({ view: VIEW_TYPES.LOGS });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Logs');
|
||||
});
|
||||
|
||||
const selectedTab = document.querySelector('[aria-checked="true"]');
|
||||
expect(selectedTab).not.toBeNull();
|
||||
|
||||
await user.click(selectedTab as Element);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Logs');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
@@ -17,11 +19,13 @@ import {
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
import APIError from 'types/api/error';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import { InfraMonitoringEntity } from '../../constants';
|
||||
import { SelectedItemParams } from '../../hooks';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
@@ -165,11 +169,14 @@ function createTestColumnsWithGroup(): TableColumnDef<TestItemWithGroup>[] {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
function renderComponent<T extends K8sEntityData>({
|
||||
function renderComponent<
|
||||
T extends K8sEntityData,
|
||||
TItemKey extends string | SelectedItemParams = string,
|
||||
>({
|
||||
queryParams,
|
||||
onUrlUpdate,
|
||||
...props
|
||||
}: K8sBaseListProps<T> & {
|
||||
}: K8sBaseListProps<T, TItemKey> & {
|
||||
queryParams?: Record<string, string>;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
}) {
|
||||
@@ -196,7 +203,7 @@ function renderComponent<T extends K8sEntityData>({
|
||||
value={{ viewportHeight: 800, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<K8sBaseList {...props} />
|
||||
<K8sBaseList<T, TItemKey> {...props} />
|
||||
</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>
|
||||
@@ -626,7 +633,15 @@ describe('K8sBaseList', () => {
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
error: 'Failed to fetch pods',
|
||||
error: new APIError({
|
||||
httpStatusCode: 500,
|
||||
error: {
|
||||
code: '500',
|
||||
message: 'Failed to fetch pods',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
@@ -941,4 +956,413 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with object itemKey (selectedItem + cluster + namespace params)', () => {
|
||||
const itemId = 'obj-item';
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<
|
||||
NonNullable<K8sBaseListProps<TestItemWithTitle>['fetchListData']>
|
||||
>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItemWithTitle>['fetchListData']>>
|
||||
>();
|
||||
|
||||
const getLatestParam = (key: string): string | undefined =>
|
||||
onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get(key))
|
||||
.filter(Boolean)
|
||||
.pop() as string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
openInNewTabMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [{ id: `PodId:${itemId}`, title: `PodTitle:${itemId}` }],
|
||||
total: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set selectedItem, cluster and namespace params on row click', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent<TestItemWithTitle, SelectedItemParams>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): SelectedItemParams => ({
|
||||
selectedItem: row.id,
|
||||
clusterName: 'prod-cluster',
|
||||
namespaceName: 'default-ns',
|
||||
}),
|
||||
});
|
||||
|
||||
const firstRowEl = await screen.findByText(`PodId:${itemId}`);
|
||||
await user.click(firstRowEl);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getLatestParam('selectedItem')).toBe(`PodId:${itemId}`);
|
||||
expect(getLatestParam('selectedItemClusterName')).toBe('prod-cluster');
|
||||
expect(getLatestParam('selectedItemNamespaceName')).toBe('default-ns');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include cluster and namespace params in new tab URL on ctrl+click', async () => {
|
||||
renderComponent<TestItemWithTitle, SelectedItemParams>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): SelectedItemParams => ({
|
||||
selectedItem: row.id,
|
||||
clusterName: 'prod-cluster',
|
||||
namespaceName: 'default-ns',
|
||||
}),
|
||||
});
|
||||
|
||||
const firstRow = await screen.findByText(`PodId:${itemId}`);
|
||||
fireEvent.click(firstRow, { ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const url = openInNewTabMock.mock.calls[0][0] as string;
|
||||
expect(url).toContain(`selectedItem=PodId%3A${itemId}`);
|
||||
expect(url).toContain('selectedItemClusterName=prod-cluster');
|
||||
expect(url).toContain('selectedItemNamespaceName=default-ns');
|
||||
});
|
||||
|
||||
it('should omit null cluster/namespace params in new tab URL', async () => {
|
||||
renderComponent<TestItemWithTitle, SelectedItemParams>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): SelectedItemParams => ({
|
||||
selectedItem: row.id,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const firstRow = await screen.findByText(`PodId:${itemId}`);
|
||||
fireEvent.click(firstRow, { ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const url = openInNewTabMock.mock.calls[0][0] as string;
|
||||
expect(url).toContain(`selectedItem=PodId%3A${itemId}`);
|
||||
expect(url).not.toContain('selectedItemClusterName');
|
||||
expect(url).not.toContain('selectedItemNamespaceName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('instrumentation checks callout', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [{ id: 'item-1' }],
|
||||
total: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render callout when ready is true', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: true,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('item-1');
|
||||
|
||||
expect(screen.queryByText('Instrumentation checks')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render callout when no entries exist', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: null,
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: null,
|
||||
missingDefaultEnabledMetrics: null,
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('item-1');
|
||||
|
||||
expect(screen.queryByText('Instrumentation checks')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render callout with present entries', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage', 'k8s.pod.memory.usage'],
|
||||
},
|
||||
],
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: null,
|
||||
missingDefaultEnabledMetrics: null,
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
await expect(
|
||||
screen.findByText('Default enabled metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.cpu.usage, k8s.pod.memory.usage'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('otel-collector'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render callout with missing entries', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: null,
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: null,
|
||||
missingDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.limit'],
|
||||
documentationLink: 'https://example.com/docs',
|
||||
},
|
||||
],
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
await expect(
|
||||
screen.findByText('Missing default metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.cpu.limit'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Learn here')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should trigger recheck on button click', async () => {
|
||||
let recheckCallCount = 0;
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://localhost/api/v2/infra_monitoring/checks',
|
||||
(_, res, ctx) => {
|
||||
recheckCallCount++;
|
||||
return res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
const initialCallCount = recheckCallCount;
|
||||
|
||||
const recheckBtn = screen.getByTestId('instrumentation-checks-recheck-btn');
|
||||
await user.click(recheckBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recheckCallCount).toBeGreaterThan(initialCallCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render both present and missing entries', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage'],
|
||||
},
|
||||
],
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
attributes: ['k8s.namespace.name'],
|
||||
},
|
||||
],
|
||||
missingDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.memory.limit'],
|
||||
},
|
||||
],
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
// Present entries
|
||||
await expect(
|
||||
screen.findByText('Default enabled metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.cpu.usage'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Required attributes'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.namespace.name'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Missing entries
|
||||
await expect(
|
||||
screen.findByText('Missing default metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.memory.limit'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
buildEventsExpression,
|
||||
buildExpressionFromSelectedItemParams,
|
||||
buildLogsTracesExpression,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
|
||||
describe('buildExpressionFromSelectedItemParams', () => {
|
||||
it('should build expression from params with all values', () => {
|
||||
const result = buildExpressionFromSelectedItemParams(
|
||||
{
|
||||
selectedItem: 'nginx',
|
||||
clusterName: 'prod',
|
||||
namespaceName: 'default',
|
||||
},
|
||||
'k8s.deployment.name',
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
"k8s.deployment.name = 'nginx' AND k8s.cluster.name = 'prod' AND k8s.namespace.name = 'default'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip null values', () => {
|
||||
const result = buildExpressionFromSelectedItemParams(
|
||||
{
|
||||
selectedItem: 'nginx',
|
||||
clusterName: null,
|
||||
namespaceName: 'default',
|
||||
},
|
||||
'k8s.deployment.name',
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
"k8s.deployment.name = 'nginx' AND k8s.namespace.name = 'default'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle only selectedItem', () => {
|
||||
const result = buildExpressionFromSelectedItemParams(
|
||||
{
|
||||
selectedItem: 'pod-123',
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
},
|
||||
'k8s.pod.uid',
|
||||
);
|
||||
|
||||
expect(result).toBe("k8s.pod.uid = 'pod-123'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEventsExpression', () => {
|
||||
it('should build expression with kind, name, cluster and attribute-context namespace', () => {
|
||||
const result = buildEventsExpression({
|
||||
objectKind: 'Deployment',
|
||||
objectName: 'nginx',
|
||||
clusterName: 'prod',
|
||||
namespaceName: 'default',
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
"k8s.object.kind = 'Deployment' AND k8s.object.name = 'nginx' AND k8s.cluster.name = 'prod' AND attribute.k8s.namespace.name = 'default'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should always include kind and name, even when name is empty', () => {
|
||||
const result = buildEventsExpression({
|
||||
objectKind: 'Node',
|
||||
objectName: '',
|
||||
});
|
||||
|
||||
expect(result).toBe("k8s.object.kind = 'Node' AND k8s.object.name = ''");
|
||||
});
|
||||
|
||||
it('should skip cluster and namespace clauses when values are missing', () => {
|
||||
const result = buildEventsExpression({
|
||||
objectKind: 'Pod',
|
||||
objectName: 'pod-1',
|
||||
clusterName: null,
|
||||
namespaceName: undefined,
|
||||
});
|
||||
|
||||
expect(result).toBe("k8s.object.kind = 'Pod' AND k8s.object.name = 'pod-1'");
|
||||
});
|
||||
|
||||
it('should escape values with quotes', () => {
|
||||
const result = buildEventsExpression({
|
||||
objectKind: 'Pod',
|
||||
objectName: "po'd",
|
||||
namespaceName: 'ns-1',
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
`k8s.object.kind = 'Pod' AND k8s.object.name = 'po\\'d' AND attribute.k8s.namespace.name = 'ns-1'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLogsTracesExpression', () => {
|
||||
it('should build expression with main attribute, cluster and namespace', () => {
|
||||
const result = buildLogsTracesExpression({
|
||||
mainAttributeKey: 'k8s.deployment.name',
|
||||
mainAttributeValue: 'nginx',
|
||||
clusterName: 'prod',
|
||||
namespaceName: 'default',
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
"k8s.deployment.name = 'nginx' AND k8s.cluster.name = 'prod' AND k8s.namespace.name = 'default'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip clauses with empty values', () => {
|
||||
const result = buildLogsTracesExpression({
|
||||
mainAttributeKey: 'k8s.node.name',
|
||||
mainAttributeValue: 'node-1',
|
||||
clusterName: '',
|
||||
namespaceName: null,
|
||||
});
|
||||
|
||||
expect(result).toBe("k8s.node.name = 'node-1'");
|
||||
});
|
||||
|
||||
it('should return empty string when all values are missing', () => {
|
||||
const result = buildLogsTracesExpression({
|
||||
mainAttributeKey: 'k8s.pod.name',
|
||||
mainAttributeValue: '',
|
||||
});
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
.countsContainer {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
margin-top: var(--spacing-8);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.countCard {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.countLabel {
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.countValue {
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.navigateButton {
|
||||
position: absolute;
|
||||
top: var(--spacing-4);
|
||||
right: var(--spacing-4);
|
||||
|
||||
--button-padding: var(--spacing-1);
|
||||
--button-height: 24px;
|
||||
--button-width: 24px;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../../constants';
|
||||
import styles from './EntityCountsSection.module.scss';
|
||||
|
||||
export interface EntityCountConfig<T> {
|
||||
label: string;
|
||||
getValue: (entity: T) => number;
|
||||
targetCategory: InfraMonitoringEntity;
|
||||
}
|
||||
|
||||
interface EntityCountsSectionProps<T> {
|
||||
entity: T;
|
||||
countsConfig: EntityCountConfig<T>[];
|
||||
selectedItem: string;
|
||||
filterExpression: string;
|
||||
closeDrawer: () => void;
|
||||
}
|
||||
|
||||
export function EntityCountsSection<T>({
|
||||
entity,
|
||||
countsConfig,
|
||||
selectedItem,
|
||||
filterExpression,
|
||||
closeDrawer,
|
||||
}: EntityCountsSectionProps<T>): JSX.Element {
|
||||
const buildNavigationUrl = (targetCategory: InfraMonitoringEntity): string => {
|
||||
const defaultQuery = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
const compositeQuery = {
|
||||
...defaultQuery,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...defaultQuery.builder,
|
||||
queryData: defaultQuery.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
filter: { expression: filterExpression },
|
||||
filters: { items: [], op: 'AND' as const },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
// TODO(H4ad): After https://github.com/SigNoz/signoz/pull/12038, inherit custom time of drawer to list
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.countsContainer}>
|
||||
{countsConfig.map((config) => (
|
||||
<div
|
||||
key={config.label}
|
||||
className={styles.countCard}
|
||||
data-testid={`count-card-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.countValue} size="xl" weight="semibold">
|
||||
{config.getValue(entity) || '-'}
|
||||
</Typography.Text>
|
||||
<Link
|
||||
to={buildNavigationUrl(config.targetCategory)}
|
||||
onClick={closeDrawer}
|
||||
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
<Tooltip
|
||||
title={`View ${config.label.toLowerCase()} of '${selectedItem}'`}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.navigateButton}
|
||||
prefix={<Compass size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.checkContainer {
|
||||
padding: 0px var(--spacing-4) var(--spacing-4) var(--spacing-4);
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entriesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { MouseEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { RefreshCw } from '@signozhq/icons';
|
||||
import setLocalStorage from 'api/browser/localstorage/set';
|
||||
import {
|
||||
invalidateGetChecks,
|
||||
useGetChecks,
|
||||
} from 'api/generated/services/inframonitoring';
|
||||
import { InframonitoringtypesCheckTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
|
||||
import styles from './K8sInstrumentationChecksCallout.module.scss';
|
||||
import { MissingEntryRow } from './MissingEntryRow';
|
||||
import { PresentEntryRow } from './PresentEntryRow';
|
||||
import {
|
||||
ENTITY_TO_CHECK_TYPE,
|
||||
getStorageKey,
|
||||
hasAnyEntries,
|
||||
hasMissingEntries,
|
||||
readExpandedState,
|
||||
} from './utils';
|
||||
|
||||
export interface InstrumentationChecksCalloutProps {
|
||||
entity: InfraMonitoringEntity;
|
||||
}
|
||||
|
||||
export function K8sInstrumentationChecksCallout({
|
||||
entity,
|
||||
}: InstrumentationChecksCalloutProps): JSX.Element | null {
|
||||
const checkType = ENTITY_TO_CHECK_TYPE[entity];
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(() => readExpandedState(entity));
|
||||
|
||||
const { data, isLoading, isFetching } = useGetChecks(
|
||||
{ type: checkType as InframonitoringtypesCheckTypeDTO },
|
||||
{ query: { enabled: !!checkType } },
|
||||
);
|
||||
|
||||
const handleRecheck = useCallback(
|
||||
(e: MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (checkType) {
|
||||
void invalidateGetChecks(queryClient, { type: checkType });
|
||||
}
|
||||
},
|
||||
[queryClient, checkType],
|
||||
);
|
||||
|
||||
const handleExpandToggle = useCallback((): void => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
setLocalStorage(getStorageKey(entity), String(next));
|
||||
return next;
|
||||
});
|
||||
}, [entity]);
|
||||
|
||||
const checksData = data?.data;
|
||||
|
||||
const hasMissingItems = useMemo(
|
||||
() => (checksData ? hasMissingEntries(checksData) : false),
|
||||
[checksData],
|
||||
);
|
||||
|
||||
if (!checkType || isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!checksData || checksData.ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasAnyEntries(checksData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.checkContainer}>
|
||||
<Callout
|
||||
type={hasMissingItems ? 'warning' : 'info'}
|
||||
showIcon
|
||||
size="medium"
|
||||
title={
|
||||
<div className={styles.header}>
|
||||
Instrumentation checks
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
size="sm"
|
||||
onClick={handleRecheck}
|
||||
loading={isFetching}
|
||||
prefix={<RefreshCw size={12} />}
|
||||
data-testid="instrumentation-checks-recheck-btn"
|
||||
>
|
||||
Recheck
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
action="expandable"
|
||||
defaultExpanded={isExpanded}
|
||||
onClick={handleExpandToggle}
|
||||
>
|
||||
<div className={styles.container} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.entriesList}>
|
||||
{checksData.presentDefaultEnabledMetrics?.map((entry) => (
|
||||
<PresentEntryRow
|
||||
key={`present-default-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Default enabled metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.presentOptionalMetrics?.map((entry) => (
|
||||
<PresentEntryRow
|
||||
key={`present-optional-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Optional metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.presentRequiredAttributes?.map((entry) => (
|
||||
<PresentEntryRow
|
||||
key={`present-attrs-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Required attributes"
|
||||
itemType="attributes"
|
||||
/>
|
||||
))}
|
||||
{checksData.missingDefaultEnabledMetrics?.map((entry) => (
|
||||
<MissingEntryRow
|
||||
key={`missing-default-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Missing default metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.missingOptionalMetrics?.map((entry) => (
|
||||
<MissingEntryRow
|
||||
key={`missing-optional-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Missing optional metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.missingRequiredAttributes?.map((entry) => (
|
||||
<MissingEntryRow
|
||||
key={`missing-attrs-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Missing required attributes"
|
||||
itemType="attributes"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.entryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.missingIcon {
|
||||
color: var(--warning-background);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.learnMoreLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
|
||||
svg {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--callout-warning-border);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import styles from './MissingEntryRow.module.scss';
|
||||
import { ExternalLink, TriangleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import type {
|
||||
InframonitoringtypesMissingAttributesComponentEntryDTO,
|
||||
InframonitoringtypesMissingMetricsComponentEntryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
type MissingEntryRowProps =
|
||||
| {
|
||||
entry: InframonitoringtypesMissingMetricsComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'metrics';
|
||||
}
|
||||
| {
|
||||
entry: InframonitoringtypesMissingAttributesComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'attributes';
|
||||
};
|
||||
|
||||
export function MissingEntryRow({
|
||||
entry,
|
||||
typeLabel,
|
||||
itemType,
|
||||
}: MissingEntryRowProps): JSX.Element {
|
||||
const items = itemType === 'metrics' ? entry.metrics : entry.attributes;
|
||||
|
||||
return (
|
||||
<div className={styles.entryRow}>
|
||||
<TriangleAlert size={14} className={styles.missingIcon} />
|
||||
<Typography.Text size="base" weight="medium">
|
||||
{typeLabel}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.entryRowMetric}
|
||||
>
|
||||
{items?.join(', ')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" italic color="muted">
|
||||
{entry.associatedComponent.name}
|
||||
</Typography.Text>
|
||||
{entry.documentationLink && (
|
||||
<Typography.Link
|
||||
href={entry.documentationLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="base"
|
||||
className={styles.learnMoreLink}
|
||||
>
|
||||
Learn here
|
||||
<ExternalLink size={12} />
|
||||
</Typography.Link>
|
||||
)}
|
||||
<Divider className={styles.divider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.entryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.presentIcon {
|
||||
color: var(--success-background);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--callout-warning-border);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import styles from './PresentEntryRow.module.scss';
|
||||
import { CircleCheck } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import type {
|
||||
InframonitoringtypesAttributesComponentEntryDTO,
|
||||
InframonitoringtypesMetricsComponentEntryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
type PresentEntryRowProps =
|
||||
| {
|
||||
entry: InframonitoringtypesMetricsComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'metrics';
|
||||
}
|
||||
| {
|
||||
entry: InframonitoringtypesAttributesComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'attributes';
|
||||
};
|
||||
|
||||
export function PresentEntryRow({
|
||||
entry,
|
||||
typeLabel,
|
||||
itemType,
|
||||
}: PresentEntryRowProps): JSX.Element {
|
||||
const items = itemType === 'metrics' ? entry.metrics : entry.attributes;
|
||||
|
||||
return (
|
||||
<div className={styles.entryRow}>
|
||||
<CircleCheck size={14} className={styles.presentIcon} />
|
||||
<Typography.Text size="base" weight="medium">
|
||||
{typeLabel}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.entryRowMetric}
|
||||
>
|
||||
{items?.join(', ')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" italic color="muted">
|
||||
{entry.associatedComponent.name}
|
||||
</Typography.Text>
|
||||
<Divider className={styles.divider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import getLocalStorage from 'api/browser/localstorage/get';
|
||||
import {
|
||||
InframonitoringtypesChecksDTO,
|
||||
InframonitoringtypesCheckTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
|
||||
export const ENTITY_TO_CHECK_TYPE: Record<
|
||||
InfraMonitoringEntity,
|
||||
InframonitoringtypesCheckTypeDTO
|
||||
> = {
|
||||
[InfraMonitoringEntity.HOSTS]: InframonitoringtypesCheckTypeDTO.hosts,
|
||||
[InfraMonitoringEntity.PODS]: InframonitoringtypesCheckTypeDTO.pods,
|
||||
[InfraMonitoringEntity.NODES]: InframonitoringtypesCheckTypeDTO.nodes,
|
||||
[InfraMonitoringEntity.NAMESPACES]:
|
||||
InframonitoringtypesCheckTypeDTO.namespaces,
|
||||
[InfraMonitoringEntity.CLUSTERS]: InframonitoringtypesCheckTypeDTO.clusters,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]:
|
||||
InframonitoringtypesCheckTypeDTO.deployments,
|
||||
[InfraMonitoringEntity.STATEFULSETS]:
|
||||
InframonitoringtypesCheckTypeDTO.statefulsets,
|
||||
[InfraMonitoringEntity.DAEMONSETS]:
|
||||
InframonitoringtypesCheckTypeDTO.daemonsets,
|
||||
[InfraMonitoringEntity.JOBS]: InframonitoringtypesCheckTypeDTO.jobs,
|
||||
[InfraMonitoringEntity.VOLUMES]: InframonitoringtypesCheckTypeDTO.volumes,
|
||||
[InfraMonitoringEntity.CONTAINERS]:
|
||||
InframonitoringtypesCheckTypeDTO.kube_containers,
|
||||
};
|
||||
|
||||
export function hasMissingEntries(
|
||||
data: InframonitoringtypesChecksDTO,
|
||||
): boolean {
|
||||
return (
|
||||
(data.missingDefaultEnabledMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingOptionalMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingRequiredAttributes?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAnyEntries(data: InframonitoringtypesChecksDTO): boolean {
|
||||
return (
|
||||
(data.presentDefaultEnabledMetrics?.length ?? 0) > 0 ||
|
||||
(data.presentOptionalMetrics?.length ?? 0) > 0 ||
|
||||
(data.presentRequiredAttributes?.length ?? 0) > 0 ||
|
||||
(data.missingDefaultEnabledMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingOptionalMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingRequiredAttributes?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = '@signozhq/k8s-instrumentation-checks-expanded';
|
||||
|
||||
export function getStorageKey(entity: InfraMonitoringEntity): string {
|
||||
return `${STORAGE_PREFIX}-${entity}`;
|
||||
}
|
||||
|
||||
export function readExpandedState(entity: InfraMonitoringEntity): boolean {
|
||||
const stored = getLocalStorage(getStorageKey(entity));
|
||||
return stored === null ? true : stored === 'true';
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user